diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 031e22d0379f..6e79743bd7dd 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -49,15 +49,7 @@ jobs: uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 with: token: ${{ github.token }} - # Enable listing of files matching each filter. - # Paths to files will be available in `${FILTER_NAME}_files` output variable. - list-files: json - - # It doesn't make sense to lint deleted files. - # Therefore we specify we are only interested in added or modified files. filters: | - all: - - added|modified: '**/*' frontend: - 'static/**' - 'tests/js/**' @@ -94,10 +86,18 @@ jobs: - name: Run prek on PR commits id: prek env: - ALL_FILES: ${{ steps.changes.outputs.all_files }} + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.sha }} run: | - jq '.[]' --raw-output <<< "$ALL_FILES" | - xargs .venv/bin/prek run -q --files + if [[ -n "$PULL_REQUEST_NUMBER" ]]; then + gh api --paginate "repos/$REPOSITORY/pulls/$PULL_REQUEST_NUMBER/files?per_page=100" \ + --jq '.[] | select(.status != "removed") | .filename' + else + git fetch --no-tags --depth=2 origin "$HEAD_SHA" + git diff --name-only --diff-filter=AM "$HEAD_SHA^" "$HEAD_SHA" + fi | xargs .venv/bin/prek run -q --files - name: Prune suppression budgets if: github.event_name == 'pull_request' && !cancelled() && (steps.prek.outcome == 'success' || steps.prek.outcome == 'failure') && steps.changes.outputs.frontend == 'true' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da277d6b0909..e2365801495c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -75,6 +75,12 @@ repos: entry: python3 -m tools.mypy_helpers.prevent_weaklist_additions files: ^pyproject\.toml$ language: python + - id: check-typing-campaign + name: check typing campaign gates + entry: python3 -m tools.mypy_helpers.check_typing_campaign + language: python + types: [python] + require_serial: true - id: prevent-push name: prevent pushing master stages: [pre-push] diff --git a/fixtures/apidocs_test_case.py b/fixtures/apidocs_test_case.py index ff9e1fe16507..a8b78947d371 100644 --- a/fixtures/apidocs_test_case.py +++ b/fixtures/apidocs_test_case.py @@ -3,10 +3,13 @@ import orjson from django.conf import settings +from django.http import HttpRequest from openapi_core.contrib.django import DjangoOpenAPIRequest, DjangoOpenAPIResponse from openapi_core.spec import Spec from openapi_core.validation.response.validators import V30ResponseDataValidator +from rest_framework.response import Response +from sentry.services.eventstore.models import Event from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.datetime import before_now from sentry.testutils.skips import requires_snuba @@ -30,7 +33,7 @@ class APIDocsTestCase(APITestCase): def cached_schema(self) -> Spec: return _load_schema() - def validate_schema(self, request, response): + def validate_schema(self, request: HttpRequest, response: Response) -> None: assert 200 <= response.status_code < 300, response.status_code if isinstance(response.data, list): @@ -42,9 +45,9 @@ def validate_schema(self, request, response): DjangoOpenAPIResponse(response), # type: ignore[arg-type] # Werkzeug 3.1 Headers vs Mapping ) - def create_event(self, name, **kwargs): + def create_event(self, name: str, **kwargs: object) -> Event: # Somewhat sane default data. - data = { + data: dict[str, object] = { "event_id": (name * 32)[:32], "fingerprint": ["1"], "sdk": {"version": "5.17.0", "name": "sentry.javascript.browser"}, diff --git a/fixtures/gitlab.py b/fixtures/gitlab.py index c71b78eab154..d2e9546f3766 100644 --- a/fixtures/gitlab.py +++ b/fixtures/gitlab.py @@ -18,7 +18,7 @@ class GitLabTestCase(APITestCase): provider = IntegrationProviderSlug.GITLAB.value - def setUp(self): + def setUp(self) -> None: self.login_as(self.user) with assume_test_silo_mode(SiloMode.CONTROL): self.integration = Integration.objects.create( diff --git a/fixtures/integrations/jira/mock.py b/fixtures/integrations/jira/mock.py index fc565b060b2e..527f3a57677f 100644 --- a/fixtures/integrations/jira/mock.py +++ b/fixtures/integrations/jira/mock.py @@ -1,3 +1,6 @@ +from collections.abc import Mapping +from typing import Any, cast + from fixtures.integrations.jira.stub_client import StubJiraApiClient from fixtures.integrations.mock_service import MockService @@ -5,7 +8,7 @@ class MockJira(StubJiraApiClient, MockService): - def get_projects_list(self, cached: bool = True): + def get_projects_list(self, cached: bool = True) -> list[dict[str, Any]]: """ List all projects in the Jira data format. @@ -25,10 +28,10 @@ def get_projects_list(self, cached: bool = True): }, "simplified": False, } - for project_name in self._get_project_names() + [DEFAULT_PROJECT_ID] + for project_name in [*self._get_project_names(), DEFAULT_PROJECT_ID] ] - def set_createmeta(self, project, createmeta): + def set_createmeta(self, project: str, createmeta: object) -> None: """ This special method lets you seed the stub data with your own metadata. # TODO validate createmeta @@ -39,7 +42,7 @@ def set_createmeta(self, project, createmeta): """ return self._set_data(project, "createmeta", createmeta) - def get_create_meta_for_project(self, project): + def get_create_meta_for_project(self, project: str) -> dict[str, Any] | None: """ Get the Jira "createmeta" for a project. @@ -50,12 +53,13 @@ def get_create_meta_for_project(self, project): createmeta = self._get_data(project, "createmeta") if createmeta: - return createmeta + # justification: seeded fixture data is JSON written through `set_createmeta`. + return cast("dict[str, Any]", createmeta) # Fallback to stub data return super().get_create_meta_for_project(project) - def create_issue(self, raw_form_data): + def create_issue(self, raw_form_data: Mapping[str, object]) -> dict[str, str]: """ Create a new Jira issue. Currently overwrites if the issue already exists. @@ -64,13 +68,15 @@ def create_issue(self, raw_form_data): """ self._throw_if_broken() - project = raw_form_data.get("project", {}).get("id") - ticket_key = self._get_new_ticket_name(project) - self._set_data(project, ticket_key, {"fields": raw_form_data}) + project = cast("Mapping[str, object]", raw_form_data.get("project", {})).get("id") + # justification: Jira project ids are strings in the request fixture payload. + project_id = cast("str", project) + ticket_key = self._get_new_ticket_name(project_id) + self._set_data(project_id, ticket_key, {"fields": raw_form_data}) return {"key": ticket_key} - def get_issue(self, issue_key): + def get_issue(self, issue_key: str) -> dict[str, Any] | None: """ Get a saved issue from Jira. @@ -82,10 +88,12 @@ def get_issue(self, issue_key): if not data: return None - data.update( + # justification: seeded fixture data is JSON written through `_set_data`. + issue = cast("dict[str, Any]", data) + issue.update( { "id": issue_key, "key": issue_key, } ) - return data + return issue diff --git a/fixtures/integrations/jira/stub_client.py b/fixtures/integrations/jira/stub_client.py index 02fb94cd46ca..84b97e327888 100644 --- a/fixtures/integrations/jira/stub_client.py +++ b/fixtures/integrations/jira/stub_client.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from fixtures.integrations.stub_service import StubService @@ -7,59 +8,71 @@ class StubJiraApiClient(StubService): service_name = "jira" - def get_create_meta_for_project(self, project): - response = self._get_stub_data("createmeta_response.json") + def get_create_meta_for_project(self, project: str) -> dict[str, Any] | None: + response: dict[str, Any] = self._get_stub_data("createmeta_response.json") if project == "10001": response["projects"][0]["id"] = "10001" - return response["projects"][0] + project_data: dict[str, Any] | None = response["projects"][0] + return project_data - def get_issue_fields(self, project_id, issue_type_id): - return self._get_stub_data("issue_fields_response.json") + def get_issue_fields(self, project_id: str, issue_type_id: str) -> dict[str, Any]: + response: dict[str, Any] = self._get_stub_data("issue_fields_response.json") + return response - def get_issue_types(self, project_id): - return self._get_stub_data("issue_types_response.json") + def get_issue_types(self, project_id: str) -> dict[str, Any]: + response: dict[str, Any] = self._get_stub_data("issue_types_response.json") + return response - def get_priorities(self): - return self._get_stub_data("priorities_response.json") + def get_priorities(self) -> list[dict[str, Any]]: + response: list[dict[str, Any]] = self._get_stub_data("priorities_response.json") + return response - def get_versions(self, project_id): - return self._get_stub_data("versions_response.json") + def get_versions(self, project_id: str) -> list[dict[str, Any]]: + response: list[dict[str, Any]] = self._get_stub_data("versions_response.json") + return response - def get_projects_paginated(self, params: dict[str, Any] | None = None): - return self._get_stub_data("projects_paginated.json") + def get_projects_paginated(self, params: dict[str, Any] | None = None) -> dict[str, Any]: + response: dict[str, Any] = self._get_stub_data("projects_paginated.json") + return response - def get_projects_list(self, cached: bool = True): - return self._get_stub_data("project_list_response.json") + def get_projects_list(self, cached: bool = True) -> list[dict[str, Any]]: + response: list[dict[str, Any]] = self._get_stub_data("project_list_response.json") + return response - def get_issue(self, issue_key): - return self._get_stub_data("get_issue_response.json") + def get_issue(self, issue_key: str) -> dict[str, Any] | None: + response: dict[str, Any] | None = self._get_stub_data("get_issue_response.json") + return response - def create_comment(self, issue_id, comment): + def create_comment(self, issue_id: str, comment: str) -> str: return comment - def update_comment(self, issue_key, comment_id, comment): + def update_comment(self, issue_key: str, comment_id: str, comment: str) -> str: return comment - def create_issue(self, raw_form_data): + def create_issue(self, raw_form_data: Mapping[str, Any]) -> dict[str, str]: return {"key": "APP-123"} - def get_transitions(self, issue_key): - return self._get_stub_data("transition_response.json")["transitions"] + def get_transitions(self, issue_key: str) -> list[dict[str, Any]]: + response: dict[str, Any] = self._get_stub_data("transition_response.json") + transitions: list[dict[str, Any]] = response["transitions"] + return transitions - def transition_issue(self, issue_key, transition_id): + def transition_issue(self, issue_key: str, transition_id: str) -> None: pass def user_id_field(self) -> str: return "accountId" - def get_user(self, user_id): - user = self._get_stub_data("user.json") + def get_user(self, user_id: str) -> dict[str, Any]: + user: dict[str, Any] = self._get_stub_data("user.json") if user["accountId"] == user_id: return user raise ApiError("no user found") - def get_valid_statuses(self): - return self._get_stub_data("status_response.json") + def get_valid_statuses(self) -> list[dict[str, Any]]: + response: list[dict[str, Any]] = self._get_stub_data("status_response.json") + return response - def search_users_for_project(self, project, username): - return [self._get_stub_data("user.json")] + def search_users_for_project(self, project: str, username: str) -> list[dict[str, Any]]: + user: dict[str, Any] = self._get_stub_data("user.json") + return [user] diff --git a/fixtures/integrations/mock_service.py b/fixtures/integrations/mock_service.py index e2b018c2b3f1..e7efd94306cb 100644 --- a/fixtures/integrations/mock_service.py +++ b/fixtures/integrations/mock_service.py @@ -3,6 +3,7 @@ import os import shutil from collections import defaultdict +from collections.abc import KeysView from typing import Any import orjson @@ -20,14 +21,15 @@ class MockService(StubService): Like stubs, mocks can make tests simpler and more reliable. """ - def __init__(self, mode="memory"): + def __init__(self, mode: str = "memory") -> None: """ Initialize the mock instance. Wipe the previous instance's data if it exists. """ super().__init__() self.mode = mode - self._next_error_code = None - self._next_ids = defaultdict(int) + self._next_error_code: int | None = None + self._next_ids: defaultdict[str, int] = defaultdict(int) + self._memory: defaultdict[str, dict[str, Any]] if self.mode == "file": path = os.path.join(FIXTURE_DIRECTORY, self.service_name, "data") @@ -35,9 +37,9 @@ def __init__(self, mode="memory"): shutil.rmtree(path) os.makedirs(path) else: - self._memory: dict[str, dict[str, Any]] = defaultdict(dict) + self._memory = defaultdict(dict) - def add_project(self, project): + def add_project(self, project: str) -> None: """ Create a new, empty project. @@ -48,7 +50,7 @@ def add_project(self, project): if self.mode == "file": self._get_project_path(project) - def remove_project(self, project): + def remove_project(self, project: str) -> None: """ Totally wipe out a project. @@ -60,13 +62,13 @@ def remove_project(self, project): path = self._get_project_path(project) shutil.rmtree(path) - def break_next_api_call(self, error_code=500): + def break_next_api_call(self, error_code: int = 500) -> None: """ Simulate an outage for a single API call. """ self._next_error_code = error_code - def _throw_if_broken(self, message_option=None): + def _throw_if_broken(self, message_option: str | None = None) -> None: """ See break_next_api_call. :param message_option: What should the message be if this raises? @@ -77,22 +79,22 @@ def _throw_if_broken(self, message_option=None): message = message_option or f"{self.service_name} is down" raise Exception(f"{self._next_error_code}: {message}") - def _get_project_names(self): + def _get_project_names(self) -> KeysView[str]: return self._next_ids.keys() - def _get_new_ticket_name(self, project): + def _get_new_ticket_name(self, project: str) -> str: counter = self._next_ids[project] self._next_ids[project] = counter + 1 return f"{project}-{base32_encode(counter)}" - def _get_project_path(self, project): + def _get_project_path(self, project: str) -> str: path = os.path.join(FIXTURE_DIRECTORY, self.service_name, "data", project) os.makedirs(path, exist_ok=True) return path - def _set_data(self, project, name, data): + def _set_data(self, project: str, name: str, data: object) -> None: if self.mode == "memory": if not self._memory[project]: self._memory[project] = defaultdict() @@ -103,7 +105,7 @@ def _set_data(self, project, name, data): with open(path, "wb") as f: f.write(orjson.dumps(data)) - def _get_data(self, project, name): + def _get_data(self, project: str, name: str) -> object | None: if self.mode == "memory": if not self._memory[project]: self._memory[project] = defaultdict() @@ -114,4 +116,5 @@ def _get_data(self, project, name): return None with open(path, "rb") as f: - return orjson.loads(f.read()) + data: object = orjson.loads(f.read()) + return data diff --git a/fixtures/integrations/stub_service.py b/fixtures/integrations/stub_service.py index dc35e3b6ec07..ab084b4fc346 100644 --- a/fixtures/integrations/stub_service.py +++ b/fixtures/integrations/stub_service.py @@ -23,7 +23,7 @@ class StubService: service_name: str @staticmethod - def get_stub_json(service_name, name): + def get_stub_json(service_name: str, name: str) -> str: """ Get the stubbed data as a JSON string. @@ -36,7 +36,7 @@ def get_stub_json(service_name, name): return f.read() @staticmethod - def get_stub_data(service_name, name): + def get_stub_data(service_name: str, name: str) -> Any: # justification: see _get_stub_data. """ Get the stubbed data as a python object. @@ -44,6 +44,7 @@ def get_stub_data(service_name, name): :param name: string :return: object """ + # justification: decoded JSON fixture data has no fixed schema. cache_key = f"{service_name}.{name}" cached = StubService.stub_data_cache.get(cache_key) if cached: @@ -53,5 +54,6 @@ def get_stub_data(service_name, name): StubService.stub_data_cache[cache_key] = data return deepcopy(data) - def _get_stub_data(self, name): + def _get_stub_data(self, name: str) -> Any: + # justification: decoded JSON fixture data has no fixed schema; callers declare the expected shape. return StubService.get_stub_data(self.service_name, name) diff --git a/fixtures/page_objects/base.py b/fixtures/page_objects/base.py index b46f484fefc2..3bf048f78f51 100644 --- a/fixtures/page_objects/base.py +++ b/fixtures/page_objects/base.py @@ -1,4 +1,6 @@ from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.remote.webelement import WebElement from sentry.testutils.pytest.selenium import Browser @@ -6,19 +8,19 @@ class BasePage: """Base class for PageObjects""" - def __init__(self, browser: Browser): + def __init__(self, browser: Browser) -> None: self.browser = browser @property - def driver(self): + def driver(self) -> WebDriver: return self.browser.driver - def wait_until_loaded(self): + def wait_until_loaded(self) -> None: self.browser.wait_until_not('[data-test-id="loading-indicator"]') class BaseElement: - def __init__(self, element): + def __init__(self, element: WebElement) -> None: self.element = element @@ -27,20 +29,20 @@ class ButtonElement(BaseElement): disabled_attr = "aria-disabled" @property - def disabled(self): + def disabled(self) -> str | None: return self.element.get_attribute(self.disabled_attr) @property - def label(self): + def label(self) -> str | None: return self.element.get_attribute(self.label_attr) - def click(self): + def click(self) -> None: self.element.click() class ButtonWithIconElement(ButtonElement): @property - def icon_href(self): + def icon_href(self) -> str | None: return self.element.find_element(by=By.TAG_NAME, value="use").get_attribute("href") diff --git a/fixtures/page_objects/dashboard_detail.py b/fixtures/page_objects/dashboard_detail.py index 5ffca076bf44..f496b312fce3 100644 --- a/fixtures/page_objects/dashboard_detail.py +++ b/fixtures/page_objects/dashboard_detail.py @@ -1,5 +1,8 @@ +from django.test import Client + from sentry.models.dashboard import Dashboard from sentry.models.organization import Organization +from sentry.testutils.pytest.selenium import Browser from .base import BasePage @@ -11,57 +14,64 @@ class DashboardDetailPage(BasePage): - def __init__(self, browser, client, *, organization: Organization, dashboard: Dashboard): + def __init__( + self, + browser: Browser, + client: Client, + *, + organization: Organization, + dashboard: Dashboard, + ) -> None: super().__init__(browser) self.client = client self.organization = organization self.dashboard = dashboard - def wait_until_loaded(self): + def wait_until_loaded(self) -> None: self.browser.wait_until_not('[data-test-id="events-request-loading"]') self.browser.wait_until_not('[data-test-id="loading-indicator"]') self.browser.wait_until_not('[data-test-id="loading-placeholder"]') self.browser.wait_until_not(".loading") - def visit_default_overview(self): + def visit_default_overview(self) -> None: self.browser.get(f"/organizations/{self.organization.slug}/dashboard/default-overview/") self.wait_until_loaded() self.browser.driver.execute_script("window.scrollTo(0, document.body.scrollHeight)") self.wait_until_loaded() - def visit_create_dashboard(self): + def visit_create_dashboard(self) -> None: self.browser.get(f"/organizations/{self.organization.slug}/dashboards/new/") self.wait_until_loaded() - def visit_dashboard_detail(self): + def visit_dashboard_detail(self) -> None: self.browser.get(f"/organizations/{self.organization.slug}/dashboard/{self.dashboard.id}/") self.wait_until_loaded() - def enter_edit_state(self): + def enter_edit_state(self) -> None: button = self.browser.element('[data-test-id="dashboard-edit"]') self.browser.wait_until_clickable('[data-test-id="dashboard-edit"]') button.click() self.wait_until_loaded() - def click_dashboard_add_widget_button(self): + def click_dashboard_add_widget_button(self) -> None: button = self.browser.element('[data-test-id="widget-add"]') # HACK: Use JavaScript to execute click to avoid click intercepted issues self.browser.driver.execute_script("arguments[0].click()", button) self.wait_until_loaded() - def click_dashboard_header_add_widget_button(self): + def click_dashboard_header_add_widget_button(self) -> None: button = self.browser.element('[data-test-id="add-widget-library"]') self.browser.wait_until_clickable('[data-test-id="add-widget-library"]') button.click() self.wait_until_loaded() - def click_cancel_button(self): + def click_cancel_button(self) -> None: button = self.browser.element('[data-test-id="dashboard-cancel"]') self.browser.wait_until_clickable('[data-test-id="dashboard-cancel"]') button.click() self.wait_until_loaded() - def add_widget_through_dashboard(self, widget_title): + def add_widget_through_dashboard(self, widget_title: str) -> None: self.click_dashboard_add_widget_button() title_input = self.browser.element(WIDGET_TITLE_FIELD) title_input.clear() @@ -70,7 +80,7 @@ def add_widget_through_dashboard(self, widget_title): button.click() self.wait_until_loaded() - def save_dashboard(self): + def save_dashboard(self) -> None: button = self.browser.element('[data-test-id="dashboard-commit"]') self.browser.wait_until_clickable('[data-test-id="dashboard-commit"]') button.click() diff --git a/fixtures/page_objects/explore_logs.py b/fixtures/page_objects/explore_logs.py index 52dfade0aac9..47b16c0ab50d 100644 --- a/fixtures/page_objects/explore_logs.py +++ b/fixtures/page_objects/explore_logs.py @@ -1,20 +1,23 @@ from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webelement import WebElement + +from sentry.testutils.pytest.selenium import Browser from .base import BasePage from .global_selection import GlobalSelectionPage class ExploreLogsPage(BasePage): - def __init__(self, browser, client): + def __init__(self, browser: Browser, client: object) -> None: super().__init__(browser) self.client = client self.global_selection = GlobalSelectionPage(browser) - def visit_explore_logs(self, org): + def visit_explore_logs(self, org: str) -> None: self.browser.get(f"/organizations/{org}/explore/logs/") self.wait_until_loaded() - def toggle_log_row_with_message(self, message): + def toggle_log_row_with_message(self, message: str) -> WebElement: row = self.get_log_row_with_message(message) try: expanded_count = len( @@ -33,20 +36,20 @@ def toggle_log_row_with_message(self, message): return row - def get_log_row_with_message(self, message): + def get_log_row_with_message(self, message: str) -> WebElement: row = self.browser.find_element( by=By.XPATH, value=f'//*[@data-test-id="log-table-row" and .//*[contains(text(),"{message}")]]', ) return row - def get_log_row_columns(self, row): + def get_log_row_columns(self, row: WebElement) -> list[WebElement]: # The expanded row actually makes a new sibling row that contains the fields-tree. columns = row.find_elements( By.XPATH, 'following-sibling::*[1]//*[@data-test-id="attribute-tree-column"]' ) return columns - def wait_until_loaded(self): + def wait_until_loaded(self) -> None: self.browser.wait_until_not('[data-test-id="loading-indicator"]') self.browser.wait_until_test_id("logs-table") diff --git a/fixtures/page_objects/explore_spans.py b/fixtures/page_objects/explore_spans.py index aa869bb10de0..7efa2276f2ea 100644 --- a/fixtures/page_objects/explore_spans.py +++ b/fixtures/page_objects/explore_spans.py @@ -1,27 +1,30 @@ from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webelement import WebElement + +from sentry.testutils.pytest.selenium import Browser from .base import BasePage from .global_selection import GlobalSelectionPage class ExploreSpansPage(BasePage): - def __init__(self, browser, client): + def __init__(self, browser: Browser, client: object) -> None: super().__init__(browser) self.client = client self.global_selection = GlobalSelectionPage(browser) - def visit_explore_spans(self, org): + def visit_explore_spans(self, org: str) -> None: self.browser.get(f"/organizations/{org}/explore/traces/") self.wait_until_loaded() - def get_spans_row_with_id(self, span_id): + def get_spans_row_with_id(self, span_id: str) -> WebElement: row = self.browser.find_element( by=By.XPATH, value=f'//tr[.//*[contains(text(),"{span_id}")]]', ) return row - def click_on_span_id(self, span_id): + def click_on_span_id(self, span_id: str) -> None: row = self.get_spans_row_with_id(span_id) column_objects = self.get_spans_row_columns(row) @@ -32,11 +35,11 @@ def click_on_span_id(self, span_id): raise Exception(f"Could not find a column with span_id '{span_id}' to click.") - def get_spans_row_columns(self, row): + def get_spans_row_columns(self, row: WebElement) -> list[WebElement]: # The expanded row actually makes a new sibling row that contains the fields-tree. columns = row.find_elements(By.XPATH, "child::td") return columns - def wait_until_loaded(self): + def wait_until_loaded(self) -> None: self.browser.wait_until_not('[data-test-id="loading-indicator"]') self.browser.wait_until_test_id("spans-table") diff --git a/fixtures/page_objects/global_selection.py b/fixtures/page_objects/global_selection.py index 3b675ce4d5c6..b52e5c708b68 100644 --- a/fixtures/page_objects/global_selection.py +++ b/fixtures/page_objects/global_selection.py @@ -2,22 +2,22 @@ class GlobalSelectionPage(BasePage): - def get_selected_project_slug(self): + def get_selected_project_slug(self) -> str: return self.browser.element('[data-test-id="page-filter-project-selector"]').text - def get_selected_environment(self): + def get_selected_environment(self) -> str: return self.browser.element('[data-test-id="page-filter-environment-selector"]').text - def get_selected_date(self): + def get_selected_date(self) -> str: return self.browser.element('[data-test-id="page-filter-timerange-selector"]').text - def go_back_to_issues(self): + def go_back_to_issues(self) -> None: self.browser.click('[data-test-id="breadcrumb-link"]') - def open_project_selector(self): + def open_project_selector(self) -> None: self.browser.click('[data-test-id="page-filter-project-selector"]') - def select_project_by_slug(self, slug): + def select_project_by_slug(self, slug: str) -> None: # Use .// to search for text in descendant nodes (handles nested Flex/Text components) project_item_selector = f'//*[@data-test-id="menu-list-item-label"][.//text()="{slug}"]' @@ -25,10 +25,10 @@ def select_project_by_slug(self, slug): self.browser.wait_until(xpath=project_item_selector) self.browser.click(xpath=project_item_selector) - def open_environment_selector(self): + def open_environment_selector(self) -> None: self.browser.click('[data-test-id="page-filter-environment-selector"]') - def select_environment(self, environment): + def select_environment(self, environment: str) -> None: # Use .// to search for text in descendant nodes (handles nested Flex/Text components) environment_path = f'//*[@data-test-id="menu-list-item-label"][.//text()="{environment}"]' @@ -36,10 +36,10 @@ def select_environment(self, environment): self.browser.wait_until(xpath=environment_path) self.browser.click(xpath=environment_path) - def open_date_selector(self): + def open_date_selector(self) -> None: self.browser.click('[data-test-id="page-filter-timerange-selector"]') - def select_date(self, date): + def select_date(self, date: str) -> None: date_path = f'//*[text()="{date}"]' self.open_date_selector() diff --git a/fixtures/page_objects/issue_details.py b/fixtures/page_objects/issue_details.py index 1732fcbae97c..8e704e50764d 100644 --- a/fixtures/page_objects/issue_details.py +++ b/fixtures/page_objects/issue_details.py @@ -1,15 +1,26 @@ +from typing import Protocol, cast + +from django.test import Client from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.wait import WebDriverWait +from sentry.models.group import Group +from sentry.testutils.pytest.selenium import Browser from sentry.utils import json from .base import BasePage from .global_selection import GlobalSelectionPage +class _ApiResponse(Protocol): + status_code: int + data: dict[str, object] + + class IssueDetailsPage(BasePage): - def __init__(self, browser, client): + def __init__(self, browser: Browser, client: Client) -> None: super().__init__(browser) self.client = client self.global_selection = GlobalSelectionPage(browser) @@ -20,11 +31,11 @@ def __init__(self, browser, client): data=json.dumps({"guide": "tour.issue_details", "status": "viewed", "useful": True}), ) - def visit_issue(self, org, groupid): + def visit_issue(self, org: str, groupid: int | str) -> None: self.browser.get(f"/organizations/{org}/issues/{groupid}/") self.wait_until_loaded() - def visit_performance_issue(self, org, groupid): + def visit_performance_issue(self, org: str, groupid: int | str) -> None: """ Wait for the span evidence and the rest of the page to finish loading so no requests are still hitting the database when the test tears down. @@ -34,54 +45,57 @@ def visit_performance_issue(self, org, groupid): self.browser.wait_until_not('[data-test-id="loading-indicator"]') self.browser.wait_until_not('[data-test-id="loading-placeholder"]') - def visit_issue_activity(self, org, groupid): + def visit_issue_activity(self, org: str, groupid: int | str) -> None: self.browser.get(f"/organizations/{org}/issues/{groupid}/activity/") self.browser.wait_until_not('[data-test-id="loading-indicator"]') - def visit_issue_in_environment(self, org, groupid, environment): + def visit_issue_in_environment(self, org: str, groupid: int | str, environment: str) -> None: self.browser.get(f"/organizations/{org}/issues/{groupid}/?environment={environment}") self.browser.wait_until('[data-test-id="group-event-details"]') - def visit_tag_values(self, org, groupid, tag): + def visit_tag_values(self, org: str, groupid: int | str, tag: str) -> None: self.browser.get(f"/organizations/{org}/issues/{groupid}/distributions/{tag}/") self.browser.wait_until('[data-test-id="group-tag-value"]') - def get_environment(self): + def get_environment(self) -> str: return self.browser.find_element( by=By.CSS_SELECTOR, value='[data-test-id="env-label"' ).text.lower() - def go_back_to_issues(self): + def go_back_to_issues(self) -> None: self.global_selection.go_back_to_issues() - def api_issue_get(self, group): - return self.client.get(f"/api/0/organizations/{group.organization.slug}/issues/{group.id}/") + def api_issue_get(self, group: Group) -> _ApiResponse: + return cast( + "_ApiResponse", + self.client.get(f"/api/0/organizations/{group.organization.slug}/issues/{group.id}/"), + ) - def go_to_subtab(self, key): + def go_to_subtab(self, key: str) -> None: tabs = self.browser.find_element(by=By.CSS_SELECTOR, value='[role="tablist"]') tabs.find_element(by=By.CSS_SELECTOR, value=f'[role="tab"][data-key="{key}"]').click() self.browser.wait_until_not('[data-test-id="loading-indicator"]') - def open_issue_errors(self): + def open_issue_errors(self) -> None: self.browser.click(".errors-toggle") self.browser.wait_until(".entries > .errors ul") - def open_curl(self): + def open_curl(self) -> None: self.browser.find_element(by=By.XPATH, value="//a//code[contains(text(), 'curl')]").click() - def resolve_issue(self): + def resolve_issue(self) -> None: self.browser.click('[aria-label="Resolve"]') # Resolve should become unresolve self.browser.wait_until('[aria-label="Unresolve"]') self.browser.wait_until(xpath="//*[@role='status'][contains(., 'Issue resolved')]") - def archive_issue(self): + def archive_issue(self) -> None: self.browser.click('[aria-label="Archive"]') # Archive should become unarchive self.browser.wait_until('[aria-label="Unarchive"]') self.browser.wait_until(xpath="//*[@role='status'][contains(., 'Issue archived')]") - def bookmark_issue(self): + def bookmark_issue(self) -> None: self.browser.click('button[aria-label="More Actions"]') self.browser.wait_until('[data-test-id="bookmark"]') button = self.browser.element('[data-test-id="bookmark"]') @@ -90,7 +104,7 @@ def bookmark_issue(self): self.browser.click('button[aria-label="More Actions"]') self.browser.wait_until('[data-test-id="unbookmark"]') - def assign_to(self, user): + def assign_to(self, user: str) -> None: # Open the assignee picker self.browser.find_element( by=By.CSS_SELECTOR, value='[aria-label="Modify issue assignee"]' @@ -115,23 +129,23 @@ def assign_to(self, user): self.browser.wait_until_not('[data-test-id="loading-indicator"]') - def find_comment_editor(self): + def find_comment_editor(self) -> WebElement: selector = '[role="combobox"][aria-label="Add a comment"]' self.browser.wait_until(selector) return self.browser.find_element(by=By.CSS_SELECTOR, value=selector) - def has_comment(self, text): + def has_comment(self, text: str) -> bool: element = self.browser.element('[data-test-id="activity-note-body"]') return text in element.text - def wait_until_loaded(self): + def wait_until_loaded(self) -> None: self.browser.wait_until_not('[data-test-id="loading-indicator"]') self.browser.wait_until_not('[data-test-id="event-errors-loading"]') self.browser.wait_until_test_id("linked-issues") self.browser.wait_until_test_id("loaded-device-name") self.browser.wait_until_not('[data-test-id="loading-placeholder"]') - def mark_reviewed(self): + def mark_reviewed(self) -> None: self.browser.click('[aria-label="More Actions"]') self.browser.wait_until('[data-test-id="mark-review"]') self.browser.click('[data-test-id="mark-review"]') diff --git a/fixtures/page_objects/issue_list.py b/fixtures/page_objects/issue_list.py index 1d04f4ad36ef..5de721fd1d7f 100644 --- a/fixtures/page_objects/issue_list.py +++ b/fixtures/page_objects/issue_list.py @@ -1,35 +1,39 @@ +from django.test import Client from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.support.wait import WebDriverWait +from sentry.testutils.pytest.selenium import Browser + from .base import BasePage from .global_selection import GlobalSelectionPage from .issue_details import IssueDetailsPage class IssueListPage(BasePage): - def __init__(self, browser, client): + def __init__(self, browser: Browser, client: Client) -> None: super().__init__(browser) self.client = client self.global_selection = GlobalSelectionPage(browser) - def visit_issue_list(self, org, query=""): + def visit_issue_list(self, org: str, query: str = "") -> None: self.browser.get(f"/organizations/{org}/issues/{query}") self.wait_until_loaded() - def wait_for_stream(self): + def wait_for_stream(self) -> None: self.browser.wait_until('[data-test-id="event-issue-header"]', timeout=20) - def select_issue(self, position): + def select_issue(self, position: int) -> None: # Must hover over the row to show the checkbox self.browser.move_to(f'[data-test-id="group"]:nth-child({position})') self.browser.click(f'[data-test-id="group"]:nth-child({position}) input[type="checkbox"]') - def navigate_to_issue(self, position): + def navigate_to_issue(self, position: int) -> None: self.browser.click(f'[data-test-id="group"]:nth-child({position}) a') self.browser.wait_until('[data-test-id="group-event-details"]') self.issue_details = IssueDetailsPage(self.browser, self.client) - def resolve_issues(self): + def resolve_issues(self) -> None: self.browser.click('[aria-label="Resolve"]') def wait_for_issue_count(self, count: int) -> None: @@ -41,22 +45,22 @@ def wait_for_issue_count(self, count: int) -> None: message=f"Expected {count} issues in the list", ) - def wait_for_issue(self): + def wait_for_issue(self) -> None: self.browser.wait_until('[data-test-id="group"]') - def find_resolved_issues(self): + def find_resolved_issues(self) -> list[WebElement]: return self.browser.elements('[data-test-id="resolved-issue"]') - def archive_issues(self): + def archive_issues(self) -> None: self.browser.click('[aria-label="Archive"]') - def delete_issues(self): + def delete_issues(self) -> None: self.browser.click('[aria-label="More issue actions"]') self.browser.wait_until('[data-test-id="delete"]') self.browser.click('[data-test-id="delete"]') self.browser.click('[data-test-id="confirm-button"]') - def merge_issues(self): + def merge_issues(self) -> None: # Merge button gets put into an overflow menu for small viewports if self.browser.element_exists('[aria-label="Merge"]'): self.browser.click('[aria-label="Merge"]') @@ -67,7 +71,7 @@ def merge_issues(self): self.browser.click('[data-test-id="merge"]') self.browser.click('[data-test-id="confirm-button"]') - def mark_reviewed_issues(self): + def mark_reviewed_issues(self) -> None: # Marked reviewed button gets put into an overflow menu for small viewports if self.browser.element_exists('[aria-label="Mark Reviewed"]'): self.browser.click('[aria-label="Mark Reviewed"]') diff --git a/fixtures/page_objects/organization_integration_settings.py b/fixtures/page_objects/organization_integration_settings.py index 20b6534df82b..7026c64110cf 100644 --- a/fixtures/page_objects/organization_integration_settings.py +++ b/fixtures/page_objects/organization_integration_settings.py @@ -1,3 +1,6 @@ +from collections.abc import Mapping +from typing import Any + from selenium.webdriver.common.by import By from .base import BasePage, ButtonElement, ModalElement @@ -7,7 +10,7 @@ class ExampleIntegrationSetupWindowElement(ModalElement): name_field_selector = "name" submit_button_selector = '[type="submit"]' - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.name = self.element.find_element(by=By.NAME, value="name") continue_button_element = self.element.find_element( @@ -15,27 +18,31 @@ def __init__(self, *args, **kwargs): ) self.continue_button = ButtonElement(continue_button_element) - def fill_in_setup_form(self, installation_data): + def fill_in_setup_form(self, installation_data: Mapping[str, str]) -> None: self.name.send_keys(installation_data[self.name_field_selector]) class OrganizationAbstractDetailViewPage(BasePage): configurations_text = "Configurations" - def click_install_button(self): + def click_install_button(self) -> None: self.browser.click('[data-test-id="install-button"]') # uninstalls any configuration (not a particular one) - def uninstall(self): + def uninstall(self) -> None: self.browser.click('[aria-label="Uninstall"]') self.browser.click('[data-test-id="confirm-button"]') - def switch_to_configuration_view(self): + def switch_to_configuration_view(self) -> None: self.browser.find_element(by=By.LINK_TEXT, value=self.configurations_text).click() class OrganizationIntegrationDetailViewPage(OrganizationAbstractDetailViewPage): - def click_through_integration_setup(self, setup_window_cls, installation_data): + def click_through_integration_setup( + self, + setup_window_cls: type[ExampleIntegrationSetupWindowElement], + installation_data: Mapping[str, str], + ) -> None: self.driver.switch_to.window(self.driver.window_handles[1]) integration_setup_window = setup_window_cls(element=self.browser) integration_setup_window.fill_in_setup_form(installation_data) @@ -44,6 +51,6 @@ def click_through_integration_setup(self, setup_window_cls, installation_data): class OrganizationSentryAppDetailViewPage(OrganizationAbstractDetailViewPage): - def uninstall(self): + def uninstall(self) -> None: self.browser.click('[data-test-id="sentry-app-uninstall"]') self.browser.click('[data-test-id="confirm-button"]') diff --git a/fixtures/page_objects/trace_view.py b/fixtures/page_objects/trace_view.py index 6b5a60bfe956..fa600346da11 100644 --- a/fixtures/page_objects/trace_view.py +++ b/fixtures/page_objects/trace_view.py @@ -1,20 +1,24 @@ +from typing import Any + from selenium.webdriver.common.by import By +from sentry.testutils.pytest.selenium import Browser + from .base import BasePage from .global_selection import GlobalSelectionPage class TraceViewWaterfallPage(BasePage): - def __init__(self, browser, client): + def __init__(self, browser: Browser, client: Any) -> None: super().__init__(browser) self.client = client self.global_selection = GlobalSelectionPage(browser) - def visit_trace_view(self, org, trace_id): + def visit_trace_view(self, org: str, trace_id: str) -> None: self.browser.get(f"/organizations/{org}/traces/trace/{trace_id}/") self.wait_until_loaded() - def get_trace_span_row(self, op, description): + def get_trace_span_row(self, op: str, description: str) -> Any: xpath = ( f'//div[contains(@class, "TraceLeftColumnInner") and ' f'contains(normalize-space(), "{op} — {description}")]' @@ -22,13 +26,13 @@ def get_trace_span_row(self, op, description): self.browser.wait_until(xpath=xpath) return self.browser.find_element(by=By.XPATH, value=xpath) - def expand_trace_rows(self): + def expand_trace_rows(self) -> None: self.browser.wait_until_clickable(selector=".TraceChildrenCount") self.browser.find_element(by=By.CSS_SELECTOR, value=".TraceChildrenCount").click() - def wait_until_loaded(self): + def wait_until_loaded(self) -> None: self.browser.wait_until_not('[data-test-id="loading-indicator"]') - def normalize_span_row_text(self, text): + def normalize_span_row_text(self, text: str) -> str: # Remove newlines and extra spaces return text.replace("\n", " ").replace("—", "-").strip() diff --git a/fixtures/page_objects/transaction_summary.py b/fixtures/page_objects/transaction_summary.py index 4764e1295ea5..8d0a18e58487 100644 --- a/fixtures/page_objects/transaction_summary.py +++ b/fixtures/page_objects/transaction_summary.py @@ -2,6 +2,6 @@ class TransactionSummaryPage(BasePage): - def wait_until_loaded(self): + def wait_until_loaded(self) -> None: self.browser.wait_until_not('[data-test-id="loading-indicator"]') self.browser.wait_until_not('[data-test-id="loading-placeholder"]') diff --git a/fixtures/sdk_crash_detection/crash_event_android.py b/fixtures/sdk_crash_detection/crash_event_android.py index d7990c3ecb4c..c236ccd2980e 100644 --- a/fixtures/sdk_crash_detection/crash_event_android.py +++ b/fixtures/sdk_crash_detection/crash_event_android.py @@ -44,9 +44,9 @@ def get_frames( def get_crash_event( - sdk_frame_module="io.sentry.Hub", - system_frame_module="java.lang.reflect.Method", - **kwargs, + sdk_frame_module: str = "io.sentry.Hub", + system_frame_module: str = "java.lang.reflect.Method", + **kwargs: object, ) -> dict[str, object]: return get_crash_event_with_frames( get_frames(sdk_frame_module, system_frame_module), @@ -85,10 +85,10 @@ def get_apex_frames( def get_apex_crash_event( - apex_frame_function="__start_thread", - apex_frame_package="/apex/com.android.art/lib64/bionic/libc.so", - system_frame_package="/apex/com.android.art/lib64/libart.so", - **kwargs, + apex_frame_function: str = "__start_thread", + apex_frame_package: str = "/apex/com.android.art/lib64/bionic/libc.so", + system_frame_package: str = "/apex/com.android.art/lib64/libart.so", + **kwargs: object, ) -> dict[str, object]: return get_crash_event_with_frames( get_apex_frames(apex_frame_function, apex_frame_package, system_frame_package), @@ -98,7 +98,7 @@ def get_apex_crash_event( def get_exception( frames: Sequence[Mapping[str, str]], - mechanism=None, + mechanism: Mapping[str, object] | None = None, ) -> dict[str, object]: if mechanism is None: # linter complains about mutable arguments otherwise @@ -112,7 +112,9 @@ def get_exception( } -def get_crash_event_with_frames(frames: Sequence[Mapping[str, str]], **kwargs) -> dict[str, object]: +def get_crash_event_with_frames( + frames: Sequence[Mapping[str, str]], **kwargs: object +) -> dict[str, object]: result = { "event_id": "0a52a8331d3b45089ebd74f8118d4fa1", "release": "io.sentry.samples.android@7.4.0+2", diff --git a/fixtures/sdk_crash_detection/crash_event_cocoa.py b/fixtures/sdk_crash_detection/crash_event_cocoa.py index 6d810d098d0f..f7da93546a5a 100644 --- a/fixtures/sdk_crash_detection/crash_event_cocoa.py +++ b/fixtures/sdk_crash_detection/crash_event_cocoa.py @@ -103,10 +103,11 @@ def get_frames( def get_crash_event( - handled=False, - function="-[Sentry]", + handled: bool = False, + function: str = "-[Sentry]", registers: Sequence[Mapping[str, str]] | None = None, - **kwargs, + # justification: fixture accepts arbitrary event overrides. + **kwargs: Any, ) -> dict[str, Collection[str]]: return get_crash_event_with_frames( get_frames(function), registers=registers, handled=handled, **kwargs @@ -116,8 +117,9 @@ def get_crash_event( def get_crash_event_with_frames( frames: Sequence[Mapping[str, Any]], registers: Sequence[Mapping[str, str]] | None = None, - handled=False, - **kwargs, + handled: bool = False, + # justification: fixture accepts arbitrary event overrides. + **kwargs: Any, ) -> dict[str, Collection[str]]: result = { "event_id": "80e3496eff734ab0ac993167aaa0d1cd", diff --git a/fixtures/sdk_crash_detection/crash_event_dart.py b/fixtures/sdk_crash_detection/crash_event_dart.py index 0d7e43185f20..c3ba0049a7b7 100644 --- a/fixtures/sdk_crash_detection/crash_event_dart.py +++ b/fixtures/sdk_crash_detection/crash_event_dart.py @@ -39,10 +39,10 @@ def get_frames( def get_crash_event( - sdk_frame_abs_path="package:sentry/src/sentry_tracer.dart", - system_frame_abs_path="dart:core-patch/growable_array.dart", - sdk_function="SentryTracer.setTag", - **kwargs, + sdk_frame_abs_path: str = "package:sentry/src/sentry_tracer.dart", + system_frame_abs_path: str = "dart:core-patch/growable_array.dart", + sdk_function: str = "SentryTracer.setTag", + **kwargs: object, ) -> dict[str, object]: return get_crash_event_with_frames( get_frames(sdk_frame_abs_path, system_frame_abs_path, sdk_function=sdk_function), @@ -50,7 +50,9 @@ def get_crash_event( ) -def get_crash_event_with_frames(frames: Sequence[Mapping[str, str]], **kwargs) -> dict[str, object]: +def get_crash_event_with_frames( + frames: Sequence[Mapping[str, str]], **kwargs: object +) -> dict[str, object]: result = { "event_id": "0a52a8331d3b45089ebd74f8118d4fa1", "release": "io.sentry.flutter.sentryFlutterExample@8.2.0+8.2.0", diff --git a/fixtures/sdk_crash_detection/crash_event_dotnet.py b/fixtures/sdk_crash_detection/crash_event_dotnet.py index 8e4c7ae2e772..61ca6f07563a 100644 --- a/fixtures/sdk_crash_detection/crash_event_dotnet.py +++ b/fixtures/sdk_crash_detection/crash_event_dotnet.py @@ -46,9 +46,9 @@ def get_frames( def get_crash_event( - sdk_frame_module="Sentry.SentryClient", - system_frame_module="System.Runtime.CompilerServices.AsyncTaskMethodBuilder", - **kwargs, + sdk_frame_module: str = "Sentry.SentryClient", + system_frame_module: str = "System.Runtime.CompilerServices.AsyncTaskMethodBuilder", + **kwargs: object, ) -> dict[str, object]: return get_crash_event_with_frames( get_frames(sdk_frame_module, system_frame_module), @@ -95,9 +95,9 @@ def get_unity_frames( def get_unity_crash_event( - sdk_frame_module="Sentry.SentryClient", - unity_frame_module="UnityEngine.Events.InvokableCall", - **kwargs, + sdk_frame_module: str = "Sentry.SentryClient", + unity_frame_module: str = "UnityEngine.Events.InvokableCall", + **kwargs: object, ) -> dict[str, object]: return get_crash_event_with_frames( get_unity_frames(sdk_frame_module, unity_frame_module), @@ -107,7 +107,7 @@ def get_unity_crash_event( def get_exception( frames: Sequence[Mapping[str, str]], - mechanism=None, + mechanism: Mapping[str, object] | None = None, ) -> dict[str, object]: if mechanism is None: # linter complains about mutable arguments otherwise @@ -121,7 +121,9 @@ def get_exception( } -def get_crash_event_with_frames(frames: Sequence[Mapping[str, str]], **kwargs) -> dict[str, object]: +def get_crash_event_with_frames( + frames: Sequence[Mapping[str, str]], **kwargs: object +) -> dict[str, object]: result = { "event_id": "0a52a8331d3b45089ebd74f8118d4fa1", "release": "sentry.dotnet@3.22.0", diff --git a/fixtures/sdk_crash_detection/crash_event_native.py b/fixtures/sdk_crash_detection/crash_event_native.py index ed965ae2a6fb..71f56438da82 100644 --- a/fixtures/sdk_crash_detection/crash_event_native.py +++ b/fixtures/sdk_crash_detection/crash_event_native.py @@ -1,5 +1,6 @@ import time from collections.abc import Mapping, MutableMapping, Sequence +from typing import Any def get_frames( @@ -38,10 +39,10 @@ def get_frames( def get_crash_event( - sdk_frame_function="sentry_value_to_msgpack", - sdk_frame_package="E:\\Sentry\\Sentaurs\\Game\\Sentaurs.exe", - system_frame_package="C:\\Windows\\System32\\DriverStore\\FileRepository\\u0398226.inf_amd64_c5d9587384e4b5ff\\B398182\\amdxx64.dll", - **kwargs, + sdk_frame_function: str = "sentry_value_to_msgpack", + sdk_frame_package: str = "E:\\Sentry\\Sentaurs\\Game\\Sentaurs.exe", + system_frame_package: str = "C:\\Windows\\System32\\DriverStore\\FileRepository\\u0398226.inf_amd64_c5d9587384e4b5ff\\B398182\\amdxx64.dll", + **kwargs: Any, ) -> dict[str, object]: return get_crash_event_with_frames( get_frames(sdk_frame_function, sdk_frame_package, system_frame_package), @@ -49,7 +50,9 @@ def get_crash_event( ) -def get_crash_event_with_frames(frames: Sequence[Mapping[str, str]], **kwargs) -> dict[str, object]: +def get_crash_event_with_frames( + frames: Sequence[Mapping[str, str]], **kwargs: Any +) -> dict[str, object]: result = { "event_id": "0a52a8331d3b45089ebd74f8118d4fa1", "release": "14.7", diff --git a/fixtures/sdk_crash_detection/crash_event_react_native.py b/fixtures/sdk_crash_detection/crash_event_react_native.py index d812b346f408..836a151e8829 100644 --- a/fixtures/sdk_crash_detection/crash_event_react_native.py +++ b/fixtures/sdk_crash_detection/crash_event_react_native.py @@ -61,12 +61,15 @@ def get_exception( def get_crash_event( - filename="/Users/sentry.user/git-repos/sentry-react-native/dist/js/client.js", **kwargs + filename: str = "/Users/sentry.user/git-repos/sentry-react-native/dist/js/client.js", + **kwargs: object, ) -> dict[str, object]: return get_crash_event_with_frames(get_frames(filename=filename), **kwargs) -def get_crash_event_with_frames(frames: Sequence[Mapping[str, str]], **kwargs) -> dict[str, object]: +def get_crash_event_with_frames( + frames: Sequence[Mapping[str, str]], **kwargs: object +) -> dict[str, object]: result = { "event_id": "150d5b0b4f3a4797a3cd1345374ac484", "release": "com.samplenewarchitecture@1.0+1", diff --git a/fixtures/stubs-for-mypy/django_zero_downtime_migrations/__init__.pyi b/fixtures/stubs-for-mypy/django_zero_downtime_migrations/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/fixtures/stubs-for-mypy/django_zero_downtime_migrations/backends/__init__.pyi b/fixtures/stubs-for-mypy/django_zero_downtime_migrations/backends/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/fixtures/stubs-for-mypy/django_zero_downtime_migrations/backends/postgres/__init__.pyi b/fixtures/stubs-for-mypy/django_zero_downtime_migrations/backends/postgres/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/fixtures/stubs-for-mypy/django_zero_downtime_migrations/backends/postgres/schema.pyi b/fixtures/stubs-for-mypy/django_zero_downtime_migrations/backends/postgres/schema.pyi new file mode 100644 index 000000000000..e44496f3162f --- /dev/null +++ b/fixtures/stubs-for-mypy/django_zero_downtime_migrations/backends/postgres/schema.pyi @@ -0,0 +1,11 @@ +from typing import Any + +class Unsafe: + ADD_COLUMN_NOT_NULL: Any + ALTER_COLUMN_TYPE: Any + ADD_CONSTRAINT_EXCLUDE: Any + ALTER_TABLE_SET_TABLESPACE: Any + ALTER_TABLE_RENAME_COLUMN: Any + +class UnsafeOperationException(Exception): ... +class DatabaseSchemaEditorMixin: ... diff --git a/fixtures/stubs-for-mypy/emmett55/__init__.pyi b/fixtures/stubs-for-mypy/emmett55/__init__.pyi new file mode 100644 index 000000000000..afc96106ef91 --- /dev/null +++ b/fixtures/stubs-for-mypy/emmett55/__init__.pyi @@ -0,0 +1,58 @@ +from collections.abc import Callable +from typing import Any, TypeVar + +from emmett_core.http.wrappers.request import Request +from emmett_core.http.wrappers.response import Response + +from .ctx import current as current + +_T = TypeVar("_T") + +class Config: + def __getattr__(self, name: str) -> Any: ... + def __setattr__(self, name: str, value: Any) -> None: ... + +class _RouteContext: + def __call__[_F: Callable[..., Any]](self, func: _F) -> _F: ... + +class AppModule: + pipeline: list[Pipe] + config: Config + def route( + self, + paths: str | list[str] | None = None, + name: str | None = None, + **kwargs: Any, + ) -> _RouteContext: ... + def websocket( + self, + paths: str | list[str] | None = None, + name: str | None = None, + **kwargs: Any, + ) -> _RouteContext: ... + +class App: + config: Config + log: Any + _router_http: Any + def __init__(self, import_name: str, **opts: Any) -> None: ... + def module(self, import_name: str, name: str, **kwargs: Any) -> AppModule: ... + def use_extension(self, ext_cls: type[_T]) -> _T: ... + +class Pipe: + output: str | None = None + async def open(self) -> None: ... + async def open_request(self) -> None: ... + async def close(self) -> None: ... + async def close_request(self) -> None: ... + async def pipe(self, next_pipe: Any, **kwargs: Any) -> Any: ... + async def pipe_request(self, next_pipe: Any, **kwargs: Any) -> Any: ... + +def abort(code: int, body: str = "") -> None: ... +def redirect(path: str, code: int = 302) -> Any: ... +def now() -> Any: ... + +request: Request +response: Response +session: Any +websocket: Any diff --git a/fixtures/stubs-for-mypy/emmett55/cache.pyi b/fixtures/stubs-for-mypy/emmett55/cache.pyi new file mode 100644 index 000000000000..55982aefeca9 --- /dev/null +++ b/fixtures/stubs-for-mypy/emmett55/cache.pyi @@ -0,0 +1,17 @@ +from collections.abc import Callable +from typing import Any + +class RamCache: + def __init__(self, default_expire: int = ..., threshold: int = ..., **kwargs: Any) -> None: ... + +class _CacheDecorator: + def __call__[_F: Callable[..., Any]](self, f: _F) -> _F: ... + +class Cache: + def __init__(self, **kwargs: Any) -> None: ... + def __call__( + self, + key: str | None = ..., + function: Any = ..., + duration: int | str | None = ..., + ) -> _CacheDecorator: ... diff --git a/fixtures/stubs-for-mypy/emmett55/ctx.pyi b/fixtures/stubs-for-mypy/emmett55/ctx.pyi new file mode 100644 index 000000000000..60693cc984ed --- /dev/null +++ b/fixtures/stubs-for-mypy/emmett55/ctx.pyi @@ -0,0 +1,8 @@ +from typing import Any + +class Current: + def __getattr__(self, name: str) -> Any: ... + def __setattr__(self, name: str, value: Any) -> None: ... + def __delattr__(self, name: str) -> None: ... + +current: Current diff --git a/fixtures/stubs-for-mypy/emmett55/extensions.pyi b/fixtures/stubs-for-mypy/emmett55/extensions.pyi new file mode 100644 index 000000000000..09bd653eb31b --- /dev/null +++ b/fixtures/stubs-for-mypy/emmett55/extensions.pyi @@ -0,0 +1,24 @@ +from collections.abc import Callable +from enum import Enum +from typing import Any + +from . import Config + +class Signals(str, Enum): + after_loop = "after_loop" + after_route = "after_route" + before_route = "before_route" + before_routes = "before_routes" + +class Extension: + namespace: str | None = None + default_config: dict[str, Any] = ... + app: Any + env: Any + config: Config + def __init__(self, app: Any, env: Any, config: Config) -> None: ... + def on_load(self) -> None: ... + +class listen_signal: + def __init__(self, signal: Signals) -> None: ... + def __call__[_F: Callable[..., Any]](self, f: _F) -> _F: ... diff --git a/fixtures/stubs-for-mypy/emmett55/routing/__init__.pyi b/fixtures/stubs-for-mypy/emmett55/routing/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/fixtures/stubs-for-mypy/emmett55/routing/urls.pyi b/fixtures/stubs-for-mypy/emmett55/routing/urls.pyi new file mode 100644 index 000000000000..8a80c1d7341f --- /dev/null +++ b/fixtures/stubs-for-mypy/emmett55/routing/urls.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def url(*args: Any, **kwargs: Any) -> str: ... diff --git a/fixtures/stubs-for-mypy/emmett55/serializers.pyi b/fixtures/stubs-for-mypy/emmett55/serializers.pyi new file mode 100644 index 000000000000..c77611c4887c --- /dev/null +++ b/fixtures/stubs-for-mypy/emmett55/serializers.pyi @@ -0,0 +1,5 @@ +from typing import Any + +class Serializers: + @staticmethod + def get_for(name: str) -> Any: ... diff --git a/fixtures/stubs-for-mypy/google/__init__.pyi b/fixtures/stubs-for-mypy/google/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/fixtures/stubs-for-mypy/google/api_core/__init__.pyi b/fixtures/stubs-for-mypy/google/api_core/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/fixtures/stubs-for-mypy/google/api_core/exceptions.pyi b/fixtures/stubs-for-mypy/google/api_core/exceptions.pyi new file mode 100644 index 000000000000..85f45ee9021b --- /dev/null +++ b/fixtures/stubs-for-mypy/google/api_core/exceptions.pyi @@ -0,0 +1,28 @@ +from typing import Any + +class GoogleAPIError(Exception): + def __init__(self, *args: object) -> None: ... + +class GoogleAPICallError(GoogleAPIError): + code: int | None + def __init__( + self, + message: str, + errors: Any = (), + details: Any = (), + response: Any = None, + error_info: Any = None, + ) -> None: ... + +class ClientError(GoogleAPICallError): ... +class ServerError(GoogleAPICallError): ... +class NotFound(ClientError): ... +class InternalServerError(ServerError): ... +class BadGateway(ServerError): ... +class ServiceUnavailable(ServerError): ... +class GatewayTimeout(ServerError): ... +class DeadlineExceeded(ServerError): ... +class TooManyRequests(ClientError): ... + +class RetryError(GoogleAPIError): + def __init__(self, message: str, cause: BaseException | None = None) -> None: ... diff --git a/fixtures/stubs-for-mypy/google/api_core/retry.pyi b/fixtures/stubs-for-mypy/google/api_core/retry.pyi new file mode 100644 index 000000000000..fa197f11e41c --- /dev/null +++ b/fixtures/stubs-for-mypy/google/api_core/retry.pyi @@ -0,0 +1,18 @@ +from collections.abc import Callable +from typing import Any + +class Retry: + def __init__( + self, + predicate: Callable[[BaseException], bool] | None = None, + initial: float = ..., + maximum: float = ..., + multiplier: float = ..., + deadline: float | None = ..., + on_error: Callable[[BaseException], Any] | None = None, + ) -> None: ... + def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: ... + +def if_exception_type( + *exception_types: type[BaseException], +) -> Callable[[BaseException], bool]: ... diff --git a/fixtures/stubs-for-mypy/google/cloud/__init__.pyi b/fixtures/stubs-for-mypy/google/cloud/__init__.pyi new file mode 100644 index 000000000000..8a17abcee2b5 --- /dev/null +++ b/fixtures/stubs-for-mypy/google/cloud/__init__.pyi @@ -0,0 +1,3 @@ +from google.cloud import exceptions as exceptions + +from . import bigtable as bigtable diff --git a/fixtures/stubs-for-mypy/google/cloud/bigtable/__init__.pyi b/fixtures/stubs-for-mypy/google/cloud/bigtable/__init__.pyi new file mode 100644 index 000000000000..f7e981915fa3 --- /dev/null +++ b/fixtures/stubs-for-mypy/google/cloud/bigtable/__init__.pyi @@ -0,0 +1,11 @@ +from typing import Any + +from . import column_family as column_family +from .table import Table + +class Instance: + def table(self, table_id: str, app_profile_id: str | None = None, **kwargs: Any) -> Table: ... + +class Client: + def __init__(self, project: str | None = ..., admin: bool = ..., **kwargs: Any) -> None: ... + def instance(self, instance_id: str) -> Instance: ... diff --git a/fixtures/stubs-for-mypy/google/cloud/bigtable/column_family.pyi b/fixtures/stubs-for-mypy/google/cloud/bigtable/column_family.pyi new file mode 100644 index 000000000000..d9aad4b11ff9 --- /dev/null +++ b/fixtures/stubs-for-mypy/google/cloud/bigtable/column_family.pyi @@ -0,0 +1,4 @@ +from datetime import timedelta + +class MaxAgeGCRule: + def __init__(self, max_age: timedelta) -> None: ... diff --git a/fixtures/stubs-for-mypy/google/cloud/bigtable/row.pyi b/fixtures/stubs-for-mypy/google/cloud/bigtable/row.pyi new file mode 100644 index 000000000000..3746ebe7abde --- /dev/null +++ b/fixtures/stubs-for-mypy/google/cloud/bigtable/row.pyi @@ -0,0 +1,21 @@ +from datetime import datetime +from typing import Any + +class Cell: + timestamp: datetime + value: bytes + +class PartialRowData: + row_key: bytes + cells: dict[str, dict[bytes, list[Cell]]] + +class Row: + def delete(self) -> None: ... + def set_cell( + self, + column_family_id: str, + column: bytes, + value: bytes, + timestamp: datetime | None = None, + ) -> None: ... + def commit(self) -> Any: ... diff --git a/fixtures/stubs-for-mypy/google/cloud/bigtable/row_data.pyi b/fixtures/stubs-for-mypy/google/cloud/bigtable/row_data.pyi new file mode 100644 index 000000000000..ea62e16c8702 --- /dev/null +++ b/fixtures/stubs-for-mypy/google/cloud/bigtable/row_data.pyi @@ -0,0 +1,4 @@ +class Retry: + def with_timeout(self, timeout: float) -> Retry: ... + +DEFAULT_RETRY_READ_ROWS: Retry diff --git a/fixtures/stubs-for-mypy/google/cloud/bigtable/row_set.pyi b/fixtures/stubs-for-mypy/google/cloud/bigtable/row_set.pyi new file mode 100644 index 000000000000..5fa3def8b500 --- /dev/null +++ b/fixtures/stubs-for-mypy/google/cloud/bigtable/row_set.pyi @@ -0,0 +1,2 @@ +class RowSet: + def add_row_key(self, row_key: bytes | str) -> None: ... diff --git a/fixtures/stubs-for-mypy/google/cloud/bigtable/table.pyi b/fixtures/stubs-for-mypy/google/cloud/bigtable/table.pyi new file mode 100644 index 000000000000..549e88bc0729 --- /dev/null +++ b/fixtures/stubs-for-mypy/google/cloud/bigtable/table.pyi @@ -0,0 +1,27 @@ +from collections.abc import Iterable +from typing import Any + +from .row import PartialRowData, Row +from .row_set import RowSet + +class Table: + def read_row( + self, row_key: str, filter_: Any = None, retry: Any = None + ) -> PartialRowData | None: ... + def read_rows( + self, + start_key: str | None = None, + end_key: str | None = None, + limit: int | None = None, + filter_: Any = None, + end_inclusive: bool = False, + row_set: RowSet | None = None, + retry: Any = None, + ) -> Iterable[PartialRowData]: ... + def direct_row(self, key: str) -> Row: ... + def mutate_rows( + self, rows: Iterable[Row], retry: Any = None, timeout: float | None = None + ) -> Iterable[Any]: ... + def exists(self) -> bool: ... + def create(self, column_families: dict[str, Any] | None = None) -> None: ... + def delete(self) -> None: ... diff --git a/fixtures/stubs-for-mypy/google/cloud/exceptions.pyi b/fixtures/stubs-for-mypy/google/cloud/exceptions.pyi new file mode 100644 index 000000000000..60c1e52aa684 --- /dev/null +++ b/fixtures/stubs-for-mypy/google/cloud/exceptions.pyi @@ -0,0 +1,9 @@ +class GoogleCloudError(Exception): + code: int | None + message: str + def __init__(self, message: str, errors: object = ..., response: object = ...) -> None: ... + +class NotFound(GoogleCloudError): ... +class Forbidden(GoogleCloudError): ... +class Conflict(GoogleCloudError): ... +class PreconditionFailed(GoogleCloudError): ... diff --git a/fixtures/stubs-for-mypy/google/cloud/storage/__init__.pyi b/fixtures/stubs-for-mypy/google/cloud/storage/__init__.pyi new file mode 100644 index 000000000000..7f84f00cc9fc --- /dev/null +++ b/fixtures/stubs-for-mypy/google/cloud/storage/__init__.pyi @@ -0,0 +1,3 @@ +from google.cloud.storage.blob import Blob as Blob +from google.cloud.storage.bucket import Bucket as Bucket +from google.cloud.storage.client import Client as Client diff --git a/fixtures/stubs-for-mypy/google/cloud/storage/blob.pyi b/fixtures/stubs-for-mypy/google/cloud/storage/blob.pyi new file mode 100644 index 000000000000..b98b11b5b61e --- /dev/null +++ b/fixtures/stubs-for-mypy/google/cloud/storage/blob.pyi @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import IO, Any + +from google.cloud.storage.bucket import Bucket + +class Blob: + name: str + size: int | None + updated: datetime + public_url: str + path: str + generation: int | None + content_type: str | None + def __init__(self, name: str, bucket: Bucket, **kwargs: Any) -> None: ... + def reload(self, client: Any = None, timeout: float = ...) -> None: ... + def upload_from_file( + self, + file_obj: IO[bytes], + size: int | None = None, + content_type: str | None = None, + **kwargs: Any, + ) -> None: ... + def upload_from_string( + self, data: bytes | str, content_type: str | None = None, **kwargs: Any + ) -> None: ... + def download_to_file(self, file_obj: IO[bytes], **kwargs: Any) -> None: ... + def download_as_bytes(self, **kwargs: Any) -> bytes: ... + def exists(self, client: Any = None) -> bool: ... + def delete(self, client: Any = None) -> None: ... diff --git a/fixtures/stubs-for-mypy/google/cloud/storage/bucket.pyi b/fixtures/stubs-for-mypy/google/cloud/storage/bucket.pyi new file mode 100644 index 000000000000..694e232e0be5 --- /dev/null +++ b/fixtures/stubs-for-mypy/google/cloud/storage/bucket.pyi @@ -0,0 +1,15 @@ +from collections.abc import Iterable +from typing import Any + +from google.cloud.storage.blob import Blob +from google.cloud.storage.client import Client + +class Bucket: + name: str + def __init__(self, client: Client, name: str | None = None, **kwargs: Any) -> None: ... + def blob(self, blob_name: str, **kwargs: Any) -> Blob: ... + def get_blob(self, blob_name: str, **kwargs: Any) -> Blob | None: ... + def delete_blob(self, blob_name: str, **kwargs: Any) -> None: ... + def list_blobs( + self, prefix: str | None = None, delimiter: str | None = None, **kwargs: Any + ) -> Iterable[Blob]: ... diff --git a/fixtures/stubs-for-mypy/google/cloud/storage/client.pyi b/fixtures/stubs-for-mypy/google/cloud/storage/client.pyi new file mode 100644 index 000000000000..41a808936147 --- /dev/null +++ b/fixtures/stubs-for-mypy/google/cloud/storage/client.pyi @@ -0,0 +1,13 @@ +from typing import Any + +from google.auth.credentials import Credentials + +class Client: + _http: Any + project: str | None + def __init__( + self, + project: str | None = None, + credentials: Credentials | None = None, + **kwargs: Any, + ) -> None: ... diff --git a/fixtures/stubs-for-mypy/selenium/__init__.pyi b/fixtures/stubs-for-mypy/selenium/__init__.pyi new file mode 100644 index 000000000000..930c918f422b --- /dev/null +++ b/fixtures/stubs-for-mypy/selenium/__init__.pyi @@ -0,0 +1 @@ +from selenium import webdriver as webdriver diff --git a/fixtures/stubs-for-mypy/selenium/common/__init__.pyi b/fixtures/stubs-for-mypy/selenium/common/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/fixtures/stubs-for-mypy/selenium/common/exceptions.pyi b/fixtures/stubs-for-mypy/selenium/common/exceptions.pyi new file mode 100644 index 000000000000..ce18d1239209 --- /dev/null +++ b/fixtures/stubs-for-mypy/selenium/common/exceptions.pyi @@ -0,0 +1,11 @@ +from typing import Any + +class WebDriverException(Exception): + msg: str | None + def __init__( + self, msg: str | None = None, screen: Any = None, stacktrace: Any = None + ) -> None: ... + +class NoSuchElementException(WebDriverException): ... +class SessionNotCreatedException(WebDriverException): ... +class TimeoutException(WebDriverException): ... diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/__init__.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/__init__.pyi new file mode 100644 index 000000000000..ce23c2a1a378 --- /dev/null +++ b/fixtures/stubs-for-mypy/selenium/webdriver/__init__.pyi @@ -0,0 +1,14 @@ +from typing import Any + +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.chrome.service import Service as ChromeService +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.remote.webelement import WebElement as WebElement + +class ChromeOptions(Options): ... + +class Chrome(WebDriver): + def __init__(self, **kwargs: Any) -> None: ... + +class Firefox(WebDriver): + def __init__(self, **kwargs: Any) -> None: ... diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/chrome/__init__.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/chrome/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/chrome/options.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/chrome/options.pyi new file mode 100644 index 000000000000..86ebf9aba553 --- /dev/null +++ b/fixtures/stubs-for-mypy/selenium/webdriver/chrome/options.pyi @@ -0,0 +1,8 @@ +from typing import Any + +class Options: + binary_location: str + headless: bool + def add_argument(self, argument: str) -> None: ... + def add_experimental_option(self, name: str, value: object) -> None: ... + def __getattr__(self, name: str) -> Any: ... diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/chrome/service.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/chrome/service.pyi new file mode 100644 index 000000000000..dee2aebadefa --- /dev/null +++ b/fixtures/stubs-for-mypy/selenium/webdriver/chrome/service.pyi @@ -0,0 +1,5 @@ +from typing import Any + +class Service: + def __init__(self, executable_path: str | None = None, **kwargs: Any) -> None: ... + def __getattr__(self, name: str) -> Any: ... diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/common/__init__.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/common/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/common/action_chains.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/common/action_chains.pyi new file mode 100644 index 000000000000..aea83bc5893f --- /dev/null +++ b/fixtures/stubs-for-mypy/selenium/webdriver/common/action_chains.pyi @@ -0,0 +1,15 @@ +from typing import Any, Self + +from selenium.webdriver.remote.webelement import WebElement + +class ActionChains: + def __init__(self, driver: Any, duration: int = 250, devices: Any = None) -> None: ... + def move_to_element(self, to_element: WebElement) -> Self: ... + def click(self, on_element: WebElement | None = None) -> Self: ... + def send_keys(self, *keys_to_send: str) -> Self: ... + def key_down(self, value: str, element: WebElement | None = None) -> Self: ... + def key_up(self, value: str, element: WebElement | None = None) -> Self: ... + def pause(self, seconds: float) -> Self: ... + def perform(self) -> None: ... + def reset_actions(self) -> None: ... + def __getattr__(self, name: str) -> Any: ... diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/common/by.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/common/by.pyi new file mode 100644 index 000000000000..fbdf77bd6a97 --- /dev/null +++ b/fixtures/stubs-for-mypy/selenium/webdriver/common/by.pyi @@ -0,0 +1,9 @@ +class By: + ID: str + XPATH: str + LINK_TEXT: str + PARTIAL_LINK_TEXT: str + NAME: str + TAG_NAME: str + CLASS_NAME: str + CSS_SELECTOR: str diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/common/keys.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/common/keys.pyi new file mode 100644 index 000000000000..b98cae2a32f3 --- /dev/null +++ b/fixtures/stubs-for-mypy/selenium/webdriver/common/keys.pyi @@ -0,0 +1,12 @@ +class Keys: + ENTER: str + BACKSPACE: str + END: str + ESCAPE: str + TAB: str + RETURN: str + SPACE: str + ARROW_UP: str + ARROW_DOWN: str + ARROW_LEFT: str + ARROW_RIGHT: str diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/remote/__init__.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/remote/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/remote/switch_to.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/remote/switch_to.pyi new file mode 100644 index 000000000000..2222ff2d082b --- /dev/null +++ b/fixtures/stubs-for-mypy/selenium/webdriver/remote/switch_to.pyi @@ -0,0 +1,12 @@ +from typing import Any + +from selenium.webdriver.remote.webelement import WebElement + +class SwitchTo: + def window(self, window_name: str) -> None: ... + def frame(self, frame_reference: str | int | WebElement) -> None: ... + def new_window(self, type_hint: str | None = None) -> None: ... + def default_content(self) -> None: ... + def parent_frame(self) -> None: ... + def alert(self) -> Any: ... + def __getattr__(self, name: str) -> Any: ... diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/remote/switches.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/remote/switches.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/remote/webdriver.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/remote/webdriver.pyi new file mode 100644 index 000000000000..f08cc7f91e34 --- /dev/null +++ b/fixtures/stubs-for-mypy/selenium/webdriver/remote/webdriver.pyi @@ -0,0 +1,25 @@ +from collections.abc import Sequence +from typing import Any + +from selenium.webdriver.remote.switch_to import SwitchTo +from selenium.webdriver.remote.webelement import WebElement + +class WebDriver: + current_url: str + title: str + current_window_handle: str + window_handles: Sequence[str] + switch_to: SwitchTo + def get(self, url: str) -> None: ... + def find_element(self, by: str = ..., value: str | None = ...) -> WebElement: ... + def find_elements(self, by: str = ..., value: str | None = ...) -> list[WebElement]: ... + def close(self) -> None: ... + def quit(self) -> None: ... + def refresh(self) -> None: ... + def execute_script(self, script: str, *args: Any) -> Any: ... + def execute_cdp_cmd(self, cmd: str, cmd_args: dict[str, Any]) -> dict[str, Any]: ... + def get_window_size(self, window_handle: str = ...) -> dict[str, int]: ... + def set_window_size(self, width: int, height: int, window_handle: str = ...) -> None: ... + def get_log(self, log_type: str) -> list[dict[str, Any]]: ... + def implicitly_wait(self, duration: float) -> None: ... + def __getattr__(self, name: str) -> Any: ... diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/remote/webelement.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/remote/webelement.pyi new file mode 100644 index 000000000000..29073f177b65 --- /dev/null +++ b/fixtures/stubs-for-mypy/selenium/webdriver/remote/webelement.pyi @@ -0,0 +1,18 @@ +from collections.abc import Sequence +from typing import Any + +class WebElement: + text: str + tag_name: str + def click(self) -> None: ... + def submit(self) -> None: ... + def clear(self) -> None: ... + def send_keys(self, *value: str) -> None: ... + def get_attribute(self, name: str) -> str | None: ... + def is_displayed(self) -> bool: ... + def is_enabled(self) -> bool: ... + def value_of_css_property(self, property_name: str) -> str: ... + def find_element(self, by: str = ..., value: str | None = ...) -> WebElement: ... + def find_elements(self, by: str = ..., value: str | None = ...) -> list[WebElement]: ... + def get_property(self, name: str) -> Any: ... + def __getattr__(self, name: str) -> Any: ... diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/support/__init__.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/support/__init__.pyi new file mode 100644 index 000000000000..1d6b6f5decd8 --- /dev/null +++ b/fixtures/stubs-for-mypy/selenium/webdriver/support/__init__.pyi @@ -0,0 +1 @@ +from selenium.webdriver.support import expected_conditions as expected_conditions diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/support/expected_conditions.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/support/expected_conditions.pyi new file mode 100644 index 000000000000..72f6f8d37cb9 --- /dev/null +++ b/fixtures/stubs-for-mypy/selenium/webdriver/support/expected_conditions.pyi @@ -0,0 +1,20 @@ +from collections.abc import Callable +from typing import Any + +from selenium.webdriver.remote.webelement import WebElement +from selenium.webdriver.remote.webdriver import WebDriver + +def presence_of_element_located( + locator: tuple[str, str], +) -> Callable[[WebDriver], WebElement]: ... +def element_to_be_clickable( + element: tuple[str, str] | WebElement, +) -> Callable[[WebDriver], WebElement | bool]: ... +def text_to_be_present_in_element( + locator: tuple[str, str], text_: str +) -> Callable[[WebDriver], bool]: ... +def title_is(title: str) -> Callable[[WebDriver], bool]: ... +def title_contains(title: str) -> Callable[[WebDriver], bool]: ... +def staleness_of(element: WebElement) -> Callable[[WebDriver], bool]: ... +def number_of_windows_to_be(num_windows: int) -> Callable[[WebDriver], bool]: ... +def alert_is_present() -> Callable[[WebDriver], Any]: ... diff --git a/fixtures/stubs-for-mypy/selenium/webdriver/support/wait.pyi b/fixtures/stubs-for-mypy/selenium/webdriver/support/wait.pyi new file mode 100644 index 000000000000..f5dfa83d7e04 --- /dev/null +++ b/fixtures/stubs-for-mypy/selenium/webdriver/support/wait.pyi @@ -0,0 +1,17 @@ +from collections.abc import Callable, Sequence +from typing import Any, TypeVar + +from selenium.webdriver.remote.webdriver import WebDriver + +T = TypeVar("T") + +class WebDriverWait: + def __init__( + self, + driver: WebDriver, + timeout: float, + poll_frequency: float = 0.5, + ignored_exceptions: Sequence[type[Exception]] | None = None, + ) -> None: ... + def until(self, method: Callable[[WebDriver], T], message: str = "") -> T: ... + def until_not(self, method: Callable[[WebDriver], T], message: str = "") -> T: ... diff --git a/fixtures/stubs-for-mypy/sentry_relay/__init__.pyi b/fixtures/stubs-for-mypy/sentry_relay/__init__.pyi new file mode 100644 index 000000000000..28dfa7d0e2f8 --- /dev/null +++ b/fixtures/stubs-for-mypy/sentry_relay/__init__.pyi @@ -0,0 +1,5 @@ +from sentry_relay import auth as auth +from sentry_relay import consts as consts +from sentry_relay import exceptions as exceptions +from sentry_relay import processing as processing +from sentry_relay import utils as utils diff --git a/fixtures/stubs-for-mypy/sentry_relay/_lowlevel.pyi b/fixtures/stubs-for-mypy/sentry_relay/_lowlevel.pyi new file mode 100644 index 000000000000..db83f9cbe71b --- /dev/null +++ b/fixtures/stubs-for-mypy/sentry_relay/_lowlevel.pyi @@ -0,0 +1,4 @@ +from typing import Any + +lib: Any +ffi: Any diff --git a/fixtures/stubs-for-mypy/sentry_relay/auth.pyi b/fixtures/stubs-for-mypy/sentry_relay/auth.pyi new file mode 100644 index 000000000000..53b82c124e30 --- /dev/null +++ b/fixtures/stubs-for-mypy/sentry_relay/auth.pyi @@ -0,0 +1,41 @@ +import json +from collections.abc import Callable +from typing import Any + +from sentry_relay.utils import RustObject + +class PublicKey(RustObject): + @classmethod + def parse(cls, string: str) -> PublicKey: ... + def verify(self, buf: bytes, sig: str, max_age: int | None = None) -> bool: ... + def unpack( + self, + buf: bytes, + sig: str, + max_age: int | None = None, + json_loads: Callable[[str | bytes], Any] = json.loads, + ) -> Any: ... + +class SecretKey(RustObject): + @classmethod + def parse(cls, string: str) -> SecretKey: ... + def sign(self, value: bytes) -> str: ... + def pack(self, data: Any) -> tuple[bytes, str]: ... + +def generate_key_pair() -> tuple[SecretKey, PublicKey]: ... +def generate_relay_id() -> Any: ... +def create_register_challenge( + data: bytes, + signature: str, + secret: str, + max_age: int = 60, + json_loads: Callable[[str | bytes], Any] = json.loads, +) -> dict[str, Any]: ... +def validate_register_response( + data: bytes, + signature: str, + secret: str, + max_age: int = 60, + json_loads: Callable[[str | bytes], Any] = json.loads, +) -> dict[str, Any]: ... +def is_version_supported(version: str | None) -> bool: ... diff --git a/fixtures/stubs-for-mypy/sentry_relay/consts.pyi b/fixtures/stubs-for-mypy/sentry_relay/consts.pyi new file mode 100644 index 000000000000..d06d78ef5bda --- /dev/null +++ b/fixtures/stubs-for-mypy/sentry_relay/consts.pyi @@ -0,0 +1,60 @@ +from enum import IntEnum + +class CategoryUnit(IntEnum): + COUNT = 0 + BYTES = 1 + MILLISECONDS = 2 + +class DataCategory(IntEnum): + DEFAULT = 0 + ERROR = 1 + TRANSACTION = 2 + SECURITY = 3 + ATTACHMENT = 4 + SESSION = 5 + PROFILE = 6 + REPLAY = 7 + TRANSACTION_PROCESSED = 8 + TRANSACTION_INDEXED = 9 + MONITOR = 10 + PROFILE_INDEXED = 11 + SPAN = 12 + MONITOR_SEAT = 13 + USER_REPORT_V2 = 14 + METRIC_BUCKET = 15 + SPAN_INDEXED = 16 + PROFILE_DURATION = 17 + PROFILE_CHUNK = 18 + METRIC_SECOND = 19 + DO_NOT_USE_REPLAY_VIDEO = 20 + UPTIME = 21 + ATTACHMENT_ITEM = 22 + LOG_ITEM = 23 + LOG_BYTE = 24 + PROFILE_DURATION_UI = 25 + PROFILE_CHUNK_UI = 26 + SEER_AUTOFIX = 27 + SEER_SCANNER = 28 + PREVENT_USER = 29 + PREVENT_REVIEW = 30 + SIZE_ANALYSIS = 31 + INSTALLABLE_BUILD = 32 + TRACE_METRIC = 33 + SEER_USER = 34 + PROFILE_BACKEND = 35 + PROFILE_UI = 36 + TRACE_METRIC_BYTE = 37 + SNAPSHOT_IMAGE = 38 + UNKNOWN = -1 + @classmethod + def parse(cls, name: str | None) -> DataCategory | None: ... + @classmethod + def from_event_type(cls, event_type: str | None) -> DataCategory: ... + @classmethod + def event_categories(cls) -> list[DataCategory]: ... + @classmethod + def error_categories(cls) -> list[DataCategory]: ... + def api_name(self) -> str: ... + +SPAN_STATUS_CODE_TO_NAME: dict[int, str] +SPAN_STATUS_NAME_TO_CODE: dict[str, int] diff --git a/fixtures/stubs-for-mypy/sentry_relay/exceptions.pyi b/fixtures/stubs-for-mypy/sentry_relay/exceptions.pyi new file mode 100644 index 000000000000..57496a0f53f0 --- /dev/null +++ b/fixtures/stubs-for-mypy/sentry_relay/exceptions.pyi @@ -0,0 +1,12 @@ +from typing import Any + +exceptions_by_code: dict[int, type[Exception]] + +class RelayError(Exception): + code: int | None + message: str + rust_info: str | None + +class UnpackError(RelayError): ... +class UnpackErrorSignatureExpired(UnpackError): ... +class InvalidReleaseError(RelayError): ... diff --git a/fixtures/stubs-for-mypy/sentry_relay/processing.pyi b/fixtures/stubs-for-mypy/sentry_relay/processing.pyi new file mode 100644 index 000000000000..d70d7dd7898c --- /dev/null +++ b/fixtures/stubs-for-mypy/sentry_relay/processing.pyi @@ -0,0 +1,62 @@ +import json +from collections.abc import Callable, Mapping +from typing import Any + +from sentry_relay.utils import RustObject + +VALID_PLATFORMS: frozenset[str] + +class GeoIpLookup(RustObject): + @classmethod + def from_path(cls, path: str) -> GeoIpLookup: ... + +class StoreNormalizer(RustObject): + def __new__(cls, **config: Any) -> StoreNormalizer: ... + def normalize_event( + self, + event: Any = None, + raw_event: Any = None, + json_loads: Callable[[str | bytes], Any] = json.loads, + ) -> dict[str, Any]: ... + +def split_chunks( + string: str, + remarks: Mapping[str, Any], + json_dumps: Callable[[Any], Any] = json.dumps, + json_loads: Callable[[str | bytes], Any] = json.loads, +) -> list[str]: ... +def meta_with_chunks(data: Any, meta: Any) -> Any: ... +def parse_release( + release: str, json_loads: Callable[[str | bytes], Any] = json.loads +) -> dict[str, Any]: ... +def compare_version(a: str, b: str, semver_precedence: bool = False) -> int: ... +def validate_rule_condition(condition: str) -> None: ... +def validate_sampling_condition(condition: str) -> None: ... +def validate_pii_selector(selector: str) -> None: ... +def validate_pii_config(config: str) -> None: ... +def convert_datascrubbing_config( + config: Mapping[str, Any], + json_dumps: Callable[[Any], Any] = json.dumps, + json_loads: Callable[[str | bytes], Any] = json.loads, +) -> dict[str, Any]: ... +def pii_strip_event( + config: Mapping[str, Any], + event: Mapping[str, Any], + json_dumps: Callable[[Any], Any] = json.dumps, + json_loads: Callable[[str | bytes], Any] = json.loads, +) -> dict[str, Any]: ... +def pii_selector_suggestions_from_event( + event: Mapping[str, Any], + json_dumps: Callable[[Any], Any] = json.dumps, + json_loads: Callable[[str | bytes], Any] = json.loads, +) -> list[Any]: ... +def normalize_project_config( + config: Any, + json_dumps: Callable[[Any], Any] = json.dumps, + json_loads: Callable[[str | bytes], Any] = json.loads, +) -> dict[str, Any]: ... +def normalize_global_config( + config: Any, + json_dumps: Callable[[Any], Any] = json.dumps, + json_loads: Callable[[str | bytes], Any] = json.loads, +) -> dict[str, Any]: ... diff --git a/fixtures/stubs-for-mypy/sentry_relay/utils.pyi b/fixtures/stubs-for-mypy/sentry_relay/utils.pyi new file mode 100644 index 000000000000..3a0c707f654d --- /dev/null +++ b/fixtures/stubs-for-mypy/sentry_relay/utils.pyi @@ -0,0 +1,16 @@ +from typing import Any + +from sentry_relay._lowlevel import lib as lib + +class RustObject: + def __init__(self) -> None: ... + @classmethod + def _from_objptr(cls, ptr: Any, shared: bool = False) -> Any: ... + def _methodcall(self, func: Any, *args: Any) -> Any: ... + def _get_objptr(self) -> Any: ... + +def rustcall(func: Any, *args: Any) -> Any: ... +def decode_str(s: Any, free: bool = False) -> str: ... +def encode_str(s: str | bytes, mutable: bool = False) -> Any: ... +def make_buf(value: bytes) -> Any: ... +def decode_uuid(value: Any) -> Any: ... diff --git a/fixtures/stubs-for-mypy/slack_sdk/errors/__init__.pyi b/fixtures/stubs-for-mypy/slack_sdk/errors/__init__.pyi new file mode 100644 index 000000000000..a2adcc73a76b --- /dev/null +++ b/fixtures/stubs-for-mypy/slack_sdk/errors/__init__.pyi @@ -0,0 +1,7 @@ +from typing import Any + +class SlackClientError(Exception): ... + +class SlackApiError(SlackClientError): + response: Any + def __init__(self, message: str, response: Any) -> None: ... diff --git a/fixtures/sudo_testutils.py b/fixtures/sudo_testutils.py index 577994409d24..227a38ebee7d 100644 --- a/fixtures/sudo_testutils.py +++ b/fixtures/sudo_testutils.py @@ -1,22 +1,31 @@ +from typing import Any, cast + from django.contrib.auth.models import AnonymousUser, User +from django.http import HttpRequest from django.test import RequestFactory, TestCase +from sentry.auth.principal import set_request_principal + class BaseTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: self.request = self.get("/foo") - self.request.session = {} + self.request.session = cast("Any", {}) self.setUser(AnonymousUser()) - def get(self, *args, **kwargs): + # justification: passthrough to RequestFactory's overloaded request signatures. + def get(self, *args: Any, **kwargs: Any) -> HttpRequest: return RequestFactory().get(*args, **kwargs) - def post(self, *args, **kwargs): + # justification: passthrough to RequestFactory's overloaded request signatures. + def post(self, *args: Any, **kwargs: Any) -> HttpRequest: return RequestFactory().post(*args, **kwargs) - def setUser(self, user): - self.user = self.request.user = user + # justification: accepts any Django auth user class supplied by callers. + def setUser(self, user: Any) -> None: + self.user = user + set_request_principal(self.request, user, None) - def login(self, user_class=User): + def login(self, user_class: type[User] = User) -> None: user = user_class() self.setUser(user) diff --git a/fixtures/vsts.py b/fixtures/vsts.py index 557419ee0e11..6f8483e4901a 100644 --- a/fixtures/vsts.py +++ b/fixtures/vsts.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import Iterator from typing import Any -from urllib.parse import parse_qs, urlparse +from urllib.parse import ParseResult, parse_qs, urlparse import pytest import responses @@ -17,7 +18,7 @@ class VstsIntegrationTestCase(IntegrationTestCase): - provider = VstsIntegrationProvider() + provider = VstsIntegrationProvider def _get_integration_and_install(self) -> tuple[Integration, VstsIntegration]: integration = Integration.objects.get(provider="vsts") @@ -29,7 +30,7 @@ def _get_integration_and_install(self) -> tuple[Integration, VstsIntegration]: return integration, installation @pytest.fixture(autouse=True) - def setup_data(self): + def setup_data(self) -> Iterator[None]: self.access_token = "9d646e20-7a62-4bcc-abc0-cb2d4d075e36" self.refresh_token = "32004633-a3c0-4616-9aa0-a40632adac77" @@ -53,7 +54,7 @@ def setup_data(self): self._stub_vsts() yield - def _stub_vsts(self): + def _stub_vsts(self) -> None: responses.reset() responses.add( @@ -195,7 +196,7 @@ def _stub_vsts(self): }, ) - def _pipeline_url(self): + def _pipeline_url(self) -> str: return reverse( "sentry-api-0-organization-pipeline", kwargs={ @@ -204,13 +205,13 @@ def _pipeline_url(self): }, ) - def assert_vsts_new_oauth_redirect(self, redirect): + def assert_vsts_new_oauth_redirect(self, redirect: ParseResult) -> None: assert redirect.scheme == "https" assert redirect.netloc == "login.microsoftonline.com" assert redirect.path == "/common/oauth2/v2.0/authorize" @assume_test_silo_mode(SiloMode.CONTROL) - def assert_installation(self, account_id=None): + def assert_installation(self, account_id: str | None = None) -> None: # Drives the API pipeline: initialize -> oauth -> account selection. account_id = account_id or self.vsts_account_id pipeline_url = self._pipeline_url() @@ -234,7 +235,7 @@ def assert_installation(self, account_id=None): assert resp.data["step"] == "account_selection" resp = self.client.post(pipeline_url, data={"account": account_id}, format="json") - return resp + assert resp.status_code == 200, resp.content COMPARE_COMMITS_EXAMPLE = b""" diff --git a/pyproject.toml b/pyproject.toml index 48545dd82b28..e5a1f964b954 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -340,18 +340,10 @@ plugins = ["pydantic.mypy", "mypy_django_plugin.main", "tools.mypy_helpers.plugi files = ["."] exclude = ["^.venv/", "^venv/", "^self-hosted/", "^bin/"] -# minimal strictness settings -check_untyped_defs = true -no_implicit_reexport = true +strict = true warn_unreachable = true -warn_unused_configs = true -warn_unused_ignores = true -warn_redundant_casts = true enable_error_code = ["ignore-without-code", "redundant-self"] local_partial_types = true # compat with dmypy -disallow_any_generics = true -disallow_untyped_defs = true -strict_equality = true [tool.django-stubs] django_settings_module = "sentry.conf.server_mypy" @@ -375,7 +367,6 @@ module = [ "boto3.*", "botocore.client.*", "botocore.exceptions.*", - "confluent_kafka.*", "cssselect.*", "django_zero_downtime_migrations.backends.postgres.schema.*", "emmett55.*", @@ -387,12 +378,10 @@ module = [ "google.resumable_media.common.*", "google.rpc.status_pb2.*", "google.type.*", - "mistune.*", "onelogin.saml2.auth.*", "onelogin.saml2.constants.*", "onelogin.saml2.idp_metadata_parser.*", "pymemcache.*", - "P4", "rb.*", "statsd.*", "tokenizers.*", @@ -400,1809 +389,3 @@ module = [ ] ignore_missing_imports = true # end: missing 3rd party stubs - -# python3 -m tools.mypy_helpers.make_module_ignores -# begin: sentry modules with typing issues -# - remove the module from the list and fix the issues! -# - python3 -m tools.mypy_helpers.find_easiest_modules -[[tool.mypy.overrides]] -module = [ - "sentry.snuba.metrics.query_builder", - "sentry.testutils.cases", -] -disable_error_code = [ - "arg-type", - "assignment", - "attr-defined", - "misc", - "union-attr", -] -# end: sentry modules with typing issues - -# begin: not yet strongly typed -# - Moving a module from the "typing issues" list above to here is already a valuable improvement! -# - To fully resolve: remove the module from this list once it is fully typed. -# - python3 -m tools.mypy_helpers.find_easiest_modules -[[tool.mypy.overrides]] -module = [ - "bitfield.*", - "fixtures.apidocs_test_case", - "fixtures.gitlab", - "fixtures.integrations.jira.mock", - "fixtures.integrations.jira.stub_client", - "fixtures.integrations.mock_service", - "fixtures.integrations.stub_service", - "fixtures.page_objects.base", - "fixtures.page_objects.dashboard_detail", - "fixtures.page_objects.explore_logs", - "fixtures.page_objects.explore_spans", - "fixtures.page_objects.global_selection", - "fixtures.page_objects.issue_details", - "fixtures.page_objects.issue_list", - "fixtures.page_objects.organization_integration_settings", - "fixtures.page_objects.trace_view", - "fixtures.page_objects.transaction_summary", - "fixtures.sdk_crash_detection.crash_event_android", - "fixtures.sdk_crash_detection.crash_event_cocoa", - "fixtures.sdk_crash_detection.crash_event_dart", - "fixtures.sdk_crash_detection.crash_event_dotnet", - "fixtures.sdk_crash_detection.crash_event_native", - "fixtures.sdk_crash_detection.crash_event_react_native", - "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", - "sentry.api.endpoints", - "sentry.api.endpoints.accept_organization_invite", - "sentry.api.endpoints.accept_project_transfer", - "sentry.api.endpoints.admin_project_configs", - "sentry.api.endpoints.api_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", - "sentry.api.endpoints.organization_metrics_meta", - "sentry.api.endpoints.organization_missing_org_members", - "sentry.api.endpoints.organization_on_demand_metrics_estimation_stats", - "sentry.api.endpoints.organization_onboarding_continuation_email", - "sentry.api.endpoints.organization_onboarding_tasks", - "sentry.api.endpoints.organization_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", - "sentry.api.endpoints.organization_trace_item_stats", - "sentry.api.endpoints.organization_trace_logs", - "sentry.api.endpoints.organization_trace_meta", - "sentry.api.endpoints.organization_traces", - "sentry.api.endpoints.organization_unsubscribe", - "sentry.api.endpoints.project_artifact_bundle_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", - "sentry.api.endpoints.project_symbol_sources", - "sentry.api.endpoints.project_tagkey_details", - "sentry.api.endpoints.project_tagkey_values", - "sentry.api.endpoints.project_tags", - "sentry.api.endpoints.project_trace_item_details", - "sentry.api.endpoints.project_transaction_names", - "sentry.api.endpoints.project_transaction_threshold", - "sentry.api.endpoints.project_transaction_threshold_override", - "sentry.api.endpoints.prompts_activity", - "sentry.api.endpoints.relay.*", - "sentry.api.endpoints.seer_models", - "sentry.api.endpoints.setup_wizard", - "sentry.api.endpoints.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", - "sentry.api.serializers.models.auth_provider", - "sentry.api.serializers.models.broadcast", - "sentry.api.serializers.models.commit", - "sentry.api.serializers.models.dashboard", - "sentry.api.serializers.models.debug_file", - "sentry.api.serializers.models.deploy", - "sentry.api.serializers.models.discoversavedquery", - "sentry.api.serializers.models.environment", - "sentry.api.serializers.models.event", - "sentry.api.serializers.models.eventattachment", - "sentry.api.serializers.models.eventuser", - "sentry.api.serializers.models.exploresavedquery", - "sentry.api.serializers.models.exporteddata", - "sentry.api.serializers.models.filechange", - "sentry.api.serializers.models.group", - "sentry.api.serializers.models.group_stream", - "sentry.api.serializers.models.grouprelease", - "sentry.api.serializers.models.groupsearchview", - "sentry.api.serializers.models.groupsearchviewstarred", - "sentry.api.serializers.models.groupseen", - "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", - "sentry.api.serializers.rest_framework.dashboard", - "sentry.api.serializers.rest_framework.environment", - "sentry.api.serializers.rest_framework.groupsearchview", - "sentry.api.serializers.rest_framework.list", - "sentry.api.serializers.rest_framework.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", - "sentry.audit_log.events", - "sentry.audit_log.manager", - "sentry.audit_log.register", - "sentry.backup.*", - "sentry.billing.*", - "sentry.cache.*", - "sentry.conduit.*", - "sentry.conf.*", - "sentry.constants", - "sentry.consumers.*", - "sentry.core.*", - "sentry.culprit", - "sentry.db", - "sentry.db.analyze", - "sentry.db.exceptions", - "sentry.db.models", - "sentry.db.models.base", - "sentry.db.models.fields", - "sentry.db.models.fields.array", - "sentry.db.models.fields.bounded", - "sentry.db.models.fields.encryption.*", - "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", - "sentry.discover.dataset_split", - "sentry.discover.endpoints.*", - "sentry.discover.models", - "sentry.discover.translation.*", - "sentry.event_manager", - "sentry.exceptions", - "sentry.explore", - "sentry.explore.endpoints.*", - "sentry.explore.models", - "sentry.explore.translation.*", - "sentry.explore.utils", - "sentry.features.*", - "sentry.feedback", - "sentry.feedback.apps", - "sentry.feedback.endpoints.*", - "sentry.feedback.lib.*", - "sentry.feedback.usecases.*", - "sentry.flags", - "sentry.flags.endpoints.*", - "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", - "sentry.grouping.strategies.*", - "sentry.http", - "sentry.identity", - "sentry.identity.base", - "sentry.identity.github.*", - "sentry.identity.gitlab.*", - "sentry.identity.google.*", - "sentry.identity.manager", - "sentry.identity.oauth2", - "sentry.identity.pipeline", - "sentry.identity.providers.*", - "sentry.identity.slack.*", - "sentry.identity.vsts.*", - "sentry.incidents.endpoints.serializers.*", - "sentry.incidents.serializers.*", - "sentry.ingest", - "sentry.ingest.consumer.*", - "sentry.ingest.inbound_filters", - "sentry.ingest.transaction_clusterer.*", - "sentry.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", - "sentry.integrations.api.endpoints.external_team_index", - "sentry.integrations.api.endpoints.external_user_details", - "sentry.integrations.api.endpoints.external_user_index", - "sentry.integrations.api.endpoints.integration_features", - "sentry.integrations.api.endpoints.integration_proxy", - "sentry.integrations.api.endpoints.organization_code_mapping_codeowners", - "sentry.integrations.api.endpoints.organization_code_mapping_details", - "sentry.integrations.api.endpoints.organization_code_mappings", - "sentry.integrations.api.endpoints.organization_coding_agents", - "sentry.integrations.api.endpoints.organization_config_integrations", - "sentry.integrations.api.endpoints.organization_integration_channel_validate", - "sentry.integrations.api.endpoints.organization_integration_channels", - "sentry.integrations.api.endpoints.organization_integration_details", - "sentry.integrations.api.endpoints.organization_integration_direct_enable", - "sentry.integrations.api.endpoints.organization_integration_issues", - "sentry.integrations.api.endpoints.organization_integration_repo_sync", - "sentry.integrations.api.endpoints.organization_integration_repos", - "sentry.integrations.api.endpoints.organization_integration_request", - "sentry.integrations.api.endpoints.organization_integration_serverless_functions", - "sentry.integrations.api.endpoints.organization_integrations_index", - "sentry.integrations.api.endpoints.organization_repositories", - "sentry.integrations.api.endpoints.organization_repository_commits", - "sentry.integrations.api.endpoints.organization_repository_details", - "sentry.integrations.api.endpoints.organization_repository_platforms", - "sentry.integrations.api.endpoints.organization_repository_settings", - "sentry.integrations.api.serializers.*", - "sentry.integrations.aws_lambda.*", - "sentry.integrations.base", - "sentry.integrations.bitbucket.*", - "sentry.integrations.bitbucket_server.*", - "sentry.integrations.claude_code.*", - "sentry.integrations.client", - "sentry.integrations.cursor.*", - "sentry.integrations.data_forwarding.*", - "sentry.integrations.discord", - "sentry.integrations.discord.analytics", - "sentry.integrations.discord.client", - "sentry.integrations.discord.integration", - "sentry.integrations.discord.requests.*", - "sentry.integrations.discord.spec", - "sentry.integrations.discord.types", - "sentry.integrations.discord.urls", - "sentry.integrations.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", - "sentry.integrations.github.issues", - "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", - "sentry.integrations.github_copilot.*", - "sentry.integrations.github_enterprise", - "sentry.integrations.github_enterprise.client", - "sentry.integrations.github_enterprise.integration", - "sentry.integrations.github_enterprise.repository", - "sentry.integrations.github_enterprise.urls", - "sentry.integrations.github_enterprise.webhook", - "sentry.integrations.gitlab.*", - "sentry.integrations.issue_alert_image_builder", - "sentry.integrations.jira", - "sentry.integrations.jira.client", - "sentry.integrations.jira.integration", - "sentry.integrations.jira.tasks", - "sentry.integrations.jira.urls", - "sentry.integrations.jira.utils.*", - "sentry.integrations.jira.views.*", - "sentry.integrations.jira.webhooks.*", - "sentry.integrations.jira_server", - "sentry.integrations.jira_server.client", - "sentry.integrations.jira_server.integration", - "sentry.integrations.jira_server.search", - "sentry.integrations.jira_server.urls", - "sentry.integrations.jira_server.webhooks", - "sentry.integrations.manager", - "sentry.integrations.messaging.*", - "sentry.integrations.metric_alerts", - "sentry.integrations.middleware.*", - "sentry.integrations.mixins.*", - "sentry.integrations.models", - "sentry.integrations.models.data_forwarder", - "sentry.integrations.models.data_forwarder_project", - "sentry.integrations.models.doc_integration", - "sentry.integrations.models.doc_integration_avatar", - "sentry.integrations.models.external_actor", - "sentry.integrations.models.external_issue", - "sentry.integrations.models.integration", - "sentry.integrations.models.integration_external_project", - "sentry.integrations.models.organization_integration", - "sentry.integrations.models.repository_project_path_config", - "sentry.integrations.models.utils", - "sentry.integrations.msteams", - "sentry.integrations.msteams.actions.*", - "sentry.integrations.msteams.analytics", - "sentry.integrations.msteams.card_builder.*", - "sentry.integrations.msteams.client", - "sentry.integrations.msteams.constants", - "sentry.integrations.msteams.integration", - "sentry.integrations.msteams.link_identity", - "sentry.integrations.msteams.linkage", - "sentry.integrations.msteams.metrics", - "sentry.integrations.msteams.parsing", - "sentry.integrations.msteams.spec", - "sentry.integrations.msteams.unlink_identity", - "sentry.integrations.msteams.urls", - "sentry.integrations.msteams.utils", - "sentry.integrations.msteams.webhook", - "sentry.integrations.on_call.*", - "sentry.integrations.opsgenie", - "sentry.integrations.opsgenie.actions.*", - "sentry.integrations.opsgenie.analytics", - "sentry.integrations.opsgenie.client", - "sentry.integrations.opsgenie.integration", - "sentry.integrations.opsgenie.metrics", - "sentry.integrations.opsgenie.utils", - "sentry.integrations.pipeline", - "sentry.integrations.referrer_ids", - "sentry.integrations.slack", - "sentry.integrations.slack.actions.*", - "sentry.integrations.slack.analytics", - "sentry.integrations.slack.integration", - "sentry.integrations.slack.message_builder.*", - "sentry.integrations.slack.metrics", - "sentry.integrations.slack.sdk_client", - "sentry.integrations.slack.service", - "sentry.integrations.slack.spec", - "sentry.integrations.slack.staging.*", - "sentry.integrations.slack.tasks.*", - "sentry.integrations.slack.urls", - "sentry.integrations.slack.utils.*", - "sentry.integrations.slack.webhooks.*", - "sentry.integrations.slack.workspace", - "sentry.integrations.tasks.*", - "sentry.integrations.time_utils", - "sentry.integrations.types", - "sentry.integrations.utils", - "sentry.integrations.utils.atlassian_connect", - "sentry.integrations.utils.commit_context", - "sentry.integrations.utils.common", - "sentry.integrations.utils.identities", - "sentry.integrations.utils.metrics", - "sentry.integrations.utils.providers", - "sentry.integrations.utils.scope", - "sentry.integrations.utils.source_context", - "sentry.integrations.utils.stacktrace_link", - "sentry.integrations.utils.webhook_viewer_context", - "sentry.integrations.vsts", - "sentry.integrations.vsts.client", - "sentry.integrations.vsts.integration", - "sentry.integrations.vsts.issues", - "sentry.integrations.vsts.repository", - "sentry.integrations.vsts.search", - "sentry.integrations.vsts.urls", - "sentry.integrations.vsts.webhooks", - "sentry.integrations.web.*", - "sentry.interfaces.*", - "sentry.issues.attributes", - "sentry.issues.endpoints.event_grouping_info", - "sentry.issues.endpoints.event_owners", - "sentry.issues.endpoints.event_reprocessable", - "sentry.issues.endpoints.group_attachments", - "sentry.issues.endpoints.group_current_release", - "sentry.issues.endpoints.group_first_last_release", - "sentry.issues.endpoints.group_integrations", - "sentry.issues.endpoints.group_reprocessing", - "sentry.issues.endpoints.group_stats", - "sentry.issues.endpoints.group_tagkey_details", - "sentry.issues.endpoints.group_tagkey_values", - "sentry.issues.endpoints.group_tags", - "sentry.issues.endpoints.group_user_reports", - "sentry.issues.endpoints.organization_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", - "sentry.issues.escalating.issue_velocity", - "sentry.issues.grouptype", - "sentry.issues.highlights", - "sentry.issues.issue_search", - "sentry.issues.search", - "sentry.killswitches", - "sentry.lang", - "sentry.lang.dart.*", - "sentry.lang.java", - "sentry.lang.java.exceptions", - "sentry.lang.java.proguard", - "sentry.lang.java.utils", - "sentry.lang.java.view_hierarchies", - "sentry.lang.javascript.*", - "sentry.lang.native.*", - "sentry.loader.*", - "sentry.logging.*", - "sentry.management.*", - "sentry.middleware", - "sentry.middleware.access_log", - "sentry.middleware.ai_agent", - "sentry.middleware.auth", - "sentry.middleware.customer_domain", - "sentry.middleware.demo_mode_guard", - "sentry.middleware.devtoolbar", - "sentry.middleware.env", - "sentry.middleware.health", - "sentry.middleware.locale", - "sentry.middleware.placeholder", - "sentry.middleware.proxy", - "sentry.middleware.security", - "sentry.middleware.staff", - "sentry.middleware.stats", - "sentry.middleware.subdomain", - "sentry.middleware.sudo", - "sentry.middleware.superuser", - "sentry.middleware.viewer_context", - "sentry.models", - "sentry.models.artifactbundle", - "sentry.models.assistant", - "sentry.models.auditlogentry", - "sentry.models.authidentity", - "sentry.models.authidentityreplica", - "sentry.models.authprovider", - "sentry.models.authproviderreplica", - "sentry.models.avatars.*", - "sentry.models.broadcast", - "sentry.models.code_review_event", - "sentry.models.commit", - "sentry.models.commitauthor", - "sentry.models.commitcomparison", - "sentry.models.commitfilechange", - "sentry.models.counter", - "sentry.models.dashboard", - "sentry.models.dashboard_permissions", - "sentry.models.dashboard_widget", - "sentry.models.debugfile", - "sentry.models.deletedentry", - "sentry.models.deletedorganization", - "sentry.models.deletedproject", - "sentry.models.deletedteam", - "sentry.models.deploy", - "sentry.models.distribution", - "sentry.models.environment", - "sentry.models.eventerror", - "sentry.models.featureadoption", - "sentry.models.file", - "sentry.models.files.*", - "sentry.models.group", - "sentry.models.groupbookmark", - "sentry.models.groupcommitresolution", - "sentry.models.groupemailthread", - "sentry.models.groupenvironment", - "sentry.models.grouphash", - "sentry.models.grouphashmetadata", - "sentry.models.grouplink", - "sentry.models.groupmeta", - "sentry.models.groupopenperiod", - "sentry.models.groupopenperiodactivity", - "sentry.models.groupowner", - "sentry.models.groupreaction", - "sentry.models.grouprelease", - "sentry.models.groupresolution", - "sentry.models.groupsearchview", - "sentry.models.groupsearchviewlastvisited", - "sentry.models.groupsearchviewstarred", - "sentry.models.groupseen", - "sentry.models.groupshare", - "sentry.models.groupsnooze", - "sentry.models.grouptombstone", - "sentry.models.latestreporeleaseenvironment", - "sentry.models.organization", - "sentry.models.organizationaccessrequest", - "sentry.models.organizationcontributors", - "sentry.models.organizationmember", - "sentry.models.organizationmemberinvite", - "sentry.models.organizationmemberteam", - "sentry.models.organizationonboardingtask", - "sentry.models.orgauthtoken", - "sentry.models.owner_base", - "sentry.models.project", - "sentry.models.projectbookmark", - "sentry.models.projectcodeowners", - "sentry.models.projectkey", - "sentry.models.projectownership", - "sentry.models.projectplatform", - "sentry.models.projectredirect", - "sentry.models.projectsdk", - "sentry.models.projectteam", - "sentry.models.promptsactivity", - "sentry.models.pullrequest", - "sentry.models.recentsearch", - "sentry.models.relay", - "sentry.models.release", - "sentry.models.release_threshold.*", - "sentry.models.releaseactivity", - "sentry.models.releasecommit", - "sentry.models.releaseenvironment", - "sentry.models.releasefile", - "sentry.models.releaseheadcommit", - "sentry.models.releaseprojectenvironment", - "sentry.models.releases.*", - "sentry.models.repository", - "sentry.models.repositorysettings", - "sentry.models.rollbackorganization", - "sentry.models.rollbackuser", - "sentry.models.rule", - "sentry.models.savedsearch", - "sentry.models.search_common", - "sentry.models.sentryshot", - "sentry.models.sourcemapprocessingissue", - "sentry.models.statistical_detectors", - "sentry.models.team", - "sentry.models.tombstone", - "sentry.models.transaction_threshold", - "sentry.models.userreport", - "sentry.monitors", - "sentry.monitors.apps", - "sentry.monitors.clock_dispatch", - "sentry.monitors.constants", - "sentry.monitors.endpoints.*", - "sentry.monitors.grouptype", - "sentry.monitors.logic.*", - "sentry.monitors.migrations.*", - "sentry.monitors.models", - "sentry.monitors.processing_errors.*", - "sentry.monitors.rate_limit", - "sentry.monitors.schedule", - "sentry.monitors.serializers", - "sentry.monitors.system_incidents", - "sentry.monitors.tasks.*", - "sentry.monitors.testutils", - "sentry.monitors.types", - "sentry.monitors.utils", - "sentry.monitors.validators", - "sentry.net", - "sentry.net.http", - "sentry.new_migrations.*", - "sentry.newsletter.*", - "sentry.notifications.api.endpoints.*", - "sentry.notifications.api.serializers.*", - "sentry.notifications.models.*", - "sentry.notifications.notification_action.grouptype", - "sentry.notifications.notificationcontroller", - "sentry.notifications.validators", - "sentry.objectstore.*", - "sentry.onboarding_tasks.*", - "sentry.options", - "sentry.options.defaults", - "sentry.options.manager", - "sentry.options.store", - "sentry.partnerships.*", - "sentry.plugins", - "sentry.plugins.base", - "sentry.plugins.base.binding_manager", - "sentry.plugins.interfaces.*", - "sentry.plugins.providers", - "sentry.plugins.providers.base", - "sentry.processing.*", - "sentry.processing_errors.*", - "sentry.projectoptions.*", - "sentry.quotas.*", - "sentry.receivers.*", - "sentry.relay", - "sentry.relay.config", - "sentry.relay.config.ai_model_costs", - "sentry.relay.config.experimental", - "sentry.relay.config.measurements", - "sentry.relay.datascrubbing", - "sentry.relay.globalconfig", - "sentry.relay.projectconfig_cache.*", - "sentry.relay.projectconfig_debounce_cache.*", - "sentry.relay.utils", - "sentry.release_health", - "sentry.release_health.base", - "sentry.release_health.eap_sessions_rollout", - "sentry.release_health.metrics", - "sentry.release_health.tasks", - "sentry.releases", - "sentry.releases.endpoints.*", - "sentry.remote_subscriptions.*", - "sentry.replays", - "sentry.replays._case_studies.*", - "sentry.replays.data_export", - "sentry.replays.endpoints.*", - "sentry.replays.lib", - "sentry.replays.lib.cache", - "sentry.replays.lib.eap.*", - "sentry.replays.lib.event_linking", - "sentry.replays.lib.http", - "sentry.replays.lib.kafka", - "sentry.replays.lib.query", - "sentry.replays.lib.seer_api", - "sentry.replays.lib.storage", - "sentry.replays.models", - "sentry.replays.permissions", - "sentry.replays.post_process", - "sentry.replays.query", - "sentry.replays.tasks", - "sentry.replays.testutils", - "sentry.replays.usecases.*", - "sentry.replays.validators", - "sentry.rules.filters.*", - "sentry.rules.processing.*", - "sentry.scm.*", - "sentry.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", - "sentry.seer", - "sentry.seer.agent.*", - "sentry.seer.anomaly_detection.*", - "sentry.seer.apps", - "sentry.seer.assisted_query.*", - "sentry.seer.autofix.*", - "sentry.seer.breakpoints", - "sentry.seer.constants", - "sentry.seer.endpoints.*", - "sentry.seer.entrypoints.*", - "sentry.seer.explorer.*", - "sentry.seer.issue_detection", - "sentry.seer.migrations.*", - "sentry.seer.models.*", - "sentry.seer.seer_setup", - "sentry.seer.sentry_data_models", - "sentry.seer.signed_seer_api", - "sentry.seer.supergroups.*", - "sentry.seer.utils", - "sentry.sentry_apps.alert_rule_action_creator", - "sentry.sentry_apps.api.bases.*", - "sentry.sentry_apps.api.endpoints.*", - "sentry.sentry_apps.api.parsers.*", - "sentry.sentry_apps.api.serializers.*", - "sentry.sentry_apps.logic", - "sentry.sentry_apps.models.*", - "sentry.sentry_apps.tasks.*", - "sentry.sentry_metrics", - "sentry.sentry_metrics.aggregation_option_registry", - "sentry.sentry_metrics.configuration", - "sentry.sentry_metrics.querying.*", - "sentry.sentry_metrics.use_case_id_registry", - "sentry.sentry_metrics.use_case_utils", - "sentry.sentry_metrics.utils", - "sentry.services", - "sentry.services.eventstore", - "sentry.services.eventstore.query_preprocessing", - "sentry.services.filestore.*", - "sentry.services.http", - "sentry.services.organization.*", - "sentry.shared_integrations", - "sentry.shared_integrations.response.*", - "sentry.signals", - "sentry.similarity.*", - "sentry.snuba.functions", - "sentry.snuba.issue_platform", - "sentry.snuba.metrics.fields.*", - "sentry.snuba.metrics.mqb_query_transformer", - "sentry.snuba.metrics.query_builder", - "sentry.snuba.metrics_enhanced_performance", - "sentry.snuba.metrics_performance", - "sentry.snuba.outcomes", - "sentry.snuba.sessions_v2", - "sentry.snuba.spans_metrics", - "sentry.spans", - "sentry.spans.buffer", - "sentry.spans.buffer_logger", - "sentry.spans.consumers", - "sentry.spans.consumers.process.*", - "sentry.spans.gcp_log_analyzer", - "sentry.spans.log_analyzer.*", - "sentry.stacktraces", - "sentry.stacktraces.functions", - "sentry.stacktraces.processing", - "sentry.statistical_detectors.*", - "sentry.synapse.*", - "sentry.tagstore.*", - "sentry.tasks", - "sentry.tasks.activity", - "sentry.tasks.ai_agent_monitoring", - "sentry.tasks.assemble", - "sentry.tasks.auth.*", - "sentry.tasks.auto_ongoing_issues", - "sentry.tasks.auto_remove_inbox", - "sentry.tasks.auto_resolve_issues", - "sentry.tasks.auto_source_code_config", - "sentry.tasks.base", - "sentry.tasks.clear_expired_resolutions", - "sentry.tasks.clear_expired_snoozes", - "sentry.tasks.collect_project_platforms", - "sentry.tasks.console_platform_cleanup", - "sentry.tasks.delete_pending_groups", - "sentry.tasks.digests", - "sentry.tasks.email", - "sentry.tasks.embeddings_grouping.*", - "sentry.tasks.files", - "sentry.tasks.groupowner", - "sentry.tasks.integrations.*", - "sentry.tasks.llm_issue_detection.*", - "sentry.tasks.merge", - "sentry.tasks.options", - "sentry.tasks.organization_contributors", - "sentry.tasks.ping", - "sentry.tasks.process_buffer", - "sentry.tasks.relay", - "sentry.tasks.release_registry", - "sentry.tasks.scim.*", - "sentry.tasks.seer", - "sentry.tasks.seer.agentic_triage.*", - "sentry.tasks.seer.autofix", - "sentry.tasks.seer.backfill_supergroups_lightweight", - "sentry.tasks.seer.context_engine_index", - "sentry.tasks.seer.explorer_index", - "sentry.tasks.seer.lightweight_rca_cluster", - "sentry.tasks.statistical_detectors", - "sentry.tasks.summaries.*", - "sentry.tasks.symbolication", - "sentry.tasks.user_report", - "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", - "sentry.tempest.tasks", - "sentry.tempest.utils", - "sentry.templatetags.*", - "sentry.testutils", - "sentry.testutils.abstract", - "sentry.testutils.asserts", - "sentry.testutils.auth", - "sentry.testutils.cases", - "sentry.testutils.cell", - "sentry.testutils.factories", - "sentry.testutils.fixtures", - "sentry.testutils.helpers", - "sentry.testutils.helpers.analytics", - "sentry.testutils.helpers.apigateway", - "sentry.testutils.helpers.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", - "sentry.testutils.helpers.integrations", - "sentry.testutils.helpers.link_header", - "sentry.testutils.helpers.notifications", - "sentry.testutils.helpers.on_demand", - "sentry.testutils.helpers.options", - "sentry.testutils.helpers.pagination", - "sentry.testutils.helpers.redis", - "sentry.testutils.helpers.response", - "sentry.testutils.helpers.sdk", - "sentry.testutils.helpers.slack", - "sentry.testutils.helpers.socket", - "sentry.testutils.helpers.uptime", - "sentry.testutils.hybrid_cloud", - "sentry.testutils.issue_detection.*", - "sentry.testutils.middleware", - "sentry.testutils.outbox", - "sentry.testutils.pytest", - "sentry.testutils.pytest.fixtures", - "sentry.testutils.pytest.kafka", - "sentry.testutils.pytest.metrics", - "sentry.testutils.pytest.mocking", - "sentry.testutils.pytest.relay", - "sentry.testutils.pytest.selenium", - "sentry.testutils.pytest.sentry", - "sentry.testutils.pytest.stale_database_reads", - "sentry.testutils.pytest.unittest", - "sentry.testutils.pytest.xdist", - "sentry.testutils.relay", - "sentry.testutils.requests", - "sentry.testutils.silo", - "sentry.toolbar", - "sentry.toolbar.views.*", - "sentry.tsdb.*", - "sentry.uptime", - "sentry.uptime.apps", - "sentry.uptime.autodetect.*", - "sentry.uptime.checker_api", - "sentry.uptime.config_producer", - "sentry.uptime.config_schema", - "sentry.uptime.consumers.*", - "sentry.uptime.eap_utils", - "sentry.uptime.endpoints.*", - "sentry.uptime.grouptype", - "sentry.uptime.models", - "sentry.uptime.subscriptions.*", - "sentry.uptime.types", - "sentry.uptime.utils", - "sentry.utils", - "sentry.utils.arroyo_producer", - "sentry.utils.auth", - "sentry.utils.avatar", - "sentry.utils.batching_kafka_consumer", - "sentry.utils.cache", - "sentry.utils.circuit_breaker", - "sentry.utils.circuit_breaker2", - "sentry.utils.codecs", - "sentry.utils.concurrent", - "sentry.utils.confluent_producer", - "sentry.utils.console_platforms", - "sentry.utils.cursored_scheduler", - "sentry.utils.cursors", - "sentry.utils.datastructures", - "sentry.utils.dates", - "sentry.utils.db", - "sentry.utils.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", - "sentry.utils.patch_set", - "sentry.utils.platform_categories", - "sentry.utils.rollout", - "sentry.utils.rust", - "sentry.utils.safe", - "sentry.utils.samples", - "sentry.utils.sdk", - "sentry.utils.serializers", - "sentry.utils.session_store", - "sentry.utils.settings", - "sentry.utils.snuba", - "sentry.utils.strings", - "sentry.utils.tag_normalization", - "sentry.utils.time_window", - "sentry.utils.types", - "sentry.utils.validators", - "sentry.utils.yaml", - "sentry.viewer_context", - "sentry.web", - "sentry.web.api", - "sentry.web.client_config", - "sentry.web.constants", - "sentry.web.debug_urls", - "sentry.web.decorators", - "sentry.web.forms.*", - "sentry.web.frontend", - "sentry.web.frontend.analytics", - "sentry.web.frontend.auth_channel_login", - "sentry.web.frontend.auth_close", - "sentry.web.frontend.auth_login", - "sentry.web.frontend.auth_logout", - "sentry.web.frontend.auth_organization_login", - "sentry.web.frontend.base", - "sentry.web.frontend.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", - "sentry.web.frontend.home", - "sentry.web.frontend.idp_email_verification", - "sentry.web.frontend.js_sdk_loader", - "sentry.web.frontend.mailgun_inbound_webhook", - "sentry.web.frontend.oauth_device_authorization", - "sentry.web.frontend.oauth_token", - "sentry.web.frontend.openidtoken", - "sentry.web.frontend.organization_auth_settings", - "sentry.web.frontend.organization_avatar", - "sentry.web.frontend.out", - "sentry.web.frontend.pipeline_advancer", - "sentry.web.frontend.project_event", - "sentry.web.frontend.react_page", - "sentry.web.frontend.reactivate_account", - "sentry.web.frontend.release_webhook", - "sentry.web.frontend.setup_wizard", - "sentry.web.frontend.shared_group_details", - "sentry.web.frontend.twofactor", - "sentry.web.helpers", - "sentry.web.urls", - "sentry.web_vitals.*", - "social_auth", - "social_auth.backends.*", - "social_auth.decorators", - "social_auth.django_compat", - "social_auth.exceptions", - "social_auth.fields", - "social_auth.models", - "social_auth.urls", - "social_auth.utils", - "social_auth.views", - "tests", - "tests.conftest", - "tests.integration.*", - "tests.relay_integration.*", - "tests.sentry", - "tests.sentry.analytics.*", - "tests.sentry.api", - "tests.sentry.api.bases.*", - "tests.sentry.api.endpoints", - "tests.sentry.api.endpoints.conftest", - "tests.sentry.api.endpoints.test_accept_organization_invite", - "tests.sentry.api.endpoints.test_accept_project_transfer", - "tests.sentry.api.endpoints.test_admin_project_configs", - "tests.sentry.api.endpoints.test_api_application_details", - "tests.sentry.api.endpoints.test_api_application_rotate_secrets", - "tests.sentry.api.endpoints.test_api_applications", - "tests.sentry.api.endpoints.test_api_authorizations", - "tests.sentry.api.endpoints.test_api_token_details", - "tests.sentry.api.endpoints.test_api_tokens", - "tests.sentry.api.endpoints.test_artifact_bundles", - "tests.sentry.api.endpoints.test_assistant", - "tests.sentry.api.endpoints.test_auth", - "tests.sentry.api.endpoints.test_auth_config", - "tests.sentry.api.endpoints.test_auth_login", - "tests.sentry.api.endpoints.test_auth_validate", - "tests.sentry.api.endpoints.test_broadcast_details", - "tests.sentry.api.endpoints.test_broadcast_index", - "tests.sentry.api.endpoints.test_builtin_symbol_sources", - "tests.sentry.api.endpoints.test_catchall", - "tests.sentry.api.endpoints.test_chunk_upload", - "tests.sentry.api.endpoints.test_commit_filechange", - "tests.sentry.api.endpoints.test_debug_files", - "tests.sentry.api.endpoints.test_dif_assemble", - "tests.sentry.api.endpoints.test_dsn_lookup", - "tests.sentry.api.endpoints.test_email_capture", - "tests.sentry.api.endpoints.test_event_attachment_details", - "tests.sentry.api.endpoints.test_event_attachments", - "tests.sentry.api.endpoints.test_event_committers", - "tests.sentry.api.endpoints.test_frontend_version", - "tests.sentry.api.endpoints.test_group_external_issues", - "tests.sentry.api.endpoints.test_index", - "tests.sentry.api.endpoints.test_internal_beacon", - "tests.sentry.api.endpoints.test_internal_feature_flags", - "tests.sentry.api.endpoints.test_oauth_userinfo", - "tests.sentry.api.endpoints.test_onboarding", - "tests.sentry.api.endpoints.test_organization_access_request_details", - "tests.sentry.api.endpoints.test_organization_access_requests", - "tests.sentry.api.endpoints.test_organization_ai_conversation_details", - "tests.sentry.api.endpoints.test_organization_ai_conversations", - "tests.sentry.api.endpoints.test_organization_ai_conversations_base", - "tests.sentry.api.endpoints.test_organization_api_key_details", - "tests.sentry.api.endpoints.test_organization_api_key_index", - "tests.sentry.api.endpoints.test_organization_artifactbundle_assemble", - "tests.sentry.api.endpoints.test_organization_attribute_mappings", - "tests.sentry.api.endpoints.test_organization_auth_provider_details", - "tests.sentry.api.endpoints.test_organization_auth_providers", - "tests.sentry.api.endpoints.test_organization_auth_token_details", - "tests.sentry.api.endpoints.test_organization_auth_tokens", - "tests.sentry.api.endpoints.test_organization_config_integrations", - "tests.sentry.api.endpoints.test_organization_fork", - "tests.sentry.api.endpoints.test_organization_insights_tree", - "tests.sentry.api.endpoints.test_organization_intercom_jwt", - "tests.sentry.api.endpoints.test_organization_invite_request_details", - "tests.sentry.api.endpoints.test_organization_invite_request_index", - "tests.sentry.api.endpoints.test_organization_metrics", - "tests.sentry.api.endpoints.test_organization_missing_org_members", - "tests.sentry.api.endpoints.test_organization_on_demand_metrics_estimation_stats", - "tests.sentry.api.endpoints.test_organization_onboarding_continuation", - "tests.sentry.api.endpoints.test_organization_onboarding_tasks", - "tests.sentry.api.endpoints.test_organization_pipeline", - "tests.sentry.api.endpoints.test_organization_profiling_functions", - "tests.sentry.api.endpoints.test_organization_profiling_profiles", - "tests.sentry.api.endpoints.test_organization_project_keys", - "tests.sentry.api.endpoints.test_organization_projects_sent_first_event", - "tests.sentry.api.endpoints.test_organization_recent_searches", - "tests.sentry.api.endpoints.test_organization_relay_usage", - "tests.sentry.api.endpoints.test_organization_release_previous_commits", - "tests.sentry.api.endpoints.test_organization_releases", - "tests.sentry.api.endpoints.test_organization_sampling_effective_sample_rate", - "tests.sentry.api.endpoints.test_organization_sampling_project_rates", - "tests.sentry.api.endpoints.test_organization_sampling_project_span_counts", - "tests.sentry.api.endpoints.test_organization_sdk_deprecations", - "tests.sentry.api.endpoints.test_organization_sdk_updates", - "tests.sentry.api.endpoints.test_organization_stats", - "tests.sentry.api.endpoints.test_organization_traces", - "tests.sentry.api.endpoints.test_organization_unsubscribe", - "tests.sentry.api.endpoints.test_project_artifact_bundle_file_details", - "tests.sentry.api.endpoints.test_project_artifact_bundle_files", - "tests.sentry.api.endpoints.test_project_artifact_lookup", - "tests.sentry.api.endpoints.test_project_commits", - "tests.sentry.api.endpoints.test_project_create_sample", - "tests.sentry.api.endpoints.test_project_filter_details", - "tests.sentry.api.endpoints.test_project_filters", - "tests.sentry.api.endpoints.test_project_member_index", - "tests.sentry.api.endpoints.test_project_overview", - "tests.sentry.api.endpoints.test_project_performance_general_settings", - "tests.sentry.api.endpoints.test_project_profiling_profile", - "tests.sentry.api.endpoints.test_project_proguard_artifact_releases", - "tests.sentry.api.endpoints.test_project_servicehook_details", - "tests.sentry.api.endpoints.test_project_servicehooks", - "tests.sentry.api.endpoints.test_project_symbol_sources", - "tests.sentry.api.endpoints.test_project_tagkey_details", - "tests.sentry.api.endpoints.test_project_tagkey_values", - "tests.sentry.api.endpoints.test_project_transaction_names", - "tests.sentry.api.endpoints.test_project_transaction_threshold", - "tests.sentry.api.endpoints.test_project_transaction_threshold_override", - "tests.sentry.api.endpoints.test_prompts_activity", - "tests.sentry.api.endpoints.test_relay_globalconfig_v3", - "tests.sentry.api.endpoints.test_relay_healthcheck", - "tests.sentry.api.endpoints.test_relay_projectconfigs", - "tests.sentry.api.endpoints.test_relay_projectconfigs_v2", - "tests.sentry.api.endpoints.test_relay_projectconfigs_v3", - "tests.sentry.api.endpoints.test_relay_publickeys", - "tests.sentry.api.endpoints.test_relay_register", - "tests.sentry.api.endpoints.test_rpc", - "tests.sentry.api.endpoints.test_seer_models", - "tests.sentry.api.endpoints.test_setup_wizard", - "tests.sentry.api.endpoints.test_sudo", - "tests.sentry.api.endpoints.test_system_health", - "tests.sentry.api.endpoints.test_system_options", - "tests.sentry.api.endpoints.test_user_organizations", - "tests.sentry.api.endpoints.test_user_subscriptions", - "tests.sentry.api.endpoints.test_warmup", - "tests.sentry.api.helpers", - "tests.sentry.api.helpers.test_default_symbol_sources", - "tests.sentry.api.helpers.test_deprecation", - "tests.sentry.api.helpers.test_events", - "tests.sentry.api.helpers.test_group_index", - "tests.sentry.api.serializers.*", - "tests.sentry.api.test_api_owners", - "tests.sentry.api.test_api_pagination_check", - "tests.sentry.api.test_authentication", - "tests.sentry.api.test_base", - "tests.sentry.api.test_client", - "tests.sentry.api.test_data_secrecy", - "tests.sentry.api.test_event_search", - "tests.sentry.api.test_handlers", - "tests.sentry.api.test_invite_helper", - "tests.sentry.api.test_organization_events", - "tests.sentry.api.test_paginator", - "tests.sentry.api.test_path_params", - "tests.sentry.api.test_permissions", - "tests.sentry.api.test_release_search", - "tests.sentry.api.test_utils", - "tests.sentry.apidocs.*", - "tests.sentry.attachments.*", - "tests.sentry.auth.providers.fly.*", - "tests.sentry.auth.providers.github.*", - "tests.sentry.auth.providers.test_oauth2", - "tests.sentry.auth.providers.test_saml2", - "tests.sentry.auth.test_access", - "tests.sentry.auth.test_email", - "tests.sentry.auth.test_helper", - "tests.sentry.auth.test_idpmigration", - "tests.sentry.auth.test_password_validation", - "tests.sentry.auth.test_staff", - "tests.sentry.auth.test_superuser", - "tests.sentry.auth.test_system", - "tests.sentry.auth_v2.utils.*", - "tests.sentry.autofix.*", - "tests.sentry.backup.*", - "tests.sentry.billing.*", - "tests.sentry.buffer.*", - "tests.sentry.cache.*", - "tests.sentry.conduit.*", - "tests.sentry.conf.*", - "tests.sentry.consumers.*", - "tests.sentry.core.*", - "tests.sentry.dashboards.*", - "tests.sentry.data.*", - "tests.sentry.db", - "tests.sentry.db.models.*", - "tests.sentry.db.postgres.*", - "tests.sentry.db.test_pending_deletion", - "tests.sentry.db.test_router", - "tests.sentry.db.test_silo_models", - "tests.sentry.db.test_transactions", - "tests.sentry.debug", - "tests.sentry.debug_files.*", - "tests.sentry.discover", - "tests.sentry.discover.test_arithmetic", - "tests.sentry.discover.test_compare_tables", - "tests.sentry.discover.test_dashboard_widget_split", - "tests.sentry.discover.test_dataset_split", - "tests.sentry.discover.test_models", - "tests.sentry.dynamic_sampling.*", - "tests.sentry.event_manager.grouping.*", - "tests.sentry.event_manager.test_ensure_has_ip", - "tests.sentry.event_manager.test_event_manager_grouping", - "tests.sentry.event_manager.test_generate_culprit", - "tests.sentry.event_manager.test_normalization", - "tests.sentry.event_manager.test_priority", - "tests.sentry.event_manager.test_project_platform_infer", - "tests.sentry.event_manager.test_severity", - "tests.sentry.event_manager.test_validate_data", - "tests.sentry.explore.*", - "tests.sentry.features.*", - "tests.sentry.feedback.*", - "tests.sentry.identity.*", - "tests.sentry.incidents", - "tests.sentry.incidents.endpoints.*", - "tests.sentry.incidents.models.*", - "tests.sentry.incidents.subscription_processor.*", - "tests.sentry.incidents.test_charts", - "tests.sentry.incidents.test_logic", - "tests.sentry.incidents.test_metric_issue_detector_handler", - "tests.sentry.incidents.test_metric_issue_post_process", - "tests.sentry.incidents.utils.*", - "tests.sentry.ingest.*", - "tests.sentry.integrations", - "tests.sentry.integrations.api.bases.*", - "tests.sentry.integrations.api.endpoints.*", - "tests.sentry.integrations.aws_lambda.*", - "tests.sentry.integrations.bitbucket.*", - "tests.sentry.integrations.bitbucket_server.*", - "tests.sentry.integrations.claude_code.*", - "tests.sentry.integrations.cursor.*", - "tests.sentry.integrations.data_forwarding.*", - "tests.sentry.integrations.example.*", - "tests.sentry.integrations.github.*", - "tests.sentry.integrations.github_copilot.*", - "tests.sentry.integrations.github_enterprise.*", - "tests.sentry.integrations.gitlab.*", - "tests.sentry.integrations.jira", - "tests.sentry.integrations.jira.test_client", - "tests.sentry.integrations.jira.test_csp", - "tests.sentry.integrations.jira.test_installed", - "tests.sentry.integrations.jira.test_integration", - "tests.sentry.integrations.jira.test_notify_action", - "tests.sentry.integrations.jira.test_search_endpoint", - "tests.sentry.integrations.jira.test_sentry_installation", - "tests.sentry.integrations.jira.test_sentry_issue_details", - "tests.sentry.integrations.jira.test_tasks", - "tests.sentry.integrations.jira.test_ticket_action", - "tests.sentry.integrations.jira.test_uninstalled", - "tests.sentry.integrations.jira.test_utils", - "tests.sentry.integrations.jira.test_webhooks", - "tests.sentry.integrations.jira_server.*", - "tests.sentry.integrations.opsgenie.*", - "tests.sentry.integrations.pagerduty.*", - "tests.sentry.integrations.perforce.*", - "tests.sentry.integrations.slack", - "tests.sentry.integrations.slack.notifications.*", - "tests.sentry.integrations.slack.service.*", - "tests.sentry.integrations.slack.staging.*", - "tests.sentry.integrations.slack.tasks.*", - "tests.sentry.integrations.slack.test_integration", - "tests.sentry.integrations.slack.test_link_identity", - "tests.sentry.integrations.slack.test_link_team", - "tests.sentry.integrations.slack.test_message_builder", - "tests.sentry.integrations.slack.test_notifications", - "tests.sentry.integrations.slack.test_notify_action", - "tests.sentry.integrations.slack.test_requests", - "tests.sentry.integrations.slack.test_sdk_client", - "tests.sentry.integrations.slack.test_unfurl", - "tests.sentry.integrations.slack.utils.*", - "tests.sentry.integrations.slack.webhooks", - "tests.sentry.integrations.slack.webhooks.actions.*", - "tests.sentry.integrations.slack.webhooks.events.*", - "tests.sentry.integrations.slack.webhooks.options_load.*", - "tests.sentry.integrations.tasks.*", - "tests.sentry.integrations.test_base", - "tests.sentry.integrations.test_client", - "tests.sentry.integrations.test_helpers", - "tests.sentry.integrations.test_issues", - "tests.sentry.integrations.test_metric_alerts", - "tests.sentry.integrations.test_model", - "tests.sentry.integrations.test_pipeline", - "tests.sentry.integrations.utils.*", - "tests.sentry.integrations.vsts.*", - "tests.sentry.issues.related.*", - "tests.sentry.issues.test_ingest_incident_integration", - "tests.sentry.issues.test_issue_search", - "tests.sentry.issues.test_utils", - "tests.sentry.lang", - "tests.sentry.lang.java.*", - "tests.sentry.lang.javascript.*", - "tests.sentry.lang.native.*", - "tests.sentry.logging.*", - "tests.sentry.mail", - "tests.sentry.mail.test_actions", - "tests.sentry.mail.test_adapter", - "tests.sentry.management.*", - "tests.sentry.metrics.*", - "tests.sentry.middleware.*", - "tests.sentry.migrations.*", - "tests.sentry.models", - "tests.sentry.models.test_activity", - "tests.sentry.models.test_apiapplication", - "tests.sentry.models.test_apigrant", - "tests.sentry.models.test_apikey", - "tests.sentry.models.test_apitoken", - "tests.sentry.models.test_authidentity", - "tests.sentry.models.test_base", - "tests.sentry.models.test_commit", - "tests.sentry.models.test_commitauthor", - "tests.sentry.models.test_commitfilechange", - "tests.sentry.models.test_dashboard", - "tests.sentry.models.test_debugfile", - "tests.sentry.models.test_deploy", - "tests.sentry.models.test_environment", - "tests.sentry.models.test_eventattachment", - "tests.sentry.models.test_eventerror", - "tests.sentry.models.test_featureadoption", - "tests.sentry.models.test_file", - "tests.sentry.models.test_group", - "tests.sentry.models.test_groupassignee", - "tests.sentry.models.test_grouphash", - "tests.sentry.models.test_grouphistory", - "tests.sentry.models.test_groupinbox", - "tests.sentry.models.test_groupmeta", - "tests.sentry.models.test_groupowner", - "tests.sentry.models.test_groupreaction", - "tests.sentry.models.test_grouprelease", - "tests.sentry.models.test_groupresolution", - "tests.sentry.models.test_groupsnooze", - "tests.sentry.models.test_groupsubscription", - "tests.sentry.models.test_manager", - "tests.sentry.models.test_organization", - "tests.sentry.models.test_organization_avatar", - "tests.sentry.models.test_organizationaccessrequest", - "tests.sentry.models.test_organizationmember", - "tests.sentry.models.test_organizationmemberteam", - "tests.sentry.models.test_organizationoption", - "tests.sentry.models.test_orgauthtoken", - "tests.sentry.models.test_project", - "tests.sentry.models.test_projectcodeowners", - "tests.sentry.models.test_projectcounter", - "tests.sentry.models.test_projectkey", - "tests.sentry.models.test_projectkeymapping", - "tests.sentry.models.test_projectoption", - "tests.sentry.models.test_projectownership", - "tests.sentry.models.test_projectredirect", - "tests.sentry.models.test_projectsdk", - "tests.sentry.models.test_pullrequest", - "tests.sentry.models.test_recentsearch", - "tests.sentry.models.test_release", - "tests.sentry.models.test_releaseenvironment", - "tests.sentry.models.test_releasefile", - "tests.sentry.models.test_releaseprojectenvironment", - "tests.sentry.models.test_repository", - "tests.sentry.models.test_repositorysettings", - "tests.sentry.models.test_statistical_detectors", - "tests.sentry.models.test_team", - "tests.sentry.models.test_teamkeytransaction", - "tests.sentry.monitors", - "tests.sentry.monitors.clock_tasks.*", - "tests.sentry.monitors.consumers.*", - "tests.sentry.monitors.endpoints.*", - "tests.sentry.monitors.logic.*", - "tests.sentry.monitors.processing_errors.*", - "tests.sentry.monitors.tasks.*", - "tests.sentry.monitors.test_clock_dispatch", - "tests.sentry.monitors.test_models", - "tests.sentry.monitors.test_rate_limit", - "tests.sentry.monitors.test_schedule", - "tests.sentry.monitors.test_serializers", - "tests.sentry.monitors.test_system_incidents", - "tests.sentry.monitors.test_types", - "tests.sentry.monitors.test_utils", - "tests.sentry.monitors.test_validators", - "tests.sentry.newsletter.*", - "tests.sentry.notifications.api.*", - "tests.sentry.notifications.notification_action.test_group_type_notification_registry_handlers", - "tests.sentry.notifications.notification_action.test_issue_alert_registry_handlers", - "tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers", - "tests.sentry.notifications.notifications.test_assigned", - "tests.sentry.notifications.notifications.test_suspect_commits_activity", - "tests.sentry.notifications.test_notificationcontroller", - "tests.sentry.notifications.test_notifications", - "tests.sentry.notifications.test_utils", - "tests.sentry.objectstore.*", - "tests.sentry.options.*", - "tests.sentry.pipeline.*", - "tests.sentry.preprod.*", - "tests.sentry.processing_errors.*", - "tests.sentry.profiles.test_flamegraph", - "tests.sentry.profiles.test_java", - "tests.sentry.profiles.test_task", - "tests.sentry.profiles.test_utils", - "tests.sentry.projectoptions.*", - "tests.sentry.quotas.*", - "tests.sentry.receivers", - "tests.sentry.receivers.test_analytics", - "tests.sentry.receivers.test_core", - "tests.sentry.receivers.test_data_forwarding", - "tests.sentry.receivers.test_featureadoption", - "tests.sentry.receivers.test_onboarding", - "tests.sentry.receivers.test_releases", - "tests.sentry.receivers.test_sentry_apps", - "tests.sentry.receivers.test_signals", - "tests.sentry.receivers.test_staff", - "tests.sentry.receivers.test_superuser", - "tests.sentry.receivers.test_transactions", - "tests.sentry.relay.config.test_experimental", - "tests.sentry.relay.test_config", - "tests.sentry.relay.test_datascrubbing", - "tests.sentry.relay.test_projectconfig_cache", - "tests.sentry.relay.test_projectconfig_debounce_cache", - "tests.sentry.releases.*", - "tests.sentry.relocation", - "tests.sentry.relocation.tasks.*", - "tests.sentry.relocation.test_utils", - "tests.sentry.rules.actions.test_create_ticket_utils", - "tests.sentry.runner", - "tests.sentry.runner.commands", - "tests.sentry.runner.commands.test_backup", - "tests.sentry.runner.commands.test_config", - "tests.sentry.runner.commands.test_configoptions", - "tests.sentry.runner.commands.test_createorg", - "tests.sentry.runner.commands.test_createproject", - "tests.sentry.runner.commands.test_createuser", - "tests.sentry.runner.commands.test_deletions", - "tests.sentry.runner.commands.test_init", - "tests.sentry.runner.commands.test_migrations", - "tests.sentry.runner.commands.test_run", - "tests.sentry.runner.test_initializer", - "tests.sentry.scm.*", - "tests.sentry.search.*", - "tests.sentry.seer", - "tests.sentry.seer.agent.*", - "tests.sentry.seer.assisted_query.*", - "tests.sentry.seer.autofix.*", - "tests.sentry.seer.endpoints.*", - "tests.sentry.seer.entrypoints.*", - "tests.sentry.seer.fetch_issues.*", - "tests.sentry.seer.similarity.*", - "tests.sentry.seer.supergroups.*", - "tests.sentry.seer.test_breakpoints", - "tests.sentry.seer.test_issue_detection", - "tests.sentry.seer.test_signed_seer_api", - "tests.sentry.sentry_apps", - "tests.sentry.sentry_apps.tasks.*", - "tests.sentry.sentry_apps.test_sentry_app_component_preparer", - "tests.sentry.sentry_apps.test_sentry_app_creator", - "tests.sentry.sentry_apps.test_sentry_app_installation_creator", - "tests.sentry.sentry_apps.test_sentry_app_installation_notifier", - "tests.sentry.sentry_apps.test_sentry_app_installation_token_creator", - "tests.sentry.sentry_apps.test_sentry_app_updater", - "tests.sentry.sentry_apps.test_webhooks", - "tests.sentry.sentry_metrics.querying.*", - "tests.sentry.sentry_metrics.test_all_indexers", - "tests.sentry.sentry_metrics.test_base_indexer", - "tests.sentry.sentry_metrics.test_configuration", - "tests.sentry.sentry_metrics.test_indexer", - "tests.sentry.sentry_metrics.test_indexer_cache", - "tests.sentry.sentry_metrics.test_postgres_indexer", - "tests.sentry.sentry_metrics.test_prevent_modifications_to_indexer_strings", - "tests.sentry.sentry_metrics.test_strings", - "tests.sentry.services", - "tests.sentry.services.eventstore", - "tests.sentry.services.eventstore.test_base", - "tests.sentry.services.eventstore.test_models", - "tests.sentry.services.eventstore.test_query_preprocessing", - "tests.sentry.services.test_http", - "tests.sentry.services.test_organization_actions", - "tests.sentry.shared_integrations.*", - "tests.sentry.silo.*", - "tests.sentry.similarity", - "tests.sentry.similarity.test_encoder", - "tests.sentry.similarity.test_signatures", - "tests.sentry.snuba", - "tests.sentry.snuba.metrics", - "tests.sentry.snuba.metrics.fields.*", - "tests.sentry.snuba.metrics.test_datasource", - "tests.sentry.snuba.metrics.test_extraction", - "tests.sentry.snuba.metrics.test_naming_layer", - "tests.sentry.snuba.metrics.test_query", - "tests.sentry.snuba.metrics.test_query_builder", - "tests.sentry.snuba.metrics.test_snql", - "tests.sentry.snuba.metrics.test_utils", - "tests.sentry.snuba.test_discover_facets_query", - "tests.sentry.snuba.test_discover_query", - "tests.sentry.snuba.test_discover_timeseries_query", - "tests.sentry.snuba.test_entity_subscriptions", - "tests.sentry.snuba.test_errors", - "tests.sentry.snuba.test_models", - "tests.sentry.snuba.test_occurrences_rpc", - "tests.sentry.snuba.test_outcomes", - "tests.sentry.snuba.test_profiles", - "tests.sentry.snuba.test_rpc_dataset_common", - "tests.sentry.snuba.test_subscriptions", - "tests.sentry.snuba.test_transactions", - "tests.sentry.snuba.test_transactions_timeseries_query", - "tests.sentry.snuba.test_validators", - "tests.sentry.spans.consumers.*", - "tests.sentry.spans.log_analyzer.*", - "tests.sentry.spans.test_buffer", - "tests.sentry.spans.test_buffer_logger", - "tests.sentry.spans.test_debug_trace_logger", - "tests.sentry.spans.test_gcp_log_analyzer", - "tests.sentry.spans.test_log_analyzer", - "tests.sentry.stacktraces.*", - "tests.sentry.statistical_detectors.*", - "tests.sentry.sudo.*", - "tests.sentry.synapse.*", - "tests.sentry.tagstore.*", - "tests.sentry.tasks", - "tests.sentry.tasks.scim.*", - "tests.sentry.tasks.seer", - "tests.sentry.tasks.seer.agentic_triage.*", - "tests.sentry.tasks.seer.test_agentic_triage", - "tests.sentry.tasks.seer.test_autofix", - "tests.sentry.tasks.seer.test_backfill_supergroups_lightweight", - "tests.sentry.tasks.seer.test_context_engine_index", - "tests.sentry.tasks.seer.test_explorer_index", - "tests.sentry.tasks.test_activity", - "tests.sentry.tasks.test_ai_agent_monitoring", - "tests.sentry.tasks.test_assemble", - "tests.sentry.tasks.test_auth", - "tests.sentry.tasks.test_auto_ongoing_issues", - "tests.sentry.tasks.test_auto_remove_inbox", - "tests.sentry.tasks.test_auto_resolve_issues", - "tests.sentry.tasks.test_base", - "tests.sentry.tasks.test_beacon", - "tests.sentry.tasks.test_check_auth", - "tests.sentry.tasks.test_clear_expired_resolutions", - "tests.sentry.tasks.test_clear_expired_snoozes", - "tests.sentry.tasks.test_collect_project_platforms", - "tests.sentry.tasks.test_commit_context", - "tests.sentry.tasks.test_commits", - "tests.sentry.tasks.test_console_platform_cleanup", - "tests.sentry.tasks.test_delete_pending_groups", - "tests.sentry.tasks.test_digests", - "tests.sentry.tasks.test_email", - "tests.sentry.tasks.test_groupowner", - "tests.sentry.tasks.test_llm_issue_detection", - "tests.sentry.tasks.test_merge", - "tests.sentry.tasks.test_options", - "tests.sentry.tasks.test_organization_contributors", - "tests.sentry.tasks.test_post_process", - "tests.sentry.tasks.test_process_buffer", - "tests.sentry.tasks.test_relay", - "tests.sentry.tasks.test_reprocessing2", - "tests.sentry.tasks.test_statistical_detectors", - "tests.sentry.tasks.test_store", - "tests.sentry.tasks.test_symbolication", - "tests.sentry.tasks.test_update_code_owners_schema", - "tests.sentry.tasks.test_user_report", - "tests.sentry.tasks.test_web_vitals_issue_detection", - "tests.sentry.tasks.test_weekly_escalating_forecast", - "tests.sentry.tasks.test_weekly_reports", - "tests.sentry.taskworker.*", - "tests.sentry.test_constants", - "tests.sentry.test_culprit", - "tests.sentry.test_dependencies", - "tests.sentry.test_devimports", - "tests.sentry.test_http", - "tests.sentry.test_killswitches", - "tests.sentry.test_mypy_stronglist", - "tests.sentry.test_sdk_updates", - "tests.sentry.test_stacktraces", - "tests.sentry.test_unmerge", - "tests.sentry.test_viewer_context", - "tests.sentry.test_viewer_context_jwt", - "tests.sentry.test_wsgi", - "tests.sentry.toolbar.*", - "tests.sentry.tsdb.*", - "tests.sentry.uptime", - "tests.sentry.uptime.consumers.*", - "tests.sentry.uptime.endpoints.*", - "tests.sentry.uptime.rdap.*", - "tests.sentry.uptime.subscriptions.*", - "tests.sentry.uptime.test_grouptype", - "tests.sentry.uptime.test_models", - "tests.sentry.uptime.test_utils", - "tests.sentry.utils", - "tests.sentry.utils.kafka.*", - "tests.sentry.utils.kvstore", - "tests.sentry.utils.kvstore.test_common", - "tests.sentry.utils.kvstore.test_compat", - "tests.sentry.utils.kvstore.test_encoding", - "tests.sentry.utils.locking.*", - "tests.sentry.utils.sdk_crashes.*", - "tests.sentry.utils.sentry_apps.*", - "tests.sentry.utils.test_arroyo", - "tests.sentry.utils.test_arroyo_producer", - "tests.sentry.utils.test_assets", - "tests.sentry.utils.test_audit", - "tests.sentry.utils.test_auth", - "tests.sentry.utils.test_avatar", - "tests.sentry.utils.test_circuit_breaker", - "tests.sentry.utils.test_circuit_breaker2", - "tests.sentry.utils.test_codecs", - "tests.sentry.utils.test_committers", - "tests.sentry.utils.test_concurrent", - "tests.sentry.utils.test_confluent_producer", - "tests.sentry.utils.test_cursored_scheduler", - "tests.sentry.utils.test_cursors", - "tests.sentry.utils.test_datastructures", - "tests.sentry.utils.test_dates", - "tests.sentry.utils.test_display_name_filter", - "tests.sentry.utils.test_eap", - "tests.sentry.utils.test_event", - "tests.sentry.utils.test_event_frames", - "tests.sentry.utils.test_event_tracker", - "tests.sentry.utils.test_eventuser", - "tests.sentry.utils.test_exceptions", - "tests.sentry.utils.test_function_cache", - "tests.sentry.utils.test_geo", - "tests.sentry.utils.test_github", - "tests.sentry.utils.test_glob", - "tests.sentry.utils.test_hashlib", - "tests.sentry.utils.test_http", - "tests.sentry.utils.test_integrationdocs", - "tests.sentry.utils.test_iterators", - "tests.sentry.utils.test_json", - "tests.sentry.utils.test_jwt", - "tests.sentry.utils.test_kafka_config", - "tests.sentry.utils.test_letter_avatar", - "tests.sentry.utils.test_linksign", - "tests.sentry.utils.test_math", - "tests.sentry.utils.test_meta", - "tests.sentry.utils.test_metrics", - "tests.sentry.utils.test_not_set", - "tests.sentry.utils.test_numbers", - "tests.sentry.utils.test_outcome_aggregator", - "tests.sentry.utils.test_outcomes", - "tests.sentry.utils.test_patch_set", - "tests.sentry.utils.test_projectflags", - "tests.sentry.utils.test_query", - "tests.sentry.utils.test_ratelimits", - "tests.sentry.utils.test_redis", - "tests.sentry.utils.test_registry", - "tests.sentry.utils.test_rollout", - "tests.sentry.utils.test_rust", - "tests.sentry.utils.test_safe", - "tests.sentry.utils.test_samples", - "tests.sentry.utils.test_sdk", - "tests.sentry.utils.test_sentryappwebhookrequests", - "tests.sentry.utils.test_services", - "tests.sentry.utils.test_session_store", - "tests.sentry.utils.test_signing", - "tests.sentry.utils.test_snowflake", - "tests.sentry.utils.test_snuba", - "tests.sentry.utils.test_strings", - "tests.sentry.utils.test_tag_normalization", - "tests.sentry.utils.test_time_window", - "tests.sentry.utils.test_types", - "tests.sentry.utils.test_urls", - "tests.sentry.utils.test_validators", - "tests.sentry.utils.test_zip", - "tests.sentry.web", - "tests.sentry.web.frontend", - "tests.sentry.web.frontend.test_auth_channel_login", - "tests.sentry.web.frontend.test_auth_close", - "tests.sentry.web.frontend.test_auth_login", - "tests.sentry.web.frontend.test_auth_logout", - "tests.sentry.web.frontend.test_auth_oauth2", - "tests.sentry.web.frontend.test_auth_organization_login", - "tests.sentry.web.frontend.test_auth_saml2", - "tests.sentry.web.frontend.test_base", - "tests.sentry.web.frontend.test_csrf_failure", - "tests.sentry.web.frontend.test_data_secrecy_error", - "tests.sentry.web.frontend.test_disabled_member_view", - "tests.sentry.web.frontend.test_doc_integration_avatar", - "tests.sentry.web.frontend.test_error_404", - "tests.sentry.web.frontend.test_error_500", - "tests.sentry.web.frontend.test_group_event_json", - "tests.sentry.web.frontend.test_group_tag_export", - "tests.sentry.web.frontend.test_home", - "tests.sentry.web.frontend.test_js_sdk_loader", - "tests.sentry.web.frontend.test_mailgun_inbound_webhook", - "tests.sentry.web.frontend.test_oauth_device_authorization", - "tests.sentry.web.frontend.test_oauth_token", - "tests.sentry.web.frontend.test_openidtoken", - "tests.sentry.web.frontend.test_organization_auth_settings", - "tests.sentry.web.frontend.test_organization_avatar", - "tests.sentry.web.frontend.test_project_event", - "tests.sentry.web.frontend.test_react_page", - "tests.sentry.web.frontend.test_reactivate_account", - "tests.sentry.web.frontend.test_release_webhook", - "tests.sentry.web.frontend.test_security_txt", - "tests.sentry.web.frontend.test_setup_wizard", - "tests.sentry.web.frontend.test_shared_group_details", - "tests.sentry.web.frontend.test_twofactor", - "tests.sentry.web.test_api", - "tests.sentry.web.test_client_config", - "tests.sentry.web.test_urls", - "tests.snuba", - "tests.snuba.api", - "tests.snuba.api.endpoints.*", - "tests.snuba.billing.*", - "tests.snuba.incidents.*", - "tests.snuba.preprod.*", - "tests.snuba.processing_errors.*", - "tests.snuba.search.*", - "tests.snuba.sessions.*", - "tests.snuba.tagstore.*", - "tests.snuba.tasks.*", - "tests.snuba.test_metrics_layer", - "tests.snuba.test_outcomes", - "tests.snuba.test_referrer", - "tests.snuba.test_snql_snuba", - "tests.snuba.test_snuba", - "tests.snuba.test_util", - "tests.snuba.tsdb.*", - "tests.symbolicator.*", - "tests.tools.*", -] -disallow_any_generics = false -disallow_untyped_defs = false -strict_equality = false -# end: not yet strongly typed diff --git a/src/apigw/proxy.py b/src/apigw/proxy.py index b1335cb0a313..06c4bc85e412 100644 --- a/src/apigw/proxy.py +++ b/src/apigw/proxy.py @@ -1,7 +1,7 @@ import asyncio import time from collections import defaultdict -from typing import Any, AsyncIterator +from typing import Any, AsyncIterator, cast from urllib.parse import urljoin, urlsplit import prometheus_client @@ -199,7 +199,8 @@ def adapt_response(presp: punkreq.Response) -> Any: headers[key].append(val) for key, vals in headers.items(): response.headers[key] = ",".join(vals) - response.cookies = { + # justification: emmett serializes cookies by calling str() on each stored value. + cast("Any", response).cookies = { f"_proxied{idx}": ResponseCookieGlue(val) for idx, val in enumerate(cookies) } return response.stream(presp.iter_raw(CHUNK_SIZE)) diff --git a/src/apigw/utils.py b/src/apigw/utils.py index 4736e38783e2..5b616f3b3184 100644 --- a/src/apigw/utils.py +++ b/src/apigw/utils.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, NoReturn, cast from emmett55 import response from emmett55.serializers import Serializers @@ -7,7 +7,9 @@ json = Serializers.get_for("json") -def abort_with_json(status: int, json_data: Any) -> None: +def abort_with_json(status: int, json_data: Any) -> NoReturn: response.status = status response.content_type = "application/json" - raise HTTPBytesResponse(status, body=json(json_data), headers=response.headers) + # justification: HTTPResponse only reads headers through the mapping protocol. + headers = cast("dict[str, str]", response.headers) + raise HTTPBytesResponse(status, body=json(json_data), headers=headers) diff --git a/src/apigw/views/proxy.py b/src/apigw/views/proxy.py index 442aa76be959..958187c9fbec 100644 --- a/src/apigw/views/proxy.py +++ b/src/apigw/views/proxy.py @@ -169,10 +169,11 @@ async def proxy_cell_from_id(cell_name: str, subp: str | None = None) -> Any: methods=["get", "post", "put", "patch", "delete", "head", "options"], ) async def proxy_cell_from_dsn_qp() -> Any: - if not request.query_params.dsn: + dsn = request.query_params.dsn + if not dsn or not isinstance(dsn, str): abort_with_json(400, {"error": "apigateway", "detail": "Invalid request"}) try: - cell = get_cell_from_dsn(request.query_params.dsn, app.config.cells.default) + cell = get_cell_from_dsn(dsn, app.config.cells.default) except ValueError: abort_with_json(400, {"error": "apigateway", "detail": "Invalid request"}) except CellResolutionError: diff --git a/src/bitfield/compat.py b/src/bitfield/compat.py index c25e7af0fbbe..6ee6c910fb6e 100644 --- a/src/bitfield/compat.py +++ b/src/bitfield/compat.py @@ -1,9 +1,12 @@ +from django.db.models import F +from django.db.models.expressions import CombinedExpression + __all__ = ("bitand", "bitor") -def bitand(a, b): +def bitand(a: F, b: int) -> CombinedExpression: return a.bitand(b) -def bitor(a, b): +def bitor(a: F, b: int) -> CombinedExpression: return a.bitor(b) diff --git a/src/bitfield/models.py b/src/bitfield/models.py index 43f5204b11db..16019c28adc8 100644 --- a/src/bitfield/models.py +++ b/src/bitfield/models.py @@ -1,5 +1,7 @@ -from collections.abc import Mapping, Sequence -from typing import Any, TypeVar, cast +from __future__ import annotations + +from collections.abc import Iterator, Mapping, Sequence +from typing import TYPE_CHECKING, Any, SupportsIndex, SupportsInt, TypeVar, cast from django.db.models import Model from django.db.models.fields import BigIntegerField @@ -7,13 +9,18 @@ from bitfield.query import BitQueryExactLookupStub from bitfield.types import Bit, BitHandler +if TYPE_CHECKING: + _BigIntegerFieldBase = BigIntegerField[int, int] +else: + _BigIntegerFieldBase = BigIntegerField + # Count binary capacity. Truncate "0b" prefix from binary form. # Twice faster than bin(i)[2:] or math.floor(math.log(i)) MAX_FLAG_COUNT = int(len(bin(BigIntegerField.MAX_BIGINT)) - 2) 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 +28,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,28 +68,43 @@ 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): + def __get__( + self, obj: Model | None, type: type[Model] | None = None + ) -> BitFieldFlags | BitHandler: if obj is None: return BitFieldFlags(self.field.flags) retval = obj.__dict__[self.field.name] if self.field.__class__ is BitField: # Update flags from class in case they've changed. retval._keys = self.field.flags - return retval + # justification: the descriptor stores the field's flags value. + return cast("BitFieldFlags | BitHandler", retval) + +class BitField(_BigIntegerFieldBase): + flags: list[str] + labels: list[str] + _arg_flags: dict[int, str] | Sequence[str | tuple[str, str]] -class BitField(BigIntegerField): 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)) - def __init__(self, flags, default=None, *args, **kwargs): + # justification: forwards arbitrary Django Field constructor arguments. + def __init__( + self, + flags: dict[int, str] | Sequence[str | tuple[str, str]], + default: object = None, + *args: Any, + **kwargs: Any, + ) -> None: + flag_definitions: Sequence[str | tuple[str, str]] if isinstance(flags, dict): # Get only integer keys in correct range valid_keys = ( @@ -91,44 +113,50 @@ def __init__(self, flags, default=None, *args, **kwargs): if not valid_keys: raise ValueError("Wrong keys or empty dictionary") # Fill list with values from dict or with empty values - flags = [flags.get(i, "") for i in range(max(valid_keys) + 1)] + flag_definitions = [flags.get(i, "") for i in range(max(valid_keys) + 1)] + else: + flag_definitions = flags - if len(flags) > MAX_FLAG_COUNT: + if len(flag_definitions) > MAX_FLAG_COUNT: raise ValueError("Too many flags") self._arg_flags = flags - flags = list(flags) - labels = [] - for num, flag in enumerate(flags): + + flags_list: list[str] = [] + labels: list[str] = [] + for flag in flag_definitions: if isinstance(flag, (tuple, list)): - flags[num] = flag[0] + flags_list.append(flag[0]) labels.append(flag[1]) else: - labels.append(flag) + flags_list.append(flag) if isinstance(default, (list, tuple, set, frozenset)): new_value = 0 for flag in default: - new_value |= Bit(flags.index(flag)) + new_value |= Bit(flags_list.index(flag)) default = new_value kwargs["default"] = default BigIntegerField.__init__(self, *args, **kwargs) - self.flags = flags + self.flags = flags_list self.labels = labels - def pre_save(self, instance, add): + def pre_save(self, instance: Model, add: bool) -> int: value = getattr(instance, self.attname) - return value + return int(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)): value = value.mask return int(value) - def to_python(self, value): + def to_python(self, value: object) -> BitHandler: if isinstance(value, Bit): value = value.mask if not isinstance(value, BitHandler): @@ -141,19 +169,23 @@ def to_python(self, value): new_value |= value & (2**bit_number) value = new_value - value = BitHandler(value, self.flags, self.labels) + value = BitHandler( + cast("SupportsInt | SupportsIndex | str | bytes | bytearray | None", value), + self.flags, + self.labels, + ) else: # Ensure flags are consistent for unpickling value._keys = self.flags return value - def deconstruct(self): + def deconstruct(self) -> tuple[str, str, list[object], dict[str, object]]: name, path, args, kwargs = super().deconstruct() args = [self._arg_flags, *args] 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}" @@ -163,12 +195,16 @@ def flags_from_annotations(annotations: Mapping[str, type]) -> Sequence[str]: class TypedBitfieldMeta(type): - def __new__(cls, name, bases, clsdict): + def __new__( + cls, name: str, bases: tuple[type, ...], clsdict: dict[str, object] + ) -> TypedBitfieldMeta: if name == "TypedClassBitField": return type.__new__(cls, name, bases, clsdict) - flags = {} - for attr, ty in clsdict["__annotations__"].items(): + flags: dict[str, object] = {} + annotations = clsdict.get("__annotations__", {}) + assert isinstance(annotations, dict) + for attr, ty in annotations.items(): if attr.startswith("_"): continue @@ -177,11 +213,14 @@ def __new__(cls, name, bases, clsdict): flags[attr] = ty - return BitField( - flags=flags_from_annotations(flags), - default=clsdict.get("bitfield_default"), - null=clsdict.get("bitfield_null") or False, - db_column=clsdict.get("bitfield_db_column"), + return cast( + TypedBitfieldMeta, + BitField( + flags=flags_from_annotations(flags), + default=clsdict.get("bitfield_default"), + null=clsdict.get("bitfield_null") or False, + db_column=clsdict.get("bitfield_db_column"), + ), ) def __int__(self) -> int: @@ -203,7 +242,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..5a988cf82b5b 100644 --- a/src/bitfield/query.py +++ b/src/bitfield/query.py @@ -1,15 +1,29 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from django.db.models.lookups import Exact from bitfield.types import Bit, BitHandler +if TYPE_CHECKING: + from django.db.backends.base.base import BaseDatabaseWrapper + from django.db.models.sql.compiler import _AsSqlType, _ParamT + + _ExactBase = Exact[object] +else: + _ExactBase = Exact + -class BitQueryExactLookupStub(Exact): - def get_db_prep_lookup(self, value, connection, prepared=False): +class BitQueryExactLookupStub(_ExactBase): + def get_db_prep_lookup( + self, value: _ParamT, connection: BaseDatabaseWrapper, prepared: bool = False + ) -> _AsSqlType: if isinstance(value, (BitHandler, Bit)): 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..37fef16eadaf 100644 --- a/src/bitfield/types.py +++ b/src/bitfield/types.py @@ -1,26 +1,46 @@ -def cmp(a, b): +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Protocol, SupportsIndex, SupportsInt, TypeVar, cast + +_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): + def __eq__(self, value: object) -> bool: if isinstance(value, Bit): return value.number == self.number and value.is_set == self.is_set elif isinstance(value, bool): @@ -29,72 +49,76 @@ 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): + def __and__(self, value: int | Bit) -> int: if isinstance(value, Bit): value = value.mask return value & self.mask - def __rand__(self, value): + def __rand__(self, value: int | Bit) -> int: if isinstance(value, Bit): value = value.mask return self.mask & value - def __or__(self, value): + def __or__(self, value: int | Bit) -> int: if isinstance(value, Bit): value = value.mask return value | self.mask - def __ror__(self, value): + def __ror__(self, value: int | Bit) -> int: if isinstance(value, Bit): value = value.mask return self.mask | value - def __lshift__(self, value): + def __lshift__(self, value: int | Bit) -> int: if isinstance(value, Bit): value = value.mask return value << self.mask - def __rlshift__(self, value): + def __rlshift__(self, value: int | Bit) -> int: if isinstance(value, Bit): value = value.mask return self.mask << value - def __rshift__(self, value): + def __rshift__(self, value: int | Bit) -> int: if isinstance(value, Bit): value = value.mask return value >> self.mask - def __rrshift__(self, value): + def __rrshift__(self, value: int | Bit) -> int: if isinstance(value, Bit): value = value.mask return self.mask >> value - def __xor__(self, value): + def __xor__(self, value: int | Bit) -> int: if isinstance(value, Bit): value = value.mask return value ^ self.mask - def __rxor__(self, value): + def __rxor__(self, value: int | Bit) -> int: if isinstance(value, Bit): 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,25 +145,25 @@ 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 - def __lt__(self, other): + def __lt__(self, other: int | BitHandler) -> bool: return int(self._value) < other - def __le__(self, other): + def __le__(self, other: int | BitHandler) -> bool: return int(self._value) <= other - def __gt__(self, other): + def __gt__(self, other: int | BitHandler) -> bool: return int(self._value) > other - def __ge__(self, other): + def __ge__(self, other: int | BitHandler) -> bool: return int(self._value) >= other - def __cmp__(self, other): - return cmp(self._value, other) + def __cmp__(self, other: int | BitHandler) -> int: + return cmp(self._value, int(other)) def __repr__(self) -> str: return "<{}: {}>".format( @@ -141,47 +174,48 @@ 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)) - def __getattr__(self, key): + def __getattr__(self, key: str) -> Bit: if key.startswith("_"): - return object.__getattribute__(self, key) + # justification: dunder lookups return their real attribute, not a flag bit. + return cast("Bit", object.__getattribute__(self, key)) if key not in self._keys: raise AttributeError("%s is not a valid flag" % key) return self.get_bit(self._keys.index(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 +224,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 +252,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/__init__.py b/src/sentry/__init__.py index da0af3f8ea28..b9025e7693e2 100644 --- a/src/sentry/__init__.py +++ b/src/sentry/__init__.py @@ -11,7 +11,7 @@ VERSION = "unknown" -def _get_git_revision(path): +def _get_git_revision(path: str) -> str | None: if not os.path.exists(os.path.join(path, ".git")): return None try: @@ -22,7 +22,7 @@ def _get_git_revision(path): return revision.strip().decode("utf-8") -def get_revision(): +def get_revision() -> str | None: """ :returns: Revision number of this branch/checkout, if available. None if no revision number can be determined. @@ -37,13 +37,13 @@ def get_revision(): return None -def get_version(): +def get_version() -> str: if __build__: return f"{__version__}.{__build__}" return __version__ -def is_docker(): +def is_docker() -> bool: # One of these environment variables are guaranteed to exist # from our official docker images. # SENTRY_VERSION is from a tagged release, and SENTRY_BUILD is from a diff --git a/src/sentry/adoption/manager.py b/src/sentry/adoption/manager.py index 4b969338d047..98e6cef60c15 100644 --- a/src/sentry/adoption/manager.py +++ b/src/sentry/adoption/manager.py @@ -1,8 +1,16 @@ -from collections import defaultdict, namedtuple +from collections import defaultdict +from collections.abc import Sequence +from typing import NamedTuple, cast FEATURE_LOCATION = {"language", "integration", "code", "web", "admin", "api"} -Feature = namedtuple("Feature", ["id", "slug", "name", "location", "prerequisite"]) + +class Feature(NamedTuple): + id: int + slug: str + name: str + location: str + prerequisite: Sequence[str | tuple[str, ...]] | None class UnknownFeature(KeyError): @@ -10,15 +18,22 @@ class UnknownFeature(KeyError): class AdoptionManager: - def __init__(self): - self._id_registry = {} - self._slug_registry = {} - self._integration_slugs = defaultdict(set) - self._location_slugs = defaultdict(set) - self._slugs = set() - self._ids = set() - - def add(self, id, slug, name, location, prerequisite=None): + def __init__(self) -> None: + self._id_registry: dict[int, Feature] = {} + self._slug_registry: dict[str, Feature] = {} + self._integration_slugs: defaultdict[str, set[str]] = defaultdict(set) + self._location_slugs: defaultdict[str, set[str]] = defaultdict(set) + self._slugs: set[str] = set() + self._ids: set[int] = set() + + def add( + self, + id: int, + slug: str, + name: str, + location: str, + prerequisite: Sequence[str | tuple[str, ...]] | None = None, + ) -> None: assert location in FEATURE_LOCATION feature = Feature(id, slug, name, location, prerequisite) @@ -29,25 +44,27 @@ def add(self, id, slug, name, location, prerequisite=None): self._location_slugs[location].add(slug) if location == "integration": - self._integration_slugs[prerequisite[0]].add(slug) + assert prerequisite is not None + # justification: integration prerequisites are plain slugs. + self._integration_slugs[cast(str, prerequisite[0])].add(slug) - def get_by_id(self, id): + def get_by_id(self, id: int) -> Feature: try: return self._id_registry[id] except KeyError: raise UnknownFeature(id) - def get_by_slug(self, slug): + def get_by_slug(self, slug: str) -> Feature: try: return self._slug_registry[slug] except KeyError: raise UnknownFeature(slug) - def all(self): + def all(self) -> list[Feature]: return [self._id_registry[k] for k in self._id_registry] - def location_slugs(self, location): + def location_slugs(self, location: str) -> set[str]: return self._location_slugs[location] - def integration_slugs(self, language): + def integration_slugs(self, language: str) -> set[str]: return self._integration_slugs[language] diff --git a/src/sentry/ai_monitoring/endpoints/organization_ai_conversation_details.py b/src/sentry/ai_monitoring/endpoints/organization_ai_conversation_details.py index f052d3f4ee2f..4583dbb339de 100644 --- a/src/sentry/ai_monitoring/endpoints/organization_ai_conversation_details.py +++ b/src/sentry/ai_monitoring/endpoints/organization_ai_conversation_details.py @@ -471,7 +471,7 @@ def on_results( None, ) projects_by_id = {project.id: project for project in resolved_params.projects} - project = projects_by_id.get(project_id) + project = projects_by_id.get(project_id) if project_id is not None else None return { "conversationId": conversation_id, "title": self._resolve_title(conversation_id, spans, organization), diff --git a/src/sentry/ai_monitoring/endpoints/organization_ai_conversations.py b/src/sentry/ai_monitoring/endpoints/organization_ai_conversations.py index 370c911620e3..f0293ee1d5dd 100644 --- a/src/sentry/ai_monitoring/endpoints/organization_ai_conversations.py +++ b/src/sentry/ai_monitoring/endpoints/organization_ai_conversations.py @@ -273,7 +273,7 @@ def _get_conversations( response: list[AIConversationResponse] = [] for conversation in conversations: project_id = conversation["projectId"] - project = projects_by_id.get(project_id) + project = projects_by_id.get(project_id) if project_id is not None else None response.append( { **conversation, diff --git a/src/sentry/ai_monitoring/message_normalizer.py b/src/sentry/ai_monitoring/message_normalizer.py index aa84d09dee6a..84fdb801393f 100644 --- a/src/sentry/ai_monitoring/message_normalizer.py +++ b/src/sentry/ai_monitoring/message_normalizer.py @@ -429,12 +429,11 @@ def _text_from_part( part: dict[str, Any], *, strip: bool = False, content_first: bool = False ) -> str | None: fields = ("content", "text") if content_first else ("text", "content") - text = next( - (part[field] for field in fields if isinstance(part.get(field), str) and part[field]), None - ) - if text is None: - return None - return text.strip() if strip else text + for field in fields: + text = part.get(field) + if isinstance(text, str) and text: + return text.strip() if strip else text + return None def _redacted_file_content(part: dict[str, Any]) -> str: diff --git a/src/sentry/ai_monitoring/utils.py b/src/sentry/ai_monitoring/utils.py index 97bd64f1262f..f1d275ac2d9a 100644 --- a/src/sentry/ai_monitoring/utils.py +++ b/src/sentry/ai_monitoring/utils.py @@ -42,7 +42,9 @@ def timestamp_to_float(value: Any) -> float: if isinstance(value, (int, float)): return float(value) if hasattr(value, "timestamp"): - return value.timestamp() + timestamp = value.timestamp() + if isinstance(timestamp, (int, float)): + return float(timestamp) if isinstance(value, str): try: return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() diff --git a/src/sentry/analytics/events/onboarding_complete.py b/src/sentry/analytics/events/onboarding_complete.py index a1bb9186c237..2063b80faf6b 100644 --- a/src/sentry/analytics/events/onboarding_complete.py +++ b/src/sentry/analytics/events/onboarding_complete.py @@ -3,7 +3,7 @@ @analytics.eventclass("onboarding.complete") class OnboardingCompleteEvent(analytics.Event): - user_id: int + user_id: int | None organization_id: int referrer: str diff --git a/src/sentry/api/authentication.py b/src/sentry/api/authentication.py index 4f6919e2ca7a..cdaaa07cbaa9 100644 --- a/src/sentry/api/authentication.py +++ b/src/sentry/api/authentication.py @@ -5,13 +5,13 @@ import logging import time from collections.abc import Callable, Iterable -from typing import Any, ClassVar, NoReturn +from typing import Any, ClassVar, NoReturn, cast import sentry_sdk from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.core.cache import cache -from django.http import HttpHeaders +from django.http import HttpHeaders, HttpRequest from django.urls import resolve from django.utils.crypto import constant_time_compare from django.utils.encoding import force_str @@ -99,7 +99,7 @@ def __call__(self, decorated_obj: Any) -> Any: ) -def is_internal_relay(request, public_key): +def is_internal_relay(request: HttpRequest, public_key: str) -> bool: """ Checks if the relay is trusted (authorized for all project configs) """ @@ -128,7 +128,7 @@ def is_internal_relay(request, public_key): return False -def is_static_relay(request): +def is_static_relay(request: HttpRequest) -> bool: """ Checks if the request comes from a statically configured relay @@ -136,7 +136,7 @@ def is_static_relay(request): """ relay_id = get_header_relay_id(request) static_relays = settings.SENTRY_RELAY_STATIC_AUTH - relay_info = static_relays.get(relay_id) + relay_info = static_relays.get(relay_id) if relay_id is not None else None if relay_info is not None: metrics.incr( @@ -179,7 +179,7 @@ def relay_from_id(request: Request, relay_id: str) -> tuple[Relay | None, bool]: return None, False # no Relay found -def update_token_access_record(auth: object): +def update_token_access_record(auth: object) -> None: """ Perform updates to token models for security purposes (i.e. 'date_last_used') """ @@ -196,7 +196,7 @@ def transform_auth( user: int | User | RpcUser | None | AnonymousUser, request_auth: Any, entity_id_tag: str | None = None, - **tags, + **tags: Any, ) -> tuple[RpcUser | AnonymousUser, AuthenticatedToken | None]: if isinstance(user, int): user = user_service.get_user(user_id=user) @@ -231,7 +231,7 @@ def authenticate_header(self, request: Request) -> str: def authenticate_token(self, request: Request, token_str: str) -> tuple[Any, Any]: raise NotImplementedError - def authenticate(self, request: Request): + def authenticate(self, request: Request) -> tuple[Any, Any] | None: auth = get_authorization_header(request).split() if not self.accepts_auth(auth): @@ -249,7 +249,7 @@ def authenticate(self, request: Request): @AuthenticationSiloLimit(SiloMode.CELL) class RelayAuthentication(BasicAuthentication): - def authenticate(self, request: Request): + def authenticate(self, request: Request) -> tuple[AnonymousUser, None]: relay_id = get_header_relay_id(request) relay_sig = get_header_relay_signature(request) if not relay_id: @@ -259,7 +259,7 @@ def authenticate(self, request: Request): return self.authenticate_credentials(relay_id, relay_sig, request) def authenticate_credentials( - self, relay_id: str, relay_sig: str, request=None + self, relay_id: str, relay_sig: str, request: Request | None = None ) -> tuple[AnonymousUser, None]: sentry_sdk.get_isolation_scope().set_tag("relay_id", relay_id) sentry_sdk.get_isolation_scope().set_attribute("relay_id", relay_id) @@ -294,7 +294,10 @@ class ApiKeyAuthentication(QuietBasicAuthentication): def accepts_auth(self, auth: list[bytes]) -> bool: return bool(auth) and auth[0].lower() == self.token_name - def authenticate_credentials(self, userid, password, request=None): + # justification: DRF's BasicAuthentication stub omits the None this override returns when a password is supplied + def authenticate_credentials( + self, userid: str, password: str | None, request: HttpRequest | None = None + ) -> Any: # We don't use request, but it needs to be passed through to DRF 3.7+. if password: return None @@ -315,7 +318,7 @@ def authenticate_credentials(self, userid, password, request=None): if not key.is_active: raise AuthenticationFailed("Key is disabled") - return self.transform_auth(None, key, "api_key") + return cast(tuple[Any, None], self.transform_auth(None, key, "api_key")) @AuthenticationSiloLimit(SiloMode.CONTROL, SiloMode.CELL) @@ -325,7 +328,7 @@ class SessionNoAuthTokenAuthentication(SessionAuthentication): Rejects any token-based authentication (Bearer, Basic, etc.). """ - def authenticate(self, request: Request): + def authenticate(self, request: Request) -> tuple[Any, Any] | None: # Check if there's any authorization header - if so, reject it auth = get_authorization_header(request) if auth: @@ -346,7 +349,7 @@ class ClientIdSecretAuthentication(QuietBasicAuthentication): For example, the request to exchange a Grant Code for an Api Token. """ - def authenticate(self, request: Request): + def authenticate(self, request: Request) -> tuple[Any, Any] | None: if not request.data: raise AuthenticationFailed("Invalid request") @@ -435,7 +438,7 @@ def _create_cache_key(self, sentry_app_id: int, jti: str) -> str: jti_digest = hashlib.sha256(jti.encode()).hexdigest() return f"jwt_client_secret:{sentry_app_id}:{jti_digest}" - def authenticate(self, request: Request): + def authenticate(self, request: Request) -> tuple[Any, Any] | None: if not request.data: raise AuthenticationFailed("Invalid request") diff --git a/src/sentry/api/base.py b/src/sentry/api/base.py index ec0da82d3129..19e5bda824e6 100644 --- a/src/sentry/api/base.py +++ b/src/sentry/api/base.py @@ -6,7 +6,7 @@ import time from collections.abc import Callable, Iterable, Mapping from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, TypedDict +from typing import TYPE_CHECKING, Any, Never, Protocol, TypedDict, cast from urllib.parse import quote as urlquote import sentry_sdk @@ -58,7 +58,7 @@ from sentry.snuba.query_sources import QuerySource from sentry.utils import metrics from sentry.utils.audit import create_audit_entry -from sentry.utils.cursors import Cursor +from sentry.utils.cursors import Cursor, CursorResult from sentry.utils.dates import to_datetime from sentry.utils.http import ( absolute_uri, @@ -71,6 +71,8 @@ if TYPE_CHECKING: from rest_framework.views import AsView, GenericView + from sentry.models.auditlogentry import AuditLogEntry + from ..utils.pagination_factory import ( annotate_span_with_pagination_args, clamp_pagination_per_page, @@ -142,7 +144,11 @@ def wrapper(self: Endpoint, request: Request, *args: Any, **kwargs: Any) -> Resp return wrapper -def allow_cors_options(func): +class _CorsView(Protocol): + def _allowed_methods(self) -> list[str]: ... + + +def allow_cors_options(func: Callable[..., HttpResponse]) -> Callable[..., HttpResponse]: """ Decorator that adds automatic handling of OPTIONS requests for CORS @@ -155,7 +161,10 @@ def allow_cors_options(func): """ @functools.wraps(func) - def allow_cors_options_wrapper(self, request: Request, *args, **kwargs): + def allow_cors_options_wrapper( + self: _CorsView, request: Request, *args: Any, **kwargs: Any + ) -> HttpResponse: + response: HttpResponse if request.method == "OPTIONS": response = HttpResponse(status=200) response["Access-Control-Max-Age"] = "3600" # don't ask for options again for 1 hour @@ -233,8 +242,13 @@ class BaseEndpointMixin(abc.ABC): @abc.abstractmethod def create_audit_entry( - self, request: Request, transaction_id=None, *, data: dict[str, Any], **kwargs - ): + self, + request: Request, + transaction_id: int | str | None = None, + *, + data: dict[str, Any], + **kwargs: Any, + ) -> AuditLogEntry: pass @abc.abstractmethod @@ -244,18 +258,20 @@ def respond(self, context: object | None = None, **kwargs: Any) -> Response: @abc.abstractmethod def paginate( self, - request, - on_results=None, - paginator=None, - paginator_cls=Paginator, + request: Request, + on_results: Callable[..., object] | None = None, + # justification: dynamic paginators (e.g. ChainPaginator) need not implement PaginatorLike. + paginator: Any = None, + # justification: not every paginator class implements the full PaginatorLike protocol. + paginator_cls: type[Any] = Paginator, default_per_page: int | None = None, max_per_page: int | None = None, - cursor_cls=Cursor, - response_cls=Response, - response_kwargs=None, - count_hits=None, - **paginator_kwargs, - ): + cursor_cls: type[Cursor] = Cursor, + response_cls: type[Response] = Response, + response_kwargs: Mapping[str, Any] | None = None, + count_hits: bool | None = None, + **paginator_kwargs: Any, + ) -> Response: pass @@ -323,10 +339,14 @@ def initial(self, request: Request, *args: Any, **kwargs: Any) -> None: if self.requires_user and principal.is_authenticated and not has_user(principal): raise UserRequired() - def convert_args(self, request: Request, *args, **kwargs): + def convert_args( + self, request: Request, *args: Any, **kwargs: Any + ) -> tuple[tuple[Any, ...], dict[str, Any]]: return (args, kwargs) - def permission_denied(self, request, message=None, code=None): + def permission_denied( + self, request: Request, message: str | None = None, code: str | None = None + ) -> Never: """ Raise a specific superuser exception if the user can become superuser and the only permission class is SuperuserPermission. Otherwise, raises @@ -440,8 +460,13 @@ def handle_exception_with_details( return response def create_audit_entry( - self, request: Request, transaction_id=None, *, data: dict[str, Any], **kwargs - ): + self, + request: Request, + transaction_id: int | str | None = None, + *, + data: dict[str, Any], + **kwargs: Any, + ) -> AuditLogEntry: return create_audit_entry(request, transaction_id, audit_logger, data=data, **kwargs) def initialize_request(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Request: @@ -468,7 +493,7 @@ def has_pagination(self, response: Response) -> bool: @csrf_exempt @allow_cors_options @_with_endpoint_scope_declaration - def dispatch(self, request: Request, *args, **kwargs) -> Response: + def dispatch(self, request: Request, *args: Any, **kwargs: Any) -> Response: """ Identical to rest framework's dispatch except we add the ability to convert arguments (for common URL params). @@ -595,11 +620,13 @@ def dispatch(self, request: Request, *args, **kwargs) -> Response: raise MissingPaginationError(handler.__func__.__qualname__) return self.response - def add_cors_headers(self, request: Request, response): + def add_cors_headers(self, request: Request, response: Response) -> None: response["Access-Control-Allow-Origin"] = request.META["HTTP_ORIGIN"] response["Access-Control-Allow-Methods"] = ", ".join(self.http_method_names) - def add_cursor_headers(self, request: Request, response, cursor_result): + def add_cursor_headers( + self, request: Request, response: Response, cursor_result: CursorResult[Any] + ) -> None: if cursor_result.hits is not None: response["X-Hits"] = cursor_result.hits if cursor_result.max_hits is not None: @@ -614,12 +641,12 @@ def add_cursor_headers(self, request: Request, response, cursor_result): def respond(self, context: object | None = None, **kwargs: Any) -> Response: return Response(context, **kwargs) - def respond_with_text(self, text): + def respond_with_text(self, text: str) -> Response: return self.respond({"text": text}) def get_per_page( self, request: Request, default_per_page: int | None = None, max_per_page: int | None = None - ): + ) -> int: default_per_page = default_per_page or PAGINATION_DEFAULT_PER_PAGE max_per_page = max_per_page or 100 @@ -632,7 +659,9 @@ def get_per_page( except ValueError as e: raise ParseError(detail=str(e)) - def get_cursor_from_request(self, request: Request, cursor_cls=Cursor): + def get_cursor_from_request( + self, request: Request, cursor_cls: type[Cursor] = Cursor + ) -> Cursor | None: try: return get_cursor(request.GET.get(self.cursor_name), cursor_cls) except ValueError as e: @@ -640,18 +669,20 @@ def get_cursor_from_request(self, request: Request, cursor_cls=Cursor): def paginate( self, - request, - on_results=None, - paginator=None, - paginator_cls=Paginator, + request: Request, + on_results: Callable[..., object] | None = None, + # justification: dynamic paginators (e.g. ChainPaginator) need not implement PaginatorLike. + paginator: Any = None, + # justification: not every paginator class implements the full PaginatorLike protocol. + paginator_cls: type[Any] = Paginator, default_per_page: int | None = None, max_per_page: int | None = None, - cursor_cls=Cursor, - response_cls=Response, - response_kwargs=None, - count_hits=None, - **paginator_kwargs, - ): + cursor_cls: type[Cursor] = Cursor, + response_cls: type[Response] = Response, + response_kwargs: Mapping[str, Any] | None = None, + count_hits: bool | None = None, + **paginator_kwargs: Any, + ) -> Response: try: per_page = self.get_per_page(request, default_per_page, max_per_page) cursor = self.get_cursor_from_request(request, cursor_cls) @@ -710,7 +741,10 @@ class StatsArgsDict(TypedDict): class StatsMixin: def _parse_args( - self, request: Request, environment_id=None, restrict_rollups=True + self, + request: Request, + environment_id: int | None = None, + restrict_rollups: bool = True, ) -> StatsArgsDict: """ Parse common stats parameters from the query string. This includes @@ -761,7 +795,7 @@ def _parse_args( "start": start, "end": end, "rollup": resolution, - "environment_ids": environment_id and [environment_id], + "environment_ids": cast("list[int]", environment_id and [environment_id]), } def _parse_resolution(self, value: str) -> int: @@ -778,7 +812,9 @@ def _parse_resolution(self, value: str) -> int: class ReleaseAnalyticsMixin: - def track_set_commits_local(self, request: Request, organization_id=None, project_ids=None): + def track_set_commits_local( + self, request: Request, organization_id: int, project_ids: list[int] | None = None + ) -> None: try: analytics.record( ReleaseSetCommitsLocalEvent( 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/bases/organizationmember.py b/src/sentry/api/bases/organizationmember.py index 284b8fcf8eaa..2013f2018f58 100644 --- a/src/sentry/api/bases/organizationmember.py +++ b/src/sentry/api/bases/organizationmember.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Literal, NotRequired, TypedDict +from typing import Any, Literal, NotRequired, TypedDict, cast from rest_framework import serializers from rest_framework.fields import empty @@ -68,7 +68,8 @@ def to_internal_value(self, data: float | int | str) -> Any: def run_validation(self, data: object | None = empty) -> object | None: if data == "me": return data - return super().run_validation(data) + # justification: DRF does not annotate Field.run_validation. + return cast("object | None", super().run_validation(data)) class MemberSerializer(serializers.Serializer[dict[str, int | Literal["me"]]]): diff --git a/src/sentry/api/bases/project.py b/src/sentry/api/bases/project.py index c1f5e2b041af..867fae6afc16 100644 --- a/src/sentry/api/bases/project.py +++ b/src/sentry/api/bases/project.py @@ -58,7 +58,7 @@ def has_object_permission(self, request: Request, view: APIView, project: Projec assert request.method is not None allowed_scopes = set(self.scope_map.get(request.method, [])) - return request.access.has_any_project_scope(project, allowed_scopes) + return bool(request.access.has_any_project_scope(project, allowed_scopes)) class ProjectAndStaffPermission(StaffPermissionMixin, ProjectPermission): diff --git a/src/sentry/api/bases/team.py b/src/sentry/api/bases/team.py index 1b7abcadf615..1e4250b24348 100644 --- a/src/sentry/api/bases/team.py +++ b/src/sentry/api/bases/team.py @@ -31,7 +31,7 @@ def has_object_permission(self, request: Request, view: APIView, team: Any) -> b if has_org_scope: # Org-admin has "team:admin", but they can only act on their teams # Org-owners and Org-managers have no restrictions due to team memberships - return request.access.has_team_access(team) + return bool(request.access.has_team_access(team)) return has_team_permission(request, team, self.scope_map) diff --git a/src/sentry/api/client.py b/src/sentry/api/client.py index 81609a666bd2..ba627d6b6b6e 100644 --- a/src/sentry/api/client.py +++ b/src/sentry/api/client.py @@ -1,11 +1,16 @@ from __future__ import annotations -from typing import TypeAlias +from collections.abc import Mapping +from typing import Any, TypeAlias, cast import orjson from django.conf import settings +from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.models import AnonymousUser +from django.http import HttpRequest from django.urls import resolve +from rest_framework.authtoken.models import Token +from rest_framework.response import Response from rest_framework.test import APIRequestFactory, force_authenticate from sentry.auth.principal import set_request_principal @@ -16,7 +21,7 @@ class ApiError(Exception): - def __init__(self, status_code, body): + def __init__(self, status_code: int, body: object) -> None: self.status_code = status_code self.body = body @@ -32,23 +37,25 @@ class ApiClient: ApiError: TypeAlias = ApiError - def request(self, *args, **kwargs): + def request( + self, *args: Any, **kwargs: Any + ) -> Response: # justification: variadic forwarding to _request. # The called view sets its own caller on the viewer context; keep it out of ours. with viewer_context_scope(get_viewer_context()): return self._request(*args, **kwargs) def _request( self, - method, - path, - user=None, - auth=None, - params=None, - data=None, - is_sudo=None, - is_superuser=None, - request=None, - ): + method: str, + path: str, + user: AbstractBaseUser | AnonymousUser | None = None, + auth: object = None, + params: Mapping[str, object] | None = None, + data: Mapping[str, object] | None = None, + is_sudo: bool | None = None, + is_superuser: bool | None = None, + request: HttpRequest | None = None, + ) -> Response: if self.prefix not in path: full_path = self.prefix + path else: @@ -97,7 +104,7 @@ def _request( # superuser checks require access to IP mock_request.META["REMOTE_ADDR"] = request.META["REMOTE_ADDR"] - force_authenticate(mock_request, user, auth) + force_authenticate(mock_request, user, cast(Token | None, auth)) if params: mock_request.GET._mutable = True @@ -116,19 +123,28 @@ def _request( response = callback(mock_request, *callback_args, **callback_kwargs) if 200 <= response.status_code < 400: - return response + # justification: requests_mock callbacks return a PreparedResponse. + return cast("Response[Any]", response) raise self.ApiError(response.status_code, response.data) - def get(self, *args, **kwargs): + def get( + self, *args: Any, **kwargs: Any + ) -> Response: # justification: variadic forwarding to request. return self.request("GET", *args, **kwargs) - def post(self, *args, **kwargs): + def post( + self, *args: Any, **kwargs: Any + ) -> Response: # justification: variadic forwarding to request. return self.request("POST", *args, **kwargs) - def put(self, *args, **kwargs): + def put( + self, *args: Any, **kwargs: Any + ) -> Response: # justification: variadic forwarding to request. return self.request("PUT", *args, **kwargs) - def delete(self, *args, **kwargs): + def delete( + self, *args: Any, **kwargs: Any + ) -> Response: # justification: variadic forwarding to request. return self.request("DELETE", *args, **kwargs) diff --git a/src/sentry/api/decorators.py b/src/sentry/api/decorators.py index 800484faac8c..6f38ca66ea4f 100644 --- a/src/sentry/api/decorators.py +++ b/src/sentry/api/decorators.py @@ -1,6 +1,6 @@ from collections.abc import Callable from functools import wraps -from typing import Concatenate +from typing import Concatenate, cast from django.contrib.auth.models import AnonymousUser from django.http.response import HttpResponseBase @@ -52,7 +52,8 @@ def wrapped(self: T, request: Request, *args: P.args, **kwargs: P.kwargs) -> Htt require_sudo(request) return func(self, request, *args, **kwargs) - return wrapped + # justification: wraps preserves the decorated view's signature. + return cast("_RFMethod[T, P]", wrapped) def primary_email_verification_required[T, **P](func: _RFMethod[T, P]) -> _RFMethod[T, P]: @@ -62,4 +63,5 @@ def wrapped(self: T, request: Request, *args: P.args, **kwargs: P.kwargs) -> Htt raise PrimaryEmailVerificationRequired(request.user) return func(self, request, *args, **kwargs) - return wrapped + # justification: wraps preserves the decorated view's signature. + return cast("_RFMethod[T, P]", wrapped) diff --git a/src/sentry/api/endpoints/accept_organization_invite.py b/src/sentry/api/endpoints/accept_organization_invite.py index c67ab713f262..95bb0911c033 100644 --- a/src/sentry/api/endpoints/accept_organization_invite.py +++ b/src/sentry/api/endpoints/accept_organization_invite.py @@ -58,9 +58,9 @@ def convert_args( member_id: str, token: str, organization_id_or_slug: str, - *args, - **kwargs, - ): + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: # Demo users will be logged out in get() before processing the invite, # so we should fetch the invite context as if they were anonymous. # This matches the original behavior where logout happened before get_invite_state(). @@ -93,7 +93,7 @@ def get( member_id: str, token: str, invite_context: RpcUserInviteContext, - **kwargs, + **kwargs: object, ) -> Response | HttpResponse: # Demo user can't accept invites, this invite is probably meant for another user # so we log out the demo user and let the invite flow continue since it can handle @@ -201,7 +201,7 @@ def post( request: Request, token: str, invite_context: RpcUserInviteContext, - **kwargs, + **kwargs: object, ) -> Response: if is_demo_user(request.user): return Response(status=status.HTTP_403_FORBIDDEN) diff --git a/src/sentry/api/endpoints/accept_project_transfer.py b/src/sentry/api/endpoints/accept_project_transfer.py index 15c59b1cb4ba..38da44e92985 100644 --- a/src/sentry/api/endpoints/accept_project_transfer.py +++ b/src/sentry/api/endpoints/accept_project_transfer.py @@ -1,4 +1,5 @@ import logging +from typing import cast from django.core.signing import BadSignature, SignatureExpired from django.http import Http404 @@ -43,20 +44,21 @@ class AcceptProjectTransferEndpoint(Endpoint): authentication_classes = (SessionAuthentication,) permission_classes = (SentryIsAuthenticated,) - def get_validated_data(self, data, user: User): + def get_validated_data(self, data: str, user: User) -> tuple[dict[str, object], Project]: try: - data = unsign(force_str(data), salt=SALT) + payload = cast(dict[str, object], unsign(force_str(data), salt=SALT)) except SignatureExpired: raise InvalidPayload("Project transfer link has expired.") except BadSignature: raise InvalidPayload("Could not approve transfer, please make sure link is valid.") - if data["user_id"] != user.id: + if payload["user_id"] != user.id: raise InvalidPayload("Invalid permissions") try: project = Project.objects.get( - id=data["project_id"], organization_id=data["from_organization_id"] + id=cast(str | int, payload["project_id"]), + organization_id=cast(str | int, payload["from_organization_id"]), ) except Project.DoesNotExist: raise InvalidPayload("Project no longer exists") @@ -64,10 +66,10 @@ def get_validated_data(self, data, user: User): expected_transaction_id = ProjectOption.objects.get_value( project, "sentry:project-transfer-transaction-id" ) - if data["transaction_id"] != expected_transaction_id: + if payload["transaction_id"] != expected_transaction_id: raise InvalidPayload("Invalid transaction id") - return data, project + return payload, project @sudo_required @deprecated(CELL_API_DEPRECATION_DATE, url_names=["sentry-api-0-accept-project-transfer"]) @@ -81,7 +83,7 @@ def get(self, request: Request) -> Response: raise Http404 try: - data, project = self.get_validated_data(data, request.user) + _payload, project = self.get_validated_data(data, request.user) except InvalidPayload as e: return Response({"detail": str(e)}, status=400) @@ -113,11 +115,12 @@ def post(self, request: Request) -> Response: raise Http404 try: - data, project = self.get_validated_data(data, request.user) + payload, project = self.get_validated_data(data, request.user) except InvalidPayload as e: return Response({"detail": str(e)}, status=400) - transaction_id = data["transaction_id"] + # justification: `payload` is decoded signed JSON validated against the stored option. + transaction_id = cast("int | str | None", payload["transaction_id"]) org_slug = request.data.get("organization") # DEPRECATED diff --git a/src/sentry/api/endpoints/admin_project_configs.py b/src/sentry/api/endpoints/admin_project_configs.py index e556dc100095..762a9c7beebd 100644 --- a/src/sentry/api/endpoints/admin_project_configs.py +++ b/src/sentry/api/endpoints/admin_project_configs.py @@ -12,7 +12,7 @@ from sentry.models.project import Project from sentry.models.projectkey import ProjectKey from sentry.relay import projectconfig_cache -from sentry.relay.config import ProjectConfig, get_project_config +from sentry.relay.config import get_project_config from sentry.tasks.relay import schedule_invalidate_project_config @@ -57,7 +57,7 @@ def get(self, request: Request) -> Response: project_keys = self._get_project_keys(project, supplied_project_key) - configs: MutableMapping[str, MutableMapping[str, Any] | ProjectConfig | None] = {} + configs: MutableMapping[str, MutableMapping[str, Any] | None] = {} uncached_keys = [] for project_key in project_keys: if isinstance(project_key, ProjectKey) and project_key.public_key is not None: 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/artifact_bundles.py b/src/sentry/api/endpoints/artifact_bundles.py index 7807d2915b62..bf3e4506b3b7 100644 --- a/src/sentry/api/endpoints/artifact_bundles.py +++ b/src/sentry/api/endpoints/artifact_bundles.py @@ -17,6 +17,7 @@ from sentry.api.serializers import serialize from sentry.api.serializers.models.artifactbundle import ArtifactBundlesSerializer from sentry.models.artifactbundle import ArtifactBundle, ProjectArtifactBundle +from sentry.models.project import Project from sentry.utils.db import atomic_transaction # We want to keep a mapping of the fields that the frontend uses for filtering since we want to align the UI names and @@ -43,7 +44,7 @@ def derive_order_by(cls, sort_by: str) -> str | None: raise InvalidSortByParameter @classmethod - def is_valid_uuid(cls, value): + def is_valid_uuid(cls, value: object) -> bool: try: uuid.UUID(str(value)) return True @@ -60,7 +61,7 @@ class ArtifactBundlesEndpoint(ProjectEndpoint, ArtifactBundlesMixin): owner = ApiOwner.COMMUNITY permission_classes = (ProjectReleasePermission,) - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project: Project) -> Response: """ List a Project's Artifact Bundles ```````````````````````````````````` @@ -125,7 +126,7 @@ def get(self, request: Request, project) -> Response: on_results=lambda r: serialize(r, request.user, ArtifactBundlesSerializer()), ) - def delete(self, request: Request, project) -> Response: + def delete(self, request: Request, project: Project) -> Response: """ Delete an Archive ``````````````````````````````````````````````````` diff --git a/src/sentry/api/endpoints/artifact_lookup.py b/src/sentry/api/endpoints/artifact_lookup.py index f5de91eaf715..a0c80bf4515d 100644 --- a/src/sentry/api/endpoints/artifact_lookup.py +++ b/src/sentry/api/endpoints/artifact_lookup.py @@ -4,7 +4,13 @@ from typing import NotRequired, TypedDict from django.db.models.query import QuerySet -from django.http import Http404, HttpResponse, HttpResponseBadRequest, StreamingHttpResponse +from django.http import ( + Http404, + HttpResponse, + HttpResponseBadRequest, + HttpResponseBase, + StreamingHttpResponse, +) from rest_framework.request import Request from rest_framework.response import Response from symbolic.debuginfo import normalize_debug_id @@ -57,14 +63,14 @@ class ProjectArtifactLookupEndpoint(ProjectEndpoint): } permission_classes = (ProjectReleasePermission,) - def download_file(self, download_id, project: Project): + def download_file(self, download_id: str, project: Project) -> HttpResponseBase: split = download_id.split("/") if len(split) < 2: return HttpResponseBadRequest(content=b"Invalid download ID") - ty, ty_id, *_rest = split + ty, ty_id_str, *_rest = split try: - ty_id = int(ty_id) + ty_id = int(ty_id_str) except ValueError: return HttpResponseBadRequest(content=b"Invalid download ID") @@ -118,13 +124,13 @@ def download_file(self, download_id, project: Project): response = StreamingHttpResponse( iter(lambda: fp.read(4096), b""), content_type="application/octet-stream" ) - response["Content-Length"] = file.size + response["Content-Length"] = str(file.size) response["Content-Disposition"] = f'attachment; filename="{file.name}"' return response except OSError: raise Http404 - def get(self, request: Request, project: Project) -> Response: + def get(self, request: Request, project: Project) -> Response | HttpResponseBase: """ List a Project's Individual Artifacts or Bundles ```````````````````````````````````````` diff --git a/src/sentry/api/endpoints/assistant.py b/src/sentry/api/endpoints/assistant.py index b774be068900..075f192960bf 100644 --- a/src/sentry/api/endpoints/assistant.py +++ b/src/sentry/api/endpoints/assistant.py @@ -29,13 +29,13 @@ class AssistantSerializer(serializers.Serializer): status = serializers.ChoiceField(choices=list(zip(VALID_STATUSES, VALID_STATUSES))) useful = serializers.BooleanField(required=False) - def validate_guide_id(self, value): + def validate_guide_id(self, value: int) -> int: valid_ids = manager.get_valid_ids() if value not in valid_ids: raise serializers.ValidationError("Not a valid assistant guide_id") return value - def validate(self, attrs): + def validate(self, attrs: dict[str, object]) -> dict[str, object]: attrs = super().validate(attrs) guide = attrs.get("guide") guide_id = attrs.get("guide_id") @@ -78,7 +78,7 @@ def get(self, request: Request) -> Response: [{"guide": key, "seen": id in seen_ids} for key, id in manager.all().items()] ) - def put(self, request: Request): + def put(self, request: Request) -> Response | HttpResponse: """Mark a guide as viewed or dismissed. Request is of the form { diff --git a/src/sentry/api/endpoints/auth_config.py b/src/sentry/api/endpoints/auth_config.py index 9a557dce5602..ee2177cd56f1 100644 --- a/src/sentry/api/endpoints/auth_config.py +++ b/src/sentry/api/endpoints/auth_config.py @@ -3,7 +3,7 @@ from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME from django.http.request import HttpRequest -from django.http.response import HttpResponseBase +from django.http.response import HttpResponse from rest_framework.request import Request from rest_framework.response import Response @@ -53,11 +53,11 @@ class AuthConfigEndpoint(Endpoint, OrganizationMixin): # Disable authentication and permission requirements. permission_classes = () - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: self.active_organization = determine_active_organization(request) return super().dispatch(request, *args, **kwargs) - def get(self, request: Request, *args, **kwargs) -> Response: + def get(self, request: Request, *args: object, **kwargs: object) -> Response: """ Get context required to show a login page. Registration is handled elsewhere. """ @@ -97,7 +97,7 @@ def respond_with_login_context(self, request: Request, payload: AuthConfigRespon return response - def respond_authenticated(self, request: Request): + def respond_authenticated(self, request: Request) -> Response: next_uri = self.get_next_uri(request, consume=False) if not is_valid_redirect(next_uri, allowed_hosts=(request.get_host(),)): @@ -113,7 +113,9 @@ def get_next_uri(self, request: HttpRequest, *, consume: bool = True) -> str: ) return request.GET.get(REDIRECT_FIELD_NAME, next_uri_fallback) or "" - def prepare_login_context(self, request: Request, *args, **kwargs) -> AuthConfigResponse: + def prepare_login_context( + self, request: Request, *args: object, **kwargs: object + ) -> AuthConfigResponse: can_register = bool(has_user_registration() or request.session.get("can_register")) context: dict[str, object] = { diff --git a/src/sentry/api/endpoints/auth_index.py b/src/sentry/api/endpoints/auth_index.py index ec142e3e9721..5aba13a0bf2b 100644 --- a/src/sentry/api/endpoints/auth_index.py +++ b/src/sentry/api/endpoints/auth_index.py @@ -1,8 +1,10 @@ import logging +from typing import Any from django.conf import settings from django.contrib.auth import logout from django.contrib.auth.models import AnonymousUser +from django.http import HttpRequest from django.utils.http import url_has_allowed_host_and_scheme from rest_framework import status from rest_framework.authentication import SessionAuthentication @@ -137,7 +139,8 @@ class AuthIndexEndpoint(BaseAuthIndexEndpoint): and simple HTTP authentication. """ - def initialize_request(self, request, *args, **kwargs): + # justification: framework kwargs + def initialize_request(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Request: rv = super().initialize_request(request, *args, **kwargs) # Allow Bearer token authentication for GET (whoami) only. # POST/PUT/DELETE are session-management operations where Bearer auth @@ -361,7 +364,8 @@ def put(self, request: Request) -> Response: return self.get(request) - def delete(self, request: Request, *args, **kwargs) -> Response: + # justification: framework kwargs + def delete(self, request: Request, *args: Any, **kwargs: Any) -> Response: """ Logout the Authenticated User ````````````````````````````` diff --git a/src/sentry/api/endpoints/auth_login.py b/src/sentry/api/endpoints/auth_login.py index d82044e2bacd..5a9a7dcd12bd 100644 --- a/src/sentry/api/endpoints/auth_login.py +++ b/src/sentry/api/endpoints/auth_login.py @@ -1,6 +1,7 @@ +from collections.abc import Mapping from typing import TypedDict -from django.http import HttpRequest +from django.http import HttpRequest, HttpResponse from django.urls import reverse from drf_spectacular.utils import extend_schema from rest_framework import serializers @@ -100,7 +101,7 @@ def get_password_organization( return None - def dispatch(self, request: HttpRequest, *args, **kwargs) -> Response: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: self.active_organization = determine_active_organization(request) return super().dispatch(request, *args, **kwargs) @@ -109,7 +110,7 @@ def dispatch(self, request: HttpRequest, *args, **kwargs) -> Response: request=AuthLoginRequestSerializer, responses={200: AuthSuccessSerializer, 202: AuthMfaRequiredSerializer}, ) - def post(self, request: Request, *args, **kwargs) -> Response: + def post(self, request: Request, *args: object, **kwargs: object) -> Response: """ Process a login request via username/password. SSO login is handled elsewhere. @@ -142,6 +143,9 @@ def post(self, request: Request, *args, **kwargs) -> Response: return self.respond_with_error(login_form.errors) user = login_form.get_user() + if user is None: + metrics.incr("login.attempt", instance="failure", skip_internal=True, sample_rate=1.0) + return self.respond_with_error({"__all__": ["Invalid username or password."]}) if getattr(user, "is_suspended", False): metrics.incr("login.attempt", instance="failure", skip_internal=True, sample_rate=1.0) @@ -189,5 +193,5 @@ def post(self, request: Request, *args, **kwargs) -> Response: return Response(payload) - def respond_with_error(self, errors): + def respond_with_error(self, errors: Mapping[str, object]) -> Response: return Response({"detail": "Login attempt failed", "errors": errors}, status=400) diff --git a/src/sentry/api/endpoints/broadcast_details.py b/src/sentry/api/endpoints/broadcast_details.py index 7bc745a440e6..19e55df5889f 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: @@ -56,17 +56,21 @@ def convert_args( kwargs["broadcast"] = broadcast return args, kwargs - def _get_validator(self, request: Request): + def _get_validator( + self, request: Request + ) -> type[BroadcastValidator] | type[AdminBroadcastValidator]: if request.access.has_permission("broadcasts.admin"): return AdminBroadcastValidator return BroadcastValidator - def _get_serializer(self, request: Request): + def _get_serializer( + self, request: Request + ) -> type[BroadcastSerializer] | type[AdminBroadcastSerializer]: if request.access.has_permission("broadcasts.admin"): return AdminBroadcastSerializer return BroadcastSerializer - def _serialize_response(self, request: Request, broadcast): + def _serialize_response(self, request: Request, broadcast: Broadcast) -> Response: serializer_cls = self._get_serializer(request) return self.respond(serialize(broadcast, request.user, serializer=serializer_cls())) diff --git a/src/sentry/api/endpoints/broadcast_index.py b/src/sentry/api/endpoints/broadcast_index.py index 6b93cebd0b08..8c98991c58e9 100644 --- a/src/sentry/api/endpoints/broadcast_index.py +++ b/src/sentry/api/endpoints/broadcast_index.py @@ -3,6 +3,7 @@ import logging from functools import reduce from operator import or_ +from typing import Any from django.db import IntegrityError, router, transaction from django.db.models import Q @@ -36,20 +37,31 @@ class BroadcastIndexEndpoint(ControlSiloOrganizationEndpoint): } permission_classes = (OrganizationPermission,) - def _get_serializer(self, request: Request): + def _get_serializer(self, request: Request) -> type[BroadcastSerializer]: if request.access.has_permission("broadcasts.admin"): return AdminBroadcastSerializer return BroadcastSerializer - def _serialize_objects(self, items, request): + # justification: Django view args + def _serialize_objects(self, items: Any, request: Request) -> Any: serializer_cls = self._get_serializer(request) return serialize(items, request.user, serializer=serializer_cls()) - def _secondary_filtering(self, request: Request, organization_slug, queryset): + def _secondary_filtering( + self, + request: Request, + organization_slug: RpcOrganization | str | None, + queryset: Any, # justification: Django view args + ) -> list[Broadcast]: # used in the SAAS product return list(queryset) - def convert_args(self, request: Request, *args, **kwargs): + def convert_args( + self, + request: Request, + *args: Any, # justification: Django view args + **kwargs: Any, # justification: framework kwargs + ) -> tuple[tuple[Any, ...], dict[str, Any]]: # justification: Django view args organization_id_or_slug: int | str | None = None if args and args[0] is not None: organization_id_or_slug = args[0] @@ -66,7 +78,10 @@ def convert_args(self, request: Request, *args, **kwargs): return (args, kwargs) def get( - self, request: Request, organization: RpcOrganization | None = None, **kwargs + self, + request: Request, + organization: RpcOrganization | None = None, + **kwargs: Any, # justification: framework kwargs ) -> Response: limit = None if request.GET.get("show") == "all" and request.access.has_permission("broadcasts.admin"): diff --git a/src/sentry/api/endpoints/builtin_symbol_sources.py b/src/sentry/api/endpoints/builtin_symbol_sources.py index db9982b4291b..30218df62a04 100644 --- a/src/sentry/api/endpoints/builtin_symbol_sources.py +++ b/src/sentry/api/endpoints/builtin_symbol_sources.py @@ -1,4 +1,5 @@ -from typing import cast +from collections.abc import Mapping +from typing import TypedDict, cast from django.conf import settings from rest_framework.request import Request @@ -13,11 +14,18 @@ from sentry.utils.console_platforms import organization_has_console_platform_access -def normalize_symbol_source(key, source): +class NormalizedSymbolSource(TypedDict): + sentry_key: str + id: str + name: str + hidden: bool + + +def normalize_symbol_source(key: str, source: Mapping[str, object]) -> NormalizedSymbolSource: return { "sentry_key": key, - "id": source["id"], - "name": source["name"], + "id": cast(str, source["id"]), + "name": cast(str, source["name"]), "hidden": bool(source.get("hidden")), } @@ -29,10 +37,10 @@ class BuiltinSymbolSourcesEndpoint(OrganizationEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, organization: Organization, **kwargs) -> Response: + def get(self, request: Request, organization: Organization, **kwargs: object) -> Response: platform = request.GET.get("platform") - sources = [] + sources: list[NormalizedSymbolSource] = [] for key, source in settings.SENTRY_BUILTIN_SOURCES.items(): source_platforms: list[str] | None = cast("list[str] | None", source.get("platforms")) 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/chunk.py b/src/sentry/api/endpoints/chunk.py index f1962f9e8d8a..1fccc4d6dc81 100644 --- a/src/sentry/api/endpoints/chunk.py +++ b/src/sentry/api/endpoints/chunk.py @@ -1,16 +1,19 @@ import logging import re +from collections.abc import Iterator from gzip import BadGzipFile, GzipFile from io import BytesIO -from typing import TypedDict +from typing import Protocol, TypedDict, cast import zstandard from django.conf import settings +from django.core.files.uploadedfile import UploadedFile from django.urls import reverse from drf_spectacular.utils import extend_schema from rest_framework import serializers, status 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.api_owners import ApiOwner @@ -30,6 +33,10 @@ from sentry.models.files.fileblob import FileBlob from sentry.models.files.utils import MAX_FILE_SIZE from sentry.models.organization import Organization +from sentry.organizations.services.organization.model import ( + RpcOrganization, + RpcUserOrganizationContext, +) from sentry.preprod.authentication import LaunchpadRpcSignatureAuthentication from sentry.ratelimits.config import RateLimitConfig from sentry.utils.http import absolute_uri @@ -67,7 +74,11 @@ class ChunkTooLarge(OSError): """Raised when a (possibly decompressed) chunk exceeds the per-chunk size limit.""" -def _read_bounded(reader, limit): +class _ChunkReader(Protocol): + def read(self, size: int) -> bytes: ... + + +def _read_bounded(reader: _ChunkReader, limit: int) -> bytes: """Read up to ``limit`` bytes from ``reader``; raise :class:`ChunkTooLarge` if more. Loops until the reader signals EOF or we exceed ``limit``. The loop matters @@ -87,7 +98,7 @@ def _read_bounded(reader, limit): class GzipChunk(BytesIO): - def __init__(self, file): + def __init__(self, file: UploadedFile) -> None: reader = GzipFile(fileobj=file, mode="rb") data = _read_bounded(reader, settings.SENTRY_CHUNK_UPLOAD_BLOB_SIZE) self.size = len(data) @@ -96,7 +107,7 @@ def __init__(self, file): class ZstdChunk(BytesIO): - def __init__(self, file): + def __init__(self, file: UploadedFile) -> None: # `read_across_frames=True` mirrors the behaviour of `GzipFile.read()` # (which reads across concatenated gzip members) and matches the # convention in `src/sentry/models/eventattachment.py`. Without it a @@ -109,6 +120,9 @@ def __init__(self, file): super().__init__(data) +ChunkFile = UploadedFile | GzipChunk | ZstdChunk + + class ChunkUploadPermission(OrganizationReleasePermission): """ Allow OrganizationReleasePermission OR Launchpad service authentication @@ -120,7 +134,7 @@ def _is_launchpad_authenticated(self, request: Request) -> bool: getattr(request, "successful_authenticator", None), LaunchpadRpcSignatureAuthentication ) - def has_permission(self, request: Request, view) -> bool: + def has_permission(self, request: Request, view: APIView) -> bool: # Allow access for Launchpad service authentication if self._is_launchpad_authenticated(request): return True @@ -128,7 +142,12 @@ def has_permission(self, request: Request, view) -> bool: # Fall back to standard organization permission check return super().has_permission(request, view) - def has_object_permission(self, request: Request, view, organization) -> bool: + def has_object_permission( + self, + request: Request, + view: APIView, + organization: Organization | RpcOrganization | RpcUserOrganizationContext, + ) -> bool: # Allow access for Launchpad service authentication if self._is_launchpad_authenticated(request): return True @@ -282,7 +301,7 @@ def get( }, ) def post( - self, request: Request, organization + self, request: Request, organization: Organization ) -> Response[None] | Response[_ChunkUploadErrorResponse]: """ Upload file chunks so they can be assembled into debug information files or artifact @@ -333,13 +352,14 @@ def post( try: for chunk, name in get_files(request, encoding): - if chunk.size > settings.SENTRY_CHUNK_UPLOAD_BLOB_SIZE: + chunk_size = cast(int, chunk.size) + if chunk_size > settings.SENTRY_CHUNK_UPLOAD_BLOB_SIZE: logger.warning("chunkupload.end", extra={"status": status.HTTP_400_BAD_REQUEST}) return Response( {"error": "Chunk size too large"}, status=status.HTTP_400_BAD_REQUEST ) - total_size += chunk.size + total_size += chunk_size if total_size > MAX_REQUEST_SIZE: logger.warning("chunkupload.end", extra={"status": status.HTTP_400_BAD_REQUEST}) return Response( @@ -370,16 +390,16 @@ def post( return Response(status=status.HTTP_200_OK) -def get_files(request: Request, encoding: str): +def get_files(request: Request, encoding: str) -> Iterator[tuple[ChunkFile, str]]: for chunk in request.FILES.getlist("file"): if encoding == "gzip": - yield GzipChunk(chunk), chunk.name + yield GzipChunk(chunk), cast(str, chunk.name) elif encoding == "zstd": - yield ZstdChunk(chunk), chunk.name + yield ZstdChunk(chunk), cast(str, chunk.name) else: - yield chunk, chunk.name + yield chunk, cast(str, chunk.name) # Legacy: older clients send gzipped chunks under `file_gzip` with no # Content-Encoding header. Prefer `Content-Encoding: gzip` on `file`. for chunk in request.FILES.getlist("file_gzip"): - yield GzipChunk(chunk), chunk.name + yield GzipChunk(chunk), cast(str, chunk.name) diff --git a/src/sentry/api/endpoints/debug_files.py b/src/sentry/api/endpoints/debug_files.py index b2a5c3746476..213707c6e5ed 100644 --- a/src/sentry/api/endpoints/debug_files.py +++ b/src/sentry/api/endpoints/debug_files.py @@ -2,8 +2,9 @@ import posixpath import re import uuid -from collections.abc import Iterable, Mapping, Sequence, Set +from collections.abc import Iterable, Iterator, Mapping, Sequence, Set from contextlib import closing +from datetime import datetime from typing import TYPE_CHECKING, NotRequired, TypedDict, TypeGuard, cast import jsonschema @@ -84,7 +85,13 @@ _release_suffix = re.compile(r"^(.*)\s+\(([^)]+)\)\s*$") -def upload_from_request(request: Request, project: Project): +class _ReleaseRow(TypedDict): + id: int + version: str + date_added: datetime + + +def upload_from_request(request: Request, project: Project) -> Response: if "file" not in request.data: return Response({"detail": "Missing uploaded file"}, status=400) fileobj = request.data["file"] @@ -92,7 +99,7 @@ def upload_from_request(request: Request, project: Project): return Response(serialize(files, request.user), status=201) -def has_download_permission(request: Request, project: Project): +def has_download_permission(request: Request, project: Project) -> bool: if is_system_auth(request.auth) or is_active_superuser(request): return True @@ -108,9 +115,9 @@ def has_download_permission(request: Request, project: Project): if request.user.is_sentry_app: if organization_roles.can_manage("member", required_role): - return request.access.has_scope("project:read") + return bool(request.access.has_scope("project:read")) else: - return request.access.has_scope("project:write") + return bool(request.access.has_scope("project:write")) try: current_role = ( @@ -314,7 +321,9 @@ class DebugFilesEndpoint(ProjectEndpoint): } permission_classes = (ProjectReleasePermission,) - def download(self, debug_file_id, project: Project): + def download( + self, debug_file_id: int, project: Project + ) -> HttpResponse | StreamingHttpResponse: rate_limited = ratelimits.backend.is_limited( project=project, key=f"rl:DSymFilesEndpoint:download:{debug_file_id}:{project.id}", @@ -340,7 +349,7 @@ def download(self, debug_file_id, project: Project): try: fp = debug_file.get_file() - def stream_debug_file(): + def stream_debug_file() -> Iterator[bytes]: try: yield from iter(lambda: fp.read(4096), b"") finally: @@ -381,7 +390,12 @@ def stream_debug_file(): ) def get( self, request: Request, project: Project - ) -> Response[list[DebugFileSerializerResponse]] | Response[DetailResponse]: + ) -> ( + Response[list[DebugFileSerializerResponse]] + | Response[DetailResponse] + | HttpResponse + | StreamingHttpResponse + ): """ Retrieve a list of debug information files for a given project. """ @@ -471,12 +485,15 @@ def get( else: queryset = ProjectDebugFile.objects.filter(q).select_related("file") - def on_results(difs: Sequence[ProjectDebugFile]): + def on_results( + difs: Sequence[ProjectDebugFile], + ) -> list[DebugFileSerializerResponse]: # NOTE: we are only refreshing files if there is direct query for specific files if debug_id and not query and not file_formats: maybe_renew_debug_files(difs) - return serialize(difs, request.user) + serialized: list[DebugFileSerializerResponse] = serialize(difs, request.user) + return serialized return self.paginate( request=request, @@ -678,7 +695,8 @@ def batch_assemble( "state": state, "detail": None, "missingChunks": [], - "dif": detail, + # justification: assemble status detail is decoded JSON holding the serialized DIF. + "dif": cast("DebugFileSerializerResponse", detail), } files_to_check.pop(checksum) elif state is not None and state != ChunkFileState.OK: @@ -1094,7 +1112,7 @@ def get(self, request: Request, project: Project) -> Response: queryset = queryset.filter(query_q) - def expose_release(release, count: int): + def expose_release(release: _ReleaseRow, count: int) -> dict[str, object]: return { "type": "release", "id": release["id"], @@ -1103,13 +1121,16 @@ def expose_release(release, count: int): "fileCount": count, } - def serialize_results(results): + def serialize_results( + results: Sequence[_ReleaseRow], + ) -> list[dict[str, object]]: file_count_map = get_artifact_counts([r["id"] for r in results]) # In case we didn't find a file count for a specific release, we will return -1, signaling to the # frontend that this release doesn't have one or more ReleaseFile. - return serialize( + serialized: list[dict[str, object]] = serialize( [expose_release(r, file_count_map.get(r["id"], -1)) for r in results], request.user ) + return serialized sort_by = request.GET.get("sortBy", "-date_added") if sort_by not in {"-date_added", "date_added"}: 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_attachment_details.py b/src/sentry/api/endpoints/event_attachment_details.py index fa1dbfea3257..f01867424722 100644 --- a/src/sentry/api/endpoints/event_attachment_details.py +++ b/src/sentry/api/endpoints/event_attachment_details.py @@ -1,11 +1,13 @@ import posixpath -from typing import Any +from collections.abc import Iterator +from typing import Any, cast import sentry_sdk from django.http import HttpResponseRedirect, StreamingHttpResponse from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.views import APIView from sentry import features, roles from sentry.api.api_owners import ApiOwner @@ -29,9 +31,14 @@ from sentry.issues.action_log.request import resolve_action_actor, resolve_action_source from sentry.models.activity import Activity from sentry.models.eventattachment import EventAttachment +from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember from sentry.models.project import Project from sentry.objectstore import parse_accept_encoding +from sentry.organizations.services.organization import ( + RpcOrganization, + RpcUserOrganizationContext, +) from sentry.services import eventstore from sentry.types.activity import ActivityType @@ -59,7 +66,13 @@ class EventAttachmentDetailsPermission(ProjectPermission): - def has_object_permission(self, request: Request, view, project): + def has_object_permission( + self, + request: Request, + view: APIView, + project: Project | Organization | RpcOrganization | RpcUserOrganizationContext, + ) -> bool: + project = cast(Project, project) result = super().has_object_permission(request, view, project) if not result: @@ -113,7 +126,7 @@ def download( accept_encoding = parse_accept_encoding(request.headers.get("Accept-Encoding", "")) blob_stream = attachment.get_blob_stream(accept_encoding) - def stream_attachment(): + def stream_attachment() -> Iterator[bytes]: with blob_stream.payload as payload: while chunk := payload.read(4096): yield chunk @@ -121,7 +134,7 @@ def stream_attachment(): response = StreamingHttpResponse(stream_attachment(), content_type=attachment.content_type) if blob_stream.encoding: response["Content-Encoding"] = blob_stream.encoding - else: + elif attachment.size is not None: response["Content-Length"] = attachment.size response["Content-Disposition"] = f'attachment; filename="{name}"' return response 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_agentic_onboarding.py b/src/sentry/api/endpoints/organization_agentic_onboarding.py index a4d968cc1df3..cde161632dc3 100644 --- a/src/sentry/api/endpoints/organization_agentic_onboarding.py +++ b/src/sentry/api/endpoints/organization_agentic_onboarding.py @@ -1,4 +1,4 @@ -from typing import Any, Literal, NotRequired, TypedDict +from typing import Any, Literal, NotRequired, TypedDict, cast from uuid import UUID from drf_spectacular.utils import extend_schema @@ -116,7 +116,8 @@ def deserialize_stage_extra(stage: Stage, value: dict[str, Any]) -> StageExtra: {field: "Unknown extra field" for field in sorted(unknown_fields)} ) serializer.is_valid(raise_exception=True) - return get_stage_definition(stage).parse_extra(serializer.validated_data) + # justification: parse_extra returns the stage's own StageExtra subtype. + return cast("StageExtra", get_stage_definition(stage).parse_extra(serializer.validated_data)) class AgenticOnboardingStatusRequestSerializer(CamelSnakeSerializer[AgenticOnboardingStatusData]): diff --git a/src/sentry/api/endpoints/organization_api_key_details.py b/src/sentry/api/endpoints/organization_api_key_details.py index a89d0eebb1e0..0106e6305c43 100644 --- a/src/sentry/api/endpoints/organization_api_key_details.py +++ b/src/sentry/api/endpoints/organization_api_key_details.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import cast from rest_framework import serializers, status from rest_framework.request import Request @@ -20,12 +20,12 @@ from sentry.organizations.services.organization.model import RpcOrganization -class ApiKeySerializer(serializers.ModelSerializer): +class ApiKeySerializer(serializers.ModelSerializer[ApiKey]): class Meta: model = ApiKey fields = ("label", "scope_list", "allowed_origins") - 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( @@ -45,10 +45,10 @@ class OrganizationApiKeyDetailsEndpoint(ControlSiloOrganizationEndpoint): permission_classes = (OrganizationAdminPermission,) def convert_args( - self, request: Request, api_key_id: str, *args: Any, **kwargs: Any - ) -> tuple[tuple[Any, ...], dict[str, Any]]: + self, request: Request, api_key_id: str, *args: object, **kwargs: object + ) -> tuple[tuple[object, ...], dict[str, object]]: args, kwargs = super().convert_args(request, *args, **kwargs) - organization = kwargs["organization"] + organization = cast(RpcOrganization, kwargs["organization"]) validated_api_key_id = to_valid_int_id("api_key_id", api_key_id, raise_404=True) try: kwargs["api_key"] = ApiKey.objects.get( @@ -59,7 +59,7 @@ def convert_args( return (args, kwargs) - def get(self, request: Request, api_key: ApiKey, **kwargs) -> Response: + def get(self, request: Request, api_key: ApiKey, **kwargs: object) -> Response: """ Retrieves API Key details ````````````````````````` @@ -72,7 +72,11 @@ def get(self, request: Request, api_key: ApiKey, **kwargs) -> Response: return Response(serialize(api_key, request.user)) def put( - self, request: Request, api_key: ApiKey, organization: RpcOrganization, **kwargs + self, + request: Request, + api_key: ApiKey, + organization: RpcOrganization, + **kwargs: object, ) -> Response: """ Update an API Key @@ -104,7 +108,11 @@ def put( return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete( - self, request: Request, api_key: ApiKey, organization: RpcOrganization, **kwargs + self, + request: Request, + api_key: ApiKey, + organization: RpcOrganization, + **kwargs: object, ) -> Response: """ Deletes an API Key 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_events.py b/src/sentry/api/endpoints/organization_events.py index e3c577fb7407..d59644660977 100644 --- a/src/sentry/api/endpoints/organization_events.py +++ b/src/sentry/api/endpoints/organization_events.py @@ -1,7 +1,7 @@ import logging -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from concurrent.futures import as_completed -from typing import Any, TypedDict +from typing import Any, TypedDict, cast import sentry_sdk from drf_spectacular.utils import OpenApiResponse, extend_schema @@ -9,6 +9,7 @@ from rest_framework.exceptions import ParseError from rest_framework.request import Request from rest_framework.response import Response +from sentry_protos.snuba.v1.request_common_pb2 import PageToken from sentry_sdk import traces from sentry import features @@ -329,7 +330,7 @@ def _data_fn( offset: int, limit: int, query: str | None, - ): + ) -> dict[str, Any]: selected_columns = self.get_field_list(organization, request) orderby = self.get_orderby(request) if is_errors_query_for_error_upsampled_projects( @@ -345,26 +346,29 @@ def _data_fn( sentry_sdk.set_tag("query.query_source", query_source.value) sentry_sdk.set_attribute("query.query_source", query_source.value) - return dataset_query( - selected_columns=selected_columns, - query=query or "", - snuba_params=snuba_params, - equations=self.get_equation_list(organization, request), - orderby=orderby, - offset=offset, - limit=limit, - referrer=referrer, - auto_fields=True, - auto_aggregations=True, - allow_metric_aggregates=allow_metric_aggregates, - use_aggregate_conditions=use_aggregate_conditions, - transform_alias_to_input_format=True, - # Whether the flag is enabled or not, regardless of the referrer - has_metrics=True, - on_demand_metrics_enabled=on_demand_metrics_enabled, - on_demand_metrics_type=on_demand_metrics_type, - fallback_to_transactions=True, - query_source=query_source, + return cast( + dict[str, Any], + dataset_query( + selected_columns=selected_columns, + query=query or "", + snuba_params=snuba_params, + equations=self.get_equation_list(organization, request), + orderby=orderby, + offset=offset, + limit=limit, + referrer=referrer, + auto_fields=True, + auto_aggregations=True, + allow_metric_aggregates=allow_metric_aggregates, + use_aggregate_conditions=use_aggregate_conditions, + transform_alias_to_input_format=True, + # Whether the flag is enabled or not, regardless of the referrer + has_metrics=True, + on_demand_metrics_enabled=on_demand_metrics_enabled, + on_demand_metrics_type=on_demand_metrics_type, + fallback_to_transactions=True, + query_source=query_source, + ), ) @traces.trace @@ -374,7 +378,7 @@ def _dashboards_data_fn( limit: int, scoped_query: str | None, dashboard_widget_id: str, - ): + ) -> dict[str, Any]: try: widget = DashboardWidget.objects.get( id=dashboard_widget_id, dashboard__organization_id=organization.id @@ -462,7 +466,7 @@ def _discover_data_fn( limit: int, scoped_query: str | None, discover_saved_query_id: str, - ): + ) -> dict[str, Any]: try: discover_query = DiscoverSavedQuery.objects.get( id=discover_saved_query_id, organization=organization @@ -576,7 +580,10 @@ def _discover_data_fn( sentry_sdk.capture_exception(e) return _data_fn(scoped_dataset_query, offset, limit, scoped_query) - def paginator_factory(scoped_dataset): + # justification: scoped_dataset is one of several dataset modules selected at runtime + def paginator_factory( + scoped_dataset: Any, + ) -> tuple[GenericOffsetPaginator | EAPPageTokenPaginator, type[Cursor]]: """ This factory closes over query and dataset in order to make an additional request to the errors dataset in the case that this request is from a dashboard widget or a discover query and we're trying to split @@ -589,7 +596,7 @@ def paginator_factory(scoped_dataset): discover_saved_query_id = request.GET.get("discoverSavedQueryId", None) additional_queries = self.get_additional_queries(request) - def get_rpc_config(): + def get_rpc_config() -> SearchResolverConfig: if scoped_dataset not in RPC_DATASETS: raise NotImplementedError @@ -699,44 +706,50 @@ def get_rpc_config(): if snuba_params.sampling_mode == "HIGHEST_ACCURACY_FLEX_TIME": - def flex_time_data_fn(limit, page_token): + def flex_time_data_fn(limit: int, page_token: PageToken | None) -> dict[str, Any]: config = get_rpc_config() - return scoped_dataset.run_table_query( - params=snuba_params, - query_string=scoped_query or "", - selected_columns=self.get_field_list(organization, request), - equations=self.get_equation_list(organization, request), - orderby=self.get_orderby(request), - offset=0, # required but not used because we're using page tokens here - limit=limit, - referrer=referrer, - config=config, - sampling_mode=snuba_params.sampling_mode, - page_token=page_token, - additional_queries=additional_queries, - max_string_length=max_string_length, + return cast( + dict[str, Any], + scoped_dataset.run_table_query( + params=snuba_params, + query_string=scoped_query or "", + selected_columns=self.get_field_list(organization, request), + equations=self.get_equation_list(organization, request), + orderby=self.get_orderby(request), + offset=0, # required but not used because we're using page tokens here + limit=limit, + referrer=referrer, + config=config, + sampling_mode=snuba_params.sampling_mode, + page_token=page_token, + additional_queries=additional_queries, + max_string_length=max_string_length, + ), ) return EAPPageTokenPaginator(data_fn=flex_time_data_fn), EAPPageTokenCursor - def data_fn(offset, limit): + def data_fn(offset: int, limit: int) -> dict[str, Any]: if scoped_dataset in RPC_DATASETS: config = get_rpc_config() - return scoped_dataset.run_table_query( - params=snuba_params, - query_string=scoped_query or "", - selected_columns=self.get_field_list(organization, request), - equations=self.get_equation_list(organization, request), - orderby=self.get_orderby(request), - offset=offset, - limit=limit, - referrer=referrer, - config=config, - sampling_mode=snuba_params.sampling_mode, - additional_queries=additional_queries, - max_string_length=max_string_length, + return cast( + dict[str, Any], + scoped_dataset.run_table_query( + params=snuba_params, + query_string=scoped_query or "", + selected_columns=self.get_field_list(organization, request), + equations=self.get_equation_list(organization, request), + orderby=self.get_orderby(request), + offset=offset, + limit=limit, + referrer=referrer, + config=config, + sampling_mode=snuba_params.sampling_mode, + additional_queries=additional_queries, + max_string_length=max_string_length, + ), ) if save_discover_dataset_decision and discover_saved_query_id: @@ -764,14 +777,15 @@ def data_fn(offset, limit): "organizations:measured-ingestion-delay-metadata", False ) - def _handle_results(results): + def _handle_results(results: Sequence[object]) -> EventsApiResponse: # Apply error upsampling for regular Events API - self.handle_error_upsampling(snuba_params.project_ids, results) + results_dict = cast(dict[str, Any], results) + self.handle_error_upsampling(snuba_params.project_ids, results_dict) handled = self.handle_results_with_meta( request, organization, snuba_params.project_ids, - results, + results_dict, standard_meta=True, dataset=dataset, ) @@ -783,7 +797,7 @@ def _handle_results(results): status = get_ingestion_delay_status(dataset, snuba_params) if status is not None: handled["meta"]["ingestion"] = serialize_ingestion_status(status) - return handled + return cast(EventsApiResponse, handled) with handle_query_errors(): # Don't include cursor headers if the client won't be using them diff --git a/src/sentry/api/endpoints/organization_events_facets.py b/src/sentry/api/endpoints/organization_events_facets.py index 6545e51de44d..8105cbaeeaa3 100644 --- a/src/sentry/api/endpoints/organization_events_facets.py +++ b/src/sentry/api/endpoints/organization_events_facets.py @@ -18,7 +18,7 @@ class _TopValue(TypedDict): - name: str + name: str | None value: int | str count: int @@ -54,7 +54,7 @@ def get(self, request: Request, organization: Organization) -> Response: dataset = self.get_dataset(request, organization) - def data_fn(offset, limit): + def data_fn(offset: int, limit: int) -> list[_KeyTopValues]: with traces.start_span( name="discover_query", attributes={"sentry.op": "discover.endpoint"} ): diff --git a/src/sentry/api/endpoints/organization_events_stats.py b/src/sentry/api/endpoints/organization_events_stats.py index f042624ba95d..e2089e0e7bee 100644 --- a/src/sentry/api/endpoints/organization_events_stats.py +++ b/src/sentry/api/endpoints/organization_events_stats.py @@ -1,5 +1,5 @@ import logging -from collections.abc import Mapping +from collections.abc import Callable, Mapping from datetime import timedelta from typing import Any @@ -92,16 +92,19 @@ def get_features( } ) - def flatten_results(self, results: SnubaTSResult | dict[str, SnubaTSResult]): + def flatten_results( + self, results: SnubaTSResult | dict[str, SnubaTSResult] + ) -> list[dict[str, object]]: if isinstance(results, SnubaTSResult): - return results.data["data"] - else: - return sum( - [timeseries_result.data["data"] for timeseries_result in results.values()], - [], - ) + data: list[dict[str, object]] = results.data["data"] + return data + + flattened: list[dict[str, object]] = [] + for timeseries_result in results.values(): + flattened.extend(timeseries_result.data["data"]) + return flattened - def check_if_results_have_data(self, results: SnubaTSResult | dict[str, SnubaTSResult]): + def check_if_results_have_data(self, results: SnubaTSResult | dict[str, SnubaTSResult]) -> bool: flattened_data = self.flatten_results(results) has_data = any( any( @@ -242,7 +245,7 @@ def _get_event_stats( if should_upsample: final_columns = transform_query_columns_for_error_upsampling(query_columns) - def get_rpc_config(): + def get_rpc_config() -> SearchResolverConfig: if scoped_dataset not in RPC_DATASETS: raise NotImplementedError @@ -305,7 +308,7 @@ def get_rpc_config(): if "timestamp" in raw_groupby: raise ParseError("Cannot group by timestamp") if use_rpc: - return scoped_dataset.run_top_events_timeseries_query( + rpc_result: SnubaTSResult = scoped_dataset.run_top_events_timeseries_query( params=snuba_params, query_string=query, y_axes=final_columns, @@ -318,7 +321,8 @@ def get_rpc_config(): sampling_mode=snuba_params.sampling_mode, equations=self.get_equation_list(organization, request), ) - return scoped_dataset.top_events_timeseries( + return rpc_result + top_events_result: SnubaTSResult = scoped_dataset.top_events_timeseries( timeseries_columns=final_columns, selected_columns=raw_groupby, equations=self.get_equation_list(organization, request), @@ -338,9 +342,10 @@ def get_rpc_config(): transform_alias_to_input_format=transform_alias_to_input_format, fallback_to_transactions=True, ) + return top_events_result if use_rpc: - return scoped_dataset.run_timeseries_query( + rpc_timeseries: SnubaTSResult = scoped_dataset.run_timeseries_query( params=snuba_params, query_string=query, y_axes=final_columns, @@ -349,8 +354,9 @@ def get_rpc_config(): sampling_mode=snuba_params.sampling_mode, comparison_delta=comparison_delta, ) + return rpc_timeseries - return scoped_dataset.timeseries_query( + timeseries: SnubaTSResult = scoped_dataset.timeseries_query( selected_columns=final_columns, query=query, snuba_params=snuba_params, @@ -372,8 +378,14 @@ def get_rpc_config(): fallback_to_transactions=True, transform_alias_to_input_format=transform_alias_to_input_format, ) - - def get_event_stats_factory(scoped_dataset): + return timeseries + + def get_event_stats_factory( + scoped_dataset: Any, # justification: dataset modules expose different query functions. + ) -> Callable[ + [list[str], str, SnubaParams, int, bool, timedelta | None], + SnubaTSResult | dict[str, SnubaTSResult], + ]: """ This factory closes over dataset in order to make an additional request to the errors dataset in the case that this request is from a dashboard widget and we're trying to split their discover dataset. diff --git a/src/sentry/api/endpoints/organization_events_timeseries.py b/src/sentry/api/endpoints/organization_events_timeseries.py index 0a5e4115ec19..cf42e8078b2a 100644 --- a/src/sentry/api/endpoints/organization_events_timeseries.py +++ b/src/sentry/api/endpoints/organization_events_timeseries.py @@ -1,7 +1,8 @@ import logging from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone -from typing import Any +from types import ModuleType +from typing import Any, cast import sentry_sdk from django.db import connections @@ -542,7 +543,9 @@ def get_event_stats( dataset, snuba_params, query, query_columns, rollup ) if cached := default_cache.get(cache_key): - cached_timestamps = sorted([item["time"] for item in cached]) + # justification: cache stores query rows with numeric time values. + cached_rows = cast(Sequence[Mapping[str, int]], cached) + cached_timestamps = sorted(row["time"] for row in cached_rows) # Don't use the cache if we haven't queried the start before # If this works well we can fork two queries in the future but keeping it simple for now if cached_timestamps[0] > snuba_params.start_date.timestamp(): @@ -576,51 +579,66 @@ def get_event_stats( if "timestamp" in [col.strip("-") for col in raw_orderby]: raise ParseError("Cannot order by timestamp") if dataset in RPC_DATASETS: - return dataset.run_top_events_timeseries_query( - params=snuba_params, - query_string=query, - y_axes=query_columns, - raw_groupby=raw_groupby, + # justification: dataset modules are chosen at runtime and return SnubaTSResult. + rpc_top_events = cast( + "SnubaTSResult | dict[str, SnubaTSResult]", + dataset.run_top_events_timeseries_query( + params=snuba_params, + query_string=query, + y_axes=query_columns, + raw_groupby=raw_groupby, + orderby=self.get_orderby(request), + limit=top_events, + include_other=include_other, + referrer=referrer, + config=self.get_rpc_config(dataset, request, organization), + sampling_mode=snuba_params.sampling_mode, + equations=self.get_equation_list( + organization, request, param_name="groupBy" + ), + additional_queries=additional_queries, + transformations=transformations, + ), + ) + return rpc_top_events + # justification: dataset modules are chosen at runtime and return SnubaTSResult. + return cast( + "SnubaTSResult | dict[str, SnubaTSResult]", + dataset.top_events_timeseries( + timeseries_columns=query_columns, + selected_columns=raw_groupby, + equations=self.get_equation_list(organization, request), + user_query=query, + snuba_params=snuba_params, orderby=self.get_orderby(request), + rollup=rollup, limit=top_events, + organization=organization, + referrer=referrer + ".find-topn", + allow_empty=False, + zerofill_results=True, include_other=include_other, + query_source=query_source, + transform_alias_to_input_format=True, + fallback_to_transactions=True, + ), + ) + + if dataset in RPC_DATASETS: + # justification: dataset modules are chosen at runtime and return SnubaTSResult. + result = cast( + "SnubaTSResult | dict[str, SnubaTSResult]", + dataset.run_timeseries_query( + params=snuba_params, + query_string=query, + y_axes=query_columns, referrer=referrer, config=self.get_rpc_config(dataset, request, organization), sampling_mode=snuba_params.sampling_mode, - equations=self.get_equation_list(organization, request, param_name="groupBy"), + comparison_delta=comparison_delta, additional_queries=additional_queries, transformations=transformations, - ) - return dataset.top_events_timeseries( - timeseries_columns=query_columns, - selected_columns=raw_groupby, - equations=self.get_equation_list(organization, request), - user_query=query, - snuba_params=snuba_params, - orderby=self.get_orderby(request), - rollup=rollup, - limit=top_events, - organization=organization, - referrer=referrer + ".find-topn", - allow_empty=False, - zerofill_results=True, - include_other=include_other, - query_source=query_source, - transform_alias_to_input_format=True, - fallback_to_transactions=True, - ) - - if dataset in RPC_DATASETS: - result = dataset.run_timeseries_query( - params=snuba_params, - query_string=query, - y_axes=query_columns, - referrer=referrer, - config=self.get_rpc_config(dataset, request, organization), - sampling_mode=snuba_params.sampling_mode, - comparison_delta=comparison_delta, - additional_queries=additional_queries, - transformations=transformations, + ), ) if incremental_loading and not skip_cache and is_cache_candidate: with traces.start_span("set_cache"): @@ -636,19 +654,23 @@ def get_event_stats( ) return result - return dataset.timeseries_query( - selected_columns=query_columns, - query=query, - snuba_params=snuba_params, - rollup=rollup, - referrer=referrer, - zerofill_results=True, - comparison_delta=comparison_delta, - allow_metric_aggregates=allow_metric_aggregates, - has_metrics=True, - query_source=query_source, - fallback_to_transactions=True, - transform_alias_to_input_format=True, + # justification: dataset modules are chosen at runtime and return SnubaTSResult. + return cast( + "SnubaTSResult | dict[str, SnubaTSResult]", + dataset.timeseries_query( + selected_columns=query_columns, + query=query, + snuba_params=snuba_params, + rollup=rollup, + referrer=referrer, + zerofill_results=True, + comparison_delta=comparison_delta, + allow_metric_aggregates=allow_metric_aggregates, + has_metrics=True, + query_source=query_source, + fallback_to_transactions=True, + transform_alias_to_input_format=True, + ), ) def serialize_stats_data( @@ -657,7 +679,7 @@ def serialize_stats_data( axes: list[str], snuba_params: SnubaParams, rollup: int, - dataset, + dataset: ModuleType, organization: Organization, ingestion_delay_status: IngestionDelayStatus | None = None, ) -> StatsResponse: diff --git a/src/sentry/api/endpoints/organization_explore_formulas.py b/src/sentry/api/endpoints/organization_explore_formulas.py index ea1f56cd26d5..d0d801283c24 100644 --- a/src/sentry/api/endpoints/organization_explore_formulas.py +++ b/src/sentry/api/endpoints/organization_explore_formulas.py @@ -210,10 +210,14 @@ def serialize( user: User | RpcUser | AnonymousUser, **kwargs: Any, ) -> ExploreSavedParam: + assert obj.order is not None + assert obj.param_type is not None + param_type = ParamItemTypes.get_type_name(obj.param_type) + assert param_type is not None return { "name": obj.name, "order": obj.order, - "type": ParamItemTypes.get_type_name(obj.param_type), + "type": param_type, "value": obj.value, } @@ -258,7 +262,8 @@ def serialize( # Make sure that we use the prefetch variables = list(obj.variables.all()) params = sorted( - (var for var in variables if var.kind == KindItemTypes.PARAM), key=lambda var: var.order + (var for var in variables if var.kind == KindItemTypes.PARAM), + key=lambda var: var.order if var.order is not None else 0, ) # order by name so references return in a consistent order references = sorted( @@ -266,11 +271,13 @@ def serialize( key=lambda var: var.name, ) + dataset = ExploreSavedQueryDataset.get_type_name(obj.dataset) + assert dataset is not None data: ExploreSavedFormulaResponse = { "formula": obj.formula, "id": str(obj.id), "name": obj.name, - "dataset": ExploreSavedQueryDataset.get_type_name(obj.dataset), + "dataset": dataset, "description": obj.description, "params": serialize(params, user, serializer=ExploreSavedParamsSerializer()), "references": serialize( 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_insights_tree.py b/src/sentry/api/endpoints/organization_insights_tree.py index a0dbf053fbd7..a2aec07fd246 100644 --- a/src/sentry/api/endpoints/organization_insights_tree.py +++ b/src/sentry/api/endpoints/organization_insights_tree.py @@ -78,7 +78,7 @@ def _parse_path(path: str | None) -> list[str]: parts = path.strip("/").split("/") return parts if parts != [""] else [] - def _separate_span_description_info(self, response): + def _separate_span_description_info(self, response: Response) -> Response: for line in response.data["data"]: component_type = None path_components: list[str] = [] diff --git a/src/sentry/api/endpoints/organization_missing_org_members.py b/src/sentry/api/endpoints/organization_missing_org_members.py index 25a673b44365..c04fb7dcc203 100644 --- a/src/sentry/api/endpoints/organization_missing_org_members.py +++ b/src/sentry/api/endpoints/organization_missing_org_members.py @@ -1,12 +1,13 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from datetime import timedelta from email.headerregistry import Address from functools import reduce from typing import Any +from django.contrib.auth.models import AnonymousUser from django.db.models import Q from django.utils import timezone from rest_framework import status @@ -21,10 +22,12 @@ from sentry.api.serializers import Serializer, serialize from sentry.constants import ObjectStatus from sentry.integrations.base import IntegrationFeatures -from sentry.integrations.services.integration import integration_service +from sentry.integrations.services.integration import RpcIntegration, integration_service from sentry.integrations.types import IntegrationProviderSlug from sentry.models.commitauthor import CommitAuthor from sentry.models.organization import Organization +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser FILTERED_EMAILS = { "%%@gmail.com", @@ -39,13 +42,19 @@ class MissingOrgMemberSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: CommitAuthor, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[str, object]: formatted_external_id = _format_external_id(obj.external_id) return { "email": obj.email, "externalId": formatted_external_id, - "commitCount": obj.commit__count, + "commitCount": getattr(obj, "commit__count"), } @@ -163,7 +172,9 @@ def get(self, request: Request, organization: Organization) -> Response: organization_id=organization.id, status=ObjectStatus.ACTIVE ) - def provider_reducer(dict, integration): + def provider_reducer( + dict: dict[str, list[int]], integration: RpcIntegration + ) -> dict[str, list[int]]: if not integration.has_feature(feature=IntegrationFeatures.COMMITS): return dict if dict.get(integration.provider): @@ -173,7 +184,7 @@ def provider_reducer(dict, integration): return dict - integration_provider_to_ids: dict[str, Sequence[int]] = reduce( + integration_provider_to_ids: dict[str, list[int]] = reduce( provider_reducer, integrations, defaultdict(list) ) diff --git a/src/sentry/api/endpoints/organization_on_demand_metrics_estimation_stats.py b/src/sentry/api/endpoints/organization_on_demand_metrics_estimation_stats.py index 5ac58b840720..8b150968a57b 100644 --- a/src/sentry/api/endpoints/organization_on_demand_metrics_estimation_stats.py +++ b/src/sentry/api/endpoints/organization_on_demand_metrics_estimation_stats.py @@ -181,14 +181,18 @@ def get_discover_stats( if remove_on_demand: query = to_standard_metrics_query(query) - return module.timeseries_query( - selected_columns=query_columns, - query=query, - snuba_params=snuba_params, - rollup=rollup, - referrer=Referrer.API_ORGANIZATION_METRICS_ESTIMATION_STATS.value, - zerofill_results=True, - has_metrics=True, + # justification: the dataset module is selected at runtime and returns SnubaTSResult. + return cast( + "SnubaTSResult", + module.timeseries_query( + selected_columns=query_columns, + query=query, + snuba_params=snuba_params, + rollup=rollup, + referrer=Referrer.API_ORGANIZATION_METRICS_ESTIMATION_STATS.value, + zerofill_results=True, + has_metrics=True, + ), ) return get_discover_stats diff --git a/src/sentry/api/endpoints/organization_onboarding_continuation_email.py b/src/sentry/api/endpoints/organization_onboarding_continuation_email.py index 8351444da7ce..0c50698ffcec 100644 --- a/src/sentry/api/endpoints/organization_onboarding_continuation_email.py +++ b/src/sentry/api/endpoints/organization_onboarding_continuation_email.py @@ -1,6 +1,9 @@ +from typing import TypedDict + from django.contrib.auth.models import AnonymousUser from rest_framework import serializers from rest_framework.request import Request +from rest_framework.response import Response from sentry import analytics from sentry.analytics.events.onboarding_continuation_sent import OnboardingContinuationSent @@ -15,13 +18,23 @@ from sentry.utils.strings import oxfordize_list -class OnboardingContinuationSerializer(CamelSnakeSerializer): +class OnboardingContinuationSerializer(CamelSnakeSerializer[dict[str, list[str]]]): platforms = serializers.ListField( child=serializers.CharField(max_length=255), ) -def get_request_builder_args(user: User, organization: Organization, platforms: list[str]): +class RequestBuilderArgs(TypedDict): + subject: str + type: str + context: dict[str, object] + template: str + html_template: str + + +def get_request_builder_args( + user: User, organization: Organization, platforms: list[str] +) -> RequestBuilderArgs: num_platforms = len(platforms) context = { "recipient_name": user.get_display_name(), @@ -53,7 +66,7 @@ class OrganizationOnboardingContinuationEmail(OrganizationEndpoint): owner = ApiOwner.VALUE_DISCOVERY permission_classes = (OnboardingContinuationPermission,) - def post(self, request: Request, organization: Organization): + def post(self, request: Request, organization: Organization) -> Response: serializer = OnboardingContinuationSerializer(data=request.data) if not serializer.is_valid(): return self.respond(serializer.errors, status=400) diff --git a/src/sentry/api/endpoints/organization_onboarding_tasks.py b/src/sentry/api/endpoints/organization_onboarding_tasks.py index 0f9a36cae464..9356a555f0fa 100644 --- a/src/sentry/api/endpoints/organization_onboarding_tasks.py +++ b/src/sentry/api/endpoints/organization_onboarding_tasks.py @@ -1,3 +1,5 @@ +from typing import cast + import sentry_sdk from django.utils import timezone from rest_framework.request import Request @@ -11,6 +13,7 @@ from sentry.api.serializers import serialize from sentry.models.organization import Organization from sentry.models.organizationonboardingtask import OnboardingTask, OnboardingTaskStatus +from sentry.users.models.user import User class OnboardingTaskPermission(OrganizationPermission): @@ -26,7 +29,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) @@ -37,7 +40,11 @@ def post(self, request: Request, organization) -> Response: if status_value is None and completion_seen is None: return Response({"detail": "completionSeen or status must be provided"}, status=422) - status = onboarding_tasks.get_status_lookup_by_key(status_value) + status = ( + onboarding_tasks.get_status_lookup_by_key(status_value) + if isinstance(status_value, str) + else None + ) if status_value and status is None: return Response({"detail": "Invalid status key"}, status=422) @@ -49,7 +56,7 @@ def post(self, request: Request, organization) -> Response: ): return Response(status=422) - values = {} + values: dict[str, object] = {} if status: values["status"] = status @@ -57,10 +64,12 @@ def post(self, request: Request, organization) -> Response: if completion_seen: values["completion_seen"] = timezone.now() + # justification: authenticated request user is the model user the service expects. + user = cast(User, request.user) instance, created = onboarding_tasks.create_or_update_onboarding_task( organization=organization, task=task_id, - user=request.user, + user=user, values=values, ) @@ -78,9 +87,9 @@ def post(self, request: Request, organization) -> Response: return Response(status=204) def get(self, request: Request, organization: Organization) -> Response: - tasks_to_serialize = list( - onboarding_tasks.fetch_onboarding_tasks(organization, request.user) - ) - serialized_tasks = serialize(tasks_to_serialize, request.user) + # justification: authenticated request user is the model user the service expects. + user = cast(User, request.user) + tasks_to_serialize = list(onboarding_tasks.fetch_onboarding_tasks(organization, user)) + serialized_tasks = serialize(tasks_to_serialize, user) return Response({"onboardingTasks": serialized_tasks}, status=200) 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_profiling_functions.py b/src/sentry/api/endpoints/organization_profiling_functions.py index 15fd0b3d592c..e9994f0ca6f3 100644 --- a/src/sentry/api/endpoints/organization_profiling_functions.py +++ b/src/sentry/api/endpoints/organization_profiling_functions.py @@ -1,8 +1,9 @@ from __future__ import annotations +from collections.abc import Mapping from datetime import timedelta from enum import Enum -from typing import Any +from typing import Any, cast from rest_framework import serializers from rest_framework.request import Request @@ -19,14 +20,14 @@ from sentry.exceptions import InvalidSearchQuery from sentry.models.organization import Organization from sentry.search.events.builder.profile_functions import ProfileTopFunctionsTimeseriesQueryBuilder -from sentry.search.events.types import QueryBuilderConfig +from sentry.search.events.types import QueryBuilderConfig, SnubaParams from sentry.seer.breakpoints import BreakpointData, BreakpointRequest, detect_breakpoints from sentry.seer.signed_seer_api import SeerViewerContext from sentry.snuba import functions from sentry.snuba.dataset import Dataset from sentry.snuba.referrer import Referrer from sentry.utils.dates import parse_stats_period, validate_interval -from sentry.utils.snuba import bulk_snuba_queries +from sentry.utils.snuba import SnubaTSResult, bulk_snuba_queries TOP_FUNCTIONS_LIMIT = 50 FUNCTIONS_PER_QUERY = 10 @@ -36,7 +37,7 @@ class TrendType(Enum): REGRESSION = "regression" IMPROVEMENT = "improvement" - def as_sort(self): + def as_sort(self) -> str: if self is TrendType.REGRESSION: return "-trend_percentage()" @@ -46,11 +47,12 @@ def as_sort(self): raise ValueError(f"Unknown TrendType: {self.value}") -class TrendTypeField(serializers.Field): - def to_representation(self, trend_type: TrendType): +# justification: decoded profiling JSON +class TrendTypeField(serializers.Field[TrendType, Any, str, object]): + def to_representation(self, trend_type: TrendType) -> str: return trend_type.value - def to_internal_value(self, data: Any) -> TrendType | None: + def to_internal_value(self, data: Any) -> TrendType: # justification: decoded profiling JSON for trend_type in TrendType: if data == trend_type.value: return trend_type @@ -73,7 +75,7 @@ class OrganizationProfilingFunctionTrendsEndpoint(OrganizationEventsEndpointBase "GET": ApiPublishStatus.PRIVATE, } - def has_feature(self, organization: Organization, request: Request): + def has_feature(self, organization: Organization, request: Request) -> bool: return features.has( "organizations:profiling-global-suspect-functions", organization, actor=request.user ) @@ -114,8 +116,13 @@ def get(self, request: Request, organization: Organization) -> Response: ) def get_event_stats( - _columns, query, snuba_params, _rollup, zerofill_results, _comparison_delta - ): + _columns: list[str], + query: str, + snuba_params: SnubaParams, + _rollup: int, + zerofill_results: bool, + _comparison_delta: timedelta | None, + ) -> SnubaTSResult | dict[str, SnubaTSResult]: rollup = get_rollup_from_range(snuba_params.date_range) chunks = [ @@ -151,20 +158,25 @@ def get_event_stats( results = {} for chunk, builder, result in zip(chunks, builders, bulk_results): - formatted_results = functions.format_top_events_timeseries_results( - result, - builder, - rollup=rollup, - snuba_params=snuba_params, - top_events={"data": chunk}, - result_key_order=["project.id", "fingerprint"], + # justification: top events are supplied and allow_empty defaults to True. + formatted_results = cast( + dict[str, SnubaTSResult], + functions.format_top_events_timeseries_results( + result, + builder, + rollup=rollup, + snuba_params=snuba_params, + top_events={"data": chunk}, + result_key_order=["project.id", "fingerprint"], + ), ) results.update(formatted_results) return results - def get_trends_data(stats_data) -> list[BreakpointData]: + # justification: decoded profiling JSON + def get_trends_data(stats_data: Mapping[str, Any]) -> list[BreakpointData]: if not stats_data: return [] @@ -232,10 +244,13 @@ def get_trends_data(stats_data) -> list[BreakpointData]: reverse=data["trend"] is TrendType.REGRESSION, ) - def paginate_trending_events(offset, limit): + # justification: decoded profiling JSON + def paginate_trending_events(offset: int, limit: int) -> dict[str, Any]: return {"data": trending_functions[offset : limit + offset]} - def get_stats_data_for_trending_events(results): + def get_stats_data_for_trending_events( + results: Mapping[str, Any], # justification: decoded profiling JSON + ) -> list[dict[str, Any]]: # justification: decoded profiling JSON functions = { f"{function['project.id']},{function['fingerprint']}": function for function in top_functions.get("data", []) @@ -294,7 +309,7 @@ def get_stats_data_for_trending_events(results): ) -def get_rollup_from_range(date_range: timedelta, top_functions=TOP_FUNCTIONS_LIMIT) -> int: +def get_rollup_from_range(date_range: timedelta, top_functions: int = TOP_FUNCTIONS_LIMIT) -> int: interval = parse_stats_period(get_interval_from_range(date_range)) if interval is None: interval = timedelta(hours=1) diff --git a/src/sentry/api/endpoints/organization_profiling_profiles.py b/src/sentry/api/endpoints/organization_profiling_profiles.py index bf71aec1cbc6..e95b9db98441 100644 --- a/src/sentry/api/endpoints/organization_profiling_profiles.py +++ b/src/sentry/api/endpoints/organization_profiling_profiles.py @@ -96,7 +96,7 @@ class OrganizationProfilingFlamegraphSerializer(serializers.Serializer): query = serializers.CharField(required=False) expand = serializers.ListField(child=serializers.ChoiceField(["metrics"]), required=False) - def validate(self, attrs): + def validate(self, attrs: dict[str, object]) -> dict[str, object]: source = attrs.get("dataSource") if source is None: diff --git a/src/sentry/api/endpoints/organization_project_keys.py b/src/sentry/api/endpoints/organization_project_keys.py index 8eae59787274..5357dbecccd8 100644 --- a/src/sentry/api/endpoints/organization_project_keys.py +++ b/src/sentry/api/endpoints/organization_project_keys.py @@ -1,3 +1,5 @@ +from typing import cast + from django.db.models import F from drf_spectacular.utils import OpenApiExample, extend_schema, inline_serializer from rest_framework import serializers @@ -15,6 +17,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 +84,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. @@ -132,7 +137,11 @@ def serialize_keys(keys: list[ProjectKey]) -> list[ProjectKeySerializerResponse] project = projects_by_id[key.project_id] project.set_cached_field_value("organization", organization) key.set_cached_field_value("project", project) - return serialize(keys, request.user, request=request) + # justification: the serializer emits one payload per project key. + return cast( + "list[ProjectKeySerializerResponse]", + serialize(keys, request.user, request=request), + ) return self.paginate( request=request, diff --git a/src/sentry/api/endpoints/organization_recent_searches.py b/src/sentry/api/endpoints/organization_recent_searches.py index 0f9e837e089c..bd765510d5fb 100644 --- a/src/sentry/api/endpoints/organization_recent_searches.py +++ b/src/sentry/api/endpoints/organization_recent_searches.py @@ -1,3 +1,5 @@ +from typing import cast + from django.utils import timezone from rest_framework import serializers from rest_framework.request import Request @@ -8,8 +10,10 @@ 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 +from sentry.users.models.user import User class RecentSearchSerializer(serializers.Serializer): @@ -33,7 +37,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 `````````````````````````````````````````````````````` @@ -52,7 +56,7 @@ def get(self, request: Request, organization) -> Response: except ValueError as e: return Response({"detail": "Invalid input for `limit`. Error: %s" % str(e)}, status=400) - query_kwargs = { + query_kwargs: dict[str, object] = { "organization": organization, "user_id": request.user.id, "type": search_type, @@ -67,7 +71,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(): @@ -81,7 +85,10 @@ def post(self, request: Request, organization) -> Response: defaults={"last_seen": timezone.now()}, ) if created: - remove_excess_recent_searches(organization, request.user, result["type"]) + # justification: DRF exposes request.user as a SimpleLazyObject proxy around User. + remove_excess_recent_searches( + organization, cast(User, request.user), result["type"] + ) status = 201 if created else 204 return Response(status=status) diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index a69ada821ac3..803e9bd926e7 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -1,12 +1,13 @@ from __future__ import annotations import re +from collections.abc import Callable, Iterable, Mapping, Sequence from datetime import datetime, timedelta from typing import Any, TypedDict, cast import sentry_sdk from django.db import IntegrityError -from django.db.models import F, Q +from django.db.models import F, Model, Q, QuerySet from drf_spectacular.utils import extend_schema from rest_framework.exceptions import ParseError from rest_framework.request import Request @@ -101,14 +102,15 @@ 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)) _release_suffix = re.compile(r"^(.*)\s+\(([^)]+)\)\s*$") -def add_environment_to_queryset(queryset, filter_params): +# justification: querysets carry runtime annotate()/values() shapes the Django plugin cannot express. +def add_environment_to_queryset(queryset: Any, filter_params: Mapping[str, Any]) -> Any: if "environment" in filter_params: return queryset.filter( releaseprojectenvironment__environment__name__in=filter_params["environment"], @@ -117,14 +119,21 @@ def add_environment_to_queryset(queryset, filter_params): return queryset -def add_date_filter_to_queryset(queryset, filter_params): +# justification: querysets carry runtime annotate()/values() shapes the Django plugin cannot express. +def add_date_filter_to_queryset(queryset: Any, filter_params: Mapping[str, Any]) -> Any: """Once date has been coalesced over released and added, use it to filter releases""" if filter_params["start"] and filter_params["end"]: return queryset.filter(date__gte=filter_params["start"], date__lte=filter_params["end"]) return queryset -def _filter_releases_by_query(queryset, organization, query, filter_params): +# justification: querysets carry runtime annotate()/values() shapes the Django plugin cannot express. +def _filter_releases_by_query( + queryset: Any, + organization: Organization, + query: str, + filter_params: Mapping[str, Any], +) -> Any: search_filters = parse_search_query(query) for search_filter in search_filters: if search_filter.key.name == FINALIZED_KEY: @@ -243,7 +252,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. """ @@ -452,7 +461,7 @@ def __get_new(self, request: Request, organization: Organization) -> Response: # health data in the last 24 hours. debounce_update_release_health_data(organization, filter_params["project_id"]) - queryset = Release.objects.filter(organization_id=organization.id) + queryset: QuerySet[Release] = Release.objects.filter(organization_id=organization.id) queryset = filter_releases_by_environments( queryset, filter_params["project_id"], @@ -489,7 +498,11 @@ def __get_new(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - queryset = queryset.annotate_prerelease_column().annotate_build_code_column() + queryset = ( + cast(ReleaseQuerySet, queryset) + .annotate_prerelease_column() + .annotate_build_code_column() + ) semver_cols = Release.SEMVER_COLS_WITH_BUILD_CODE order_by = [F(col).desc(nulls_last=True) for col in semver_cols] @@ -510,7 +523,9 @@ def __get_new(self, request: Request, organization: Organization) -> Response: status=400, ) - def qs_load_func(queryset, total_offset, qs_offset, limit): + def qs_load_func( + queryset: ReleaseQuerySet, total_offset: int, qs_offset: int, limit: int + ) -> list[Release]: # We want to fetch at least total_offset + limit releases to check, to make sure # we're not fetching only releases that were on previous pages. release_versions = list( @@ -688,7 +703,9 @@ def __get_old(self, request: Request, organization: Organization) -> Response: status=400, ) - def qs_load_func(queryset, total_offset, qs_offset, limit): + def qs_load_func( + queryset: ReleaseQuerySet, total_offset: int, qs_offset: int, limit: int + ) -> list[Release]: # We want to fetch at least total_offset + limit releases to check, to make sure # we're not fetching only releases that were on previous pages. release_versions = list( @@ -1056,17 +1073,18 @@ class ReleasesMergingOffsetPaginator(OffsetPaginator): def __init__( self, - queryset, - data_load_func, - apply_to_queryset, + queryset: ReleaseQuerySet, + data_load_func: Callable[..., list[object]], + apply_to_queryset: Callable[[ReleaseQuerySet, list[object]], ReleaseQuerySet], project_ids: list[int], - key_from_data=None, - max_limit=MAX_LIMIT, - on_results=None, - data_count_func=None, - queryset_load_func=None, - ): + key_from_data: Callable[[object], object] | None = None, + max_limit: int = MAX_LIMIT, + on_results: Callable[[Sequence[Model]], Sequence[Model]] | None = None, + data_count_func: Callable[[], int] | None = None, + queryset_load_func: Callable[[ReleaseQuerySet, int, int, int], list[Release]] | None = None, + ) -> None: super().__init__(queryset, max_limit=max_limit, on_results=on_results) + self.release_queryset = queryset self.data_load_func = data_load_func self.apply_to_queryset = apply_to_queryset self.key_from_data = key_from_data or (lambda x: x) @@ -1074,7 +1092,15 @@ def __init__( self.queryset_load_func = queryset_load_func self.project_ids = project_ids - def get_result(self, limit=100, cursor=None): # type: ignore[override] + # justification: matches the untyped OffsetPaginator override contract. + def get_result( + self, + limit: int = 100, + cursor: Any = None, + count_hits: bool = False, + known_hits: int | None = None, + max_hits: int | None = None, + ) -> CursorResult[Release]: if cursor is None: cursor = Cursor(0, 0, 0) @@ -1094,7 +1120,7 @@ def get_result(self, limit=100, cursor=None): # type: ignore[override] # This is the reason we defined our own merging paginator. We need to look up the # project_id since it doesn't exist on the model. This was previously accomplished with a # join (and a distinct clause for other reasons) but it was horribly slow. - queryset = self.apply_to_queryset(self.queryset, primary_results) + queryset = self.apply_to_queryset(self.release_queryset, primary_results) releases = list(queryset) releases_projects = list( ReleaseProject.objects.filter( @@ -1107,7 +1133,7 @@ def get_result(self, limit=100, cursor=None): # type: ignore[override] results = [] for row in primary_results: - model = mapping.get(self.key_from_data(row)) + model = mapping.get(cast("tuple[int, str]", self.key_from_data(row))) if model is not None: results.append(model) @@ -1119,7 +1145,7 @@ def get_result(self, limit=100, cursor=None): # type: ignore[override] total_offset = offset + len(results) qs_offset = max(0, total_offset - total_data_count) qs_results = self.queryset_load_func( - self.queryset, total_offset, qs_offset, extra_limit + self.release_queryset, total_offset, qs_offset, extra_limit ) results.extend(qs_results) has_more = len(qs_results) == extra_limit @@ -1131,6 +1157,6 @@ def get_result(self, limit=100, cursor=None): # type: ignore[override] prev_cursor = Cursor(limit, page - 1, True, page > 0) if self.on_results: - results = self.on_results(results) + results = cast("list[Release]", self.on_results(results)) return CursorResult(results=results, next=next_cursor, prev=prev_cursor) diff --git a/src/sentry/api/endpoints/organization_sampling_project_rates.py b/src/sentry/api/endpoints/organization_sampling_project_rates.py index 89f79dc8acf5..785015cb076f 100644 --- a/src/sentry/api/endpoints/organization_sampling_project_rates.py +++ b/src/sentry/api/endpoints/organization_sampling_project_rates.py @@ -1,6 +1,7 @@ -from collections.abc import Mapping, MutableMapping +from collections.abc import Mapping, MutableMapping, Sequence from typing import Any +from django.contrib.auth.models import AnonymousUser from django.db import router, transaction from rest_framework import serializers from rest_framework.request import Request @@ -19,6 +20,8 @@ from sentry.models.options.project_option import ProjectOption from sentry.models.organization import Organization from sentry.models.project import Project +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser OPTION_KEY = "sentry:target_sample_rate" @@ -26,7 +29,12 @@ class GetSerializer(Serializer[Mapping[str, Any]]): """Serializer for OrganizationSamplingProjectRatesEndpoint.get""" - def get_attrs(self, item_list, user, **kwargs) -> MutableMapping[Any, Any]: + def get_attrs( + self, + item_list: Sequence[Project], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> MutableMapping[Project, object]: options = ProjectOption.objects.get_value_bulk(item_list, OPTION_KEY) # NOTE: `get_value_bulk` does not resolve defaults. The default does not # depend on epochs, so we can speed this up by using the constant. @@ -35,7 +43,13 @@ def get_attrs(self, item_list, user, **kwargs) -> MutableMapping[Any, Any]: for item, value in options.items() } - def serialize(self, obj: Any, attrs: Any, user, **kwargs) -> Mapping[str, Any]: + def serialize( + self, + obj: Any, + attrs: Any, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> Mapping[str, Any]: return {"id": obj.id, "sampleRate": attrs} @@ -59,7 +73,7 @@ class OrganizationSamplingProjectRatesEndpoint(OrganizationEndpoint): permission_classes = (OrganizationPermission,) - def _check_feature(self, request: Request, organization: Organization): + def _check_feature(self, request: Request, organization: Organization) -> None: if not features.has( "organizations:dynamic-sampling-custom", organization, actor=request.user ): diff --git a/src/sentry/api/endpoints/organization_sdk_updates.py b/src/sentry/api/endpoints/organization_sdk_updates.py index a75a94229ae6..79e435dd5865 100644 --- a/src/sentry/api/endpoints/organization_sdk_updates.py +++ b/src/sentry/api/endpoints/organization_sdk_updates.py @@ -1,5 +1,7 @@ +from collections.abc import Sequence from datetime import timedelta from itertools import chain, groupby +from typing import TypedDict, cast import sentry_sdk from django.utils import timezone @@ -14,23 +16,43 @@ from sentry.api.bases.organization import OrganizationEndpoint from sentry.api.utils import handle_query_errors from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.sdk_updates import SdkIndexState, SdkSetupState, get_sdk_index, get_suggested_updates from sentry.search.events.types import SnubaParams from sentry.snuba import discover from sentry.utils.numbers import format_grouped_length +SdkUsageRow = TypedDict( + "SdkUsageRow", + { + "project.id": int, + "sdk.name": str, + "sdk.version": str, + }, +) -def by_sdk_name(sdk): + +class SdkLatest(TypedDict): + projectId: str + sdkName: str + sdkVersion: str + + +class SdkUpdate(SdkLatest): + suggestions: list[dict[str, object]] + + +def by_sdk_name(sdk: SdkUsageRow) -> str: return sdk["sdk.name"] -def by_project_id(sdk): +def by_project_id(sdk: SdkUsageRow) -> int: return sdk["project.id"] -def serialize(data, projects): +def serialize(data: Sequence[SdkUsageRow], projects: Sequence[Project]) -> list[SdkUpdate]: # filter out SDKs with empty sdk.name or sdk.version or invalid version - nonempty_sdks = [] + nonempty_sdks: list[SdkUsageRow] = [] for sdk in data: if not sdk["sdk.name"] or not sdk["sdk.version"]: continue @@ -46,11 +68,11 @@ def serialize(data, projects): # project we have events for. latest_sdks = chain.from_iterable( [ - { - "projectId": str(project_id), - "sdkName": sdk_name, - "sdkVersion": max((s["sdk.version"] for s in sdks), key=version.parse), - } + SdkLatest( + projectId=str(project_id), + sdkName=sdk_name, + sdkVersion=max((s["sdk.version"] for s in sdks), key=version.parse), + ) for sdk_name, sdks in groupby(sorted(sdks_used, key=by_sdk_name), key=by_sdk_name) ] for project_id, sdks_used in groupby(nonempty_sdks, key=by_project_id) @@ -60,8 +82,10 @@ def serialize(data, projects): index_state = SdkIndexState() updates_list = [ - dict( - **latest, + SdkUpdate( + projectId=latest["projectId"], + sdkName=latest["sdkName"], + sdkVersion=latest["sdkVersion"], suggestions=list( get_suggested_updates( # TODO: In the future it would be nice to also add @@ -121,7 +145,7 @@ def get(self, request: Request, organization: Organization) -> Response: referrer="api.organization-sdk-updates", ) - return Response(serialize(result["data"], projects)) + return Response(serialize(cast(Sequence[SdkUsageRow], result["data"]), projects)) @cell_silo_endpoint diff --git a/src/sentry/api/endpoints/organization_sessions.py b/src/sentry/api/endpoints/organization_sessions.py index d0be691bb770..4a19e8b1b595 100644 --- a/src/sentry/api/endpoints/organization_sessions.py +++ b/src/sentry/api/endpoints/organization_sessions.py @@ -1,4 +1,6 @@ +from collections.abc import Iterator, Mapping, Sequence from contextlib import contextmanager +from typing import cast from drf_spectacular.utils import extend_schema from rest_framework.exceptions import ParseError @@ -113,7 +115,7 @@ def build_sessions_query( organization: Organization, offset: int | None, limit: int | None, - ): + ) -> QueryDefinition: try: params = self.get_filter_params(request, organization, date_filter_optional=True) except NoProjects: @@ -127,7 +129,7 @@ def build_sessions_query( ) @contextmanager - def handle_query_errors(self): + def handle_query_errors(self) -> Iterator[None]: try: with handle_query_errors(): yield @@ -136,20 +138,22 @@ def handle_query_errors(self): class SessionsDataSeriesPaginator(GenericOffsetPaginator): - def get_result(self, limit, cursor=None): + def get_result(self, limit: int, cursor: Cursor | None = None) -> CursorResult[object]: assert limit > 0 offset = cursor.offset if cursor is not None else 0 data = self.data_fn(offset=offset, limit=limit + 1) - - if isinstance(data.get("groups"), list): - has_more = len(data["groups"]) == limit + 1 + assert isinstance(data, Mapping) + groups = data.get("groups") + if isinstance(groups, list): + has_more = len(groups) == limit + 1 if has_more: - data["groups"].pop() + groups.pop() else: raise NotImplementedError return CursorResult( - data, + # justification: paginator passes its raw mapping payload through to the response. + cast("Sequence[object]", data), prev=Cursor(0, max(0, offset - limit), True, offset > 0), next=Cursor(0, max(0, offset + limit), False, has_more), ) diff --git a/src/sentry/api/endpoints/organization_stats.py b/src/sentry/api/endpoints/organization_stats.py index a09b3f1694a7..a95c3f10853b 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 ````````````````````````````````````````` @@ -79,7 +80,7 @@ def get(self, request: Request, organization) -> Response: stat_model = None stat = request.GET.get("stat", "received") - query_kwargs = {} + environment_id: int | None = None if stat == "received": if group == "project": stat_model = TSDBModel.project_total_received @@ -99,7 +100,7 @@ def get(self, request: Request, organization) -> Response: if group == "project": stat_model = TSDBModel.project try: - query_kwargs["environment_id"] = get_environment_id(request, organization.id) + environment_id = get_environment_id(request, organization.id) except Environment.DoesNotExist: raise ResourceDoesNotExist @@ -115,7 +116,7 @@ def get(self, request: Request, organization) -> Response: data = tsdb.backend.get_range( model=stat_model, keys=keys, - **self._parse_args(request, **query_kwargs), + **self._parse_args(request, environment_id=environment_id), tenant_ids={"organization_id": organization.id}, ) diff --git a/src/sentry/api/endpoints/organization_stats_summary.py b/src/sentry/api/endpoints/organization_stats_summary.py index 9653ee03f222..45144facbbdc 100644 --- a/src/sentry/api/endpoints/organization_stats_summary.py +++ b/src/sentry/api/endpoints/organization_stats_summary.py @@ -1,7 +1,8 @@ import csv +from collections.abc import Iterator from contextlib import contextmanager from io import StringIO -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from django.http import HttpResponse from drf_spectacular.utils import extend_schema @@ -28,6 +29,7 @@ from sentry.snuba.outcomes import ( COLUMN_MAP, QueryDefinition, + _CategoryStats, massage_sessions_result_summary, run_outcomes_query_totals, ) @@ -169,7 +171,7 @@ def get( ) if request.GET.get("download"): - csv_content = self._generate_csv(projects) + csv_content = cast(str, self._generate_csv(projects)) response = HttpResponse(content_type="text/csv", status=200) response["Content-Disposition"] = 'attachment; filename="stats_summary.csv"' response.write(csv_content) @@ -177,8 +179,8 @@ def get( return Response(result, status=200) - def build_outcomes_query(self, request: Request, organization): - params = {"organization_id": organization.id} + def build_outcomes_query(self, request: Request, organization: Organization) -> QueryDefinition: + params: dict[str, int | list[int]] = {"organization_id": organization.id} project_ids = self._get_projects_for_orgstats_query(request, organization) query_dict = request.GET.copy() @@ -193,7 +195,9 @@ def build_outcomes_query(self, request: Request, organization): return QueryDefinition.from_query_dict(query_dict, params) - def _get_projects_for_orgstats_query(self, request: Request, organization): + def _get_projects_for_orgstats_query( + self, request: Request, organization: Organization + ) -> list[int]: # the projects table always filters by project # the projects in the table should be those the user has access to @@ -202,14 +206,16 @@ def _get_projects_for_orgstats_query(self, request: Request, organization): raise NoProjects("No projects available") return [p.id for p in projects] - def _generate_csv(self, projects): + def _generate_csv( + self, projects: dict[int, dict[str, dict[str, _CategoryStats]]] + ) -> str | None: if not len(projects): - return + return None output = StringIO() csv_writer = csv.writer(output) - longest_key = None + longest_key: int | None = None max_length = 0 for key, value in projects.items(): if len(value) > max_length: @@ -217,7 +223,7 @@ def _generate_csv(self, projects): longest_key = key headers = ["project_id", "project_slug"] - longest_key_project = projects[longest_key] + longest_key_project = projects[cast(int, longest_key)] for category_stats in longest_key_project.values(): for category, stats in category_stats.items(): for outcome in stats["outcomes"]: @@ -232,7 +238,7 @@ def _generate_csv(self, projects): for project_id, project_stats in projects.items(): slug = project_id_to_slug[project_id] - row = {"project_id": project_id, "project_slug": slug} + row: dict[str, object] = {"project_id": project_id, "project_slug": slug} for category_stats in project_stats.values(): for category, stats in category_stats.items(): for outcome, val in stats["outcomes"].items(): @@ -247,7 +253,7 @@ def _generate_csv(self, projects): row[header_name] = val else: row[header_name] = 0 - formatted_row = [] + formatted_row: list[object] = [] for header in headers: if header in row: formatted_row.append(row[header]) @@ -259,7 +265,7 @@ def _generate_csv(self, projects): return output.getvalue() @contextmanager - def handle_query_errors(self): + def handle_query_errors(self) -> Iterator[None]: try: with handle_query_errors(): yield diff --git a/src/sentry/api/endpoints/organization_stats_v2.py b/src/sentry/api/endpoints/organization_stats_v2.py index 9e03529a6204..477223480c53 100644 --- a/src/sentry/api/endpoints/organization_stats_v2.py +++ b/src/sentry/api/endpoints/organization_stats_v2.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from contextlib import contextmanager from drf_spectacular.utils import extend_schema @@ -12,6 +13,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases import NoProjects from sentry.api.bases.organization import OrganizationAndStaffPermission, OrganizationEndpoint +from sentry.api.helpers.projects import ParsedProjectIdOrSlugParams from sentry.api.utils import handle_query_errors from sentry.apidocs.constants import RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED from sentry.apidocs.examples.organization_examples import OrganizationExamples @@ -199,8 +201,8 @@ def get(self, request: Request, organization: Organization) -> Response[StatsApi result = massage_outcomes_result(query, result_totals, result_timeseries) return Response(result, status=200) - def build_outcomes_query(self, request: Request, organization): - params = {"organization_id": organization.id} + def build_outcomes_query(self, request: Request, organization: Organization) -> QueryDefinition: + params: dict[str, object] = {"organization_id": organization.id} project_ids = self._get_projects_for_orgstats_query(request, organization) if project_ids: @@ -208,7 +210,9 @@ def build_outcomes_query(self, request: Request, organization): return QueryDefinition.from_query_dict(request.GET, params) - def _get_projects_for_orgstats_query(self, request: Request, organization): + def _get_projects_for_orgstats_query( + self, request: Request, organization: Organization + ) -> list[int] | None: # Only an explicit all-projects selection can request an organization total. # An omitted project filter means the user's projects. requested_projects = self.get_requested_project_params_unchecked(request) @@ -220,14 +224,16 @@ def _get_projects_for_orgstats_query(self, request: Request, organization): raise NoProjects("No projects available") return [p.id for p in projects] - def _is_org_total_query(self, request: Request, requested_projects): + def _is_org_total_query( + self, request: Request, requested_projects: ParsedProjectIdOrSlugParams + ) -> bool: all_access_filter = ( requested_projects.ids == ALL_ACCESS_PROJECTS and not requested_projects.slugs ) or (requested_projects.slugs == {ALL_ACCESS_PROJECTS_SLUG} and not requested_projects.ids) return all_access_filter and "project" not in request.GET.getlist("groupBy") @contextmanager - def handle_query_errors(self): + def handle_query_errors(self) -> Iterator[None]: try: with handle_query_errors(): yield 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/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 639c3a80d01a..29b458d2725d 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -1,6 +1,6 @@ from collections.abc import Callable, Sequence from datetime import datetime, timedelta -from typing import Any +from typing import Any, cast import sentry_sdk from django.db.models import Q @@ -117,8 +117,8 @@ from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.cursors import Cursor, CursorResult -SCALAR_ATTRIBUTE_TYPES = ["string", "number", "boolean"] -POSSIBLE_ATTRIBUTE_TYPES = [*SCALAR_ATTRIBUTE_TYPES, "array"] +SCALAR_ATTRIBUTE_TYPES: list[ColumnType] = ["string", "number", "boolean"] +POSSIBLE_ATTRIBUTE_TYPES: list[ColumnType] = [*SCALAR_ATTRIBUTE_TYPES, "array"] # Max whole-array rows the attribute-values RPC will return in one call. MAX_ATTRIBUTE_VALUE_ROWS = 10_000 @@ -150,10 +150,12 @@ class TraceItemAttributesNamesPaginator: even if it exceeds limit + 1. """ - def __init__(self, data_fn): + def __init__(self, data_fn: Callable[..., list[TraceItemAttributeKey]]): self.data_fn = data_fn - def get_result(self, limit, cursor=None): + def get_result( + self, limit: int, cursor: Cursor | None = None + ) -> CursorResult[TraceItemAttributeKey]: if limit <= 0: raise ValueError(f"invalid limit for paginator, expected >0, got {limit}") @@ -512,18 +514,24 @@ def attach_custom_attribute_context( if scoped_project_id is not None: project_scope |= Q(project_id=scoped_project_id) + item_type_id = TraceItemTypes.get_id_for_type_name(item_type.value) + if item_type_id is None: + return + rows = TraceItemAttributeContextModel.objects.filter( project_scope, organization=organization, - item_type=TraceItemTypes.get_id_for_type_name(item_type.value), + item_type=item_type_id, attribute_key__in={name for name, _ in describable}, ) # Org-wide rows first, so a project-scoped row overwrites them. for row in sorted(rows, key=lambda row: row.project_id is not None): - attribute = describable.get( - (row.attribute_key, TraceItemAttributeTypes.get_type_name(row.attribute_type)) - ) + attribute_type = TraceItemAttributeTypes.get_type_name(row.attribute_type) + if attribute_type is None: + continue + # justification: `TraceItemAttributeTypes` names are the `ColumnType` literals. + attribute = describable.get((row.attribute_key, cast(ColumnType, attribute_type))) if attribute is None: continue attribute["context"] = build_custom_attribute_context(row) @@ -801,7 +809,7 @@ def get( include_internal = is_active_superuser(request) or is_active_staff(request) include_internal_convention_attributes = request.user.is_staff or request.user.is_superuser debug = request.user.is_superuser and request.GET.get("debug", False) - debug_infos: list[dict] = [] + debug_infos: list[dict[str, Any]] = [] # justification: protobuf JSON debug payloads. # Expand the sentry conventions context when explicitly requested via # `expand=context`. The conventions metadata is static with no data @@ -879,8 +887,8 @@ def query_trace_attributes( include_context: bool = False, debug: str | bool = False, include_internal_convention_attributes: bool = False, - ) -> tuple[list[TraceItemAttributeKey], dict | None]: - debug_info: dict | None = None + ) -> tuple[list[TraceItemAttributeKey], dict[str, Any] | None]: + debug_info: dict[str, Any] | None = None value_substring_match = translate_escape_sequences(substring_match) attr_type = constants.ATTRIBUTES_QUERY_PARAM_TO_ATTRIBUTE_TYPE_MAP.get( attribute_type, AttributeKey.Type.TYPE_STRING @@ -1161,7 +1169,7 @@ def get( definitions = get_column_definitions(SupportedTraceItemType(dataset)) - def data_fn(offset: int, limit: int): + def data_fn(offset: int, limit: int) -> list[TagValue]: executor = TraceItemAttributeValuesAutocompletionExecutor( organization=organization, snuba_params=snuba_params, @@ -1298,7 +1306,7 @@ def project_slug_autocomplete_function(self) -> list[TagValue]: if not self.query or self.query in project.slug ] - def release_stage_autocomplete_function(self): + def release_stage_autocomplete_function(self) -> list[TagValue]: return [ TagValue( key=self.key, @@ -1311,7 +1319,7 @@ def release_stage_autocomplete_function(self): if not self.query or self.query in stage.value ] - def semver_autocomplete_function(self): + def semver_autocomplete_function(self) -> list[TagValue]: versions = Release.objects.filter(NON_ARCHIVED_RELEASES, version__contains="@" + self.query) project_ids = self.snuba_params.project_ids @@ -1360,7 +1368,7 @@ def semver_autocomplete_function(self): return formatted_versions - def semver_build_autocomplete_function(self): + def semver_build_autocomplete_function(self) -> list[TagValue]: build = self.query if self.query else "" if not build.endswith("*"): build += "*" @@ -1399,7 +1407,7 @@ def semver_build_autocomplete_function(self): for build in builds ] - def semver_package_autocomplete_function(self): + def semver_package_autocomplete_function(self) -> list[TagValue]: assert self.snuba_params.organization_id is not None packages = ( Release.objects.filter( diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py b/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py index 29ab870901e4..8a9f6a6d6003 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py @@ -1,18 +1,21 @@ import logging from collections import defaultdict -from typing import TypedDict +from typing import Any, TypedDict, cast from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.request import Request from rest_framework.response import Response from sentry_protos.snuba.v1.endpoint_trace_item_attributes_pb2 import TraceItemAttributeNamesRequest from sentry_protos.snuba.v1.endpoint_trace_item_stats_pb2 import ( + AttributeDistribution, AttributeDistributionsRequest, StatsType, TraceItemStatsRequest, + TraceItemStatsResponse, ) from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey, ExtrapolationMode +from sentry_protos.snuba.v1.trace_item_filter_pb2 import TraceItemFilter from sentry import options from sentry.api.api_owners import ApiOwner @@ -40,9 +43,10 @@ from sentry.search.eap.resolver import SearchResolver from sentry.search.eap.spans.attributes import SPANS_STATS_EXCLUDED_ATTRIBUTES from sentry.search.eap.spans.definitions import SPAN_DEFINITIONS -from sentry.search.eap.types import SearchResolverConfig, SupportedTraceItemType +from sentry.search.eap.types import EAPResponse, SearchResolverConfig, SupportedTraceItemType from sentry.search.eap.utils import can_expose_attribute, translate_internal_to_public_alias from sentry.search.events import fields +from sentry.search.events.types import SnubaParams from sentry.seer.endpoints.compare import compare_distributions from sentry.seer.signed_seer_api import SeerViewerContext from sentry.snuba.referrer import Referrer @@ -196,9 +200,11 @@ def get( ) viewer_context = SeerViewerContext(organization_id=organization.id, user_id=request.user.id) + # justification: seer's contract takes `(name, label, value)` tuples; its TypedDict + # declares `dict` entries inaccurately. scored_attrs_rrr = compare_distributions( - baseline=cohort_2_distribution, - outliers=cohort_1_distribution, + baseline=cast("list[dict[str, Any]]", cohort_2_distribution), + outliers=cast("list[dict[str, Any]]", cohort_1_distribution), total_outliers=total_outliers, total_baseline=total_baseline, config={ @@ -258,7 +264,7 @@ class AttributeDistributionsResponse(TypedDict): def query_attribute_distributions( - snuba_params, + snuba_params: SnubaParams, function_string: str, above: bool, query_1: str, @@ -385,7 +391,9 @@ def query_attribute_distributions( AttributeKey(name=attr_proto.name, type=AttributeKey.TYPE_STRING) ) - def run_stats_request_with_error_handling(filter, attributes): + def run_stats_request_with_error_handling( + filter: TraceItemFilter | None, attributes: list[AttributeKey] + ) -> TraceItemStatsResponse: with handle_query_errors(): request = TraceItemStatsRequest( filter=filter, @@ -401,7 +409,7 @@ def run_stats_request_with_error_handling(filter, attributes): ) return trace_item_stats_rpc(request) - def run_table_query_with_error_handling(query_string): + def run_table_query_with_error_handling(query_string: str) -> EAPResponse: with handle_query_errors(): return Spans.run_table_query( params=snuba_params, @@ -432,14 +440,14 @@ def run_table_query_with_error_handling(query_string): totals_2_future = query_thread_pool.submit(run_table_query_with_error_handling, query_2) # Merge cohort 1 results - cohort_1_data = [] + cohort_1_data: list[AttributeDistribution] = [] for future in cohort_1_futures: result = future.result() if result.results: cohort_1_data.extend(result.results[0].attribute_distributions.attributes) # Merge cohort 2 results - cohort_2_data = [] + cohort_2_data: list[AttributeDistribution] = [] for future in cohort_2_futures: result = future.result() if result.results: diff --git a/src/sentry/api/endpoints/organization_trace_item_metrics.py b/src/sentry/api/endpoints/organization_trace_item_metrics.py index 5e5d9d4bd1db..d7953274e233 100644 --- a/src/sentry/api/endpoints/organization_trace_item_metrics.py +++ b/src/sentry/api/endpoints/organization_trace_item_metrics.py @@ -312,6 +312,8 @@ def _attach_context( for metric in metrics: type_id = TraceMetricTypes.get_id_for_type_name(metric["type"]) + if type_id is None: + continue context_row = context_by_key.get((metric["name"], type_id)) if context_row is None: continue diff --git a/src/sentry/api/endpoints/organization_trace_item_stats.py b/src/sentry/api/endpoints/organization_trace_item_stats.py index b90c57eae7b5..1809bfd4dac5 100644 --- a/src/sentry/api/endpoints/organization_trace_item_stats.py +++ b/src/sentry/api/endpoints/organization_trace_item_stats.py @@ -1,8 +1,9 @@ import logging from collections import defaultdict +from collections.abc import Callable, Sequence from concurrent.futures import as_completed from dataclasses import dataclass -from typing import Literal, get_args +from typing import Any, Literal, cast, get_args from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import serializers @@ -40,7 +41,7 @@ SPANS_STATS_EXCLUDED_ATTRIBUTES_PUBLIC_ALIAS, ) from sentry.search.eap.spans.definitions import SPAN_DEFINITIONS -from sentry.search.eap.types import SearchResolverConfig, SupportedTraceItemType +from sentry.search.eap.types import EAPResponse, SearchResolverConfig, SupportedTraceItemType from sentry.search.eap.utils import can_expose_attribute_to_api from sentry.snuba import rpc_dataset_common from sentry.snuba.occurrences_rpc import Occurrences @@ -99,10 +100,10 @@ class TraceItemStatsPaginator: it work for stats results. """ - def __init__(self, data_fn): + def __init__(self, data_fn: Callable[..., tuple[dict[str, Any], int]]) -> None: self.data_fn = data_fn - def get_result(self, limit, cursor=None): + def get_result(self, limit: int, cursor: Cursor | None = None) -> CursorResult[dict[str, Any]]: if limit <= 0: raise ValueError(f"invalid limit for paginator, expected >0, got {limit}") @@ -114,7 +115,7 @@ def get_result(self, limit, cursor=None): has_more = data[1] >= offset + limit + 1 return CursorResult( - data, + cast("Sequence[dict[str, Any]]", data), prev=Cursor(0, max(0, offset - limit), True, offset > 0), next=Cursor(0, max(0, offset + limit), False, has_more), ) @@ -250,7 +251,7 @@ def get( include_internal = is_active_superuser(request) or is_active_staff(request) include_internal_convention_attributes = request.user.is_staff or request.user.is_superuser - def get_table_results(): + def get_table_results() -> EAPResponse: with handle_query_errors(): return stats_config.rpc_class.run_table_query( params=snuba_params, @@ -264,7 +265,9 @@ def get_table_results(): selected_columns=[stats_config.id_column, "timestamp"], ) - def run_stats_query_with_item_ids(item_id_filter): + def run_stats_query_with_item_ids( + item_id_filter: str, + ) -> list[dict[str, Any]]: with handle_query_errors(): return stats_config.rpc_class.run_stats_query( params=snuba_params, @@ -277,7 +280,9 @@ def run_stats_query_with_item_ids(item_id_filter): skip_translate_internal_to_public_alias=True, ) - def run_stats_query_with_error_handling(attributes): + def run_stats_query_with_error_handling( + attributes: list[AttributeKey], + ) -> list[dict[str, dict[str, dict[str, Any]]]]: with handle_query_errors(): return stats_config.rpc_class.run_stats_query( params=snuba_params, @@ -289,7 +294,7 @@ def run_stats_query_with_error_handling(attributes): attributes=attributes, ) - def data_fn(offset: int, limit: int): + def data_fn(offset: int, limit: int) -> tuple[dict[str, Any], int]: table_results = get_table_results() if not table_results["data"]: return {"data": []}, 0 @@ -355,7 +360,7 @@ def data_fn(offset: int, limit: int): AttributeKey(name=attr.name, type=AttributeKey.TYPE_STRING) ) - stats_results: dict[str, dict[str, dict]] = defaultdict(lambda: {"data": {}}) + stats_results: dict[str, dict[str, dict[str, Any]]] = defaultdict(lambda: {"data": {}}) with ContextPropagatingThreadPoolExecutor( thread_name_prefix=__name__, max_workers=MAX_THREADS, diff --git a/src/sentry/api/endpoints/organization_trace_meta.py b/src/sentry/api/endpoints/organization_trace_meta.py index 4ef998014f32..8b6e3433ffd6 100644 --- a/src/sentry/api/endpoints/organization_trace_meta.py +++ b/src/sentry/api/endpoints/organization_trace_meta.py @@ -107,6 +107,8 @@ def count_performance_issues( }, ) + if not isinstance(performance_issues_count, int): + raise TypeError("Performance issue count is not an integer") return performance_issues_count diff --git a/src/sentry/api/endpoints/organization_traces.py b/src/sentry/api/endpoints/organization_traces.py index 50e46c39e27f..985a218ffb9b 100644 --- a/src/sentry/api/endpoints/organization_traces.py +++ b/src/sentry/api/endpoints/organization_traces.py @@ -78,7 +78,7 @@ MATCHING_COUNT_ALIAS = "matching_count" -def is_trace_name_candidate(span): +def is_trace_name_candidate(span: Mapping[str, Any]) -> bool: return span["span.op"] in CANDIDATE_SPAN_OPS @@ -179,17 +179,17 @@ def validate_query(self, value: list[str]) -> list[str]: raise serializers.ValidationError("Only 1 query is supported.") return value - def validate_dataset(self, value): + def validate_dataset(self, value: str) -> Dataset: sentry_sdk.set_tag("query.dataset", value) sentry_sdk.set_attribute("query.dataset", value) if value == "spans": return Dataset.EventsAnalyticsPlatform raise ParseError(detail=f"Unsupported dataset: {value}") - def validate(self, data): + def validate(self, data: dict[str, object]) -> dict[str, object]: if data["dataset"] == Dataset.EventsAnalyticsPlatform: sort = data.get("sort") - if sort is not None: + if isinstance(sort, str): sort_field = sort[1:] if sort.startswith("-") else sort if sort_field not in {"timestamp"}: @@ -309,7 +309,7 @@ def __init__( limit: int, breakdown_slices: int, get_all_projects: Callable[[], list[Project]], - ): + ) -> None: self.dataset = dataset self.snuba_params = snuba_params self.raw_user_queries = user_queries @@ -327,7 +327,7 @@ def params_with_all_projects(self) -> SnubaParams: return all_projects_snuba_params - def execute(self, offset: int, limit: int): + def execute(self, offset: int, limit: int) -> dict[str, list[TraceResult]]: # To support pagination on only EAP, we use the offset/limit # values from the paginator here. self.offset = offset @@ -335,7 +335,7 @@ def execute(self, offset: int, limit: int): return {"data": self._execute_rpc()} - def _execute_rpc(self): + def _execute_rpc(self) -> list[TraceResult]: if self.snuba_params.organization_id is None: raise Exception("An organization is required to resolve queries") @@ -397,7 +397,7 @@ def enrich_eap_traces_with_extra_data( self, traces: list[TraceResult], snuba_params: SnubaParams, - ): + ) -> None: trace_ids = [trace["trace"] for trace in traces] breakdown_raw_results = Spans.run_table_query( @@ -502,7 +502,7 @@ def enrich_traces_with_extra_data( spans: list[dict[str, Any]], traces_errors: dict[str, int], traces_occurrences: dict[str, int], - ): + ) -> None: traces_range = { trace["trace"]: { "start": trace["start"], @@ -596,7 +596,7 @@ def get_trace_info( trace["numOccurrences"] = traces_occurrences.get(trace["trace"], 0) trace["breakdowns"] = traces_breakdowns[trace["trace"]] - def get_traces_rpc(self, projects: list[Project]): + def get_traces_rpc(self, projects: list[Project]) -> GetTracesRequest: assert self.snuba_params.organization_id is not None meta = RequestMeta( organization_id=self.snuba_params.organization_id, @@ -727,7 +727,9 @@ def get_traces_occurrences_query( return query, Referrer.API_TRACE_EXPLORER_TRACES_OCCURRENCES -def convert_to_slice(timestamp, trace_range, left_bound=None) -> int: +def convert_to_slice( + timestamp: float, trace_range: Mapping[str, int], left_bound: int | None = None +) -> int: slices = trace_range["slices"] trace_start = trace_range["start"] trace_end = trace_range["end"] @@ -741,7 +743,9 @@ def convert_to_slice(timestamp, trace_range, left_bound=None) -> int: return idx -def quantize_range(span_start, span_end, trace_range): +def quantize_range( + span_start: float, span_end: float, trace_range: Mapping[str, int] +) -> tuple[tuple[int, int], tuple[int, int]]: slices = trace_range["slices"] trace_start = trace_range["start"] trace_end = trace_range["end"] @@ -793,7 +797,7 @@ def quantize_range(span_start, span_end, trace_range): return (int(rounded_start), int(rounded_end)), (start_index, end_index) -def new_trace_interval(row) -> TraceInterval: +def new_trace_interval(row: Mapping[str, Any]) -> TraceInterval: parent_span = row.get("parent_span", "") return { "kind": "project", @@ -810,11 +814,13 @@ def new_trace_interval(row) -> TraceInterval: } -def process_breakdowns(data, traces_range): - breakdowns: Mapping[str, list[TraceInterval]] = {trace: [] for trace in traces_range} - stacks: Mapping[str, list[TraceInterval]] = {trace: [] for trace in traces_range} +def process_breakdowns( + data: list[dict[str, Any]], traces_range: Mapping[str, Mapping[str, int]] +) -> dict[str, list[TraceInterval]]: + breakdowns: dict[str, list[TraceInterval]] = {trace: [] for trace in traces_range} + stacks: dict[str, list[TraceInterval]] = {trace: [] for trace in traces_range} - def should_merge(interval_a, interval_b): + def should_merge(interval_a: TraceInterval, interval_b: TraceInterval) -> bool: return ( # only merge intervals that have parent spans, i.e. those that aren't the trace root not interval_a["isRoot"] @@ -826,7 +832,7 @@ def should_merge(interval_a, interval_b): and interval_a["sdkName"] == interval_b["sdkName"] ) - def breakdown_push(trace, interval): + def breakdown_push(trace: str, interval: TraceInterval) -> None: breakdown = breakdowns[trace] """ TODO: Add this back @@ -859,7 +865,7 @@ def breakdown_push(trace, interval): breakdown.append(interval) - def stack_push(trace, interval): + def stack_push(trace: str, interval: TraceInterval) -> None: for last_interval in reversed(stacks[trace]): if not should_merge(last_interval, interval): continue @@ -894,17 +900,20 @@ def stack_push(trace, interval): stack = stacks[trace] stack.append(interval) - def stack_peek(trace): + def stack_peek(trace: str) -> TraceInterval | None: if not stacks[trace]: return None return stacks[trace][-1] - def stack_pop(trace): + def stack_pop(trace: str) -> TraceInterval: return stacks[trace].pop() - def stack_clear(trace, until=None): + def stack_clear(trace: str, until: int | None = None) -> None: while stacks[trace]: - if until is not None and stack_peek(trace)["end"] >= until: + last_interval = stack_peek(trace) + if last_interval is None: + break + if until is not None and last_interval["end"] >= until: break stack_pop(trace) @@ -1211,7 +1220,7 @@ def format_trace_result( return result -def validate_attribute_type(attribute: TraceAttribute, type: AttributeKey.Type.ValueType): +def validate_attribute_type(attribute: TraceAttribute, type: AttributeKey.Type.ValueType) -> None: if attribute.type != type: raise ValueError(f"Expected {attribute.key} to be of type {type} but got {attribute.type}") diff --git a/src/sentry/api/endpoints/organization_unsubscribe.py b/src/sentry/api/endpoints/organization_unsubscribe.py index 17e3031c6082..1877fed88ed1 100644 --- a/src/sentry/api/endpoints/organization_unsubscribe.py +++ b/src/sentry/api/endpoints/organization_unsubscribe.py @@ -41,14 +41,14 @@ class OrganizationUnsubscribeBase(Endpoint, Generic[T]): def fetch_instance(self, request: Request, organization_id_or_slug: int | str, id: int) -> T: raise NotImplementedError() - def unsubscribe(self, request: Request, instance: T): + def unsubscribe(self, request: Request, instance: T) -> None: raise NotImplementedError() def add_instance_data(self, data: dict[str, Any], instance: T) -> dict[str, Any]: return data def get( - self, request: Request, organization_id_or_slug: int | str, id: int, **kwargs + self, request: Request, organization_id_or_slug: int | str, id: int, **kwargs: object ) -> Response: if not auth.is_user_signed_request(request): raise NotFound() @@ -69,7 +69,7 @@ def get( return Response(self.add_instance_data(data, instance), 200) def post( - self, request: Request, organization_id_or_slug: int | str, id: int, **kwargs + self, request: Request, organization_id_or_slug: int | str, id: int, **kwargs: object ) -> Response: if not request.user_from_signed_request: raise NotFound() @@ -108,7 +108,7 @@ def add_instance_data(self, data: dict[str, Any], instance: Project) -> dict[str return data - def unsubscribe(self, request: Request, instance: Project): + def unsubscribe(self, request: Request, instance: Project) -> None: notifications_service.update_notification_options( actor=Actor(id=request.user.pk, actor_type=ActorType.USER), type=NotificationSettingEnum.ISSUE_ALERTS, @@ -142,7 +142,7 @@ def fetch_instance( raise NotFound() return issue - def unsubscribe(self, request: Request, instance: Group): + def unsubscribe(self, request: Request, instance: Group) -> None: GroupSubscription.objects.update_or_create( group=instance, project_id=instance.project_id, diff --git a/src/sentry/api/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_artifact_bundle_files.py b/src/sentry/api/endpoints/project_artifact_bundle_files.py index ea2889874033..e82932d01c0f 100644 --- a/src/sentry/api/endpoints/project_artifact_bundle_files.py +++ b/src/sentry/api/endpoints/project_artifact_bundle_files.py @@ -1,4 +1,7 @@ +from __future__ import annotations + from datetime import datetime +from typing import Any, cast from django.core.exceptions import ValidationError from django.utils.functional import cached_property @@ -14,26 +17,27 @@ from sentry.api.serializers.models.artifactbundle import ArtifactBundleFilesSerializer from sentry.constants import MAX_ARTIFACT_BUNDLE_FILES_OFFSET from sentry.models.artifactbundle import ArtifactBundle, ArtifactBundleArchive +from sentry.models.project import Project from sentry.ratelimits.config import SENTRY_RATELIMITER_GROUP_DEFAULTS, RateLimitConfig class ArtifactFile: - def __init__(self, file_path: str, info: dict[str, str]): + def __init__(self, file_path: str, info: dict[str, Any]) -> None: self.file_path = file_path self.info = info - def __eq__(self, other): - return self.file_path == other.file_path + def __eq__(self, other: object) -> bool: + return self.file_path == cast(ArtifactFile, other).file_path - def __hash__(self): + def __hash__(self) -> int: return hash(self.file_path) - def __lt__(self, other): + def __lt__(self, other: ArtifactFile) -> bool: return self.file_path < other.file_path class ArtifactBundleSource: - def __init__(self, files: dict): + def __init__(self, files: dict[str, dict[str, Any]]) -> None: self._files = files @cached_property @@ -48,7 +52,7 @@ def sorted_and_filtered_files(self) -> list[ArtifactFile]: def __len__(self) -> int: return len(self.sorted_and_filtered_files) - def __getitem__(self, range): + def __getitem__(self, range: slice | int) -> list[ArtifactFile] | ArtifactFile: return self.sorted_and_filtered_files[range] @@ -63,7 +67,7 @@ class ProjectArtifactBundleFilesEndpoint(ProjectEndpoint): group="CLI", limit_overrides={"GET": SENTRY_RATELIMITER_GROUP_DEFAULTS["default"]} ) - def get(self, request: Request, project, bundle_id) -> Response: + def get(self, request: Request, project: Project, bundle_id: str) -> Response: """ List files for a given project artifact bundle. `````````````````````````````` @@ -100,7 +104,7 @@ def get(self, request: Request, project, bundle_id) -> Response: {"error": f"The archive of artifact bundle {bundle_id} can't be opened"} ) - def serialize_results(r): + def serialize_results(r: ArtifactBundleSource) -> object: # Here we don't query with project_id under the assumption that the above's code checks for it. In case # the get_release_associations method is going to be used in other places, a project_id check should # still be performed to avoid any security problems. 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_create_sample.py b/src/sentry/api/endpoints/project_create_sample.py index 140151aa3be5..e80ab2d07517 100644 --- a/src/sentry/api/endpoints/project_create_sample.py +++ b/src/sentry/api/endpoints/project_create_sample.py @@ -9,6 +9,7 @@ from sentry.api.bases.project import ProjectEndpoint, ProjectEventPermission from sentry.api.serializers import serialize from sentry.models.groupinbox import GroupInboxReason, add_group_to_inbox +from sentry.models.project import Project from sentry.utils.samples import create_sample_event # we have a more modern python example for python @@ -27,14 +28,20 @@ class ProjectCreateSampleEndpoint(ProjectEndpoint): # This is the same scope that allows members to view all issues for a project. permission_classes = (ProjectEventPermission,) - def post(self, request: Request, project) -> Response: + def post(self, request: Request, project: Project) -> Response: try: event = create_sample_event( project, - platform=PLATFORM_MAPPING.get(project.platform, project.platform), + platform=( + PLATFORM_MAPPING.get(project.platform, project.platform) + if project.platform is not None + else project.platform + ), default="javascript", tagged=True, ) + assert event is not None + assert event.group is not None add_group_to_inbox(event.group, GroupInboxReason.NEW) data = serialize(event, request.user) diff --git a/src/sentry/api/endpoints/project_filter_details.py b/src/sentry/api/endpoints/project_filter_details.py index c46e3a6370c7..75a87107d827 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. @@ -64,27 +65,32 @@ def put( return Response(as_validation_errors(serializer), status=400) current_state = inbound_filters.get_filter_state(filter_id, project) - if isinstance(current_state, list): - current_state = set(current_state) - new_state = inbound_filters.set_filter_state(filter_id, project, serializer.validated_data) - if isinstance(new_state, list): - new_state = set(new_state) + current_state_set = set(current_state) if isinstance(current_state, list) else None + new_state_set = set(new_state) if isinstance(new_state, list) else None audit_log_state = audit_log.get_event_id("PROJECT_ENABLE") - returned_state = None + returned_state: bool | str | list[str] | set[str] | None = None if filter_id == "legacy-browsers": if isinstance(current_state, bool) or isinstance(new_state, bool): returned_state = new_state if not new_state: audit_log_state = audit_log.get_event_id("PROJECT_DISABLE") - elif current_state - new_state: - returned_state = current_state - new_state + elif ( + current_state_set is not None + and new_state_set is not None + and current_state_set - new_state_set + ): + returned_state = current_state_set - new_state_set audit_log_state = audit_log.get_event_id("PROJECT_DISABLE") - elif new_state - current_state: - returned_state = new_state - current_state + elif ( + current_state_set is not None + and new_state_set is not None + and new_state_set - current_state_set + ): + returned_state = new_state_set - current_state_set elif new_state == current_state: returned_state = new_state @@ -95,6 +101,8 @@ def put( FilterStatKeys.WEB_CRAWLER, FilterStatKeys.HEALTH_CHECK, ): + assert isinstance(current_state, bool) + assert isinstance(new_state, bool) returned_state = filter_id removed = current_state - new_state 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_performance_general_settings.py b/src/sentry/api/endpoints/project_performance_general_settings.py index ecc4edebc2e3..e15d80765901 100644 --- a/src/sentry/api/endpoints/project_performance_general_settings.py +++ b/src/sentry/api/endpoints/project_performance_general_settings.py @@ -65,7 +65,7 @@ class ProjectPerformanceGeneralSettingsEndpoint(ProjectEndpoint): 404: RESPONSE_NOT_FOUND, }, ) - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project: Project) -> Response: """ Return the general performance settings for a project. """ @@ -108,16 +108,19 @@ def post(self, request: Request, project: Project) -> Response: self.update_settings(project, request.data) return Response(status=status.HTTP_204_NO_CONTENT) - def has_feature(self, project, request) -> bool: + def has_feature(self, project: Project, request: Request) -> bool: return features.has( "organizations:performance-view", project.organization, actor=request.user ) - def get_current_settings(self, project: Project): - return project.get_option( + def get_current_settings(self, project: Project) -> dict[str, object]: + settings = project.get_option( SETTINGS_PROJECT_OPTION_KEY, DEFAULT_PROJECT_PERFORMANCE_GENERAL_SETTINGS ) + if not isinstance(settings, dict): + raise TypeError(f"{SETTINGS_PROJECT_OPTION_KEY} must be an object") + return settings - def update_settings(self, project: Project, new_settings: dict): + def update_settings(self, project: Project, new_settings: dict[str, object]) -> None: current_settings = self.get_current_settings(project) project.update_option(SETTINGS_PROJECT_OPTION_KEY, {**current_settings, **new_settings}) diff --git a/src/sentry/api/endpoints/project_repo_path_parsing.py b/src/sentry/api/endpoints/project_repo_path_parsing.py index 68ac4731e25b..03eee80d972d 100644 --- a/src/sentry/api/endpoints/project_repo_path_parsing.py +++ b/src/sentry/api/endpoints/project_repo_path_parsing.py @@ -72,7 +72,10 @@ def providers(self) -> list[str]: @property def org_id(self) -> int: - return self.context["organization_id"] + org_id = self.context["organization_id"] + if not isinstance(org_id, int): + raise TypeError("organization_id is not an integer") + return org_id def validate_source_url(self, source_url: str) -> str: # URLs copied from a provider's UI can be percent-encoded, but stack trace @@ -131,6 +134,7 @@ def repo_match(repo: Repository) -> bool: # store the repo we found self.repo = matching_repos[0] + assert self.repo.integration_id is not None self.integration = matching_integrations[self.repo.integration_id] return source_url 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_servicehook_details.py b/src/sentry/api/endpoints/project_servicehook_details.py index 210f19c2b553..651eacb93c0c 100644 --- a/src/sentry/api/endpoints/project_servicehook_details.py +++ b/src/sentry/api/endpoints/project_servicehook_details.py @@ -25,7 +25,9 @@ class ProjectServiceHookDetailsEndpoint(ServiceHookEndpoint): "PUT": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, project: Project, hook: ServiceHook, **kwargs) -> Response: + def get( + self, request: Request, project: Project, hook: ServiceHook, **kwargs: object + ) -> Response: """ Retrieve a Service Hook ``````````````````````` @@ -41,7 +43,9 @@ def get(self, request: Request, project: Project, hook: ServiceHook, **kwargs) - """ return self.respond(serialize(hook, request.user, ServiceHookSerializer())) - def put(self, request: Request, project: Project, hook: ServiceHook, **kwargs) -> Response: + def put( + self, request: Request, project: Project, hook: ServiceHook, **kwargs: object + ) -> Response: """ Update a Service Hook ````````````````````` @@ -89,7 +93,9 @@ def put(self, request: Request, project: Project, hook: ServiceHook, **kwargs) - return self.respond(serialize(hook, request.user, ServiceHookSerializer())) - def delete(self, request: Request, project: Project, hook: ServiceHook, **kwargs) -> Response: + def delete( + self, request: Request, project: Project, hook: ServiceHook, **kwargs: object + ) -> Response: """ Remove a Service Hook ````````````````````` diff --git a/src/sentry/api/endpoints/project_servicehooks.py b/src/sentry/api/endpoints/project_servicehooks.py index 044ed446f10d..d43d6fa100e5 100644 --- a/src/sentry/api/endpoints/project_servicehooks.py +++ b/src/sentry/api/endpoints/project_servicehooks.py @@ -20,6 +20,7 @@ from sentry.apidocs.parameters import CursorQueryParam, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import ObjectStatus +from sentry.models.project import Project from sentry.sentry_apps.api.parsers.servicehook import ServiceHookValidator from sentry.sentry_apps.api.serializers.servicehook import ( ServiceHookSerializer, @@ -38,7 +39,7 @@ class ProjectServiceHooksEndpoint(ProjectEndpoint): "POST": ApiPublishStatus.PRIVATE, } - def has_feature(self, request: Request, project): + def has_feature(self, request: Request, project: Project) -> bool: return features.has("projects:servicehooks", project=project, actor=request.user) @extend_schema( @@ -58,7 +59,9 @@ def has_feature(self, request: Request, project): 404: RESPONSE_NOT_FOUND, }, ) - def get(self, request: Request, project) -> Response[list[ServiceHookSerializerResponse]]: + def get( + self, request: Request, project: Project + ) -> Response[list[ServiceHookSerializerResponse]]: """ Return a list of service hooks bound to a project. @@ -102,7 +105,7 @@ def get(self, request: Request, project) -> Response[list[ServiceHookSerializerR 404: RESPONSE_NOT_FOUND, }, ) - def post(self, request: Request, project) -> Response[ServiceHookSerializerResponse]: + def post(self, request: Request, project: Project) -> Response[ServiceHookSerializerResponse]: """ Register a new service hook on a project. diff --git a/src/sentry/api/endpoints/project_symbol_sources.py b/src/sentry/api/endpoints/project_symbol_sources.py index ac12e63380e0..136bf5620d9c 100644 --- a/src/sentry/api/endpoints/project_symbol_sources.py +++ b/src/sentry/api/endpoints/project_symbol_sources.py @@ -228,7 +228,7 @@ class SourceSerializer(serializers.Serializer): help_text="The Microsoft Entra client secret. Required for Azure sources, invalid for all others.", ) - def validate(self, data): + def validate(self, data: dict[str, object]) -> dict[str, object]: if data["type"] == "http": required = ["type", "name", "url", "layout"] allowed = required + ["username", "password"] diff --git a/src/sentry/api/endpoints/project_tagkey_details.py b/src/sentry/api/endpoints/project_tagkey_details.py index 062b4dfc78c3..7066a12248a0 100644 --- a/src/sentry/api/endpoints/project_tagkey_details.py +++ b/src/sentry/api/endpoints/project_tagkey_details.py @@ -1,3 +1,5 @@ +from typing import cast + from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.request import Request from rest_framework.response import Response @@ -20,8 +22,9 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import PROTECTED_TAG_KEYS from sentry.models.environment import Environment +from sentry.models.project import Project from sentry.ratelimits.config import RateLimitConfig -from sentry.tagstore.types import TagKeySerializer, TagKeySerializerResponse +from sentry.tagstore.types import TagKey, TagKeySerializer, TagKeySerializerResponse from sentry.types.ratelimit import RateLimit, RateLimitCategory _TAG_KEY_PARAM = OpenApiParameter( @@ -68,7 +71,9 @@ class ProjectTagKeyDetailsEndpoint(ProjectEndpoint): 404: RESPONSE_NOT_FOUND, }, ) - def get(self, request: Request, project, key) -> Response[TagKeySerializerResponse]: + def get( + self, request: Request, project: Project, key: str + ) -> Response[TagKeySerializerResponse]: """ Return details about a tag key, including the number of unique and total values. """ @@ -102,7 +107,7 @@ def get(self, request: Request, project, key) -> Response[TagKeySerializerRespon 404: RESPONSE_NOT_FOUND, }, ) - def delete(self, request: Request, project, key) -> Response: + def delete(self, request: Request, project: Project, key: str) -> Response: """ Remove all occurrences of the given tag key and its values. """ @@ -138,14 +143,17 @@ def delete(self, request: Request, project, key) -> Response: return Response(status=204) - def get_tag_keys_for_deletion(self, project, key): + def get_tag_keys_for_deletion(self, project: Project, key: str) -> list[TagKey]: try: return [ - tagstore.backend.get_tag_key( - project_id=project.id, - key=key, - environment_id=None, - tenant_ids={"organization_id": project.organization_id}, + cast( + TagKey, + tagstore.backend.get_tag_key( + project_id=project.id, + key=key, + environment_id=None, + tenant_ids={"organization_id": project.organization_id}, + ), ) ] except tagstore.TagKeyNotFound: diff --git a/src/sentry/api/endpoints/project_tagkey_values.py b/src/sentry/api/endpoints/project_tagkey_values.py index 34b9e82b6bc7..83ac1624bf65 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,14 +71,16 @@ 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 most 1000 values. """ lookup_key = tagstore.backend.prefix_reserved_key(key) - tenant_ids = {"organization_id": project.organization_id} + tenant_ids: dict[str, str | int] = {"organization_id": project.organization_id} try: environment_id = get_environment_id(request, project.organization_id) except Environment.DoesNotExist: diff --git a/src/sentry/api/endpoints/project_tags.py b/src/sentry/api/endpoints/project_tags.py index 5446de01a290..38ce0da4c185 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,8 @@ def get(self, request: Request, project) -> Response[list[ProjectTagKeyResponse] except Environment.DoesNotExist: tag_keys = [] else: - kwargs: dict = {} + start: datetime.datetime | None = None + end: datetime.datetime | None = None # 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 @@ -92,8 +94,6 @@ def get(self, request: Request, project) -> Response[list[ProjectTagKeyResponse] default_start_end_dates(), datetime.timedelta(days=options.get("visibility.tag-key-max-date-range.days")), ) - kwargs["start"] = start - kwargs["end"] = end tag_keys = sorted( backend.get_tag_keys( @@ -104,7 +104,8 @@ def get(self, request: Request, project) -> Response[list[ProjectTagKeyResponse] # is a pretty old endpoint, so concerned about breaking # existing api consumers. include_values_seen=include_values_seen, - **kwargs, + start=start, + end=end, ), key=lambda x: x.key, ) @@ -117,6 +118,7 @@ def get(self, request: Request, project) -> Response[list[ProjectTagKeyResponse] "canDelete": tag_key.key not in PROTECTED_TAG_KEYS and not use_flag_backend, } if include_values_seen: + assert tag_key.values_seen is not None entry["uniqueValues"] = tag_key.values_seen data.append(entry) diff --git a/src/sentry/api/endpoints/project_trace_item_details.py b/src/sentry/api/endpoints/project_trace_item_details.py index e00cf81487ac..de2f4793f1fa 100644 --- a/src/sentry/api/endpoints/project_trace_item_details.py +++ b/src/sentry/api/endpoints/project_trace_item_details.py @@ -1,7 +1,7 @@ import time import uuid from datetime import timedelta -from typing import Any, Literal +from typing import Any, Literal, cast import sentry_sdk from google.protobuf.json_format import MessageToDict @@ -57,9 +57,9 @@ def _parse_scalar( - column_type: ColumnType, value_type, value: str + column_type: ColumnType, value_type: str, value: str ) -> tuple[ScalarValueType, ScalarType]: - parsed_value_type = value_type[3:].lower() + parsed_value_type = cast("ScalarType", value_type[3:].lower()) match column_type: case "number": if value_type in _NUMERIC_COERCIONS: @@ -111,7 +111,7 @@ def _get_value_from_attribute( def convert_rpc_attribute_to_json( - attributes: list[dict], + attributes: list[dict[str, Any]], trace_item_type: SupportedTraceItemType, include_internal: bool = False, include_arrays: bool = False, @@ -207,9 +207,9 @@ def convert_rpc_attribute_to_json( def serialize_meta( - attributes: list[dict], + attributes: list[dict[str, Any]], trace_item_type: SupportedTraceItemType, -) -> dict: +) -> dict[str, Any]: internal_name = "" attribute = {} meta_result = {} @@ -296,7 +296,7 @@ def _normalize_breadcrumbs(breadcrumbs: Any) -> None: sentry_sdk.capture_exception(e) -def serialize_event(attributes: list[dict]) -> dict[str, Any] | None: +def serialize_event(attributes: list[dict[str, Any]]) -> dict[str, Any] | None: """A few event fields (contexts, extra, breadcrumbs) are stored as JSON-serialized strings under internal `sentry.event.serialized_*` attributes. Parse any that are present back into JSON and return them, @@ -326,7 +326,7 @@ def serialize_event(attributes: list[dict]) -> dict[str, Any] | None: return result or None -def serialize_links(attributes: list[dict]) -> list[dict] | None: +def serialize_links(attributes: list[dict[str, Any]]) -> list[dict[str, Any]] | None: """Links are temporarily stored in `sentry.links` so lets parse that back out and return separately""" attribute_map = {attribute["name"]: attribute for attribute in attributes} link_attribute = get_attribute(attribute_map, "sentry.links") @@ -346,7 +346,7 @@ def serialize_links(attributes: list[dict]) -> list[dict] | None: return None -def serialize_link(link: dict) -> dict: +def serialize_link(link: dict[str, Any]) -> dict[str, Any]: clean_link = { "itemId": link["span_id"], "traceId": link["trace_id"], @@ -365,7 +365,7 @@ def serialize_link(link: dict) -> dict: return clean_link -def serialize_link_attribute(name: str, value: Any) -> dict | None: +def serialize_link_attribute(name: str, value: Any) -> dict[str, Any] | None: """ Serializes a single span link attribute, returning `None` for unsupported values. The stored value shape depends on the ingest pipeline: the 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/project_transaction_threshold.py b/src/sentry/api/endpoints/project_transaction_threshold.py index 550b99c2cd51..faabce9d0c5b 100644 --- a/src/sentry/api/endpoints/project_transaction_threshold.py +++ b/src/sentry/api/endpoints/project_transaction_threshold.py @@ -8,6 +8,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint, ProjectSettingPermission from sentry.api.serializers import serialize +from sentry.models.project import Project from sentry.models.transaction_threshold import ( TRANSACTION_METRICS, ProjectTransactionThreshold, @@ -26,14 +27,14 @@ class ProjectTransactionThresholdSerializer(serializers.Serializer): threshold = serializers.IntegerField(required=False, max_value=MAX_VALUE) metric = serializers.CharField(required=False) - def validate_metric(self, metric): + def validate_metric(self, metric: str) -> int: for key, value in TRANSACTION_METRICS.items(): if value == metric: return key raise serializers.ValidationError(f"Invalid transaction metric - {metric}") - def validate_threshold(self, threshold): + def validate_threshold(self, threshold: int) -> int: if threshold % 100: raise serializers.ValidationError("Invalid threshold - specify a multiple of 100") @@ -56,12 +57,12 @@ class ProjectTransactionThresholdEndpoint(ProjectEndpoint): } permission_classes = (ProjectSettingPermission,) - def has_feature(self, project, request): + def has_feature(self, project: Project, request: Request) -> bool: return features.has( "organizations:performance-view", project.organization, actor=request.user ) - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project: Project) -> Response: if not self.has_feature(project, request): return self.respond(status=status.HTTP_404_NOT_FOUND) @@ -84,7 +85,7 @@ def get(self, request: Request, project) -> Response: status.HTTP_200_OK, ) - def post(self, request: Request, project) -> Response: + def post(self, request: Request, project: Project) -> Response: if not self.has_feature(project, request): return self.respond(status=status.HTTP_404_NOT_FOUND) @@ -120,7 +121,7 @@ def post(self, request: Request, project) -> Response: status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, ) - def delete(self, request: Request, project) -> Response: + def delete(self, request: Request, project: Project) -> Response: if not self.has_feature(project, request): return self.respond(status=status.HTTP_404_NOT_FOUND) diff --git a/src/sentry/api/endpoints/project_transaction_threshold_override.py b/src/sentry/api/endpoints/project_transaction_threshold_override.py index 1b8b084cca6d..aceb5be56ed6 100644 --- a/src/sentry/api/endpoints/project_transaction_threshold_override.py +++ b/src/sentry/api/endpoints/project_transaction_threshold_override.py @@ -1,3 +1,5 @@ +from typing import Any + from django.db import router, transaction from rest_framework import serializers, status from rest_framework.exceptions import ParseError @@ -10,6 +12,7 @@ from sentry.api.bases.organization_events import OrganizationEventsEndpointBase from sentry.api.serializers import serialize from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.models.transaction_threshold import ( TRANSACTION_METRICS, ProjectTransactionThresholdOverride, @@ -24,20 +27,20 @@ class ProjectTransactionThresholdOverrideSerializer(serializers.Serializer): threshold = serializers.IntegerField(required=True, max_value=MAX_VALUE) metric = serializers.CharField(required=True) - def validate_metric(self, metric): + def validate_metric(self, metric: str) -> int: for key, value in TRANSACTION_METRICS.items(): if value == metric: return key raise serializers.ValidationError(f"Invalid transaction metric - {metric}") - def validate_threshold(self, threshold): + def validate_threshold(self, threshold: int) -> int: if threshold % 100: raise serializers.ValidationError("Invalid threshold - specify a multiple of 100") return threshold - def validate(self, data): + def validate(self, data: dict[str, Any]) -> dict[str, Any]: data = super().validate(data) organization = self.context.get("organization") project = self.context.get("project") @@ -71,7 +74,7 @@ class ProjectTransactionThresholdOverrideEndpoint(OrganizationEventsEndpointBase } permission_classes = (ProjectTransactionThresholdOverridePermission,) - def get_project(self, request: Request, organization): + def get_project(self, request: Request, organization: Organization) -> Project: projects = self.get_projects(request, organization) if len(projects) != 1: raise ParseError("Only 1 project per transaction threshold") @@ -101,7 +104,7 @@ def get(self, request: Request, organization: Organization) -> Response: status.HTTP_200_OK, ) - def post(self, request: Request, organization) -> Response: + def post(self, request: Request, organization: Organization) -> Response: if not self.has_feature(organization, request): return self.respond(status=status.HTTP_404_NOT_FOUND) @@ -142,7 +145,7 @@ def post(self, request: Request, organization) -> Response: status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, ) - def delete(self, request: Request, organization) -> Response: + def delete(self, request: Request, organization: Organization) -> Response: if not self.has_feature(organization, request): return self.respond(status=status.HTTP_404_NOT_FOUND) diff --git a/src/sentry/api/endpoints/prompts_activity.py b/src/sentry/api/endpoints/prompts_activity.py index 2497cb306a4d..287bdd4f00dd 100644 --- a/src/sentry/api/endpoints/prompts_activity.py +++ b/src/sentry/api/endpoints/prompts_activity.py @@ -55,7 +55,7 @@ class PromptsActivitySerializer(serializers.Serializer): help_text="The new prompt status: `snoozed`, `dismissed`, or `visible`.", ) - def validate_feature(self, value): + def validate_feature(self, value: str | None) -> str: if value is None: raise serializers.ValidationError("Must specify feature name") if not prompt_config.has(value): @@ -110,7 +110,7 @@ class PromptsActivityEndpoint(OrganizationEndpoint): }, ) def get( - self, request: Request, organization: Organization, **kwargs + self, request: Request, organization: Organization, **kwargs: object ) -> ( Response[PromptsActivityResponse] | Response[DetailResponse] @@ -171,7 +171,7 @@ def get( 404: RESPONSE_NOT_FOUND, }, ) - def put(self, request: Request, organization: Organization, **kwargs) -> Response: + def put(self, request: Request, organization: Organization, **kwargs: object) -> Response: """ Snooze, dismiss, or restore a feature prompt for the current user. """ diff --git a/src/sentry/api/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/relay/project_configs.py b/src/sentry/api/endpoints/relay/project_configs.py index 6a5941cec3fb..f0b763a12882 100644 --- a/src/sentry/api/endpoints/relay/project_configs.py +++ b/src/sentry/api/endpoints/relay/project_configs.py @@ -39,7 +39,7 @@ class RelayProjectConfigsEndpoint(Endpoint): permission_classes = (RelayPermission,) enforce_rate_limit = False - def post(self, request: Request): + def post(self, request: Request) -> Response: relay = request.relay assert relay is not None # should be provided during Authentication response: dict[str, Any] = {} @@ -69,7 +69,7 @@ def post(self, request: Request): return Response(response, status=200) - def _should_post_or_schedule(self, version, request): + def _should_post_or_schedule(self, version: str, request: Request) -> bool: """Determine whether the `_post_or_schedule_by_key` function should be used for project configs. @@ -115,11 +115,11 @@ def _should_post_or_schedule(self, version, request): return post_or_schedule - def _post_or_schedule_by_key(self, request: Request): - public_keys = set(request.relay_request_data.get("publicKeys") or ()) + def _post_or_schedule_by_key(self, request: Request) -> dict[str, object]: + public_keys: set[str] = set(request.relay_request_data.get("publicKeys") or ()) - proj_configs = {} - pending = [] + proj_configs: dict[str, ProjectConfig] = {} + pending: list[str] = [] for key in public_keys: computed = self._get_cached_or_schedule(key) if not computed: @@ -135,14 +135,14 @@ def _post_or_schedule_by_key(self, request: Request): metrics.incr("relay.project_configs.post_v3.fetched", amount=len(proj_configs)) return {"configs": proj_configs, "pending": pending} - def _get_cached_or_schedule(self, public_key) -> dict | None: + def _get_cached_or_schedule(self, public_key: str) -> ProjectConfig | None: """ Returns the config of a project if it's in the cache; else, schedules a task to compute and write it into the cache. Debouncing of the project happens after the task has been scheduled. """ - cached_config = projectconfig_cache.backend.get(public_key) + cached_config: ProjectConfig | None = projectconfig_cache.backend.get(public_key) if cached_config: return cached_config @@ -150,8 +150,7 @@ def _get_cached_or_schedule(self, public_key) -> dict | None: return None def _post_by_key(self, request: Request) -> MutableMapping[str, ProjectConfig]: - public_keys = request.relay_request_data.get("publicKeys") - public_keys = set(public_keys or ()) + public_keys: set[str] = set(request.relay_request_data.get("publicKeys") or ()) project_keys: MutableMapping[str, ProjectKey] = {} project_ids: set[int] = set() diff --git a/src/sentry/api/endpoints/release_thresholds/utils/fetch_sessions_data.py b/src/sentry/api/endpoints/release_thresholds/utils/fetch_sessions_data.py index cb749e0ea67f..d3a19d8a80ff 100644 --- a/src/sentry/api/endpoints/release_thresholds/utils/fetch_sessions_data.py +++ b/src/sentry/api/endpoints/release_thresholds/utils/fetch_sessions_data.py @@ -1,9 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any -from django.utils.datastructures import MultiValueDict from rest_framework.request import Request from sentry import release_health @@ -30,10 +28,9 @@ def fetch_sessions_data( TODO: capture potential issues with `interval` returning too many results """ with handle_query_errors(): - request_get: dict[str, Any] = request.GET - query_params: MultiValueDict[str, Any] = MultiValueDict(request_get) + query_params = request.GET.copy() query_params.setlist("groupBy", ["project", "release", "session.status", "environment"]) - query_params.setlist("field", [field]) + query_params.setlist("field", [field] if field is not None else []) query_params["query"] = " OR ".join( [f"release:{version}" for version in query_params.getlist("release")] ) diff --git a/src/sentry/api/endpoints/release_thresholds/utils/get_errors_counts_timeseries.py b/src/sentry/api/endpoints/release_thresholds/utils/get_errors_counts_timeseries.py index c0a9ba9f2b92..e4b2b5ad6f02 100644 --- a/src/sentry/api/endpoints/release_thresholds/utils/get_errors_counts_timeseries.py +++ b/src/sentry/api/endpoints/release_thresholds/utils/get_errors_counts_timeseries.py @@ -130,6 +130,8 @@ def _get_errors_counts_timeseries_snuba( request=request, referrer=Referrer.SNUBA_SESSIONS_CHECK_RELEASES_HAVE_HEALTH_DATA )["data"] + if not isinstance(data, list): + raise ValueError("Snuba returned no data array") return data 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..7a649eeebb5e 100644 --- a/src/sentry/api/endpoints/source_map_debug.py +++ b/src/sentry/api/endpoints/source_map_debug.py @@ -44,6 +44,13 @@ from sentry.utils import json, metrics from sentry.utils.javascript import find_sourcemap from sentry.utils.safe import get_path + + +def _sourcemap_header(headers: Mapping[str, object]) -> str | None: + value = headers.get("sourcemap", headers.get("x-sourcemap")) + return value if isinstance(value, str) else None + + from sentry.utils.urls import non_standard_url_join logger = logging.getLogger(__name__) @@ -431,18 +438,18 @@ 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 = _sourcemap_header(headers) + 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 +489,14 @@ 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 = _sourcemap_header(headers) + 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 +536,15 @@ 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 = _sourcemap_header(headers) + 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/exceptions.py b/src/sentry/api/exceptions.py index 2e1c429981b2..b4ab0bd9d907 100644 --- a/src/sentry/api/exceptions.py +++ b/src/sentry/api/exceptions.py @@ -1,8 +1,10 @@ from __future__ import annotations from collections.abc import Iterable +from typing import TYPE_CHECKING, cast from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth.models import AnonymousUser from django.http.request import HttpRequest from django.urls import reverse from rest_framework import status @@ -10,6 +12,11 @@ from sentry.models.organization import Organization from sentry.organizations.services.organization.model import RpcOrganization +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser + +if TYPE_CHECKING: + from rest_framework.exceptions import _Detail from sentry.utils.auth import construct_link_with_query from sentry.utils.http import is_using_customer_domain @@ -43,7 +50,13 @@ class SentryAPIException(APIException): code = "" message = "" - def __init__(self, code=None, message=None, detail=None, **kwargs): + def __init__( + self, + code: str | None = None, + message: str | None = None, + detail: object = None, + **kwargs: object, + ) -> None: # Note that we no longer call the base `__init__` here. This is because # DRF now forces all detail messages that subclass `APIException` to a # string, which breaks our format. @@ -55,7 +68,7 @@ def __init__(self, code=None, message=None, detail=None, **kwargs): "extra": kwargs, } - self.detail = {"detail": detail} + self.detail = cast("dict[str, _Detail]", {"detail": detail}) class BadRequest(SentryAPIException): @@ -81,8 +94,8 @@ def __init__( self, organization: Organization | RpcOrganization, request: HttpRequest, - after_login_redirect=None, - ): + after_login_redirect: str | None = None, + ) -> None: login_url = reverse("sentry-auth-organization", args=[organization.slug]) if is_using_customer_domain(request): login_url = organization.absolute_url(path=login_url) @@ -99,7 +112,7 @@ class MemberDisabledOverLimit(SentryAPIException): code = "member-disabled-over-limit" message = "Organization over member limit" - def __init__(self, organization): + def __init__(self, organization: Organization | RpcOrganization) -> None: super().__init__( next=reverse("sentry-organization-disabled-member", args=[organization.slug]) ) @@ -135,7 +148,7 @@ class SudoRequired(SentryAPIException): code = "sudo-required" message = "Account verification required." - def __init__(self, user): + def __init__(self, user: User | RpcUser | AnonymousUser) -> None: super().__init__(username=user.username) @@ -144,7 +157,7 @@ class PrimaryEmailVerificationRequired(SentryAPIException): code = "primary-email-verification-required" message = "Primary email verification required." - def __init__(self, user): + def __init__(self, user: User | RpcUser | AnonymousUser) -> None: super().__init__(username=user.username) diff --git a/src/sentry/api/fields/actor.py b/src/sentry/api/fields/actor.py index 4d06747d0381..c4b5a022d9e6 100644 --- a/src/sentry/api/fields/actor.py +++ b/src/sentry/api/fields/actor.py @@ -8,14 +8,11 @@ @extend_schema_field(field=OpenApiTypes.STR) -class ActorField(serializers.Field): - def __init__(self, *args, **kwds): - super().__init__(*args, **kwds) +class ActorField(serializers.Field[Actor | None, str | None, str, object]): + def to_representation(self, value: Actor | None) -> str: + return value.identifier if value is not None else "" - def to_representation(self, value): - return value.identifier - - def to_internal_value(self, data) -> Actor | None: + def to_internal_value(self, data: str | None) -> Actor | None: return parse_and_validate_actor(data, self.context["organization"].id) @@ -35,7 +32,7 @@ class OwnerActorField(ActorField): don't belong to as owners when Open Team Membership is disabled. """ - def to_internal_value(self, data) -> Actor | None: + def to_internal_value(self, data: str | None) -> Actor | None: actor = super().to_internal_value(data) if actor is None: diff --git a/src/sentry/api/fields/avatar.py b/src/sentry/api/fields/avatar.py index 8ab2703f4cde..ed667ffae844 100644 --- a/src/sentry/api/fields/avatar.py +++ b/src/sentry/api/fields/avatar.py @@ -1,5 +1,8 @@ +from __future__ import annotations + from base64 import b64decode from io import BytesIO +from typing import TYPE_CHECKING, TypedDict, Unpack from django.conf import settings from PIL import Image @@ -7,6 +10,13 @@ from sentry.api.exceptions import SentryAPIException +if TYPE_CHECKING: + from collections.abc import Sequence + + from django_stubs_ext import StrOrPromise + from rest_framework.fields import _DefaultInitial + from rest_framework.validators import Validator + # These values must be synced with the avatar cropper in frontend. MIN_DIMENSION = 256 MAX_DIMENSION = 1024 @@ -20,34 +30,49 @@ class ImageTooLarge(SentryAPIException): default_code = "too_large" -class AvatarField(serializers.Field): +class _AvatarFieldKwargs(TypedDict, total=False): + read_only: bool + write_only: bool + required: bool | None + default: _DefaultInitial[BytesIO | None] + initial: _DefaultInitial[BytesIO | None] + source: str | None + label: StrOrPromise | None + help_text: StrOrPromise | None + style: dict[str, object] | None + error_messages: dict[str, StrOrPromise] | None + validators: Sequence[Validator[BytesIO | None]] | None + allow_null: bool + + +class AvatarField(serializers.Field[BytesIO | None, str, str | bytes, object]): def __init__( self, - max_size=settings.SENTRY_MAX_AVATAR_SIZE, - min_dimension=MIN_DIMENSION, - max_dimension=MAX_DIMENSION, - is_sentry_app=None, - **kwargs, - ): + max_size: int = settings.SENTRY_MAX_AVATAR_SIZE, + min_dimension: int = MIN_DIMENSION, + max_dimension: int = MAX_DIMENSION, + is_sentry_app: bool | None = None, + **kwargs: Unpack[_AvatarFieldKwargs], + ) -> None: super().__init__(**kwargs) self.max_size = max_size self.min_dimension = min_dimension self.max_dimension = max_dimension self.is_sentry_app = is_sentry_app - def to_representation(self, value): + def to_representation(self, value: BytesIO | None) -> str | bytes: if not value: return "" return value.getvalue() - def to_internal_value(self, data): + def to_internal_value(self, data: str) -> BytesIO | None: if not data: return None - data = b64decode(data) - if len(data) > self.max_size: + decoded = b64decode(data) + if len(decoded) > self.max_size: raise ImageTooLarge() - with Image.open(BytesIO(data)) as img: + with Image.open(BytesIO(decoded)) as img: if self.is_sentry_app and ( img.format is None or Image.MIME[img.format] not in SENTRY_APP_ALLOWED_MIMETYPES ): @@ -63,9 +88,9 @@ def to_internal_value(self, data): if not self.is_valid_size(width, height): raise serializers.ValidationError("Invalid image dimensions.") - return BytesIO(data) + return BytesIO(decoded) - def is_valid_size(self, width, height): + def is_valid_size(self, width: int, height: int) -> bool: if width != height: return False if width < self.min_dimension: diff --git a/src/sentry/api/fields/empty_decimal.py b/src/sentry/api/fields/empty_decimal.py index 2ef254476d30..02d4d937fa7b 100644 --- a/src/sentry/api/fields/empty_decimal.py +++ b/src/sentry/api/fields/empty_decimal.py @@ -1,3 +1,6 @@ +from decimal import Decimal +from typing import cast + from rest_framework import serializers from rest_framework.fields import empty @@ -9,12 +12,14 @@ class EmptyDecimalField(serializers.DecimalField): cases, so this restores it. """ - def to_internal_value(self, data): + def to_internal_value(self, data: int | float | str | Decimal) -> Decimal: if data == "": - return None + # justification: DRF maps blank input to None though its stub excludes it. + return cast(Decimal, None) return super().to_internal_value(data) - def run_validation(self, data=empty): + def run_validation(self, data: object = empty) -> Decimal | None: if data == "": return None - return super().run_validation(data) + # justification: DRF does not annotate Field.run_validation. + return cast("Decimal | None", super().run_validation(data)) diff --git a/src/sentry/api/fields/empty_integer.py b/src/sentry/api/fields/empty_integer.py index e5e8d6f58259..72020cad25b3 100644 --- a/src/sentry/api/fields/empty_integer.py +++ b/src/sentry/api/fields/empty_integer.py @@ -1,3 +1,5 @@ +from typing import cast + from rest_framework import serializers from rest_framework.fields import empty @@ -9,12 +11,14 @@ class EmptyIntegerField(serializers.IntegerField): cases, so this restores it. """ - def to_internal_value(self, data): + def to_internal_value(self, data: int | float | str) -> int: if data == "": - return None + # justification: DRF maps blank input to None though its stub excludes it. + return cast(int, None) return super().to_internal_value(data) - def run_validation(self, data=empty): + def run_validation(self, data: object = empty) -> int | None: if data == "": return None - return super().run_validation(data) + # justification: DRF does not annotate Field.run_validation. + return cast("int | None", super().run_validation(data)) diff --git a/src/sentry/api/fields/sentry_slug.py b/src/sentry/api/fields/sentry_slug.py index 262d76b5689b..0618687c2f76 100644 --- a/src/sentry/api/fields/sentry_slug.py +++ b/src/sentry/api/fields/sentry_slug.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING, TypedDict, Unpack + from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers @@ -12,6 +14,30 @@ ORG_SLUG_PATTERN, ) +if TYPE_CHECKING: + from collections.abc import Sequence + + from django_stubs_ext import StrOrPromise + from rest_framework.fields import _DefaultInitial + from rest_framework.validators import Validator + + +class _SentrySerializerSlugFieldKwargs(TypedDict, total=False): + read_only: bool + write_only: bool + required: bool | None + default: _DefaultInitial[StrOrPromise] + initial: _DefaultInitial[str] + source: str | None + label: StrOrPromise | None + help_text: StrOrPromise | None + style: dict[str, object] | None + validators: Sequence[Validator[str]] | None + allow_null: bool + allow_blank: bool + trim_whitespace: bool + min_length: int | None + @extend_schema_field(field=OpenApiTypes.STR) class SentrySerializerSlugField(serializers.RegexField): @@ -27,12 +53,11 @@ class SentrySerializerSlugField(serializers.RegexField): def __init__( self, - error_messages=None, + error_messages: dict[str, StrOrPromise] | None = None, org_slug: bool = False, max_length: int = DEFAULT_SLUG_MAX_LENGTH, - *args, - **kwargs, - ): + **kwargs: Unpack[_SentrySerializerSlugFieldKwargs], + ) -> None: # Avoid using mutable dict for error_messages defaults b/c all calls to # the function reuse this one instance, persisting changes between them. if error_messages is None: @@ -43,6 +68,4 @@ def __init__( pattern = ORG_SLUG_PATTERN error_messages["invalid"] = ORG_SLUG_ERROR_MESSAGE - super().__init__( - pattern, error_messages=error_messages, max_length=max_length, *args, **kwargs - ) + super().__init__(pattern, error_messages=error_messages, max_length=max_length, **kwargs) diff --git a/src/sentry/api/fields/serializedfile.py b/src/sentry/api/fields/serializedfile.py index 0b1bd692a94c..cf51be25624a 100644 --- a/src/sentry/api/fields/serializedfile.py +++ b/src/sentry/api/fields/serializedfile.py @@ -1,12 +1,21 @@ +from __future__ import annotations + from base64 import b64decode, b64encode +from collections.abc import Sequence from dataclasses import dataclass from io import BytesIO +from typing import TYPE_CHECKING, TypedDict, Unpack, cast from django.conf import settings from rest_framework import serializers from sentry.api.exceptions import SentryAPIException +if TYPE_CHECKING: + from django_stubs_ext import StrOrPromise + from rest_framework.fields import _DefaultInitial + from rest_framework.validators import Validator + @dataclass class FileUpload: @@ -20,23 +29,40 @@ class FileTooLarge(SentryAPIException): default_code = "too_large" -class SerializedFileField(serializers.Field): +class _SerializedFileFieldKwargs(TypedDict, total=False): + read_only: bool + write_only: bool + required: bool | None + default: _DefaultInitial[FileUpload | None] + initial: _DefaultInitial[FileUpload | None] + source: str | None + label: StrOrPromise | None + help_text: StrOrPromise | None + style: dict[str, object] | None + error_messages: dict[str, StrOrPromise] | None + validators: Sequence[Validator[FileUpload | None]] | None + allow_null: bool + + +class SerializedFileField( + serializers.Field[FileUpload | None, Sequence[str | bytes] | None, str | list[str], object] +): def __init__( self, - max_size=settings.SENTRY_MAX_SERIALIZED_FILE_SIZE, - **kwargs, - ): + max_size: int = settings.SENTRY_MAX_SERIALIZED_FILE_SIZE, + **kwargs: Unpack[_SerializedFileFieldKwargs], + ) -> None: super().__init__(**kwargs) self.max_size = max_size - def to_representation(self, value): + def to_representation(self, value: FileUpload | None) -> str | list[str]: if not value: return "" if not isinstance(value, FileUpload): raise ValueError return [value.name, b64encode(value.content.getvalue()).decode("utf-8")] - def to_internal_value(self, data): + def to_internal_value(self, data: Sequence[str | bytes] | None) -> FileUpload | None: if not data: return None @@ -54,4 +80,5 @@ def to_internal_value(self, data): if self.max_size and len(data) > self.max_size: raise FileTooLarge() - return FileUpload(name=filename, content=BytesIO(decodedcontent)) + # justification: wire data carries the filename as a string. + return FileUpload(name=cast(str, filename), content=BytesIO(decodedcontent)) diff --git a/src/sentry/api/fields/user.py b/src/sentry/api/fields/user.py index 2c8f87463190..b413b70a909c 100644 --- a/src/sentry/api/fields/user.py +++ b/src/sentry/api/fields/user.py @@ -12,8 +12,10 @@ @extend_schema_field(field=OpenApiTypes.STR) -class UserField(serializers.Field): - def to_representation(self, value: RpcUser) -> str: +class UserField(serializers.Field[RpcUser | User | None, object, str, object]): + def to_representation(self, value: RpcUser | User | None) -> str: + if value is None: + return "" return value.username def to_internal_value(self, data: Any) -> RpcUser | User | None: 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/helpers/deprecation.py b/src/sentry/api/helpers/deprecation.py index 4ed3dcbc7e2d..fcb175cb3dca 100644 --- a/src/sentry/api/helpers/deprecation.py +++ b/src/sentry/api/helpers/deprecation.py @@ -4,7 +4,7 @@ import logging from collections.abc import Callable from datetime import datetime, timedelta -from typing import Concatenate, ParamSpec, TypeVar +from typing import Concatenate, ParamSpec, TypeVar, cast from cronsim import CronSim, CronSimError from django.conf import settings @@ -135,7 +135,8 @@ def _resolve_route_name(route_name: str, request: Request) -> str | None: else: subs[param] = f":{param}" - return "/" + (template % subs) + resolved = "/" + (template % subs) + return resolved if isinstance(resolved, str) else None except Exception: logger.exception("deprecation.route_resolution_failed", extra={"route_name": route_name}) return None @@ -226,6 +227,7 @@ def endpoint_method( return response - return endpoint_method + # justification: wraps preserves the decorated endpoint's signature. + return cast("EndpointT[SelfT, P]", endpoint_method) return decorator diff --git a/src/sentry/api/helpers/environments.py b/src/sentry/api/helpers/environments.py index 865752cfb121..65a001c7d710 100644 --- a/src/sentry/api/helpers/environments.py +++ b/src/sentry/api/helpers/environments.py @@ -13,7 +13,14 @@ from sentry.models.organization import Organization from sentry.organizations.services.organization import RpcOrganization -environment_visibility_filter_options = { +if TYPE_CHECKING: + from django.db.models import QuerySet + + from sentry.models.environment import EnvironmentProject + +environment_visibility_filter_options: dict[ + str, Callable[[QuerySet[EnvironmentProject]], QuerySet[EnvironmentProject]] +] = { "all": lambda queryset: queryset, "hidden": lambda queryset: queryset.filter(is_hidden=True), "visible": lambda queryset: queryset.exclude(is_hidden=True), diff --git a/src/sentry/api/helpers/events.py b/src/sentry/api/helpers/events.py index 844033419631..57ac1774a974 100644 --- a/src/sentry/api/helpers/events.py +++ b/src/sentry/api/helpers/events.py @@ -212,6 +212,8 @@ def run_group_events_query( }, ) + if not isinstance(results, list): + raise TypeError("Event comparison returned no results array") return results diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 4f538045e9e3..1d7aaa77edc3 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -5,7 +5,7 @@ from collections import defaultdict from collections.abc import Mapping, MutableMapping, Sequence from http import HTTPStatus -from typing import Any, NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict, cast from urllib.parse import urlparse import rest_framework @@ -897,10 +897,14 @@ def most_recent_release(project: Project) -> Release | None: def most_recent_release_matching_commit( projects: Sequence[Project], commit: Commit ) -> Release | None: - return ( - Release.objects.filter(projects__in=projects, releasecommit__commit=commit) - .extra(select={"sort": "COALESCE(date_released, date_added)"}) - .order_by("-sort")[0] + # justification: extra() erases the queryset element type. + return cast( + "Release | None", + ( + Release.objects.filter(projects__in=projects, releasecommit__commit=commit) + .extra(select={"sort": "COALESCE(date_released, date_added)"}) + .order_by("-sort")[0] + ), ) @@ -918,7 +922,8 @@ def get_semver_releases(project: Project) -> QuerySet[Release]: .annotate_prerelease_column() .annotate_build_code_column() ) - return qs.order_by(*[f"-{col}" for col in semver_cols]) + # justification: the semver annotation helpers erase the queryset type. + return cast("QuerySet[Release]", qs.order_by(*[f"-{col}" for col in semver_cols])) def handle_is_subscribed( diff --git a/src/sentry/api/helpers/group_index/validators/status_details.py b/src/sentry/api/helpers/group_index/validators/status_details.py index 226a4ba4b162..56111d2ccc80 100644 --- a/src/sentry/api/helpers/group_index/validators/status_details.py +++ b/src/sentry/api/helpers/group_index/validators/status_details.py @@ -1,4 +1,4 @@ -from typing import NotRequired, TypedDict +from typing import NotRequired, TypedDict, cast from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers @@ -58,13 +58,15 @@ def validate_inRelease(self, value: str) -> Release: project = self.context["project"] if value == "latest": try: - return ( + release = ( Release.objects.filter( projects=project, organization_id=project.organization_id ) .extra(select={"sort": "COALESCE(date_released, date_added)"}) .order_by("-sort")[0] ) + # justification: extra() erases the queryset element type. + return cast("Release", release) except IndexError: raise serializers.ValidationError( "No release data present in the system to form a basis for 'Next Release'" @@ -82,11 +84,13 @@ def validate_inRelease(self, value: str) -> Release: def validate_inNextRelease(self, value: bool) -> "Release": project = self.context["project"] try: - return ( + release = ( Release.objects.filter(projects=project, organization_id=project.organization_id) .extra(select={"sort": "COALESCE(date_released, date_added)"}) .order_by("-sort")[0] ) + # justification: extra() erases the queryset element type. + return cast("Release", release) except IndexError: raise serializers.ValidationError( "No release data present in the system to form a basis for 'Next Release'" diff --git a/src/sentry/api/helpers/projects.py b/src/sentry/api/helpers/projects.py index c3940b39c14a..a5f636c0866f 100644 --- a/src/sentry/api/helpers/projects.py +++ b/src/sentry/api/helpers/projects.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Callable, Iterable from typing import NamedTuple, TypeAlias from django.http.request import HttpRequest @@ -86,7 +86,7 @@ def filter_projects_by_permissions( # NOTE: We must check staff before superuser or else _admin will fail when both cookies are active if is_active_staff(request): span.set_attribute("mode", "staff_fetch_all") - proj_filter = lambda proj: proj.status == ObjectStatus.ACTIVE # noqa: E731 + proj_filter: Callable[[Project], bool] = lambda proj: proj.status == ObjectStatus.ACTIVE # noqa: E731 # Superuser should fetch all projects. # Also fetch all accessible projects if requesting $all elif is_active_superuser(request) or include_all_accessible: diff --git a/src/sentry/api/paginator.py b/src/sentry/api/paginator.py index c97e9c16a2ac..531b95ed1612 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, Collection, 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 @@ -45,7 +45,10 @@ def count_hits(queryset: Any, max_hits: int) -> int: # and h_params contains the properly escaped parameters that are passed to cursor.execute(). count_sql = "SELECT COUNT(*) FROM (" + h_sql + ") as t" cursor.execute(count_sql, h_params) - return cursor.fetchone()[0] + row = cursor.fetchone() + if row is None or not isinstance(row[0], int): + raise TypeError("Count query returned no integer") + return row[0] class BadPaginationError(Exception): @@ -62,10 +65,31 @@ 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]]] + + +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 @@ -82,8 +106,9 @@ def __init__( 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 + def build_queryset(self, value: object, is_prev: bool) -> QuerySet[Model]: + 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 +122,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_by = tuple(new_order_by) else: if asc: queryset = queryset.order_by(self.key) @@ -117,8 +142,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 +152,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 +165,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 +188,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 +219,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 +233,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 +248,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 +258,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 +269,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)) @@ -251,9 +284,14 @@ def value_from_cursor(self, cursor: Cursor) -> datetime: # type: ignore[overrid # entirely and uses standard paging class OffsetPaginator(PaginatorLike): def __init__( - self, queryset, order_by=None, max_limit=MAX_LIMIT, max_offset=None, on_results=None - ): - self.key = ( + self, + queryset: QuerySet[Model], + order_by: str | Sequence[str] | None = None, + max_limit: int = MAX_LIMIT, + max_offset: int | None = None, + on_results: Callable[[Sequence[Model]], Sequence[Model]] | None = None, + ) -> None: + self.key: Collection[str] | None = ( order_by if order_by is None or isinstance(order_by, (list, tuple, set)) else (order_by,) @@ -309,9 +347,9 @@ def get_result( next_cursor = Cursor(limit, page + 1, False, len(results) > limit) prev_cursor = Cursor(limit, page - 1, True, page > 0) - results = list(results[:limit]) + page_results: Sequence[Model] = list(results[:limit]) if self.on_results: - results = self.on_results(results) + page_results = self.on_results(page_results) if known_hits is not None: hits = known_hits @@ -323,7 +361,7 @@ def get_result( hits = None return CursorResult( - results=results, + results=page_results, next=next_cursor, prev=prev_cursor, hits=hits, @@ -343,20 +381,21 @@ class MergingOffsetPaginator(OffsetPaginator): def __init__( self, - queryset, - data_load_func, - apply_to_queryset, - key_from_model=None, - key_from_data=None, - max_limit=MAX_LIMIT, - on_results=None, - data_count_func=None, - queryset_load_func=None, - ): + queryset: QuerySet[Model], + data_load_func: Callable[..., Sequence[object]], + apply_to_queryset: Callable[[QuerySet[Model], Sequence[object]], QuerySet[Model]], + key_from_model: Callable[[Model], object] | None = None, + key_from_data: Callable[[object], object] | None = None, + max_limit: int = MAX_LIMIT, + on_results: Callable[[Sequence[Model]], Sequence[Model]] | None = None, + data_count_func: Callable[[], int] | None = None, + queryset_load_func: Callable[[QuerySet[Model], int, int, int], Sequence[Model]] + | None = None, + ) -> None: super().__init__(queryset, max_limit=max_limit, on_results=on_results) self.data_load_func = data_load_func self.apply_to_queryset = apply_to_queryset - self.key_from_model = key_from_model or (lambda x: x.id) + self.key_from_model = key_from_model or (lambda x: getattr(x, "id")) self.key_from_data = key_from_data or (lambda x: x) self.data_count_func = data_count_func self.queryset_load_func = queryset_load_func @@ -408,10 +447,10 @@ def get_result(self, limit: int = 100, cursor: Any = None) -> CursorResult[Any]: next_cursor = Cursor(limit, page + 1, False, has_more) prev_cursor = Cursor(limit, page - 1, True, page > 0) + returned_results: Sequence[Model] = results if self.on_results: - results = self.on_results(results) - - return CursorResult(results=results, next=next_cursor, prev=prev_cursor) + returned_results = self.on_results(returned_results) + return CursorResult(results=returned_results, next=next_cursor, prev=prev_cursor) def reverse_bisect_left(a: Sequence[Any], x: Any, lo: int = 0, hi: int | None = None) -> int: @@ -447,8 +486,8 @@ def __init__( data: Iterable[tuple[int, T]], reverse: bool = False, max_limit: int = MAX_LIMIT, - on_results=None, - ): + on_results: Callable[[list[T]], list[T]] | None = None, + ) -> None: data = sorted(data, reverse=reverse) self.scores = [score for score, _ in data] self.values = [value for _, value in data] @@ -459,7 +498,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: @@ -545,10 +591,13 @@ class GenericOffsetPaginator: return full object if necessary. (if isinstance statement) """ - def __init__(self, data_fn): + def __init__( + self, + data_fn: Callable[..., Sequence[object] | Mapping[str, object]], + ) -> None: 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: @@ -561,10 +610,13 @@ def get_result(self, limit, cursor=None): has_more = len(data) == limit + 1 if has_more: data.pop() - elif isinstance(data.get("data"), list): - has_more = len(data["data"]) == limit + 1 + elif isinstance(data, Mapping): + rows = data.get("data") + if not isinstance(rows, list): + raise NotImplementedError + has_more = len(rows) == limit + 1 if has_more: - data["data"].pop() + rows.pop() else: raise NotImplementedError @@ -573,7 +625,7 @@ def get_result(self, limit, cursor=None): # becomes the absolute row offset from the beginning of the entire # dataset, which is the same meaning as SQLs `OFFSET`. return CursorResult( - data, + cast(Sequence[object], data), prev=Cursor(0, max(0, offset - limit), True, offset > 0), next=Cursor(0, max(0, offset + limit), False, has_more), ) @@ -705,8 +757,8 @@ def _build_combined_querysets(self, is_prev: bool, max_rows: int | None = None) queryset = queryset[:max_rows] combined_querysets += list(queryset) - def _sort_combined_querysets(item): - sort_keys = [] + def _sort_combined_querysets(item: object) -> tuple[object, ...]: + sort_keys: list[object] = [] sort_keys.append(self.get_item_key(item)) if len(self.model_key_map[type(item)]) > 1: # XXX: This doesn't do anything - it just uses a column name as the sort key. It should be pulling the @@ -757,7 +809,7 @@ def get_result(self, cursor: Cursor | None = None, limit: int = 100) -> CursorRe return CursorResult(results=results, next=next_cursor, prev=prev_cursor) -class ChainPaginator: +class ChainPaginator[T]: """ Chain multiple datasources together and paginate them as one source. The datasources should be provided in the order they should be used. @@ -766,13 +818,19 @@ class ChainPaginator: assumed that sources have their data sorted already. """ - def __init__(self, sources, max_limit=MAX_LIMIT, max_offset=None, on_results=None): + def __init__( + self, + sources: Sequence[Sequence[T]], + max_limit: int = MAX_LIMIT, + max_offset: int | None = None, + on_results: Callable[[list[T]], list[T]] | None = None, + ) -> None: self.sources = sources self.max_limit = max_limit self.max_offset = max_offset self.on_results = on_results - def get_result(self, limit=100, cursor=None): + def get_result(self, limit: int = 100, cursor: Cursor | None = None) -> CursorResult[T]: # offset is page # # value is page limit if cursor is None: @@ -781,7 +839,7 @@ def get_result(self, limit=100, cursor=None): limit = min(limit, self.max_limit) page = cursor.offset - offset = cursor.offset * cursor.value + offset = cursor.offset * cast(int, cursor.value) if self.max_offset is not None and offset >= self.max_offset: raise BadPaginationError("Pagination offset too large") @@ -790,7 +848,7 @@ def get_result(self, limit=100, cursor=None): if offset < 0: raise BadPaginationError("Pagination offset cannot be negative") - results: list[object] = [] + results: list[T] = [] # note: we shouldn't use itertools.islice(itertools.chain.from_iterable(self.sources)) # because source may be a QuerySet which is much more efficient to slice directly for source in self.sources: @@ -862,20 +920,23 @@ def get_result(self, limit: int, cursor: Cursor | None = None) -> CursorResult[A class EAPPageTokenPaginator: - def __init__(self, data_fn): + def __init__( + self, + data_fn: Callable[[int, PageToken | None], Mapping[str, object]], + ) -> None: 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) - data = self.data_fn(limit, page_token) + data = cast("dict[str, object]", self.data_fn(limit, page_token)) - next_page_token = data.pop("page_token") + next_page_token = cast(PageToken, data.pop("page_token")) return CursorResult( - results=data, + results=cast("Sequence[object]", data), prev=Cursor("", 0, True, False), next=self.cursor_from_page_token(page_token=next_page_token), ) diff --git a/src/sentry/api/serializers/models/activity.py b/src/sentry/api/serializers/models/activity.py index 04c5c1006be2..e50c42a306fb 100644 --- a/src/sentry/api/serializers/models/activity.py +++ b/src/sentry/api/serializers/models/activity.py @@ -1,6 +1,6 @@ -from collections.abc import Sequence +from collections.abc import Callable, Mapping, Sequence from datetime import datetime -from typing import Any, TypeAlias, TypedDict +from typing import TypeAlias, TypedDict, cast 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: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ActivitySerializerResponse: if ( obj.type == ActivityType.SET_RESOLVED_IN_RELEASE.value and obj.data @@ -301,8 +317,8 @@ def serialize(self, obj: Activity, attrs, user, **kwargs): return { "id": str(obj.id), "commentId": str(obj.id) if obj.type == ActivityType.NOTE.value else None, - "user": attrs["user"], - "sentry_app": attrs["sentry_app"], + "user": cast(dict[str, object] | None, attrs["user"]), + "sentry_app": cast(_ActivitySentryAppEmbed | None, attrs["sentry_app"]), "type": obj.get_type_display(), "data": data, "dateCreated": obj.datetime, diff --git a/src/sentry/api/serializers/models/actor.py b/src/sentry/api/serializers/models/actor.py index d873d8fce336..1a5f6166248a 100644 --- a/src/sentry/api/serializers/models/actor.py +++ b/src/sentry/api/serializers/models/actor.py @@ -1,6 +1,11 @@ -from typing import Literal, NotRequired, TypedDict +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 +15,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..1735ce2ef555 100644 --- a/src/sentry/api/serializers/models/agentic_onboarding.py +++ b/src/sentry/api/serializers/models/agentic_onboarding.py @@ -1,5 +1,7 @@ from collections.abc import Mapping -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 from sentry.api.serializers.rest_framework import convert_dict_key_case, snake_to_camel_case @@ -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 @@ -24,7 +28,11 @@ def serialize_stage_extra(extra: StageExtra | None) -> dict[str, Any] | None: if extra is None: return None - return convert_dict_key_case(json.loads(extra.json()), snake_to_camel_case) + # justification: StageExtra serializes to a JSON object of extra fields. + return cast( + "dict[str, Any]", + convert_dict_key_case(json.loads(extra.json()), snake_to_camel_case), + ) class AgenticOnboardingRunResponse(TypedDict): @@ -47,7 +55,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..ef84d4ca8efb 100644 --- a/src/sentry/api/serializers/models/artifactbundle.py +++ b/src/sentry/api/serializers/models/artifactbundle.py @@ -1,15 +1,68 @@ import base64 +import uuid +import zipfile +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Protocol, TypedDict, cast + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer -from sentry.models.artifactbundle import ReleaseArtifactBundle, SourceFileType +from sentry.models.artifactbundle import ( + ArtifactBundleArchive, + ReleaseArtifactBundle, + SourceFileType, +) +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser 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 ArtifactBundleFilesSerializerResponse(TypedDict): + id: str + fileType: int + filePath: str | None + fileSize: int | None + debugId: str | None + sourcemap: str | None -class ArtifactBundlesSerializer(Serializer): + +class ArtifactBundleFile(Protocol): + file_path: str + info: Mapping[str, object] + + +class ArtifactBundleFileAttrs(TypedDict): + file_type: SourceFileType | None + file_path: str + file_url: str | None + file_info: zipfile.ZipInfo | None + debug_id: str | None + sourcemap: str | None + + +class ArtifactBundlesSerializer(Serializer[ArtifactBundlesSerializerResponse]): @staticmethod - def _compute_associations(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 +72,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,34 +103,46 @@ 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"])), } -class ArtifactBundleFilesSerializer(Serializer): - def __init__(self, archive, *args, **kwargs): +class ArtifactBundleFilesSerializer(Serializer[ArtifactBundleFilesSerializerResponse]): + def __init__(self, archive: ArtifactBundleArchive, *args: object, **kwargs: object) -> None: Serializer.__init__(self, *args, **kwargs) self.archive = archive - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[ArtifactBundleFile], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[ArtifactBundleFile, ArtifactBundleFileAttrs]: return {item: self._compute_attrs(item) for item in item_list} - def _compute_attrs(self, item): + def _compute_attrs(self, item: ArtifactBundleFile) -> ArtifactBundleFileAttrs: file_path = item.file_path info = item.info - headers = self.archive.normalize_headers(info.get("headers", {})) + headers = self.archive.normalize_headers(cast("dict[str, object]", info.get("headers", {}))) debug_id = self.archive.normalize_debug_id(headers.get("debug-id")) - sourcemap = headers.get("sourcemap") + # justification: manifest header values are JSON strings + sourcemap = cast("str | None", headers.get("sourcemap")) return { - "file_type": SourceFileType.from_lowercase_key(info.get("type")), + "file_type": SourceFileType.from_lowercase_key(cast("str | None", info.get("type"))), "file_path": file_path, "file_url": self.archive.get_file_url_by_file_path(file_path), "file_info": self.archive.get_file_info(file_path), @@ -80,18 +150,27 @@ def _compute_attrs(self, item): "sourcemap": sourcemap, } - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: ArtifactBundleFile, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ArtifactBundleFilesSerializerResponse: + file_attrs = cast(ArtifactBundleFileAttrs, attrs) return { - "id": base64.urlsafe_b64encode(attrs["file_path"].encode()).decode(), + "id": base64.urlsafe_b64encode(file_attrs["file_path"].encode()).decode(), # In case the file type string was invalid, we return the sentinel value INVALID_SOURCE_FILE_TYPE. "fileType": ( - attrs["file_type"].value - if attrs["file_type"] is not None + file_attrs["file_type"].value + if file_attrs["file_type"] is not None else INVALID_SOURCE_FILE_TYPE ), # We decided to return the file url as file path for better searchability. - "filePath": attrs["file_url"], - "fileSize": attrs["file_info"].file_size if attrs["file_info"] is not None else None, - "debugId": attrs["debug_id"], - "sourcemap": attrs["sourcemap"], + "filePath": file_attrs["file_url"], + "fileSize": ( + file_attrs["file_info"].file_size if file_attrs["file_info"] is not None else None + ), + "debugId": file_attrs["debug_id"], + "sourcemap": file_attrs["sourcemap"], } diff --git a/src/sentry/api/serializers/models/auditlogentry.py b/src/sentry/api/serializers/models/auditlogentry.py index faaf9c5145a4..24f1ed8c5b7e 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 Protocol, TypedDict, cast +from django.contrib.auth.models import AnonymousUser from django.db.models import prefetch_related_objects from sentry_sdk import capture_exception @@ -8,15 +12,21 @@ 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): +class _Team(Protocol): + id: int + + +def fix(data: dict[str, object]) -> dict[str, object]: # There was a point in time where full Team objects # got serialized into our AuditLogEntry.data, so these # values need to be stripped and reduced down to integers - if data.get("teams"): - if hasattr(data["teams"][0], "id"): - data["teams"] = [t.id for t in data["teams"]] + teams = cast(list[object], data.get("teams")) + if teams and hasattr(teams[0], "id"): + data["teams"] = [cast(_Team, team).id for team in teams] # Only return fields in the `data` field that are actually used by the frontend # Do not add fields to this list that contain sensitive information @@ -24,7 +34,7 @@ def fix(data): return {k: v for k, v in data.items() if k in required_fields} -def override_actor_id(user): +def override_actor_id(user: User | RpcUser) -> re.Match[str] | None: # overrides the usage of actor_id only to make SCIM token # name more readable (for now) scim_prefix = "scim-internal-integration-" @@ -36,9 +46,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") @@ -56,7 +84,7 @@ def get_attrs(self, item_list, user, **kwargs): item: { "actor": ( users[str(item.actor_id)] - if item.actor_id and not override_actor_id(item.actor) + if item.actor_id and not override_actor_id(cast(User, item.actor)) else {"name": item.get_actor_name()} ), "targetUser": users.get(str(item.target_user_id)) or item.target_user_id, @@ -64,7 +92,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 +119,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..d7c647a14c30 100644 --- a/src/sentry/api/serializers/models/broadcast.py +++ b/src/sentry/api/serializers/models/broadcast.py @@ -1,13 +1,53 @@ +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import NotRequired, 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 + plans: NotRequired[list[str] | None] + roles: NotRequired[list[str] | None] + trialStatus: NotRequired[list[str] | None] + region: NotRequired[str | None] + platform: NotRequired[list[str] | None] + product: NotRequired[list[str] | None] + earlyAdopter: NotRequired[bool | None] + organizations: NotRequired[list[int] | None] @register(Broadcast) -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 +59,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 +75,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 +100,22 @@ 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: + # justification: mypy cannot prove the base spread fills the subclass TypedDict. + context = cast( + "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..fd2a1b06ab38 100644 --- a/src/sentry/api/serializers/models/commit.py +++ b/src/sentry/api/serializers/models/commit.py @@ -1,7 +1,9 @@ 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 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.pullrequest import PullRequestSerializerResponse @@ -11,7 +13,10 @@ from sentry.models.commit import Commit from sentry.models.commitauthor import CommitAuthor from sentry.models.pullrequest import PullRequest +from sentry.models.release import Release from sentry.models.repository import Repository +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class EmptyAuthor(TypedDict): @@ -44,7 +49,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 +64,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,29 +120,47 @@ 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: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> CommitSerializerResponse: d: CommitSerializerResponse = { "id": obj.key, "message": obj.message, "dateCreated": obj.date_added, - "pullRequest": attrs["pull_request"], - "suspectCommitType": attrs["suspect_commit_type"], + "pullRequest": cast(PullRequestSerializerResponse | None, attrs["pull_request"]), + "suspectCommitType": cast(str, attrs["suspect_commit_type"]), } if "repository" not in self.exclude: - d["repository"] = attrs["repository"] + d["repository"] = cast(RepositorySerializerResponse, attrs["repository"]) if "author" not in self.exclude: - d["author"] = attrs["user"] + d["author"] = cast(Author | EmptyAuthor, attrs["user"]) return d @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 +174,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: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> CommitSerializerResponseWithReleases: data = super().serialize(obj, attrs, user) ret: CommitSerializerResponseWithReleases = { "id": data["id"], @@ -154,12 +197,12 @@ def serialize(self, obj, attrs, user, **kwargs) -> CommitSerializerResponseWithR "dateReleased": r.date_released, "dateCreated": r.date_added, } - for r in attrs["releases"] + for r in cast(Sequence[Release], attrs["releases"]) ], } if "repository" in data: - ret["repository"] = attrs["repository"] + ret["repository"] = cast(RepositorySerializerResponse, attrs["repository"]) if "author" in data: - ret["author"] = attrs["user"] + ret["author"] = cast(Author | EmptyAuthor, attrs["user"]) return ret diff --git a/src/sentry/api/serializers/models/dashboard.py b/src/sentry/api/serializers/models/dashboard.py index db04d7e88664..a3a01bbb3a64 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 Mapping, Sequence from datetime import datetime -from typing import Any, NotRequired, TypedDict +from typing import Any, 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 @@ -126,8 +131,13 @@ class DashboardPermissionsResponse(TypedDict): @register(DashboardWidget) class DashboardWidgetSerializer(Serializer[DashboardWidgetResponse]): - def get_attrs(self, item_list, user, **kwargs): - result = {} + def get_attrs( + self, + item_list: Sequence[DashboardWidget], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[DashboardWidget, dict[str, object]]: + result: dict[DashboardWidget, dict[str, Any]] = {} # justification: serializer API data_sources = serialize( list( DashboardWidgetQuery.objects.filter(widget_id__in=[i.id for i in item_list]) @@ -142,7 +152,8 @@ def get_attrs(self, item_list, user, **kwargs): return result - def get_explore_urls(self, obj, attrs): + # justification: serializer API + def get_explore_urls(self, obj: DashboardWidget, attrs: Mapping[Any, Any]) -> list[str]: from sentry.explore.translation.dashboards_translation import ( translate_dashboard_widget_queries, ) @@ -174,7 +185,7 @@ def get_explore_urls(self, obj, attrs): y_axes = ( aggregate_equation_fields if obj.display_type == DashboardWidgetDisplayTypes.TABLE - else spans_query.aggregates + else (spans_query.aggregates or []) ) explore_mode = "aggregate" @@ -216,7 +227,7 @@ def get_explore_urls(self, obj, attrs): group_by = ( non_aggregate_group_by_fields if spans_query.fields and obj.display_type == DashboardWidgetDisplayTypes.TABLE - else spans_query.columns + else (spans_query.columns or []) ) if len(group_by) == 0: group_by = [""] @@ -311,12 +322,22 @@ def get_explore_urls(self, obj, attrs): return urls - def serialize(self, obj, attrs, user, **kwargs) -> DashboardWidgetResponse: + def serialize( + self, + obj: DashboardWidget, + attrs: Mapping[Any, Any], # justification: serializer API + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> DashboardWidgetResponse: # Text widgets don't have a widget_type if obj.display_type == DashboardWidgetDisplayTypes.TEXT: widget_type = None else: - widget_type = DashboardWidgetTypes.get_type_name(obj.widget_type) + widget_type = ( + DashboardWidgetTypes.get_type_name(obj.widget_type) + if obj.widget_type is not None + else None + ) if widget_type is None: sentry_sdk.set_context( "dashboard", @@ -351,11 +372,14 @@ def serialize(self, obj, attrs, user, **kwargs) -> DashboardWidgetResponse: except Exception: explore_urls = None + display_type_name = DashboardWidgetDisplayTypes.get_type_name(obj.display_type) + assert display_type_name is not None + serialized_widget: DashboardWidgetResponse = { "id": str(obj.id), "title": obj.title, "description": obj.description, - "displayType": DashboardWidgetDisplayTypes.get_type_name(obj.display_type), + "displayType": display_type_name, "thresholds": _convert_thresholds_to_camel_case(obj.thresholds), # Default value until a backfill can be done. "interval": str(obj.interval or "5m"), @@ -380,7 +404,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: Mapping[Any, Any], # justification: serializer API + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> OnDemandResponse: return { "enabled": obj.extraction_enabled(), "extractionState": obj.extraction_state, @@ -390,28 +420,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 +461,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: Mapping[Any, Any], # justification: serializer API + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> DashboardWidgetQueryResponse: return { "id": str(obj.id), "name": obj.name, @@ -444,21 +488,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: Mapping[Any, Any], # justification: serializer API + 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 +527,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] @@ -533,11 +585,14 @@ def get_filters(self, obj: Dashboard) -> tuple[PageFilters, DashboardFilters]: class DashboardListSerializer(Serializer, DashboardFiltersMixin): - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, item_list: Sequence[Dashboard], user: User | RpcUser | AnonymousUser, **kwargs: object + ) -> dict[Dashboard, _Widget]: item_dict = {i.id: i for i in item_list} prefetch_related_objects(item_list, "projects__organization") - organization = (kwargs.get("context") or {}).get("organization") + context = cast("Mapping[str, Any]", kwargs.get("context") or {}) + organization = context.get("organization") use_user_last_visited = organization is not None and features.has( "organizations:dashboards-user-last-visited", organization, actor=user ) @@ -546,7 +601,7 @@ def get_attrs(self, item_list, user, **kwargs): user_last_visited_map = { dlv.dashboard_id: dlv.last_visited for dlv in DashboardLastVisited.objects.filter( - user_id=user.id, + user_id=cast("str | int", user.id), dashboard_id__in=item_dict.keys(), ) } @@ -555,7 +610,9 @@ def get_attrs(self, item_list, user, **kwargs): favorited_dashboard_ids = set( DashboardFavoriteUser.objects.filter( - user_id=user.id, dashboard_id__in=item_dict.keys(), favorited=True + user_id=cast("str | int", user.id), + dashboard_id__in=item_dict.keys(), + favorited=True, ).values_list("dashboard_id", flat=True) ) @@ -563,7 +620,7 @@ def get_attrs(self, item_list, user, **kwargs): dashboard_id__in=item_dict.keys() ).prefetch_related("teams_with_edit_access") - result: dict[int, _Widget] + result: dict[Dashboard, _Widget] result = defaultdict( lambda: { "widget_display": [], @@ -578,12 +635,15 @@ def get_attrs(self, item_list, user, **kwargs): for widget in widgets: dashboard = item_dict[widget.dashboard_id] display_type = DashboardWidgetDisplayTypes.get_type_name(widget.display_type) + assert display_type is not None result[dashboard]["widget_display"].append(display_type) for widget in widgets: dashboard = item_dict[widget.dashboard_id] + display_type = DashboardWidgetDisplayTypes.get_type_name(widget.display_type) + assert display_type is not None widget_preview: _WidgetPreview = { - "displayType": DashboardWidgetDisplayTypes.get_type_name(widget.display_type), + "displayType": display_type, "layout": None, } if widget.detail: @@ -627,7 +687,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: Mapping[Any, Any], # justification: serializer API + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> DashboardListResponse: return { "id": str(obj.id), "title": obj.title, @@ -648,7 +714,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 +729,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 +741,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)] @@ -704,7 +780,13 @@ def get_attrs(self, item_list, user, **kwargs): return result - def serialize(self, obj, attrs, user, **kwargs) -> DashboardDetailsResponse: + def serialize( + self, + obj: Dashboard, + attrs: Mapping[Any, Any], # justification: serializer API + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> DashboardDetailsResponse: page_filters, tag_filters = self.get_filters(obj) data: DashboardDetailsResponse = { @@ -715,7 +797,7 @@ def serialize(self, obj, attrs, user, **kwargs) -> DashboardDetailsResponse: "widgets": attrs["widgets"], "filters": tag_filters, "permissions": serialize(obj.permissions) if hasattr(obj, "permissions") else None, - "isFavorited": user.id in obj.favorited_by, + "isFavorited": cast("int", user.id) in obj.favorited_by, "projects": page_filters.get("projects", []), "environment": page_filters.get("environment", []), "prebuiltId": obj.prebuilt_id, @@ -729,23 +811,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: Mapping[Any, Any], # justification: serializer API + 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..5a4e4f53ecf7 100644 --- a/src/sentry/api/serializers/models/discoversavedquery.py +++ b/src/sentry/api/serializers/models/discoversavedquery.py @@ -1,6 +1,9 @@ from collections import defaultdict +from collections.abc import Mapping, Sequence from datetime import datetime -from typing import TypedDict +from typing import TypedDict, cast + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register from sentry.auth.principal import has_user @@ -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[str, object] | None + lastVisited: datetime | None starred: bool position: int | None @@ -50,14 +55,14 @@ class DiscoverSavedQueryResponse(DiscoverSavedQueryResponseOptional): queryDataset: str datasetSource: str expired: bool - dateCreated: str - dateUpdated: str - createdBy: UserSerializerResponse + dateCreated: datetime + dateUpdated: datetime + createdBy: UserSerializerResponse | None @register(DiscoverSavedQuery) class DiscoverSavedQueryModelSerializer(Serializer[DiscoverSavedQueryResponse]): - def partial_serialize_explore_query(self, query: ExploreSavedQuery) -> dict: + def partial_serialize_explore_query(self, query: ExploreSavedQuery) -> dict[str, object]: query_keys = [ "environment", "query", @@ -66,7 +71,7 @@ def partial_serialize_explore_query(self, query: ExploreSavedQuery) -> dict: "end", "interval", ] - data = { + data: dict[str, object] = { "id": str(query.id), "name": query.name, "projects": [project.id for project in query.projects.all()], @@ -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: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> DiscoverSavedQueryResponse: query_keys = [ "environment", "query", @@ -192,27 +208,29 @@ def serialize(self, obj, attrs, user, **kwargs) -> DiscoverSavedQueryResponse: "topEvents", "interval", ] + dataset_name = DiscoverSavedQueryTypes.get_type_name(obj.dataset) + assert dataset_name is not None data: DiscoverSavedQueryResponse = { "id": str(obj.id), "name": obj.name, "projects": [project.id for project in obj.projects.all()], "version": obj.version or obj.query.get("version", 1), - "queryDataset": DiscoverSavedQueryTypes.get_type_name(obj.dataset), + "queryDataset": dataset_name, "datasetSource": DATASET_SOURCES[obj.dataset_source], "expired": False, "dateCreated": obj.date_created, "dateUpdated": obj.date_updated, - "createdBy": attrs.get("created_by"), + "createdBy": cast(UserSerializerResponse | None, attrs.get("created_by")), } if "user_last_visited" in attrs: - data["lastVisited"] = attrs["user_last_visited"] + data["lastVisited"] = cast(datetime | None, attrs["user_last_visited"]) if "starred" in attrs: - data["starred"] = attrs["starred"] + data["starred"] = cast(bool, attrs["starred"]) if "position" in attrs: - data["position"] = attrs["position"] + data["position"] = cast(int | None, attrs["position"]) for key in query_keys: if obj.query.get(key) is not None: @@ -232,6 +250,6 @@ def serialize(self, obj, attrs, user, **kwargs) -> DiscoverSavedQueryResponse: data["projects"] = list(ALL_ACCESS_PROJECTS) if attrs.get("explore_query") is not None: - data["exploreQuery"] = attrs.get("explore_query") + data["exploreQuery"] = cast(dict[str, object] | None, attrs.get("explore_query")) return data diff --git a/src/sentry/api/serializers/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..9cfdc8fb2885 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 {} @@ -137,7 +144,7 @@ def get_entries(event: Event | GroupEvent, user: User, is_public: bool = False): if not data and interface.path != "spans": continue - entry = {"data": data, "type": interface.external_type} + entry: dict[str, object] = {"data": data, "type": interface.external_type} api_meta = None if meta.get(key): @@ -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] @@ -219,7 +226,9 @@ class GroupEventDetailsResponse(IssueEventSerializerResponse): @register(GroupEvent) @register(Event) class EventSerializer(Serializer): - def _get_interface_with_meta(self, event, name, is_public=False): + def _get_interface_with_meta( + self, event: Event | GroupEvent, name: str, is_public: bool = False + ) -> tuple[Any, Any]: interface = event.get_interface(name) if not interface: return (None, None) @@ -237,12 +246,14 @@ def _get_interface_with_meta(self, event, name, is_public=False): return (data, meta_with_chunks(data, api_meta)) - def _get_attr_with_meta(self, event, attr, default=None): + def _get_attr_with_meta( + self, event: Event | GroupEvent, attr: str, default: Any = None + ) -> tuple[Any, Any]: value = event.data.get(attr, default) meta = get_path(event.data, "_meta", attr) return (value, meta_with_chunks(value, meta)) - def _get_legacy_message_with_meta(self, event): + def _get_legacy_message_with_meta(self, event: Event | GroupEvent) -> tuple[str | None, Any]: meta = event.data.get("_meta") message: str | None = get_path(event.data, "logentry", "formatted") @@ -258,17 +269,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 +318,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 +330,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: Mapping[str, Any], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> EventSerializerResponse: from sentry.api.serializers.rest_framework import convert_dict_key_case, snake_to_camel_case errors = [ @@ -392,20 +416,24 @@ def serialize(self, obj, attrs, user, **kwargs) -> EventSerializerResponse: **self.__serialize_error_attrs(attrs, obj), } - def __serialize_transaction_attrs(self, attrs, obj) -> TransactionEventFields: + def __serialize_transaction_attrs( + self, attrs: Mapping[str, Any], obj: Event | GroupEvent + ) -> TransactionEventFields: """ Add attributes that are only present on transaction events. """ transaction_attrs: TransactionEventFields = { - "startTimestamp": obj.data.get("start_timestamp"), - "endTimestamp": obj.data.get("timestamp"), + "startTimestamp": cast("float", obj.data.get("start_timestamp")), + "endTimestamp": cast("float", obj.data.get("timestamp")), "measurements": obj.data.get("measurements"), "breakdowns": obj.data.get("breakdowns"), } return transaction_attrs - def __serialize_error_attrs(self, attrs, obj) -> ErrorEventFields: + def __serialize_error_attrs( + self, attrs: Mapping[str, Any], obj: Event | GroupEvent + ) -> ErrorEventFields: """ Add attributes that are present on error and default event types """ @@ -427,12 +455,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 +493,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 +521,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: @@ -498,13 +537,17 @@ def _get_release_info( ) except Release.DoesNotExist: return {"version": version} + # justification: the release serializer emits the release payload. if include_full_release_data: - return serialize(release, user) + return cast("GroupEventReleaseSerializerResponse", serialize(release, user)) else: 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 +568,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: Mapping[str, Any], + user: User | RpcUser | AnonymousUser, + **kwargs: Any, + ) -> 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"}): @@ -546,15 +595,15 @@ def get_attrs( self, item_list: Sequence[Event | GroupEvent], user: User | RpcUser | AnonymousUser, - **kwargs, - ): - is_public = kwargs.pop("is_public", False) + **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, **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 +618,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: Mapping[str, Any], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> IssueEventSerializerResponse: result = super().serialize(obj, attrs, user, **kwargs) return { @@ -581,10 +636,21 @@ 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): + def serialize( + self, + obj: Event | GroupEvent, + attrs: Mapping[str, Any], + user: User | RpcUser | AnonymousUser, + **kwargs: Any, + ) -> EventSerializerResponse: base = super().serialize(obj, attrs, user) result: dict[str, Any] = { k: v @@ -592,7 +658,7 @@ def serialize(self, obj, attrs, user, **kwargs): if k not in {"context", "contexts", "user", "tags", "sdk", "errors"} } result["entries"] = [e for e in result["entries"] if e["type"] != "breadcrumbs"] - return result + return cast("EventSerializerResponse", result) SimpleEventSerializerResponse = TypedDict( @@ -631,7 +697,12 @@ class SimpleEventSerializer(EventSerializer): organization event search API gets real slow. """ - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[Event | GroupEvent], + user: User | RpcUser | AnonymousUser, + **kwargs: Any, + ) -> dict[Event | GroupEvent, dict[str, Any]]: # Batch-fetch event bodies from nodestore in a single multi-get RPC. # serialize() accesses properties (tags, message, title, # get_event_metadata, etc.) that fall through to nodestore when the @@ -643,17 +714,27 @@ def get_attrs(self, item_list, user, **kwargs): if options.get("issues.group_events.batch_nodestore_enabled"): unbound = [e for e in item_list if e.data._node_data is None] if unbound: - eventstore.backend.bind_nodes(unbound) + eventstore.backend.bind_nodes(cast("list[Event]", unbound)) 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: Mapping[str, Any], + 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,23 +769,45 @@ 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 should be used for Integrations that need to include event data. """ - def serialize(self, obj, attrs, user, **kwargs): + def serialize( # type: ignore[override] # justification: external payload intentionally differs. + self, + obj: Event | GroupEvent, + attrs: Mapping[str, Any], + user: User | RpcUser | AnonymousUser, + **kwargs: Any, + ) -> ExternalEventSerializerResponse: from sentry.notifications.utils import get_notification_group_title - tags = [{"key": key.split("sentry:", 1)[-1], "value": value} for key, value in obj.tags] + tags: list[EventTag] = [ + {"key": key.split("sentry:", 1)[-1], "value": value} for key, value in obj.tags + ] for tag in tags: query = convert_user_tag_to_query(tag["key"], tag["value"]) if query: tag["query"] = query map_device_class_tags(tags) - user = obj.get_minimal_user() + minimal_user = obj.get_minimal_user() return { "groupID": str(obj.group_id) if obj.group_id else None, @@ -716,14 +819,14 @@ def serialize(self, obj, attrs, user, **kwargs): "title": get_notification_group_title(obj.group, obj, 1024), "location": obj.location, "culprit": obj.culprit, - "user": user and user.get_api_context(), + "user": minimal_user.get_api_context() if minimal_user is not None else None, "tags": tags, "platform": obj.platform, "datetime": obj.datetime.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), } -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..da8697566b4f 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} @@ -43,6 +54,6 @@ def serialize(self, obj, attrs, user, **kwargs) -> EventAttachmentSerializerResp def get_mimetype(file: File) -> str: rv = file.headers.get("Content-Type") - if rv: + if isinstance(rv, str) and rv: return rv.split(";")[0].strip() return mimetypes.guess_type(file.name)[0] or "application/octet-stream" diff --git a/src/sentry/api/serializers/models/eventuser.py b/src/sentry/api/serializers/models/eventuser.py index 394ec8f5bea1..5508a37cc335 100644 --- a/src/sentry/api/serializers/models/eventuser.py +++ b/src/sentry/api/serializers/models/eventuser.py @@ -1,28 +1,39 @@ +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.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: Mapping[str, object], + 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..19d745d810ed 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, Sequence 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,27 @@ 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": {}}) + def get_attrs( + self, + item_list: Sequence[ExploreSavedQuery], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[ExploreSavedQuery, dict[str, object]]: + result: defaultdict[ExploreSavedQuery, dict[str, object]] = defaultdict( + lambda: {"created_by": {}} + ) - starred_queries: 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 +177,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", @@ -170,18 +195,22 @@ def serialize(self, obj, attrs, user, **kwargs) -> ExploreSavedQueryResponse: "agent", "errorsOnly", ] + dataset_name = ExploreSavedQueryDataset.get_type_name(obj.dataset) + assert dataset_name is not None + # justification: names come from `ExploreSavedQueryDataset.TYPES`, a fixed literal set. + dataset_name = cast(ExploreSavedQueryDatasetType, dataset_name) data: ExploreSavedQueryResponse = { "id": str(obj.id), "name": obj.name, "projects": [project.id for project in obj.projects.all()], - "dataset": ExploreSavedQueryDataset.get_type_name(obj.dataset), + "dataset": dataset_name, "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..668de502ea52 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 Mapping, Sequence from datetime import datetime -from typing import Any, TypedDict +from typing import TypedDict, cast + +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: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ExportedDataResponse: file = obj._get_file() if file is None: checksum = None @@ -66,7 +82,7 @@ def serialize(self, obj, attrs, user, **kwargs) -> ExportedDataResponse: return { "id": obj.id, - "user": attrs.get("user"), + "user": cast(ExportedDataUserResponse | None, attrs.get("user")), "dateCreated": obj.date_added, "dateFinished": obj.date_finished, "dateExpired": obj.date_expired, diff --git a/src/sentry/api/serializers/models/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..872a09faa7af 100644 --- a/src/sentry/api/serializers/models/group.py +++ b/src/sentry/api/serializers/models/group.py @@ -6,7 +6,16 @@ from collections import defaultdict from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, TypeGuard +from typing import ( + TYPE_CHECKING, + Any, + Literal, + NotRequired, + Protocol, + TypedDict, + TypeGuard, + cast, +) from django.conf import settings from django.contrib.auth.models import AnonymousUser @@ -14,6 +23,7 @@ from sentry_sdk import traces from sentry import tagstore +from sentry.api.event_search import SearchFilter from sentry.api.serializers import Serializer, register, serialize from sentry.api.serializers.models.actor import ActorSerializer, ActorSerializerResponse from sentry.auth.principal import has_user @@ -52,7 +62,11 @@ from sentry.notifications.types import NotificationSettingEnum from sentry.reprocessing2 import get_progress from sentry.search.events.constants import RELEASE_STAGE_ALIAS -from sentry.search.events.filter import convert_search_filter_to_snuba_query, format_search_filter +from sentry.search.events.filter import ( + FilterConvertParams, + convert_search_filter_to_snuba_query, + format_search_filter, +) from sentry.services.eventstore.reprocessing.base import ReprocessingInfo from sentry.snuba.dataset import Dataset from sentry.tagstore.snuba.backend import fix_tag_value_data @@ -84,7 +98,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) @@ -127,7 +141,7 @@ class BaseGroupResponseOptional(TypedDict, total=False): class BaseGroupSerializerResponse(BaseGroupResponseOptional): id: str shareId: str | None - shortId: str + shortId: str | None title: str culprit: str | None permalink: str @@ -184,6 +198,12 @@ class SeenStats(TypedDict): first_seen: datetime | None last_seen: datetime | None user_count: int + filtered: NotRequired[SeenStats | None] + lifetime: NotRequired[SeenStats | None] + + +class _SeenStatsQueryResult(TypedDict): + data: Sequence[Mapping[str, object]] class SeenStatsResponse(TypedDict): @@ -256,9 +276,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 @@ -401,7 +421,11 @@ def get_attrs( "has_seen": seen_groups.get(item.id, active_date) > active_date, "annotations": annotations_by_group_id[item.id], "ignore_until": ignore_item, - "ignore_actor": actors.get(ignore_item.actor_id) if ignore_item else None, + "ignore_actor": ( + actors.get(ignore_item.actor_id) + if ignore_item and ignore_item.actor_id is not None + else None + ), "resolution": resolution, "resolution_type": resolution_type, "resolution_actor": resolution_actor, @@ -430,11 +454,12 @@ def serialize( share_id = attrs["share_id"] priority_label = PriorityLevel(obj.priority).to_str() if obj.priority else None issue_category = obj.issue_category.name.lower() + qualified_short_id = obj.qualified_short_id group_dict: BaseGroupSerializerResponse = { "id": str(obj.id), "shareId": share_id, - "shortId": obj.qualified_short_id, + "shortId": qualified_short_id, "title": obj.title, "culprit": obj.culprit, "permalink": permalink, @@ -448,7 +473,7 @@ def serialize( "project": _make_group_project_response(obj.project), "type": obj.get_event_type(), "metadata": obj.get_event_metadata(), - "numComments": obj.num_comments, + "numComments": obj.num_comments or 0, "assignedTo": serialize(attrs["assigned_to"], user, ActorSerializer()), "isBookmarked": attrs["is_bookmarked"], "isSubscribed": is_subscribed, @@ -470,35 +495,40 @@ def serialize( if "derived_data" in attrs: group_dict["derivedData"] = attrs["derived_data"] if is_seen_stats(attrs): - group_dict.update(self._convert_seen_stats(attrs)) + # justification: seen-stats fields are optional members of the response TypedDict. + cast("dict[str, Any]", group_dict).update(self._convert_seen_stats(attrs)) return group_dict @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 - def _get_status(self, attrs: Mapping[str, Any], obj: Group): + def _get_status( + self, + attrs: Mapping[str, Any], # justification: serializer attrs are dynamically keyed. + obj: Group, + ) -> tuple[GroupStatusDetailsResponseOptional, GroupStatusStr]: status = obj.status - status_details = {} + status_details: GroupStatusDetailsResponseOptional = {} if attrs["ignore_until"]: snooze = attrs["ignore_until"] if snooze.is_valid(group=obj): @@ -552,7 +582,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 +615,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: @@ -597,12 +629,16 @@ def _get_group_snuba_stats( for item, cache_key in zip(item_list, cache_keys): unhandled[item.id] = cache_data.get(cache_key) - filter_keys: dict[str, list[int]] = {} + filter_keys: dict[str, Sequence[int]] = {} + project_ids: list[int] = [] + group_ids: list[int] = [] for item in item_list: if unhandled.get(item.id) is not None: continue - filter_keys.setdefault("project_id", []).append(item.project_id) - filter_keys.setdefault("group_id", []).append(item.id) + project_ids.append(item.project_id) + group_ids.append(item.id) + if project_ids: + filter_keys = {"project_id": project_ids, "group_id": group_ids} if filter_keys: rv = raw_query( @@ -624,7 +660,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 +674,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 +746,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 +845,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 +891,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 +908,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, @@ -896,15 +934,17 @@ def __seen_stats_impl( project_id = issue_list[0].project_id item_ids = [g.id for g in issue_list] - tenant_ids = {"organization_id": issue_list[0].project.organization_id} + tenant_ids: dict[str, str | int] = { + "organization_id": issue_list[0].project.organization_id + } user_counts: Mapping[int, int] = user_counts_func( [project_id], item_ids, environment_ids=[environment.id] if environment is not None else None, tenant_ids=tenant_ids, ) - first_seen: MutableMapping[int, datetime] = {} - last_seen: MutableMapping[int, datetime] = {} + first_seen: MutableMapping[int, datetime | None] = {} + last_seen: MutableMapping[int, datetime | None] = {} times_seen: MutableMapping[int, int] = {} if environment is not None: @@ -944,7 +984,7 @@ class SharedGroupSerializerResponse(TypedDict): isUnhandled: bool | None issueCategory: str permalink: str - shortId: str + shortId: str | None title: str latestEvent: dict[str, Any] project: dict[str, Any] @@ -1024,12 +1064,12 @@ def __init__( environment_ids: list[int] | None = None, start: datetime | None = None, end: datetime | None = None, - search_filters=None, - collapse=None, - expand=None, - organization_id=None, - project_ids=None, - ): + search_filters: Sequence[SearchFilter] | None = None, + collapse: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + organization_id: int | None = None, + project_ids: list[int] | None = None, + ) -> None: super().__init__(collapse=collapse, expand=expand) from sentry.search.snuba.executors import get_search_filter @@ -1066,15 +1106,19 @@ def __init__( conditions = [] if search_filters is not None: + params = cast( + FilterConvertParams, + { + "organization_id": organization_id, + "project_id": project_ids, + "environment_id": environment_ids, + }, + ) for search_filter in search_filters: if search_filter.key.name not in self.skip_snuba_fields: formatted_conditions, projects_to_filter, group_ids = format_search_filter( search_filter, - params={ - "organization_id": organization_id, - "project_id": project_ids, - "environment_id": environment_ids, - }, + params=params, ) # if no re-formatted conditions, use fallback method @@ -1084,11 +1128,7 @@ def __init__( elif group_ids: new_condition = convert_search_filter_to_snuba_query( search_filter, - params={ - "organization_id": organization_id, - "project_id": project_ids, - "environment_id": environment_ids, - }, + params=params, ) if new_condition: @@ -1096,7 +1136,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 +1152,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 +1169,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, + ) -> _SeenStatsQueryResult: project_ids = list({item.project_id for item in item_list}) group_ids = [item.id for item in item_list] aggregations = [ @@ -1144,25 +1188,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( + _SeenStatsQueryResult, + 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, + ) -> _SeenStatsQueryResult: project_ids = list({item.project_id for item in item_list}) group_ids = [item.id for item in item_list] aggregations = [ @@ -1175,25 +1226,31 @@ 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( + _SeenStatsQueryResult, + 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 + ), ), ) @staticmethod def _parse_seen_stats_results( - result, item_list, use_result_first_seen_times_seen, environment_ids=None - ): + result: _SeenStatsQueryResult, + item_list: Sequence[Group], + use_result_first_seen_times_seen: bool, + environment_ids: list[int] | None = None, + ) -> Mapping[Group, SeenStats]: seen_data = { issue["group_id"]: fix_tag_value_data( dict(filter(lambda key: key[0] != "group_id", issue.items())) @@ -1264,12 +1321,13 @@ def serialize( **kwargs: Any, ) -> SimpleGroupSerializerResponse: issue_category = obj.issue_category.name.lower() + qualified_short_id = obj.qualified_short_id return SimpleGroupSerializerResponse( id=str(obj.id), title=obj.title, culprit=obj.culprit, - shortId=obj.qualified_short_id, + shortId=qualified_short_id, level=_get_level_label(obj), status=_get_status_label(obj), substatus=_get_substatus_label(obj), @@ -1279,7 +1337,7 @@ def serialize( issueType=obj.issue_type.slug, issueCategory=issue_category, metadata=obj.get_event_metadata(), - numComments=obj.num_comments, + numComments=obj.num_comments or 0, firstSeen=obj.first_seen, lastSeen=obj.last_seen, ) diff --git a/src/sentry/api/serializers/models/group_stream.py b/src/sentry/api/serializers/models/group_stream.py index b69ba5ac5f10..86703b350eb6 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 ( @@ -23,6 +24,7 @@ GroupStatusDetailsResponseOptional, GroupStatusStr, SeenStats, + _SeenStatsQueryResult, is_seen_stats, snuba_tsdb, ) @@ -61,6 +63,12 @@ class GroupStatsQueryArgs(NamedTuple): stats_period_end: datetime | None = None +class _TSDBQueryParams(TypedDict): + start: datetime + end: datetime + rollup: int + + class GroupStatsMixin: STATS_PERIOD_CHOICES = { "14d": StatsPeriod(14, timedelta(hours=24)), @@ -83,21 +91,25 @@ class GroupStatsMixin: def query_tsdb( self, groups: Sequence[Group], - query_params, - conditions=None, - environment_ids=None, - user=None, - **kwargs, - ): - pass + query_params: _TSDBQueryParams, + conditions: Sequence[Sequence[object]] | None = None, + environment_ids: list[int] | None = None, + user: User | RpcUser | AnonymousUser | None = None, + aggregation_override: str | None = None, + **kwargs: object, + ) -> dict[int, list[tuple[int, int]]] | None: + return None def get_stats( self, item_list: Sequence[Group], - user, + user: User | RpcUser | AnonymousUser, stats_query_args: GroupStatsQueryArgs, - **kwargs, - ): + conditions: Sequence[Sequence[object]] | None = None, + environment_ids: list[int] | None = None, + aggregation_override: str | None = None, + **kwargs: object, + ) -> dict[int, list[tuple[int, int]]] | None: if stats_query_args and stats_query_args.stats_period: # we need to compute stats at 1d (1h resolution), and 14d or a custom given period if stats_query_args.stats_period == "auto": @@ -126,7 +138,7 @@ def get_stats( delta_day = self.CUSTOM_ROLLUP_CHOICES["24h"] rollup = round(total_period / (self.CUSTOM_SEGMENTS * delta_day)) * delta_day - query_params = { + query_params: _TSDBQueryParams = { "start": stats_query_args.stats_period_start, "end": stats_query_args.stats_period_end, "rollup": max(int(rollup), 1), # Zero is a bad thing to divide by @@ -143,13 +155,18 @@ def get_stats( return self.query_tsdb( item_list, query_params, + conditions=conditions, + environment_ids=environment_ids, user=user, + aggregation_override=aggregation_override, **kwargs, ) + return None + 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 +174,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 ( @@ -183,7 +204,7 @@ def get_attrs( GroupStatsQueryArgs(self.stats_period, None, None), ) for item in item_list: - attrs[item]["stats"] = stats[item.id] + attrs[item]["stats"] = cast("dict[int, list[tuple[int, int]]]", stats)[item.id] return attrs @@ -197,7 +218,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 = {} @@ -206,12 +229,13 @@ def serialize( def query_tsdb( self, groups: Sequence[Group], - query_params, - conditions=None, - environment_ids=None, - user=None, - **kwargs, - ): + query_params: _TSDBQueryParams, + conditions: Sequence[Sequence[object]] | None = None, + environment_ids: list[int] | None = None, + user: User | RpcUser | AnonymousUser | None = None, + aggregation_override: str | None = None, + **kwargs: object, + ) -> dict[int, list[tuple[int, int]]] | None: try: environment = self.environment_func() except Environment.DoesNotExist: @@ -232,12 +256,12 @@ def query_tsdb( class _SeenStatsFunc(Protocol): def __call__( self, - item_list, - start=None, - end=None, - conditions=None, - environment_ids=None, - ) -> Mapping[str, Any]: ... + item_list: Sequence[Group], + start: datetime | None = None, + end: datetime | None = None, + conditions: Sequence[Sequence[object]] | None = None, + environment_ids: list[int] | None = None, + ) -> _SeenStatsQueryResult: ... class _Filtered(TypedDict): @@ -252,7 +276,7 @@ class StreamGroupSerializerSnubaResponse(TypedDict): id: str # from base response shareId: NotRequired[str | None] - shortId: NotRequired[str] + shortId: NotRequired[str | None] title: NotRequired[str] culprit: NotRequired[str | None] permalink: NotRequired[str] @@ -308,17 +332,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, @@ -385,7 +409,7 @@ def get_attrs( for item in item_list: if filtered_stats: attrs[item]["filtered_stats"] = filtered_stats[item.id] - attrs[item]["stats"] = stats[item.id] + attrs[item]["stats"] = cast("dict[int, list[tuple[int, int]]]", stats)[item.id] if self._expand("sessions"): uniq_project_ids = list({item.project_id for item in item_list}) @@ -511,11 +535,15 @@ def serialize( # type: ignore[override] # intentionally different shape **kwargs: Any, ) -> StreamGroupSerializerSnubaResponse: if not self._collapse("base"): - result: StreamGroupSerializerSnubaResponse = {**super().serialize(obj, attrs, user)} + # justification: mypy cannot prove the base spread fills the subclass TypedDict. + result = cast( + "StreamGroupSerializerSnubaResponse", {**super().serialize(obj, attrs, user)} + ) else: result = {"id": str(obj.id)} if is_seen_stats(attrs): - result.update(self._convert_seen_stats(attrs)) + # justification: seen-stats fields are optional members of the response TypedDict. + cast("dict[str, Any]", result).update(self._convert_seen_stats(attrs)) if "is_unhandled" in attrs: result["isUnhandled"] = attrs["is_unhandled"] @@ -573,15 +601,15 @@ def serialize( # type: ignore[override] # intentionally different shape def query_tsdb( self, groups: Sequence[Group], - query_params, - conditions=None, - environment_ids=None, - user=None, + query_params: _TSDBQueryParams, + conditions: Sequence[Sequence[object]] | None = None, + environment_ids: Sequence[int] | None = None, + user: User | RpcUser | AnonymousUser | None = None, aggregation_override: str | None = None, - **kwargs, - ): + **kwargs: object, + ) -> dict[int, list[tuple[int, int]]] | None: if not groups: - return + return None error_issue_ids, generic_issue_ids = [], [] for group in groups: @@ -599,12 +627,12 @@ def query_tsdb( get_range = functools.partial( snuba_tsdb.get_range, environment_ids=environment_ids, - tenant_ids={"organization_id": self.organization_id}, + tenant_ids={"organization_id": cast(int, self.organization_id)}, project_ids=self.project_ids, **query_params, ) - results = {} + results: dict[int, list[tuple[int, int]]] = {} if error_issue_ids: results.update( @@ -621,12 +649,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) @@ -645,14 +673,14 @@ def __seen_stats_impl( time_range_result = self._parse_seen_stats_results( partial_execute_seen_stats_query(), error_issue_list, - self.start or self.end or self.conditions, + bool(self.start or self.end or self.conditions), self.environment_ids, ) filtered_result = ( self._parse_seen_stats_results( partial_execute_seen_stats_query(conditions=self.conditions), error_issue_list, - self.start or self.end or self.conditions, + bool(self.start or self.end or self.conditions), self.environment_ids, ) if self.conditions and not self._collapse("filtered") @@ -682,7 +710,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..a768c7cbfb45 100644 --- a/src/sentry/api/serializers/models/grouprelease.py +++ b/src/sentry/api/serializers/models/grouprelease.py @@ -1,32 +1,62 @@ -from collections import namedtuple -from datetime import timedelta +from collections.abc import Mapping, 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 - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: GroupRelease, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> GroupReleaseSerializerResponse: return { - "release": attrs["release"], + "release": cast(ReleaseSerializerResponse | None, attrs["release"]), "environment": obj.environment, "firstSeen": obj.first_seen, "lastSeen": obj.last_seen, @@ -39,14 +69,19 @@ 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 = ( + tenant_ids: dict[str, str | int] | None = ( { "organization_id": Project.objects.get_from_cache( id=item_list[0].project_id @@ -56,7 +91,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,12 +115,17 @@ 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): - result = super().serialize(obj, attrs, user) - result["stats"] = attrs["stats"] + def serialize( + self, + obj: GroupRelease, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> GroupReleaseWithStatsSerializerResponse: + result = cast(GroupReleaseWithStatsSerializerResponse, super().serialize(obj, attrs, user)) + result["stats"] = cast(dict[str, list[tuple[float, float]]], attrs["stats"]) return result diff --git a/src/sentry/api/serializers/models/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/groupseen.py b/src/sentry/api/serializers/models/groupseen.py index ef26e0b00826..9816d5ad5db6 100644 --- a/src/sentry/api/serializers/models/groupseen.py +++ b/src/sentry/api/serializers/models/groupseen.py @@ -1,21 +1,33 @@ +from collections.abc import Mapping, Sequence +from typing import Any, cast + +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.models.groupseen import GroupSeen +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.users.services.user.serial import serialize_generic_user from sentry.users.services.user.service import user_service @register(GroupSeen) class GroupSeenSerializer(Serializer): - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[GroupSeen], + user: User | RpcUser | AnonymousUser, + **kwargs: Any, + ) -> dict[GroupSeen, dict[str, object]]: serialized_users = user_service.serialize_many( filter=dict(user_ids=[i.user_id for i in item_list]), as_user=serialize_generic_user(user), ) - user_map = {} + user_map: dict[str, object] = {} for serialized in serialized_users: user_map[serialized["id"]] = serialized - result = {} + result: dict[GroupSeen, dict[str, object]] = {} for item in item_list: user_id_str = str(item.user_id) # Deleted users may have stale groupseen references as the "cascade deletion" is @@ -24,8 +36,14 @@ def get_attrs(self, item_list, user, **kwargs): result[item] = {"user": user_map[user_id_str]} return result - def serialize(self, obj, attrs, user, **kwargs): - data = attrs.get("user") + def serialize( + self, + obj: GroupSeen, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: Any, + ) -> dict[str, object] | None: + data = cast("dict[str, object] | None", attrs.get("user")) if data is None: return None diff --git a/src/sentry/api/serializers/models/grouptombstone.py b/src/sentry/api/serializers/models/grouptombstone.py index 38bf7cfb85d7..aeb7019ba60d 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]} ) @@ -14,10 +40,18 @@ def get_attrs(self, item_list, user, **kwargs): attrs = {} for item in item_list: - attrs[item] = {"user": users.get(item.actor_id, {})} + attrs[item] = { + "user": users.get(item.actor_id, {}) if item.actor_id is not None else {} + } 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 +59,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..e4178bb540bb 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 @@ -368,6 +368,7 @@ def serialize( user: User | RpcUser | AnonymousUser, **kwargs: Any, ) -> ControlSiloOrganizationMappingSerializerResponse: + assert obj.status is not None status = OrganizationStatus(obj.status) avatar_replica: OrganizationAvatarReplica | None = attrs["avatar"] if avatar_replica is not None: @@ -574,8 +575,10 @@ def serialize( if "access" in kwargs: context["access"] = kwargs["access"].scopes - tasks_to_serialize = list(onboarding_tasks.fetch_onboarding_tasks(obj, user)) - context["onboardingTasks"] = serialize(tasks_to_serialize, user) + # justification: onboarding tasks are queried for the requesting model user. + requesting_user = cast(User, user) + tasks_to_serialize = list(onboarding_tasks.fetch_onboarding_tasks(obj, requesting_user)) + context["onboardingTasks"] = serialize(tasks_to_serialize, requesting_user) return context @@ -721,7 +724,9 @@ def _get_default_automated_run_stopping_point(self, obj: Organization) -> str: "sentry:default_automated_run_stopping_point", SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, ) - if stopping_point not in get_valid_automated_run_stopping_points(obj): + if not isinstance(stopping_point, str) or stopping_point not in ( + get_valid_automated_run_stopping_points(obj) + ): return SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT return stopping_point @@ -768,124 +773,134 @@ def serialize( # type: ignore[override] "sentry:seer_default_coding_agent_integration_id", None ) - context: OrganizationSerializerResponse = { - **base, - "experiments": features.get_experiment_assignments(obj, actor=user), - "isDefault": obj.is_default, - "defaultRole": obj.default_role, - "availableRoles": [{"id": r.id, "name": r.name} for r in roles.get_all()], # Deprecated - "orgRoleList": serialize( - roles.get_all(), serializer=OrganizationRoleSerializer(organization=obj) - ), - "teamRoleList": serialize( - roles.team_roles.get_all(), serializer=TeamRoleSerializer(organization=obj) - ), - "openMembership": bool(obj.flags.allow_joinleave), - "require2FA": bool(obj.flags.require_2fa), - "allowSharedIssues": not obj.flags.disable_shared_issues, - "enhancedPrivacy": bool(obj.flags.enhanced_privacy), - "dataScrubber": bool( - obj.get_option("sentry:require_scrub_data", REQUIRE_SCRUB_DATA_DEFAULT) - ), - "dataScrubberDefaults": bool( - obj.get_option("sentry:require_scrub_defaults", REQUIRE_SCRUB_DEFAULTS_DEFAULT) - ), - "sensitiveFields": obj.get_option("sentry:sensitive_fields", None) or [], - "safeFields": obj.get_option("sentry:safe_fields", None) or [], - "storeCrashReports": convert_crashreport_count( - obj.get_option("sentry:store_crash_reports") - ), - "attachmentsRole": str( - obj.get_option("sentry:attachments_role", ATTACHMENTS_ROLE_DEFAULT) - ), - "debugFilesRole": str( - obj.get_option("sentry:debug_files_role", DEBUG_FILES_ROLE_DEFAULT) - ), - "eventsMemberAdmin": bool( - obj.get_option("sentry:events_member_admin", EVENTS_MEMBER_ADMIN_DEFAULT) - ), - "alertsMemberWrite": bool( - obj.get_option("sentry:alerts_member_write", ALERTS_MEMBER_WRITE_DEFAULT) - ), - "scrubIPAddresses": bool( - obj.get_option("sentry:require_scrub_ip_address", REQUIRE_SCRUB_IP_ADDRESS_DEFAULT) - ), - "scrapeJavaScript": bool( - obj.get_option("sentry:scrape_javascript", SCRAPE_JAVASCRIPT_DEFAULT) - ), - "allowJoinRequests": bool( - obj.get_option("sentry:join_requests", JOIN_REQUESTS_DEFAULT) - ), - "relayPiiConfig": str(obj.get_option("sentry:relay_pii_config") or "") or None, - "relayDsnEndpoint": obj.get_option("sentry:relay_dsn_endpoint") or None, - "hideAiFeatures": bool( - obj.get_option("sentry:hide_ai_features", HIDE_AI_FEATURES_DEFAULT) - ), - "genAIConsent": bool( - obj.get_option("sentry:gen_ai_consent_v2024_11_14", DATA_CONSENT_DEFAULT) - ), - "aggregatedDataConsent": bool( - obj.get_option("sentry:aggregated_data_consent", DATA_CONSENT_DEFAULT) - ), - "issueAlertsThreadFlag": bool( - obj.get_option("sentry:issue_alerts_thread_flag", ISSUE_ALERTS_THREAD_DEFAULT) - ), - "metricAlertsThreadFlag": bool( - obj.get_option("sentry:metric_alerts_thread_flag", METRIC_ALERTS_THREAD_DEFAULT) - ), - "rollbackEnabled": bool( - obj.get_option("sentry:rollback_enabled", ROLLBACK_ENABLED_DEFAULT) - ), - "defaultAutofixAutomationTuning": obj.get_option( - "sentry:default_autofix_automation_tuning", - AUTOFIX_AUTOMATION_TUNING_DEFAULT, - ), - "defaultSeerScannerAutomation": obj.get_option( - "sentry:default_seer_scanner_automation", - DEFAULT_SEER_SCANNER_AUTOMATION_DEFAULT, - ), - "enableSeerCoding": bool( - obj.get_option( - "sentry:enable_seer_coding", - ENABLE_SEER_CODING_DEFAULT, - ) - ), - "defaultCodingAgent": obj.get_option( - "sentry:seer_default_coding_agent", SEER_DEFAULT_CODING_AGENT_DEFAULT - ), - "defaultCodingAgentIntegrationId": ( - str(coding_agent_integration_id) if coding_agent_integration_id else None - ), - "defaultAutomatedRunStoppingPoint": self._get_default_automated_run_stopping_point(obj), - "autoOpenPrs": bool( - obj.get_option( - "sentry:auto_open_prs", - AUTO_OPEN_PRS_DEFAULT, - ) - ), - "autoEnableCodeReview": bool( - obj.get_option( - "sentry:auto_enable_code_review", - AUTO_ENABLE_CODE_REVIEW, - ) - ), - "defaultCodeReviewTriggers": obj.get_option( - "sentry:default_code_review_triggers", DEFAULT_CODE_REVIEW_TRIGGERS - ), - "streamlineOnly": obj.get_option("sentry:streamline_ui_only", None), - "trustedRelays": [ - # serialize trusted relays info into their external form - _relay_internal_to_external(raw) - for raw in obj.get_option("sentry:trusted-relays") or [] - ], - "requiresSso": access.requires_sso, - "pendingAccessRequests": OrganizationAccessRequest.objects.filter( - team__organization=obj - ).count(), - "isDynamicallySampled": is_dynamically_sampled, - "hasGranularReplayPermissions": bool(attrs.get("replay_permissions_enabled")), - "replayAccessMembers": attrs.get("replay_access_members", []), - } + # justification: mypy cannot prove the base spread fills the subclass TypedDict. + context = cast( + "OrganizationSerializerResponse", + { + **base, + "experiments": features.get_experiment_assignments(obj, actor=user), + "isDefault": obj.is_default, + "defaultRole": obj.default_role, + "availableRoles": [ + {"id": r.id, "name": r.name} for r in roles.get_all() + ], # Deprecated + "orgRoleList": serialize( + roles.get_all(), serializer=OrganizationRoleSerializer(organization=obj) + ), + "teamRoleList": serialize( + roles.team_roles.get_all(), serializer=TeamRoleSerializer(organization=obj) + ), + "openMembership": bool(obj.flags.allow_joinleave), + "require2FA": bool(obj.flags.require_2fa), + "allowSharedIssues": not obj.flags.disable_shared_issues, + "enhancedPrivacy": bool(obj.flags.enhanced_privacy), + "dataScrubber": bool( + obj.get_option("sentry:require_scrub_data", REQUIRE_SCRUB_DATA_DEFAULT) + ), + "dataScrubberDefaults": bool( + obj.get_option("sentry:require_scrub_defaults", REQUIRE_SCRUB_DEFAULTS_DEFAULT) + ), + "sensitiveFields": obj.get_option("sentry:sensitive_fields", None) or [], + "safeFields": obj.get_option("sentry:safe_fields", None) or [], + "storeCrashReports": convert_crashreport_count( + obj.get_option("sentry:store_crash_reports") + ), + "attachmentsRole": str( + obj.get_option("sentry:attachments_role", ATTACHMENTS_ROLE_DEFAULT) + ), + "debugFilesRole": str( + obj.get_option("sentry:debug_files_role", DEBUG_FILES_ROLE_DEFAULT) + ), + "eventsMemberAdmin": bool( + obj.get_option("sentry:events_member_admin", EVENTS_MEMBER_ADMIN_DEFAULT) + ), + "alertsMemberWrite": bool( + obj.get_option("sentry:alerts_member_write", ALERTS_MEMBER_WRITE_DEFAULT) + ), + "scrubIPAddresses": bool( + obj.get_option( + "sentry:require_scrub_ip_address", REQUIRE_SCRUB_IP_ADDRESS_DEFAULT + ) + ), + "scrapeJavaScript": bool( + obj.get_option("sentry:scrape_javascript", SCRAPE_JAVASCRIPT_DEFAULT) + ), + "allowJoinRequests": bool( + obj.get_option("sentry:join_requests", JOIN_REQUESTS_DEFAULT) + ), + "relayPiiConfig": str(obj.get_option("sentry:relay_pii_config") or "") or None, + "relayDsnEndpoint": obj.get_option("sentry:relay_dsn_endpoint") or None, + "hideAiFeatures": bool( + obj.get_option("sentry:hide_ai_features", HIDE_AI_FEATURES_DEFAULT) + ), + "genAIConsent": bool( + obj.get_option("sentry:gen_ai_consent_v2024_11_14", DATA_CONSENT_DEFAULT) + ), + "aggregatedDataConsent": bool( + obj.get_option("sentry:aggregated_data_consent", DATA_CONSENT_DEFAULT) + ), + "issueAlertsThreadFlag": bool( + obj.get_option("sentry:issue_alerts_thread_flag", ISSUE_ALERTS_THREAD_DEFAULT) + ), + "metricAlertsThreadFlag": bool( + obj.get_option("sentry:metric_alerts_thread_flag", METRIC_ALERTS_THREAD_DEFAULT) + ), + "rollbackEnabled": bool( + obj.get_option("sentry:rollback_enabled", ROLLBACK_ENABLED_DEFAULT) + ), + "defaultAutofixAutomationTuning": obj.get_option( + "sentry:default_autofix_automation_tuning", + AUTOFIX_AUTOMATION_TUNING_DEFAULT, + ), + "defaultSeerScannerAutomation": obj.get_option( + "sentry:default_seer_scanner_automation", + DEFAULT_SEER_SCANNER_AUTOMATION_DEFAULT, + ), + "enableSeerCoding": bool( + obj.get_option( + "sentry:enable_seer_coding", + ENABLE_SEER_CODING_DEFAULT, + ) + ), + "defaultCodingAgent": obj.get_option( + "sentry:seer_default_coding_agent", SEER_DEFAULT_CODING_AGENT_DEFAULT + ), + "defaultCodingAgentIntegrationId": ( + str(coding_agent_integration_id) if coding_agent_integration_id else None + ), + "defaultAutomatedRunStoppingPoint": self._get_default_automated_run_stopping_point( + obj + ), + "autoOpenPrs": bool( + obj.get_option( + "sentry:auto_open_prs", + AUTO_OPEN_PRS_DEFAULT, + ) + ), + "autoEnableCodeReview": bool( + obj.get_option( + "sentry:auto_enable_code_review", + AUTO_ENABLE_CODE_REVIEW, + ) + ), + "defaultCodeReviewTriggers": obj.get_option( + "sentry:default_code_review_triggers", DEFAULT_CODE_REVIEW_TRIGGERS + ), + "streamlineOnly": obj.get_option("sentry:streamline_ui_only", None), + "trustedRelays": [ + # serialize trusted relays info into their external form + _relay_internal_to_external(raw) + for raw in obj.get_option("sentry:trusted-relays") or [] + ], + "requiresSso": access.requires_sso, + "pendingAccessRequests": OrganizationAccessRequest.objects.filter( + team__organization=obj + ).count(), + "isDynamicallySampled": is_dynamically_sampled, + "hasGranularReplayPermissions": bool(attrs.get("replay_permissions_enabled")), + "replayAccessMembers": attrs.get("replay_access_members", []), + }, + ) if has_custom_dynamic_sampling(obj, actor=user): context["targetSampleRate"] = float( 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/organization_member/utils.py b/src/sentry/api/serializers/models/organization_member/utils.py index d51d134fbdaf..24d8ef004bca 100644 --- a/src/sentry/api/serializers/models/organization_member/utils.py +++ b/src/sentry/api/serializers/models/organization_member/utils.py @@ -9,7 +9,7 @@ TeamData = TypeVar("TeamData") DictOfMembers = dict[Any, list[TeamData]] MembersTeams = DictOfMembers[str] -MembersTeamRoles = DictOfMembers[dict[str, str]] +MembersTeamRoles = DictOfMembers[dict[str, str | None]] def get_teams_by_organization_member_id( @@ -25,8 +25,8 @@ def get_teams_by_organization_member_id( teams = Team.objects.filter(id__in=team_ids) teams_by_id = {team.id: team for team in teams} - result_teams = defaultdict(list) - result_teams_with_roles = defaultdict(list) + result_teams: MembersTeams = defaultdict(list) + result_teams_with_roles: MembersTeamRoles = defaultdict(list) for member_id, team_id, role in organization_member_tuples: teamSlug = teams_by_id[team_id].slug result_teams[member_id].append(teamSlug) # Deprecated diff --git a/src/sentry/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..1b94b33e0184 100644 --- a/src/sentry/api/serializers/models/project.py +++ b/src/sentry/api/serializers/models/project.py @@ -3,7 +3,8 @@ 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 types import ModuleType +from typing import TYPE_CHECKING, Any, Final, NotRequired, Protocol, TypedDict, cast import orjson from django.conf import settings @@ -12,6 +13,7 @@ from django.db.models import prefetch_related_objects from django.utils import timezone from sentry_sdk import traces +from sentry_sdk.traces import StreamedSpan from sentry import features, options, projectoptions, quotas, release_health, roles from sentry.api.serializers import Serializer, register, serialize @@ -117,7 +119,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 +143,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 +285,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): @@ -338,6 +340,8 @@ class ProjectSerializer(Serializer): such as "show all projects for this organization", and its attributes be kept to a minimum. """ + dataset: ModuleType + def __init__( self, environment_id: str | None = None, @@ -373,9 +377,9 @@ 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]]: - def measure_span(op_tag): + self, item_list: Sequence[Project], user: User | RpcUser | AnonymousUser, **kwargs: object + ) -> dict[Project, dict[str, object]]: + def measure_span(op_tag: str) -> StreamedSpan: span = traces.start_span( name=f"serialize.get_attrs.project.{op_tag}", attributes={ @@ -450,7 +454,9 @@ def measure_span(op_tag): serialized["options"] = options[project.id] return result - def get_stats(self, projects, query): + def get_stats( + self, projects: Sequence[Project], query: str + ) -> dict[int, list[tuple[int, int]]]: assert self.stats_period is not None # we need to compute stats at 1d (1h resolution), and 14d segments, interval = STATS_PERIOD_CHOICES[self.stats_period] @@ -588,7 +594,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 +606,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 @@ -666,10 +672,14 @@ def serialize( return context +class ProjectWithOrganizationResponse(ProjectSerializerResponse): + organization: OrganizationSummarySerializerResponse + + class ProjectWithOrganizationSerializer(ProjectSerializer): def get_attrs( - self, item_list: Sequence[Project], user: User | RpcUser | AnonymousUser, **kwargs: 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)} @@ -677,9 +687,16 @@ def get_attrs( attrs[item]["organization"] = orgs[str(item.organization_id)] return attrs - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: Project, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ProjectWithOrganizationResponse: base = super().serialize(obj, attrs, user) - return {**base, "organization": attrs["organization"]} + organization = cast("OrganizationSummarySerializerResponse", attrs["organization"]) + return {**base, "organization": organization} class TeamResponseDict(TypedDict): @@ -698,8 +715,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 +762,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 +782,14 @@ class _DeployDict(TypedDict): class ProjectSummarySerializer(ProjectWithTeamSerializer): - def __init__(self, access: Access | None = None, **kwargs): + # justification: forwards the base serializer keyword options. + def __init__(self, access: Access | None = None, **kwargs: Any) -> 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 +835,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 +928,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 +978,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 +992,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 +1009,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 +1030,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 +1049,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 +1077,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 @@ -1122,93 +1152,97 @@ def serialize( sample_rate = quotas.backend.get_blended_sample_rate( organization_id=obj.organization.id ) - data: DetailedProjectResponse = { - **base, - "latestRelease": attrs["latest_release"], - "options": self.format_options(attrs), - "digestsMinDelay": attrs["options"].get( - "digests:mail:minimum_delay", digests.minimum_delay - ), - "digestsMaxDelay": attrs["options"].get( - "digests:mail:maximum_delay", digests.maximum_delay - ), - "subjectPrefix": attrs["options"].get( - "mail:subject_prefix", options.get("mail.subject-prefix") - ), - "allowedDomains": attrs["options"].get("sentry:origins", ["*"]), - "resolveAge": int(attrs["options"].get("sentry:resolve_age", 0)), - "dataScrubber": bool(attrs["options"].get("sentry:scrub_data", True)), - "dataScrubberDefaults": bool(attrs["options"].get("sentry:scrub_defaults", True)), - "safeFields": attrs["options"].get("sentry:safe_fields", []), - "storeCrashReports": convert_crashreport_count( - attrs["options"].get("sentry:store_crash_reports"), allow_none=True - ), - "sensitiveFields": attrs["options"].get("sentry:sensitive_fields", []), - "subjectTemplate": attrs["options"].get("mail:subject_template") - or DEFAULT_SUBJECT_TEMPLATE.template, - "securityToken": attrs["options"].get("sentry:token") or obj.get_security_token(), - "securityTokenHeader": attrs["options"].get("sentry:token_header"), - "verifySSL": bool(attrs["options"].get("sentry:verify_ssl", False)), - "scrubIPAddresses": bool(attrs["options"].get("sentry:scrub_ip_address", False)), - "scrapeJavaScript": bool(attrs["options"].get("sentry:scrape_javascript", True)), - "enableAutoReleaseCreation": bool( - attrs["options"].get("sentry:enable_auto_release_creation", True) - ), - "highlightTags": attrs["options"].get( - "sentry:highlight_tags", - attrs["highlight_preset"].get("tags", []), - ), - "highlightContext": attrs["options"].get( - "sentry:highlight_context", - attrs["highlight_preset"].get("context", {}), - ), - "highlightPreset": attrs["highlight_preset"], - "groupingConfig": self.get_value_with_default(attrs, "sentry:grouping_config"), - "groupingEnhancements": self.get_value_with_default( - attrs, "sentry:grouping_enhancements" - ), - "derivedGroupingEnhancements": self.get_value_with_default( - attrs, "sentry:derived_grouping_enhancements" - ), - "secondaryGroupingExpiry": self.get_value_with_default( - attrs, "sentry:secondary_grouping_expiry" - ), - "secondaryGroupingConfig": self.get_value_with_default( - attrs, "sentry:secondary_grouping_config" - ), - "fingerprintingRules": self.get_value_with_default( - attrs, "sentry:fingerprinting_rules" - ), - "organization": attrs["org"], - "platforms": attrs["platforms"], - "processingIssues": attrs["processing_issues"], - "defaultEnvironment": attrs["options"].get("sentry:default_environment"), - "relayPiiConfig": attrs["options"].get("sentry:relay_pii_config"), - "builtinSymbolSources": self.get_value_with_default( - attrs, "sentry:builtin_symbol_sources" - ), - "enableNativeSourceServerMapping": bool( - self.get_value_with_default(attrs, "sentry:enable_native_source_server_mapping") - ), - "dynamicSamplingBiases": self.get_value_with_default( - attrs, "sentry:dynamic_sampling_biases" - ), - "symbolSources": serialized_sources, - "isDynamicallySampled": sample_rate is not None and sample_rate < 1.0, - "autofixAutomationTuning": self.get_value_with_default( - attrs, "sentry:autofix_automation_tuning" - ), - "seerScannerAutomation": self.get_value_with_default( - attrs, "sentry:seer_scanner_automation" - ), - "seerNightshiftTweaks": self.get_value_with_default( - attrs, "sentry:seer_nightshift_tweaks" - ), - "debugFilesRole": attrs["options"].get("sentry:debug_files_role"), - "scmSourceContextEnabled": self.get_value_with_default( - attrs, "sentry:scm_source_context_enabled" - ), - } + # justification: mypy cannot prove the base spread fills the subclass TypedDict. + data = cast( + "DetailedProjectResponse", + { + **base, + "latestRelease": attrs["latest_release"], + "options": self.format_options(attrs), + "digestsMinDelay": attrs["options"].get( + "digests:mail:minimum_delay", digests.minimum_delay + ), + "digestsMaxDelay": attrs["options"].get( + "digests:mail:maximum_delay", digests.maximum_delay + ), + "subjectPrefix": attrs["options"].get( + "mail:subject_prefix", options.get("mail.subject-prefix") + ), + "allowedDomains": attrs["options"].get("sentry:origins", ["*"]), + "resolveAge": int(attrs["options"].get("sentry:resolve_age", 0)), + "dataScrubber": bool(attrs["options"].get("sentry:scrub_data", True)), + "dataScrubberDefaults": bool(attrs["options"].get("sentry:scrub_defaults", True)), + "safeFields": attrs["options"].get("sentry:safe_fields", []), + "storeCrashReports": convert_crashreport_count( + attrs["options"].get("sentry:store_crash_reports"), allow_none=True + ), + "sensitiveFields": attrs["options"].get("sentry:sensitive_fields", []), + "subjectTemplate": attrs["options"].get("mail:subject_template") + or DEFAULT_SUBJECT_TEMPLATE.template, + "securityToken": attrs["options"].get("sentry:token") or obj.get_security_token(), + "securityTokenHeader": attrs["options"].get("sentry:token_header"), + "verifySSL": bool(attrs["options"].get("sentry:verify_ssl", False)), + "scrubIPAddresses": bool(attrs["options"].get("sentry:scrub_ip_address", False)), + "scrapeJavaScript": bool(attrs["options"].get("sentry:scrape_javascript", True)), + "enableAutoReleaseCreation": bool( + attrs["options"].get("sentry:enable_auto_release_creation", True) + ), + "highlightTags": attrs["options"].get( + "sentry:highlight_tags", + attrs["highlight_preset"].get("tags", []), + ), + "highlightContext": attrs["options"].get( + "sentry:highlight_context", + attrs["highlight_preset"].get("context", {}), + ), + "highlightPreset": attrs["highlight_preset"], + "groupingConfig": self.get_value_with_default(attrs, "sentry:grouping_config"), + "groupingEnhancements": self.get_value_with_default( + attrs, "sentry:grouping_enhancements" + ), + "derivedGroupingEnhancements": self.get_value_with_default( + attrs, "sentry:derived_grouping_enhancements" + ), + "secondaryGroupingExpiry": self.get_value_with_default( + attrs, "sentry:secondary_grouping_expiry" + ), + "secondaryGroupingConfig": self.get_value_with_default( + attrs, "sentry:secondary_grouping_config" + ), + "fingerprintingRules": self.get_value_with_default( + attrs, "sentry:fingerprinting_rules" + ), + "organization": attrs["org"], + "platforms": attrs["platforms"], + "processingIssues": attrs["processing_issues"], + "defaultEnvironment": attrs["options"].get("sentry:default_environment"), + "relayPiiConfig": attrs["options"].get("sentry:relay_pii_config"), + "builtinSymbolSources": self.get_value_with_default( + attrs, "sentry:builtin_symbol_sources" + ), + "enableNativeSourceServerMapping": bool( + self.get_value_with_default(attrs, "sentry:enable_native_source_server_mapping") + ), + "dynamicSamplingBiases": self.get_value_with_default( + attrs, "sentry:dynamic_sampling_biases" + ), + "symbolSources": serialized_sources, + "isDynamicallySampled": sample_rate is not None and sample_rate < 1.0, + "autofixAutomationTuning": self.get_value_with_default( + attrs, "sentry:autofix_automation_tuning" + ), + "seerScannerAutomation": self.get_value_with_default( + attrs, "sentry:seer_scanner_automation" + ), + "seerNightshiftTweaks": self.get_value_with_default( + attrs, "sentry:seer_nightshift_tweaks" + ), + "debugFilesRole": attrs["options"].get("sentry:debug_files_role"), + "scmSourceContextEnabled": self.get_value_with_default( + attrs, "sentry:scm_source_context_enabled" + ), + }, + ) if has_tempest_access(obj.organization): data["tempestFetchScreenshots"] = attrs["options"].get( @@ -1217,7 +1251,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 { @@ -1323,7 +1357,8 @@ def format_options(self, attrs: Mapping[str, Any]) -> dict[str, Any]: ), } - def get_value_with_default(self, attrs, key): + # justification: project option values are dynamically typed per option. + def get_value_with_default(self, attrs: Mapping[str, Any], key: str) -> Any: value = attrs["options"].get(key) if value is not None: return value @@ -1333,7 +1368,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..1c906a12e942 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 @@ -106,7 +115,9 @@ def serialize( "otlp_logs": obj.otlp_logs_endpoint, }, "browserSdkVersion": get_selected_browser_sdk_version(obj), - "browserSdk": {"choices": get_browser_sdk_version_choices(obj.project)}, + "browserSdk": { + "choices": [list(choice) for choice in get_browser_sdk_version_choices(obj.project)] + }, "dateCreated": obj.date_added, "dynamicSdkLoaderOptions": { "hasReplay": get_dynamic_sdk_loader_option(obj, DynamicSdkLoaderOption.HAS_REPLAY), @@ -123,7 +134,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..a96e92d39371 100644 --- a/src/sentry/api/serializers/models/projectcodeowners.py +++ b/src/sentry/api/serializers/models/projectcodeowners.py @@ -1,6 +1,9 @@ import logging +from collections.abc import Collection, Mapping, Sequence from datetime import datetime -from typing import TypedDict +from typing import TypedDict, cast + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register, serialize from sentry.api.serializers.models.projectownership import ( @@ -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: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ProjectCodeOwnersResponse: data: ProjectCodeOwnersResponse = { "id": str(obj.id), "raw": obj.raw, @@ -128,7 +144,7 @@ def serialize(self, obj, attrs, user, **kwargs) -> ProjectCodeOwnersResponse: "dateUpdated": obj.date_updated, "dateSynced": obj.date_synced, "codeMappingId": str(obj.repository_project_path_config_id), - "provider": attrs.get("provider", "unknown"), + "provider": cast(str, attrs.get("provider", "unknown")), } if "codeMapping" in self.expand: @@ -153,6 +169,6 @@ def serialize(self, obj, attrs, user, **kwargs) -> ProjectCodeOwnersResponse: if "hasTargetingContext" in self.expand: data["schema"] = _serialize_ownership_schema(obj.schema) if obj.schema else obj.schema - data["codeOwnersUrl"] = attrs.get("codeOwnersUrl", "unknown") + data["codeOwnersUrl"] = cast(str, attrs.get("codeOwnersUrl", "unknown")) return data diff --git a/src/sentry/api/serializers/models/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..35d358a1c4b6 100644 --- a/src/sentry/api/serializers/models/pullrequest.py +++ b/src/sentry/api/serializers/models/pullrequest.py @@ -1,7 +1,8 @@ from collections.abc import Mapping, Sequence from datetime import datetime -from typing import Literal, TypedDict +from typing import Literal, TypedDict, cast +from django.contrib.auth.models import AnonymousUser from django.db.models import Prefetch, prefetch_related_objects 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"] @@ -79,7 +82,9 @@ class LinkedPullRequestResponse(PullRequestSerializerResponse): reviewStatus: AggregateReviewStatus | None -def get_users_for_pull_requests(item_list, user=None): +def get_users_for_pull_requests( + item_list: Sequence[PullRequest], user: User | RpcUser | AnonymousUser | None = None +) -> Mapping[str, Author]: authors = list( CommitAuthor.objects.filter(id__in=[i.author_id for i in item_list if i.author_id]) ) @@ -93,16 +98,21 @@ def get_users_for_pull_requests(item_list, user=None): @register(PullRequest) class PullRequestSerializer(Serializer[PullRequestSerializerResponse]): - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[PullRequest], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[PullRequest, dict[str, object]]: users_by_author = get_users_for_pull_requests(item_list, user) repositories = list(Repository.objects.filter(id__in=[c.repository_id for c in item_list])) repository_map = {repository.id: repository for repository in repositories} serialized_repos = {r["id"]: r for r in serialize(repositories, user)} - result = {} + result: dict[PullRequest, dict[str, object]] = {} for item in item_list: repository_id = str(item.repository_id) - external_url = "" + external_url: str | None = "" if item.repository_id in repository_map: external_url = item.get_external_url() result[item] = { @@ -113,7 +123,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: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> PullRequestSerializerResponse: return { "id": obj.key, "title": obj.title, @@ -121,9 +137,9 @@ def serialize(self, obj: PullRequest, attrs, user, **kwargs) -> PullRequestSeria "dateCreated": obj.date_added, "mergedAt": obj.merged_at, "status": get_stored_pull_request_status(obj), - "repository": attrs["repository"], - "author": attrs["user"], - "externalUrl": attrs["external_url"], + "repository": cast(RepositorySerializerResponse, attrs["repository"]), + "author": cast(Author, attrs["user"]), + "externalUrl": cast(str, attrs["external_url"]), } @@ -168,7 +184,12 @@ def __init__( self.status_by_pr_id = status_by_pr_id self.checks_and_review_by_pr_id = checks_and_review_by_pr_id - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[PullRequest], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[PullRequest, dict[str, object]]: attrs = super().get_attrs(item_list, user) prefetch_related_objects( item_list, @@ -179,14 +200,21 @@ def get_attrs(self, item_list, user, **kwargs): ), ) for item in item_list: - attrs[item]["attribution"] = _serialize_attribution(item.valid_attributions) + attributions: Sequence[PullRequestAttribution] = getattr(item, "valid_attributions") + attrs[item]["attribution"] = _serialize_attribution(attributions) return attrs - def serialize(self, obj: PullRequest, attrs, user, **kwargs) -> LinkedPullRequestResponse: + def serialize( + self, + obj: PullRequest, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> LinkedPullRequestResponse: checks_and_review = self.checks_and_review_by_pr_id.get(obj.id, PullRequestStatusResult()) return { **super().serialize(obj, attrs, user, **kwargs), - "attribution": attrs["attribution"], + "attribution": cast(LinkedPullRequestAttributionResponse | None, attrs["attribution"]), "dateLinked": self.date_linked_by_pr_id[obj.id], "status": self.status_by_pr_id[obj.id], "checksStatus": checks_and_review.checks, diff --git a/src/sentry/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..fd941a9fbbba 100644 --- a/src/sentry/api/serializers/models/release.py +++ b/src/sentry/api/serializers/models/release.py @@ -2,16 +2,23 @@ import datetime from collections import defaultdict -from collections.abc import Mapping, Sequence -from typing import Any, NotRequired, TypedDict +from collections.abc import Collection, Mapping, Sequence +from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, cast 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, + LastDeploy, + NonMappableUser, + VersionInfo, +) +from sentry.api.serializers.release_details_types import Project as SerializedProject from sentry.api.serializers.types import ( GroupEventReleaseSerializerResponse, ReleaseSerializerResponse, @@ -20,11 +27,13 @@ from sentry.models.commit import Commit from sentry.models.commitauthor import CommitAuthor from sentry.models.deploy import Deploy +from sentry.models.environment import Environment +from sentry.models.project import Project from sentry.models.projectplatform import ProjectPlatform from sentry.models.release import Release, ReleaseStatus -from sentry.models.releaseprojectenvironment import 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.release_health.base import ReleaseHealthOverview, StatsPeriod from sentry.users.api.serializers.user import UserSerializerResponse from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser @@ -33,31 +42,44 @@ from sentry.utils import metrics from sentry.utils.hashlib import md5_text +if TYPE_CHECKING: + from sentry.tagstore.types import TagValue + + +class _ParsedVersion(TypedDict): + major: int + minor: int + patch: int + pre: str | None + build_code: str | None + components: int -def expose_version_info(info) -> VersionInfo | None: + +def expose_version_info(info: Mapping[str, object] | None) -> VersionInfo | None: if info is None: return None - version = {"raw": info["version_raw"]} - if info["version_parsed"]: + version_parsed = cast("_ParsedVersion | None", info["version_parsed"]) + version: dict[str, object] = {"raw": info["version_raw"]} + if version_parsed: version.update( { - "major": info["version_parsed"]["major"], - "minor": info["version_parsed"]["minor"], - "patch": info["version_parsed"]["patch"], - "pre": info["version_parsed"]["pre"], - "buildCode": info["version_parsed"]["build_code"], - "components": info["version_parsed"]["components"], + "major": version_parsed["major"], + "minor": version_parsed["minor"], + "patch": version_parsed["patch"], + "pre": version_parsed["pre"], + "buildCode": version_parsed["build_code"], + "components": version_parsed["components"], } ) return { - "package": info["package"], + "package": cast("str | None", info["package"]), "version": version, - "description": info["description"], - "buildHash": info["build_hash"], + "description": cast(str, info["description"]), + "buildHash": cast("str | None", info["build_hash"]), } -def _expose_health_data(data): +def _expose_health_data(data: ReleaseHealthOverview | None) -> HealthData | None: if not data: return None return { @@ -75,18 +97,18 @@ def _expose_health_data(data): "totalProjectSessions24h": data["total_project_sessions_24h"], "adoption": data["adoption"], "sessionsAdoption": data["sessions_adoption"], - "stats": data.get("stats"), + "stats": {str(k): v for k, v in (data.get("stats") or {}).items()}, # XXX: legacy key, should be removed later. "hasHealthData": data["has_health_data"], } -def _expose_project(project): - rv = { +def _expose_project(project: _ProjectDict) -> SerializedProject: + rv: SerializedProject = { "id": project["id"], "slug": project["slug"], "name": project["name"], - "newGroups": project["new_groups"], + "newGroups": project["new_groups"] or 0, "platform": project["platform"], "platforms": project["platforms"], # XXX: Legacy should be removed @@ -97,8 +119,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 +141,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 +190,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,15 +211,19 @@ 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), + "last_commit": ( + commits.get(item.last_commit_id) if item.last_commit_id is not None else None + ), } 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,9 +243,13 @@ 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)} + result[item] = { + "last_deploy": ( + deploys.get(item.last_deploy_id) if item.last_deploy_id is not None else None + ) + } return result @@ -407,9 +439,21 @@ def get_users_for_authors( return final_results +class _ReleaseAttrs(TypedDict): + owner: dict[str, object] | None + new_groups: int + projects: list[_ProjectDict] + first_seen: datetime.datetime | None + last_seen: datetime.datetime | None + adoption_stages: NotRequired[dict[str, AdoptionStage] | None] + authors: NotRequired[list[Author]] + last_commit: NotRequired[dict[str, object] | None] + last_deploy: NotRequired[LastDeploy | None] + + class _ProjectDict(TypedDict): id: int - slug: str | None + slug: str name: str new_groups: int | None platform: str | None @@ -420,7 +464,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 @@ -439,7 +483,14 @@ def __get_project_id_list(self, item_list) -> list[int]: .distinct() ) - def __get_release_data_no_environment(self, project, item_list, no_snuba_for_release_creation): + def __get_release_data_no_environment( + self, + project: Project | None, + item_list: Sequence[Release], + no_snuba_for_release_creation: bool, + ) -> tuple[ + dict[str, datetime.datetime], dict[str, datetime.datetime], dict[int, dict[int, int]] + ]: if project is not None: project_ids = [project.id] organization_id = project.organization_id @@ -449,6 +500,7 @@ def __get_release_data_no_environment(self, project, item_list, no_snuba_for_rel first_seen: dict[str, datetime.datetime] = {} last_seen: dict[str, datetime.datetime] = {} + tag_values: Collection[TagValue] if no_snuba_for_release_creation: tag_values = [] else: @@ -460,27 +512,34 @@ def __get_release_data_no_environment(self, project, item_list, no_snuba_for_rel ) for tv in tag_values: + assert tv.value is not None + assert tv.first_seen is not None + assert tv.last_seen is not None first_val = first_seen.get(tv.value) last_val = last_seen.get(tv.value) first_seen[tv.value] = min(tv.first_seen, first_val) if first_val else tv.first_seen last_seen[tv.value] = max(tv.last_seen, last_val) if last_val else tv.last_seen - group_counts_by_release = {} + group_counts_by_release: dict[int, dict[int, int]] = {} if project is not None: for release_id, new_groups in ReleaseProject.objects.filter( project=project, release__in=item_list, new_groups__isnull=False ).values_list("release_id", "new_groups"): - group_counts_by_release[release_id] = {project.id: new_groups} + group_counts_by_release[release_id] = {project.id: cast(int, new_groups)} else: for project_id, release_id, new_groups in ReleaseProject.objects.filter( release__in=item_list, new_groups__isnull=False ).values_list("project_id", "release_id", "new_groups"): - group_counts_by_release.setdefault(release_id, {})[project_id] = new_groups + group_counts_by_release.setdefault(release_id, {})[project_id] = cast( + int, new_groups + ) return first_seen, last_seen, group_counts_by_release - 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 +548,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 +578,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 +596,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") @@ -540,29 +613,34 @@ def _get_release_project_envs_unordered(self, item_list, environments, project): return release_project_envs - def get_attrs(self, item_list, user, **kwargs): - project = kwargs.get("project") + def get_attrs( + self, + item_list: Sequence[Release], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[Release, dict[str, object]]: + project = cast(Project | None, kwargs.get("project")) # Some code paths pass an environment object, other pass a list of # environment names. - environment = kwargs.get("environment") - environments = kwargs.get("environments") + environment = cast(Environment | None, kwargs.get("environment")) + environments = cast(Sequence[str] | None, kwargs.get("environments")) if not environments: if environment: environments = [environment.name] else: environments = None - self.with_adoption_stages = kwargs.get("with_adoption_stages", False) - with_health_data = kwargs.get("with_health_data", False) - health_stat = kwargs.get("health_stat", None) - health_stats_period = kwargs.get("health_stats_period") - summary_stats_period = kwargs.get("summary_stats_period") - no_snuba_for_release_creation = kwargs.get("no_snuba_for_release_creation") + self.with_adoption_stages = cast(bool, kwargs.get("with_adoption_stages", False)) + with_health_data = cast(bool, kwargs.get("with_health_data", False)) + health_stat = cast(Literal["users", "sessions"] | None, kwargs.get("health_stat", None)) + health_stats_period = cast(StatsPeriod | None, kwargs.get("health_stats_period")) + summary_stats_period = cast(StatsPeriod | None, kwargs.get("summary_stats_period")) + no_snuba_for_release_creation = cast(bool, kwargs.get("no_snuba_for_release_creation")) if with_health_data and no_snuba_for_release_creation: raise TypeError("health data requires snuba") - 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 +659,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 +693,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 +702,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 +737,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 +751,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 +772,14 @@ 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: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ReleaseSerializerResponse: + typed_attrs = cast(_ReleaseAttrs, attrs) d: ReleaseSerializerResponse = { "id": obj.id, "version": obj.version, @@ -707,25 +791,25 @@ def serialize(self, obj, attrs, user, **kwargs) -> ReleaseSerializerResponse: "dateReleased": obj.date_released, "dateCreated": obj.date_added, "data": obj.data, - "newGroups": attrs["new_groups"], - "owner": attrs["owner"], - "commitCount": obj.commit_count, - "lastCommit": attrs.get("last_commit"), - "deployCount": obj.total_deploys, - "lastDeploy": attrs.get("last_deploy"), - "authors": attrs.get("authors", []), - "projects": [_expose_project(p) for p in attrs.get("projects", [])], - "firstEvent": attrs.get("first_seen"), - "lastEvent": attrs.get("last_seen"), + "newGroups": typed_attrs["new_groups"], + "owner": typed_attrs["owner"], + "commitCount": obj.commit_count or 0, + "lastCommit": typed_attrs.get("last_commit"), + "deployCount": obj.total_deploys or 0, + "lastDeploy": typed_attrs.get("last_deploy"), + "authors": typed_attrs.get("authors", []), + "projects": [_expose_project(p) for p in typed_attrs["projects"]], + "firstEvent": typed_attrs["first_seen"], + "lastEvent": typed_attrs["last_seen"], "currentProjectMeta": _expose_current_project_meta( - kwargs.get("current_project_meta", {}) + cast(dict[str, object], kwargs.get("current_project_meta", {})) ), "userAgent": obj.user_agent, } if self.with_adoption_stages: d.update( { - "adoptionStages": attrs.get("adoption_stages"), + "adoptionStages": typed_attrs.get("adoption_stages"), } ) return d @@ -736,30 +820,41 @@ 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: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> GroupEventReleaseSerializerResponse: return { "id": obj.id, - "commitCount": obj.commit_count, + "commitCount": obj.commit_count or 0, "data": obj.data, "dateCreated": obj.date_added, "dateReleased": obj.date_released, - "deployCount": obj.total_deploys, + "deployCount": obj.total_deploys or 0, "ref": obj.ref, - "lastCommit": attrs.get("last_commit"), - "lastDeploy": attrs.get("last_deploy"), + "lastCommit": cast(dict[str, object] | None, attrs.get("last_commit")), + "lastDeploy": cast(LastDeploy | None, attrs.get("last_deploy")), "status": ReleaseStatus.to_string(obj.status), "url": obj.url, "userAgent": obj.user_agent, diff --git a/src/sentry/api/serializers/models/release_file.py b/src/sentry/api/serializers/models/release_file.py index 1da430e1e5c9..c8bc20131f81 100644 --- a/src/sentry/api/serializers/models/release_file.py +++ b/src/sentry/api/serializers/models/release_file.py @@ -1,23 +1,28 @@ from base64 import urlsafe_b64decode, urlsafe_b64encode +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.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 + id: str | bytes | None name: str dist: str | None - headers: dict[str, Any] - size: int - sha1: str + headers: dict[str, object] + size: int | None + sha1: str | None dateCreated: datetime -def encode_release_file_id(obj): +def encode_release_file_id(obj: ReleaseFile) -> str | bytes | None: """Generate ID for artifacts that only exist in a bundle This ID is only unique per release. @@ -33,9 +38,10 @@ def encode_release_file_id(obj): if obj.dist_id: dist_name = Distribution.objects.get(pk=obj.dist_id).name return urlsafe_b64encode(f"{dist_name}_{obj.name}".encode()) + return None -def decode_release_file_id(id: str): +def decode_release_file_id(id: str) -> int | tuple[str | None, str]: """May raise ValueError""" try: return int(id) @@ -47,7 +53,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: Mapping[object, object], + 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..94727370c03f 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,14 +76,18 @@ 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 if obj.integration_id: integration_id = str(obj.integration_id) repo_provider = obj.get_provider() if obj.provider else None - if repo_provider is not None: + if repo_provider is not None and obj.provider is not None: provider = {"id": obj.provider, "name": repo_provider.name} external_slug = repo_provider.repository_external_slug(obj) else: @@ -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..841813f54fe8 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, Mapping, Sequence +from datetime import datetime, timedelta +from typing import NotRequired, TypedDict, cast +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: Mapping[str, object], + 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,9 +145,15 @@ def get_attrs(self, item_list, user, **kwargs): ) return attrs - def serialize(self, obj, attrs, user, **kwargs) -> UserReportWithGroupSerializerResponse: + def serialize( + self, + obj: UserReport, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> UserReportWithGroupSerializerResponse: context = super().serialize(obj, attrs, user) return { **context, - "issue": attrs["group"], + "issue": cast(dict[str, object], attrs["group"]), } diff --git a/src/sentry/api/serializers/models/userrollback.py b/src/sentry/api/serializers/models/userrollback.py index ea9be0fc8334..78b5d9aeebc7 100644 --- a/src/sentry/api/serializers/models/userrollback.py +++ b/src/sentry/api/serializers/models/userrollback.py @@ -1,6 +1,11 @@ -from typing import TypedDict +from collections.abc import Mapping +from typing import Any, 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 RollbackOrganizationSerializerResponse(TypedDict): @@ -17,11 +22,18 @@ class RollbackUserSerializerResponse(TypedDict): class RollbackSerializerResponse(TypedDict): organization: RollbackOrganizationSerializerResponse user: RollbackUserSerializerResponse - data: dict # JSON Blob + data: dict[str, object] class UserRollbackSerializer(Serializer[RollbackSerializerResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> RollbackSerializerResponse: + # justification: serializer contract accepts arbitrary model instances and attributes. + def serialize( + self, + obj: Any, + attrs: Mapping[Any, Any], + user: User | RpcUser | AnonymousUser, + **kwargs: Any, + ) -> RollbackSerializerResponse: rollback_org = kwargs.get("rollback_org") return { @@ -32,7 +44,7 @@ def serialize(self, obj, attrs, user, **kwargs) -> RollbackSerializerResponse: }, "user": { "id": obj.user_id, - "name": user.name, + "name": cast("User | RpcUser", user).name, }, "data": { "user": obj.data, diff --git a/src/sentry/api/serializers/release_details_types.py b/src/sentry/api/serializers/release_details_types.py index a69531b92a8d..a741fff5d923 100644 --- a/src/sentry/api/serializers/release_details_types.py +++ b/src/sentry/api/serializers/release_details_types.py @@ -1,6 +1,7 @@ from datetime import datetime -from typing import Any, TypedDict +from typing import Any, TypeAlias, TypedDict +from sentry.release_health.base import ReleaseHealthStats from sentry.users.api.serializers.user import UserSerializerResponse @@ -31,7 +32,7 @@ class NonMappableUser(TypedDict): email: str -Author = UserSerializerResponse | NonMappableUser +Author: TypeAlias = UserSerializerResponse | NonMappableUser class HealthDataOptional(TypedDict, total=False): @@ -53,7 +54,7 @@ class HealthData(HealthDataOptional): sessionsCrashed: int sessionsErrored: int hasHealthData: bool - stats: dict[str, Any] + stats: dict[str, ReleaseHealthStats] class ProjectOptional(TypedDict, total=False): diff --git a/src/sentry/api/serializers/rest_framework/base.py b/src/sentry/api/serializers/rest_framework/base.py index 6ba9d3078ddc..e9cf66e52d46 100644 --- a/src/sentry/api/serializers/rest_framework/base.py +++ b/src/sentry/api/serializers/rest_framework/base.py @@ -1,19 +1,21 @@ from __future__ import annotations -from typing import TypeVar +from collections.abc import Callable +from typing import Any, TypeVar, cast from django.db.models import Model from django.utils.text import re_camel_case from rest_framework.exceptions import ValidationError from rest_framework.fields import empty from rest_framework.serializers import ModelSerializer, Serializer +from rest_framework.utils.serializer_helpers import ReturnDict from sentry.utils import metrics T = TypeVar("T") -def _classify_key_case(data: dict) -> str: +def _classify_key_case(data: dict[str, object]) -> str: """Classify the top-level keys of a dict as camel, snake, mixed, or none.""" has_camel = False has_snake = False @@ -36,7 +38,7 @@ def _classify_key_case(data: dict) -> str: return "uncertain" -def _record_key_case_metric(serializer_name: str, data: dict) -> None: +def _record_key_case_metric(serializer_name: str, data: dict[str, object]) -> None: """Emit a metric classifying the parameter key case of incoming data.""" key_case = _classify_key_case(data) metrics.incr( @@ -48,14 +50,14 @@ def _record_key_case_metric(serializer_name: str, data: dict) -> None: ) -def camel_to_snake_case(value): +def camel_to_snake_case(value: str) -> str: """ Splits CamelCase and converts to lower case with underscores. """ return re_camel_case.sub(r"_\1", value).strip("_").lower() -def snake_to_camel_case(value): +def snake_to_camel_case(value: str) -> str: """ Converts a string from snake_case to camelCase """ @@ -68,7 +70,7 @@ def snake_to_camel_case(value): _OPAQUE_DICT_KEYS = frozenset({"additional_fields", "additionalFields"}) -def convert_dict_key_case(obj, converter): +def convert_dict_key_case(obj: T, converter: Callable[[str], str]) -> T: """ Recursively converts the keys of a dictionary using the provided converter param. @@ -78,26 +80,28 @@ def convert_dict_key_case(obj, converter): field names. """ if isinstance(obj, list): - return [convert_dict_key_case(x, converter) for x in obj] + # justification: a list input returns the same list type with converted entries. + return cast("T", [convert_dict_key_case(x, converter) for x in obj]) if not isinstance(obj, dict): return obj - obj = obj.copy() - for key in list(obj.keys()): + result = obj.copy() + for key in list(result.keys()): converted_key = converter(key) - if converted_key != key and converted_key in obj: + if converted_key != key and converted_key in result: raise ValidationError( {key: f"{key} collides with {converted_key}, please pass only one value"} ) - value = obj.pop(key) + value = result.pop(key) if key in _OPAQUE_DICT_KEYS and isinstance(value, dict): # Convert the container key, keep nested third-party keys as-is. - obj[converted_key] = value + result[converted_key] = value else: - obj[converted_key] = convert_dict_key_case(value, converter) + result[converted_key] = convert_dict_key_case(value, converter) - return obj + # justification: a dict input returns the same dict type with converted keys. + return cast("T", result) class CamelSnakeSerializer(Serializer[T]): @@ -107,7 +111,7 @@ class CamelSnakeSerializer(Serializer[T]): Errors are output in camel case. """ - def __init__(self, instance=None, data=empty, **kwargs): + def __init__(self, instance: T | None = None, data: Any = empty, **kwargs: Any) -> None: if data is not empty: if isinstance(data, dict): _record_key_case_metric(type(self).__name__, data) @@ -115,9 +119,9 @@ def __init__(self, instance=None, data=empty, **kwargs): super().__init__(instance=instance, data=data, **kwargs) @property - def errors(self): + def errors(self) -> ReturnDict[str, Any]: errors = super().errors - return convert_dict_key_case(errors, snake_to_camel_case) + return cast("ReturnDict[str, Any]", convert_dict_key_case(errors, snake_to_camel_case)) M = TypeVar("M", bound=Model) @@ -130,7 +134,7 @@ class CamelSnakeModelSerializer(ModelSerializer[M]): Errors are output in camel case. """ - def __init__(self, instance=None, data=empty, **kwargs): + def __init__(self, instance: M | None = None, data: Any = empty, **kwargs: Any) -> None: if data is not empty: if isinstance(data, dict): _record_key_case_metric(type(self).__name__, data) @@ -138,6 +142,6 @@ def __init__(self, instance=None, data=empty, **kwargs): super().__init__(instance=instance, data=data, **kwargs) @property - def errors(self): + def errors(self) -> ReturnDict[str, Any]: errors = super().errors - return convert_dict_key_case(errors, snake_to_camel_case) + return cast("ReturnDict[str, Any]", convert_dict_key_case(errors, snake_to_camel_case)) 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..2aa6a9b51b61 100644 --- a/src/sentry/api/serializers/rest_framework/dashboard.py +++ b/src/sentry/api/serializers/rest_framework/dashboard.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from enum import Enum from math import floor -from typing import Any, TypedDict +from typing import Any, TypedDict, cast import sentry_sdk from django.db.models import Max @@ -70,6 +70,11 @@ class LinkedDashboard(TypedDict): dashboard_id: int +class QueryArrayEntry(TypedDict): + query_obj: DashboardWidgetQuery + query_data: dict[str, Any] + + def is_equation(field: str) -> bool: """check if a public alias is an equation, which start with the equation prefix eg. `equation|5 + 5` @@ -89,24 +94,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) ) @@ -201,7 +206,7 @@ class BaseWidgetLayoutSerializer(CamelSnakeSerializer[Dashboard]): ) h = serializers.IntegerField(min_value=1, help_text="Height in grid rows.") - def validate(self, data): + def validate(self, data: dict[str, Any]) -> dict[str, Any]: if data["x"] + data["w"] > MAX_WIDGET_COLS: raise serializers.ValidationError(f"x + w must not exceed {MAX_WIDGET_COLS}") return convert_dict_key_case(data, snake_to_camel_case) @@ -242,7 +247,7 @@ class DashboardWidgetQueryOnDemandSerializer(CamelSnakeSerializer[Dashboard]): extraction_state = serializers.CharField(required=False) enabled = serializers.BooleanField(required=False) - def validate(self, data): + def validate(self, data: dict[str, Any]) -> dict[str, Any]: return data @@ -254,7 +259,7 @@ class LinkedDashboardSerializer(CamelSnakeSerializer[Dashboard]): class TransformationField(serializers.CharField): - def to_internal_value(self, data): + def to_internal_value(self, data: object) -> str: if not isinstance(data, str): self.fail("invalid") return super().to_internal_value(data) @@ -263,7 +268,7 @@ def to_internal_value(self, data): class TransformationListField(serializers.ListField): child = TransformationField(trim_whitespace=False) - def to_internal_value(self, data): + def to_internal_value(self, data: list[Any]) -> list[str]: values = super().to_internal_value(data) try: parse_transformations(values) @@ -331,7 +336,7 @@ def validate_transformations(self, value: dict[str, list[str]]) -> dict[str, lis raise serializers.ValidationError("Invalid transformations index.") return value - def validate(self, data): + def validate(self, data: dict[str, Any]) -> dict[str, Any]: if not data.get("id"): keys = set(data.keys()) if self.required_for_create - keys: @@ -351,8 +356,8 @@ def validate(self, data): conditions = self._get_attr(data, "conditions", "") orderby = self._get_attr(data, "orderby", "") is_table = is_table_display_type(self.context.get("display_type")) - columns = self._get_attr(data, "columns", []).copy() - aggregates = self._get_attr(data, "aggregates", []).copy() + columns: list[str] = self._get_attr(data, "columns", []).copy() + aggregates: list[str] = self._get_attr(data, "aggregates", []).copy() fields = columns + aggregates # Handle the orderby since it can be a value that's not included in fields @@ -455,14 +460,15 @@ def _get_validation_projects(self) -> list[Project]: projects = self.context.get("validation_projects") if projects is None: projects = self.context["projects"] + assert isinstance(projects, list) return projects - def _get_attr(self, data, attr, empty_value=None): + def _get_attr[T](self, data: dict[str, Any], attr: str, empty_value: T) -> T: value = data.get(attr) if value is not None: - return value + return cast("T", value) if self.instance: - return getattr(self.instance, attr) + return cast("T", getattr(self.instance, attr)) return empty_value @@ -510,13 +516,13 @@ class DashboardWidgetSerializer(CamelSnakeSerializer[Dashboard]): error_messages={"invalid_choice": "Invalid dataset source"}, ) - def validate_display_type(self, display_type): + def validate_display_type(self, display_type: str) -> int | None: display_type_id = DashboardWidgetDisplayTypes.get_id_for_type_name(display_type) widget_type_name = self.context.get("widget_type") if widget_type_name is not None and display_type_id is not None: widget_type_id = DashboardWidgetTypes.get_id_for_type_name(widget_type_name) - config = DATASET_CONFIG.get(widget_type_id) + config = DATASET_CONFIG.get(widget_type_id) if widget_type_id is not None else None if ( config is not None and display_type_id not in config["supported_display_types"] @@ -537,7 +543,9 @@ def validate_display_type(self, display_type): return display_type_id - def _allows_tracemetrics_table(self, widget_type_id, display_type_id): + def _allows_tracemetrics_table( + self, widget_type_id: int | None, display_type_id: int | None + ) -> bool: if ( widget_type_id != DashboardWidgetTypes.TRACEMETRICS or display_type_id != DashboardWidgetDisplayTypes.TABLE @@ -553,8 +561,13 @@ def _has_tracemetrics_table_feature(self) -> bool: actor=self.context["request"].user, ) - def _validate_widget_type(self, data): - widget_type = DashboardWidgetTypes.get_id_for_type_name(data.get("widget_type")) + def _validate_widget_type(self, data: dict[str, Any]) -> int: + widget_type_name = data.get("widget_type") + widget_type = ( + DashboardWidgetTypes.get_id_for_type_name(widget_type_name) + if widget_type_name is not None + else None + ) if widget_type == DashboardWidgetTypes.DISCOVER or widget_type is None: sentry_sdk.set_context( "dashboard", @@ -573,12 +586,12 @@ def _validate_widget_type(self, data): validate_id = validate_id - def _validate_interval(self, data): + def _validate_interval(self, data: dict[str, Any]) -> None: interval = data.get("interval") - if parse_stats_period(interval) is None: + if parse_stats_period(cast("str", interval)) is None: raise serializers.ValidationError({"interval": "Invalid interval"}) - def to_internal_value(self, data): + def to_internal_value(self, data: dict[str, Any]) -> dict[str, Any]: # Update the context for the queries serializer because the display type is # required for validation of the queries queries_serializer = self.fields["queries"] @@ -598,9 +611,10 @@ def to_internal_value(self, data): additional_context["user"] = self.context["request"].user queries_serializer.context.update(additional_context) - return super().to_internal_value(data) + # justification: DRF returns the validated dict from to_internal_value. + return cast("dict[str, Any]", super().to_internal_value(data)) - def _validate_text_widget(self, data): + def _validate_text_widget(self, data: dict[str, Any]) -> dict[str, Any]: if data.get("widget_type"): raise serializers.ValidationError( {"widget_type": "Text widgets don't have a widget type or dataset"} @@ -629,7 +643,7 @@ def _validate_text_widget(self, data): return data - def _validate_tracemetrics_constraints(self, data) -> dict[str, Any]: + def _validate_tracemetrics_constraints(self, data: dict[str, Any]) -> dict[str, Any]: if not data.get("widget_type") == DashboardWidgetTypes.TRACEMETRICS: return data @@ -639,14 +653,14 @@ def _validate_tracemetrics_constraints(self, data) -> dict[str, Any]: DashboardWidgetDisplayTypes.AREA_CHART, DashboardWidgetDisplayTypes.BAR_CHART, }: - for query in data.get("queries"): + for query in data.get("queries") or []: aggregates = query.get("aggregates") or [] if any(is_equation(aggregate) for aggregate in aggregates) and len(aggregates) > 1: raise serializers.ValidationError( {"queries": "Tracemetrics timeseries widgets support at most one equation."} ) elif data.get("display_type") == DashboardWidgetDisplayTypes.HEATMAP: - for query in data.get("queries"): + for query in data.get("queries") or []: aggregates = query.get("aggregates") or [] if any(is_equation(aggregate) for aggregate in aggregates): raise serializers.ValidationError( @@ -657,7 +671,7 @@ def _validate_tracemetrics_constraints(self, data) -> dict[str, Any]: and self._has_tracemetrics_table_feature() and all( not any(is_aggregate(field) for field in query.get("fields", [])) - for query in data.get("queries") + for query in data.get("queries") or [] ) ): raise serializers.ValidationError( @@ -668,7 +682,7 @@ def _validate_tracemetrics_constraints(self, data) -> dict[str, Any]: return data - def validate(self, data): + def validate(self, data: dict[str, Any]) -> dict[str, Any]: self.query_warnings = {"queries": [], "columns": {}} if data.get("display_type") == DashboardWidgetDisplayTypes.TEXT: @@ -720,13 +734,13 @@ def validate(self, data): self._validate_tracemetrics_constraints(data) if data.get("display_type") == DashboardWidgetDisplayTypes.HEATMAP: - if len(data.get("queries")) > 1: + if len(data.get("queries") or []) > 1: raise serializers.ValidationError( {"queries": "Heatmap widgets cannot have multiple queries"} ) # Check each query to see if they have an issue or discover error depending on the type of the widget - for query in data.get("queries"): + for query in data.get("queries") or []: if len(query.get("columns", [])) > 0: has_columns = True if ( @@ -884,7 +898,7 @@ def validate(self, data): # tables up to 20, all other chart types up to 10. widget_limit = data.get("limit") if widget_limit is not None: - max_allowed = get_max_widget_limit(data.get("display_type")) + max_allowed = get_max_widget_limit(cast("int", data.get("display_type"))) if widget_limit > max_allowed: raise serializers.ValidationError( {"limit": f"The maximum limit for this display type is {max_allowed}."} @@ -897,46 +911,65 @@ def validate(self, data): {"thresholds": "Heatmap widgets do not support thresholds."} ) - if thresholds: + if thresholds is not None: + if not isinstance(thresholds, dict): + raise serializers.ValidationError({"thresholds": "Must be an object."}) + max_values = thresholds.get("max_values") allowed_max_keys = [key.value for key in ThresholdMaxKeys] - if max_values: - for i in range(len(max_values)): - max_key = f"max{i + 1}" - - if max_key not in allowed_max_keys: + if max_values is not None: + if not isinstance(max_values, dict): + raise serializers.ValidationError( + {"thresholds": {"max_values": "Must be an object."}} + ) + if max_values: + invalid_keys = set(max_values) - set(allowed_max_keys) + if invalid_keys: raise serializers.ValidationError( - {"thresholds": f"Invalid maximum key {max_key}"} + {"thresholds": f"Invalid maximum key {sorted(invalid_keys)[0]}"} ) - if max_values.get(max_key): - if max_values.get(max_key) < 0: + for max_key, max_value in max_values.items(): + if not isinstance(max_value, int) or isinstance(max_value, bool): + raise serializers.ValidationError( + {"thresholds": {max_key: "Maximum values must be integers."}} + ) + if max_value < 0: raise serializers.ValidationError( {"thresholds": {max_key: "Maximum values can not be negative"}} ) - elif i > 0: - prev_max_key = f"max{i}" - if max_values.get(prev_max_key) and max_values.get( - prev_max_key - ) >= max_values.get(max_key): - raise serializers.ValidationError( - { - "thresholds": { - max_key: "Maximum value must be greater than minimum." - } - } - ) - if len(max_values) < len(ThresholdMaxKeys): - for key in allowed_max_keys: - if max_values.get(key) is None: + for prev_max_key, max_key in zip( + allowed_max_keys, allowed_max_keys[1:], strict=False + ): + if ( + prev_max_key in max_values + and max_key in max_values + and max_values[prev_max_key] >= max_values[max_key] + ): raise serializers.ValidationError( { "thresholds": { - key: "Must set all threshold maximums or none at all." + max_key: "Maximum value must be greater than minimum." } } ) + + if len(max_values) < len(ThresholdMaxKeys): + for key in allowed_max_keys: + if key not in max_values: + raise serializers.ValidationError( + { + "thresholds": { + key: "Must set all threshold maximums or none at all." + } + } + ) + + unit = thresholds.get("unit") + if unit is not None and not isinstance(unit, str): + raise serializers.ValidationError({"thresholds": {"unit": "Must be a string."}}) + preferred_polarity = thresholds.get("preferred_polarity") if preferred_polarity is not None and preferred_polarity not in ("+", "-", ""): raise serializers.ValidationError( @@ -994,7 +1027,7 @@ class DashboardPermissionsSerializer(CamelSnakeSerializer[Dashboard]): default=list, ) - def validate(self, data): + def validate(self, data: dict[str, Any]) -> dict[str, Any]: if "teams_with_edit_access" in data: team_ids = data["teams_with_edit_access"] existing_team_ids = set( @@ -1054,7 +1087,7 @@ class DashboardDetailsSerializer(CamelSnakeSerializer[Dashboard]): help_text="Permissions that restrict users from editing dashboards", ) - 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, {project.id for project in self.context["projects"]}) @@ -1074,7 +1107,7 @@ def validate_query_transformation_updates(self, widgets: list[dict[str, Any]]) - ] if len(query_ids) != len(set(query_ids)): raise serializers.ValidationError({"widgets": "Query IDs must be unique."}) - existing_queries = {} + existing_queries: dict[tuple[int | None, int | None], DashboardWidgetQuery] = {} if isinstance(self.instance, Dashboard) and query_ids: existing_queries = { (query.widget_id, query.id): query @@ -1085,11 +1118,14 @@ def validate_query_transformation_updates(self, widgets: list[dict[str, Any]]) - widget_errors: list[dict[str, Any]] = [{} for _ in widgets] for widget_index, widget in enumerate(widgets): queries = widget.get("queries", []) + queries = cast("list[dict[str, Any]]", queries) query_errors: list[dict[str, Any] | list[Any]] = [{} for _ in queries] for query_index, data in enumerate(queries): if "aggregates" not in data and "transformations" not in data: continue - query = existing_queries.get((widget.get("id"), data.get("id"))) + query = existing_queries.get( + (cast("int | None", widget.get("id")), cast("int | None", data.get("id"))) + ) try: validate_transformation_indices( data.get("transformations", query.transformations if query else None) or {}, @@ -1143,7 +1179,7 @@ def validate_widget_layout_updates(self, widgets: list[dict[str, Any]]) -> None: if any(widget_errors): raise serializers.ValidationError({"widgets": widget_errors}) - def validate(self, data): + def validate(self, data: dict[str, Any]) -> dict[str, Any]: start = data.get("start") end = data.get("end") @@ -1170,7 +1206,7 @@ def validate(self, data): return data - def update_dashboard_filters(self, instance, validated_data): + def update_dashboard_filters(self, instance: Dashboard, validated_data: dict[str, Any]) -> None: page_filter_keys = ["environment", "period", "start", "end", "utc"] dashboard_filter_keys = ["release", "release_id"] @@ -1199,7 +1235,7 @@ def update_dashboard_filters(self, instance, validated_data): instance.filters = filters instance.save() - def update_permissions(self, instance, validated_data): + def update_permissions(self, instance: Dashboard, validated_data: dict[str, Any]) -> None: if "permissions" in validated_data and validated_data["permissions"] is not None: permissions_data = validated_data["permissions"] permissions = DashboardPermissions.objects.update_or_create( @@ -1221,7 +1257,7 @@ def update_permissions(self, instance, validated_data): instance.permissions = permissions - def create(self, validated_data): + def create(self, validated_data: dict[str, Any]) -> Dashboard: """ Create a dashboard, and create any widgets and their queries @@ -1245,7 +1281,7 @@ def create(self, validated_data): return self.instance - def update(self, instance, validated_data): + def update(self, instance: Dashboard, validated_data: dict[str, Any]) -> Dashboard: """ Update a dashboard, the connected widgets and queries @@ -1268,7 +1304,7 @@ def update(self, instance, validated_data): return instance - def update_widgets(self, instance, widget_data): + def update_widgets(self, instance: Dashboard, widget_data: list[dict[str, Any]]) -> None: with traces.start_span( name="dashboard.update_widgets", attributes={"sentry.op": "function"} ): @@ -1313,13 +1349,13 @@ def update_widgets(self, instance, widget_data): "You cannot update widgets that are not part of this dashboard." ) - def remove_missing_widgets(self, dashboard_id, keep_ids): + def remove_missing_widgets(self, dashboard_id: int, keep_ids: list[int]) -> None: """ Removes current widgets belonging to dashboard not in keep_ids. """ DashboardWidget.objects.filter(dashboard_id=dashboard_id).exclude(id__in=keep_ids).delete() - def create_widget(self, dashboard, widget_data): + def create_widget(self, dashboard: Dashboard, widget_data: dict[str, Any]) -> None: is_text_widget = widget_data.get("display_type") == DashboardWidgetDisplayTypes.TEXT widget = DashboardWidget.objects.create( @@ -1329,12 +1365,13 @@ def create_widget(self, dashboard, widget_data): description=widget_data.get("description", None), thresholds=widget_data.get("thresholds", None), interval=widget_data.get("interval", "5m"), - widget_type=( + widget_type=cast( + "int", None if is_text_widget - else widget_data.get("widget_type", DashboardWidgetTypes.ERROR_EVENTS) + else widget_data.get("widget_type", DashboardWidgetTypes.ERROR_EVENTS), ), - discover_widget_split=widget_data.get("discover_widget_split", None), + discover_widget_split=cast("int", widget_data.get("discover_widget_split", None)), limit=widget_data.get("limit", None), detail={ "layout": widget_data.get("layout"), @@ -1383,7 +1420,7 @@ def create_widget(self, dashboard, widget_data): ]: self._check_query_cardinality(new_queries) - def _check_query_cardinality(self, new_queries: Sequence[DashboardWidgetQuery]): + def _check_query_cardinality(self, new_queries: Sequence[DashboardWidgetQuery]) -> None: organization = self.context["organization"] max_cardinality_allowed = options.get("on_demand.max_widget_cardinality.on_query_count") @@ -1412,7 +1449,7 @@ def _update_or_create_field_links( query: DashboardWidgetQuery, linked_dashboards: list[LinkedDashboard], widget: DashboardWidget, - ): + ) -> None: """ Update DashboardFieldLink entries for a query. linked_dashboards is expected to be an array of dicts with format {"field": str, "dashboard_id": int} @@ -1541,7 +1578,7 @@ def _update_or_create_field_links( update_fields=["dashboard_id"], ) - def update_widget(self, widget, data): + def update_widget(self, widget: DashboardWidget, data: dict[str, Any]) -> None: prev_layout = widget.detail.get("layout") if widget.detail else None prev_axis_range = widget.detail.get("axis_range") if widget.detail else None prev_legend_type = widget.detail.get("legend_type") if widget.detail else None @@ -1567,8 +1604,8 @@ def update_widget(self, widget, data): # Text widgets don't have widget_type or dataset_source if is_text_widget: - widget.widget_type = None - widget.discover_widget_split = None + setattr(widget, "widget_type", None) + setattr(widget, "discover_widget_split", None) widget.dataset_source = DatasetSourcesTypes.UNKNOWN.value elif widget.widget_type == DashboardWidgetTypes.SPANS: if new_dataset_source == DatasetSourcesTypes.USER.value: @@ -1581,10 +1618,13 @@ def update_widget(self, widget, data): ]: # Reset the discover split fields if the widget type is no longer # a discover/errors/transactions widget - widget.discover_widget_split = None + setattr(widget, "discover_widget_split", None) widget.dataset_source = DatasetSourcesTypes.UNKNOWN.value - if widget.widget_type is None and widget.display_type != DashboardWidgetDisplayTypes.TEXT: + if ( + getattr(widget, "widget_type") is None + and widget.display_type != DashboardWidgetDisplayTypes.TEXT + ): raise serializers.ValidationError( {"widget_type": "`widgetType` is required for widgets that are not text widgets"} ) @@ -1597,9 +1637,9 @@ def update_widget(self, widget, data): elif "queries" in data: self.update_widget_queries(widget, data["queries"]) - def update_widget_queries(self, widget, data): + def update_widget_queries(self, widget: DashboardWidget, data: list[dict[str, Any]]) -> None: query_ids = [query["id"] for query in data if "id" in query] - all_query_array = [] + all_query_array: list[QueryArrayEntry] = [] self.remove_missing_queries(widget.id, query_ids) existing = DashboardWidgetQuery.objects.filter(widget=widget, id__in=query_ids) @@ -1639,11 +1679,11 @@ def update_widget_queries(self, widget, data): raise serializers.ValidationError("You cannot use a query not owned by this widget") DashboardWidgetQuery.objects.bulk_create(new_queries) - for query_data in all_query_array: - if "linked_dashboards" in query_data["query_data"]: + for query_entry in all_query_array: + if "linked_dashboards" in query_entry["query_data"]: self._update_or_create_field_links( - query_data["query_obj"], - query_data["query_data"].get("linked_dashboards", []), + query_entry["query_obj"], + query_entry["query_data"].get("linked_dashboards", []), widget, ) @@ -1653,7 +1693,9 @@ def update_widget_queries(self, widget, data): ]: self._check_query_cardinality(new_queries + update_queries) - def update_widget_query(self, query, data, order): + def update_widget_query( + self, query: DashboardWidgetQuery, data: dict[str, Any], order: int + ) -> DashboardWidgetQuery: query.name = data.get("name", query.name) query.fields = data.get("fields", query.fields) query.conditions = data.get("conditions", query.conditions) @@ -1669,7 +1711,7 @@ def update_widget_query(self, query, data, order): query.save() return query - def remove_missing_queries(self, widget_id, keep_ids): + def remove_missing_queries(self, widget_id: int, keep_ids: list[int]) -> None: DashboardWidgetQuery.objects.filter(widget_id=widget_id).exclude(id__in=keep_ids).delete() @@ -1678,7 +1720,7 @@ class DashboardCreateWidgetSerializer(DashboardWidgetSerializer): required=False, allow_null=True ) - def validate(self, data): + def validate(self, data: dict[str, Any]) -> dict[str, Any]: data = super().validate(data) layout = data.get("layout") display_type = data.get("display_type") @@ -1710,7 +1752,7 @@ class DashboardSerializer(DashboardDetailsSerializer): class DashboardStarredOrderSerializer(serializers.Serializer): dashboard_ids = serializers.ListField(child=serializers.IntegerField(), required=True) - def validate_dashboard_ids(self, dashboard_ids): + def validate_dashboard_ids(self, dashboard_ids: list[int]) -> list[int]: if len(dashboard_ids) != len(set(dashboard_ids)): raise serializers.ValidationError("Single dashboard cannot take up multiple positions") return dashboard_ids diff --git a/src/sentry/api/serializers/rest_framework/environment.py b/src/sentry/api/serializers/rest_framework/environment.py index 3550c308dd97..f90a7405f807 100644 --- a/src/sentry/api/serializers/rest_framework/environment.py +++ b/src/sentry/api/serializers/rest_framework/environment.py @@ -7,11 +7,13 @@ @extend_schema_field(str) -class EnvironmentField(serializers.Field): - def to_representation(self, value): +class EnvironmentField( + serializers.Field[Environment | None, str | None, Environment | None, object] +): + def to_representation(self, value: Environment | None) -> Environment | None: return value - def to_internal_value(self, data): + def to_internal_value(self, data: str | None) -> Environment | None: if data is None: return None try: diff --git a/src/sentry/api/serializers/rest_framework/groupsearchview.py b/src/sentry/api/serializers/rest_framework/groupsearchview.py index e5bfeb9ecfb5..3ff830d41ad8 100644 --- a/src/sentry/api/serializers/rest_framework/groupsearchview.py +++ b/src/sentry/api/serializers/rest_framework/groupsearchview.py @@ -108,7 +108,7 @@ def validate_query(self, value: str) -> str: raise ValidationError(detail=f"Invalid issue search query: {e}") return value - def validate_projects(self, value): + def validate_projects(self, value: list[int]) -> list[int]: if value != [-1]: project_ids = set(value) existing_project_ids = set( @@ -123,7 +123,7 @@ def validate_projects(self, value): return value - def validate(self, data) -> GroupSearchViewValidatorResponse: + def validate(self, data: GroupSearchViewValidatorResponse) -> GroupSearchViewValidatorResponse: if data["projects"] == [-1]: data["projects"] = [] data["isAllProjects"] = True @@ -142,5 +142,5 @@ class GroupSearchViewPostValidator(ViewValidator): required=False, help_text="Whether to star the issue view for the current user." ) - def validate(self, data): + def validate(self, data: GroupSearchViewValidatorResponse) -> GroupSearchViewValidatorResponse: return super().validate(data) diff --git a/src/sentry/api/serializers/rest_framework/list.py b/src/sentry/api/serializers/rest_framework/list.py index e42c40ef76f0..b76dcfb49b7d 100644 --- a/src/sentry/api/serializers/rest_framework/list.py +++ b/src/sentry/api/serializers/rest_framework/list.py @@ -1,8 +1,10 @@ +from collections.abc import Sequence + from rest_framework.serializers import ListField class EmptyListField(ListField): - def to_internal_value(self, data): + def to_internal_value(self, data: Sequence[object] | str) -> list[object]: if data == "": - return "" - return super().to_internal_value(data) + return [] + return super().to_internal_value(list(data)) diff --git a/src/sentry/api/serializers/rest_framework/origin.py b/src/sentry/api/serializers/rest_framework/origin.py index 3e2899ff4c6c..c5d256a2fa00 100644 --- a/src/sentry/api/serializers/rest_framework/origin.py +++ b/src/sentry/api/serializers/rest_framework/origin.py @@ -4,17 +4,14 @@ 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 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.py b/src/sentry/api/serializers/rest_framework/project.py index c97da713cbf2..fd8a62f2c8f4 100644 --- a/src/sentry/api/serializers/rest_framework/project.py +++ b/src/sentry/api/serializers/rest_framework/project.py @@ -1,4 +1,7 @@ -from collections.abc import Collection +from __future__ import annotations + +from collections.abc import Collection, Sequence +from typing import TYPE_CHECKING, TypedDict, Unpack from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field @@ -7,14 +10,37 @@ from sentry.api.helpers.projects import ProjectIdOrSlugField from sentry.models.project import Project +if TYPE_CHECKING: + from django_stubs_ext import StrOrPromise + from rest_framework.fields import _DefaultInitial + from rest_framework.validators import Validator + ValidationError = serializers.ValidationError +class _ProjectFieldKwargs(TypedDict, total=False): + read_only: bool + write_only: bool + required: bool | None + default: _DefaultInitial[Project] + initial: _DefaultInitial[Project] + source: str | None + label: StrOrPromise | None + help_text: StrOrPromise | None + style: dict[str, object] | None + error_messages: dict[str, StrOrPromise] | None + validators: Sequence[Validator[Project]] | None + allow_null: bool + + @extend_schema_field(field=OpenApiTypes.STR) -class ProjectField(serializers.Field): +class ProjectField(serializers.Field[Project, object, Project, object]): def __init__( - self, scope: str | Collection[str] = "project:write", id_allowed: bool = False, **kwags - ): + self, + scope: str | Collection[str] = "project:write", + id_allowed: bool = False, + **kwags: Unpack[_ProjectFieldKwargs], + ) -> None: """ The scope parameter specifies which permissions are required to access the project field. If multiple scopes are provided, the project can be accessed when the user is authenticated with @@ -26,7 +52,7 @@ def __init__( self.id_allowed = id_allowed super().__init__(**kwags) - def to_representation(self, value): + def to_representation(self, value: Project) -> Project: return value def to_internal_value(self, data: object) -> Project: diff --git a/src/sentry/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/serializers/rest_framework/release.py b/src/sentry/api/serializers/rest_framework/release.py index c19925689fea..bf0f78aae899 100644 --- a/src/sentry/api/serializers/rest_framework/release.py +++ b/src/sentry/api/serializers/rest_framework/release.py @@ -5,6 +5,8 @@ from sentry.constants import COMMIT_RANGE_DELIMITER, MAX_COMMIT_LENGTH, MAX_VERSION_LENGTH from sentry.models.organizationmember import OrganizationMember from sentry.models.release import Release, ReleaseStatus +from sentry.users.models.user import User +from sentry.users.services.user import RpcUser class ReleaseHeadCommitSerializerDeprecated(serializers.Serializer): @@ -22,7 +24,7 @@ class ReleaseHeadCommitSerializer(serializers.Serializer): max_length=MAX_COMMIT_LENGTH, required=False, allow_null=True, allow_blank=True ) - def validate_commit(self, value): + def validate_commit(self, value: str) -> str: """ Value can be either a single commit or a commit range (1a2b3c..6f5e4d) """ @@ -82,7 +84,7 @@ class ReleaseSerializer(serializers.Serializer): help_text="The status of the release. Can be `open` or `archived`.", ) - def validate_status(self, value): + def validate_status(self, value: str) -> int: try: return ReleaseStatus.from_string(value) except ValueError: @@ -101,12 +103,12 @@ class ReleaseWithVersionSerializer(ReleaseSerializer): help_text="The username of the user to set as the release owner.", ) - def validate_version(self, value): + def validate_version(self, value: str) -> str: if not Release.is_valid_version(value): raise serializers.ValidationError("Release with name %s is not allowed" % value) return value - def validate_owner(self, owner): + def validate_owner(self, owner: User | RpcUser) -> User | RpcUser: if not OrganizationMember.objects.filter( organization=self.context["organization"], user_id=owner.id ).exists(): diff --git a/src/sentry/api/serializers/snuba.py b/src/sentry/api/serializers/snuba.py index c6de3478c177..d186420149f4 100644 --- a/src/sentry/api/serializers/snuba.py +++ b/src/sentry/api/serializers/snuba.py @@ -1,5 +1,17 @@ +from __future__ import annotations + import itertools -from typing import Any, NotRequired, TypedDict +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import TYPE_CHECKING, Any, NotRequired, Protocol, TypedDict, cast + +if TYPE_CHECKING: + from django.contrib.auth.models import AnonymousUser + + from sentry.models.organization import Organization + from sentry.users.models.user import User + from sentry.users.services.user import RpcUser + from sentry.utils.snuba import SnubaTSResult class StatsTimeSeriesResult(TypedDict): @@ -26,17 +38,36 @@ class StatsTimeSeriesResult(TypedDict): end: NotRequired[int] -def value_from_row(row, tagkey): +class TimeSeriesLookup(Protocol): + columns: Sequence[str] + name: str + + def serializer( + self, + organization: Organization | None, + item_list: list[tuple[object, ...]], + user: User | RpcUser | AnonymousUser | None, + ) -> Mapping[tuple[object, ...], object]: ... + + +def value_from_row(row: Mapping[str, object], tagkey: Sequence[str]) -> tuple[object, ...]: return tuple(row[k] for k in tagkey) -def zerofill(data, start, end, rollup, allow_partial_buckets=False, fill_default=None): +def zerofill( + data: Sequence[tuple[float, object]], + start: datetime, + end: datetime, + rollup: int, + allow_partial_buckets: bool = False, + fill_default: object = None, +) -> list[tuple[float, object]]: if fill_default is None: fill_default = [] rv = [] - end = int(end.timestamp()) + end_seconds = int(end.timestamp()) rollup_start = (int(start.timestamp()) // rollup) * rollup - rollup_end = (end // rollup) * rollup + rollup_end = (end_seconds // rollup) * rollup # Fudge the end value when we're only getting a single window. # This ensure that we get both values for a single large window that @@ -61,13 +92,13 @@ def zerofill(data, start, end, rollup, allow_partial_buckets=False, fill_default # Add any remaining rows that are not aligned to the rollup and are lower than the # end date. if i < len(data): - end_timestamp = end if allow_partial_buckets else rollup_end + end_timestamp = end_seconds if allow_partial_buckets else rollup_end rv.extend(row for row in data[i:] if row[0] < end_timestamp) return rv -def calculate_time_frame(start, end, rollup): +def calculate_time_frame(start: datetime, end: datetime, rollup: int) -> dict[str, int]: rollup_start = (int(start.timestamp()) // rollup) * rollup rollup_end = (int(end.timestamp()) // rollup) * rollup if rollup_end - rollup_start == rollup: @@ -80,12 +111,19 @@ class SnubaTSResultSerializer: Serializer for time-series Snuba data. """ - def __init__(self, organization, lookup, user): + def __init__( + self, + organization: Organization | None, + lookup: TimeSeriesLookup | None, + user: User | RpcUser | AnonymousUser | None, + ) -> None: self.organization = organization self.lookup = lookup self.user = user - def get_attrs(self, item_list): + def get_attrs( + self, item_list: list[tuple[object, ...]] + ) -> Mapping[tuple[object, ...], object] | list[tuple[object, ...]]: if self.lookup is None: return item_list @@ -93,22 +131,25 @@ def get_attrs(self, item_list): def serialize( self, - result, - column="count", - order=None, - allow_partial_buckets=False, - zerofill_results=True, - extra_columns=None, - confidence_column="count", - ): + result: SnubaTSResult, + column: str = "count", + order: int | None = None, + allow_partial_buckets: bool = False, + zerofill_results: bool = True, + extra_columns: Sequence[str] | None = None, + confidence_column: str = "count", + ) -> dict[str, Any]: # justification: payload shape varies per axis/column data = [ (key, list(group)) for key, group in itertools.groupby(result.data["data"], key=lambda r: r["time"]) ] - attrs = {} + attrs: Mapping[tuple[object, ...], object] = {} if self.lookup: - attrs = self.get_attrs( - [value_from_row(r, self.lookup.columns) for _, v in data for r in v] + attrs = cast( + Mapping[tuple[object, ...], object], + self.get_attrs( + [value_from_row(r, self.lookup.columns) for _, v in data for r in v] + ), ) rv = [] for k, v in data: @@ -124,7 +165,7 @@ def serialize( row.append(item) rv.append((k, row)) - res = { + res: StatsTimeSeriesResult = { "data": ( zerofill( rv, @@ -172,4 +213,4 @@ def serialize( res["start"] = timeframe["start"] res["end"] = timeframe["end"] - return res + return dict(res) diff --git a/src/sentry/api/utils.py b/src/sentry/api/utils.py index a23b87eee6d0..e88132fd7c23 100644 --- a/src/sentry/api/utils.py +++ b/src/sentry/api/utils.py @@ -342,7 +342,10 @@ def generate_locality_url(locality_name: str | None = None) -> str: ): locality_name = get_locality_name_for_cell(settings.SENTRY_LOCAL_CELL) if not region_url_template or not locality_name: - return options.get("system.url-prefix") + url_prefix = options.get("system.url-prefix") + if not isinstance(url_prefix, str): + raise TypeError("system.url-prefix is not configured") + return url_prefix return region_url_template.replace("{region}", locality_name) diff --git a/src/sentry/api/validators/auth.py b/src/sentry/api/validators/auth.py index e8aa7809140d..9aca43358206 100644 --- a/src/sentry/api/validators/auth.py +++ b/src/sentry/api/validators/auth.py @@ -1,4 +1,4 @@ -from typing import NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict from django.core.exceptions import ValidationError as DjangoValidationError from django.db.models import Q @@ -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: @@ -50,7 +56,8 @@ class RegistrationValidator(serializers.Serializer[RegistrationRequest]): choices=get_timezone_choices(), required=False, allow_blank=True ) - def __init__(self, *args, **kwargs): + # justification: DRF passes dynamic serializer constructor keywords. + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) if not newsletter.backend.is_enabled(): self.fields.pop("subscribe") diff --git a/src/sentry/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..4dbdc31705d0 100644 --- a/src/sentry/apidocs/extensions.py +++ b/src/sentry/apidocs/extensions.py @@ -1,4 +1,5 @@ -from typing import Any, get_type_hints +from collections.abc import Callable +from typing import Any, cast, get_type_hints from drf_spectacular.extensions import ( OpenApiAuthenticationExtension, @@ -15,7 +16,19 @@ from sentry.conf.server import GRANULAR_SCOPES -class TokenAuthExtension(OpenApiAuthenticationExtension): +class _TypedExtension: + """Typed adapter for drf-spectacular's unannotated extension base class.""" + + def __init__(self, target: object) -> None: + # justification: drf-spectacular does not annotate OpenApiGeneratorExtension.__init__. + cast("Callable[..., None]", super().__init__)(target) + + def __init_subclass__(cls, **kwargs: object) -> None: + # justification: drf-spectacular does not annotate __init_subclass__. + cast("Callable[..., None]", super().__init_subclass__)(**kwargs) + + +class TokenAuthExtension(_TypedExtension, OpenApiAuthenticationExtension): """ Extension that adds what scopes are needed to access an endpoint to the OpenAPI Schema. @@ -39,7 +52,7 @@ def get_security_definition( return {"type": "http", "scheme": "bearer"} -class SentryResponseSerializerExtension(OpenApiSerializerExtension): +class SentryResponseSerializerExtension(_TypedExtension, OpenApiSerializerExtension): """ This extension will register any Sentry Response Serializer as a component that can be used in an OpenAPI schema. To have the serializer schema be mapped, you must type the @@ -51,7 +64,8 @@ class SentryResponseSerializerExtension(OpenApiSerializerExtension): match_subclasses = True def get_name(self, auto_schema: AutoSchema, direction: Direction) -> str | None: - return self.target.__name__ + name = self.target.__name__ + return name if isinstance(name, str) else None def map_serializer(self, auto_schema: AutoSchema, direction: Direction) -> Any: type_hints = get_type_hints(self.target.serialize) @@ -61,7 +75,7 @@ def map_serializer(self, auto_schema: AutoSchema, direction: Direction) -> Any: return resolve_type_hint(type_hints["return"]) -class SentryInlineResponseSerializerExtension(OpenApiSerializerExtension): +class SentryInlineResponseSerializerExtension(_TypedExtension, OpenApiSerializerExtension): """ This extension is used for the `inline_sentry_response_serializer` utils function and will simply resolve the type passed into the function to an OpenAPI schema. @@ -72,24 +86,27 @@ class SentryInlineResponseSerializerExtension(OpenApiSerializerExtension): match_subclasses = True def get_name(self, auto_schema: AutoSchema, direction: Direction) -> str | None: - return self.target.__name__ + name = self.target.__name__ + return name if isinstance(name, str) else None def map_serializer(self, auto_schema: AutoSchema, direction: Direction) -> Any: return resolve_type_hint(self.target.typeSchema) -class SentryAppIssueActionSerializerExtension(OpenApiSerializerExtension): +class SentryAppIssueActionSerializerExtension(_TypedExtension, OpenApiSerializerExtension): target_class = "sentry.sentry_apps.api.endpoints.installation_external_issue_actions.SentryAppInstallationExternalIssueActionsSerializer" def map_serializer(self, auto_schema: AutoSchema, direction: Direction) -> dict[str, Any]: - schema = auto_schema._map_serializer(self.target, direction, bypass_extensions=True) + # justification: drf-spectacular does not annotate AutoSchema._map_serializer. + map_serializer = cast("Callable[..., dict[str, Any]]", auto_schema._map_serializer) + schema = map_serializer(self.target, direction, bypass_extensions=True) schema["properties"]["action"]["enum"] = ["link", "create"] # Apps define additional form fields at the top level of the request. schema["additionalProperties"] = True return schema -class RestrictedJsonFieldExtension(OpenApiSerializerFieldExtension): +class RestrictedJsonFieldExtension(_TypedExtension, OpenApiSerializerFieldExtension): """ This extension restricts Sentry's use of the JSONField to mimic a DictField. It comes from a change in drf-spectacular because rest_framework's JSONField actually accepts @@ -102,22 +119,24 @@ 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. # https://github.com/tfranzel/drf-spectacular/issues/334 -class InvestigationRequestSerializerExtension(OpenApiSerializerExtension): +class InvestigationRequestSerializerExtension(_TypedExtension, OpenApiSerializerExtension): """Expose the wire names without changing opaque JSON keys or runtime validation.""" target_class = "sentry.notebooks.endpoints.validators.base.StrictCamelSnakeValidator" match_subclasses = True def map_serializer(self, auto_schema: AutoSchema, direction: Direction) -> dict[str, Any]: - schema = auto_schema._map_serializer(self.target, direction, bypass_extensions=True) + # justification: drf-spectacular does not annotate AutoSchema._map_serializer. + map_serializer = cast("Callable[..., dict[str, Any]]", auto_schema._map_serializer) + schema = map_serializer(self.target, direction, bypass_extensions=True) schema["properties"] = { snake_to_camel_case(name): field for name, field in schema["properties"].items() } diff --git a/src/sentry/apidocs/hooks.py b/src/sentry/apidocs/hooks.py index f5755a9d7296..238e2c074812 100644 --- a/src/sentry/apidocs/hooks.py +++ b/src/sentry/apidocs/hooks.py @@ -2,9 +2,10 @@ import os import re from collections import OrderedDict -from collections.abc import Mapping -from typing import Any, Literal, NotRequired, TypedDict +from collections.abc import Callable, Mapping +from typing import Any, Literal, NotRequired, TypedDict, cast +from django.http import HttpResponseBase from drf_spectacular.generators import EndpointEnumerator, SchemaGenerator from sentry.api.api_owners import ApiOwner @@ -48,7 +49,7 @@ class EndpointRegistryType(TypedDict): ] -def __get_line_count_for_team_stats(team_stats: Mapping): +def __get_line_count_for_team_stats(team_stats: Mapping[ApiPublishStatus, set[str]]) -> int: """ Returns number of lines it takes to write ownership for each team. For example returns 15 for: @@ -71,12 +72,14 @@ def __get_line_count_for_team_stats(team_stats: Mapping): return line_count -def __write_ownership_data(ownership_data: dict[ApiOwner, dict]): +def __write_ownership_data( + ownership_data: dict[ApiOwner, dict[ApiPublishStatus, set[str]]], +) -> None: """ Writes API ownership for all the teams in _OWNERSHIP_FILE. This file is used by Sentaur slack bot to inform teams on status of their APIs """ - processed_data = {} + processed_data: dict[str, dict[str, int | list[str]]] = {} index = 2 for team in ownership_data: # sorting APIs list so it doesn't trigger file change on every commit @@ -102,26 +105,44 @@ def __write_ownership_data(ownership_data: dict[ApiOwner, dict]): class CustomEndpointEnumerator(EndpointEnumerator): _non_capturing = re.compile(r"\(\?:[a-z-]+\|[a-z-]+\)") + def collect_endpoints(self) -> list[tuple[str, str, str, Callable[..., HttpResponseBase]]]: + # justification: drf-spectacular does not annotate the inherited endpoint enumerator. + enumerate_endpoints = cast( + "Callable[[], list[tuple[str, str, str, Callable[..., HttpResponseBase]]]]", + self.get_api_endpoints, + ) + return enumerate_endpoints() + def get_path_from_regex(self, path_regex: str) -> str: # django 4.x changes to simplify_regex breaks these urls path_regex = self._non_capturing.sub("{var}", path_regex) - return super().get_path_from_regex(path_regex) + # justification: drf-spectacular does not annotate EndpointEnumerator.get_path_from_regex. + return cast("Callable[[str], str]", super().get_path_from_regex)(path_regex) class CustomGenerator(SchemaGenerator): endpoint_inspector_cls = CustomEndpointEnumerator - def get_schema(self, request: Any = None, public: bool = False) -> Any: + def __init__(self, *args: object, **kwargs: object) -> None: + # justification: drf-spectacular does not annotate SchemaGenerator.__init__. + cast("Callable[..., None]", super().__init__)(*args, **kwargs) + + def get_schema(self, request: Any = None, public: bool = False) -> dict[str, Any]: """Build the schema, then prove its omissions changed only what they declare. The baseline is a second build, on a fresh registry, with omissions off.""" baseline_generator = type(self)( patterns=self.patterns, urlconf=self.urlconf, api_version=self.api_version ) + # justification: drf-spectacular does not annotate SchemaGenerator.get_schema. + get_schema = cast("Callable[..., Any]", SchemaGenerator.get_schema) + super_get_schema = cast("Callable[..., Any]", super().get_schema) with omissions_disabled(): - baseline = SchemaGenerator.get_schema(baseline_generator, request, public) + # justification: drf-spectacular returns the generated schema mapping untyped. + baseline = cast("dict[str, Any]", get_schema(baseline_generator, request, public)) with recording_omissions() as recorded: - schema = super().get_schema(request, public) + # justification: drf-spectacular returns the generated schema mapping untyped. + schema = cast("dict[str, Any]", super_get_schema(request, public)) try: check_schema_omissions(baseline, schema, recorded) except OmissionError as exc: @@ -186,7 +207,7 @@ def custom_preprocessing_hook(endpoints: Any) -> Any: # TODO: organize method, _IDEMPOTENT_OPERATIONS.clear() filtered = [] - ownership_data: dict[ApiOwner, dict] = {} + ownership_data: dict[ApiOwner, dict[ApiPublishStatus, set[str]]] = {} for path, path_regex, method, callback in endpoints: # Collect servers from endpoint class for postprocessing endpoint_servers = getattr(callback.view_class, "servers", None) @@ -319,7 +340,9 @@ def _validate_request_body( ) -def custom_postprocessing_hook(result: Any, generator: Any, **kwargs: Any) -> Any: +def custom_postprocessing_hook( + result: dict[str, Any], generator: Any, **kwargs: Any +) -> dict[str, Any]: # Stamp before alias canonicalization so metadata stays with its operation. for path, operations in result["paths"].items(): for method, operation in operations.items(): diff --git a/src/sentry/apidocs/parameters.py b/src/sentry/apidocs/parameters.py index f94179f1ade6..8c3dfea86662 100644 --- a/src/sentry/apidocs/parameters.py +++ b/src/sentry/apidocs/parameters.py @@ -1,5 +1,3 @@ -from typing import Any - from drf_spectacular.plumbing import build_array_type, build_basic_type from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter @@ -39,7 +37,7 @@ def mark_parameter_experimental(parameter: OpenApiParameter) -> OpenApiParameter return parameter -def build_typed_list(type: Any): +def build_typed_list(type: OpenApiTypes) -> dict[str, object]: """ drf-spectacular doesn't support a list type in it's OpenApiTypes, so we manually build a typed list using this workaround. build_basic_type will dynamically check the type diff --git a/src/sentry/apidocs/schema.py b/src/sentry/apidocs/schema.py index edd0b1482180..75febe1e6bc8 100644 --- a/src/sentry/apidocs/schema.py +++ b/src/sentry/apidocs/schema.py @@ -1,12 +1,15 @@ import contextlib import copy -from collections.abc import Iterator -from typing import Any +import inspect +from collections.abc import Callable, Iterable, Iterator +from typing import Any, Protocol, cast from drf_spectacular.drainage import get_override from drf_spectacular.openapi import AutoSchema -from drf_spectacular.plumbing import force_instance, get_doc, is_basic_serializer +from drf_spectacular.plumbing import get_doc, is_basic_serializer from drf_spectacular.utils import OpenApiParameter +from rest_framework import serializers +from rest_framework.fields import Field from sentry.apidocs.omission_apply import ( OmissionError, @@ -21,12 +24,28 @@ from sentry.apidocs.utils import SentryApiBuildError +class _SerializerFields(Protocol): + fields: Iterable[str] + + +def _force_instance(serializer_or_field: object) -> object: + if not inspect.isclass(serializer_or_field): + return serializer_or_field + if issubclass(serializer_or_field, (serializers.BaseSerializer, Field)): + return serializer_or_field() + return serializer_or_field + + class SentrySchema(AutoSchema): """DRF Documentation Schema for sentry endpoints""" @property - def view_func(self): - return getattr(self.view, self.method.lower()) + def view_func(self) -> Callable[..., object]: + view_func = getattr(self.view, self.method.lower()) + if not callable(view_func): + raise AttributeError(f"{self.method} is not callable") + # justification: Django binds the view method by name and it is callable above. + return cast("Callable[..., object]", view_func) def get_operation_id(self) -> str: """ @@ -50,9 +69,12 @@ def resolve_serializer( self, serializer: Any, direction: Any, bypass_extensions: bool = False ) -> Any: """Note which component the serializer is mapped into, for the build's check.""" - name = self._get_serializer_name(force_instance(serializer), direction, bypass_extensions) + name = self._get_serializer_name(_force_instance(serializer), direction, bypass_extensions) with placed_at(("components", name)): - return super().resolve_serializer(serializer, direction, bypass_extensions) + # justification: drf-spectacular does not annotate AutoSchema.resolve_serializer. + return cast("Callable[..., Any]", super().resolve_serializer)( + serializer, direction, bypass_extensions + ) def _map_serializer( self, serializer: Any, direction: Any, bypass_extensions: bool = False @@ -60,12 +82,14 @@ def _map_serializer( """Withhold declared choices from the schema of the serializer declaring them. Components and exploded query parameters are both built here.""" - instance = force_instance(serializer) + instance = _force_instance(serializer) + # justification: drf-spectacular does not annotate AutoSchema._map_serializer. + super_map_serializer = cast("Callable[..., Any]", super()._map_serializer) if not omissions_enabled(): with _decorator_exclusions_suspended(instance): - return super()._map_serializer(instance, direction, bypass_extensions) + return super_map_serializer(instance, direction, bypass_extensions) - schema = super()._map_serializer(instance, direction, bypass_extensions) + schema = super_map_serializer(instance, direction, bypass_extensions) rules = _choice_rules(instance) try: if not in_operation(): @@ -84,7 +108,8 @@ def _process_override_parameters(self, direction: Any = "request") -> Any: drf-spectacular takes `required` from the field, which still has one.""" with placed_at(("operations", self.get_operation_id())): - result = super()._process_override_parameters(direction) + # justification: drf-spectacular does not annotate AutoSchema._process_override_parameters. + result = cast("Callable[..., Any]", super()._process_override_parameters)(direction) _lift_deprecated(result) if not omissions_enabled(): return result @@ -95,14 +120,15 @@ def _process_override_parameters(self, direction: Any = "request") -> Any: if isinstance(parameter, OpenApiParameter): source[parameter.name, parameter.location] = parameter elif is_basic_serializer(parameter): - for name in force_instance(parameter).fields: + instance = cast("_SerializerFields", _force_instance(parameter)) + for name in instance.fields: source[name, OpenApiParameter.QUERY] = parameter serializers = {id(p): p for p in source.values() if not isinstance(p, OpenApiParameter)} try: with placed_at(("operations", self.get_operation_id())): for serializer in serializers.values(): owned = {name for (name, _), src in source.items() if src is serializer} - _record(force_instance(serializer), _choice_rules(serializer), owned) + _record(_force_instance(serializer), _choice_rules(serializer), owned) except OmissionError as exc: raise SentryApiBuildError(str(exc)) from exc required: set[tuple[str, str]] = set() @@ -126,7 +152,7 @@ def _process_override_parameters(self, direction: Any = "request") -> Any: def _choice_rules(serializer: Any) -> dict[str, Resolved]: try: - return choice_rules(force_instance(serializer)) + return choice_rules(_force_instance(serializer)) except OmissionError as exc: raise SentryApiBuildError(str(exc)) from exc diff --git a/src/sentry/apidocs/spectacular_ports.py b/src/sentry/apidocs/spectacular_ports.py index b8826c0dc90d..31555ffebb07 100644 --- a/src/sentry/apidocs/spectacular_ports.py +++ b/src/sentry/apidocs/spectacular_ports.py @@ -32,6 +32,7 @@ import inspect import typing from collections import defaultdict +from collections.abc import Sequence from enum import Enum from types import UnionType from typing import Any, Literal, Union, is_typeddict @@ -66,13 +67,22 @@ # map TypedDicts in schema registry -def get_type_hints(hint, **kwargs): +# justification: drf-spectacular introspects arbitrary runtime types. +TypeHint = Any + + +def get_type_hints( + hint: TypeHint, + globalns: dict[str, TypeHint] | None = None, + localns: dict[str, TypeHint] | None = None, + include_extras: bool = False, +) -> dict[str, TypeHint]: try: - return _get_type_hints(hint, **kwargs) + return _get_type_hints(hint, globalns, localns, include_extras) except NameError: # try to resolve a circular import from TYPE_CHECKING imports reload_module_with_type_checking_enabled(hint.__module__) - return _get_type_hints(hint, **kwargs) + return _get_type_hints(hint, globalns, localns, include_extras) except TypeError: raise UnableToProceedError( f"""Unable to resolve type hints for {hint}. @@ -82,11 +92,11 @@ def get_type_hints(hint, **kwargs): ) -def _get_type_hint_origin(hint): +def _get_type_hint_origin(hint: TypeHint) -> tuple[TypeHint, tuple[TypeHint, ...]]: return typing.get_origin(hint), typing.get_args(hint) -def build_choice_description_list(choices) -> str: +def build_choice_description_list(choices: Sequence[tuple[object, object]]) -> str: """ Override the default generated description for choicefields if the value and label are identical to be (* `value`) instead of (* `value` - `label`). @@ -102,7 +112,7 @@ def build_choice_description_list(choices) -> str: def build_array_type( schema: _SchemaType | None, min_length: int | None = None, max_length: int | None = None -): +) -> _SchemaType: """ This function wraps the build_array_type from `drf-spectacular` because it is not type-safe. """ @@ -124,11 +134,13 @@ def _cycle_placeholder(hint: Any) -> dict[str, Any]: } -def resolve_type_hint(hint) -> Any: +def resolve_type_hint(hint: TypeHint) -> TypeHint: """drf-spectacular library method modified as described above""" origin, args = _get_type_hint_origin(hint) excluded_fields = get_override(hint, "exclude_fields", []) + if hint is object: + return build_basic_type(OpenApiTypes.ANY) if origin is None and is_basic_type(hint, allow_none=False): return build_basic_type(hint) elif origin is None and inspect.isclass(hint) and issubclass(hint, tuple): @@ -140,7 +152,7 @@ def resolve_type_hint(hint) -> Any: else: properties = {k: build_basic_type(OpenApiTypes.ANY) for k in hint._fields} # type: ignore[attr-defined] return build_object_type(properties=properties, required=properties.keys()) - elif origin is list or hint is list: + elif origin is list or hint is list or origin is collections.abc.Sequence: return build_array_type( _resolve_at(args[0], "items") if args else build_basic_type(OpenApiTypes.ANY) ) @@ -150,7 +162,7 @@ def resolve_type_hint(hint) -> Any: max_length=len(args), min_length=len(args), ) - elif origin is dict or origin is defaultdict: + elif origin is dict or origin is defaultdict or origin is collections.abc.Mapping: schema = build_basic_type(OpenApiTypes.OBJECT) if args and args[1] is not typing.Any and schema is not None: schema["additionalProperties"] = _resolve_at(args[1], "additionalProperties") diff --git a/src/sentry/assistant/manager.py b/src/sentry/assistant/manager.py index fb6bda69d7d5..6ed101891c30 100644 --- a/src/sentry/assistant/manager.py +++ b/src/sentry/assistant/manager.py @@ -1,16 +1,21 @@ +from collections.abc import Mapping + + class AssistantManager: - def __init__(self): - self._guides = {} + def __init__(self) -> None: + self._guides: dict[str, int] = {} - def add(self, guides): + def add(self, guides: Mapping[str, int]) -> None: for guide_key, id in guides.items(): self._guides[guide_key] = id - def get_valid_ids(self): + def get_valid_ids(self) -> list[int]: return list(self._guides.values()) - def get_guide_id(self, guide): + def get_guide_id(self, guide: object) -> int | None: + if not isinstance(guide, str): + return None return self._guides.get(guide) - def all(self): + def all(self) -> dict[str, int]: return self._guides diff --git a/src/sentry/attachments/__init__.py b/src/sentry/attachments/__init__.py index 52499c5d1016..d183f05eb6f6 100644 --- a/src/sentry/attachments/__init__.py +++ b/src/sentry/attachments/__init__.py @@ -32,7 +32,7 @@ @traces.trace def store_attachments_for_event( project: Project, event: Any, attachments: list[CachedAttachment], timeout: int -): +) -> None: """ Stores the given list of `attachments` belonging to `event` for processing. @@ -64,7 +64,7 @@ def get_attachments_for_event(event: Any) -> Generator[CachedAttachment]: @traces.trace def delete_cached_and_ratelimited_attachments( project: Project, attachments: list[CachedAttachment] -): +) -> None: """ This deletes all attachment payloads from the attachment cache (if those are stored there), as well as delete all the `rate_limited` diff --git a/src/sentry/attachments/base.py b/src/sentry/attachments/base.py index 00d149b671a0..38e7af5ed171 100644 --- a/src/sentry/attachments/base.py +++ b/src/sentry/attachments/base.py @@ -2,9 +2,10 @@ from collections.abc import Generator from datetime import timedelta -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol, TypedDict, Unpack, cast import zstandard +from django.core.files.uploadedfile import UploadedFile from objectstore_client import TimeToLive from sentry.objectstore import UsecaseId, default_attachment_retention, get_session @@ -24,23 +25,54 @@ class MissingAttachmentChunks(Exception): pass +class _AttachmentCacheStorage(Protocol): + def set(self, key: str, value: bytes, timeout: int, *, raw: bool = ...) -> None: ... + + def get(self, key: str, *, raw: bool = ...) -> object | None: ... + + def delete(self, key: str) -> None: ... + + +class _AttachmentMetadata(TypedDict, total=False): + id: int | None + name: str | None + content_type: str | None + type: str | None + chunks: int | None + data: object + stored_id: str | None + rate_limited: bool | None + size: int | None + retention_days: int | None + + +class _AttachmentUploadOptions(TypedDict, total=False): + id: int | None + type: str | None + chunks: int | None + stored_id: str | None + rate_limited: bool | None + size: int | None + retention_days: int | None + + class CachedAttachment: def __init__( self, - key=None, - id=None, - name=None, - content_type=None, - type=None, - chunks=None, - data=UNINITIALIZED_DATA, + key: str | None = None, + id: int | None = None, + name: str | None = None, + content_type: str | None = None, + type: str | None = None, + chunks: int | None = None, + data: object = UNINITIALIZED_DATA, stored_id: str | None = None, - cache=None, - rate_limited=None, - size=None, - retention_days=None, - **kwargs, - ): + cache: BaseAttachmentCache | None = None, + rate_limited: bool | None = None, + size: int | None = None, + retention_days: int | None = None, + **kwargs: object, + ) -> None: self.key = key self.id = id @@ -54,7 +86,7 @@ def __init__( if size is not None: self.size = size elif data not in (None, UNINITIALIZED_DATA): - self.size = len(data) + self.size = len(cast(bytes, data)) else: self.size = 0 @@ -65,7 +97,9 @@ def __init__( self._has_initial_data = data is not UNINITIALIZED_DATA @classmethod - def from_upload(cls, file, **kwargs): + def from_upload( + cls, file: UploadedFile, **kwargs: Unpack[_AttachmentUploadOptions] + ) -> CachedAttachment: return CachedAttachment( name=file.name, content_type=file.content_type, data=file.read(), **kwargs ) @@ -83,11 +117,11 @@ def load_data(self, project: Project | None = None) -> bytes: self._data = self._cache.get_data(self) assert self._data is not UNINITIALIZED_DATA - return self._data + return cast(bytes, self._data) - def delete(self): + def delete(self) -> None: for key in self.chunk_keys: - self._cache.inner.delete(key) + cast(BaseAttachmentCache, self._cache).inner.delete(key) @property def chunk_keys(self) -> Generator[str]: @@ -106,25 +140,28 @@ def chunk_keys(self) -> Generator[str]: key=self.key, id=self.id, chunk_index=chunk_index ) - def meta(self) -> dict: - return prune_empty_keys( - { - "key": self.key, - "id": self.id, - "name": self.name, - "rate_limited": self.rate_limited, - "content_type": self.content_type, - "type": self.type, - "size": self.size or None, # None for backwards compatibility - "chunks": self.chunks, - "stored_id": self.stored_id, - "retention_days": self.retention_days, - } + def meta(self) -> _AttachmentMetadata: + return cast( + _AttachmentMetadata, + prune_empty_keys( + { + "key": self.key, + "id": self.id, + "name": self.name, + "rate_limited": self.rate_limited, + "content_type": self.content_type, + "type": self.type, + "size": self.size or None, # None for backwards compatibility + "chunks": self.chunks, + "stored_id": self.stored_id, + "retention_days": self.retention_days, + } + ), ) class BaseAttachmentCache: - def __init__(self, inner): + def __init__(self, inner: _AttachmentCacheStorage) -> None: self.inner = inner def set( @@ -133,7 +170,7 @@ def set( attachments: list[CachedAttachment], timeout: int, project: Project | None = None, - ) -> list[dict]: + ) -> list[_AttachmentMetadata]: for id, attachment in enumerate(attachments): # TODO(markus): We need to get away from sequential IDs, they # are risking collision when using Relay. @@ -149,7 +186,7 @@ def set( # A keyed `put` replaces the object wholesale, so metadata that is not # sent here is lost rather than preserved from the previous write. session.put( - contents=attachment._data, + contents=cast(bytes, attachment._data), key=attachment.stored_id, content_type=attachment.content_type, filename=attachment.name, @@ -177,12 +214,21 @@ def set( return meta - def set_chunk(self, key: str, id: int, chunk_index: int, chunk_data: bytes, timeout: int): + def set_chunk( + self, key: str, id: int, chunk_index: int, chunk_data: bytes, timeout: int + ) -> None: key = ATTACHMENT_DATA_CHUNK_KEY.format(key=key, id=id, chunk_index=chunk_index) compressed = zstandard.compress(chunk_data) self.inner.set(key, compressed, timeout, raw=True) - def set_unchunked_data(self, key: str, id: int, data: bytes, timeout: int, metrics_tags=None): + def set_unchunked_data( + self, + key: str, + id: int, + data: bytes, + timeout: int, + metrics_tags: dict[str, str] | None = None, + ) -> None: key = ATTACHMENT_UNCHUNKED_DATA_KEY.format(key=key, id=id) compressed = zstandard.compress(data) metrics.distribution("attachments.blob-size.raw", len(data), tags=metrics_tags, unit="byte") @@ -192,7 +238,9 @@ def set_unchunked_data(self, key: str, id: int, data: bytes, timeout: int, metri metrics.incr("attachments.received", tags=metrics_tags, skip_internal=False) self.inner.set(key, compressed, timeout, raw=True) - def get_from_chunks(self, key: str, **attachment) -> CachedAttachment: + def get_from_chunks( + self, key: str, **attachment: Unpack[_AttachmentMetadata] + ) -> CachedAttachment: return CachedAttachment(key=key, cache=self, **attachment) def get_data(self, attachment: CachedAttachment) -> bytes: @@ -202,7 +250,7 @@ def get_data(self, attachment: CachedAttachment) -> bytes: raw_data = self.inner.get(key, raw=True) if raw_data is None: raise MissingAttachmentChunks() - decompressed = zstandard.decompress(raw_data) + decompressed = zstandard.decompress(cast(bytes, raw_data)) data.extend(decompressed) return bytes(data) 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/attachments/redis.py b/src/sentry/attachments/redis.py index 6f76d4902f20..859573fec62f 100644 --- a/src/sentry/attachments/redis.py +++ b/src/sentry/attachments/redis.py @@ -1,4 +1,5 @@ import logging +from typing import cast from django.conf import settings @@ -10,11 +11,26 @@ class RedisClusterAttachmentCache(BaseAttachmentCache): - def __init__(self, **options): - cluster_id = options.pop("cluster_id", None) + def __init__(self, **options: object) -> None: + cluster_id = cast("str | None", options.pop("cluster_id", None)) if cluster_id is None: cluster_id = getattr(settings, "SENTRY_ATTACHMENTS_REDIS_CLUSTER", "rc-short") - BaseAttachmentCache.__init__(self, inner=RedisClusterCache(cluster_id, **options)) + version = options.pop("version", None) + if version is not None and not isinstance(version, int): + version = int(cast(str, version)) + prefix = options.pop("prefix", None) + assert prefix is None or isinstance(prefix, str) + is_default_cache = options.pop("is_default_cache", False) + assert isinstance(is_default_cache, bool) + BaseAttachmentCache.__init__( + self, + inner=RedisClusterCache( + cluster_id, + version=version, + prefix=prefix, + is_default_cache=is_default_cache, + ), + ) # Confusing legacy name for RediscClusterCache 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/audit_log/manager.py b/src/sentry/audit_log/manager.py index fa2e0fac0b99..5756462ec2bb 100644 --- a/src/sentry/audit_log/manager.py +++ b/src/sentry/audit_log/manager.py @@ -67,7 +67,9 @@ class AuditLogEvent: # subclass AuditLogEvent and override the render method. template: str | None = None - def __init__(self, event_id, name, api_name, template=None): + def __init__( + self, event_id: int, name: str, api_name: str, template: str | None = None + ) -> None: self.event_id = event_id self.name = name self.api_name = api_name diff --git a/src/sentry/audit_log/services/log/impl.py b/src/sentry/audit_log/services/log/impl.py index 68c8ec3f9ff1..150a9a288da6 100644 --- a/src/sentry/audit_log/services/log/impl.py +++ b/src/sentry/audit_log/services/log/impl.py @@ -2,6 +2,7 @@ import datetime import logging +from typing import cast from django.db import IntegrityError, router, transaction @@ -101,7 +102,8 @@ def _get_invalid_event_id_pass_list(self) -> list[int]: logger.error("audit_log.invalid_audit_log_pass_list", extra={"pass_list": pass_list}) return [] - return pass_list + # justification: the isinstance loop above proved every item is an int. + return cast("list[int]", pass_list) class OutboxBackedLogService(LogService): diff --git a/src/sentry/auth/authenticators/base.py b/src/sentry/auth/authenticators/base.py index 60b88d9fb5ed..73c969bbd64c 100644 --- a/src/sentry/auth/authenticators/base.py +++ b/src/sentry/auth/authenticators/base.py @@ -135,7 +135,10 @@ def config(self) -> dict[str, Any]: a new config is used on first access. """ if self.authenticator is not None: - return self.authenticator.config + config = self.authenticator.config + if not isinstance(config, dict): + raise TypeError("Authenticator config is not an object") + return config rv = getattr(self, "_unbound_config", None) if rv is None: # Prevent bad recursion if stuff wants to access the default @@ -217,7 +220,10 @@ def generate_new_config(self) -> dict[str, Any]: @property def secret(self) -> str: - return self.config["secret"] + secret = self.config["secret"] + if not isinstance(secret, str): + raise TypeError("Authenticator secret is not a string") + return secret @secret.setter def secret(self, secret: str) -> None: diff --git a/src/sentry/auth/authenticators/sms.py b/src/sentry/auth/authenticators/sms.py index 5701dbdb89e6..863f8bae6ac1 100644 --- a/src/sentry/auth/authenticators/sms.py +++ b/src/sentry/auth/authenticators/sms.py @@ -2,7 +2,7 @@ import logging from hashlib import md5 -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from django.http.request import HttpRequest from django.utils.functional import classproperty @@ -55,8 +55,9 @@ def make_otp(self) -> TOTP: return TOTP(self.config["secret"], digits=6, interval=self.code_ttl, default_window=1) @property - def phone_number(self) -> str: - return self.config["phone_number"] + def phone_number(self) -> str | None: + # justification: an unenrolled SMS interface keeps a null phone number. + return cast("str | None", self.config["phone_number"]) @phone_number.setter def phone_number(self, value: str) -> None: @@ -109,7 +110,8 @@ def send_text(self, *, request: HttpRequest, for_enrollment: bool = False) -> bo else: user_id = None - phone_number = phone_number_as_e164(self.phone_number) + # justification: send_text only runs once enrollment stored a phone number. + phone_number = phone_number_as_e164(cast("str", self.phone_number)) if ratelimiter.is_limited( f"sms:{md5(phone_number.encode('utf-8')).hexdigest()}", diff --git a/src/sentry/auth/authenticators/u2f.py b/src/sentry/auth/authenticators/u2f.py index 3fbc6427fbb7..c532642baa2a 100644 --- a/src/sentry/auth/authenticators/u2f.py +++ b/src/sentry/auth/authenticators/u2f.py @@ -47,7 +47,8 @@ def create_credential_object(registeredKey: dict[str, str]) -> base.AttestedCred def _get_url_prefix() -> str: - return options.get("system.url-prefix") + url_prefix = options.get("system.url-prefix") + return url_prefix if isinstance(url_prefix, str) else "" class U2fInterface(AuthenticatorInterface): @@ -107,7 +108,7 @@ def _key_does_not_match(device: dict[str, Any]) -> bool: if isinstance(device["binding"], AuthenticatorData): return decode_credential_id(device) != key else: - return device["binding"]["keyHandle"] != key + return bool(device["binding"]["keyHandle"] != key) return [device for device in self.config.get("devices", ()) if _key_does_not_match(device)] @@ -169,9 +170,11 @@ def get_device_name(self, key: str) -> str | None: for device in self.config.get("devices", ()): if isinstance(device["binding"], AuthenticatorData): if decode_credential_id(device) == key: - return device["name"] + name = device["name"] + return name if isinstance(name, str) else None elif device["binding"]["keyHandle"] == key: - return device["name"] + name = device["name"] + return name if isinstance(name, str) else None return None def get_registered_devices(self) -> list[dict[str, Any]]: diff --git a/src/sentry/auth/elevated_mode.py b/src/sentry/auth/elevated_mode.py index 188a5f5524e0..9d375934589d 100644 --- a/src/sentry/auth/elevated_mode.py +++ b/src/sentry/auth/elevated_mode.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from datetime import datetime from enum import Enum -from typing import Any +from typing import Any, cast from django.contrib.auth.models import AnonymousUser from django.http import HttpResponse @@ -20,7 +20,8 @@ def __bool__(self) -> bool: return self.value is not None def __str__(self) -> str: - return self.value + # justification: every InactiveReason member is a string. + return cast("str", self.value) class ElevatedMode(ABC): diff --git a/src/sentry/auth/email_verification.py b/src/sentry/auth/email_verification.py index d89c5882ee6a..019f324a9177 100644 --- a/src/sentry/auth/email_verification.py +++ b/src/sentry/auth/email_verification.py @@ -138,6 +138,8 @@ def verify_signup_link(signed_data: str) -> dict[str, Any]: payload = unsign(signed_data, salt=settings.SIGNUP_VERIFICATION_EMAIL_SALT, max_age=None) except binascii.Error as e: raise BadSignature("Malformed verification link") from e + if not isinstance(payload, dict): + raise BadSignature("Malformed verification link") if time.time() > payload["expires_at"]: raise SignupLinkExpired("Verification link expired", email=payload["email"]) return payload diff --git a/src/sentry/auth/helper.py b/src/sentry/auth/helper.py index 44d0478bd70e..3c0b0128d45c 100644 --- a/src/sentry/auth/helper.py +++ b/src/sentry/auth/helper.py @@ -630,10 +630,10 @@ def _has_verified_signup_email(self, state: AuthHelperSessionStore) -> bool: Consumes the marker on read so it can only be used once. """ verified = getattr(state, "verified_email", None) - if not verified: + if not isinstance(verified, str) or not verified: return False state.verified_email = None - return verified.lower() == self.identity["email"].lower() + return bool(verified.lower() == self.identity["email"].lower()) def _send_sso_verification_email_and_redirect( self, email: str, state: AuthHelperSessionStore diff --git a/src/sentry/auth/provider.py b/src/sentry/auth/provider.py index 5e5d6e6ebcdf..010b030a0bde 100644 --- a/src/sentry/auth/provider.py +++ b/src/sentry/auth/provider.py @@ -4,8 +4,10 @@ import logging from collections import namedtuple from collections.abc import Callable, Mapping, Sequence -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast +from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth.models import AnonymousUser from django.http.request import HttpRequest from django.utils.encoding import force_str @@ -14,7 +16,6 @@ from sentry.models.authidentity import AuthIdentity from sentry.organizations.services.organization.model import RpcOrganization from sentry.pipeline.provider import PipelineProvider -from sentry.users.models.user import User if TYPE_CHECKING: from sentry.auth.helper import AuthHelper # noqa: F401 @@ -38,7 +39,8 @@ class MigratingIdentityId(namedtuple("MigratingIdentityId", ["id", "legacy_id"]) __slots__ = () def __str__(self) -> str: - return force_str(self.id) + # justification: force_str returns a str for the Any-typed id. + return cast("str", force_str(self.id)) class Provider(PipelineProvider["AuthHelper"], abc.ABC): @@ -145,7 +147,7 @@ def refresh_identity(self, auth_identity: AuthIdentity) -> None: """ raise NotImplementedError - def can_use_scim(self, organization_id: int, user: User) -> bool: + def can_use_scim(self, organization_id: int, user: AbstractBaseUser | AnonymousUser) -> bool: """ Controls whether or not a provider can have SCIM enabled to manage users. By default we have this on for all providers. diff --git a/src/sentry/auth/providers/fly/client.py b/src/sentry/auth/providers/fly/client.py index 9d8c423aea3a..33a930995c87 100644 --- a/src/sentry/auth/providers/fly/client.py +++ b/src/sentry/auth/providers/fly/client.py @@ -45,7 +45,10 @@ def _request(self, path: str) -> dict[str, Any]: raise FlyApiError(f"{e}", status=getattr(e, "status_code", 0)) if req.status_code < 200 or req.status_code >= 300: raise FlyApiError(req.content, status=req.status_code) - return orjson.loads(req.content) + data = orjson.loads(req.content) + if not isinstance(data, dict): + raise FlyApiError("Fly returned a non-object response", status=req.status_code) + return data def get_info(self) -> dict[str, Any]: """ diff --git a/src/sentry/auth/providers/fly/provider.py b/src/sentry/auth/providers/fly/provider.py index 204310f6d864..8e9324756c88 100644 --- a/src/sentry/auth/providers/fly/provider.py +++ b/src/sentry/auth/providers/fly/provider.py @@ -3,6 +3,7 @@ from collections.abc import Callable, Mapping from typing import Any +from django.core.exceptions import ImproperlyConfigured from django.http.request import HttpRequest from sentry import options @@ -29,10 +30,16 @@ def __init__(self, org: RpcOrganization | None = None, **config: Any) -> None: super().__init__(**config) def get_client_id(self) -> str: - return options.get("auth-fly.client-id") + client_id = options.get("auth-fly.client-id") + if not isinstance(client_id, str): + raise ImproperlyConfigured("auth-fly.client-id is not configured") + return client_id def get_client_secret(self) -> str: - return options.get("auth-fly.client-secret") + client_secret = options.get("auth-fly.client-secret") + if not isinstance(client_secret, str): + raise ImproperlyConfigured("auth-fly.client-secret is not configured") + return client_secret def get_configure_view( self, diff --git a/src/sentry/auth/providers/github/client.py b/src/sentry/auth/providers/github/client.py index e737d611ede1..626bed8adc2f 100644 --- a/src/sentry/auth/providers/github/client.py +++ b/src/sentry/auth/providers/github/client.py @@ -42,7 +42,10 @@ def _request(self, path: str) -> dict[str, Any] | list[dict[str, Any]]: raise GitHubApiError(f"{e}", status=getattr(e, "status_code", 0)) if req.status_code < 200 or req.status_code >= 300: raise GitHubApiError(req.content, status=req.status_code) - return orjson.loads(req.content) + data = orjson.loads(req.content) + if not isinstance(data, dict): + raise GitHubApiError("GitHub returned a non-object response") + return data def get_org_list(self) -> list[dict[str, Any]]: res = self._request("/user/orgs") diff --git a/src/sentry/auth/providers/google/provider.py b/src/sentry/auth/providers/google/provider.py index 4b801921b0cb..ec8df7c32010 100644 --- a/src/sentry/auth/providers/google/provider.py +++ b/src/sentry/auth/providers/google/provider.py @@ -3,6 +3,7 @@ from collections.abc import Callable, Mapping from typing import Any +from django.core.exceptions import ImproperlyConfigured from django.http import HttpRequest from sentry import options @@ -65,10 +66,16 @@ def __init__( super().__init__(**config) def get_client_id(self) -> str: - return options.get("auth-google.client-id") + client_id = options.get("auth-google.client-id") + if not isinstance(client_id, str): + raise ImproperlyConfigured("auth-google.client-id is not configured") + return client_id def get_client_secret(self) -> str: - return options.get("auth-google.client-secret") + client_secret = options.get("auth-google.client-secret") + if not isinstance(client_secret, str): + raise ImproperlyConfigured("auth-google.client-secret is not configured") + return client_secret def get_configure_view( self, diff --git a/src/sentry/auth/providers/oauth2.py b/src/sentry/auth/providers/oauth2.py index 0ea48c835cd6..e58b95f2776a 100644 --- a/src/sentry/auth/providers/oauth2.py +++ b/src/sentry/auth/providers/oauth2.py @@ -124,8 +124,11 @@ def exchange_token( req = safe_urlopen(self.access_token_url, data=data) body = safe_urlread(req) if req.headers["Content-Type"].startswith("application/x-www-form-urlencoded"): - return dict(parse_qsl(body)) - return orjson.loads(body) + return dict(parse_qsl(body.decode("utf-8"))) + data = orjson.loads(body) + if not isinstance(data, Mapping): + raise TypeError("OAuth token response is not an object") + return data def dispatch(self, request: HttpRequest, pipeline: AuthHelper) -> HttpResponseBase: error = request.GET.get("error") diff --git a/src/sentry/auth/services/auth/impl.py b/src/sentry/auth/services/auth/impl.py index b6f5929d33d2..bc1099083776 100644 --- a/src/sentry/auth/services/auth/impl.py +++ b/src/sentry/auth/services/auth/impl.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, cast from django.db import router, transaction from django.db.models import Count, F @@ -244,7 +244,8 @@ def get(self, key: str, default: str | bytes | None = None) -> str | bytes | Non def promote_request_rpc_user(request: Any) -> User: if not hasattr(request, "_promoted_user"): setattr(request, "_promoted_user", User.objects.get(id=request.user.id)) - return request._promoted_user + # justification: the attribute is set to a User above. + return cast("User", request._promoted_user) promote_request_api_user = promote_request_rpc_user diff --git a/src/sentry/auth/staff.py b/src/sentry/auth/staff.py index 3735416f75bc..8c21e9e48710 100644 --- a/src/sentry/auth/staff.py +++ b/src/sentry/auth/staff.py @@ -19,6 +19,7 @@ from sentry.auth.elevated_mode import ElevatedMode, InactiveReason from sentry.auth.system import is_system_auth from sentry.users.models.user import User +from sentry.users.services.user import RpcUser from sentry.utils.auth import has_completed_sso logger = logging.getLogger("sentry.staff") @@ -56,7 +57,7 @@ def is_active_staff(request: HttpRequest | Request) -> bool: # TODO(schew2381): Delete after staff is GA'd and the options are removed -def has_staff_option(user: User | AnonymousUser) -> bool: +def has_staff_option(user: User | RpcUser | AnonymousUser) -> bool: """ This checks two options, the first being whether or not staff has been GA'd. If not, it falls back to checking the second option which by email specifies which @@ -204,7 +205,7 @@ def get_session_data(self, current_datetime: datetime | None = None) -> dict[str ) return None - return data + return data if isinstance(data, dict) else None def _populate(self) -> None: current_datetime = django_timezone.now() diff --git a/src/sentry/auth/system.py b/src/sentry/auth/system.py index 2f4a38c320d6..c454692c025c 100644 --- a/src/sentry/auth/system.py +++ b/src/sentry/auth/system.py @@ -30,6 +30,8 @@ def get_system_token() -> str: if not token: token = secrets.token_hex() options.set("sentry:system-token", token, channel=options.UpdateChannel.APPLICATION) + if not isinstance(token, str): + raise TypeError("sentry:system-token must be a string") return token diff --git a/src/sentry/backup/comparators.py b/src/sentry/backup/comparators.py index 249e1795bf55..39fbad7ff627 100644 --- a/src/sentry/backup/comparators.py +++ b/src/sentry/backup/comparators.py @@ -250,7 +250,7 @@ def __init__(self, foreign_fields: dict[str, type[models.base.Model]]): super().__init__(*(foreign_fields.keys())) self.foreign_fields = foreign_fields - def set_primary_key_maps(self, left_pk_map: PrimaryKeyMap, right_pk_map: PrimaryKeyMap): + def set_primary_key_maps(self, left_pk_map: PrimaryKeyMap, right_pk_map: PrimaryKeyMap) -> None: """Call this function before running the comparator, to ensure that it has access to the latest mapping information for both sides of the comparison.""" self.left_pk_map = left_pk_map @@ -393,7 +393,7 @@ class UserPasswordObfuscatingComparator(ObfuscatingComparator): - If the right side is `is_unclaimed = False`, make sure that the password stays the same. """ - def __init__(self): + def __init__(self) -> None: super().__init__("password") def compare(self, on: InstanceID, left: Any, right: Any) -> list[ComparatorFinding]: @@ -503,7 +503,7 @@ def existence(self, _o: InstanceID, _l: Any, _r: Any) -> list[ComparatorFinding] class RegexComparator(JSONScrubbingComparator, ABC): """Comparator that ensures that both sides match a certain regex.""" - def __init__(self, regex: re.Pattern, *fields: str): + def __init__(self, regex: re.Pattern[str], *fields: str) -> None: self.regex = regex super().__init__(*fields) @@ -743,7 +743,7 @@ class DataSourceComparator(IgnoredComparator): need to verify that both sides have a valid source_id value, without comparing the actual values. """ - def __init__(self): + def __init__(self) -> None: super().__init__("source_id") diff --git a/src/sentry/backup/dependencies.py b/src/sentry/backup/dependencies.py index 9f15035970d8..2ca1d8114178 100644 --- a/src/sentry/backup/dependencies.py +++ b/src/sentry/backup/dependencies.py @@ -4,11 +4,11 @@ from dataclasses import dataclass from enum import Enum, auto, unique from functools import lru_cache -from typing import NamedTuple +from typing import Any, NamedTuple 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 @@ -28,10 +28,10 @@ def __init__(self, model_name: str): raise TypeError("cannot create NormalizedModelName from invalid input string") self.__model_name = model_name.lower() - def __hash__(self): + def __hash__(self) -> int: return hash(self.__model_name) - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: if other is None: return False if not isinstance(other, self.__class__): @@ -40,7 +40,7 @@ def __eq__(self, other) -> bool: ) return self.__model_name == other.__model_name - def __lt__(self, other) -> bool: + def __lt__(self, other: object) -> bool: if not isinstance(other, self.__class__): raise TypeError( "NormalizedModelName can only be compared with other NormalizedModelName" @@ -194,7 +194,9 @@ class DependenciesJSONEncoder(json.JSONEncoder): """JSON serializer that outputs a detailed serialization of all models included in a `ModelRelations`.""" - def default(self, obj): + def default( + self, obj: object + ) -> Any: # justification: JSON encoder fallback returns arbitrary encoded values. if meta := getattr(obj, "_meta", None): return f"{meta.app_label}.{meta.object_name}".lower() if isinstance(obj, ModelRelations): @@ -245,7 +247,7 @@ class PrimaryKeyMap: mapping: dict[str, dict[int, tuple[int, ImportKind, str | None]]] - def __init__(self): + def __init__(self) -> None: self.mapping = defaultdict(dict) def get_pk(self, model_name: NormalizedModelName, old: int) -> int | None: @@ -407,21 +409,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 +447,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/backup/exports.py b/src/sentry/backup/exports.py index 172c4e19b1ca..d27ed679b795 100644 --- a/src/sentry/backup/exports.py +++ b/src/sentry/backup/exports.py @@ -6,7 +6,7 @@ # we still use the one from `sentry.utils`, imported as `sentry_json` below. import json as builtin_json # noqa: S003 from abc import ABC, abstractmethod -from typing import IO +from typing import IO, Any import orjson @@ -114,10 +114,10 @@ def _export( *, encryptor: Encryptor | None = None, indent: int = 2, - filter_by: Filter | None = None, + filter_by: Filter[Any] | None = None, # justification: filters carry integer or string values. printer: Printer, checkpointer: ExportCheckpointer | None = None, -): +) -> None: """ Exports core data for the Sentry installation. @@ -236,7 +236,7 @@ def export_in_user_scope( indent: int = 2, printer: Printer, checkpointer: ExportCheckpointer | None = None, -): +) -> None: """ Perform an export in the `User` scope, meaning that only models with `RelocationScope.User` will be exported from the provided `dest` file. @@ -264,7 +264,7 @@ def export_in_organization_scope( indent: int = 2, printer: Printer, checkpointer: ExportCheckpointer | None = None, -): +) -> None: """ Perform an export in the `Organization` scope, meaning that only models with `RelocationScope.User` or `RelocationScope.Organization` will be exported from the provided @@ -292,7 +292,7 @@ def export_in_config_scope( indent: int = 2, printer: Printer, checkpointer: ExportCheckpointer | None = None, -): +) -> None: """ Perform an export in the `Config` scope, meaning that only models directly related to the global configuration and administration of an entire Sentry instance will be exported. @@ -319,7 +319,7 @@ def export_in_global_scope( indent: int = 2, printer: Printer, checkpointer: ExportCheckpointer | None = None, -): +) -> None: """ Perform an export in the `Global` scope, meaning that all models will be exported from the provided source file. diff --git a/src/sentry/backup/findings.py b/src/sentry/backup/findings.py index 1ac7c8712749..2aa22cc28f66 100644 --- a/src/sentry/backup/findings.py +++ b/src/sentry/backup/findings.py @@ -225,7 +225,9 @@ def pretty(self) -> str: class FindingJSONEncoder(json.JSONEncoder): """JSON serializer that handles findings properly.""" - def default(self, obj): + def default( + self, obj: object + ) -> Any: # justification: JSON encoder fallback returns arbitrary encoded values. if isinstance(obj, Finding): kind = getattr(obj, "kind", None) d = obj.to_dict() diff --git a/src/sentry/backup/helpers.py b/src/sentry/backup/helpers.py index 3521d0214aaa..95844cd5775a 100644 --- a/src/sentry/backup/helpers.py +++ b/src/sentry/backup/helpers.py @@ -2,7 +2,7 @@ from datetime import UTC, datetime from enum import Enum -from typing import Generic, NamedTuple, TypeVar +from typing import Any, Generic, NamedTuple, TypeVar from django.core.serializers.json import DjangoJSONEncoder from django.db import models @@ -45,7 +45,9 @@ class DatetimeSafeDjangoJSONEncoder(DjangoJSONEncoder): `DjangoJSONEncoder` drops these by default. """ - def default(self, obj): + def default( + self, obj: object + ) -> Any: # justification: JSON encoder fallback returns arbitrary encoded values. if isinstance(obj, datetime): return obj.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" return super().default(obj) diff --git a/src/sentry/backup/imports.py b/src/sentry/backup/imports.py index 5580be659690..15ec99862405 100644 --- a/src/sentry/backup/imports.py +++ b/src/sentry/backup/imports.py @@ -4,7 +4,7 @@ from collections.abc import Iterator from dataclasses import dataclass from typing import IO, Any -from uuid import uuid4 +from uuid import UUID, uuid4 import orjson from django.apps import apps @@ -67,7 +67,7 @@ def __init__(self, context: RpcImportError) -> None: self.context = context -def _clear_model_tables_before_import(): +def _clear_model_tables_before_import() -> None: reversed = reversed_dependencies() # Outbox models are cleared last: some models (e.g. ProjectKey) use pre_delete signals @@ -115,9 +115,9 @@ def _import( *, decryptor: Decryptor | None = None, flags: ImportFlags | None = None, - filter_by: Filter | None = None, + filter_by: Filter[Any] | None = None, # justification: filters carry integer or string values. printer: Printer, -): +) -> None: """ Imports core data for a Sentry installation. @@ -281,7 +281,7 @@ def reorder_models_by_dependencies(models: list[dict[str, Any]]) -> list[dict[st # serialized JSON string containing some number of such model instances, and 3. an offset # representing how many instances of this model have already been produced by this generator, # NOT including the current instance. - def yield_json_models(content) -> Iterator[tuple[NormalizedModelName, str, int]]: + def yield_json_models(content: bytes | str) -> Iterator[tuple[NormalizedModelName, str, int]]: # TODO(getsentry#team-ospo/190): Better error handling for unparsable JSON. models = orjson.loads(content) @@ -383,7 +383,9 @@ def do_write( # can't be races here, since there is only one task running at a time, pushing # updates in a synchronous manner. existing_control_import_chunk_replica = ControlImportChunkReplica.objects.filter( - import_uuid=flags.import_uuid, model=model_name_str, min_ordinal=result.min_ordinal + import_uuid=UUID(flags.import_uuid), + model=model_name_str, + min_ordinal=result.min_ordinal, ).first() if existing_control_import_chunk_replica is not None: logger.info("import_by_model.control_replica_already_exists", extra=extra) @@ -404,7 +406,7 @@ def do_write( ] control_import_chunk_replica = ControlImportChunkReplica( - import_uuid=flags.import_uuid, + import_uuid=UUID(flags.import_uuid), model=model_name_str, min_ordinal=result.min_ordinal, max_ordinal=result.max_ordinal, @@ -439,7 +441,7 @@ def do_writes(pk_map: PrimaryKeyMap) -> None: # Resolves slugs for all imported organization models via the PrimaryKeyMap and reconciles # their slug globally via control silo by issuing a slug update. - def resolve_org_slugs_from_pk_map(pk_map: PrimaryKeyMap): + def resolve_org_slugs_from_pk_map(pk_map: PrimaryKeyMap) -> None: from sentry.services.organization import organization_provisioning_service org_pk_mapping = pk_map.mapping[str(org_model_name)] @@ -522,7 +524,7 @@ def import_in_user_scope( flags: ImportFlags | None = None, user_filter: set[str] | None = None, printer: Printer, -): +) -> None: """ Perform an import in the `User` scope, meaning that only models with `RelocationScope.User` will be imported from the provided `src` file. @@ -551,7 +553,7 @@ def import_in_organization_scope( flags: ImportFlags | None = None, org_filter: set[str] | None = None, printer: Printer, -): +) -> None: """ Perform an import in the `Organization` scope, meaning that only models with `RelocationScope.User` or `RelocationScope.Organization` will be imported from the provided @@ -582,7 +584,7 @@ def import_in_config_scope( flags: ImportFlags | None = None, user_filter: set[str] | None = None, printer: Printer, -): +) -> None: """ Perform an import in the `Config` scope, meaning that we will import all models required to globally configure and administrate a Sentry instance from the provided `src` file. This @@ -613,7 +615,7 @@ def import_in_global_scope( decryptor: Decryptor | None = None, flags: ImportFlags | None = None, printer: Printer, -): +) -> None: """ Perform an import in the `Global` scope, meaning that all models will be imported from the provided source file. Because a `Global` import is really only useful when restoring to a fresh diff --git a/src/sentry/backup/mixins.py b/src/sentry/backup/mixins.py index f79199b07ede..2ba49b089fd7 100644 --- a/src/sentry/backup/mixins.py +++ b/src/sentry/backup/mixins.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import cast + from sentry.backup.dependencies import ImportKind, dependencies, get_model_name from sentry.backup.helpers import ImportFlags from sentry.backup.scopes import ImportScope, RelocationScope @@ -49,4 +51,8 @@ def write_relocation_import( return (self.pk, ImportKind.Overwrite) # Does not have a single colliding unique field - write as usual. - return super().write_relocation_import(scope, flags) # type: ignore[misc] + # justification: the base relocation mixin returns the imported pk and kind. + return cast( + "tuple[int, ImportKind] | None", + super().write_relocation_import(scope, flags), # type: ignore[misc] + ) diff --git a/src/sentry/backup/sanitize.py b/src/sentry/backup/sanitize.py index d84f1d44c5a7..73d2fb1f70e4 100644 --- a/src/sentry/backup/sanitize.py +++ b/src/sentry/backup/sanitize.py @@ -5,7 +5,7 @@ from datetime import UTC, datetime, timedelta, timezone from ipaddress import IPv4Address, IPv6Address, ip_address from random import choice, randint -from typing import Any +from typing import Any, TypeVar from urllib.parse import urlparse, urlunparse from uuid import UUID, uuid4 @@ -109,7 +109,10 @@ def _get_field_value(json: Any, field: SanitizableField) -> Any | None: return json.get("fields", {}).get(field.field, None) -def _set_field_value(json: Any, field: SanitizableField, value: Any) -> Any: +_T = TypeVar("_T") + + +def _set_field_value(json: Any, field: SanitizableField, value: _T) -> _T: json.get("fields", {})[field.field] = value return value diff --git a/src/sentry/backup/services/import_export/impl.py b/src/sentry/backup/services/import_export/impl.py index 9f7201cd8114..0c832187cc63 100644 --- a/src/sentry/backup/services/import_export/impl.py +++ b/src/sentry/backup/services/import_export/impl.py @@ -5,6 +5,8 @@ import logging import traceback +from collections.abc import Iterator +from typing import Any import psycopg2.errors import sentry_sdk @@ -158,7 +160,7 @@ def import_by_model( import_scope = scope.from_rpc() in_pk_map = pk_map.from_rpc() - filters: list[Filter] = [] + filters: list[Filter[Any]] = [] for fb in filter_by: if NormalizedModelName(fb.on_model) == batch_model_name: filters.append(fb.from_rpc()) @@ -487,12 +489,14 @@ def export_by_model( max_pk = from_pk out_pk_map = PrimaryKeyMap() - filters: list[Filter] = [] + filters: list[Filter[Any]] = [] for fb in filter_by: if NormalizedModelName(fb.on_model) == batch_model_name: filters.append(fb.from_rpc()) - def filter_objects(queryset_iterator): + def filter_objects( + queryset_iterator: Iterator[Any], + ) -> Iterator[Any]: # justification: relocatable models use dynamic mixins. # Intercept each value from the queryset iterator, ensure that it has the correct # relocation scope and that all of its dependencies have already been exported. If # they have, store it in the `pk_map`, and then yield it again. If they have not, we @@ -545,7 +549,9 @@ def filter_objects(queryset_iterator): item.normalize_before_relocation_export() yield item - def yield_objects(): + def yield_objects() -> Iterator[ + Any + ]: # justification: relocatable models use dynamic mixins. q = Q(pk__gt=from_pk) # Only do database query filtering if this is a non-global export. If it is a diff --git a/src/sentry/backup/services/import_export/model.py b/src/sentry/backup/services/import_export/model.py index 40604e3a2fb9..c2bce1f15e14 100644 --- a/src/sentry/backup/services/import_export/model.py +++ b/src/sentry/backup/services/import_export/model.py @@ -36,7 +36,7 @@ class RpcFilter(RpcModel): # TODO(getsentry/team-ospo#190): Unify with the base filter type. values: set[StrictStr | StrictInt] - def from_rpc(self) -> Filter: + def from_rpc(self) -> Filter[Any]: model = get_model(NormalizedModelName(self.on_model)) if model is None: raise ValueError("model not found") @@ -44,7 +44,7 @@ def from_rpc(self) -> Filter: return Filter(model, self.field, self.values) @classmethod - def into_rpc(cls, base_filter: Filter) -> "RpcFilter": + def into_rpc(cls, base_filter: Filter[Any]) -> "RpcFilter": return cls( on_model=str(get_model_name(base_filter.model)), field=base_filter.field, diff --git a/src/sentry/backup/services/import_export/service.py b/src/sentry/backup/services/import_export/service.py index 6fc4421d2cce..bcea950bd3ed 100644 --- a/src/sentry/backup/services/import_export/service.py +++ b/src/sentry/backup/services/import_export/service.py @@ -5,6 +5,7 @@ from abc import abstractmethod from collections.abc import Callable +from typing import Any, cast from django.db.models.base import Model @@ -71,7 +72,9 @@ def import_by_model( @staticmethod def get_importer_for_model( model: type[Model], - ) -> Callable: + ) -> Callable[ + ..., Any + ]: # justification: importer implementations have heterogeneous call shapes. """ Called should resolve their implementation of `export_by_model` by calling this method first, rather than calling `export_by_model` directly. See this class' comment for more @@ -80,7 +83,11 @@ def get_importer_for_model( if SiloMode.CONTROL in model._meta.silo_limit.modes: # type: ignore[attr-defined] return import_export_service.import_by_model - return ImportExportService.get_local_implementation().import_by_model # type: ignore[attr-defined] + # justification: the local implementation provides the import_by_model RPC method. + return cast( + "Callable[..., Any]", + ImportExportService.get_local_implementation().import_by_model, # type: ignore[attr-defined] + ) @rpc_method @abstractmethod @@ -103,7 +110,9 @@ def export_by_model( @staticmethod def get_exporter_for_model( model: type[Model], - ) -> Callable: + ) -> Callable[ + ..., Any + ]: # justification: importer implementations have heterogeneous call shapes. """ Called should resolve their implementation of `export_by_model` by calling this method first, rather than calling `export_by_model` directly. See this class' comment for more @@ -112,7 +121,11 @@ def get_exporter_for_model( if SiloMode.CONTROL in model._meta.silo_limit.modes: # type: ignore[attr-defined] return import_export_service.export_by_model - return ImportExportService.get_local_implementation().export_by_model # type: ignore[attr-defined] + # justification: the local implementation provides the export_by_model RPC method. + return cast( + "Callable[..., Any]", + ImportExportService.get_local_implementation().export_by_model, # type: ignore[attr-defined] + ) @rpc_method @abstractmethod diff --git a/src/sentry/backup/validate.py b/src/sentry/backup/validate.py index 9a5d68b0fb5f..86ce79147b9c 100644 --- a/src/sentry/backup/validate.py +++ b/src/sentry/backup/validate.py @@ -35,15 +35,15 @@ class OrdinalCounter: """Keeps track of the next ordinal to be assigned for a given model kind.""" # The `value` being tracked is either the custom ordering tuple for this model (see: `BaseModel::get_relocation_ordinal_fields()` method), or otherwise just the pk. - max_seen_ordinal_value: int | tuple | None + max_seen_ordinal_value: int | tuple[Any, ...] | None next_ordinal: int - def __init__(self): + def __init__(self) -> None: self.max_seen_ordinal_value = None self.next_ordinal = 1 def assign( - self, obj: Any, ordinal_value: int | tuple, side: Side + self, obj: Any, ordinal_value: int | tuple[Any, ...], side: Side ) -> tuple[InstanceID, list[ComparatorFinding]]: """Assigns the next available ordinal to the supplied `obj` model.""" @@ -87,7 +87,7 @@ def build_model_map( model_map: ModelMap = defaultdict(ordereddict) ordinal_counters: OrdinalCounters = defaultdict(OrdinalCounter) - need_ordering: dict[NormalizedModelName, dict[tuple, Any]] = defaultdict(dict) + need_ordering: dict[NormalizedModelName, dict[tuple[Any, ...], Any]] = defaultdict(dict) pks_to_usernames: dict[int, str] = dict() for model in models: diff --git a/src/sentry/billing/platform/services/quota/quota_config_mapping.py b/src/sentry/billing/platform/services/quota/quota_config_mapping.py index e9306c429109..caa6770fa9f5 100644 --- a/src/sentry/billing/platform/services/quota/quota_config_mapping.py +++ b/src/sentry/billing/platform/services/quota/quota_config_mapping.py @@ -17,23 +17,23 @@ def sentry_to_proto_quota_config(quota: QuotaConfig) -> ProtoQuotaConfig: """Convert a sentry QuotaConfig to its proto equivalent.""" - kwargs: dict = { - "id": quota.id or "", - "categories": sorted(sentry_to_proto_category(int(c)) for c in quota.categories), - "scope": ProtoQuotaScope.ValueType(int(quota.scope)) + proto_quota = ProtoQuotaConfig( + id=quota.id or "", + categories=sorted(sentry_to_proto_category(int(c)) for c in quota.categories), + scope=ProtoQuotaScope.ValueType(int(quota.scope)) if quota.scope is not None else ProtoQuotaScope.QUOTA_SCOPE_UNSPECIFIED, - } + ) if quota.scope_id is not None: - kwargs["scope_id"] = quota.scope_id + proto_quota.scope_id = quota.scope_id if quota.limit is not None: - kwargs["limit"] = quota.limit + proto_quota.limit = quota.limit if quota.window is not None: - kwargs["window"] = quota.window + proto_quota.window = quota.window if quota.reason_code is not None: - kwargs["reason_code"] = quota.reason_code + proto_quota.reason_code = quota.reason_code - return ProtoQuotaConfig(**kwargs) + return proto_quota def proto_to_sentry_quota_config(proto_quota: ProtoQuotaConfig) -> QuotaConfig | None: diff --git a/src/sentry/billing/platform/services/usage/_outcomes_query.py b/src/sentry/billing/platform/services/usage/_outcomes_query.py index 85ef5a5c8e24..e907e4bd250f 100644 --- a/src/sentry/billing/platform/services/usage/_outcomes_query.py +++ b/src/sentry/billing/platform/services/usage/_outcomes_query.py @@ -4,6 +4,7 @@ from collections import defaultdict from collections.abc import Sequence from datetime import datetime, timedelta, timezone +from typing import TypedDict from google.protobuf.timestamp_pb2 import Timestamp from sentry_protos.billing.v1.data_category_pb2 import DataCategory as ProtoDataCategory @@ -52,6 +53,19 @@ _BILLABLE_OUTCOMES = [Outcome.ACCEPTED, Outcome.FILTERED, Outcome.RATE_LIMITED] +class _OutcomeRow(TypedDict): + category: int + time: str + max_ts: str + total: int + accepted: int + dropped: int + filtered: int + over_quota: int + spike_protection: int + dynamic_sampling: int + + def query_outcomes_usage(request: GetUsageRequest) -> GetUsageResponse: org_id = request.organization_id start = _timestamp_to_datetime(request.start) @@ -78,7 +92,7 @@ def query_outcomes_usage(request: GetUsageRequest) -> GetUsageResponse: return _build_response(rows, _latest_usage_timestamp(rows)) -def _latest_usage_timestamp(rows: list[dict]) -> datetime | None: +def _latest_usage_timestamp(rows: Sequence[_OutcomeRow]) -> datetime | None: last_usage_ts: datetime | None = None for row in rows: row_max = row.get("max_ts") @@ -139,7 +153,7 @@ def _build_query( ) -def _build_response(rows: list[dict], last_usage_ts: datetime | None) -> GetUsageResponse: +def _build_response(rows: list[_OutcomeRow], last_usage_ts: datetime | None) -> GetUsageResponse: # Two-level accumulator: days_map[day_str][category_id] -> category usage. # Each row already contains all 7 sumIf-aggregated fields from ClickHouse. # @@ -220,7 +234,7 @@ def _usage_aggregates(total_outcomes: Sequence[int] | None) -> list[Function]: ] -def _category_usage_from_row(row: dict) -> CategoryUsage | None: +def _category_usage_from_row(row: _OutcomeRow) -> CategoryUsage | None: category = sentry_to_proto_category(int(row["category"])) if category == ProtoDataCategory.DATA_CATEGORY_UNKNOWN: # Sentry category has no proto mapping; skipping prevents it diff --git a/src/sentry/billing/platform/services/usage/_project_outcomes_query.py b/src/sentry/billing/platform/services/usage/_project_outcomes_query.py index 58a6cbbc3ce2..ba8807d860df 100644 --- a/src/sentry/billing/platform/services/usage/_project_outcomes_query.py +++ b/src/sentry/billing/platform/services/usage/_project_outcomes_query.py @@ -21,6 +21,7 @@ _category_usage_from_row, _inclusive_end_to_exclusive, _latest_usage_timestamp, + _OutcomeRow, _parse_day, _timestamp_to_datetime, ) @@ -33,6 +34,10 @@ _REFERRER = Referrer.BILLING_USAGE_SERVICE_CLICKHOUSE.value +class _ProjectOutcomeRow(_OutcomeRow): + project_id: int + + class ProjectUsageQueryTruncatedError(RuntimeError): """Raised when ClickHouse may have omitted project usage rows.""" @@ -82,7 +87,7 @@ def _build_project_query( def _build_project_response( - rows: list[dict], last_usage_ts: datetime | None + rows: list[_ProjectOutcomeRow], last_usage_ts: datetime | None ) -> GetUsageByProjectResponse: projects: defaultdict[int, defaultdict[str, dict[int, CategoryUsage]]] = defaultdict( lambda: defaultdict(dict) diff --git a/src/sentry/buffer/redis.py b/src/sentry/buffer/redis.py index c3b3969d6829..06310857f711 100644 --- a/src/sentry/buffer/redis.py +++ b/src/sentry/buffer/redis.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import date, datetime, timezone from time import time -from typing import Any, TypeVar +from typing import Any, TypeVar, cast import rb from django.utils.encoding import force_bytes, force_str @@ -321,7 +321,8 @@ def _load_value( if type_ == "n": return None elif type_ == "s": - return force_str(value) + # justification: force_str returns a str for the decoded Any value. + return cast("str", force_str(value)) elif type_ == "dt": return datetime.fromtimestamp(float(value)).replace(tzinfo=timezone.utc) elif type_ == "d": diff --git a/src/sentry/build/_download.py b/src/sentry/build/_download.py index 932740813bbb..a250dc543658 100644 --- a/src/sentry/build/_download.py +++ b/src/sentry/build/_download.py @@ -2,7 +2,7 @@ import time import urllib.request -from typing import IO +from typing import IO, cast def urlopen_with_retries(url: str, timeout: int = 5, retries: int = 6) -> IO[bytes]: @@ -14,7 +14,8 @@ def urlopen_with_retries(url: str, timeout: int = 5, retries: int = 6) -> IO[byt """ for i in range(retries): try: - return urllib.request.urlopen(url, timeout=timeout) + # justification: urlopen returns a binary, read-only response body. + return cast("IO[bytes]", urllib.request.urlopen(url, timeout=timeout)) except Exception: if i == retries - 1: raise diff --git a/src/sentry/cache/backends/reconnectingmemcache.py b/src/sentry/cache/backends/reconnectingmemcache.py index b2cf3f1fe6b9..2d091e984529 100644 --- a/src/sentry/cache/backends/reconnectingmemcache.py +++ b/src/sentry/cache/backends/reconnectingmemcache.py @@ -1,7 +1,7 @@ import contextvars import threading import time -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import NamedTuple import pymemcache @@ -37,8 +37,10 @@ class ReconnectingMemcache(PyMemcacheCache): _class: pymemcache.Client _options: Mapping[str, str] | None = None - def __init__(self, server, params) -> None: - self._reconnect_age: int = params.get("OPTIONS", {}).pop("reconnect_age", 300) + def __init__(self, server: str | Sequence[str], params: dict[str, object]) -> None: + options = params.get("OPTIONS", {}) + assert isinstance(options, dict) + self._reconnect_age: int = options.pop("reconnect_age", 300) self._backend_var: contextvars.ContextVar[_BackendState | None] = contextvars.ContextVar( f"reconnecting_memcache_{id(self)}", default=None ) diff --git a/src/sentry/cache/base.py b/src/sentry/cache/base.py index 71ac1145e042..b4c76d687d61 100644 --- a/src/sentry/cache/base.py +++ b/src/sentry/cache/base.py @@ -20,26 +20,38 @@ def unwrap_key(prefix: str, version: Any, value: str) -> str: class BaseCache(local): prefix = "c" - def __init__(self, version=None, prefix=None, is_default_cache=False): + def __init__( + self, + version: int | None = None, + prefix: str | None = None, + is_default_cache: bool = False, + ) -> None: self.version = version or settings.CACHE_VERSION if prefix is not None: self.prefix = prefix self.is_default_cache = is_default_cache - def make_key(self, key, version=None) -> str: + def make_key(self, key: str, version: int | None = None) -> str: return wrap_key(self.prefix, version or self.version, key) - def set(self, key, value, timeout, version=None, raw=False): + def set( + self, + key: str, + value: object, + timeout: int | float | None, + version: int | None = None, + raw: bool = False, + ) -> None: raise NotImplementedError - def delete(self, key, version=None): + def delete(self, key: str, version: int | None = None) -> None: raise NotImplementedError - def get(self, key, version=None, raw=False): + def get(self, key: str, version: int | None = None, raw: bool = False) -> object: raise NotImplementedError - def _mark_transaction(self, op): + def _mark_transaction(self, op: str) -> None: """ Mark transaction with a tag so we can identify system components that rely on the default cache as potential SPOF. diff --git a/src/sentry/cache/django.py b/src/sentry/cache/django.py index 1053ba67322c..38d1321ed907 100644 --- a/src/sentry/cache/django.py +++ b/src/sentry/cache/django.py @@ -4,15 +4,22 @@ class DjangoCache(BaseCache): - def set(self, key, value, timeout, version=None, raw=False): + def set( + self, + key: str, + value: object, + timeout: int | float | None, + version: int | None = None, + raw: bool = False, + ) -> None: cache.set(key, value, timeout, version=version or self.version) self._mark_transaction("set") - def delete(self, key, version=None): + def delete(self, key: str, version: int | None = None) -> None: cache.delete(key, version=version or self.version) self._mark_transaction("delete") - def get(self, key, version=None, raw=False): + def get(self, key: str, version: int | None = None, raw: bool = False) -> object: result = cache.get(key, version=version or self.version) self._mark_transaction("get") return result diff --git a/src/sentry/cache/redis.py b/src/sentry/cache/redis.py index 870c5be9245d..89c373e7bad1 100644 --- a/src/sentry/cache/redis.py +++ b/src/sentry/cache/redis.py @@ -1,3 +1,10 @@ +from __future__ import annotations + +from typing import cast + +from redis.client import StrictRedis +from sentry_redis_tools.clients import RedisCluster + from sentry.exceptions import MissingTTL from sentry.utils import json from sentry.utils.redis import redis_clusters @@ -13,24 +20,40 @@ class CommonRedisCache(BaseCache): key_expire = 60 * 60 # 1 hour max_size = 50 * 1024 * 1024 # 50MB - def __init__(self, client, raw_client, **options): + def __init__( + self, + client: RedisCluster[str] | StrictRedis[str], + raw_client: RedisCluster[bytes] | StrictRedis[bytes], + version: int | None = None, + prefix: str | None = None, + is_default_cache: bool = False, + ) -> None: self._text_client = client self._bytes_client = raw_client - super().__init__(**options) + super().__init__(version=version, prefix=prefix, is_default_cache=is_default_cache) - def _client(self, *, raw: bool): + def _client( + self, *, raw: bool + ) -> RedisCluster[str] | StrictRedis[str] | RedisCluster[bytes] | StrictRedis[bytes]: if raw: return self._bytes_client else: return self._text_client - def set(self, key, value, timeout, version=None, raw=False): + def set( + self, + key: str, + value: object, + timeout: int | float | None, + version: int | None = None, + raw: bool = False, + ) -> None: key = self.make_key(key, version=version) if not timeout: raise MissingTTL(key) if raw: - v = value + v = cast(str | bytes, value) else: v = json.dumps(value) @@ -41,13 +64,13 @@ def set(self, key, value, timeout, version=None, raw=False): self._mark_transaction("set") - def delete(self, key, version=None): + def delete(self, key: str, version: int | None = None) -> None: key = self.make_key(key, version=version) self._client(raw=False).delete(key) self._mark_transaction("delete") - def get(self, key, version=None, raw=False): + def get(self, key: str, version: int | None = None, raw: bool = False) -> object: key = self.make_key(key, version=version) result = self._client(raw=raw).get(key) if result is not None and not raw: @@ -59,10 +82,22 @@ def get(self, key, version=None, raw=False): class RedisClusterCache(CommonRedisCache): - def __init__(self, cluster_id: str = "default", **options: object) -> None: + def __init__( + self, + cluster_id: str = "default", + version: int | None = None, + prefix: str | None = None, + is_default_cache: bool = False, + ) -> None: client = redis_clusters.get(cluster_id) raw_client = redis_clusters.get_binary(cluster_id) - super().__init__(client=client, raw_client=raw_client, **options) + super().__init__( + client=client, + raw_client=raw_client, + version=version, + prefix=prefix, + is_default_cache=is_default_cache, + ) # Deploy configs refer to both names by string. diff --git a/src/sentry/charts/chartcuterie.py b/src/sentry/charts/chartcuterie.py index 6c522a8338c5..cc4912b288c9 100644 --- a/src/sentry/charts/chartcuterie.py +++ b/src/sentry/charts/chartcuterie.py @@ -27,7 +27,11 @@ class Chartcuterie(ChartRenderer): @property def service_url(self) -> str | None: - return options.get("chart-rendering.chartcuterie", {}).get("url") + config = options.get("chart-rendering.chartcuterie", {}) + if not isinstance(config, dict): + return None + url = config.get("url") + return url if isinstance(url, str) else None @property def storage_options(self) -> dict[str, Any] | None: diff --git a/src/sentry/conf/locale.py b/src/sentry/conf/locale.py index c9fb4ff78a84..fd4658895cdc 100644 --- a/src/sentry/conf/locale.py +++ b/src/sentry/conf/locale.py @@ -6,7 +6,7 @@ # change locale file dir name to locale code -def dirname_to_local(dir_name): +def dirname_to_local(dir_name: str) -> str: if "_" in dir_name: pre, post = dir_name.split("_", 1) dir_name = f"{pre}-{post.lower()}" diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index bc8ccde3a6f0..ac2e3e16d75b 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -11,7 +11,7 @@ import sys from collections.abc import Callable, Mapping, MutableSequence from datetime import datetime, timedelta -from typing import Any, Final, Literal, Union, overload +from typing import Any, Final, Literal, NotRequired, TypedDict, Union, overload from urllib.parse import urlparse import orjson @@ -41,7 +41,7 @@ def gettext_noop(s: str) -> str: socket.setdefaulttimeout(5) -_EnvTypes = Union[str, float, int, list, dict] +_EnvTypes = Union[str, float, int, list[object], dict[str, object]] @overload @@ -49,13 +49,15 @@ def env(key: str) -> str: ... @overload -def env(key: str, default: _EnvTypes, type: env_types.Type | None = None) -> _EnvTypes: ... +def env( + key: str, default: _EnvTypes, type: env_types.Type[_EnvTypes] | None = None +) -> _EnvTypes: ... def env( key: str, default: str | _EnvTypes = "", - type: env_types.Type | None = None, + type: env_types.Type[_EnvTypes] | None = None, ) -> _EnvTypes: """ Extract an environment variable for use in configuration @@ -1449,7 +1451,10 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: } -def custom_parameter_sort(parameter: dict) -> tuple[str, int]: +_OperationParameter = TypedDict("_OperationParameter", {"in": str, "required": NotRequired[bool]}) + + +def custom_parameter_sort(parameter: _OperationParameter) -> tuple[str, int]: """ Sort parameters by type then if the parameter is required or not. It should group path parameters first, then query parameters. @@ -3378,7 +3383,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: SEER_AUTOFIX_GITHUB_APP_USER_ID = 157164994 SENTRY_GITHUB_APP_USER_ID = 39604003 -SEER_AUTOFIX_FORCE_USE_REPOS: list[dict] = [] +SEER_AUTOFIX_FORCE_USE_REPOS: list[dict[str, object]] = [] # For encrypting the access token for the GHE integration SEER_GHE_ENCRYPT_KEY: str | None = os.getenv("SEER_GHE_ENCRYPT_KEY") diff --git a/src/sentry/conf/types/kafka_definition.py b/src/sentry/conf/types/kafka_definition.py index 2b06cf19328f..631fcfd20374 100644 --- a/src/sentry/conf/types/kafka_definition.py +++ b/src/sentry/conf/types/kafka_definition.py @@ -113,7 +113,7 @@ def validate_consumer_definition(consumer_definition: ConsumerDefinition) -> Non ) -def get_topic_codec(topic: Topic) -> Codec: +def get_topic_codec(topic: Topic) -> Codec[Any]: # justification: codecs differ per topic. """ Like sentry_kafka_schemas.get_codec, but only accepts a Topic enum """ diff --git a/src/sentry/consumers/__init__.py b/src/sentry/consumers/__init__.py index e65fe1ea7d71..a4a6be60b1a7 100644 --- a/src/sentry/consumers/__init__.py +++ b/src/sentry/consumers/__init__.py @@ -8,12 +8,13 @@ import click from arroyo.backends.abstract import Consumer from arroyo.backends.kafka.configuration import build_kafka_consumer_configuration -from arroyo.backends.kafka.consumer import KafkaConsumer +from arroyo.backends.kafka.consumer import KafkaConsumer, KafkaPayload from arroyo.commit import ONCE_PER_SECOND from arroyo.dlq import DlqPolicy from arroyo.processing.processor import StreamProcessor from arroyo.processing.strategies import Healthcheck from arroyo.processing.strategies.abstract import ProcessingStrategy, ProcessingStrategyFactory +from arroyo.types import Commit, Partition from arroyo.types import Topic as ArroyoTopic from django.conf import settings @@ -100,7 +101,7 @@ def apply_processor_args_overrides( return base_args -def convert_max_batch_time(ctx, param, value): +def convert_max_batch_time(ctx: click.Context, param: click.Parameter, value: int) -> int: if value <= 0: raise click.BadParameter("--max-batch-time must be greater than 0") @@ -491,7 +492,7 @@ def get_stream_processor( add_global_tags: bool = False, profile_consumer_join: bool = False, arroyo_args: Sequence[str] | None = None, -) -> StreamProcessor: +) -> StreamProcessor[KafkaPayload]: from sentry.utils import kafka_config try: @@ -535,7 +536,9 @@ def get_stream_processor( **extra_kwargs, ) - def build_consumer_config(group_id: str, topic: Topic | None = consumer_topic): + def build_consumer_config( + group_id: str, topic: Topic | None = consumer_topic + ) -> dict[str, object]: consumer_config = build_kafka_consumer_configuration( kafka_config.get_kafka_consumer_cluster_options( cluster_from_config, @@ -561,7 +564,7 @@ def build_consumer_config(group_id: str, topic: Topic | None = consumer_topic): return consumer_config - consumer: Consumer = KafkaConsumer(build_consumer_config(group_id)) + consumer: Consumer[KafkaPayload] = KafkaConsumer(build_consumer_config(group_id)) if synchronize_commit_group is None: synchronize_commit_group = consumer_definition.get("synchronize_commit_group_default") @@ -667,35 +670,44 @@ def build_consumer_config(group_id: str, topic: Topic | None = consumer_topic): return StreamProcessor(**processor_args) -class ValidateSchemaStrategyFactoryWrapper(ProcessingStrategyFactory): +class ValidateSchemaStrategyFactoryWrapper(ProcessingStrategyFactory[KafkaPayload]): """ This wrapper is used to validate the schema of the event before passing to the rest of the pipeline. Since the message is currently decoded twice, it should only be run in dev or on a small fraction of prod data. """ - def __init__(self, topic: str, enforce_schema: bool, inner: ProcessingStrategyFactory) -> None: + def __init__( + self, + topic: str, + enforce_schema: bool, + inner: ProcessingStrategyFactory[KafkaPayload], + ) -> None: self.topic = topic self.enforce_schema = enforce_schema self.inner = inner - def create_with_partitions(self, commit, partitions) -> ProcessingStrategy: + def create_with_partitions( + self, commit: Commit, partitions: Mapping[Partition, int] + ) -> ProcessingStrategy[KafkaPayload]: rv = self.inner.create_with_partitions(commit, partitions) return ValidateSchema(self.topic, self.enforce_schema, rv) -class MinPartitionMetricTagWrapper(ProcessingStrategyFactory): +class MinPartitionMetricTagWrapper(ProcessingStrategyFactory[KafkaPayload]): """ A wrapper that tracks the minimum partition index being processed by the consumer and adds it as a global metric tag. This helps with monitoring partition distribution and debugging partition-specific issues. """ - def __init__(self, inner: ProcessingStrategyFactory): + def __init__(self, inner: ProcessingStrategyFactory[KafkaPayload]) -> None: self.inner = inner - def create_with_partitions(self, commit, partitions): + def create_with_partitions( + self, commit: Commit, partitions: Mapping[Partition, int] + ) -> ProcessingStrategy[KafkaPayload]: from sentry.metrics.middleware import add_global_tags # Update the min_partition global tag based on current partition assignment @@ -706,20 +718,26 @@ def create_with_partitions(self, commit, partitions): return self.inner.create_with_partitions(commit, partitions) -class HealthcheckStrategyFactoryWrapper(ProcessingStrategyFactory): - def __init__(self, healthcheck_file_path: str, inner: ProcessingStrategyFactory): +class HealthcheckStrategyFactoryWrapper(ProcessingStrategyFactory[KafkaPayload]): + def __init__( + self, healthcheck_file_path: str, inner: ProcessingStrategyFactory[KafkaPayload] + ) -> None: self.healthcheck_file_path = healthcheck_file_path self.inner = inner - def create_with_partitions(self, commit, partitions): + def create_with_partitions( + self, commit: Commit, partitions: Mapping[Partition, int] + ) -> ProcessingStrategy[KafkaPayload]: rv = self.inner.create_with_partitions(commit, partitions) return Healthcheck(self.healthcheck_file_path, rv) -class JoinProfilerStrategyFactoryWrapper(ProcessingStrategyFactory): - def __init__(self, inner: ProcessingStrategyFactory): +class JoinProfilerStrategyFactoryWrapper(ProcessingStrategyFactory[KafkaPayload]): + def __init__(self, inner: ProcessingStrategyFactory[KafkaPayload]) -> None: self.inner = inner - def create_with_partitions(self, commit, partitions): + def create_with_partitions( + self, commit: Commit, partitions: Mapping[Partition, int] + ) -> ProcessingStrategy[KafkaPayload]: rv = self.inner.create_with_partitions(commit, partitions) return JoinProfiler(rv) diff --git a/src/sentry/consumers/profiler.py b/src/sentry/consumers/profiler.py index d8cf414f1e84..c73cdb95b803 100644 --- a/src/sentry/consumers/profiler.py +++ b/src/sentry/consumers/profiler.py @@ -18,7 +18,7 @@ class JoinProfiler(ProcessingStrategy[TStrategyPayload]): def __init__(self, next_step: ProcessingStrategy[TStrategyPayload]) -> None: self.__next_step = next_step - def join(self, timeout: float | None = None): + def join(self, timeout: float | None = None) -> None: traces.new_trace() propagation_context = sentry_sdk.get_current_scope().get_active_propagation_context() prev_sampling_context = propagation_context.custom_sampling_context diff --git a/src/sentry/core/endpoints/organization_auditlogs.py b/src/sentry/core/endpoints/organization_auditlogs.py index becbcb3a1605..fa5eb7a38795 100644 --- a/src/sentry/core/endpoints/organization_auditlogs.py +++ b/src/sentry/core/endpoints/organization_auditlogs.py @@ -27,7 +27,7 @@ class AuditLogQueryParamSerializer(serializers.Serializer): end = serializers.DateTimeField(required=False) statsPeriod = serializers.CharField(required=False) - def validate_event(self, event): + def validate_event(self, event: str) -> int | None: try: return audit_log.get_event_id_from_api_name(event) except AuditLogEventNotRegistered: 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..8f0fac667775 100644 --- a/src/sentry/core/endpoints/organization_details.py +++ b/src/sentry/core/endpoints/organization_details.py @@ -3,7 +3,7 @@ import logging from copy import copy from datetime import datetime, timedelta, timezone -from typing import TypedDict +from typing import TypedDict, cast from urllib.parse import urlsplit from django.db import models, router, transaction @@ -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,15 +1364,15 @@ 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): +def update_tracked_data(model: models.Model) -> None: "Updates a local copy of attributes values" - if model.id: - data = {} + if model.pk: + data: dict[str, object] = {} for f in model._meta.fields: # XXX(dcramer): this is how Django determines this (copypasta from Model) if isinstance(type(f).__dict__.get(f.attname), DeferredAttribute) or f.column is None: @@ -1378,9 +1386,9 @@ def update_tracked_data(model): if isinstance(v, BitHandler): v = copy(v) data[f.column] = v - model.__data = data + setattr(model, "__data", data) else: - model.__data = UNSAVED + setattr(model, "__data", UNSAVED) class DeleteConfirmationArgs(TypedDict): @@ -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"] @@ -1425,30 +1433,34 @@ def send_delete_confirmation(delete_confirmation_args: DeleteConfirmationArgs): message.send_async([o.email for o in owners]) -def get_field_value(model, field): - if isinstance(type(field).__dict__.get(field.attname), DeferredAttribute): +def get_field_value(model: models.Model, field: object) -> object: + attname = getattr(field, "attname") + if isinstance(type(field).__dict__.get(attname), DeferredAttribute): return DEFERRED if isinstance(field, models.ForeignKey): return getattr(model, field.column, None) - return getattr(model, field.attname, None) + return getattr(model, attname, None) -def has_changed(model, field_name): +def has_changed(model: models.Model, field_name: str) -> bool: "Returns ``True`` if ``field`` has changed since initialization." - if model.__data is UNSAVED: + tracked = getattr(model, "__data") + if tracked is UNSAVED: return False field = model._meta.get_field(field_name) value = get_field_value(model, field) if value is DEFERRED: return False - return model.__data.get(field_name) != value + return bool(tracked.get(field_name) != value) -def old_value(model, field_name): +def old_value(model: models.Model, field_name: str) -> object | None: "Returns the previous value of ``field``" - if model.__data is UNSAVED: + tracked = getattr(model, "__data") + if tracked is UNSAVED: return None - value = model.__data.get(field_name) + value = tracked.get(field_name) if value is DEFERRED: return None - return model.__data.get(field_name) + # justification: tracked option values are arbitrary decoded values. + return cast("object | None", value) diff --git a/src/sentry/core/endpoints/organization_environments.py b/src/sentry/core/endpoints/organization_environments.py index 5a563bbdc1bc..079a2d5556ad 100644 --- a/src/sentry/core/endpoints/organization_environments.py +++ b/src/sentry/core/endpoints/organization_environments.py @@ -57,9 +57,9 @@ def get( project__in=self.get_projects(request, organization) ) add_visibility_filters = environment_visibility_filter_options[visibility] - environment_projects = add_visibility_filters(environment_projects).values("environment") + environment_ids = add_visibility_filters(environment_projects).values("environment") queryset = ( - Environment.objects.filter(id__in=environment_projects) + Environment.objects.filter(id__in=environment_ids) .exclude( # HACK(mattrobenolt): We don't want to surface the # "No Environment" environment to the UI since it diff --git a/src/sentry/core/endpoints/organization_index.py b/src/sentry/core/endpoints/organization_index.py index e040902b2fff..1a3f6969158a 100644 --- a/src/sentry/core/endpoints/organization_index.py +++ b/src/sentry/core/endpoints/organization_index.py @@ -1,4 +1,5 @@ import logging +from typing import Any, cast from django.conf import settings from django.db import IntegrityError @@ -78,14 +79,14 @@ class OrganizationPostSerializer(BaseOrganizationSerializer): idempotencyKey = serializers.CharField(max_length=IDEMPOTENCY_KEY_LENGTH, required=False) dataStorageLocation = serializers.CharField(required=False) - def __init__(self, *args, **kwargs): + def __init__(self, *args: object, **kwargs: Any) -> None: super().__init__(*args, **kwargs) if not (settings.TERMS_URL and settings.PRIVACY_URL): del self.fields["agreeTerms"] self.fields["slug"].required = False self.fields["name"].required = True - def validate_agreeTerms(self, value): + def validate_agreeTerms(self, value: bool) -> bool: if not value: raise serializers.ValidationError("This attribute is required.") return value @@ -112,13 +113,13 @@ def validate_dataStorageLocation(self, value: str) -> str: raise serializers.ValidationError(f"Unknown data storage location {value!r}.") return value - def validate(self, attrs): + def validate(self, attrs: dict[str, object]) -> dict[str, object]: attrs = super().validate(attrs) locality_name = attrs.get("dataStorageLocation") if locality_name: - attrs["cell_name"] = resolve_provisioning_cell(locality_name) + attrs["cell_name"] = resolve_provisioning_cell(cast(str, locality_name)) else: # TODO(cells) Remove this when cell silo compatibility is removed. attrs["cell_name"] = settings.SENTRY_LOCAL_CELL or settings.SENTRY_FALLBACK_CELL 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/organization_projects.py b/src/sentry/core/endpoints/organization_projects.py index 9b58e013b89e..dd50762c614b 100644 --- a/src/sentry/core/endpoints/organization_projects.py +++ b/src/sentry/core/endpoints/organization_projects.py @@ -1,6 +1,7 @@ import logging import random import string +from collections.abc import Sequence from email.headerregistry import Address from typing import Any, TypedDict, TypeIs @@ -276,7 +277,7 @@ def get( if expand_context: expand.add("options") - def serialize_on_result(result): + def serialize_on_result(result: Sequence[Project]) -> list[object]: environment_id = get_environment_id(request, organization.id) serializer = ProjectSummarySerializer( environment_id=environment_id, @@ -286,7 +287,7 @@ def serialize_on_result(result): collapse=collapse, dataset=dataset, ) - return serialize(result, request.user, serializer) + return list(serialize(result, request.user, serializer)) return self.paginate( request=request, diff --git a/src/sentry/core/endpoints/organization_request_project_creation.py b/src/sentry/core/endpoints/organization_request_project_creation.py index abf4eb05bb2c..2fc81fc4a509 100644 --- a/src/sentry/core/endpoints/organization_request_project_creation.py +++ b/src/sentry/core/endpoints/organization_request_project_creation.py @@ -7,11 +7,12 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization_request_change import OrganizationRequestChangeEndpoint from sentry.api.serializers.rest_framework import CamelSnakeSerializer +from sentry.models.organization import Organization from sentry.utils.email import MessageBuilder from sentry.utils.http import absolute_uri -class OrganizationRequestProjectCreationSerializer(CamelSnakeSerializer): +class OrganizationRequestProjectCreationSerializer(CamelSnakeSerializer[dict[str, object]]): target_user_email = serializers.EmailField(required=True) @@ -21,7 +22,7 @@ class OrganizationRequestProjectCreation(OrganizationRequestChangeEndpoint): "POST": ApiPublishStatus.PRIVATE, } - def post(self, request: Request, organization) -> Response: + def post(self, request: Request, organization: Organization) -> Response: """ Send an email requesting a project be created """ diff --git a/src/sentry/core/endpoints/organization_teams.py b/src/sentry/core/endpoints/organization_teams.py index 5c3e6399021b..69b7cc154c31 100644 --- a/src/sentry/core/endpoints/organization_teams.py +++ b/src/sentry/core/endpoints/organization_teams.py @@ -73,7 +73,7 @@ class TeamPostSerializer(serializers.Serializer): ) idp_provisioned = serializers.BooleanField(required=False, default=False) - def validate(self, attrs): + def validate(self, attrs: dict[str, object]) -> dict[str, object]: if not (attrs.get("name") or attrs.get("slug")): raise serializers.ValidationError("Name or slug is required") return attrs @@ -88,7 +88,7 @@ class OrganizationTeamsEndpoint(OrganizationEndpoint): } permission_classes = (OrganizationTeamsPermission,) - def team_serializer_for_post(self): + def team_serializer_for_post(self) -> TeamSerializer: # allow child routes to supply own serializer, used in SCIM teams route return TeamSerializer() @@ -177,7 +177,7 @@ def get( paginator_cls=OffsetPaginator, ) - def should_add_creator_to_team(self, request: Request): + def should_add_creator_to_team(self, request: Request) -> bool: return has_user(request.user) @extend_schema( @@ -196,7 +196,7 @@ def should_add_creator_to_team(self, request: Request): examples=TeamExamples.CREATE_TEAM, ) def post( - self, request: Request, organization, **kwargs + self, request: Request, organization: Organization, **kwargs: object ) -> Response[TeamSerializerResponse] | Response[ValidationErrorResponse]: """ Create a new team bound to an organization. Requires at least one of the `name` diff --git a/src/sentry/core/endpoints/organization_user_details.py b/src/sentry/core/endpoints/organization_user_details.py index 60a829932be3..741d39785daa 100644 --- a/src/sentry/core/endpoints/organization_user_details.py +++ b/src/sentry/core/endpoints/organization_user_details.py @@ -6,6 +6,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint from sentry.api.bases.organizationmember import MemberPermission +from sentry.models.organization import Organization from sentry.users.services.user.serial import serialize_generic_user from sentry.users.services.user.service import user_service @@ -17,7 +18,7 @@ class OrganizationUserDetailsEndpoint(OrganizationEndpoint): } permission_classes = (MemberPermission,) - def get(self, request: Request, organization, user_id) -> Response: + def get(self, request: Request, organization: Organization, user_id: str) -> Response: try: int(user_id) except ValueError: diff --git a/src/sentry/core/endpoints/project_details.py b/src/sentry/core/endpoints/project_details.py index 58d4339f4afa..b916d1e162e4 100644 --- a/src/sentry/core/endpoints/project_details.py +++ b/src/sentry/core/endpoints/project_details.py @@ -76,11 +76,11 @@ MAX_SENSITIVE_FIELD_CHARS = 4000 -def coerce_to_string_or_none(value) -> str | None: +def coerce_to_string_or_none(value: object) -> str | None: return None if value is None else str(value) -def clean_newline_inputs(value, case_insensitive=True): +def clean_newline_inputs(value: str, case_insensitive: bool = True) -> list[str]: result = [] for v in value.split("\n"): if case_insensitive: @@ -95,7 +95,7 @@ class DynamicSamplingBiasSerializer(serializers.Serializer): id = serializers.ChoiceField(required=True, choices=get_supported_biases_ids()) active = serializers.BooleanField(default=False) - def validate(self, data): + def validate(self, data: dict[str, object]) -> dict[str, object]: if data.keys() != {"id", "active"}: raise serializers.ValidationError( "Error: Only 'id' and 'active' fields are allowed for bias." @@ -427,7 +427,7 @@ class ProjectAdminSerializer(ProjectMemberSerializer): help_text="The role required to download debug information files, ProGuard mappings and source maps. If not set, inherits from organization setting.", ) - def validate(self, data): + def validate(self, data: dict[str, int | None]) -> dict[str, int | None]: max_delay = ( data["digestsMaxDelay"] if "digestsMaxDelay" in data @@ -446,7 +446,7 @@ def validate(self, data): return data - def validate_allowedDomains(self, value): + def validate_allowedDomains(self, value: list[str]) -> list[str]: value = list(filter(bool, value)) if len(value) == 0: raise serializers.ValidationError( @@ -469,14 +469,14 @@ def validate_slug(self, slug: str) -> str: ) return slug - def validate_relayPiiConfig(self, value): + def validate_relayPiiConfig(self, value: str | None) -> str | None: organization = self.context["project"].organization return validate_pii_config_update(organization, value) - def validate_builtinSymbolSources(self, value): + def validate_builtinSymbolSources(self, value: list[str]) -> list[str]: return value - def validate_symbolSources(self, sources_json) -> str: + def validate_symbolSources(self, sources_json: str) -> str: if not sources_json: return sources_json @@ -529,7 +529,7 @@ def validate_symbolSources(self, sources_json) -> str: return sources_json - def validate_groupingEnhancements(self, value): + def validate_groupingEnhancements(self, value: str | None) -> str | None: if not value: return value @@ -541,16 +541,16 @@ def validate_groupingEnhancements(self, value): return value # TODO: Once these have been in place for a while we can probably remove them - def validate_groupingConfig(self, value): + def validate_groupingConfig(self, value: object) -> None: raise serializers.ValidationError("Grouping config cannot be manually set.") - def validate_secondaryGroupingConfig(self, value): + def validate_secondaryGroupingConfig(self, value: object) -> None: raise serializers.ValidationError("Secondary grouping config cannot be manually set.") - def validate_secondaryGroupingExpiry(self, value): + def validate_secondaryGroupingExpiry(self, value: object) -> None: raise serializers.ValidationError("Secondary grouping expiry cannot be manually set.") - def validate_fingerprintingRules(self, value): + def validate_fingerprintingRules(self, value: str | None) -> str | None: if not value: return value @@ -561,7 +561,7 @@ def validate_fingerprintingRules(self, value): return value - def validate_copy_from_project(self, other_project_id): + def validate_copy_from_project(self, other_project_id: int) -> int: try: other_project = Project.objects.filter( id=other_project_id, organization_id=self.context["project"].organization_id @@ -583,20 +583,20 @@ def validate_copy_from_project(self, other_project_id): return other_project_id - def validate_platform(self, value): + def validate_platform(self, value: str) -> str: if Project.is_valid_platform(value): return value raise serializers.ValidationError("Invalid platform") - def validate_sensitiveFields(self, value): + def validate_sensitiveFields(self, value: list[str]) -> list[str]: 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]: return validate_pii_selectors(value) - def validate_targetSampleRate(self, value): + def validate_targetSampleRate(self, value: float) -> float: organization = self.context["project"].organization actor = self.context["request"].user if not has_custom_dynamic_sampling(organization, actor=actor): @@ -614,7 +614,7 @@ def validate_targetSampleRate(self, value): return value - def validate_tempestFetchScreenshots(self, value): + def validate_tempestFetchScreenshots(self, value: bool) -> bool: organization = self.context["project"].organization if not has_tempest_access(organization): raise serializers.ValidationError( @@ -622,7 +622,7 @@ def validate_tempestFetchScreenshots(self, value): ) return value - def validate_debugFilesRole(self, value): + def validate_debugFilesRole(self, value: str | None) -> str | None: if value is None: return value @@ -664,7 +664,7 @@ class ProjectDetailsEndpoint(ProjectEndpoint): } permission_classes = (RelaxedProjectAndStaffPermission,) - def _get_unresolved_count(self, project): + def _get_unresolved_count(self, project: Project) -> int: queryset = Group.objects.filter(status=GroupStatus.UNRESOLVED, project=project) resolve_age = project.get_option("sentry:resolve_age", None) @@ -738,7 +738,7 @@ def get( examples=ProjectExamples.DETAILED_PROJECT, ) def put( - self, request: Request, project + self, request: Request, project: Project ) -> ( Response[_ProjectDetailsResponse] | Response[None] @@ -794,7 +794,7 @@ def put( status=403, ) changed = False - changed_proj_settings = {} + changed_proj_settings: dict[str, object] = {} old_slug = None if result.get("slug"): @@ -1397,11 +1397,11 @@ def delete(self, request: Request, project: Project) -> Response[None] | Respons common_audit_data = { "organization": project.organization, "target_object": project.id, - "transaction_id": scheduled.id, } if request.data.get("origin"): self.create_audit_entry( + transaction_id=scheduled.id, **common_audit_data, request=request, event=audit_log.get_event_id("PROJECT_REMOVE_WITH_ORIGIN"), @@ -1412,6 +1412,7 @@ def delete(self, request: Request, project: Project) -> Response[None] | Respons ) else: self.create_audit_entry( + transaction_id=scheduled.id, **common_audit_data, request=request, event=audit_log.get_event_id("PROJECT_REMOVE"), @@ -1427,8 +1428,12 @@ def delete(self, request: Request, project: Project) -> Response[None] | Respons return Response(status=204) def dynamic_sampling_biases_audit_log( - self, project, request, old_raw_dynamic_sampling_biases, new_raw_dynamic_sampling_biases - ): + self, + project: Project, + request: Request, + old_raw_dynamic_sampling_biases: list[dict[str, object]] | None, + new_raw_dynamic_sampling_biases: list[dict[str, object]], + ) -> None: """ Compares the previous and next dynamic sampling biases object, triggering audit logs according to the changes. We are currently verifying the following cases: diff --git a/src/sentry/core/endpoints/project_environment_details.py b/src/sentry/core/endpoints/project_environment_details.py index f615de123b46..5e2964ab5679 100644 --- a/src/sentry/core/endpoints/project_environment_details.py +++ b/src/sentry/core/endpoints/project_environment_details.py @@ -22,6 +22,7 @@ from sentry.apidocs.parameters import EnvironmentParams, GlobalParams from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors from sentry.models.environment import Environment, EnvironmentProject +from sentry.models.project import Project @extend_schema(tags=["Environments"]) @@ -49,7 +50,7 @@ class ProjectEnvironmentDetailsEndpoint(ProjectEndpoint): examples=EnvironmentExamples.RETRIEVE_PROJECT_ENVIRONMENT, ) def get( - self, request: Request, project, environment + self, request: Request, project: Project, environment: str ) -> Response[EnvironmentProjectSerializerResponse]: """ Return details on a project environment. @@ -84,7 +85,7 @@ def get( examples=EnvironmentExamples.RETRIEVE_PROJECT_ENVIRONMENT, ) def put( - self, request: Request, project, environment + self, request: Request, project: Project, environment: str ) -> Response[EnvironmentProjectSerializerResponse] | Response[ValidationErrorResponse]: """ Update the visibility for a project environment. diff --git a/src/sentry/core/endpoints/project_environments.py b/src/sentry/core/endpoints/project_environments.py index a386ca15f506..89a580771131 100644 --- a/src/sentry/core/endpoints/project_environments.py +++ b/src/sentry/core/endpoints/project_environments.py @@ -28,6 +28,7 @@ ) from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.environment import EnvironmentProject +from sentry.models.project import Project @extend_schema(tags=["Environments"]) @@ -59,7 +60,7 @@ class ProjectEnvironmentsEndpoint(ProjectEndpoint): examples=EnvironmentExamples.GET_PROJECT_ENVIRONMENTS, ) def get( - self, request: Request, project + self, request: Request, project: Project ) -> Response[list[EnvironmentProjectSerializerResponse]] | Response[DetailResponse]: """ Lists a project's environments. @@ -95,9 +96,9 @@ def get( ) add_visibility_filters = environment_visibility_filter_options[visibility] - queryset = add_visibility_filters(queryset) + visible_queryset = add_visibility_filters(queryset) - items: list[EnvironmentProject] = list(queryset) + items: list[EnvironmentProject] = list(visible_queryset) return Response(serialize(items, request.user, EnvironmentProjectSerializer())) @extend_schema( @@ -119,7 +120,7 @@ def get( examples=EnvironmentExamples.GET_PROJECT_ENVIRONMENTS, ) def put( - self, request: Request, project + self, request: Request, project: Project ) -> Response[list[EnvironmentProjectSerializerResponse]] | Response[ValidationErrorResponse]: """ Bulk update the visibility for a project's environments. diff --git a/src/sentry/core/endpoints/project_key_details.py b/src/sentry/core/endpoints/project_key_details.py index 5056026f44ee..de786f62ea4d 100644 --- a/src/sentry/core/endpoints/project_key_details.py +++ b/src/sentry/core/endpoints/project_key_details.py @@ -56,7 +56,7 @@ class ProjectKeyDetailsEndpoint(ProjectKeyEndpoint): examples=ProjectExamples.CLIENT_KEY_RESPONSE, ) def get( - self, request: Request, project_key: ProjectKey, **kwargs + self, request: Request, project_key: ProjectKey, **kwargs: object ) -> Response[ProjectKeySerializerResponse]: """ Return a client key bound to a project. @@ -106,7 +106,7 @@ def get( examples=ProjectExamples.CLIENT_KEY_RESPONSE, ) def put( - self, request: Request, project_key: ProjectKey, project: Project, **kwargs + self, request: Request, project_key: ProjectKey, project: Project, **kwargs: object ) -> Response[ProjectKeySerializerResponse] | Response[ValidationErrorResponse]: """ Update various settings for a client key. @@ -188,7 +188,7 @@ def put( examples=None, ) def delete( - self, request: Request, project_key: ProjectKey, project: Project, **kwargs + self, request: Request, project_key: ProjectKey, project: Project, **kwargs: object ) -> Response[None]: """ Delete a client key for a given project. diff --git a/src/sentry/core/endpoints/project_keys.py b/src/sentry/core/endpoints/project_keys.py index f18d7c7d82f5..e86b9749df70 100644 --- a/src/sentry/core/endpoints/project_keys.py +++ b/src/sentry/core/endpoints/project_keys.py @@ -1,3 +1,5 @@ +from typing import cast + from django.db.models import F from drf_spectacular.utils import extend_schema from rest_framework import status @@ -21,6 +23,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.superuser import is_active_superuser from sentry.loader.dynamic_sdk_options import get_default_loader_data +from sentry.models.project import Project from sentry.models.projectkey import ProjectKey, ProjectKeyStatus, UseCase from sentry.ratelimits.config import RateLimitConfig from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -67,7 +70,9 @@ class ProjectKeysEndpoint(ProjectEndpoint): }, examples=ProjectExamples.LIST_CLIENT_KEYS, ) - def get(self, request: Request, project) -> Response[list[ProjectKeySerializerResponse]]: + def get( + self, request: Request, project: Project + ) -> Response[list[ProjectKeySerializerResponse]]: """ Return a list of client keys bound to a project. """ @@ -87,7 +92,11 @@ def serialize_keys(keys: list[ProjectKey]) -> list[ProjectKeySerializerResponse] # loaded. Reuse it so building each key's DSNs doesn't load both again. for key in keys: key.set_cached_field_value("project", project) - return serialize(keys, request.user, request=request) + # justification: the serializer emits one payload per project key. + return cast( + "list[ProjectKeySerializerResponse]", + serialize(keys, request.user, request=request), + ) return self.paginate( request=request, @@ -113,7 +122,7 @@ def serialize_keys(keys: list[ProjectKey]) -> list[ProjectKeySerializerResponse] examples=ProjectExamples.CLIENT_KEY_RESPONSE, ) def post( - self, request: Request, project + self, request: Request, project: Project ) -> Response[ProjectKeySerializerResponse] | Response[ValidationErrorResponse]: """ Create a new client key bound to a project. The key's secret and public key diff --git a/src/sentry/core/endpoints/project_stats.py b/src/sentry/core/endpoints/project_stats.py index d669313ad100..1a7b488abeaa 100644 --- a/src/sentry/core/endpoints/project_stats.py +++ b/src/sentry/core/endpoints/project_stats.py @@ -127,7 +127,7 @@ def get(self, request: Request, project: Project) -> Response[list[tuple[int, in This endpoint may change in the future without notice. """ stat = request.GET.get("stat", "received") - query_kwargs = {} + environment_id: int | None = None if stat == "received": stat_model = TSDBModel.project_total_received elif stat == "rejected": @@ -137,9 +137,7 @@ def get(self, request: Request, project: Project) -> Response[list[tuple[int, in elif stat == "generated": stat_model = TSDBModel.project try: - query_kwargs["environment_id"] = get_environment_id( - request, project.organization_id - ) + environment_id = get_environment_id(request, project.organization_id) except Environment.DoesNotExist: raise ResourceDoesNotExist else: @@ -151,7 +149,7 @@ def get(self, request: Request, project: Project) -> Response[list[tuple[int, in data = tsdb.backend.get_range( model=stat_model, keys=[project.id], - **self._parse_args(request, **query_kwargs), + **self._parse_args(request, environment_id=environment_id), tenant_ids={"organization_id": project.organization_id}, )[project.id] diff --git a/src/sentry/core/endpoints/project_team_details.py b/src/sentry/core/endpoints/project_team_details.py index 4c95c99a2fad..09978cef9c82 100644 --- a/src/sentry/core/endpoints/project_team_details.py +++ b/src/sentry/core/endpoints/project_team_details.py @@ -1,3 +1,5 @@ +from typing import cast + from drf_spectacular.utils import extend_schema from rest_framework.request import Request from rest_framework.response import Response @@ -17,6 +19,7 @@ from sentry.apidocs.examples.project_examples import ProjectExamples from sentry.apidocs.parameters import GlobalParams from sentry.apidocs.response_types import ValidationErrorResponse +from sentry.models.project import Project from sentry.models.team import Team @@ -45,14 +48,14 @@ def convert_args( organization_id_or_slug: int | str, project_id_or_slug: int | str, team_id_or_slug: int | str, - *args, - **kwargs, - ): + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: (args, kwargs) = super().convert_args( request, organization_id_or_slug, project_id_or_slug, *args, **kwargs ) - project = kwargs["project"] + project = cast("Project", kwargs["project"]) try: team = Team.objects.get( @@ -81,7 +84,9 @@ def convert_args( }, examples=ProjectExamples.ADD_TEAM_TO_PROJECT, ) - def post(self, request: Request, project, team: Team) -> Response[ProjectWithTeamResponseDict]: + def post( + self, request: Request, project: Project, team: Team + ) -> Response[ProjectWithTeamResponseDict]: """ Give a team access to a project. """ @@ -116,7 +121,7 @@ def post(self, request: Request, project, team: Team) -> Response[ProjectWithTea examples=ProjectExamples.DELETE_TEAM_FROM_PROJECT, ) def delete( - self, request: Request, project, team: Team + self, request: Request, project: Project, team: Team ) -> Response[ProjectWithTeamResponseDict] | Response[ValidationErrorResponse]: """ Revoke a team's access to a project. diff --git a/src/sentry/core/endpoints/project_teams.py b/src/sentry/core/endpoints/project_teams.py index 485e4691bdcc..2cfc97dceb3b 100644 --- a/src/sentry/core/endpoints/project_teams.py +++ b/src/sentry/core/endpoints/project_teams.py @@ -13,6 +13,7 @@ from sentry.apidocs.examples.team_examples import TeamExamples from sentry.apidocs.parameters import CursorQueryParam, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.project import Project from sentry.models.team import Team @@ -38,7 +39,7 @@ class ProjectTeamsEndpoint(ProjectEndpoint): }, examples=TeamExamples.LIST_PROJECT_TEAMS, ) - def get(self, request: Request, project) -> Response[list[BaseTeamSerializerResponse]]: + def get(self, request: Request, project: Project) -> Response[list[BaseTeamSerializerResponse]]: """ Return a list of teams that have access to this project. """ 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/project_users.py b/src/sentry/core/endpoints/project_users.py index 3e1479358357..8beca304bd5b 100644 --- a/src/sentry/core/endpoints/project_users.py +++ b/src/sentry/core/endpoints/project_users.py @@ -14,6 +14,7 @@ from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED from sentry.apidocs.parameters import CursorQueryParam, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.project import Project from sentry.ratelimits.config import RateLimitConfig from sentry.types.ratelimit import RateLimit, RateLimitCategory from sentry.utils.eventuser import EventUser @@ -86,7 +87,9 @@ class ProjectUsersEndpoint(ProjectEndpoint): ) ], ) - def get(self, request: Request, project) -> Response[list[EventUserSerializerResponse]]: + def get( + self, request: Request, project: Project + ) -> Response[list[EventUserSerializerResponse]]: """ Return a list of users seen within this project. """ @@ -107,7 +110,7 @@ def get(self, request: Request, project) -> Response[list[EventUserSerializerRes if field and identifier: keyword_filters[field] = [identifier] - def callback(limit, offset): + def callback(limit: int, offset: int) -> list[EventUser]: return EventUser.for_projects( projects=[project], keyword_filters=keyword_filters, diff --git a/src/sentry/core/endpoints/scim/members.py b/src/sentry/core/endpoints/scim/members.py index 774e380f017a..47be6e97106c 100644 --- a/src/sentry/core/endpoints/scim/members.py +++ b/src/sentry/core/endpoints/scim/members.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any +from collections.abc import Sequence +from typing import Any, cast import sentry_sdk from django.conf import settings @@ -71,13 +72,13 @@ @extend_schema_field(OpenApiTypes.ANY) # union field types are kind of hard, leaving Any for now. -class OperationValue(Field): +class OperationValue(Field[object, object, dict[str, object] | bool, object]): """ A SCIM PATCH operation value can either be a boolean, or an object depending on the client. """ - def to_representation(self, value) -> dict | bool: + def to_representation(self, value: object) -> dict[str, object] | bool: if isinstance(value, bool): return value elif isinstance(value, dict): @@ -88,7 +89,7 @@ def to_representation(self, value) -> dict | bool: return value raise ValidationError("value must be a boolean or object") - def to_internal_value(self, data) -> dict | bool: + def to_internal_value(self, data: object) -> dict[str, object] | bool: if isinstance(data, bool): return data elif isinstance(data, dict): @@ -139,7 +140,9 @@ class SCIMPatchRequestSerializer(serializers.Serializer): ) -def _scim_member_serializer_with_expansion(organization): +def _scim_member_serializer_with_expansion( + organization: Organization, +) -> OrganizationMemberSCIMSerializer: """ For our Azure SCIM integration, we don't want to return the `active` flag since we don't support soft deletes. Other integrations don't @@ -154,7 +157,7 @@ def _scim_member_serializer_with_expansion(organization): return OrganizationMemberSCIMSerializer(expand=expand) -def resolve_maybe_bool_value(value): +def resolve_maybe_bool_value(value: object) -> bool | None: if isinstance(value, str): value = value.lower() # Some IdP vendors such as Azure send boolean values as actual strings. @@ -239,12 +242,10 @@ def _delete_member( for permission in settings.SENTRY_SCIM_PERMISSION_TEAM_SLUGS: user_service.remove_permission(user_id=user.id, permission=permission) - def _should_delete_member(self, operation): - if operation.get("op").lower() == MemberPatchOps.REPLACE: - if ( - isinstance(operation.get("value"), dict) - and resolve_maybe_bool_value(operation.get("value").get("active")) is False - ): + def _should_delete_member(self, operation: dict[str, object]) -> bool: + if cast(str, operation.get("op")).lower() == MemberPatchOps.REPLACE: + value = operation.get("value") + if isinstance(value, dict) and resolve_maybe_bool_value(value.get("active")) is False: # how okta sets active to false return True elif ( @@ -272,7 +273,7 @@ def _should_delete_member(self, operation): examples=SCIMExamples.QUERY_ORG_MEMBER, ) def get( - self, request: Request, organization, member + self, request: Request, organization: Organization, member: OrganizationMember ) -> Response[OrganizationMemberSCIMSerializerResponse]: """ Query an individual organization member with a SCIM User GET Request. @@ -302,7 +303,7 @@ def get( examples=SCIMExamples.UPDATE_ORG_MEMBER_ATTRIBUTES, ) def patch( - self, request: Request, organization, member + self, request: Request, organization: Organization, member: OrganizationMember ) -> ( Response[OrganizationMemberSCIMSerializerResponse] | Response[None] @@ -332,7 +333,7 @@ def patch( self._delete_member(request, organization, member) metrics.incr( "sentry.scim.member.delete", - tags={"organization": organization}, + tags={"organization": cast(str, organization)}, ) return Response(status=204) else: @@ -359,7 +360,7 @@ def patch( }, ) def delete( - self, request: Request, organization, member + self, request: Request, organization: Organization, member: OrganizationMember ) -> Response[None] | Response[DetailResponse]: """ Delete an organization member with a SCIM User DELETE Request. @@ -373,7 +374,7 @@ def delete( ) self._delete_member(request, organization, member) - metrics.incr("sentry.scim.member.delete", tags={"organization": organization}) + metrics.incr("sentry.scim.member.delete", tags={"organization": cast(str, organization)}) return Response(status=204) @extend_schema( @@ -394,7 +395,9 @@ def delete( }, examples=SCIMExamples.UPDATE_USER_ROLE, ) - def put(self, request: Request, organization: Organization, member): + def put( + self, request: Request, organization: Organization, member: OrganizationMember + ) -> Response[OrganizationMemberSCIMSerializerResponse] | Response[DetailResponse]: """ Update an organization member @@ -537,16 +540,16 @@ def get( | Q(user_id__in=[u.id for u in filtered_users]) ) # not including secondary email vals (dups, etc.) - def data_fn(offset, limit): + def data_fn(offset: int, limit: int) -> list[OrganizationMember]: return list(queryset[offset : offset + limit]) - def on_results(results): - results = serialize( + def on_results(results: Sequence[OrganizationMember]) -> dict[str, object]: + serialized = serialize( results, None, _scim_member_serializer_with_expansion(organization), ) - return self.list_api_format(results, queryset.count(), query_params["start_index"]) + return self.list_api_format(serialized, queryset.count(), query_params["start_index"]) return self.paginate( request=request, @@ -585,7 +588,7 @@ def on_results(results): examples=SCIMExamples.PROVISION_NEW_MEMBER, ) def post( - self, request: Request, organization + self, request: Request, organization: Organization ) -> Response[OrganizationMemberSCIMSerializerResponse]: """ Create a new Organization Member via a SCIM Users POST Request. @@ -695,10 +698,12 @@ def post( metrics.incr( "sentry.scim.member.provision", - tags={"organization": organization}, + tags={"organization": cast(str, organization)}, ) if update_role: - metrics.incr("sentry.scim.member.update_role", tags={"organization": organization}) + metrics.incr( + "sentry.scim.member.update_role", tags={"organization": cast(str, organization)} + ) body: OrganizationMemberSCIMSerializerResponse = serialize( member, diff --git a/src/sentry/core/endpoints/scim/teams.py b/src/sentry/core/endpoints/scim/teams.py index c6a1d57ca33b..4442aa23e880 100644 --- a/src/sentry/core/endpoints/scim/teams.py +++ b/src/sentry/core/endpoints/scim/teams.py @@ -1,6 +1,7 @@ import logging import re -from typing import Any +from collections.abc import Mapping, Sequence +from typing import Any, cast import sentry_sdk from django.conf import settings @@ -74,7 +75,7 @@ class UnsupportedAttributeError(APIException): status_code = 400 - def __init__(self, detail=None): + def __init__(self, detail: str | Mapping[str, Sequence[str]] | None = None) -> None: super().__init__(detail=detail) @@ -171,7 +172,7 @@ class SCIMTeamPatchRequestSerializer(serializers.Serializer): ) -def _team_expand(excluded_attributes): +def _team_expand(excluded_attributes: list[str]) -> list[str] | None: return None if "members" in excluded_attributes else ["members"] @@ -219,16 +220,16 @@ def get( if query_params["filter"]: queryset = queryset.filter(slug__iexact=slugify(query_params["filter"])) - def data_fn(offset, limit): + def data_fn(offset: int, limit: int) -> list[Team]: return list(queryset[offset : offset + limit]) - def on_results(results): - results = serialize( + def on_results(results: Sequence[Team]) -> dict[str, object]: + serialized = serialize( results, None, TeamSCIMSerializer(expand=_team_expand(query_params["excluded_attributes"])), ) - return self.list_api_format(results, queryset.count(), query_params["start_index"]) + return self.list_api_format(serialized, queryset.count(), query_params["start_index"]) return self.paginate( request=request, @@ -333,16 +334,23 @@ class OrganizationSCIMTeamDetails(SCIMEndpoint, TeamDetailsEndpoint): _allow_idp_changes = True def convert_args( - self, request: Request, organization_id_or_slug: int | str, team_id_or_slug, *args, **kwargs - ): + self, + request: Request, + organization_id_or_slug: int | str, + team_id_or_slug: int | str, + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: args, kwargs = super().convert_args(request, organization_id_or_slug) try: - kwargs["team"] = self._get_team(kwargs["organization"], team_id_or_slug) + kwargs["team"] = self._get_team( + cast("Organization", kwargs["organization"]), team_id_or_slug + ) except Team.DoesNotExist: raise ResourceDoesNotExist(detail=SCIM_404_GROUP_RES) return (args, kwargs) - def _get_team(self, organization, team_id_or_slug): + def _get_team(self, organization: Organization, team_id_or_slug: int | str) -> Team: team = ( Team.objects.filter(organization=organization, slug__id_or_slug=team_id_or_slug) .select_related("organization") @@ -440,7 +448,7 @@ def _remove_members_operation( ) OrganizationMemberTeam.objects.filter(id__in=[omt.id for omt in omts]).delete() - def _rename_team_operation(self, request: Request, new_name, team): + def _rename_team_operation(self, request: Request, new_name: str, team: Team) -> None: serializer = TeamSerializer( team, data={"name": new_name, "slug": slugify(new_name)}, @@ -490,7 +498,7 @@ def _handle_remove_member_patch_op( OrganizationMemberTeam.objects.filter( team=team, organizationmember__organization=team.organization, - organizationmember_id=member_id, + organizationmember_id=cast("str | int", member_id), ) .select_related("organizationmember") .first() @@ -567,7 +575,7 @@ def _handle_replace_patch_op( }, ) def patch( - self, request: Request, organization, team + self, request: Request, organization: Organization, team: Team ) -> Response[None] | Response[ValidationErrorResponse]: """ Update a team's attributes with a SCIM Group PATCH Request. @@ -645,7 +653,7 @@ def patch( user_ids_to_revoke=user_ids_to_revoke_privileges, ) - metrics.incr("sentry.scim.team.update", tags={"organization": organization}) + metrics.incr("sentry.scim.team.update", tags={"organization": cast(str, organization)}) return self.respond(status=204) @extend_schema( @@ -691,15 +699,15 @@ def put(self, request: Request, organization: Organization, team: Team) -> HttpR # in SCIM Team routes return self.http_method_not_allowed(request) - def _get_member_id_for_remove_op(self, operation): + def _get_member_id_for_remove_op(self, operation: dict[str, object]) -> object: if "value" in operation: # azure sends member ids in this format under the key 'value' - return operation["value"][0]["value"] + return cast("list[dict[str, object]]", operation["value"])[0]["value"] try: # grab the filter out of the brackets of the string that looks # like so: members[value eq "123124"] - regex_search = re.search(r"\[(.*?)\]", operation["path"]) + regex_search = re.search(r"\[(.*?)\]", cast(str, operation["path"])) if regex_search is None: raise SCIMFilterError filter_path = regex_search.groups()[0] diff --git a/src/sentry/core/endpoints/scim/utils.py b/src/sentry/core/endpoints/scim/utils.py index 2790961b6972..c06ff004fe67 100644 --- a/src/sentry/core/endpoints/scim/utils.py +++ b/src/sentry/core/endpoints/scim/utils.py @@ -1,15 +1,18 @@ from __future__ import annotations -from collections.abc import Iterable -from typing import NoReturn, TypedDict +from collections.abc import Iterable, Sequence +from typing import NoReturn, NotRequired, TypedDict, cast from django.http import HttpRequest from drf_spectacular.utils import extend_schema from rest_framework import serializers from rest_framework.exceptions import APIException, ParseError from rest_framework.negotiation import BaseContentNegotiation +from rest_framework.parsers import BaseParser from rest_framework.renderers import BaseRenderer from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView from sentry_sdk import traces from sentry.api.api_owners import ApiOwner @@ -25,7 +28,7 @@ class SCIMApiError(APIException): - def __init__(self, detail, status_code=400, scim_type=None): + def __init__(self, detail: str, status_code: int = 400, scim_type: str | None = None) -> None: span = traces.get_current_span() if span is not None: span.set_attribute("http.status_code", status_code) @@ -47,13 +50,14 @@ class SCIMFilterError(ValueError): class SCIMClientNegotiation(BaseContentNegotiation): # SCIM uses the content type "application/json+scim" # which is just json for our purposes. - def select_parser(self, request: Request, parsers): + def select_parser(self, request: Request, parsers: Iterable[BaseParser]) -> BaseParser | None: """ Select the first parser in the `.parser_classes` list. """ for parser in parsers: if parser.media_type in SCIM_CONTENT_TYPES: return parser + return None def select_renderer( self, request: Request, renderers: Iterable[BaseRenderer], format_suffix: str | None = None @@ -97,19 +101,25 @@ class SCIMQueryParamSerializer(serializers.Serializer): help_text="Fields that should be left off of return values. Right now the only supported field for this query is members.", ) - def validate_filter(self, filter): + def validate_filter(self, filter: str) -> str | int | None: try: - filter = parse_filter_conditions(filter) + return parse_filter_conditions(filter) except SCIMFilterError: raise serializers.ValidationError("invalid filter") - return filter + + +class SCIMQueryParams(TypedDict): + start_index: int + count: int + filter: NotRequired[str | None] + excluded_attributes: NotRequired[list[str]] class OrganizationSCIMPermission(OrganizationPermission): def has_object_permission( self, request: Request, - view, + view: APIView, organization: Organization | RpcOrganization | RpcUserOrganizationContext, ) -> bool: result = super().has_object_permission(request, view, organization) @@ -159,10 +169,14 @@ class SCIMEndpoint(OrganizationEndpoint): content_negotiation_class = SCIMClientNegotiation cursor_name = "startIndex" - def add_cursor_headers(self, request: Request, response, cursor_result): + def add_cursor_headers( + self, request: Request, response: Response, cursor_result: object + ) -> None: pass - def list_api_format(self, results, total_results, start_index): + def list_api_format( + self, results: Sequence[object], total_results: int, start_index: int + ) -> dict[str, object]: return { "schemas": [SCIM_API_LIST], "totalResults": total_results, # TODO: audit perf of queryset.count() @@ -171,7 +185,7 @@ def list_api_format(self, results, total_results, start_index): "Resources": results, } - def get_query_parameters(self, request: Request): + def get_query_parameters(self, request: Request) -> SCIMQueryParams: serializer = SCIMQueryParamSerializer(data=request.GET) if not serializer.is_valid(): if "filter" in serializer.errors: @@ -180,7 +194,8 @@ def get_query_parameters(self, request: Request): raise ParseError(detail=SCIM_400_INVALID_FILTER) raise ParseError(serializer.errors) - return serializer.validated_data + # justification: the serializer's validated data is the SCIM query params schema. + return cast("SCIMQueryParams", serializer.validated_data) class SCIMDiscoveryEndpoint(SCIMEndpoint): @@ -203,11 +218,13 @@ def reject_filter_param(self, request: Request) -> None: detail="Filtering is not supported on this endpoint.", status_code=403 ) - def http_method_not_allowed(self, request: HttpRequest, *args, **kwargs) -> NoReturn: + def http_method_not_allowed( + self, request: HttpRequest, *args: object, **kwargs: object + ) -> NoReturn: raise SCIMApiError(detail=f'Method "{request.method}" not allowed.', status_code=405) -def parse_filter_conditions(raw_filters): +def parse_filter_conditions(raw_filters: str | None) -> str | int | None: """ this function parses a scim filter, see: https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2 @@ -252,6 +269,6 @@ def parse_filter_conditions(raw_filters): if key not in ACCEPTED_FILTERED_KEYS: raise SCIMFilterError if key == "value": - value = int(value) + return int(value) return value 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/core/endpoints/team_details.py b/src/sentry/core/endpoints/team_details.py index 6fbb39ec5d55..5f5e95ba19b7 100644 --- a/src/sentry/core/endpoints/team_details.py +++ b/src/sentry/core/endpoints/team_details.py @@ -34,7 +34,7 @@ from sentry.models.team import Team, TeamStatus -class TeamDetailsSerializer(CamelSnakeModelSerializer): +class TeamDetailsSerializer(CamelSnakeModelSerializer[Team]): name = serializers.CharField( max_length=64, required=False, @@ -70,7 +70,7 @@ class TeamDetailsEndpoint(TeamEndpoint): # OrganizationSCIMTeamDetails inherits this endpoint, but toggles this setting _allow_idp_changes = False - def can_modify_idp_team(self, team: Team): + def can_modify_idp_team(self, team: Team) -> bool: if not team.idp_provisioned: return True return self._allow_idp_changes @@ -92,7 +92,7 @@ def can_modify_idp_team(self, team: Team): }, examples=TeamExamples.RETRIEVE_TEAM_DETAILS, ) - def get(self, request: Request, team) -> Response[TeamSerializerResponse]: + def get(self, request: Request, team: Team) -> Response[TeamSerializerResponse]: """ Return details on an individual team. """ @@ -124,7 +124,7 @@ def get(self, request: Request, team) -> Response[TeamSerializerResponse]: examples=TeamExamples.UPDATE_TEAM, ) def put( - self, request: Request, team + self, request: Request, team: Team ) -> ( Response[TeamSerializerResponse] | Response[DetailResponse] @@ -170,7 +170,7 @@ def put( }, ) @sudo_required - def delete(self, request: Request, team) -> Response[None] | Response[DetailResponse]: + def delete(self, request: Request, team: Team) -> Response[None] | Response[DetailResponse]: """ Schedules a team for deletion. diff --git a/src/sentry/core/endpoints/team_members.py b/src/sentry/core/endpoints/team_members.py index 2bf36f69885d..4f2261d97820 100644 --- a/src/sentry/core/endpoints/team_members.py +++ b/src/sentry/core/endpoints/team_members.py @@ -1,3 +1,6 @@ +from collections.abc import Mapping, MutableMapping, Sequence +from typing import cast + from django.db.models import Q, prefetch_related_objects from drf_spectacular.utils import extend_schema from rest_framework.request import Request @@ -16,6 +19,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.organizationmember import InviteStatus from sentry.models.organizationmemberteam import OrganizationMemberTeam +from sentry.models.team import Team from sentry.users.api.serializers.user import UserSerializerResponse @@ -29,11 +33,13 @@ class OrganizationMemberOnTeamResponse(OrganizationMemberResponse): @register(OrganizationMemberTeam) class DetailedOrganizationMemberTeamSerializer(Serializer[OrganizationMemberOnTeamResponse]): - def __init__(self, *args, **kwargs): - self.team = kwargs.pop("team", None) + def __init__(self, *args: object, **kwargs: object) -> None: + self.team = cast("Team | None", kwargs.pop("team", None)) super().__init__(*args, **kwargs) - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, item_list: Sequence[OrganizationMemberTeam], user: object, **kwargs: object + ) -> MutableMapping[OrganizationMemberTeam, dict[str, object]]: prefetch_related_objects(item_list, "organizationmember") org_member_set = serialize( @@ -45,17 +51,23 @@ def get_attrs(self, item_list, user, **kwargs): ) org_member_dict = {om["id"]: om for om in org_member_set} - attrs = {} + attrs: dict[OrganizationMemberTeam, dict[str, object]] = {} for org_member_team in item_list: attrs[org_member_team] = { "org_member": org_member_dict[f"{org_member_team.organizationmember_id}"] } return attrs - def serialize(self, obj, attrs, user, **kwargs) -> OrganizationMemberOnTeamResponse: - org_member = attrs["org_member"] + def serialize( + self, + obj: OrganizationMemberTeam, + attrs: Mapping[object, object], + user: object, + **kwargs: object, + ) -> OrganizationMemberOnTeamResponse: + org_member = cast("OrganizationMemberOnTeamResponse", attrs["org_member"]) org_member["teamRole"] = obj.role - org_member["teamSlug"] = self.team.slug + org_member["teamSlug"] = cast(Team, self.team).slug return org_member @@ -84,7 +96,7 @@ class TeamMembersEndpoint(TeamEndpoint): }, examples=TeamExamples.LIST_TEAM_MEMBERS, ) - def get(self, request: Request, team) -> Response[list[OrganizationMemberOnTeamResponse]]: + def get(self, request: Request, team: Team) -> Response[list[OrganizationMemberOnTeamResponse]]: """ List all members on a team. diff --git a/src/sentry/core/endpoints/team_projects.py b/src/sentry/core/endpoints/team_projects.py index 78a76e81d975..99f442ea2b33 100644 --- a/src/sentry/core/endpoints/team_projects.py +++ b/src/sentry/core/endpoints/team_projects.py @@ -97,7 +97,7 @@ class ProjectPostSerializer(serializers.Serializer): initial=True, ) - def validate_platform(self, value): + def validate_platform(self, value: str) -> str: if not Project.is_valid_platform(value): raise serializers.ValidationError("Invalid platform") @@ -208,7 +208,7 @@ class TeamProjectsEndpoint(TeamEndpoint): examples=TeamExamples.LIST_TEAM_PROJECTS, ) def get( - self, request: Request, team + self, request: Request, team: Team ) -> Response[list[OrganizationProjectResponse]] | Response[_StatsPeriodErrorResponse]: """ Return a list of projects bound to a team. diff --git a/src/sentry/core/endpoints/team_release_count.py b/src/sentry/core/endpoints/team_release_count.py index 88e8ef3c5ce1..532c7e1e201b 100644 --- a/src/sentry/core/endpoints/team_release_count.py +++ b/src/sentry/core/endpoints/team_release_count.py @@ -15,6 +15,7 @@ from sentry.api.utils import get_date_range_from_params from sentry.models.project import Project from sentry.models.release import Release +from sentry.models.team import Team from sentry.utils.dates import floor_to_utc_day @@ -25,7 +26,7 @@ class TeamReleaseCountEndpoint(TeamEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, team) -> Response: + def get(self, request: Request, team: Team) -> Response: """ Returns a dict of team projects, and a time-series list of release counts for each. """ diff --git a/src/sentry/core/endpoints/team_stats.py b/src/sentry/core/endpoints/team_stats.py index e8a2b61dc053..7a86ca5a78de 100644 --- a/src/sentry/core/endpoints/team_stats.py +++ b/src/sentry/core/endpoints/team_stats.py @@ -10,6 +10,7 @@ from sentry.api.helpers.environments import get_environment_id from sentry.models.environment import Environment from sentry.models.project import Project +from sentry.models.team import Team from sentry.tsdb.base import TSDBModel @@ -20,7 +21,7 @@ class TeamStatsEndpoint(TeamEndpoint, StatsMixin): } owner = ApiOwner.FOUNDATIONS - def get(self, request: Request, team) -> Response: + def get(self, request: Request, team: Team) -> Response: """ Retrieve Event Counts for a Team ```````````````````````````````` diff --git a/src/sentry/core/endpoints/team_time_to_resolution.py b/src/sentry/core/endpoints/team_time_to_resolution.py index 890b2b52ff2a..47caa7ae02b0 100644 --- a/src/sentry/core/endpoints/team_time_to_resolution.py +++ b/src/sentry/core/endpoints/team_time_to_resolution.py @@ -17,6 +17,7 @@ from sentry.api.helpers.environments import get_environments from sentry.api.utils import get_date_range_from_params from sentry.models.grouphistory import GroupHistory, GroupHistoryStatus +from sentry.models.team import Team from sentry.utils.dates import floor_to_utc_day @@ -32,7 +33,7 @@ class TeamTimeToResolutionEndpoint(TeamEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, team) -> Response: + def get(self, request: Request, team: Team) -> Response: """ Return a a time bucketed list of mean group resolution times for a given team. """ diff --git a/src/sentry/culprit.py b/src/sentry/culprit.py index 54bc9a089514..e93a91a1bc7c 100644 --- a/src/sentry/culprit.py +++ b/src/sentry/culprit.py @@ -7,12 +7,15 @@ code that generates it. """ +from collections.abc import Mapping, Sequence +from typing import cast + from sentry.constants import MAX_CULPRIT_LENGTH, NEL_CULPRITS from sentry.utils.safe import get_path from sentry.utils.strings import truncatechars -def generate_culprit(data): +def generate_culprit(data: Mapping[str, object]) -> str: platform = data.get("platform") exceptions = get_path(data, "exception", "values", filter=True) if exceptions: @@ -23,7 +26,7 @@ def generate_culprit(data): stacktraces = [e["stacktrace"] for e in exceptions if get_path(e, "stacktrace", "frames")] else: - stacktrace = data.get("stacktrace") + stacktrace = cast(Mapping[str, object] | None, data.get("stacktrace")) if stacktrace and stacktrace.get("frames"): stacktraces = [stacktrace] else: @@ -32,10 +35,12 @@ def generate_culprit(data): culprit = None if not culprit and stacktraces: - culprit = get_stacktrace_culprit(get_path(stacktraces, -1), platform=platform) + culprit = get_stacktrace_culprit( + get_path(stacktraces, -1), platform=cast(str | None, platform) + ) if not culprit and data.get("type") and data.get("type") == "nel": - culprit = get_nel_culprit(data.get("contexts")) + culprit = get_nel_culprit(cast(Mapping[str, object], data.get("contexts"))) if not culprit and data.get("request"): culprit = get_path(data, "request", "url") @@ -43,9 +48,9 @@ def generate_culprit(data): return truncatechars(culprit or "", MAX_CULPRIT_LENGTH) -def get_stacktrace_culprit(stacktrace, platform): +def get_stacktrace_culprit(stacktrace: Mapping[str, object], platform: str | None) -> str | None: default = None - for frame in reversed(stacktrace["frames"]): + for frame in reversed(cast(Sequence[Mapping[str, object]], stacktrace["frames"])): if not frame: continue if frame.get("in_app"): @@ -57,13 +62,13 @@ def get_stacktrace_culprit(stacktrace, platform): return default -def get_frame_culprit(frame, platform): +def get_frame_culprit(frame: Mapping[str, object], platform: str | None) -> str: # If this frame has a platform, we use it instead of the one that # was passed in (as that one comes from the exception which might # not necessarily be the same platform). - platform = frame.get("platform") or platform + platform = cast(str | None, frame.get("platform") or platform) if platform in ("objc", "cocoa", "native"): - return frame.get("function") or "?" + return cast(str, frame.get("function") or "?") fileloc = frame.get("module") or frame.get("filename") if not fileloc: return "" @@ -74,8 +79,11 @@ def get_frame_culprit(frame, platform): return "{} in {}".format(fileloc, frame.get("function") or "?") -def get_nel_culprit(contexts): - ty = contexts.get("nel").get("error_type", "") +def get_nel_culprit(contexts: Mapping[str, object]) -> str: + nel = cast(Mapping[str, object], contexts.get("nel")) + ty = cast(str, nel.get("error_type", "")) if ty == "http.error": - return NEL_CULPRITS[ty].format(contexts.get("response").get("status_code")) + # justification: the NEL http.error context carries a response mapping. + response = cast(Mapping[str, object], contexts.get("response")) + return NEL_CULPRITS[ty].format(response.get("status_code")) return NEL_CULPRITS.get(ty, ty) diff --git a/src/sentry/dashboards/endpoints/organization_dashboard_details.py b/src/sentry/dashboards/endpoints/organization_dashboard_details.py index ce043fcc927a..dd6a315fdcb8 100644 --- a/src/sentry/dashboards/endpoints/organization_dashboard_details.py +++ b/src/sentry/dashboards/endpoints/organization_dashboard_details.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from django.db import IntegrityError, router, transaction from django.db.models import F @@ -63,7 +63,8 @@ def _take_dashboard_snapshot( inside a transaction. """ try: - return serialize(dashboard, user) + # justification: the dashboard serializer emits the dashboard payload. + return cast("dict[str, Any]", serialize(dashboard, user)) except Exception: # Snapshot failures must not block the dashboard save. Log and skip. logger.exception( @@ -276,7 +277,8 @@ def post(self, request: Request, organization: Organization, dashboard: Dashboar if not features.has(EDIT_FEATURE, organization, actor=request.user): return Response(status=404) - dashboard.visits = F("visits") + 1 + # justification: the ORM resolves F expressions against the numeric column at save time. + dashboard.visits = cast(int, F("visits") + 1) dashboard.last_visited = timezone.now() dashboard.save(update_fields=["visits", "last_visited"]) diff --git a/src/sentry/dashboards/models/generate_dashboard_artifact.py b/src/sentry/dashboards/models/generate_dashboard_artifact.py index 6d6e493435ce..41a4092c5a9f 100644 --- a/src/sentry/dashboards/models/generate_dashboard_artifact.py +++ b/src/sentry/dashboards/models/generate_dashboard_artifact.py @@ -204,6 +204,8 @@ def clamp_min_h(cls, v: int) -> int: @validator("w", always=True) def fit_within_grid(cls, w: int, values: dict[str, Any]) -> int: x = values.get("x", 0) + if not isinstance(x, int): + x = 0 if x + w > GRID_WIDTH: return GRID_WIDTH - x # Return the maximum width possible return w @@ -269,9 +271,10 @@ def check_limit_by_display_type(cls, values: dict[str, Any]) -> dict[str, Any]: limit = values.get("limit") if display_type is None or limit is None or display_type == "text": return values - max_limit = get_max_widget_limit( - DashboardWidgetDisplayTypes.get_id_for_type_name(display_type) - ) + display_type_id = DashboardWidgetDisplayTypes.get_id_for_type_name(display_type) + if display_type_id is None: + raise ValueError(f"Unknown display_type '{display_type}'") + max_limit = get_max_widget_limit(display_type_id) if limit > max_limit: raise ValueError( f"limit={limit} exceeds the maximum of {max_limit} for display_type '{display_type}'" @@ -285,9 +288,10 @@ def check_height_by_display_type(cls, values: dict[str, Any]) -> dict[str, Any]: if display_type is None or layout is None: return values - min_height = get_min_widget_height( - DashboardWidgetDisplayTypes.get_id_for_type_name(display_type) - ) + display_type_id = DashboardWidgetDisplayTypes.get_id_for_type_name(display_type) + if display_type_id is None: + raise ValueError(f"Unknown display_type '{display_type}'") + min_height = get_min_widget_height(display_type_id) if layout.h < min_height: raise ValueError(f"Height must be at least {min_height} for {display_type} widgets.") diff --git a/src/sentry/data_export/endpoints/data_export_details.py b/src/sentry/data_export/endpoints/data_export_details.py index bdcb9a8ca60a..508444728295 100644 --- a/src/sentry/data_export/endpoints/data_export_details.py +++ b/src/sentry/data_export/endpoints/data_export_details.py @@ -104,6 +104,6 @@ def download(self, data_export: ExportedData) -> StreamingHttpResponse: response = StreamingHttpResponse( iter(lambda: raw_file.read(4096), b""), content_type=content_type ) - response["Content-Length"] = file.size + response["Content-Length"] = str(file.size) response["Content-Disposition"] = f'attachment; filename="{file.name}"' return response diff --git a/src/sentry/data_export/models.py b/src/sentry/data_export/models.py index 7ad8397d754c..2c25dbed34c1 100644 --- a/src/sentry/data_export/models.py +++ b/src/sentry/data_export/models.py @@ -46,7 +46,7 @@ class ExportedData(Model): organization = FlexibleForeignKey("sentry.Organization") user_id = HybridCloudForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete="SET_NULL") - file_id = BoundedBigIntegerField(null=True, db_index=True) + file_id = BoundedBigIntegerField[int | None, int | None](null=True, db_index=True) date_added = models.DateTimeField(default=timezone.now) date_finished = models.DateTimeField(null=True) date_expired = models.DateTimeField(null=True, db_index=True) diff --git a/src/sentry/data_export/processors/discover.py b/src/sentry/data_export/processors/discover.py index 272ade9dbffd..b44f90312ee5 100644 --- a/src/sentry/data_export/processors/discover.py +++ b/src/sentry/data_export/processors/discover.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Protocol +from typing import Any, Protocol, cast from sentry_relay.consts import SPAN_STATUS_CODE_TO_NAME @@ -101,18 +101,22 @@ def get_data_fn( dataset = discover def data_fn(offset: int, limit: int) -> dict[str, Any]: - return dataset.query( - selected_columns=fields, - equations=equations, - query=query, - snuba_params=snuba_params, - offset=offset, - orderby=sort, - limit=limit, - referrer="data_export.tasks.discover", - auto_fields=True, - auto_aggregations=True, - use_aggregate_conditions=True, + # justification: the dataset module is selected at runtime and returns a JSON payload. + return cast( + "dict[str, Any]", + dataset.query( + selected_columns=fields, + equations=equations, + query=query, + snuba_params=snuba_params, + offset=offset, + orderby=sort, + limit=limit, + referrer="data_export.tasks.discover", + auto_fields=True, + auto_aggregations=True, + use_aggregate_conditions=True, + ), ) return data_fn diff --git a/src/sentry/data_export/processors/issues_by_tag.py b/src/sentry/data_export/processors/issues_by_tag.py index 35f279e13e00..ce29d90787a0 100644 --- a/src/sentry/data_export/processors/issues_by_tag.py +++ b/src/sentry/data_export/processors/issues_by_tag.py @@ -82,8 +82,10 @@ def get_lookup_key(key: str) -> str: return f"sentry:{key}" if tagstore.backend.is_reserved_key(key) else key @staticmethod - def serialize_row(item: GroupTagValueAndEventUser, key: str) -> dict[str, str]: - result = { + def serialize_row(item: GroupTagValueAndEventUser, key: str) -> dict[str, str | int | None]: + assert item.value.last_seen is not None + assert item.value.first_seen is not None + result: dict[str, str | int | None] = { "value": item.value.value, "times_seen": item.value.times_seen, "last_seen": item.value.last_seen.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), @@ -120,7 +122,9 @@ def get_raw_data(self, limit: int = 1000, offset: int = 0) -> list[GroupTagValue for item in items ] - def get_serialized_data(self, limit: int = 1000, offset: int = 0) -> list[dict[str, str]]: + def get_serialized_data( + self, limit: int = 1000, offset: int = 0 + ) -> list[dict[str, str | int | None]]: """ Returns list of serialized GroupTagValue dictionaries """ diff --git a/src/sentry/data_export/tasks.py b/src/sentry/data_export/tasks.py index a996d4916b37..07f3ea8025e0 100644 --- a/src/sentry/data_export/tasks.py +++ b/src/sentry/data_export/tasks.py @@ -240,7 +240,7 @@ def recoverable_retry_countdown(remaining_export_retries: int) -> int: attempt_count = max(0, DEFAULT_EXPORT_RETRIES - remaining_export_retries) return min( RECOVERABLE_RETRY_MAX_SECONDS, - RECOVERABLE_RETRY_BASE_SECONDS * (2**attempt_count), + RECOVERABLE_RETRY_BASE_SECONDS * (1 << attempt_count), ) @@ -659,6 +659,8 @@ def process_rows( rows = process_explore(processor, batch_size, offset) else: raise ExportError(f"No processor found for this query type: {data_export.query_type}") + if not isinstance(rows, list): + raise ExportError("Export processor returned no rows array") return rows except ExportError as error: error_str = str(error) @@ -714,6 +716,7 @@ def store_export_chunk_as_blob( data_export=data_export, blob_id=blob.id, offset=bytes_written + bytes_offset ) + assert blob.size is not None bytes_offset += blob.size # there is a maximum file size allowed, so we need to make sure we don't exceed it @@ -780,6 +783,7 @@ def merge_export_blobs( blob_offsets.append(int(export_blob.offset)) blob = FileBlob.objects.get(pk=export_blob.blob_id) FileBlobIndex.objects.create(file=file, blob=blob, offset=size) + assert blob.size is not None size += blob.size blob_checksum = sha1(b"") diff --git a/src/sentry/db/analyze.py b/src/sentry/db/analyze.py index e035c73ef472..d3d2b8063ccc 100644 --- a/src/sentry/db/analyze.py +++ b/src/sentry/db/analyze.py @@ -1,16 +1,17 @@ from __future__ import annotations from django.db import connections, router +from django.db.models import Model class AnalyzeQuery: - def __init__(self, model): + def __init__(self, model: type[Model]) -> None: self.model = model self.using = router.db_for_write(model) - def execute(self): + def execute(self) -> None: query = f""" analyze {self.model._meta.db_table}; """ - return connections[self.using].cursor().execute(query) + connections[self.using].cursor().execute(query) diff --git a/src/sentry/db/models/base.py b/src/sentry/db/models/base.py index bcbdf9711c4e..8b4cb9cc408b 100644 --- a/src/sentry/db/models/base.py +++ b/src/sentry/db/models/base.py @@ -91,7 +91,9 @@ def __reduce__( def __setstate__(self, state: Mapping[str, Any]) -> None: self.__dict__.update(state) - def _get_relational_field(self, field_name: str) -> models.ForeignKey: + def _get_relational_field( + self, field_name: str + ) -> models.ForeignKey[models.Model, models.Model]: ret = self._meta.get_field(field_name) if not isinstance(ret, models.ForeignKey): raise TypeError(f"expected {field_name=} to be ForeignKey") @@ -292,7 +294,7 @@ def normalize_before_relocation_import( old_pk = self.pk self.pk = None - return old_pk + return old_pk if isinstance(old_pk, int) else None def normalize_before_relocation_export(self) -> None: """ diff --git a/src/sentry/db/models/fields/array.py b/src/sentry/db/models/fields/array.py index 11b3d1423c66..a478358cd1c8 100644 --- a/src/sentry/db/models/fields/array.py +++ b/src/sentry/db/models/fields/array.py @@ -6,8 +6,11 @@ from __future__ import annotations import ast +from collections.abc import Iterable +from typing import Any from django.db import models +from django.db.backends.base.base import BaseDatabaseWrapper from sentry.db.models.utils import Creator from sentry.utils import json @@ -15,8 +18,15 @@ # Adapted from django-pgfields # https://github.com/lukesneeringer/django-pgfields/blob/master/django_pg/models/fields/array.py -class ArrayField(models.Field): - def __init__(self, of=models.TextField, **kwargs): +class ArrayField(models.Field[object, object]): + of: models.Field[object, object] + + def __init__( + self, + of: type[models.Field[object, object]] | models.Field[object, object] = models.TextField, + # justification: Django field constructors accept arbitrary keyword options. + **kwargs: Any, + ): # Arrays in PostgreSQL are arrays of a particular type. # Save the subtype in our field class. if isinstance(of, type): @@ -30,7 +40,9 @@ def __init__(self, of=models.TextField, **kwargs): super().__init__(**kwargs) - def contribute_to_class(self, cls: type[models.Model], name: str, private_only: bool = False): + def contribute_to_class( + self, cls: type[models.Model], name: str, private_only: bool = False + ) -> None: """ Add a descriptor for backwards compatibility with previous Django behavior. @@ -38,13 +50,13 @@ def contribute_to_class(self, cls: type[models.Model], name: str, private_only: super().contribute_to_class(cls, name, private_only=private_only) setattr(cls, name, Creator(self)) - def db_type(self, connection) -> str: + def db_type(self, connection: BaseDatabaseWrapper) -> str: return f"{self.of.db_type(connection)}[]" def get_internal_type(self) -> str: return "TextField" - def get_prep_value(self, value): + def get_prep_value(self, value: Iterable[object]) -> list[object]: """Iterate over each item in the array, and run it through the `get_prep_value` of this array's type. """ @@ -56,7 +68,8 @@ def get_prep_value(self, value): # our array. return [self.of.get_prep_value(item) for item in value] - def to_python(self, value): + # justification: database values are arbitrary JSON-ish payloads. + def to_python(self, value: Any) -> list[object]: if not value: value = [] if isinstance(value, str): diff --git a/src/sentry/db/models/fields/bounded.py b/src/sentry/db/models/fields/bounded.py index c68991983491..7a99626a7ca1 100644 --- a/src/sentry/db/models/fields/bounded.py +++ b/src/sentry/db/models/fields/bounded.py @@ -1,6 +1,9 @@ from __future__ import annotations +from typing import TypeVar, cast + from django.db import models +from django.db.models.expressions import Combinable from django.utils.translation import gettext_lazy as _ __all__ = ( @@ -13,32 +16,37 @@ "WrappingU32IntegerField", ) +_ST = TypeVar("_ST", contravariant=True, default=int) +_GT = TypeVar("_GT", covariant=True, default=int) + I32_MAX = 2_147_483_647 # 2**31 - 1 U32_MAX = 4_294_967_295 # 2**32 - 1 I64_MAX = 9_223_372_036_854_775_807 # 2**63 - 1 -class BoundedIntegerField(models.IntegerField): +class BoundedIntegerField(models.IntegerField[_ST, _GT]): MAX_VALUE = I32_MAX def get_prep_value(self, value: int) -> int: if value: value = int(value) assert value <= self.MAX_VALUE - return super().get_prep_value(value) + # justification: django-stubs types Field.get_prep_value as returning Any. + return cast("int", super().get_prep_value(value)) -class BoundedPositiveIntegerField(models.PositiveIntegerField): +class BoundedPositiveIntegerField(models.PositiveIntegerField[_ST, _GT]): MAX_VALUE = I32_MAX def get_prep_value(self, value: int) -> int: if value: value = int(value) assert value <= self.MAX_VALUE - return super().get_prep_value(value) + # justification: django-stubs types Field.get_prep_value as returning Any. + return cast("int", super().get_prep_value(value)) -class WrappingU32IntegerField(models.IntegerField): +class WrappingU32IntegerField(models.IntegerField[_ST, _GT]): """ This type allows storing a full unsigned `u32` value by manually wrapping it around, so it is stored as a signed `i32` value in the database. @@ -55,9 +63,12 @@ def get_prep_value(self, value: int) -> int: if value > I32_MAX: value = value - 2**32 - return super().get_prep_value(value) + # justification: django-stubs types Field.get_prep_value as returning Any. + return cast("int", super().get_prep_value(value)) - def from_db_value(self, value: int | None, expression, connection) -> int | None: + def from_db_value( + self, value: int | None, expression: object, connection: object + ) -> int | None: if value is None: return None if value < 0: @@ -65,17 +76,18 @@ def from_db_value(self, value: int | None, expression, connection) -> int | None return value -class BoundedAutoField(models.AutoField): +class BoundedAutoField(models.AutoField[int, int]): MAX_VALUE = I32_MAX def get_prep_value(self, value: int) -> int: if value: value = int(value) assert value <= self.MAX_VALUE - return super().get_prep_value(value) + # justification: django-stubs types Field.get_prep_value as returning Any. + return cast("int", super().get_prep_value(value)) -class BoundedBigIntegerField(models.BigIntegerField): +class BoundedBigIntegerField(models.BigIntegerField[_ST, _GT]): description = _("Big Integer") MAX_VALUE = I64_MAX @@ -87,10 +99,11 @@ def get_prep_value(self, value: int) -> int: if value: value = int(value) assert value <= self.MAX_VALUE - return super().get_prep_value(value) + # justification: django-stubs types Field.get_prep_value as returning Any. + return cast("int", super().get_prep_value(value)) -class BoundedPositiveBigIntegerField(models.PositiveBigIntegerField): +class BoundedPositiveBigIntegerField(models.PositiveBigIntegerField[_ST, _GT]): description = _("Positive big integer") MAX_VALUE = I64_MAX @@ -102,10 +115,14 @@ def get_prep_value(self, value: int) -> int: if value: value = int(value) assert value <= self.MAX_VALUE - return super().get_prep_value(value) + # justification: django-stubs types Field.get_prep_value as returning Any. + return cast("int", super().get_prep_value(value)) + +class BoundedBigAutoField(models.BigAutoField[int, int]): + _pyi_private_set_type: float | int | str | Combinable + _pyi_private_get_type: int -class BoundedBigAutoField(models.BigAutoField): description = _("Big Integer") MAX_VALUE = I64_MAX @@ -117,4 +134,5 @@ def get_prep_value(self, value: int) -> int: if value: value = int(value) assert value <= self.MAX_VALUE - return super().get_prep_value(value) + # justification: django-stubs types Field.get_prep_value as returning Any. + return cast("int", super().get_prep_value(value)) diff --git a/src/sentry/db/models/fields/encryption/_base.py b/src/sentry/db/models/fields/encryption/_base.py index 16b6007a674d..cb4fe5a733c5 100644 --- a/src/sentry/db/models/fields/encryption/_base.py +++ b/src/sentry/db/models/fields/encryption/_base.py @@ -3,7 +3,7 @@ import base64 import logging from collections.abc import Callable -from typing import Any, Literal, TypedDict +from typing import Any, Literal, TypedDict, TypeVar, cast import sentry_sdk from cryptography.fernet import InvalidToken @@ -32,8 +32,11 @@ class _EncryptionHandler(TypedDict): type _EncryptionMethod = Literal["plaintext"] | Literal["fernet"] | Literal["keysets"] +_ST = TypeVar("_ST") +_GT = TypeVar("_GT") -class EncryptedField(Field): + +class EncryptedField(Field[_ST, _GT]): """ A mixin that adds encryption functionality to Django fields. @@ -57,14 +60,13 @@ class EncryptedField(Field): The Fernet format always includes a key_id to support key rotation. """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Default to "unknown" - will be overridden by __set_name__ or contribute_to_class - # when the field is properly attached to a model - self._model_name = "unknown" + # Default to "unknown" - will be overridden by contribute_to_class when the + # field is properly attached to a model. + _model_name: str = "unknown" - self._encryption_handlers: dict[_EncryptionMethod, _EncryptionHandler] = { + @property + def _encryption_handlers(self) -> dict[_EncryptionMethod, _EncryptionHandler]: + return { "plaintext": { "marker": MARKER_PLAINTEXT, "encrypt": self._encrypt_plaintext, @@ -150,7 +152,8 @@ def get_prep_value(self, value: Any) -> Any: @traces.trace def from_db_value(self, value: Any, expression: Any, connection: Any) -> bytes | str | None: - return self.to_python(value) + # justification: to_python decodes the stored ciphertext to bytes or str. + return cast("bytes | str | None", self.to_python(value)) @traces.trace def to_python(self, value: Any) -> Any: diff --git a/src/sentry/db/models/fields/encryption/encrypted_char_field.py b/src/sentry/db/models/fields/encryption/encrypted_char_field.py index d35be2edc365..edaf7d472b1c 100644 --- a/src/sentry/db/models/fields/encryption/encrypted_char_field.py +++ b/src/sentry/db/models/fields/encryption/encrypted_char_field.py @@ -6,7 +6,7 @@ from ._base import EncryptedField -class EncryptedCharField(EncryptedField, CharField): +class EncryptedCharField(EncryptedField[str, str], CharField[str, str]): @traces.trace def from_db_value(self, value: Any, expression: Any, connection: Any) -> Any: db_value = super().from_db_value(value, expression, connection) diff --git a/src/sentry/db/models/fields/encryption/encrypted_json_field.py b/src/sentry/db/models/fields/encryption/encrypted_json_field.py index fb6c9105a878..3b393bf5bd14 100644 --- a/src/sentry/db/models/fields/encryption/encrypted_json_field.py +++ b/src/sentry/db/models/fields/encryption/encrypted_json_field.py @@ -11,7 +11,9 @@ logger = logging.getLogger(__name__) -class EncryptedJSONField(EncryptedField, JSONField): +class EncryptedJSONField( # justification: decrypted JSON is arbitrary. + EncryptedField[Any, Any], JSONField[Any, Any] +): """ An encrypted field that stores JSON data. @@ -34,7 +36,10 @@ class EncryptedJSONField(EncryptedField, JSONField): _encrypted_field_key = "sentry_encrypted_field_value" @traces.trace - def get_prep_value(self, value: Any) -> dict | None: + def get_prep_value( + self, + value: Any, # justification: stored JSON is arbitrary. + ) -> dict[str, str] | None: """ Prepare value for database storage. @@ -48,7 +53,7 @@ def get_prep_value(self, value: Any) -> dict | None: # Encrypt the JSON string using the EncryptedField # This will return something like "enc:fernet:key_id:base64data" - encrypted_value = super().get_prep_value(json_string) + encrypted_value: str | None = super().get_prep_value(json_string) if encrypted_value is None: return None diff --git a/src/sentry/db/models/fields/encryption/encrypted_text_field.py b/src/sentry/db/models/fields/encryption/encrypted_text_field.py index 7a9891cbe459..cc0a97c44d0b 100644 --- a/src/sentry/db/models/fields/encryption/encrypted_text_field.py +++ b/src/sentry/db/models/fields/encryption/encrypted_text_field.py @@ -6,7 +6,7 @@ from ._base import EncryptedField -class EncryptedTextField(EncryptedField, TextField): +class EncryptedTextField(EncryptedField[str | None, str | None], TextField[str | None, str | None]): @traces.trace def from_db_value(self, value: Any, expression: Any, connection: Any) -> Any: db_value = super().from_db_value(value, expression, connection) diff --git a/src/sentry/db/models/fields/gzippeddict.py b/src/sentry/db/models/fields/gzippeddict.py index df7edb275580..900c2c8f600b 100644 --- a/src/sentry/db/models/fields/gzippeddict.py +++ b/src/sentry/db/models/fields/gzippeddict.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import cast + from django.db.models import Model, TextField from sentry.db.models.utils import Creator @@ -8,7 +10,7 @@ __all__ = ("GzippedDictField",) -class GzippedDictField(TextField): +class GzippedDictField(TextField[dict[str, object] | None, dict[str, object] | None]): """ Slightly different from a JSONField in the sense that the default value is a dictionary. @@ -22,18 +24,18 @@ def contribute_to_class(self, cls: type[Model], name: str, private_only: bool = super().contribute_to_class(cls, name, private_only=private_only) setattr(cls, name, Creator(self)) - def to_python(self, value): + def to_python(self, value: object) -> object: try: if not value: return {} - return json.loads(value) + return json.loads(cast(str | bytes, value)) except (ValueError, TypeError): return value - def from_db_value(self, value, expression, connection): + def from_db_value(self, value: object, expression: object, connection: object) -> object: return self.to_python(value) - def get_prep_value(self, value): + def get_prep_value(self, value: object) -> str | None: if not value and self.null: # save ourselves some storage return None @@ -43,5 +45,5 @@ def get_prep_value(self, value): return None return json.dumps(value) - def value_to_string(self, obj): - return self.get_prep_value(self.value_from_object(obj)) + def value_to_string(self, obj: Model) -> str: + return cast(str, self.get_prep_value(self.value_from_object(obj))) diff --git a/src/sentry/db/models/fields/hybrid_cloud_foreign_key.py b/src/sentry/db/models/fields/hybrid_cloud_foreign_key.py index b9565b04309e..06b4d9f3c1b3 100644 --- a/src/sentry/db/models/fields/hybrid_cloud_foreign_key.py +++ b/src/sentry/db/models/fields/hybrid_cloud_foreign_key.py @@ -47,7 +47,7 @@ from __future__ import annotations from enum import IntEnum -from typing import Any +from typing import Any, cast from django.apps import apps from django.db import models @@ -71,7 +71,8 @@ def foreign_model(self) -> Any: @property def foreign_table_name(self) -> str: - return self.foreign_model._meta.db_table + # justification: Django model metadata exposes the table name as a str. + return cast("str", self.foreign_model._meta.db_table) def __init__( self, diff --git a/src/sentry/db/models/fields/jsonfield.py b/src/sentry/db/models/fields/jsonfield.py index c85d63333f71..2356a49a1180 100644 --- a/src/sentry/db/models/fields/jsonfield.py +++ b/src/sentry/db/models/fields/jsonfield.py @@ -28,7 +28,8 @@ from __future__ import annotations -from typing import Any +from collections.abc import Callable +from typing import TYPE_CHECKING, Any from django.core.exceptions import ValidationError from django.db import models @@ -39,8 +40,19 @@ from sentry.db.models.utils import Creator from sentry.utils import json +if TYPE_CHECKING: + _Lookup = Lookup[Any] + _Exact = Exact[Any] + _IExact = IExact[Any] + _In = In[Any] +else: + _Lookup = Lookup + _Exact = Exact + _IExact = IExact + _In = In -class JSONField(models.TextField): + +class JSONField(models.TextField[Any, Any]): """ A field that will ensure the data entered into it is valid JSON. @@ -60,7 +72,9 @@ class JSONField(models.TextField): description = "JSON object" no_creator_hook = False - def __init__(self, *args, json_dumps=json.dumps, **kwargs): + def __init__( + self, *args: Any, json_dumps: Callable[[Any], str] = json.dumps, **kwargs: Any + ) -> None: self.json_dumps = json_dumps if not kwargs.get("null", False): kwargs["default"] = kwargs.get("default", dict) @@ -78,7 +92,7 @@ def contribute_to_class( if not self.no_creator_hook: setattr(cls, name, Creator(self)) - def validate(self, value, model_instance): + def validate(self, value: Any, model_instance: models.Model | None) -> None: if not self.null and value is None: raise ValidationError(self.error_messages["null"]) try: @@ -86,7 +100,7 @@ def validate(self, value, model_instance): except Exception: raise ValidationError(self.error_messages["invalid"] % value) - def get_default(self): + def get_default(self) -> Any: if self.has_default(): default = self.default if callable(default): @@ -99,10 +113,10 @@ def get_default(self): def get_internal_type(self) -> str: return "TextField" - def db_type(self, connection) -> str: + def db_type(self, connection: BaseDatabaseWrapper) -> str: return "text" - def to_python(self, value): + def to_python(self, value: Any) -> Any: if isinstance(value, str) or self.no_creator_hook: if value == "": if self.null: @@ -117,45 +131,47 @@ def to_python(self, value): # TODO: Look for date/time/datetime objects within the structure? return value - def get_db_prep_value(self, value, connection=None, prepared=None): + def get_db_prep_value( + self, value: Any, connection: BaseDatabaseWrapper | None = None, prepared: bool = False + ) -> Any: return self.get_prep_value(value) - def get_prep_value(self, value): + def get_prep_value(self, value: Any) -> str | None: if value is None: if not self.null and self.blank: return "" return None return self.json_dumps(value) - def value_to_string(self, obj): + def value_to_string(self, obj: models.Model) -> Any: return self.value_from_object(obj) -class NoPrepareMixin(Lookup): - def get_prep_lookup(self): +class NoPrepareMixin(_Lookup): + def get_prep_lookup(self) -> Any: return self.rhs -class JSONFieldExactLookup(NoPrepareMixin, Exact): - def get_prep_lookup(self): +class JSONFieldExactLookup(NoPrepareMixin, _Exact): + def get_prep_lookup(self) -> Any: return self.lhs.output_field.to_python(self.lhs.output_field.get_prep_value(self.rhs)) -class JSONFieldIExactLookup(NoPrepareMixin, IExact): - def get_prep_lookup(self): +class JSONFieldIExactLookup(NoPrepareMixin, _IExact): + def get_prep_lookup(self) -> Any: return self.lhs.output_field.to_python(self.lhs.output_field.get_prep_value(self.rhs)) -class JSONFieldInLookup(NoPrepareMixin, In): - def get_prep_lookup(self): +class JSONFieldInLookup(NoPrepareMixin, _In): + def get_prep_lookup(self) -> Any: return [ self.lhs.output_field.to_python(self.lhs.output_field.get_prep_value(v)) for v in self.rhs ] -class ContainsLookupMixin(Lookup): - def get_prep_lookup(self): +class ContainsLookupMixin(_Lookup): + def get_prep_lookup(self) -> Any: if isinstance(self.rhs, (list, tuple)): raise TypeError( "Lookup type %r not supported with %s argument" diff --git a/src/sentry/db/models/fields/node.py b/src/sentry/db/models/fields/node.py index f345f3c2f9e3..de62dd177320 100644 --- a/src/sentry/db/models/fields/node.py +++ b/src/sentry/db/models/fields/node.py @@ -1,8 +1,9 @@ from __future__ import annotations import logging -from collections.abc import MutableMapping -from typing import Any +from collections.abc import Callable, Iterator, Mapping, MutableMapping +from copy import copy as shallow_copy +from typing import Any, cast from django.utils.functional import cached_property @@ -27,7 +28,14 @@ class NodeData(MutableMapping[str, Any]): data={...} means, this is an object that should be saved to nodestore. """ - def __init__(self, id, data=None, wrapper=None, ref_version=None, ref_func=None): + def __init__( + self, + id: str, + data: Mapping[str, Any] | None = None, + wrapper: Callable[[Mapping[str, Any]], MutableMapping[str, Any]] | None = None, + ref_version: int | None = None, + ref_func: Callable[[Any], object] | None = None, + ) -> None: self.id = id self.ref = None # ref version is used to discredit a previous ref @@ -38,28 +46,30 @@ def __init__(self, id, data=None, wrapper=None, ref_version=None, ref_func=None) self.wrapper = wrapper if data is not None and self.wrapper is not None: data = self.wrapper(data) - self._node_data = data + self._node_data: MutableMapping[str, Any] | None = ( + cast("MutableMapping[str, Any]", data) if data is not None else None + ) - def __getstate__(self): + def __getstate__(self) -> dict[str, object]: data = dict(self.__dict__) data.pop("data", None) # downgrade this into a normal dict in case it's a shim dict. data["_node_data"] = dict(data["_node_data"].items()) return data - def __setstate__(self, state): + def __setstate__(self, state: dict[str, object]) -> None: self.__dict__ = state - def __getitem__(self, key): + def __getitem__(self, key: str) -> Any: return self.data[key] - def __setitem__(self, key, value): + def __setitem__(self, key: str, value: Any) -> None: self.data[key] = value - def __delitem__(self, key): + def __delitem__(self, key: str) -> None: del self.data[key] - def __iter__(self): + def __iter__(self) -> Iterator[str]: return iter(self.data) def __len__(self) -> int: @@ -71,16 +81,16 @@ def __repr__(self) -> str: return f"<{cls_name}: id={self.id} data={self._node_data!r}>" return f"<{cls_name}: id={self.id}>" - def get_ref(self, instance): + def get_ref(self, instance: Any) -> object | None: if not self.ref_func: - return + return None return self.ref_func(instance) - def copy(self): - return self.data.copy() + def copy(self) -> MutableMapping[str, Any]: + return shallow_copy(self.data) @cached_property - def data(self): + def data(self) -> MutableMapping[str, Any]: """ Get the current data object, fetching from nodestore if necessary. """ @@ -90,14 +100,14 @@ def data(self): elif self.id: self.bind_data(nodestore.backend.get(self.id) or {}) - return self._node_data + return cast("MutableMapping[str, Any]", self._node_data) - rv: dict[str, Any] = {} + rv: MutableMapping[str, Any] = {} if self.wrapper is not None: rv = self.wrapper(rv) return rv - def bind_data(self, data, ref=None): + def bind_data(self, data: MutableMapping[str, Any], ref: object | None = None) -> None: self.ref = data.pop("_ref", ref) ref_version = data.pop("_ref_version", None) if ref_version == self.ref_version and ref is not None and self.ref != ref: @@ -108,13 +118,13 @@ def bind_data(self, data, ref=None): data = self.wrapper(data) self._node_data = data - def bind_ref(self, instance): + def bind_ref(self, instance: Any) -> None: ref = self.get_ref(instance) if ref: self.data["_ref"] = ref self.data["_ref_version"] = self.ref_version - def save(self, subkeys=None): + def save(self, subkeys: dict[str | None, Mapping[str, Any]] | None = None) -> None: """ Write current data back to nodestore. @@ -126,7 +136,7 @@ def save(self, subkeys=None): # We never loaded any data for reading or writing, so there # is nothing to save. if self._node_data is None: - return + return None # We can't put our wrappers into the nodestore, so we need to # ensure that the data is converted into a plain old dict 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/db/models/fields/slug.py b/src/sentry/db/models/fields/slug.py index 94e569fcfb15..a9b7753c8f24 100644 --- a/src/sentry/db/models/fields/slug.py +++ b/src/sentry/db/models/fields/slug.py @@ -1,24 +1,37 @@ +from typing import TYPE_CHECKING + +from django.db.backends.base.base import BaseDatabaseWrapper from django.db.models import SlugField from django.db.models.lookups import Lookup +from django.db.models.sql.compiler import SQLCompiler from sentry.db.models.fields.bounded import BoundedBigAutoField from sentry.utils.slug import no_numeric_validator, org_slug_validator DEFAULT_SLUG_MAX_LENGTH = 50 +if TYPE_CHECKING: + _SlugField = SlugField[str, str] + _Lookup = Lookup[str | int] +else: + _SlugField = SlugField + _Lookup = Lookup + -class SentrySlugField(SlugField): +class SentrySlugField(_SlugField): default_validators = [*SlugField.default_validators, no_numeric_validator] -class SentryOrgSlugField(SlugField): +class SentryOrgSlugField(_SlugField): default_validators = [*SlugField.default_validators, org_slug_validator] -class IdOrSlugLookup(Lookup): +class IdOrSlugLookup(_Lookup): lookup_name = "id_or_slug" - def as_sql(self, compiler, connection): + def as_sql( + self, compiler: SQLCompiler, connection: BaseDatabaseWrapper + ) -> tuple[str, list[str | int] | tuple[str | int, ...]]: lhs, lhs_params = self.process_lhs(compiler, connection) rhs, rhs_params = self.process_rhs(compiler, connection) diff --git a/src/sentry/db/models/fields/text.py b/src/sentry/db/models/fields/text.py index 26f391f01483..0b12d84442ab 100644 --- a/src/sentry/db/models/fields/text.py +++ b/src/sentry/db/models/fields/text.py @@ -24,9 +24,9 @@ def db_type(self, connection: BaseDatabaseWrapper) -> str: return "text" -class CharField(TextType, models.CharField): +class CharField[GetT: str | None, SetT: str | None](TextType, models.CharField[GetT, SetT]): pass -class EmailField(TextType, models.EmailField): +class EmailField(TextType, models.EmailField[str, str]): pass diff --git a/src/sentry/db/models/fields/uuid.py b/src/sentry/db/models/fields/uuid.py index 377e69d59bfd..a64c1d9b6dcb 100644 --- a/src/sentry/db/models/fields/uuid.py +++ b/src/sentry/db/models/fields/uuid.py @@ -31,23 +31,38 @@ from __future__ import annotations import importlib +from collections.abc import Callable +from typing import Any, TypeVar, cast from uuid import UUID, uuid4 from django.db import models +from django.db.backends.base.base import BaseDatabaseWrapper from django.db.models.fields import NOT_PROVIDED from psycopg2.extensions import register_adapter from sentry.db.models.utils import Creator - # Adapted from django-pgfields # https://github.com/lukesneeringer/django-pgfields/blob/master/django_pg/models/fields/uuid.py -class UUIDField(models.Field): +_SetType = TypeVar("_SetType", default=UUID) +_GetType = TypeVar("_GetType", default=UUID) + + +class UUIDField(models.Field[_SetType, _GetType]): """Field for storing UUIDs.""" description = "Universally unique identifier." - def __init__(self, auto_add=False, coerce_to=UUID, **kwargs): + _auto_add: bool | Callable[[], UUID] + _coerce_to: type[UUID] + + def __init__( + self, + auto_add: bool | str | Callable[[], UUID] = False, + coerce_to: type[UUID] = UUID, + # justification: Django field constructors accept arbitrary keyword options. + **kwargs: Any, + ) -> None: """Instantiate the field.""" # If the `auto_add` argument is specified as True, substitute an @@ -64,7 +79,7 @@ def __init__(self, auto_add=False, coerce_to=UUID, **kwargs): auto_add = getattr(module, member) # Save the `auto_add` and `coerce_to` rules. - self._auto_add = auto_add + self._auto_add = cast("bool | Callable[[], UUID]", auto_add) self._coerce_to = coerce_to # If `auto_add` is enabled, it should imply that the field @@ -89,13 +104,13 @@ def __init__(self, auto_add=False, coerce_to=UUID, **kwargs): # Now pass the rest of the work to CharField. super().__init__(**kwargs) - def db_type(self, connection) -> str: + def db_type(self, connection: BaseDatabaseWrapper) -> str: return "uuid" def get_internal_type(self) -> str: return "CharField" - def get_prep_value(self, value): + def get_prep_value(self, value: object) -> UUID | None: """Return a wrapped, valid UUID value.""" # If the value is None, return None. @@ -111,23 +126,25 @@ def get_prep_value(self, value): return value # Convert our value to a UUID. - return UUID(value) + return UUID(cast("str | None", value)) - def pre_save(self, instance, add): + def pre_save(self, instance: models.Model, add: bool) -> UUID | None: """If auto is set, generate a UUID at random.""" # If the `auto_add` option was set, and there is no value # on the model instance, then generate a UUID using the given # callable. - if self._auto_add and add and not getattr(instance, self.attname): - uuid_value = self._auto_add() + auto_add = self._auto_add + if callable(auto_add) and add and not getattr(instance, self.attname): + uuid_value = auto_add() # Save the UUID to the model instance setattr(instance, self.attname, uuid_value) return uuid_value # This is the standard case; just use the superclass logic. - return super().pre_save(instance, add) + # justification: Django's UUIDField returns the assigned UUID or None. + return cast("UUID | None", super().pre_save(instance, add)) def contribute_to_class( self, cls: type[models.Model], name: str, private_only: bool = False @@ -135,14 +152,14 @@ def contribute_to_class( super().contribute_to_class(cls, name, private_only=private_only) setattr(cls, name, Creator(self)) - def to_python(self, value): + def to_python(self, value: object) -> object: """Return a UUID object.""" if isinstance(value, self._coerce_to) or not value: return value - return self._coerce_to(value) + return self._coerce_to(cast("str | None", value)) @property - def _auto_add_str(self): + def _auto_add_str(self) -> bool | str: """Return a dot path, as a string, of the `_auto_add` callable. If `_auto_add` is a boolean, return it unchanged. """ @@ -152,12 +169,12 @@ def _auto_add_str(self): class UUIDAdapter: - def __init__(self, value): + def __init__(self, value: object) -> None: if not isinstance(value, UUID): raise TypeError("UUIDAdapter only understands UUID objects.") self.value = value - def getquoted(self): + def getquoted(self) -> bytes: return ("'%s'" % self.value).encode("utf8") diff --git a/src/sentry/db/models/manager/base.py b/src/sentry/db/models/manager/base.py index 92eee92750f1..3bff53d36677 100644 --- a/src/sentry/db/models/manager/base.py +++ b/src/sentry/db/models/manager/base.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Collection, Generator, Mapping, MutableMapping, Sequence from contextlib import contextmanager from enum import IntEnum, auto -from typing import Any +from typing import Any, cast from django.conf import settings from django.db import models, router, transaction @@ -75,7 +75,8 @@ class BaseManager(_base_manager_base[M]): lookup_handlers = {"iexact": lambda x: x.upper()} use_for_related_fields = True - _queryset_class = BaseQuerySet + # justification: queryset rows hold decoded values of any type. + _queryset_class: type[BaseQuerySet[M, Any]] = BaseQuerySet def __init__( self, @@ -125,13 +126,13 @@ def _get_local_cache(self) -> MutableMapping[str, M] | None: _local_cache.cache = {} _local_cache.generation = gen - return _local_cache.cache + return cast("MutableMapping[str, M] | None", _local_cache.cache) def _get_cache(self) -> MutableMapping[str, Any]: if not hasattr(self.__local_cache, "value"): self.__local_cache.value = weakref.WeakKeyDictionary() - return self.__local_cache.value + return cast("MutableMapping[str, Any]", self.__local_cache.value) def _set_cache(self, value: Any) -> None: self.__local_cache.value = value @@ -599,7 +600,8 @@ def create_silo_limited_copy(self: BaseManager[M], limit: SiloLimit) -> BaseMana else None ), } - manager_subclass = type(cls.__name__, (cls,), overrides) + # justification: type() builds a manager subclass carrying the overrides above. + manager_subclass = cast("type[BaseManager[M]]", type(cls.__name__, (cls,), overrides)) manager_instance = manager_subclass() # Ordinarily a pointer to the model class is set after the class is defined, @@ -616,7 +618,7 @@ def create_silo_limited_copy(self: BaseManager[M], limit: SiloLimit) -> BaseMana # Dynamically extend and replace the queryset class. This will affect all # queryset objects later returned from the new manager. - qs_cls = manager_instance._queryset_class + qs_cls: type[object] = manager_instance._queryset_class assert issubclass(qs_cls, BaseQuerySet) or issubclass(qs_cls, models.QuerySet) queryset_overrides = { "bulk_create": limit.create_override(qs_cls.bulk_create), @@ -627,7 +629,10 @@ def create_silo_limited_copy(self: BaseManager[M], limit: SiloLimit) -> BaseMana "update": limit.create_override(qs_cls.update), "update_or_create": limit.create_override(qs_cls.update_or_create), } - queryset_subclass = type(qs_cls.__name__, (qs_cls,), queryset_overrides) + # justification: type() builds a queryset subclass carrying the overrides above. + queryset_subclass = cast( + "type[BaseQuerySet[M, Any]]", type(qs_cls.__name__, (qs_cls,), queryset_overrides) + ) manager_instance._queryset_class = queryset_subclass return manager_instance diff --git a/src/sentry/db/models/manager/base_query_set.py b/src/sentry/db/models/manager/base_query_set.py index d9e9f0f3e6a4..c873ae52680e 100644 --- a/src/sentry/db/models/manager/base_query_set.py +++ b/src/sentry/db/models/manager/base_query_set.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Self +from typing import Any, Self, cast from django.core import exceptions from django.core.exceptions import EmptyResultSet @@ -30,7 +30,8 @@ def with_post_update_signal(self, enable: bool) -> Self: def _clone(self) -> Self: qs = super()._clone() # type: ignore[misc] qs._with_post_update_signal = self._with_post_update_signal - return qs + # justification: Django returns an instance of the same queryset class. + return cast("Self", qs) def update_with_returning( self, returned_fields: list[str], **kwargs: Any @@ -99,6 +100,8 @@ def update_with_returning( self._send_post_update_signal(updated_fields=list(kwargs.keys()), model_ids=model_ids) + if not isinstance(result_ids, list): + raise TypeError("update_with_returning returned no ids array") return result_ids update_with_returning.alters_data = True # type: ignore[attr-defined] diff --git a/src/sentry/db/models/manager/option.py b/src/sentry/db/models/manager/option.py index f09ae2c328ea..7335acd6c5cb 100644 --- a/src/sentry/db/models/manager/option.py +++ b/src/sentry/db/models/manager/option.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from django.core.signals import request_finished from django.db.models import Model @@ -16,7 +16,8 @@ def _option_cache(self) -> dict[str, dict[str, Any]]: if not hasattr(_local_cache, "option_cache"): _local_cache.option_cache = {} - return _local_cache.option_cache + # justification: option_cache is initialized as a dict above. + return cast("dict[str, dict[str, Any]]", _local_cache.option_cache) def clear_local_cache(self, **kwargs: Any) -> None: self._option_cache.clear() diff --git a/src/sentry/db/models/query.py b/src/sentry/db/models/query.py index 95d6ad500e19..c91c638b6d4b 100644 --- a/src/sentry/db/models/query.py +++ b/src/sentry/db/models/query.py @@ -2,7 +2,7 @@ import operator from functools import reduce -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from django.db import router from django.db.models import F, Model, Q @@ -32,15 +32,18 @@ class CannotResolveExpression(Exception): def resolve_combined_expression(instance: Model, node: CombinedExpression) -> BaseExpression: def _resolve(instance: Model, node: BaseExpression | F) -> BaseExpression: if isinstance(node, Value): - return node.value + # justification: Value wraps the raw expression value. + return cast("BaseExpression", node.value) if isinstance(node, F): - return getattr(instance, node.name) + # justification: F names a model field, resolved dynamically here. + return cast("BaseExpression", getattr(instance, node.name)) if isinstance(node, CombinedExpression): return resolve_combined_expression(instance, node) return node if isinstance(node, Value): - return node.value + # justification: Value wraps the raw expression value. + return cast("BaseExpression", node.value) if not isinstance(node, CombinedExpression): raise CannotResolveExpression op = COMBINED_EXPRESSION_CALLBACKS.get(node.connector, None) diff --git a/src/sentry/db/postgres/base.py b/src/sentry/db/postgres/base.py index 8b340f8075a3..39069a50c15e 100644 --- a/src/sentry/db/postgres/base.py +++ b/src/sentry/db/postgres/base.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Iterator from typing import Any import psycopg2 @@ -73,30 +73,30 @@ def _execute__include_sql_in_error( raise -class CursorWrapper: +class CursorWrapper: # justification: wraps untyped psycopg2 cursor objects. """ A wrapper around the postgresql_psycopg2 backend which handles various events from cursors, such as auto reconnects and lazy time zone evaluation. """ - def __init__(self, db, cursor): + def __init__(self, db: DatabaseWrapper, cursor: Any) -> None: self.db = db self.cursor = cursor - def __getattr__(self, attr): + def __getattr__(self, attr: str) -> Any: return getattr(self.cursor, attr) - def __iter__(self): + def __iter__(self) -> Iterator[Any]: return iter(self.cursor) @auto_reconnect_cursor - def execute(self, sql, params=None): + def execute(self, sql: str, params: object = None) -> Any: if params is not None: return self.cursor.execute(sql, params) return self.cursor.execute(sql) @auto_reconnect_cursor - def executemany(self, sql, paramlist=()): + def executemany(self, sql: str, paramlist: Iterable[object] = ()) -> Any: return self.cursor.executemany(sql, paramlist) @@ -104,13 +104,13 @@ class DatabaseWrapper(DjangoDatabaseWrapper): SchemaEditorClass = DatabaseSchemaEditorProxy # type: ignore[assignment] queries_limit = 15000 - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.ops = DatabaseOperations(self) self.execute_wrappers.extend((_execute__include_sql_in_error, _execute__clean_params)) @auto_reconnect_connection - def _cursor(self, *args, **kwargs): + def _cursor(self, *args: Any, **kwargs: Any) -> Any: return super()._cursor() # type: ignore[misc] # We're overriding this internal method that's present in Django 1.11+, because @@ -118,11 +118,11 @@ def _cursor(self, *args, **kwargs): # with our CursorWrapper. We need to be passing our wrapped cursor to their wrapped cursor, # not the other way around since then we'll lose things like __enter__ due to the way this # wrapper is working (getattr on self.cursor). - def _prepare_cursor(self, cursor): + def _prepare_cursor(self, cursor: Any) -> Any: cursor = super()._prepare_cursor(CursorWrapper(self, cursor)) # type: ignore[misc] return cursor - def close(self, reconnect=False): + def close(self, reconnect: bool = False) -> None: """ This ensures we don't error if the connection has already been closed. """ diff --git a/src/sentry/db/postgres/decorators.py b/src/sentry/db/postgres/decorators.py index 91061756c0ce..036834fb8337 100644 --- a/src/sentry/db/postgres/decorators.py +++ b/src/sentry/db/postgres/decorators.py @@ -1,9 +1,16 @@ +from collections.abc import Callable from functools import wraps +from typing import Any, Concatenate, ParamSpec, TypeVar, cast from .helpers import can_reconnect +P = ParamSpec("P") +R = TypeVar("R") -def auto_reconnect_cursor(func): + +def auto_reconnect_cursor( + func: Callable[Concatenate[Any, P], R], +) -> Callable[Concatenate[Any, P], R]: """ Attempt to safely reconnect when an error is hit that resembles the bouncer disconnecting the client due to a timeout/etc during a cursor @@ -11,7 +18,7 @@ def auto_reconnect_cursor(func): """ @wraps(func) - def inner(self, *args, **kwargs): + def inner(self: Any, *args: P.args, **kwargs: P.kwargs) -> R: try: return func(self, *args, **kwargs) except Exception as e: @@ -23,17 +30,20 @@ def inner(self, *args, **kwargs): return func(self, *args, **kwargs) - return inner + # justification: wraps preserves the wrapped function's signature. + return cast("Callable[Concatenate[Any, P], R]", inner) -def auto_reconnect_connection(func): +def auto_reconnect_connection( + func: Callable[Concatenate[Any, P], R], +) -> Callable[Concatenate[Any, P], R]: """ Attempt to safely reconnect when an error is hit that resembles the bouncer disconnecting the client due to a timeout/etc. """ @wraps(func) - def inner(self, *args, **kwargs): + def inner(self: Any, *args: P.args, **kwargs: P.kwargs) -> R: try: return func(self, *args, **kwargs) except Exception as e: @@ -44,4 +54,5 @@ def inner(self, *args, **kwargs): return func(self, *args, **kwargs) - return inner + # justification: wraps preserves the wrapped function's signature. + return cast("Callable[Concatenate[Any, P], R]", inner) diff --git a/src/sentry/db/postgres/helpers.py b/src/sentry/db/postgres/helpers.py index ae8ffd5deabf..2b1256ed0a5f 100644 --- a/src/sentry/db/postgres/helpers.py +++ b/src/sentry/db/postgres/helpers.py @@ -2,7 +2,7 @@ from django.db.utils import DatabaseError, InterfaceError -def can_reconnect(exc): +def can_reconnect(exc: BaseException) -> bool: if isinstance(exc, (psycopg2.InterfaceError, InterfaceError)): return True # elif isinstance(exc, psycopg2.OperationalError): diff --git a/src/sentry/db/postgres/schema.py b/src/sentry/db/postgres/schema.py index e5cfca37e33d..04ea06aa1563 100644 --- a/src/sentry/db/postgres/schema.py +++ b/src/sentry/db/postgres/schema.py @@ -1,3 +1,7 @@ +from collections.abc import Callable +from types import TracebackType +from typing import Any, Concatenate, ParamSpec, cast + from django.contrib.postgres.constraints import ExclusionConstraint from django.db.backends.postgresql.schema import ( DatabaseSchemaEditor as PostgresDatabaseSchemaEditor, @@ -36,7 +40,10 @@ } -def value_translator(value): +P = ParamSpec("P") + + +def value_translator(value: object) -> object: if isinstance(value, Field): return value.name if isinstance(value, ModelBase): @@ -44,8 +51,10 @@ def value_translator(value): return value -def translate_unsafeoperation_exception(func): - def inner(self, *args, **kwargs): +def translate_unsafeoperation_exception( + func: Callable[Concatenate[Any, P], None], +) -> Callable[Concatenate[Any, P], None]: + def inner(self: Any, *args: P.args, **kwargs: P.kwargs) -> None: try: func(self, *args, **kwargs) except UnsafeOperationException as e: @@ -57,7 +66,8 @@ def inner(self, *args, **kwargs): raise UnsafeOperationException(exc_str.format(*formatted_args)) - return inner + # justification: the wrapper preserves the wrapped schema editor method. + return cast("Callable[Concatenate[Any, P], None]", inner) class MakeBtreeGistSchemaEditor(PostgresDatabaseSchemaEditor): @@ -82,13 +92,17 @@ class SafePostgresDatabaseSchemaEditor(DatabaseSchemaEditorMixin, PostgresDataba @translate_unsafeoperation_exception def alter_field( - self, model: type[Model], old_field: Field, new_field: Field, strict: bool = False + self, + model: type[Model], + old_field: Field[Any, Any], + new_field: Field[Any, Any], + strict: bool = False, ) -> None: self._check_alter_indexed_column_type(model, old_field, new_field) PostgresDatabaseSchemaEditor.alter_field(self, model, old_field, new_field, strict) def _check_alter_indexed_column_type( - self, model: type[Model], old_field: Field, new_field: Field + self, model: type[Model], old_field: Field[Any, Any], new_field: Field[Any, Any] ) -> None: """ Block column type changes (including varchar length changes) when the column is @@ -124,7 +138,7 @@ def _check_alter_indexed_column_type( f"More info: https://develop.sentry.dev/database-migrations/#altering-column-types" ) - def alter_db_table(self, model, old_db_table, new_db_table): + def alter_db_table(self, model: type[Model], old_db_table: str, new_db_table: str) -> None: """ This didn't work correctly in django_zero_downtime_migrations, so implementing here. This method is only used to modify table name, so we just need to raise. @@ -134,7 +148,7 @@ def alter_db_table(self, model, old_db_table, new_db_table): "More info here: https://develop.sentry.dev/database-migrations/#renaming-tables" ) - def delete_model(self, model, is_safe=False): + def delete_model(self, model: type[Model], is_safe: bool = False) -> None: """ It's never safe to delete a model using the standard migration process """ @@ -145,7 +159,9 @@ def delete_model(self, model, is_safe=False): ) super(DatabaseSchemaEditorMixin, self).delete_model(model) - def remove_field(self, model, field, is_safe=False): + def remove_field( + self, model: type[Model], field: Field[Any, Any], is_safe: bool = False + ) -> None: """ It's never safe to remove a field using the standard migration process """ @@ -157,7 +173,7 @@ def remove_field(self, model, field, is_safe=False): super(DatabaseSchemaEditorMixin, self).remove_field(model, field) -class DatabaseSchemaEditorProxy: +class DatabaseSchemaEditorProxy: # justification: proxies untyped schema editor implementations. """ Wrapper that allows us to use either the `SafePostgresDatabaseSchemaEditor` or `PostgresDatabaseSchemaEditor`. Can be configured by setting the `safe` property @@ -168,25 +184,25 @@ class DatabaseSchemaEditorProxy: class AlreadyInUse(Exception): pass - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: self.args = args self.kwargs = kwargs self._safe = False - self._schema_editor = None + self._schema_editor: PostgresDatabaseSchemaEditor | None = None @property - def safe(self): + def safe(self) -> bool: return self._safe @safe.setter - def safe(self, safe): + def safe(self, safe: bool) -> None: if self._schema_editor is not None: raise self.AlreadyInUse("Schema editor already in use, can't set `safe`") self._safe = safe @property - def schema_editor(self): + def schema_editor(self) -> PostgresDatabaseSchemaEditor: if self._schema_editor is None: schema_editor_cls = ( SafePostgresDatabaseSchemaEditor if self.safe else MakeBtreeGistSchemaEditor @@ -196,11 +212,16 @@ def schema_editor(self): self._schema_editor = schema_editor return self._schema_editor - def __getattr__(self, name): + def __getattr__(self, name: str) -> Any: return getattr(self.schema_editor, name) - def __enter__(self): + def __enter__(self) -> "DatabaseSchemaEditorProxy": return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: self.schema_editor.__exit__(exc_type, exc_val, exc_tb) diff --git a/src/sentry/db/router.py b/src/sentry/db/router.py index 2f31fb117431..e80d834c1414 100644 --- a/src/sentry/db/router.py +++ b/src/sentry/db/router.py @@ -2,6 +2,7 @@ import logging from collections.abc import Iterable +from typing import Any, cast from django.apps import apps from django.db import connections @@ -141,7 +142,7 @@ class SiloRouter: we can provide compatibility for existing migrations. """ - def __init__(self): + def __init__(self) -> None: # Memoized results of table : silo pairings self.__table_to_silo: dict[str, str | None] = {} try: @@ -154,7 +155,7 @@ def __init__(self): logging.debug("Cannot use simulated silos", extra={"error": str(err)}) self.__is_simulated = False - def use_simulated(self, value: bool): + def use_simulated(self, value: bool) -> None: if not in_test_environment(): raise ValueError("Cannot mutate simulation mode outside of tests") self.__is_simulated = value @@ -193,7 +194,8 @@ def _find_model(self, table: str) -> type[Model] | None: def _silo_limit(self, model: type[Model]) -> SiloLimit | None: silo_limit = getattr(model._meta, "silo_limit", None) if silo_limit: - return silo_limit + # justification: model metadata stores a SiloLimit when one is registered. + return cast("SiloLimit", silo_limit) db_table = model._meta.db_table if not silo_limit and db_table in self.contrib_models: @@ -239,19 +241,23 @@ def _db_for_table(self, table: str, app_label: str) -> str | None: return self.__table_to_silo[table] - def db_for_read(self, model, **hints): + def db_for_read(self, model: type[Model], **hints: Any) -> str | None: return self._db_for_model(model) - def db_for_write(self, model, **hints): + def db_for_write(self, model: type[Model], **hints: Any) -> str | None: return self._db_for_model(model) - def allow_relation(self, obj1, obj2, **hints): - return self._db_for_model(obj1) == self._db_for_model(obj2) + def allow_relation(self, obj1: object, obj2: object, **hints: Any) -> bool: + return self._db_for_model(cast(type[Model], obj1)) == self._db_for_model( + cast(type[Model], obj2) + ) - def allow_syncdb(self, db, model): + def allow_syncdb(self, db: str, model: type[Model]) -> bool: return self._db_for_model(model) == db - def allow_migrate(self, db, app_label, model=None, **hints): + def allow_migrate( + self, db: str, app_label: str, model: type[Model] | None = None, **hints: Any + ) -> bool: if model: return self._db_for_table(model._meta.db_table, app_label) == db diff --git a/src/sentry/debug/utils/packages.py b/src/sentry/debug/utils/packages.py index ef69e2050607..c71c9198693c 100644 --- a/src/sentry/debug/utils/packages.py +++ b/src/sentry/debug/utils/packages.py @@ -2,8 +2,8 @@ import sys -def get_package_version(module_name, app): - version = None +def get_package_version(module_name: str, app: object) -> str | None: + version: object = None # Try to pull version from importlib.metadata first # as it is able to detect version tagged with egg_info -b @@ -12,14 +12,10 @@ def get_package_version(module_name, app): except Exception: pass - if hasattr(app, "get_version"): - version = app.get_version - elif hasattr(app, "__version__"): - version = app.__version__ - elif hasattr(app, "VERSION"): - version = app.VERSION - elif hasattr(app, "version"): - version = app.version + for attr in ("get_version", "__version__", "VERSION", "version"): + if hasattr(app, attr): + version = getattr(app, attr) + break if callable(version): try: @@ -39,7 +35,7 @@ def get_package_version(module_name, app): return str(version) -def get_all_package_versions(): +def get_all_package_versions() -> dict[str, str]: packages = {} for module_name, app in sys.modules.copy().items(): # ignore items that look like submodules diff --git a/src/sentry/debug_files/artifact_bundles.py b/src/sentry/debug_files/artifact_bundles.py index 3d49fe45f267..173aa90e8aaf 100644 --- a/src/sentry/debug_files/artifact_bundles.py +++ b/src/sentry/debug_files/artifact_bundles.py @@ -153,7 +153,7 @@ def index_urls_in_bundle( organization_id: int, artifact_bundle: ArtifactBundle, existing_archive: ArtifactBundleArchive | None, -): +) -> None: skip_debug_id_names = options.get("sourcemaps.artifact-bundles.index-skip-debug-id-names") # We first open up the bundle and extract all the things we want to index from it. @@ -221,7 +221,7 @@ def index_urls_in_bundle( # ===== Renewal of Artifact Bundles ===== -def maybe_renew_artifact_bundles(used_artifact_bundles: dict[int, datetime]): +def maybe_renew_artifact_bundles(used_artifact_bundles: dict[int, datetime]) -> None: # We take a snapshot in time that MUST be consistent across all updates. now = timezone.now() # We compute the threshold used to determine whether we want to renew the specific bundle. @@ -239,7 +239,7 @@ def maybe_renew_artifact_bundles(used_artifact_bundles: dict[int, datetime]): @traces.trace -def renew_artifact_bundle(artifact_bundle_id: int, threshold_date: datetime, now: datetime): +def renew_artifact_bundle(artifact_bundle_id: int, threshold_date: datetime, now: datetime) -> None: metrics.incr("artifact_bundle_renewal.need_renewal") # We want to use a transaction, in order to keep the `date_added` consistent across multiple tables. with atomic_transaction( @@ -359,7 +359,7 @@ def query_artifact_bundles_containing_file( # coarse-grained to fine-grained. artifact_bundles: dict[int, tuple[datetime, str]] = dict() - def update_bundles(bundles: set[tuple[int, datetime]], resolved: str): + def update_bundles(bundles: set[tuple[int, datetime]], resolved: str) -> None: for bundle_id, date_added in bundles: artifact_bundles[bundle_id] = (date_added, resolved) @@ -407,7 +407,7 @@ def get_bundles_indexing_state( total_bundles = 0 indexed_bundles = 0 - filter: dict = { + filter: dict[str, str | int] = { "releaseartifactbundle__release_name": release_name, "releaseartifactbundle__dist_name": dist_name, } diff --git a/src/sentry/debug_files/objectstore_migration/utils.py b/src/sentry/debug_files/objectstore_migration/utils.py index f3a1481febd3..e5b73d07e2fc 100644 --- a/src/sentry/debug_files/objectstore_migration/utils.py +++ b/src/sentry/debug_files/objectstore_migration/utils.py @@ -35,7 +35,7 @@ def migrate_debug_file( def attempt() -> None: source_file_id = debug_file.file_id - if source_file_id is None: + if not source_file_id: return if debug_file.storage_path is not None: @@ -263,7 +263,7 @@ def upload_and_verify(debug_file: ProjectDebugFile) -> PostMigrationMetadata | N extra={"debug_file_id": debug_file.id, "file_id": file.id}, ) - if recorded_size is None: + if not recorded_size: logger.warning( "debug_files.objectstore_migration.size_missing", extra={"debug_file_id": debug_file.id, "file_id": file.id}, diff --git a/src/sentry/deletions/defaults/group.py b/src/sentry/deletions/defaults/group.py index 0e934b7e6109..e539e83a1daa 100644 --- a/src/sentry/deletions/defaults/group.py +++ b/src/sentry/deletions/defaults/group.py @@ -134,7 +134,7 @@ def set_group_and_project_ids(self) -> None: @property def tenant_ids(self) -> Mapping[str, Any]: - result = {"referrer": self.referrer} + result: dict[str, str | int] = {"referrer": self.referrer} if self.groups: result["organization_id"] = self.groups[0].project.organization_id return result diff --git a/src/sentry/deletions/models/scheduleddeletion.py b/src/sentry/deletions/models/scheduleddeletion.py index 1d2d2fdc2314..9272b6eebd1d 100644 --- a/src/sentry/deletions/models/scheduleddeletion.py +++ b/src/sentry/deletions/models/scheduleddeletion.py @@ -2,7 +2,7 @@ import logging from datetime import datetime, timedelta -from typing import Any, Self +from typing import Any, Self, cast from uuid import uuid4 from django.apps import apps @@ -50,7 +50,7 @@ class Meta: object_id = BoundedBigIntegerField() date_added = models.DateTimeField(default=timezone.now) date_scheduled = models.DateTimeField(default=default_date_schedule) - actor_id = BoundedBigIntegerField(null=True) + actor_id = BoundedBigIntegerField[int | None, int | None](null=True) data = models.JSONField(default=dict) in_progress = models.BooleanField(default=False) @@ -128,7 +128,8 @@ def get_instance(self) -> Model: query_manager = model.objects if isinstance(deletion_task, ModelDeletionTask): query_manager = getattr(model, deletion_task.manager_name) - return query_manager.get(pk=self.object_id) + # justification: the scheduled deletion stores the model instance it deletes. + return cast("Model", query_manager.get(pk=self.object_id)) def get_actor(self) -> RpcUser | None: if not self.actor_id: diff --git a/src/sentry/demo_mode/tasks.py b/src/sentry/demo_mode/tasks.py index 22658af3ea54..9b20238147f2 100644 --- a/src/sentry/demo_mode/tasks.py +++ b/src/sentry/demo_mode/tasks.py @@ -232,8 +232,11 @@ def _sync_project_debug_file( target_storage_path = None try: with atomic_transaction(using=(router.db_for_write(ProjectDebugFile))): + source_project_id = source_project_debug_file.project_id + if source_project_id is None: + return None target_project = _find_matching_project( - source_project_debug_file.project_id, + source_project_id, target_org.id, ) diff --git a/src/sentry/demo_mode/utils.py b/src/sentry/demo_mode/utils.py index 21beb10c4125..fde25b36c939 100644 --- a/src/sentry/demo_mode/utils.py +++ b/src/sentry/demo_mode/utils.py @@ -20,7 +20,7 @@ def is_demo_mode_enabled() -> bool: - return options.get("demo-mode.enabled") + return bool(options.get("demo-mode.enabled")) def is_demo_user(user: User | AnonymousUser | None | RpcUser) -> bool: diff --git a/src/sentry/digests/backends/migrating.py b/src/sentry/digests/backends/migrating.py index d0562df5082c..78c0590b8d4d 100644 --- a/src/sentry/digests/backends/migrating.py +++ b/src/sentry/digests/backends/migrating.py @@ -1,6 +1,6 @@ from collections.abc import Generator, Iterable, Mapping from contextlib import ExitStack, contextmanager -from typing import Any +from typing import Any, cast from sentry.digests.backends.base import Backend, InvalidState, ScheduleEntry, load from sentry.digests.types import Record @@ -31,7 +31,11 @@ def add( maximum_delay: int | None = None, timestamp: float | None = None, ) -> bool: - return self.new.add(key, record, increment_delay, maximum_delay, timestamp) + # justification: the new backend reports whether the digest was added. + return cast( + "bool", + self.new.add(key, record, increment_delay, maximum_delay, timestamp), + ) @contextmanager def digest(self, key: str, minimum_delay: int | None = None) -> Generator[list[Record]]: diff --git a/src/sentry/digests/backends/redis.py b/src/sentry/digests/backends/redis.py index 4cffc1217b3b..ba53d15ce5d4 100644 --- a/src/sentry/digests/backends/redis.py +++ b/src/sentry/digests/backends/redis.py @@ -5,7 +5,7 @@ import zlib from collections.abc import Generator, Iterable from contextlib import contextmanager -from typing import Any +from typing import Any, cast import rb from rb.clients import LocalClient @@ -150,7 +150,10 @@ def _get_partition_namespace(self, partition: int) -> str: def _get_timeline_namespace(self, key: str) -> str: if not self.is_redis_cluster: - return self.namespace + namespace = self.namespace + if not isinstance(namespace, str): + raise TypeError("Digest namespace is not a string") + return namespace # Use a stable hash. The built-in ``hash`` is different in each process. partition = zlib.crc32(key.encode("utf-8")) % self.schedule_partitions @@ -233,10 +236,14 @@ def __schedule_partition( deadline: float, timestamp: float, ) -> Iterable[tuple[bytes, float]]: - return script( - [f"{namespace}:s:w"], - ["SCHEDULE", namespace, self.ttl, timestamp, deadline], - client, + # justification: the embedded redis script returns (member, score) pairs. + return cast( + "Iterable[tuple[bytes, float]]", + script( + [f"{namespace}:s:w"], + ["SCHEDULE", namespace, self.ttl, timestamp, deadline], + client, + ), ) def schedule(self, deadline: float, timestamp: float | None = None) -> Iterable[ScheduleEntry]: diff --git a/src/sentry/digests/notifications.py b/src/sentry/digests/notifications.py index 5050cf921a57..ed18a09bb94f 100644 --- a/src/sentry/digests/notifications.py +++ b/src/sentry/digests/notifications.py @@ -200,7 +200,7 @@ def build_digest(project: Project, records: Sequence[Record]) -> DigestInfo: for group_id, g in groups.items(): assert g.project_id == project.id, "Group must belong to Project" - tenant_ids = {"organization_id": project.organization_id} + tenant_ids: dict[str, int | str] = {"organization_id": project.organization_id} event_counts = tsdb.backend.get_timeseries_sums( TSDBModel.group, group_ids, diff --git a/src/sentry/discover/arithmetic.py b/src/sentry/discover/arithmetic.py index 1f3285ffb803..b93d293c4ba9 100644 --- a/src/sentry/discover/arithmetic.py +++ b/src/sentry/discover/arithmetic.py @@ -1,12 +1,13 @@ from __future__ import annotations import re +from collections.abc import Sequence from dataclasses import dataclass -from typing import Iterable, Literal, Union, overload +from typing import Any, Iterable, Literal, Union, cast, overload from parsimonious.exceptions import ParseError from parsimonious.grammar import Grammar -from parsimonious.nodes import NodeVisitor +from parsimonious.nodes import Node, NodeVisitor from sentry.exceptions import InvalidSearchQuery from sentry.search.eap.columns import ColumnDefinitions @@ -87,7 +88,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: @@ -148,7 +149,9 @@ def flatten(remaining): ) -class ArithmeticVisitor(NodeVisitor): +class ArithmeticVisitor( + NodeVisitor[Operation | float | str] +): # justification: parsimonious visits heterogeneous node trees. DEFAULT_MAX_OPERATORS = 10 # Don't wrap in VisitationErrors @@ -261,7 +264,7 @@ def __init__(self, max_operators: int | None, definitions: ColumnDefinitions | N self.fields: set[str] = set() self.functions: set[str] = set() - def visit_term(self, _, children): + def visit_term(self, node: Node, children: Sequence[Any]) -> Operation: maybe_factor, remaining_adds = children maybe_factor = maybe_factor[0] # remaining_adds is either a list containing an Operation, or a Node @@ -271,72 +274,74 @@ def visit_term(self, _, children): return flatten(remaining_adds) else: # if remaining is a node lhs contains a factor so just return that - return maybe_factor + # justification: parser children are arithmetic operations. + return cast("Operation", maybe_factor) - def visit_factor(self, _, children): + def visit_factor(self, node: Node, children: Sequence[Any]) -> Operation: primary, remaining_muls = 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: raise MaxOperatorError("Exceeded maximum number of operations") - def visit_add_sub(self, _, children): + def visit_add_sub(self, node: Node, children: Sequence[Any]) -> Operation: add_sub_operator, maybe_factor = children self.visited_operator() return Operation(add_sub_operator, rhs=maybe_factor[0]) - def visit_mul_div(self, _, children): + def visit_mul_div(self, node: Node, children: Sequence[Any]) -> Operation: mul_div_operator, primary = children self.visited_operator() return Operation(mul_div_operator, rhs=primary) @staticmethod - def strip_spaces(children): + def strip_spaces(children: Sequence[Any]) -> Any: """Visitor for a `spaces foo spaces` node""" _, value, _ = children return value - def visit_maybe_factor(self, _, children): + def visit_maybe_factor(self, node: Node, children: Sequence[Any]) -> Any: return self.strip_spaces(children) - def visit_primary(self, _, children): + def visit_primary(self, node: Node, children: Sequence[Any]) -> Any: # Return the 0th element since this is a (numeric/function/field) self.terms += 1 return self.strip_spaces(children)[0] - def visit_parens(self, _, children): + def visit_parens(self, node: Node, children: Sequence[Any]) -> Any: # Strip brackets _, term, _ = children return term @staticmethod - def parse_operator(operator): + def parse_operator(operator: Sequence[Any]) -> str: # operator is a list since the pattern is (a/b) but we'll only ever want the first value - return operator[0].expr_name + # justification: sqlparse nodes expose the operator name as a string. + return cast("str", operator[0].expr_name) - def visit_add_sub_operator(self, _, children): + def visit_add_sub_operator(self, node: Node, children: Sequence[Any]) -> str: return self.parse_operator(self.strip_spaces(children)) - def visit_mul_div_operator(self, _, children): + def visit_mul_div_operator(self, node: Node, children: Sequence[Any]) -> str: return self.parse_operator(self.strip_spaces(children)) - def visit_numeric_value(self, node, _): + def visit_numeric_value(self, node: Node, _: Sequence[Any]) -> float: return float(node.text) - def visit_field_value(self, node, _): + def visit_field_value(self, node: Node, _: Sequence[Any]) -> str: field = node.text if field not in self.field_allowlist: raise ArithmeticValidationError(f"{field} not allowed in arithmetic") self.fields.add(field) return field - def visit_function_value(self, node, children): + def visit_function_value(self, node: Node, children: Sequence[Any]) -> str: function_node, *_ = children function_name = function_node.text field = node.text @@ -345,7 +350,7 @@ def visit_function_value(self, node, children): self.functions.add(field) return field - def generic_visit(self, node, children): + def generic_visit(self, node: Node, children: Sequence[Any]) -> Any: return children or node @@ -393,7 +398,7 @@ def parse_arithmetic( return result, list(visitor.fields), list(visitor.functions) -def resolve_arithmetic(parsed: Operation | float | str): +def resolve_arithmetic(parsed: Operation | float | str) -> float: """Given a parsed arithmetic string, assume that its resolveable to a single value and return that will error if this is not possible @@ -415,9 +420,9 @@ def resolve_arithmetic(parsed: Operation | float | str): case _: raise InvalidSearchQuery("Unknown operator") elif parsed.lhs is not None: - return parsed.lhs + return cast(float, parsed.lhs) elif parsed.rhs is not None: - return parsed.rhs + return cast(float, parsed.rhs) else: raise InvalidSearchQuery("Poorly formed arithmetic an operator without sides was found") @@ -486,7 +491,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/discover/dataset_split.py b/src/sentry/discover/dataset_split.py index d79b21060946..7f8f91ac06c8 100644 --- a/src/sentry/discover/dataset_split.py +++ b/src/sentry/discover/dataset_split.py @@ -120,7 +120,7 @@ def _save_split_decision_for_query( saved_query: DiscoverSavedQuery, split_decision: int | None, dataset_source: DatasetSourcesTypes | None, -): +) -> None: if split_decision is not None: saved_query.dataset = split_decision if dataset_source is not None: @@ -129,7 +129,9 @@ def _save_split_decision_for_query( saved_query.save() -def _get_top_level_filter_conditions(builder: ErrorsQueryBuilder | DiscoverQueryBuilder): +def _get_top_level_filter_conditions( + builder: ErrorsQueryBuilder | DiscoverQueryBuilder, +) -> list[Condition | BooleanCondition]: top_level_conditions: list[Condition | BooleanCondition] = [] for cond in builder.where: if isinstance(cond, And) or isinstance(cond, Or): @@ -195,7 +197,7 @@ def _check_column_matches_dataset( return False -def _check_event_type_condition(cond: Condition, dataset: Dataset): +def _check_event_type_condition(cond: Condition, dataset: Dataset) -> bool: op = cond.op rhs = cond.rhs if dataset == Dataset.Events and ( @@ -226,7 +228,7 @@ def _check_condition_matches_dataset( def _check_top_level_conditions_match_dataset( builder: ErrorsQueryBuilder | DiscoverQueryBuilder, dataset: Dataset, -): +) -> bool: top_level_conditions = _get_top_level_filter_conditions(builder) for cond in top_level_conditions: if isinstance(cond, Condition): @@ -239,7 +241,7 @@ def _check_top_level_conditions_match_dataset( def _check_selected_columns_match_dataset( builder: ErrorsQueryBuilder | DiscoverQueryBuilder, dataset: Dataset, -): +) -> bool: for select_col in builder.columns: if isinstance(select_col, Column): if _check_column_matches_dataset(select_col, dataset): @@ -259,7 +261,7 @@ def _check_selected_columns_match_dataset( return False -def _check_event_type_filter(errors_builder) -> SplitDataset | None: +def _check_event_type_filter(errors_builder: ErrorsQueryBuilder) -> SplitDataset | None: top_level_conditions = _get_top_level_filter_conditions(errors_builder) for cond in top_level_conditions: diff --git a/src/sentry/discover/endpoints/bases.py b/src/sentry/discover/endpoints/bases.py index b45ac16d6390..d0c8871cce30 100644 --- a/src/sentry/discover/endpoints/bases.py +++ b/src/sentry/discover/endpoints/bases.py @@ -1,8 +1,11 @@ from django.db.models import Exists, OuterRef, Q, QuerySet +from rest_framework.request import Request +from rest_framework.views import APIView from sentry.api.bases.organization import OrganizationPermission from sentry.discover.models import DiscoverSavedQuery, DiscoverSavedQueryProject from sentry.models.organization import Organization +from sentry.organizations.services.organization import RpcOrganization, RpcUserOrganizationContext class DiscoverSavedQueryPermission(OrganizationPermission): @@ -14,14 +17,19 @@ class DiscoverSavedQueryPermission(OrganizationPermission): "DELETE": ["org:read", "org:write", "org:admin", "saved_query:write"], } - def has_object_permission(self, request, view, obj): + def has_object_permission( + self, + request: Request, + view: APIView, + obj: Organization | RpcOrganization | RpcUserOrganizationContext | DiscoverSavedQuery, + ) -> bool: if isinstance(obj, Organization): return super().has_object_permission(request, view, obj) if isinstance(obj, DiscoverSavedQuery): # 1. Saved Query contains certain projects if obj.projects.exists(): - return request.access.has_projects_access(obj.projects.all()) + return bool(request.access.has_projects_access(obj.projects.all())) # 2. Saved Query covers all projects or all my projects @@ -42,7 +50,7 @@ def has_object_permission(self, request, view, obj): def filter_to_accessible_discover_queries( - request, queryset: QuerySet[DiscoverSavedQuery] + request: Request, queryset: QuerySet[DiscoverSavedQuery] ) -> QuerySet[DiscoverSavedQuery]: """ Filter a ``DiscoverSavedQuery`` queryset to only those rows the request actor can access. diff --git a/src/sentry/discover/endpoints/discover_homepage_query.py b/src/sentry/discover/endpoints/discover_homepage_query.py index a20f68fb7826..31045013b955 100644 --- a/src/sentry/discover/endpoints/discover_homepage_query.py +++ b/src/sentry/discover/endpoints/discover_homepage_query.py @@ -1,3 +1,4 @@ +from django.contrib.auth.models import AnonymousUser from rest_framework import status from rest_framework.exceptions import ParseError from rest_framework.request import Request @@ -15,9 +16,12 @@ from sentry.discover.endpoints.serializers import DiscoverSavedQuerySerializer from sentry.discover.models import DatasetSourcesTypes, DiscoverSavedQuery, DiscoverSavedQueryTypes from sentry.models.organization import Organization +from sentry.users.models.user import User -def get_homepage_query(organization, user): +def get_homepage_query( + organization: Organization, user: User | AnonymousUser +) -> DiscoverSavedQuery: return DiscoverSavedQuery.objects.get( organization=organization, is_homepage=True, created_by_id=user.id ) @@ -37,7 +41,7 @@ class DiscoverHomepageQueryEndpoint(OrganizationEndpoint): DiscoverSavedQueryPermission, ) - def has_feature(self, organization, request): + def has_feature(self, organization: Organization, request: Request) -> bool: return features.has("organizations:discover-query", organization, actor=request.user) def get(self, request: Request, organization: Organization) -> Response: @@ -112,7 +116,7 @@ def put(self, request: Request, organization: Organization) -> Response: return Response(serialize(model), status=status.HTTP_201_CREATED) - def delete(self, request: Request, organization) -> Response: + def delete(self, request: Request, organization: Organization) -> Response: if not self.has_feature(organization, request): return self.respond(status=status.HTTP_404_NOT_FOUND) diff --git a/src/sentry/discover/endpoints/discover_key_transactions.py b/src/sentry/discover/endpoints/discover_key_transactions.py index d5c5e661f850..5304b84281a9 100644 --- a/src/sentry/discover/endpoints/discover_key_transactions.py +++ b/src/sentry/discover/endpoints/discover_key_transactions.py @@ -1,8 +1,10 @@ from __future__ import annotations from collections import defaultdict -from typing import TypedDict +from collections.abc import Mapping, Sequence +from typing import TypedDict, cast +from django.contrib.auth.models import AnonymousUser from django.db import IntegrityError, router, transaction from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.exceptions import ParseError @@ -42,8 +44,11 @@ from sentry.discover.models import TeamKeyTransaction from sentry.exceptions import InvalidParams from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.models.projectteam import ProjectTeam from sentry.models.team import Team +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class KeyTransactionPermission(OrganizationPermission): @@ -330,7 +335,13 @@ def get( @register(TeamKeyTransaction) class TeamKeyTransactionSerializer(Serializer[KeyTransactionTeamResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> KeyTransactionTeamResponse: + def serialize( + self, + obj: TeamKeyTransaction, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> KeyTransactionTeamResponse: return { "team": str(obj.project_team.team_id), } @@ -342,10 +353,15 @@ class KeyTransactionTeamAttrs(TypedDict): class KeyTransactionTeamSerializer(Serializer[TeamKeyTransactionsResponse]): - def __init__(self, projects): + def __init__(self, projects: Sequence[Project]) -> None: self.project_ids = {project.id for project in projects} - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[Team], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[Team, KeyTransactionTeamAttrs]: team_key_transactions = ( TeamKeyTransaction.objects.filter( project_team__in=ProjectTeam.objects.filter(team__in=item_list), @@ -375,9 +391,16 @@ def get_attrs(self, item_list, user, **kwargs): return attrs - def serialize(self, obj, attrs, user, **kwargs) -> TeamKeyTransactionsResponse: + def serialize( + self, + obj: Team, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> TeamKeyTransactionsResponse: + team_attrs = cast(KeyTransactionTeamAttrs, attrs) return { "team": str(obj.id), - "count": attrs.get("count", 0), - "keyed": attrs.get("key_transactions", []), + "count": team_attrs.get("count", 0), + "keyed": team_attrs.get("key_transactions", []), } diff --git a/src/sentry/discover/endpoints/discover_saved_queries.py b/src/sentry/discover/endpoints/discover_saved_queries.py index 3e47662ded1a..dee2a03d60d1 100644 --- a/src/sentry/discover/endpoints/discover_saved_queries.py +++ b/src/sentry/discover/endpoints/discover_saved_queries.py @@ -65,7 +65,7 @@ class DiscoverSavedQueriesEndpoint(OrganizationEndpoint): owner = ApiOwner.DATA_BROWSING permission_classes = (DiscoverSavedQueryPermission,) - def has_feature(self, organization, request): + def has_feature(self, organization: Organization, request: Request) -> bool: return features.has("organizations:discover-query", organization, actor=request.user) @extend_schema( @@ -195,7 +195,7 @@ def get( status=200, ) - def data_fn(offset, limit): + def data_fn(offset: int, limit: int) -> list[DiscoverSavedQuery]: return list(queryset[offset : offset + limit]) return self.paginate( @@ -219,7 +219,7 @@ def data_fn(offset, limit): examples=DiscoverExamples.DISCOVER_SAVED_QUERY_POST_RESPONSE, ) def post( - self, request: Request, organization + self, request: Request, organization: Organization ) -> Response[DiscoverSavedQueryResponse] | Response[ValidationErrorResponse]: """ Create a new saved query for the given organization. diff --git a/src/sentry/discover/endpoints/discover_saved_query_detail.py b/src/sentry/discover/endpoints/discover_saved_query_detail.py index 146673b635f6..fde0a9833377 100644 --- a/src/sentry/discover/endpoints/discover_saved_query_detail.py +++ b/src/sentry/discover/endpoints/discover_saved_query_detail.py @@ -1,3 +1,5 @@ +from typing import cast + from django.db.models import F, Q from django.utils import timezone from drf_spectacular.utils import extend_schema @@ -42,14 +44,21 @@ class DiscoverSavedQueryBase(OrganizationEndpoint): owner = ApiOwner.DATA_BROWSING permission_classes = (DiscoverSavedQueryPermission,) - def convert_args(self, request: Request, organization_id_or_slug, query_id, *args, **kwargs): + def convert_args( + self, + request: Request, + organization_id_or_slug: int | str, + query_id: int | str, + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: args, kwargs = super().convert_args(request, organization_id_or_slug, *args, **kwargs) try: kwargs["query"] = DiscoverSavedQuery.objects.get( Q(is_homepage=False) | Q(is_homepage__isnull=True), id=to_valid_int_id("query_id", query_id, raise_404=True), - organization=kwargs["organization"], + organization=cast(Organization, kwargs["organization"]), ) except DiscoverSavedQuery.DoesNotExist: raise ResourceDoesNotExist @@ -66,7 +75,7 @@ class DiscoverSavedQueryDetailEndpoint(DiscoverSavedQueryBase): "PUT": ApiPublishStatus.PUBLIC, } - def has_feature(self, organization, request): + def has_feature(self, organization: Organization, request: Request) -> bool: return features.has("organizations:discover-query", organization, actor=request.user) @extend_schema( @@ -85,7 +94,7 @@ def has_feature(self, organization, request): examples=DiscoverExamples.DISCOVER_SAVED_QUERY_GET_RESPONSE, ) def get( - self, request: Request, organization, query: DiscoverSavedQuery + self, request: Request, organization: Organization, query: DiscoverSavedQuery ) -> Response[DiscoverSavedQueryResponse]: """ Retrieve a saved query. @@ -170,7 +179,9 @@ def put( 404: RESPONSE_NOT_FOUND, }, ) - def delete(self, request: Request, organization, query) -> Response[None]: + def delete( + self, request: Request, organization: Organization, query: DiscoverSavedQuery + ) -> Response[None]: """ Delete a saved query. """ @@ -195,7 +206,7 @@ class DiscoverSavedQueryVisitEndpoint(DiscoverSavedQueryBase): "POST": ApiPublishStatus.PRIVATE, } - def has_feature(self, organization, request): + def has_feature(self, organization: Organization, request: Request) -> bool: return features.has("organizations:discover-query", organization, actor=request.user) @extend_schema( @@ -222,7 +233,8 @@ def post( self.check_object_permissions(request, query) - query.visits = F("visits") + 1 + # justification: the ORM resolves F expressions against the numeric column at save time. + query.visits = cast(int, F("visits") + 1) query.last_visited = timezone.now() query.save(update_fields=["visits", "last_visited"]) diff --git a/src/sentry/discover/endpoints/discover_saved_query_starred.py b/src/sentry/discover/endpoints/discover_saved_query_starred.py index 7e8af72ba994..40272e35a2ba 100644 --- a/src/sentry/discover/endpoints/discover_saved_query_starred.py +++ b/src/sentry/discover/endpoints/discover_saved_query_starred.py @@ -16,7 +16,7 @@ class StarQuerySerializer(serializers.Serializer): starred = serializers.BooleanField(required=True) position = serializers.IntegerField(required=False) - def validate(self, data): + def validate(self, data: dict[str, object]) -> dict[str, object]: if not data["starred"] and "position" in data: raise serializers.ValidationError("Position is only allowed when starring a query.") return data @@ -40,7 +40,7 @@ class DiscoverSavedQueryStarredEndpoint(OrganizationEndpoint): owner = ApiOwner.DATA_BROWSING permission_classes = (MemberPermission,) - def has_feature(self, organization, request): + def has_feature(self, organization: Organization, request: Request) -> bool: return features.has( "organizations:visibility-explore-view", organization, actor=request.user ) diff --git a/src/sentry/discover/endpoints/serializers.py b/src/sentry/discover/endpoints/serializers.py index c2c6d60152e2..d22f8bfcc687 100644 --- a/src/sentry/discover/endpoints/serializers.py +++ b/src/sentry/discover/endpoints/serializers.py @@ -1,4 +1,5 @@ -from collections.abc import Sequence +from collections.abc import Iterable, Mapping, Sequence +from typing import Any import sentry_sdk from django.db.models import Count, Max, QuerySet @@ -164,12 +165,12 @@ class DiscoverSavedQuerySerializer(serializers.Serializer): 2: {"groupby", "rollup", "aggregations", "conditions", "limit"}, } - def validate_projects(self, projects): + def validate_projects(self, projects: Iterable[int]) -> set[int]: from sentry.api.validators import validate_project_ids return validate_project_ids(projects, self.context["params"]["project_id"]) - def validate_queryDataset(self, value): + def validate_queryDataset(self, value: str) -> int | None: dataset = DiscoverSavedQueryTypes.get_id_for_type_name(value) if dataset == DiscoverSavedQueryTypes.DISCOVER or dataset is None: sentry_sdk.set_context( @@ -198,7 +199,10 @@ def validate_queryDataset(self, value): return dataset - def validate(self, data): + def validate( + self, + data: dict[str, Any], # justification: DRF validated request data is decoded JSON + ) -> dict[str, Any]: query = {} query_keys = [ "environment", @@ -275,7 +279,7 @@ def validate(self, data): "query_dataset": data["queryDataset"], } - def validate_version_fields(self, version, query): + def validate_version_fields(self, version: int, query: Mapping[str, object]) -> None: try: not_allowed = self.disallowed_fields[version] except KeyError: @@ -317,7 +321,10 @@ def validate_team(self, team_ids: Sequence[int]) -> QuerySet[Team]: return teams - def validate(self, data): + def validate( + self, + data: dict[str, Any], # justification: DRF validated request data is decoded JSON + ) -> dict[str, Any]: data = super().validate(data) if self.context.get("mode") == "create": team = data["team"] diff --git a/src/sentry/discover/models.py b/src/sentry/discover/models.py index 606af155d136..57d2062c237f 100644 --- a/src/sentry/discover/models.py +++ b/src/sentry/discover/models.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Sequence from enum import Enum from typing import ClassVar @@ -63,7 +64,7 @@ class DatasetSourcesTypes(Enum): FORCED = 3 @classmethod - def as_choices(cls): + def as_choices(cls) -> tuple[tuple[int, str], ...]: return tuple((source.value, source.name.lower()) for source in cls) @@ -96,7 +97,7 @@ class DiscoverSavedQuery(Model): version = models.IntegerField(null=True) date_created = models.DateTimeField(auto_now_add=True) date_updated = models.DateTimeField(auto_now=True) - visits = BoundedBigIntegerField(null=True, default=1) + visits = BoundedBigIntegerField[int | None, int | None](null=True, default=1) last_visited = models.DateTimeField(null=True, default=timezone.now) is_homepage = models.BooleanField(null=True, blank=True) dataset = BoundedPositiveIntegerField( @@ -129,7 +130,7 @@ class Meta: __repr__ = sane_repr("organization_id", "created_by_id", "name") - def set_projects(self, project_ids): + def set_projects(self, project_ids: Sequence[int]) -> None: with transaction.atomic(router.db_for_write(DiscoverSavedQueryProject)): DiscoverSavedQueryProject.objects.filter(discover_saved_query=self).exclude( project__in=project_ids @@ -151,7 +152,9 @@ def set_projects(self, project_ids): class TeamKeyTransactionModelManager(BaseManager["TeamKeyTransaction"]): @staticmethod - def __schedule_invalidate_project_config_transaction_commit(instance, trigger): + def __schedule_invalidate_project_config_transaction_commit( + instance: TeamKeyTransaction, trigger: str + ) -> None: try: project = getattr(instance.project_team, "project", None) except ProjectTeam.DoesNotExist: @@ -184,7 +187,7 @@ def post_save(self, *, instance: TeamKeyTransaction, created: bool, **kwargs: ob instance, "teamkeytransaction.post_save" ) - def post_delete(self, instance, **kwargs): + def post_delete(self, instance: TeamKeyTransaction, **kwargs: object) -> None: # this hook may be called from model hooks during an # open transaction. In that case, wait until the current transaction has # been committed or rolled back to ensure we don't read stale data in the diff --git a/src/sentry/discover/translation/mep_to_eap.py b/src/sentry/discover/translation/mep_to_eap.py index f3ebec4eec93..1c918a47b688 100644 --- a/src/sentry/discover/translation/mep_to_eap.py +++ b/src/sentry/discover/translation/mep_to_eap.py @@ -1,7 +1,9 @@ import re -from typing import TypedDict +from collections.abc import Iterable, Sequence +from typing import TypedDict, cast from parsimonious import NodeVisitor +from parsimonious.nodes import Node from sentry.api.event_search import event_search_grammar from sentry.discover import arithmetic @@ -22,10 +24,20 @@ class QueryParts(TypedDict): orderby: list[str] | None +class DroppedEquation(TypedDict): + equation: str + reason: list[str] + + +class DroppedOrderby(TypedDict): + orderby: str + reason: str | list[str] + + class DroppedFields(TypedDict): selected_columns: list[str] - equations: list[dict[str, list[str]]] - orderby: list[dict[str, str | list[str]]] + equations: list[DroppedEquation] + orderby: list[DroppedOrderby] | None COLUMNS_TO_DROP = ( @@ -41,7 +53,7 @@ class DroppedFields(TypedDict): FIELDS_TO_DROP = ("total.count",) -def _flatten(seq, flattened_list): +def _flatten[T](seq: Iterable[T], flattened_list: list[T]) -> None: for item in seq: if isinstance(item, list): _flatten(item, flattened_list) @@ -49,7 +61,7 @@ def _flatten(seq, flattened_list): flattened_list.append(item) -def format_percentile_term(term): +def format_percentile_term(term: str) -> str: function, args, alias = fields.parse_function(term) percentile_replacement_function = { @@ -82,7 +94,7 @@ def format_percentile_term(term): return f"{new_function}({translated_column})" -def drop_unsupported_columns(columns): +def drop_unsupported_columns(columns: list[str]) -> tuple[list[str], list[str]]: final_columns = [] dropped_columns = [] for column in columns: @@ -114,13 +126,13 @@ def apply_is_segment_condition(query: str) -> str: return "is_transaction:1" -def add_equation_prefix_if_needed(term, need_equation): +def add_equation_prefix_if_needed(term: str, need_equation: bool) -> str: if need_equation and term.startswith(("apdex(", "user_misery(", "count_if(")): return f"equation|{term}" return term -def column_switcheroo(term): +def column_switcheroo(term: str) -> tuple[str, bool]: """Swaps out the entire column name.""" parsed_mri = parse_mri(term) if parsed_mri: @@ -156,7 +168,7 @@ def column_switcheroo(term): return swapped_term, swapped_term != term -def function_switcheroo(term): +def function_switcheroo(term: str) -> tuple[str, bool]: """Swaps out the entire function, including args.""" swapped_term = term if term == "count()": @@ -179,7 +191,7 @@ def function_switcheroo(term): return swapped_term, swapped_term != term -def search_term_switcheroo(term): +def search_term_switcheroo(term: str) -> tuple[str, bool]: """Swaps out a single search term, both key and value.""" swapped_term = term if term == "event.type:transaction": @@ -188,28 +200,28 @@ def search_term_switcheroo(term): return swapped_term, swapped_term != term -class TranslationVisitor(NodeVisitor): - def __init__(self): +class TranslationVisitor(NodeVisitor[object]): + def __init__(self) -> None: super().__init__() - def visit_raw_aggregate_param(self, node, children): + def visit_raw_aggregate_param(self, node: Node, children: Sequence[object]) -> str: return column_switcheroo(node.text)[0] - def visit_aggregate_key(self, node, children): + def visit_aggregate_key(self, node: Node, children: Sequence[object]) -> object: term, did_update = function_switcheroo(node.text) if did_update: return term return children or node.text - def visit_numeric_filter(self, node, children): + def visit_numeric_filter(self, node: Node, children: Sequence[object]) -> object: term, did_update = search_term_switcheroo(node.text) if did_update: return term _, parsed_key, _, _, _ = children flattened_parsed_key: list[str] = [] - _flatten(parsed_key, flattened_parsed_key) + _flatten(cast("Iterable[str]", parsed_key), flattened_parsed_key) flattened_parsed_key_str = "".join(flattened_parsed_key) if flattened_parsed_key_str: if ( @@ -217,22 +229,22 @@ def visit_numeric_filter(self, node, children): and flattened_parsed_key_str not in SPAN_ATTRIBUTE_DEFINITIONS ): new_parsed_key = [f"tags[{flattened_parsed_key_str},number]"] - children[1] = new_parsed_key + cast("list[object]", children)[1] = new_parsed_key return children or node.text - def visit_boolean_filter(self, node, children): + def visit_boolean_filter(self, node: Node, children: Sequence[object]) -> object: term, did_update = search_term_switcheroo(node.text) if did_update: return term negation, parsed_key, sep, boolean_val = children flattened_parsed_key: list[str] = [] - _flatten(parsed_key, flattened_parsed_key) + _flatten(cast("Iterable[str]", parsed_key), flattened_parsed_key) flattened_parsed_key_str = "".join(flattened_parsed_key) flattened_parsed_val: list[str] = [] - _flatten(boolean_val, flattened_parsed_val) + _flatten(cast("Iterable[str]", boolean_val), flattened_parsed_val) flattened_parsed_val_str = "".join(flattened_parsed_val) if ( flattened_parsed_key_str @@ -241,7 +253,7 @@ def visit_boolean_filter(self, node, children): ): if flattened_parsed_val_str in ["0", "1"]: new_parsed_key = [f"tags[{flattened_parsed_key_str},number]"] - children[1] = new_parsed_key + cast("list[object]", children)[1] = new_parsed_key return children or node.text @@ -255,54 +267,56 @@ def visit_boolean_filter(self, node, children): return children or node.text - def visit_text_filter(self, node, children): + def visit_text_filter(self, node: Node, children: Sequence[object]) -> object: term, did_update = search_term_switcheroo(node.text) if did_update: return term return children or node.text - def visit_key(self, node, children): + def visit_key(self, node: Node, children: Sequence[object]) -> str: return column_switcheroo(node.text)[0] - def visit_value(self, node, children): + def visit_value(self, node: Node, children: Sequence[object]) -> str: return column_switcheroo(node.text)[0] - def generic_visit(self, node, children): + def generic_visit(self, node: Node, children: Sequence[object]) -> object: return children or node.text -class ArithmeticTranslationVisitor(NodeVisitor): - def __init__(self): - self.dropped_fields = [] +class ArithmeticTranslationVisitor(NodeVisitor[object]): + def __init__(self) -> None: + self.dropped_fields: list[str] = [] super().__init__() - def visit_field_value(self, node, children): + def visit_field_value(self, node: Node, children: Sequence[object]) -> str: if node.text in COLUMNS_TO_DROP: self.dropped_fields.append(node.text) return node.text return column_switcheroo(node.text)[0] - def visit_function_value(self, node, children): + def visit_function_value(self, node: Node, children: Sequence[object]) -> str: new_functions, dropped_functions = translate_columns([node.text]) self.dropped_fields.extend(dropped_functions) return new_functions[0] - def generic_visit(self, node, children): + def generic_visit(self, node: Node, children: Sequence[object]) -> object: return children or node.text -def translate_query(query: str): +def translate_query(query: str) -> str: flattened_query: list[str] = [] tree = event_search_grammar.parse(query) parsed = TranslationVisitor().visit(tree) - _flatten(parsed, flattened_query) + _flatten(cast("Iterable[str]", parsed), flattened_query) return apply_is_segment_condition("".join(flattened_query)) -def translate_columns(columns, need_equation=False): +def translate_columns( + columns: list[str], need_equation: bool = False +) -> tuple[list[str], list[str]]: """ @param columns: list of columns to translate @param need_equation: whether to translate some of the functions to equation notation (usually if @@ -340,7 +354,9 @@ def translate_columns(columns, need_equation=False): return translated_columns, dropped_columns -def translate_equations(equations): +def translate_equations( + equations: list[str] | None, +) -> tuple[list[str] | None, list[DroppedEquation] | None]: """ This is used to translate arithmetic equations to EAP compatible equations. It ideally takes in equations with equation notation and returns the EAP equation with equation notation. @@ -350,8 +366,8 @@ def translate_equations(equations): if equations is None: return None, None - translated_equations = [] - dropped_equations = [] + translated_equations: list[str] = [] + dropped_equations: list[DroppedEquation] = [] for equation in equations: flattened_equation: list[str] = [] @@ -370,12 +386,12 @@ def translate_equations(equations): tree = arithmetic.arithmetic_grammar.parse(arithmetic_equation) translation_visitor = ArithmeticTranslationVisitor() parsed = translation_visitor.visit(tree) - _flatten(parsed, flattened_equation) + _flatten(cast("Iterable[str]", parsed), flattened_equation) # record dropped fields and equations and skip these translations if len(translation_visitor.dropped_fields) > 0: dropped_equations.append( - {"equation": equation, "reason": translation_visitor.dropped_fields} + DroppedEquation(equation=equation, reason=translation_visitor.dropped_fields) ) continue @@ -387,7 +403,12 @@ def translate_equations(equations): return translated_equations, dropped_equations -def translate_orderbys(orderbys, equations, dropped_equations, new_equations): +def translate_orderbys( + orderbys: list[str] | None, + equations: list[str], + dropped_equations: list[str], + new_equations: list[str] | None, +) -> tuple[list[str] | None, list[DroppedOrderby] | None]: """ This is used to translate orderbys to EAP compatible orderbys. It ideally takes in orderbys with equation notation, function notation or fields and returns the EAP orderby with the same notation. @@ -396,8 +417,8 @@ def translate_orderbys(orderbys, equations, dropped_equations, new_equations): if orderbys is None: return None, None - translated_orderbys = [] - dropped_orderbys = [] + translated_orderbys: list[str] = [] + dropped_orderbys: list[DroppedOrderby] = [] for orderby in orderbys: is_negated = False @@ -407,8 +428,9 @@ def translate_orderbys(orderbys, equations, dropped_equations, new_equations): else: orderby_without_neg = orderby - dropped_orderby_reason = None - decoded_orderby = None + dropped_orderby_reason: str | list[str] | None = None + decoded_orderby: str | None = None + translated_orderby: list[str] = [] # if orderby is a predefined equation (these are usually in the format equation[index]) if re.match(INDEXED_EQUATIONS_PATTERN, orderby_without_neg): equation_index = int(orderby_without_neg.split("[")[1].split("]")[0]) @@ -430,7 +452,9 @@ def translate_orderbys(orderbys, equations, dropped_equations, new_equations): # check where equation is in list of new equations translated_equation_list, _ = translate_equations([selected_equation]) try: + assert translated_equation_list is not None translated_equation = translated_equation_list[0] + assert new_equations is not None new_equation_index = new_equations.index(translated_equation) translated_orderby = [f"equation[{new_equation_index}]"] except (IndexError, ValueError): @@ -444,11 +468,13 @@ def translate_orderbys(orderbys, equations, dropped_equations, new_equations): # if orderby is an equation elif arithmetic.is_equation(orderby_without_neg): - translated_orderby, dropped_orderby_equation = translate_equations( + translated_orderby_list, dropped_orderby_equations = translate_equations( [orderby_without_neg] ) - if len(dropped_orderby_equation) > 0: - dropped_orderby_reason = dropped_orderby_equation[0]["reason"] + assert translated_orderby_list is not None and dropped_orderby_equations is not None + translated_orderby = translated_orderby_list + if len(dropped_orderby_equations) > 0: + dropped_orderby_reason = dropped_orderby_equations[0]["reason"] # if orderby is a field/function else: @@ -460,22 +486,23 @@ def translate_orderbys(orderbys, equations, dropped_equations, new_equations): # add translated orderby to the list and record dropped orderbys if dropped_orderby_reason is None: - translated_orderbys.append( + translated_orderby_term = ( translated_orderby[0] if not is_negated else f"-{translated_orderby[0]}" ) + translated_orderbys.append(translated_orderby_term) else: dropped_orderbys.append( - { - "orderby": orderby if decoded_orderby is None else decoded_orderby, - "reason": dropped_orderby_reason, - } + DroppedOrderby( + orderby=orderby if decoded_orderby is None else decoded_orderby, + reason=dropped_orderby_reason, + ) ) continue return translated_orderbys, dropped_orderbys -def translate_mep_to_eap(query_parts: QueryParts): +def translate_mep_to_eap(query_parts: QueryParts) -> tuple[QueryParts, DroppedFields]: """ This is a utility used to translate transactions/metrics/mep queries to eap queries. It takes in event query syntax (EQS) @@ -508,7 +535,7 @@ def translate_mep_to_eap(query_parts: QueryParts): dropped_fields = DroppedFields( selected_columns=dropped_columns, - equations=dropped_equations, + equations=dropped_equations or [], orderby=dropped_orderbys, ) diff --git a/src/sentry/dynamic_sampling/per_org/cache.py b/src/sentry/dynamic_sampling/per_org/cache.py index 75a3fc05ae39..24a2ba34f3cd 100644 --- a/src/sentry/dynamic_sampling/per_org/cache.py +++ b/src/sentry/dynamic_sampling/per_org/cache.py @@ -1,9 +1,9 @@ from __future__ import annotations import math -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence from datetime import timedelta -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import orjson import sentry_sdk @@ -147,7 +147,9 @@ def get_adjusted_factor_age(org_id: int) -> timedelta | None: None when there is no stored factor or it has no expiry. """ redis_client = get_redis_client_for_ds() - remaining_ms = redis_client.pttl(generate_recalibrate_orgs_cache_key(org_id)) + # justification: sentry_redis_tools clients do not annotate pttl. + pttl = cast("Callable[[str], int]", redis_client.pttl) + remaining_ms = pttl(generate_recalibrate_orgs_cache_key(org_id)) if remaining_ms < 0: return None return timedelta(milliseconds=adjusted_factor_ttl_ms() - remaining_ms) diff --git a/src/sentry/event_manager.py b/src/sentry/event_manager.py index 9e9c77bf1b88..6ce55692975e 100644 --- a/src/sentry/event_manager.py +++ b/src/sentry/event_manager.py @@ -7,7 +7,7 @@ from collections.abc import Callable, Mapping, MutableMapping, Sequence from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict, overload +from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict, cast, overload import orjson import psycopg2.errors @@ -101,6 +101,7 @@ from sentry.issues.producer import PayloadType, produce_occurrence_to_kafka from sentry.killswitches import killswitch_matches_context from sentry.lang.native.utils import STORE_CRASH_REPORTS_ALL, convert_crashreport_count +from sentry.metrics.base import TagValue from sentry.models.activity import Activity from sentry.models.environment import Environment from sentry.models.event import EventDict @@ -202,14 +203,14 @@ class GroupInfo: is_new_group_environment: bool = False -def pop_tag(data: dict[str, Any], key: str) -> None: +def pop_tag(data: MutableMapping[str, Any], key: str) -> None: if "tags" not in data: return data["tags"] = [kv for kv in data["tags"] if kv is None or kv[0] != key] -def set_tag(data: dict[str, Any], key: str, value: Any) -> None: +def set_tag(data: MutableMapping[str, Any], key: str, value: Any) -> None: pop_tag(data, key) if value is not None: data.setdefault("tags", []).append((key, trim(value, MAX_TAG_VALUE_LENGTH))) @@ -307,7 +308,7 @@ def get_stored_crashreports(cache_key: str | None, event: Event, max_crashreport return max_crashreports cached_reports = cache.get(cache_key, None) - if cached_reports is not None and cached_reports >= max_crashreports: + if isinstance(cached_reports, int) and cached_reports >= max_crashreports: return cached_reports # Fall-through if max_crashreports was bumped to get a more accurate number. @@ -348,6 +349,11 @@ def increment_group_tombstone_hit_counter(tombstone_id: int | None, event: Event Job = MutableMapping[str, Any] +def _event_from_job(job: Job) -> Event: + # justification: _save stores the Event under "event" before returning the job. + return cast("Event", job["event"]) + + def resolve_project(project_id: int) -> Project: project = Project.objects.get_from_cache(id=project_id) project.set_cached_field_value( @@ -504,11 +510,11 @@ def save( if event_type == "transaction": job["data"]["project"] = project.id jobs = save_transaction_events([job], projects, skip_send_first_transaction) - return jobs[0]["event"] + return _event_from_job(jobs[0]) elif event_type == "generic": job["data"]["project"] = project.id jobs = save_generic_events([job], projects) - return jobs[0]["event"] + return _event_from_job(jobs[0]) else: project = job["event"].project job["in_grouping_transition"] = is_in_transition(project) @@ -563,7 +569,7 @@ def save_error_events( raise if not group_info: - return job["event"] + return _event_from_job(job) # store a reference to the group id to guarantee validation of isolation # XXX(markus): No clue what this does @@ -661,7 +667,7 @@ def save_error_events( self._data = job["event"].data.data - return job["event"] + return _event_from_job(job) @traces.trace @@ -757,7 +763,7 @@ def _set_project_platform_if_needed(project: Project, event: Event) -> None: # How often each cache-fronted model lookup on the save path reached Postgres. # `data_access` is set by the model: cache_hit, db_read, db_create, or db_update. -def _record_resolve_model(model: str, tags: dict[str, str]) -> None: +def _record_resolve_model(model: str, tags: Mapping[str, TagValue]) -> None: metrics.incr( "save_event.resolve_model", tags={ @@ -780,7 +786,7 @@ def _get_or_create_release_many(jobs: Sequence[Job], projects: ProjectsMapping) create_release = should_auto_create_releases(project) try: - resolve_tags: dict[str, str] = {} + resolve_tags: dict[str, TagValue] = {} release = Release.get_or_create( project=project, version=data["release"], @@ -973,7 +979,7 @@ def _get_or_create_group_environment( event_datetime: datetime, ) -> None: for group_info in groups: - resolve_tags: dict[str, str] = {} + resolve_tags: dict[str, str | int] = {} group_info.is_new_group_environment = GroupEnvironment.get_or_create( group_id=group_info.group.id, environment_id=environment.id, @@ -998,7 +1004,7 @@ def _get_or_create_release_associated_models( environment = job["environment"] date = job["event"].datetime - release_env_tags: dict[str, str] = {} + release_env_tags: MutableTags = {} ReleaseEnvironment.get_or_create( project=project, release=release, @@ -1008,7 +1014,7 @@ def _get_or_create_release_associated_models( ) _record_resolve_model("releaseenvironment", release_env_tags) - release_project_env_tags: dict[str, str] = {} + release_project_env_tags: MutableTags = {} ReleaseProjectEnvironment.get_or_create( project=project, release=release, @@ -1077,7 +1083,7 @@ def _get_or_create_group_release( ) -> None: if release: for group_info in groups: - resolve_tags: dict[str, str] = {} + resolve_tags: dict[str, str | int | float | None] = {} group_info.group_release = GroupRelease.get_or_create( group=group_info.group, release=release, @@ -1919,15 +1925,18 @@ def _handle_regression( return is_regression -def _is_placeholder_title(title): +def _is_placeholder_title(title: str | None) -> bool: return title in PLACEHOLDER_EVENT_TITLES -def _is_real_title(title): +def _is_real_title(title: str | None) -> bool: return bool(title) and title not in PLACEHOLDER_EVENT_TITLES -def _get_updated_group_title(existing_container, incoming_container): +def _get_updated_group_title( + existing_container: Mapping[str, str | None], + incoming_container: Mapping[str, str | None], +) -> str | None: """ Given either `group.data` or `group.data["metadata"]`, in both existing and incoming forms, pick the correct title to use when updating the group. Uses the incoming title (or `None` if there @@ -2212,7 +2221,7 @@ def _get_priority_for_group(severity: Mapping[str, Any], kwargs: Mapping[str, An return PriorityLevel.MEDIUM -def update_severity_error_count(reset=False) -> None: +def update_severity_error_count(reset: bool = False) -> None: timeout = 60 * 60 # 1 hour if reset: cache.set(SEER_ERROR_COUNT_KEY, 0, timeout=timeout) diff --git a/src/sentry/eventstream/kafka/protocol.py b/src/sentry/eventstream/kafka/protocol.py index 72008096b64d..a051e4a0e467 100644 --- a/src/sentry/eventstream/kafka/protocol.py +++ b/src/sentry/eventstream/kafka/protocol.py @@ -147,7 +147,7 @@ def decode_optional_list_str(value: str | None) -> Sequence[Any] | None: if not isinstance(parsed, list): raise ValueError(f"'{value}' could not be parsed into an instance of list.") - return json.loads(value) + return parsed def get_task_kwargs_for_message_from_headers( diff --git a/src/sentry/eventtypes/base.py b/src/sentry/eventtypes/base.py index 5954e15063ae..7d26d4a25335 100644 --- a/src/sentry/eventtypes/base.py +++ b/src/sentry/eventtypes/base.py @@ -37,7 +37,7 @@ def get_metadata(self, data: MutableMapping[str, Any]) -> dict[str, Any]: def get_title(self, metadata: Mapping[str, Any]) -> str: title = metadata.get("title") - if title is not None: + if isinstance(title, str): return title return self.compute_title(metadata) or "" 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/bases.py b/src/sentry/explore/endpoints/bases.py index b91957997112..a02db256cf22 100644 --- a/src/sentry/explore/endpoints/bases.py +++ b/src/sentry/explore/endpoints/bases.py @@ -1,4 +1,6 @@ from django.db.models import Exists, OuterRef, Q, QuerySet +from rest_framework.request import Request +from rest_framework.views import APIView from sentry.api.bases.organization import OrganizationPermission from sentry.explore.models import ExploreSavedQuery, ExploreSavedQueryProject @@ -14,7 +16,7 @@ class ExploreSavedQueryPermission(OrganizationPermission): "DELETE": ["org:read", "org:write", "org:admin", "saved_query:write"], } - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request: Request, view: APIView, obj: object) -> bool: if isinstance(obj, Organization): return super().has_object_permission(request, view, obj) @@ -26,7 +28,7 @@ def has_object_permission(self, request, view, obj): # 1. Saved Query contains certain projects if obj.projects.exists(): - return request.access.has_projects_access(obj.projects.all()) + return bool(request.access.has_projects_access(obj.projects.all())) # 2. Saved Query covers all projects or all my projects @@ -58,7 +60,7 @@ class ExploreSavedFormulaPermission(OrganizationPermission): def filter_to_accessible_explore_queries( - request, queryset: QuerySet[ExploreSavedQuery] + request: Request, queryset: QuerySet[ExploreSavedQuery] ) -> QuerySet[ExploreSavedQuery]: """ Filter an ``ExploreSavedQuery`` queryset to only those rows the request actor can access. diff --git a/src/sentry/explore/endpoints/explore_saved_queries.py b/src/sentry/explore/endpoints/explore_saved_queries.py index 5d775f1f3892..ebf9f59bf1e3 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 @@ -235,11 +236,14 @@ def sync_prebuilt_queries(organization): } else: continue + prebuilt_version = prebuilt_query["prebuilt_version"] + assert isinstance(prebuilt_version, int) if prebuilt_query["prebuilt_id"] in saved_prebuilt_query_ids: saved_prebuilt_query = saved_prebuilt_queries.get( prebuilt_id=prebuilt_query["prebuilt_id"] ) - if prebuilt_query["prebuilt_version"] > saved_prebuilt_query.prebuilt_version: + saved_prebuilt_version = saved_prebuilt_query.prebuilt_version + if saved_prebuilt_version is not None and prebuilt_version > saved_prebuilt_version: queries_to_update.append( ExploreSavedQuery( id=saved_prebuilt_query.id, @@ -269,7 +273,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 +326,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 +511,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..87a18c8cc7fe 100644 --- a/src/sentry/explore/endpoints/explore_saved_query_detail.py +++ b/src/sentry/explore/endpoints/explore_saved_query_detail.py @@ -1,3 +1,5 @@ +from typing import cast + from django.db.models import F from django.utils import timezone from drf_spectacular.utils import extend_schema @@ -36,13 +38,21 @@ class ExploreSavedQueryBase(OrganizationEndpoint): owner = ApiOwner.EXPLORE permission_classes = (ExploreSavedQueryPermission,) - def convert_args(self, request: Request, organization_id_or_slug, id, *args, **kwargs): + def convert_args( + self, + request: Request, + organization_id_or_slug: str, + id: str, + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: args, kwargs = super().convert_args(request, organization_id_or_slug, *args, **kwargs) try: kwargs["query"] = ExploreSavedQuery.objects.get( id=to_valid_int_id("id", id, raise_404=True), - organization=kwargs["organization"], + # justification: convert_args places the resolved organization in kwargs. + organization=cast(Organization, kwargs["organization"]), ) except ExploreSavedQuery.DoesNotExist: raise ResourceDoesNotExist @@ -59,7 +69,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 +192,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 """ @@ -196,7 +208,8 @@ def post(self, request: Request, organization, query) -> Response: self.check_object_permissions(request, query) - query.visits = F("visits") + 1 + # justification: the ORM resolves F expressions against the numeric column at save time. + query.visits = cast(int, F("visits") + 1) query.last_visited = timezone.now() query.save(update_fields=["visits", "last_visited"]) 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/saved_queries.py b/src/sentry/explore/endpoints/saved_queries.py index 82fa44063fb1..5c7bed74b4e2 100644 --- a/src/sentry/explore/endpoints/saved_queries.py +++ b/src/sentry/explore/endpoints/saved_queries.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Literal +from typing import Any, Literal, cast import sentry_sdk from django.db.models import ( @@ -274,10 +274,12 @@ def build_combined_queryset( if name not in columns: columns.append(name) - return ( + # justification: union() erases the queryset's value type. + return cast( + "QuerySet[DiscoverSavedQuery, dict[str, Any]]", discover_queryset.values(*columns) .union(explore_queryset.values(*columns), all=True) - .order_by(*order_by) + .order_by(*order_by), ) @@ -345,7 +347,7 @@ class SavedQueriesEndpoint(OrganizationEndpoint): owner = ApiOwner.DATA_BROWSING 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 ) @@ -446,7 +448,7 @@ def get( user_id=request.user.id, ) - def data_fn(offset, limit): + def data_fn(offset: int, limit: int) -> list[object]: return list(combined[offset : offset + limit]) return self.paginate( diff --git a/src/sentry/explore/endpoints/serializers.py b/src/sentry/explore/endpoints/serializers.py index fbe4e37d6971..76fb53bebc52 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"] @@ -360,9 +423,13 @@ def validate(self, data): 0, ) + dataset = ExploreSavedQueryDataset.get_id_for_type_name(data["dataset"]) + if dataset is None: + raise serializers.ValidationError("Invalid dataset") + return { "name": data["name"], "project_ids": data["projects"], "query": query, - "dataset": ExploreSavedQueryDataset.get_id_for_type_name(data["dataset"]), + "dataset": dataset, } diff --git a/src/sentry/explore/migrations/0016_update_if_syntax_on_spans.py b/src/sentry/explore/migrations/0016_update_if_syntax_on_spans.py index c5b3a3c80d29..d95e316a248c 100644 --- a/src/sentry/explore/migrations/0016_update_if_syntax_on_spans.py +++ b/src/sentry/explore/migrations/0016_update_if_syntax_on_spans.py @@ -5,7 +5,7 @@ import logging import re from re import Match -from typing import Any, Union +from typing import cast, Any, Union from django.db import migrations from django.db.backends.base.schema import BaseDatabaseSchemaEditor @@ -264,7 +264,8 @@ def visit_parens(self, node: Node, children: Any) -> Any: @staticmethod def parse_operator(operator: Any) -> str: - return operator[0].expr_name + # justification: sqlparse nodes expose the operator name as a string. + return cast("str", operator[0].expr_name) def visit_add_sub_operator(self, node: Node, children: Any) -> str: return self.parse_operator(self.strip_spaces(children)) diff --git a/src/sentry/explore/models.py b/src/sentry/explore/models.py index 3f1b7b9dfc61..2a1cfd6325ed 100644 --- a/src/sentry/explore/models.py +++ b/src/sentry/explore/models.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterable from typing import ClassVar, Literal from django.contrib.postgres.fields.array import ArrayField @@ -88,7 +89,7 @@ class ExploreSavedQuery(DefaultFieldsModel): created_by_id = HybridCloudForeignKey("sentry.User", null=True, on_delete="SET_NULL") name = models.CharField(max_length=255) query = models.JSONField() - visits = BoundedBigIntegerField(null=True, default=1) + visits = BoundedBigIntegerField[int | None, int | None](null=True, default=1) last_visited = models.DateTimeField(null=True, default=timezone.now) dataset = BoundedPositiveIntegerField( choices=ExploreSavedQueryDataset.as_choices(), default=ExploreSavedQueryDataset.SPANS @@ -96,10 +97,12 @@ class ExploreSavedQuery(DefaultFieldsModel): is_multi_query = models.BooleanField(default=False) # The corresponding prebuilt_id found in hardcoded prebuilt queries from src/sentry/explore/endpoints/explore_saved_queries.py # If the saved query is not a prebuilt query, this will be None - prebuilt_id = BoundedPositiveIntegerField(null=True, db_default=None) + prebuilt_id = BoundedPositiveIntegerField[int | None, int | None](null=True, db_default=None) # The version of the prebuilt query. If the version found in the explore_saved_queries.py hardcoded list is greater, then the saved # query out of date and should be updated.. - prebuilt_version = BoundedPositiveIntegerField(null=True, db_default=None) + prebuilt_version = BoundedPositiveIntegerField[int | None, int | None]( + null=True, db_default=None + ) # This field is to be used for the discover -> explore migration. This contains the reason why any part # of the saved query was changed so we can display our reasonings in the UI changed_reason = models.JSONField(null=True, default=None) @@ -111,7 +114,7 @@ class Meta: __repr__ = sane_repr("organization_id", "created_by_id", "name") - def set_projects(self, project_ids): + def set_projects(self, project_ids: Iterable[int]) -> None: with transaction.atomic(router.db_for_write(ExploreSavedQueryProject)): ExploreSavedQueryProject.objects.filter(explore_saved_query=self).exclude( project__in=project_ids @@ -513,7 +516,7 @@ class ExploreSavedFormula(DefaultFieldsModel): # The corresponding prebuilt_id found in hardcoded prebuilt formulas from # src/sentry/explore/endpoints/organization_explore_formulas.py # If the saved query is not a prebuilt query, this will be None - prebuilt_id = BoundedPositiveIntegerField(null=True, db_default=None) + prebuilt_id = BoundedPositiveIntegerField[int | None, int | None](null=True, db_default=None) # Tombstone for prebuilts so we don't recreate them if a user decides to delete it prebuilt_deleted = models.BooleanField(db_default=False) @@ -585,9 +588,11 @@ class ExploreSavedVariable(DefaultFieldsModel): # Making this 200 to match the max length of a tag name = models.CharField(max_length=200) # Where does this param go in the list of function arguments - order = BoundedPositiveIntegerField(null=True) + order = BoundedPositiveIntegerField[int | None, int | None](null=True) kind = BoundedPositiveIntegerField(choices=KindItemTypes.as_choices()) - param_type = BoundedPositiveIntegerField(choices=ParamItemTypes.as_choices(), null=True) + param_type = BoundedPositiveIntegerField[int | None, int | None]( + choices=ParamItemTypes.as_choices(), null=True + ) # TODO(wmak): Should this be longer? value = models.CharField(max_length=200) explore_saved_formula = FlexibleForeignKey( diff --git a/src/sentry/explore/translation/alerts_translation.py b/src/sentry/explore/translation/alerts_translation.py index 2c278bd43564..ad7273118e7e 100644 --- a/src/sentry/explore/translation/alerts_translation.py +++ b/src/sentry/explore/translation/alerts_translation.py @@ -40,7 +40,7 @@ COUNT_AGGREGATE_PREFIX = "count(" -def snapshot_snuba_query(snuba_query: SnubaQuery): +def snapshot_snuba_query(snuba_query: SnubaQuery) -> SnubaQuery: if ( (snuba_query.query_snapshot and snuba_query.query_snapshot.get("metrics_to_transactions")) or not snuba_query.query_snapshot @@ -61,7 +61,9 @@ def snapshot_snuba_query(snuba_query: SnubaQuery): return snuba_query -def _get_old_query_info(snuba_query: SnubaQuery): +def _get_old_query_info( + snuba_query: SnubaQuery, +) -> tuple[SnubaQuery.Type, Dataset, str, str]: old_query_type = SnubaQuery.Type(snuba_query.type) old_dataset = Dataset(snuba_query.dataset) old_query = snuba_query.query @@ -70,7 +72,7 @@ def _get_old_query_info(snuba_query: SnubaQuery): return old_query_type, old_dataset, old_query, old_aggregate -def _verify_event_types(snuba_query: SnubaQuery): +def _verify_event_types(snuba_query: SnubaQuery) -> bool: if ( snuba_query.dataset == Dataset.PerformanceMetrics.value or snuba_query.dataset == Dataset.Transactions.value @@ -86,7 +88,7 @@ def _verify_event_types(snuba_query: SnubaQuery): def translate_detector_and_update_subscription_in_snuba( snuba_query: SnubaQuery, bypass_flag_check: bool = False -): +) -> None: if not _verify_event_types(snuba_query): logger.info( "Skipping roll forward for query with invalid event types", @@ -255,7 +257,7 @@ def translate_detector_and_update_subscription_in_snuba( def rollback_detector_query_and_update_subscription_in_snuba( snuba_query: SnubaQuery, bypass_flag_check: bool = False -): +) -> None: # querying for updating as well just in case the subscription gets stuck in updating query_subscription_qs = QuerySubscription.objects.filter( snuba_query_id=snuba_query.id, diff --git a/src/sentry/explore/translation/am1_metrics_to_transactions.py b/src/sentry/explore/translation/am1_metrics_to_transactions.py index 336200742363..f2bcb258e2b7 100644 --- a/src/sentry/explore/translation/am1_metrics_to_transactions.py +++ b/src/sentry/explore/translation/am1_metrics_to_transactions.py @@ -19,7 +19,7 @@ logger = logging.getLogger(__name__) -def snapshot_snuba_query(snuba_query: SnubaQuery): +def snapshot_snuba_query(snuba_query: SnubaQuery) -> SnubaQuery: if not snuba_query.query_snapshot and snuba_query.dataset in [ Dataset.PerformanceMetrics.value, ]: @@ -32,7 +32,9 @@ def snapshot_snuba_query(snuba_query: SnubaQuery): return snuba_query -def translate_am1_metrics_detector_and_update_subscription_in_snuba(snuba_query: SnubaQuery): +def translate_am1_metrics_detector_and_update_subscription_in_snuba( + snuba_query: SnubaQuery, +) -> None: query_subscription_qs = QuerySubscription.objects.filter( snuba_query_id=snuba_query.id, status__in=[QuerySubscription.Status.ACTIVE.value, QuerySubscription.Status.UPDATING.value], @@ -101,7 +103,9 @@ def translate_am1_metrics_detector_and_update_subscription_in_snuba(snuba_query: return -def rollback_am1_metrics_detector_query_and_update_subscription_in_snuba(snuba_query: SnubaQuery): +def rollback_am1_metrics_detector_query_and_update_subscription_in_snuba( + snuba_query: SnubaQuery, +) -> None: # querying for updating as well just in case the subscription gets stuck in updating query_subscription_qs = QuerySubscription.objects.filter( snuba_query_id=snuba_query.id, diff --git a/src/sentry/explore/translation/dashboards_translation.py b/src/sentry/explore/translation/dashboards_translation.py index bb2f67dcff77..6cb6ab25dd95 100644 --- a/src/sentry/explore/translation/dashboards_translation.py +++ b/src/sentry/explore/translation/dashboards_translation.py @@ -4,7 +4,11 @@ from sentry.api.serializers.base import serialize from sentry.discover.arithmetic import is_equation, is_equation_alias -from sentry.discover.translation.mep_to_eap import QueryParts, translate_mep_to_eap +from sentry.discover.translation.mep_to_eap import ( + DroppedFields, + QueryParts, + translate_mep_to_eap, +) from sentry.explore.translation.discover_translation import _format_orderby_for_translation from sentry.models.dashboard_widget import ( DashboardWidget, @@ -16,7 +20,7 @@ from sentry.utils.db import atomic_transaction -def snapshot_widget(widget: DashboardWidget): +def snapshot_widget(widget: DashboardWidget) -> None: if ( widget.widget_type == DashboardWidgetTypes.TRANSACTION_LIKE or ( @@ -36,18 +40,18 @@ def snapshot_widget(widget: DashboardWidget): def translate_dashboard_widget_queries( - widget, - q_index, - name, - original_fields, - original_columns, - original_aggregates, - orderby, - conditions, - field_aliases, - is_hidden, - selected_aggregate, -): + widget: DashboardWidget, + q_index: int, + name: str, + original_fields: list[str], + original_columns: list[str], + original_aggregates: list[str], + orderby: str, + conditions: str, + field_aliases: list[str], + is_hidden: bool, + selected_aggregate: int | None, +) -> tuple[DashboardWidgetQuery, DroppedFields]: original_fields_length = len(original_fields) # some widgets have fields that are not columns + aggregates. # we want to combine all of these into a single list but still preserve order the way we would in the frontend. @@ -78,6 +82,7 @@ def translate_dashboard_widget_queries( dropped_cols = dropped_fields["selected_columns"] dropped_equations = [dropped["equation"] for dropped in dropped_fields["equations"]] + eap_equations = eap_query_parts["equations"] or [] new_conditions = eap_query_parts["query"] or "" new_orderby = eap_query_parts["orderby"][0] if eap_query_parts["orderby"] else "" @@ -106,8 +111,8 @@ def translate_dashboard_widget_queries( if is_equation_field: if old_index < original_fields_length: - new_fields.append(eap_query_parts["equations"][equation_index]) - new_aggregates.append(eap_query_parts["equations"][equation_index]) + new_fields.append(eap_equations[equation_index]) + new_aggregates.append(eap_equations[equation_index]) equation_index += 1 elif is_function_field: @@ -202,7 +207,7 @@ def translate_dashboard_widget(widget: DashboardWidget) -> DashboardWidget: return widget -def restore_transaction_widget(widget): +def restore_transaction_widget(widget: DashboardWidget) -> DashboardWidget: snapshot = widget.widget_snapshot if not snapshot or widget.widget_type != DashboardWidgetTypes.SPANS: diff --git a/src/sentry/explore/translation/discover_translation.py b/src/sentry/explore/translation/discover_translation.py index 5d6433ebeea9..9acdf27f6634 100644 --- a/src/sentry/explore/translation/discover_translation.py +++ b/src/sentry/explore/translation/discover_translation.py @@ -1,4 +1,5 @@ import re +from collections.abc import Sequence from typing import Any from sentry.discover.arithmetic import is_equation @@ -29,7 +30,7 @@ } -def strip_negative_from_orderby(orderby): +def strip_negative_from_orderby(orderby: str) -> tuple[str, bool]: """ This function is used to strip the negative from an orderby item. """ @@ -38,7 +39,7 @@ def strip_negative_from_orderby(orderby): return orderby, False -def _get_translated_orderby_item(orderby, columns, is_negated): +def _get_translated_orderby_item(orderby: str, columns: Sequence[str], is_negated: bool) -> str: """ This function is used to translate the function underscore notation for orderby items to regular function notation. We do this by stripping both the orderby item and the given columns @@ -73,7 +74,9 @@ def _get_translated_orderby_item(orderby, columns, is_negated): return converted_orderby -def _format_orderby_for_translation(orderby, columns): +def _format_orderby_for_translation( + orderby: str | list[str], columns: Sequence[str] +) -> list[str] | None: orderby_converted_list = [] if type(orderby) is str: orderby = [orderby] diff --git a/src/sentry/feedback/endpoints/error_page_embed.py b/src/sentry/feedback/endpoints/error_page_embed.py index aac7caff8af7..d85153ecb249 100644 --- a/src/sentry/feedback/endpoints/error_page_embed.py +++ b/src/sentry/feedback/endpoints/error_page_embed.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable from urllib.parse import urlparse from django import forms @@ -62,7 +62,13 @@ } -class UserReportForm(forms.ModelForm): +if TYPE_CHECKING: + _UserReportModelFormBase = forms.ModelForm[UserReport] +else: + _UserReportModelFormBase = forms.ModelForm + + +class UserReportForm(_UserReportModelFormBase): name = forms.CharField( max_length=UserReport._meta.get_field("name").max_length, widget=forms.TextInput(attrs={"placeholder": _("Jane Bloggs")}), @@ -128,23 +134,28 @@ class ErrorPageEmbedView(View): View for the crash report modal. """ - def _get_project_key(self, request: HttpRequest): + def _get_project_key(self, request: HttpRequest) -> ProjectKey | None: try: dsn = request.GET["dsn"] except KeyError: - return + return None try: key = ProjectKey.from_dsn(dsn) except ProjectKey.DoesNotExist: - return + return None return key - def _get_origin(self, request: HttpRequest): + def _get_origin(self, request: HttpRequest) -> str | None: return origin_from_request(request) - def _smart_response(self, request: HttpRequest, context=None, status=200): + def _smart_response( + self, + request: HttpRequest, + context: dict[str, object] | None = None, + status: int = 200, + ) -> HttpResponse: json_context = json.dumps(context or {}) accept = request.META.get("HTTP_ACCEPT") or "" if "text/javascript" in accept: diff --git a/src/sentry/feedback/endpoints/organization_feedback_categories.py b/src/sentry/feedback/endpoints/organization_feedback_categories.py index 13cab166f826..6a4b360e2786 100644 --- a/src/sentry/feedback/endpoints/organization_feedback_categories.py +++ b/src/sentry/feedback/endpoints/organization_feedback_categories.py @@ -62,6 +62,12 @@ class FeedbackLabelGroup(TypedDict): associatedLabels: list[str] +class FeedbackCategory(TypedDict): + primaryLabel: str + associatedLabels: list[str] + feedbackCount: int + + @cell_silo_endpoint class OrganizationFeedbackCategoriesEndpoint(OrganizationEndpoint): owner = ApiOwner.FEEDBACK @@ -289,7 +295,7 @@ def get(self, request: Request, organization: Organization) -> Response: labels_groups=label_groups_lists, ) - categories = [] + categories: list[FeedbackCategory] = [] for i, list_group in enumerate(label_groups_lists): primaryLabel = list_group[0] associatedLabels = list_group[1:] diff --git a/src/sentry/feedback/endpoints/organization_feedback_summary.py b/src/sentry/feedback/endpoints/organization_feedback_summary.py index ad5c8096ee69..05e06f864834 100644 --- a/src/sentry/feedback/endpoints/organization_feedback_summary.py +++ b/src/sentry/feedback/endpoints/organization_feedback_summary.py @@ -61,7 +61,8 @@ def get_summary_from_seer( ) return None - return response.json()["data"] + data = response.json()["data"] + return data if isinstance(data, str) else None @cell_silo_endpoint diff --git a/src/sentry/feedback/endpoints/project_user_reports.py b/src/sentry/feedback/endpoints/project_user_reports.py index 98f684cabec0..70fdfcfe81ce 100644 --- a/src/sentry/feedback/endpoints/project_user_reports.py +++ b/src/sentry/feedback/endpoints/project_user_reports.py @@ -20,11 +20,12 @@ from sentry.feedback.lib.utils import FeedbackCreationSource from sentry.feedback.usecases.ingest.userreport import Conflict, save_userreport from sentry.models.environment import Environment +from sentry.models.project import Project from sentry.models.userreport import UserReport from sentry.utils.dates import epoch -class UserReportSerializer(serializers.ModelSerializer): +class UserReportSerializer(serializers.ModelSerializer[UserReport]): class Meta: model = UserReport fields = ("name", "email", "comments", "event_id") @@ -48,7 +49,7 @@ class ProjectUserReportsEndpoint(ProjectEndpoint): summary="List a Project's User Feedback", parameters=[CursorQueryParam], ) - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project: Project) -> Response: """ List a Project's User Feedback `````````````````````````````` @@ -100,7 +101,7 @@ def get(self, request: Request, project) -> Response: **paginate_kwargs, ) - def post(self, request: Request, project) -> Response: + def post(self, request: Request, project: Project) -> Response: """ Submit User Feedback ```````````````````` diff --git a/src/sentry/feedback/lib/label_query.py b/src/sentry/feedback/lib/label_query.py index ffff63072f50..2b7bdc1ef07f 100644 --- a/src/sentry/feedback/lib/label_query.py +++ b/src/sentry/feedback/lib/label_query.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import TypedDict from snuba_sdk import ( Column, @@ -21,7 +22,17 @@ from sentry.utils.snuba import raw_snql_query -def _get_ai_labels_from_tags(alias: str | None = None): +class AILabelCount(TypedDict): + label: str + count: int + + +class RecentFeedbackWithAILabels(TypedDict): + labels: list[str] + feedback: str + + +def _get_ai_labels_from_tags(alias: str | None = None) -> Function: """ Gets all the AI label values as an array from the tags column. @@ -80,7 +91,7 @@ def query_top_ai_labels_by_feedback_count( start: datetime, end: datetime, limit: int, -): +) -> list[AILabelCount]: """ Query the top `limit` AI-generated labels by feedback count. @@ -123,7 +134,7 @@ def query_top_ai_labels_by_feedback_count( limit=Limit(limit), ) - return raw_snql_query( + data = raw_snql_query( Request( dataset=dataset.value, app_id="feedback-backend-web", @@ -132,6 +143,9 @@ def query_top_ai_labels_by_feedback_count( ), referrer="feedbacks.label_query", )["data"] + if not isinstance(data, list): + raise ValueError("Snuba returned no data array") + return data def query_recent_feedbacks_with_ai_labels( @@ -140,7 +154,7 @@ def query_recent_feedbacks_with_ai_labels( start: datetime, end: datetime, limit: int, -): +) -> list[RecentFeedbackWithAILabels]: """ Query the most recent `limit` feedbacks, along with their AI labels. Ensures feedbacks have at least one AI label. @@ -229,7 +243,7 @@ def query_recent_feedbacks_with_ai_labels( limit=Limit(limit), ) - return raw_snql_query( + data = raw_snql_query( Request( dataset=dataset.value, app_id="feedback-backend-web", @@ -238,6 +252,9 @@ def query_recent_feedbacks_with_ai_labels( ), referrer="feedbacks.label_query", )["data"] + if not isinstance(data, list): + raise ValueError("Snuba returned no data array") + return data def query_label_group_counts( @@ -246,7 +263,7 @@ def query_label_group_counts( start: datetime, end: datetime, labels_groups: list[list[str]], -): +) -> list[int]: """ Query how many feedbacks are in each of the given `labels_groups`. diff --git a/src/sentry/feedback/lib/utils.py b/src/sentry/feedback/lib/utils.py index 8d3ce6258bcf..3edaf3a0d01a 100644 --- a/src/sentry/feedback/lib/utils.py +++ b/src/sentry/feedback/lib/utils.py @@ -3,6 +3,7 @@ from enum import Enum from sentry import options +from sentry.models.organization import Organization UNREAL_FEEDBACK_UNATTENDED_MESSAGE = "Sent in the unattended mode" @@ -36,5 +37,5 @@ def old_feedback_category_values(cls) -> set[str]: } -def is_in_feedback_denylist(organization): +def is_in_feedback_denylist(organization: Organization) -> bool: return organization.slug in options.get("feedback.organizations.slug-denylist") diff --git a/src/sentry/feedback/usecases/ingest/create_feedback.py b/src/sentry/feedback/usecases/ingest/create_feedback.py index 2ccbdfd7437e..e7e9e9f5cfe7 100644 --- a/src/sentry/feedback/usecases/ingest/create_feedback.py +++ b/src/sentry/feedback/usecases/ingest/create_feedback.py @@ -2,8 +2,9 @@ import logging import random +from collections.abc import Mapping from datetime import UTC, datetime -from typing import Any +from typing import Any, TypedDict from uuid import UUID, uuid4 import jsonschema @@ -39,11 +40,21 @@ logger = logging.getLogger(__name__) +class _FeedbackContext(TypedDict, total=False): + associated_event_id: str + contact_email: str + message: str + name: str + + def make_evidence( - feedback, source: FeedbackCreationSource, is_message_spam: bool | None, is_spam_enabled: bool -): - evidence_data = {} - evidence_display = [] + feedback: _FeedbackContext, + source: FeedbackCreationSource, + is_message_spam: bool | None, + is_spam_enabled: bool, +) -> tuple[dict[str, object], list[IssueEvidence]]: + evidence_data: dict[str, object] = {} + evidence_display: list[IssueEvidence] = [] if feedback.get("associated_event_id"): evidence_data["associated_event_id"] = feedback["associated_event_id"] evidence_display.append( @@ -169,7 +180,7 @@ def fix_for_issue_platform(event_data: dict[str, Any]) -> dict[str, Any]: return ret_event -def validate_issue_platform_event_schema(event_data): +def validate_issue_platform_event_schema(event_data: Mapping[str, object]) -> None: """ The issue platform schema validation does not run in dev atm so we have to do the validation ourselves, or else our tests are not representative of what happens in prod. @@ -177,7 +188,9 @@ def validate_issue_platform_event_schema(event_data): jsonschema.validate(event_data, EVENT_PAYLOAD_SCHEMA) -def should_filter_feedback(event: dict) -> tuple[bool, str | None]: +def should_filter_feedback( + event: Mapping[str, Any], # justification: feedback payload is JSON. +) -> tuple[bool, str | None]: """Returns a tuple of (should_filter, reason).""" # Right now all unreal error events without a feedback diff --git a/src/sentry/feedback/usecases/label_generation.py b/src/sentry/feedback/usecases/label_generation.py index 98a071bfbfeb..b4fced0dd069 100644 --- a/src/sentry/feedback/usecases/label_generation.py +++ b/src/sentry/feedback/usecases/label_generation.py @@ -52,4 +52,7 @@ def generate_labels( raise Exception("Seer returned non-200 response") # Guaranteed to be a list of strings (validated in Seer) - return response.json()["data"]["labels"] + labels = response.json()["data"]["labels"] + if not isinstance(labels, list): + raise ValueError("Seer returned no labels array") + return labels diff --git a/src/sentry/flags/endpoints/logs.py b/src/sentry/flags/endpoints/logs.py index 893a9a4f4a6a..740bcf7272c0 100644 --- a/src/sentry/flags/endpoints/logs.py +++ b/src/sentry/flags/endpoints/logs.py @@ -1,6 +1,7 @@ -from datetime import datetime -from typing import Any, Literal, TypedDict +from collections.abc import Mapping +from typing import Any, Literal, TypedDict, cast +from django.contrib.auth.models import AnonymousUser from django.db.models import Q from drf_spectacular.utils import extend_schema from rest_framework import serializers as rest_serializers @@ -35,12 +36,14 @@ ProviderName, ) from sentry.models.organization import Organization +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class FlagAuditLogModelSerializerResponse(TypedDict): id: int action: Literal["created", "deleted", "updated"] - createdAt: datetime + createdAt: str createdBy: str | None createdByType: Literal["email", "id", "name"] | None flag: str @@ -58,16 +61,27 @@ class FlagLogDetailsResponse(TypedDict): @register(FlagAuditLogModel) class FlagAuditLogModelSerializer(Serializer[FlagAuditLogModelSerializerResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> FlagAuditLogModelSerializerResponse: + def serialize( + self, + obj: FlagAuditLogModel, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> FlagAuditLogModelSerializerResponse: return { "id": obj.id, - "action": ActionEnum.to_string(obj.action), + "action": cast( + Literal["created", "deleted", "updated"], ActionEnum.to_string(obj.action) + ), "createdAt": obj.created_at.isoformat(), "createdBy": obj.created_by, "createdByType": ( None if obj.created_by_type is None - else CreatedByTypeEnum.to_string(obj.created_by_type) + else cast( + Literal["email", "id", "name"], + CreatedByTypeEnum.to_string(obj.created_by_type), + ) ), "flag": obj.flag, "provider": (None if obj.provider is None else ProviderEnum.to_string(obj.provider)), diff --git a/src/sentry/flags/endpoints/secrets.py b/src/sentry/flags/endpoints/secrets.py index 194f81a5d100..1d1296ccfc8d 100644 --- a/src/sentry/flags/endpoints/secrets.py +++ b/src/sentry/flags/endpoints/secrets.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import Mapping from datetime import datetime, timezone -from typing import TypedDict +from typing import TYPE_CHECKING, Any, TypedDict, cast from rest_framework import serializers from rest_framework.request import Request @@ -19,10 +20,16 @@ from sentry.flags.models import FlagWebHookSigningSecretModel from sentry.models.organization import Organization +if TYPE_CHECKING: + from django.contrib.auth.models import AnonymousUser + + from sentry.users.models.user import User + from sentry.users.services.user import RpcUser + class FlagWebhookSigningSecretResponse(TypedDict): createdAt: str - createdBy: int + createdBy: int | None id: int provider: str secret: str @@ -30,7 +37,13 @@ class FlagWebhookSigningSecretResponse(TypedDict): @register(FlagWebHookSigningSecretModel) class FlagWebhookSigningSecretSerializer(Serializer[FlagWebhookSigningSecretResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> FlagWebhookSigningSecretResponse: + def serialize( + self, + obj: FlagWebHookSigningSecretModel, + attrs: Mapping[str, Any], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> FlagWebhookSigningSecretResponse: return { "createdAt": obj.date_added.isoformat(), "createdBy": obj.created_by, @@ -46,18 +59,27 @@ class FlagWebhookSigningSecretValidator(serializers.Serializer): ) secret = serializers.CharField(required=True) - def validate_secret(self, value): + def validate_secret(self, value: str) -> str: if self.initial_data.get("provider") == "statsig": if not value.startswith("webhook-"): raise serializers.ValidationError( "Ensure this field is of the format webhook-" ) - return serializers.CharField(min_length=32, max_length=64).run_validation(value) + # justification: DRF does not annotate Field.run_validation. + return cast( + "str", serializers.CharField(min_length=32, max_length=64).run_validation(value) + ) if self.initial_data.get("provider") == "generic": - return serializers.CharField(min_length=10, max_length=64).run_validation(value) + # justification: DRF does not annotate Field.run_validation. + return cast( + "str", serializers.CharField(min_length=10, max_length=64).run_validation(value) + ) - return serializers.CharField(min_length=32, max_length=32).run_validation(value) + # justification: DRF does not annotate Field.run_validation. + return cast( + "str", serializers.CharField(min_length=32, max_length=32).run_validation(value) + ) @cell_silo_endpoint diff --git a/src/sentry/flags/models.py b/src/sentry/flags/models.py index fd3b36e3fd6e..96e84fd8b0b9 100644 --- a/src/sentry/flags/models.py +++ b/src/sentry/flags/models.py @@ -16,7 +16,7 @@ class ActionEnum(Enum): UPDATED = 2 @classmethod - def to_string(cls, integer): + def to_string(cls, integer: int) -> str: if integer == 0: return "created" if integer == 1: @@ -39,7 +39,7 @@ class CreatedByTypeEnum(Enum): NAME = 2 @classmethod - def to_string(cls, integer): + def to_string(cls, integer: int) -> str: if integer == 0: return "email" if integer == 1: @@ -67,7 +67,7 @@ class ProviderEnum(Enum): STATSIG = 4 @classmethod - def to_string(cls, integer) -> ProviderName: + def to_string(cls, integer: int) -> ProviderName: if integer == 0: return "generic" if integer == 1: diff --git a/src/sentry/flags/providers.py b/src/sentry/flags/providers.py index 1ed7c9ca7c72..8ea2fc6db73a 100644 --- a/src/sentry/flags/providers.py +++ b/src/sentry/flags/providers.py @@ -68,7 +68,7 @@ def validate(self, message_bytes: bytes) -> bool: ... class DeserializationError(Exception): """The request body could not be deserialized.""" - def __init__(self, errors): + def __init__(self, errors: Any) -> None: # justification: original provider payload self.errors = errors @@ -596,5 +596,5 @@ def _query_signing_secrets(organization_id: int, provider: str) -> Iterator[str] yield model.secret -def hmac_sha256_hex_digest(key: str, message: bytes): +def hmac_sha256_hex_digest(key: str, message: bytes) -> str: return hmac.new(key.encode(), message, hashlib.sha256).hexdigest() diff --git a/src/sentry/grouping/api.py b/src/sentry/grouping/api.py index af3641e67157..7975d4e56a82 100644 --- a/src/sentry/grouping/api.py +++ b/src/sentry/grouping/api.py @@ -95,7 +95,7 @@ def _get_base64_enhancements(self, project: Project) -> str: ).hexdigest() ) base64_enhancements = cache.get(cache_key) - if base64_enhancements is not None: + if isinstance(base64_enhancements, str): return base64_enhancements try: @@ -127,11 +127,14 @@ class ProjectGroupingConfigLoader(GroupingConfigLoader): option_name: str # Set in subclasses def _get_config_id(self, project: Project) -> str: - return project.get_option( + config_id = project.get_option( self.option_name, validate=lambda x: isinstance(x, str) and x in GROUPING_CONFIG_CLASSES, default=DEFAULT_GROUPING_CONFIG, ) + if not isinstance(config_id, str): + raise TypeError("Grouping config option is not a string") + return config_id class PrimaryGroupingConfigLoader(ProjectGroupingConfigLoader): diff --git a/src/sentry/grouping/enhancer/__init__.py b/src/sentry/grouping/enhancer/__init__.py index c5432893a4ec..6bd5f6ae9cc7 100644 --- a/src/sentry/grouping/enhancer/__init__.py +++ b/src/sentry/grouping/enhancer/__init__.py @@ -193,7 +193,7 @@ def _add_rule_source_to_hint(hint: str | None, custom_rules: set[str]) -> str | if not hint: return None - def _add_type_to_rule(rule_regex_match: re.Match) -> str: + def _add_type_to_rule(rule_regex_match: re.Match[str]) -> str: rule_str = rule_regex_match.group(1) rule_type = "custom" if rule_str in custom_rules else "built-in" return f"{rule_type} stacktrace rule ({rule_str})" diff --git a/src/sentry/grouping/enhancer/rules.py b/src/sentry/grouping/enhancer/rules.py index 16dd6b1debfd..0cc7cb31c574 100644 --- a/src/sentry/grouping/enhancer/rules.py +++ b/src/sentry/grouping/enhancer/rules.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import cached_property -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from .actions import EnhancementAction from .matchers import EnhancementMatch @@ -28,11 +28,11 @@ def has_contributes_actions(self) -> bool: def __repr__(self) -> str: return f"" - def __hash__(self): + def __hash__(self) -> int: return hash(self.text) - def __eq__(self, other): - return self.text == other.text + def __eq__(self, other: object) -> bool: + return self.text == cast("EnhancementRule", other).text @property def text(self) -> str: diff --git a/src/sentry/grouping/fingerprinting/matchers.py b/src/sentry/grouping/fingerprinting/matchers.py index 5101f03883bd..52630bb7d6f0 100644 --- a/src/sentry/grouping/fingerprinting/matchers.py +++ b/src/sentry/grouping/fingerprinting/matchers.py @@ -116,7 +116,7 @@ def _positive_match(self, event_values: dict[str, Any]) -> bool: return "all" in flags or value in flags if self.key == "app": - return value == bool_from_string(self.pattern) + return bool(value == bool_from_string(self.pattern)) if self.key in ["level", "value"]: return glob_match(value, self.pattern, ignorecase=True) diff --git a/src/sentry/grouping/fingerprinting/utils.py b/src/sentry/grouping/fingerprinting/utils.py index 0eb7863f0227..ec9865e54080 100644 --- a/src/sentry/grouping/fingerprinting/utils.py +++ b/src/sentry/grouping/fingerprinting/utils.py @@ -3,7 +3,7 @@ import logging import re from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict +from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict, cast from sentry.grouping.fingerprinting.types import FingerprintInfo from sentry.grouping.utils import get_canonical_message_from_event, normalize_message_for_grouping @@ -78,7 +78,8 @@ def get_values(self, match_type: str) -> list[dict[str, Any]]: """ Pull values from all the spots in the event appropriate to the given match type. """ - return getattr(self, "_get_" + match_type)() + # justification: match types map to _get_ methods on this class. + return cast("list[dict[str, Any]]", getattr(self, "_get_" + match_type)()) def _get_messages(self) -> list[_MessageInfo]: if self._messages is None: @@ -120,7 +121,8 @@ def _get_frames(self) -> list[_FrameInfo]: self._frames = frames = [] def _push_frame(frame: dict[str, object]) -> None: - platform = frame.get("platform") or self.event.get("platform") + # justification: frame and event fields are decoded JSON. + platform = cast("str | None", frame.get("platform") or self.event.get("platform")) func = get_function_name_for_frame(frame, platform) frames.append( { @@ -288,7 +290,7 @@ def resolve_fingerprint_variable( # Turn "tags.some_tag" into just "some_tag" requested_tag = variable_key[5:] for tag_name, tag_value in event.data.get("tags") or (): - if tag_name == requested_tag and tag_value is not None: + if tag_name == requested_tag and isinstance(tag_value, str): return tag_value return "" % requested_tag else: diff --git a/src/sentry/grouping/ingest/grouphash_metadata.py b/src/sentry/grouping/ingest/grouphash_metadata.py index 7dda7bbf97da..080716cb4aee 100644 --- a/src/sentry/grouping/ingest/grouphash_metadata.py +++ b/src/sentry/grouping/ingest/grouphash_metadata.py @@ -331,7 +331,8 @@ def get_grouphash_metadata_data( ) if is_hybrid_fingerprint: - hashing_metadata.update( + # justification: hybrid fingerprinting merges fingerprint fields into the salted metadata variant. + cast("dict[str, object]", hashing_metadata).update( _get_fingerprint_hashing_metadata(contributing_variant, is_hybrid=True) ) diff --git a/src/sentry/grouping/ingest/hashing.py b/src/sentry/grouping/ingest/hashing.py index 3c2f42365e4a..b21df8c50e12 100644 --- a/src/sentry/grouping/ingest/hashing.py +++ b/src/sentry/grouping/ingest/hashing.py @@ -206,7 +206,7 @@ def _grouphash_exists_for_hash_value(hash_value: str, project: Project, use_cach if got_cache_hit: metrics_tags["grouphash_exists"] = grouphash_exists - return grouphash_exists + return bool(grouphash_exists) # Get an answer from the database if we didn't find what we needed in the cache (or if # caching is turned off) diff --git a/src/sentry/grouping/strategies/base.py b/src/sentry/grouping/strategies/base.py index 3a18fbef446d..8c510b3b3c38 100644 --- a/src/sentry/grouping/strategies/base.py +++ b/src/sentry/grouping/strategies/base.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator, Sequence -from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, overload +from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, cast, overload from sentry.grouping.component import ( BaseGroupingComponent, @@ -42,13 +42,13 @@ def __call__( event: BaseEvent, context: GroupingContext, **kwargs: Any, - ) -> ComponentsByVariant: ... + ) -> ComponentsByVariant[Any]: ... class VariantProcessor(Protocol): def __call__( - self, variants: ComponentsByVariant, context: GroupingContext, **kwargs: Any - ) -> ComponentsByVariant: ... + self, variants: ComponentsByVariant[Any], context: GroupingContext, **kwargs: Any + ) -> ComponentsByVariant[Any]: ... def strategy( @@ -116,15 +116,15 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__} id={self.id!r}>" def _invoke( - self, func: Callable[..., ComponentsByVariant], *args: Any, **kwargs: Any - ) -> ComponentsByVariant: + self, func: Callable[..., ComponentsByVariant[Any]], *args: Any, **kwargs: Any + ) -> ComponentsByVariant[Any]: # We forcefully override strategy here. This lets a strategy # function always access its metadata and directly forward it to # subcomponents. kwargs["strategy"] = self return func(*args, **kwargs) - def __call__(self, *args: Any, **kwargs: Any) -> ComponentsByVariant: + def __call__(self, *args: Any, **kwargs: Any) -> ComponentsByVariant[Any]: return self._invoke(self.func, *args, **kwargs) def variant_processor(self, func: VariantProcessor) -> VariantProcessor: @@ -136,7 +136,7 @@ def variant_processor(self, func: VariantProcessor) -> VariantProcessor: def get_grouping_components( self, event: BaseEvent, context: GroupingContext - ) -> ComponentsByVariant: + ) -> ComponentsByVariant[Any]: """ Return a dictionary, keyed by variant name, of components produced by this strategy. """ @@ -335,7 +335,7 @@ def produces_variants( def decorator( strategy_func: StrategyFunc[ConcreteInterface], ) -> StrategyFunc[ConcreteInterface]: - def inner(*args: Any, **kwargs: Any) -> ComponentsByVariant: + def inner(*args: Any, **kwargs: Any) -> ComponentsByVariant[Any]: return call_with_variants(strategy_func, variants, *args, **kwargs) return inner @@ -344,11 +344,11 @@ def inner(*args: Any, **kwargs: Any) -> ComponentsByVariant: def call_with_variants( - strategy_func: Callable[..., ComponentsByVariant], + strategy_func: Callable[..., ComponentsByVariant[Any]], variants_to_produce: Sequence[str], *args: Any, **kwargs: Any, -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: context = kwargs["context"] incoming_variant_name = context.get("variant_name") @@ -383,7 +383,7 @@ def call_with_variants( def _get_grouping_components_for_interface( interface: Interface, event: BaseEvent, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: """ Apply a delegate strategy to the given interface to get a dictionary of grouping components keyed by variant name. @@ -436,12 +436,15 @@ def get_single_grouping_component( ) assert len(components_by_variant) == 1 - return components_by_variant[variant_name] + # justification: the single entry is the component built for this variant. + return cast( + "FrameGroupingComponent | ExceptionGroupingComponent", components_by_variant[variant_name] + ) def get_grouping_components_by_variant( interface: Interface, event: BaseEvent, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: """ Called by a strategy to invoke delegates on its child interfaces. diff --git a/src/sentry/grouping/strategies/configurations.py b/src/sentry/grouping/strategies/configurations.py index 033845e419c8..260ff292c9cb 100644 --- a/src/sentry/grouping/strategies/configurations.py +++ b/src/sentry/grouping/strategies/configurations.py @@ -1,3 +1,5 @@ +from typing import Any + from sentry.conf.server import FALL_2025_GROUPING_CONFIG, WINTER_2023_GROUPING_CONFIG from sentry.grouping.strategies.base import ( StrategyConfiguration, @@ -37,7 +39,10 @@ ) -def register_grouping_config(id: str, **kwargs) -> type[StrategyConfiguration]: +def register_grouping_config( + id: str, + **kwargs: Any, # justification: forwards heterogeneous strategy configuration fields. +) -> type[StrategyConfiguration]: # Replace the base strategy id in kwargs with the base stategy class itself (or the default # base class, if no base is specified) base_config_id = kwargs.get("base") diff --git a/src/sentry/grouping/strategies/message.py b/src/sentry/grouping/strategies/message.py index dc2eff722603..432981425e70 100644 --- a/src/sentry/grouping/strategies/message.py +++ b/src/sentry/grouping/strategies/message.py @@ -20,7 +20,7 @@ @produces_variants(["default"]) def message_v1( interface: Message, event: BaseEvent, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: variant_name = context["variant_name"] # This is true for all but our test config diff --git a/src/sentry/grouping/strategies/newstyle.py b/src/sentry/grouping/strategies/newstyle.py index a920e5641291..64cfc3d14fa4 100644 --- a/src/sentry/grouping/strategies/newstyle.py +++ b/src/sentry/grouping/strategies/newstyle.py @@ -244,7 +244,7 @@ def get_function_component( else: func = function or raw_function if not raw_function and function: - func = trim_function_name(func, platform) + func = trim_function_name(function, platform) if not func: return FunctionGroupingComponent() @@ -303,7 +303,7 @@ def get_function_component( ) def frame( interface: Frame, event: BaseEvent, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: frame = interface platform = frame.platform or event.platform variant_name = context["variant_name"] @@ -414,7 +414,7 @@ def get_contextline_component( @strategy(ids=["stacktrace:v1"], interface=Stacktrace, score=1800) def stacktrace( interface: Stacktrace, event: BaseEvent, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: assert context.get("variant_name") is None stacktrace_components_by_variant = call_with_variants( @@ -481,7 +481,7 @@ def stacktrace( def _single_stacktrace_variant( stacktrace: Stacktrace, event: BaseEvent, context: GroupingContext, kwargs: dict[str, Any] -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: variant_name = context["variant_name"] assert variant_name is not None @@ -542,8 +542,8 @@ def _single_stacktrace_variant( @stacktrace.variant_processor def stacktrace_variant_processor( - variants: ComponentsByVariant, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: + variants: ComponentsByVariant[Any], context: GroupingContext, **kwargs: Any +) -> ComponentsByVariant[Any]: return remove_non_stacktrace_variants(variants) @@ -553,7 +553,7 @@ def stacktrace_variant_processor( ) def single_exception( interface: SingleException, event: BaseEvent, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: exception = interface type_component = ErrorTypeGroupingComponent( @@ -644,9 +644,12 @@ def single_exception( @strategy(ids=["chained-exception:v1"], interface=ChainedException, score=2000) def chained_exception( interface: ChainedException, event: BaseEvent, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: # Get all the exceptions to consider. - all_exceptions = interface.exceptions() + all_exceptions: list[SingleException] = [] + for exception in interface.exceptions(): + assert exception is not None + all_exceptions.append(exception) # For each exception, create a dictionary of grouping components by variant name exception_components_by_exception = { @@ -863,15 +866,15 @@ def get_first_path(exception: SingleException) -> Generator[SingleException]: @chained_exception.variant_processor def chained_exception_variant_processor( - variants: ComponentsByVariant, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: + variants: ComponentsByVariant[Any], context: GroupingContext, **kwargs: Any +) -> ComponentsByVariant[Any]: return remove_non_stacktrace_variants(variants) @strategy(ids=["threads:v1"], interface=Threads, score=1900) def threads( interface: Threads, event: BaseEvent, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: crashed_threads = [thread for thread in interface.values if thread.get("crashed")] thread_components = _get_thread_components(crashed_threads, event, context, **kwargs) if thread_components is not None: @@ -904,7 +907,7 @@ def _get_thread_components( event: BaseEvent, context: GroupingContext, **kwargs: dict[str, Any], -) -> ComponentsByVariant | None: +) -> ComponentsByVariant[Any] | None: if len(threads) != 1: return None @@ -930,8 +933,8 @@ def _get_thread_components( @threads.variant_processor def threads_variant_processor( - variants: ComponentsByVariant, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: + variants: ComponentsByVariant[Any], context: GroupingContext, **kwargs: Any +) -> ComponentsByVariant[Any]: return remove_non_stacktrace_variants(variants) @@ -990,7 +993,7 @@ def _walk_to_non_wrapper_parent( parent_id = current.mechanism.parent_id parent = exceptions_by_id[parent_id] if not _is_diagnostic_wrapper(parent): - return parent_id + return parent_id if isinstance(parent_id, int) else None current = parent return None @@ -1018,7 +1021,8 @@ def java_rxjava_framework_exceptions(exceptions: list[SingleException]) -> int | and exception.mechanism.parent_id == rxjava_exception_id and exception.mechanism.exception_id is not None ): - return exception.mechanism.exception_id + exception_id = exception.mechanism.exception_id + return exception_id if isinstance(exception_id, int) else None return None diff --git a/src/sentry/grouping/strategies/security.py b/src/sentry/grouping/strategies/security.py index 62072ddb5cde..fcd44d72c5cd 100644 --- a/src/sentry/grouping/strategies/security.py +++ b/src/sentry/grouping/strategies/security.py @@ -28,7 +28,7 @@ @produces_variants(["default"]) def expect_ct_v1( interface: ExpectCT, event: BaseEvent, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: variant_name = context["variant_name"] return { @@ -45,7 +45,7 @@ def expect_ct_v1( @produces_variants(["default"]) def expect_staple_v1( interface: ExpectStaple, event: BaseEvent, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: variant_name = context["variant_name"] return { @@ -62,7 +62,7 @@ def expect_staple_v1( @produces_variants(["default"]) def hpkp_v1( interface: Hpkp, event: BaseEvent, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: variant_name = context["variant_name"] return { @@ -79,8 +79,10 @@ def hpkp_v1( @produces_variants(["default"]) def csp_v1( interface: Csp, event: BaseEvent, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: variant_name = context["variant_name"] + normalized_blocked_uri = interface.normalized_blocked_uri + assert normalized_blocked_uri is not None violation_component = ViolationGroupingComponent() uri_component = URIGroupingComponent() @@ -90,13 +92,13 @@ def csp_v1( uri_component.update( contributes=False, hint="ignored because violation takes precedence", - values=[interface.normalized_blocked_uri], + values=[normalized_blocked_uri], ) else: violation_component.update( contributes=False, hint="ignored because it's not a local script violation" ) - uri_component.update(values=[interface.normalized_blocked_uri]) + uri_component.update(values=[normalized_blocked_uri]) return { variant_name: CSPGroupingComponent( diff --git a/src/sentry/grouping/strategies/template.py b/src/sentry/grouping/strategies/template.py index 7279537ecb98..f8a1882be5bc 100644 --- a/src/sentry/grouping/strategies/template.py +++ b/src/sentry/grouping/strategies/template.py @@ -23,7 +23,7 @@ @produces_variants(["default"]) def template_v1( interface: Template, event: BaseEvent, context: GroupingContext, **kwargs: Any -) -> ComponentsByVariant: +) -> ComponentsByVariant[Any]: variant_name = context["variant_name"] filename_component = FilenameGroupingComponent() diff --git a/src/sentry/grouping/strategies/utils.py b/src/sentry/grouping/strategies/utils.py index d83b1622b3fb..ee62d66be651 100644 --- a/src/sentry/grouping/strategies/utils.py +++ b/src/sentry/grouping/strategies/utils.py @@ -1,7 +1,9 @@ +from typing import Any + from sentry.grouping.strategies.base import ComponentsByVariant -def remove_non_stacktrace_variants(variants: ComponentsByVariant) -> ComponentsByVariant: +def remove_non_stacktrace_variants(variants: ComponentsByVariant[Any]) -> ComponentsByVariant[Any]: """ Given multiple variants, if at least one variant has a contributing stacktrace, mark all variants without a stacktrace as non-contributing. diff --git a/src/sentry/http.py b/src/sentry/http.py index afc2b9883e81..bb2ef4822a2b 100644 --- a/src/sentry/http.py +++ b/src/sentry/http.py @@ -4,12 +4,14 @@ import logging import time import warnings +from collections.abc import Mapping, MutableMapping from io import BytesIO from typing import Any, NamedTuple from urllib.parse import urlparse from django.conf import settings from django.core.exceptions import SuspiciousOperation +from requests import Response from requests.exceptions import ReadTimeout, RequestException, Timeout from sentry import options @@ -43,7 +45,7 @@ class UrlResult(NamedTuple): class BadSource(Exception): error_type = EventErrorType.UNKNOWN_ERROR - def __init__(self, data=None): + def __init__(self, data: MutableMapping[str, object] | None = None) -> None: if data is None: data = {} data.setdefault("type", self.error_type) @@ -56,25 +58,28 @@ class CannotFetch(BadSource): def get_server_hostname() -> str: - return urlparse(options.get("system.url-prefix")).hostname + hostname = urlparse(options.get("system.url-prefix")).hostname + if not isinstance(hostname, str): + raise ValueError("system.url-prefix has no hostname") + return hostname build_session = SafeSession def safe_urlopen( - url, - method=None, - params=None, - data=None, - json=None, - headers=None, - allow_redirects=False, - timeout=30, - verify_ssl=True, - user_agent=None, - stream=False, -): + url: str | bytes | None, + method: str | None = None, + params: Mapping[str, object] | None = None, + data: Mapping[str, object] | str | bytes | None = None, + json: object = None, + headers: MutableMapping[str, str] | None = None, + allow_redirects: bool = False, + timeout: float = 30, + verify_ssl: bool = True, + user_agent: str | None = None, + stream: bool = False, +) -> Response: """ A slightly safer version of ``urlib2.urlopen`` which prevents redirection and ensures the URL isn't attempting to hit a blacklisted IP range. @@ -82,6 +87,9 @@ def safe_urlopen( if user_agent is not None: warnings.warn("user_agent is no longer used with safe_urlopen") + if url is None: + raise ValueError("safe_urlopen requires a URL") + with SafeSession() as session: kwargs = {} @@ -116,19 +124,18 @@ def safe_urlopen( return response -def safe_urlread(response): +def safe_urlread(response: Response) -> bytes: return response.content -def expose_url(url): +def expose_url(url: str | bytes | None) -> str: if url is None: return "" - if url[:5] == "data:": - return "" - url = truncatechars(url, MAX_URL_LENGTH) if isinstance(url, bytes): url = url.decode("utf-8", "replace") - return url + if url[:5] == "data:": + return "" + return truncatechars(url, MAX_URL_LENGTH) def get_domain_key(url: str) -> str: @@ -136,7 +143,7 @@ def get_domain_key(url: str) -> str: return f"source:blacklist:v2:{md5_text(domain).hexdigest()}" -def lock_domain(url, error=None): +def lock_domain(url: str, error: Mapping[str, object] | None = None) -> None: error = dict(error or {}) if error.get("type") is None: error["type"] = EventErrorType.UNKNOWN_ERROR diff --git a/src/sentry/hybridcloud/apigateway/proxy.py b/src/sentry/hybridcloud/apigateway/proxy.py index b997acf5790d..954b654519fa 100644 --- a/src/sentry/hybridcloud/apigateway/proxy.py +++ b/src/sentry/hybridcloud/apigateway/proxy.py @@ -8,7 +8,7 @@ from collections.abc import Callable, Generator from http.cookiejar import Cookie from threading import local -from typing import Any +from typing import Any, cast from urllib.parse import urljoin from wsgiref.util import is_hop_by_hop @@ -75,7 +75,8 @@ def _get_connection() -> Session: session = Session() session.cookies = _StatelessCookieJar() _connection.session = session - return _connection.session + # justification: the connection's session is created above when missing. + return cast("Session", _connection.session) def _parse_response(response: ExternalResponse, remote_url: str) -> StreamingHttpResponse: diff --git a/src/sentry/hybridcloud/models/outbox.py b/src/sentry/hybridcloud/models/outbox.py index f6060fc64ee5..048771bbeef8 100644 --- a/src/sentry/hybridcloud/models/outbox.py +++ b/src/sentry/hybridcloud/models/outbox.py @@ -104,7 +104,10 @@ def next_object_identifier(cls) -> int: using = router.db_for_write(cls) with connections[using].cursor() as cursor: cursor.execute("SELECT nextval(%s)", [f"{cls._meta.db_table}_id_seq"]) - return cursor.fetchone()[0] + row = cursor.fetchone() + if row is None or not isinstance(row[0], int): + raise TypeError("Outbox identifier sequence returned no integer") + return row[0] @classmethod def find_scheduled_shards(cls, low: int = 0, hi: int | None = None) -> list[Mapping[str, Any]]: @@ -366,7 +369,7 @@ def _maybe_log_unexpected_coalescing(self, coalesced_id: int, coalesced_count: i logger.error("outbox.unexpected_coalescing", extra=extra) def _set_span_data_for_coalesced_message(self, span: StreamedSpan, message: OutboxBase) -> None: - tag_for_outbox = OutboxScope.get_tag_name(message.shard_scope) + tag_for_outbox = OutboxScope.get_tag_name(OutboxScope(message.shard_scope)) span.set_attribute(tag_for_outbox, message.shard_identifier) span.set_attribute("outbox_id", message.id) span.set_attribute("outbox_shard_id", message.shard_identifier) diff --git a/src/sentry/hybridcloud/rpc/__init__.py b/src/sentry/hybridcloud/rpc/__init__.py index 46fa54612fa8..c752dfa66dba 100644 --- a/src/sentry/hybridcloud/rpc/__init__.py +++ b/src/sentry/hybridcloud/rpc/__init__.py @@ -33,7 +33,7 @@ def __eq__(self, other: Any) -> bool: value = other if isinstance(other, Enum): value = other.value - return self.value == value + return bool(self.value == value) def __hash__(self) -> int: return hash(self.value) @@ -228,14 +228,16 @@ def coerce_id_from(m: object | int | None) -> int | None: return None if isinstance(m, int): return m - if hasattr(m, "id"): - return m.id + value = getattr(m, "id", None) + if isinstance(value, int): + return value raise ValueError(f"Cannot coerce {m!r} into id!") def extract_id_from(m: object | int) -> int: if isinstance(m, int): return m - if hasattr(m, "id"): - return m.id + value = getattr(m, "id", None) + if isinstance(value, int): + return value raise ValueError(f"Cannot extract {m!r} from id!") diff --git a/src/sentry/hybridcloud/rpc/caching/impl.py b/src/sentry/hybridcloud/rpc/caching/impl.py index 58bf332a0824..907c64aaf6d9 100644 --- a/src/sentry/hybridcloud/rpc/caching/impl.py +++ b/src/sentry/hybridcloud/rpc/caching/impl.py @@ -1,5 +1,5 @@ from collections.abc import Generator, Mapping -from typing import TypeVar +from typing import TypeVar, cast from django.core.cache import cache from django.core.cache.backends.base import DEFAULT_TIMEOUT @@ -23,7 +23,8 @@ def _consume_generator(g: Generator[None, None, _V]) -> _V: try: g.send(None) except StopIteration as e: - return e.value + # justification: the generator returns the cached value. + return cast("_V", e.value) def _set_cache( diff --git a/src/sentry/hybridcloud/rpc/service.py b/src/sentry/hybridcloud/rpc/service.py index 4061e5177c62..edad8af767be 100644 --- a/src/sentry/hybridcloud/rpc/service.py +++ b/src/sentry/hybridcloud/rpc/service.py @@ -531,7 +531,8 @@ def _get_connection(retry_count: int) -> requests.Session: http = _create_request_session(retry_count) _connections.lookup[retry_count] = http - return _connections.lookup[retry_count] + # justification: the session is created above when missing. + return cast("requests.Session", _connections.lookup[retry_count]) @dataclass(frozen=True) @@ -593,7 +594,10 @@ def get_method_retry_count(self) -> int: # value set for the override is invalid sentry_sdk.capture_exception() - return options.get("hybridcloud.rpc.retries") + retries = options.get("hybridcloud.rpc.retries") + if not isinstance(retries, int): + raise TypeError("hybridcloud.rpc.retries must be an integer") + return retries def get_method_timeout(self) -> float: timeout_key = f"{self.service_name}.{self.method_name}" diff --git a/src/sentry/hybridcloud/rpc/sig.py b/src/sentry/hybridcloud/rpc/sig.py index 9288ad802e5b..a19256e4f53e 100644 --- a/src/sentry/hybridcloud/rpc/sig.py +++ b/src/sentry/hybridcloud/rpc/sig.py @@ -3,7 +3,7 @@ import inspect import itertools from collections.abc import Callable, Iterable, Sequence -from typing import Any +from typing import Any, cast import pydantic from django.utils.functional import LazyObject @@ -82,7 +82,11 @@ def create_field(param: inspect.Parameter) -> tuple[Any, Any]: if self.is_instance_method: parameters = parameters[1:] # exclude `self` argument field_definitions = {p.name: create_field(p) for p in parameters} - return pydantic.create_model(model_name, **field_definitions) # type: ignore[call-overload] + # justification: pydantic create_model returns a model class. + return cast( + "type[pydantic.BaseModel]", + pydantic.create_model(model_name, **field_definitions), # type: ignore[call-overload] + ) _RETURN_MODEL_ATTR = "value" @@ -101,7 +105,11 @@ def _create_return_model(self) -> type[pydantic.BaseModel]: self._validate_type_token("return type", return_type) field_definitions = {self._RETURN_MODEL_ATTR: (return_type, ...)} - return pydantic.create_model(model_name, **field_definitions) # type: ignore[call-overload] + # justification: pydantic create_model returns a model class. + return cast( + "type[pydantic.BaseModel]", + pydantic.create_model(model_name, **field_definitions), # type: ignore[call-overload] + ) @staticmethod def _unwrap_lazy_django_object(arg: Any) -> Any: diff --git a/src/sentry/hybridcloud/services/organizationmember_mapping/impl.py b/src/sentry/hybridcloud/services/organizationmember_mapping/impl.py index cc1849a7802c..fa5209965e79 100644 --- a/src/sentry/hybridcloud/services/organizationmember_mapping/impl.py +++ b/src/sentry/hybridcloud/services/organizationmember_mapping/impl.py @@ -29,11 +29,11 @@ def upsert_mapping( mapping: RpcOrganizationMemberMappingUpdate, ) -> RpcOrganizationMemberMapping: def apply_update(orm_mapping: OrganizationMemberMapping) -> None: - adding_user = orm_mapping.user_id is None and mapping.user_id is not None + adding_user = not orm_mapping.user_id and mapping.user_id is not None orm_mapping.role = mapping.role - orm_mapping.user_id = mapping.user_id + setattr(orm_mapping, "user_id", mapping.user_id) orm_mapping.email = mapping.email - orm_mapping.inviter_id = mapping.inviter_id + setattr(orm_mapping, "inviter_id", mapping.inviter_id) orm_mapping.invite_status = mapping.invite_status orm_mapping.organizationmember_id = organizationmember_id orm_mapping.save() diff --git a/src/sentry/hybridcloud/tasks/backfill_outboxes.py b/src/sentry/hybridcloud/tasks/backfill_outboxes.py index 20412bc31a84..7c83051e3188 100644 --- a/src/sentry/hybridcloud/tasks/backfill_outboxes.py +++ b/src/sentry/hybridcloud/tasks/backfill_outboxes.py @@ -129,7 +129,10 @@ def find_replication_version( return coded_version model_key = f"outbox_replication.{model._meta.db_table}.replication_version" - return min(options.get(model_key), coded_version) + configured_version = options.get(model_key) + if not isinstance(configured_version, int): + raise TypeError(f"{model_key} must be an integer") + return min(configured_version, coded_version) def _chunk_processing_batch( diff --git a/src/sentry/hybridcloud/webhook_mailbox_sizing.py b/src/sentry/hybridcloud/webhook_mailbox_sizing.py index fa789da7349d..341e0279d294 100644 --- a/src/sentry/hybridcloud/webhook_mailbox_sizing.py +++ b/src/sentry/hybridcloud/webhook_mailbox_sizing.py @@ -100,11 +100,11 @@ def _payloads_per_mailbox() -> int: validated: the drain reads a zero `worker_threads` as one rather than rejecting it, so a zero must not reach the division here either. """ - return max( - 1, - options.get("hybridcloud.webhookpayload.payloads_per_thread") - * options.get("hybridcloud.webhookpayload.worker_threads"), - ) + payloads_per_thread = options.get("hybridcloud.webhookpayload.payloads_per_thread") + worker_threads = options.get("hybridcloud.webhookpayload.worker_threads") + if not isinstance(payloads_per_thread, int) or not isinstance(worker_threads, int): + raise TypeError("Webhook payload sizing options must be integers") + return max(1, payloads_per_thread * worker_threads) def _max_buckets() -> int: diff --git a/src/sentry/identity/base.py b/src/sentry/identity/base.py index a3d4e39c32ce..bf24578ffba8 100644 --- a/src/sentry/identity/base.py +++ b/src/sentry/identity/base.py @@ -2,6 +2,7 @@ import abc import logging +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from sentry.identity.services.identity.model import RpcIdentity @@ -20,7 +21,7 @@ class Provider(PipelineProvider["IdentityPipeline"], abc.ABC): auto_create_provider_model = False create_organization_identity = False - def __init__(self, **config): + def __init__(self, **config: object) -> None: super().__init__() self.config = config self.logger = logging.getLogger(f"sentry.identity.{self.key}") @@ -33,7 +34,7 @@ def get_pipeline_config(self, data: dict[str, Any]) -> dict[str, str]: """ return {} - def build_identity(self, state): + def build_identity(self, state: Mapping[str, object]) -> Mapping[str, object]: """ Return a mapping containing the identity information. @@ -58,7 +59,9 @@ def build_identity(self, state): """ raise NotImplementedError - def update_identity(self, new_data, current_data): + def update_identity( + self, new_data: Mapping[str, object], current_data: Mapping[str, object] + ) -> Mapping[str, object]: """ When re-authenticating with a provider, the identity data may need to be mutated based on the previous state. An example of this is Google, diff --git a/src/sentry/identity/datadog/provider.py b/src/sentry/identity/datadog/provider.py index 085392ac56bb..1903ebabf6af 100644 --- a/src/sentry/identity/datadog/provider.py +++ b/src/sentry/identity/datadog/provider.py @@ -3,7 +3,8 @@ import base64 import hashlib import secrets -from typing import TYPE_CHECKING, Any, NotRequired, TypedDict +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast import orjson import sentry_sdk @@ -91,9 +92,13 @@ def mcp_whoami(mcp_base_url: str, auth_headers: dict[str, str]) -> DatadogWhoami try: body = orjson.loads(safe_urlread(resp)) - return orjson.loads(body["result"]["contents"][0]["text"]) + whoami = orjson.loads(body["result"]["contents"][0]["text"]) except (KeyError, IndexError, orjson.JSONDecodeError) as e: raise IdentityNotValid("MCP whoami returned an unexpected response") from e + if not isinstance(whoami, dict): + raise IdentityNotValid("MCP whoami returned an unexpected response") + # justification: the MCP whoami payload carries the DatadogWhoami fields. + return cast("DatadogWhoami", whoami) def get_user_info(access_token: str, mcp_base_url: str) -> DatadogWhoami: @@ -213,7 +218,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 +250,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, @@ -331,15 +336,15 @@ def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]: ), ] - def get_oauth_data(self, payload: dict[str, Any]) -> dict[str, Any]: + def get_oauth_data(self, payload: Mapping[str, object]) -> dict[str, Any]: data = super().get_oauth_data(payload) if "scope" in payload: data["scope"] = payload["scope"] return data - def build_identity(self, data: dict[str, Any]) -> dict[str, Any]: - token_data = data["data"] - access_token = token_data.get("access_token") + def build_identity(self, data: Mapping[str, object]) -> dict[str, Any]: + token_data = cast(Mapping[str, object], data["data"]) + access_token = cast(str | None, token_data.get("access_token")) if not access_token: raise ValueError("Datadog token exchange did not return an access_token") @@ -353,8 +358,8 @@ def build_identity(self, data: dict[str, Any]) -> dict[str, Any]: oauth_data = self.get_oauth_data(token_data) # Persist DCR credentials and site so refresh_identity can access them outside a pipeline context. - client_id = data.get("dcr_client_id") - client_secret = data.get("dcr_client_secret") + client_id = cast(str | None, data.get("dcr_client_id")) + client_secret = cast(str | None, data.get("dcr_client_secret")) if not client_id or not client_secret: raise IdentityNotValid("Missing DCR credentials") oauth_data["client_id"] = client_id @@ -432,12 +437,12 @@ def build_mcp_urls(self, identity_data: dict[str, Any]) -> list[str]: base = mcp_base_url_for_site(identity_data.get("site")) return [f"{base}{MCP_ENDPOINT_PATH}"] if base else [] - def build_identity(self, data: dict[str, Any]) -> dict[str, Any]: - access_token = (data.get("access_token") or "").strip() + def build_identity(self, data: Mapping[str, object]) -> dict[str, Any]: + access_token = cast(str, data.get("access_token") or "").strip() if not access_token: raise ValueError("Datadog requires an 'access_token' parameter.") - site = data.get("site") + site = cast(str | None, data.get("site")) base = mcp_base_url_for_site(site) if not site: raise ValueError("Datadog requires a 'site' parameter (e.g. 'datadoghq.com').") diff --git a/src/sentry/identity/discord/provider.py b/src/sentry/identity/discord/provider.py index 44b4f9364461..425f0360bb04 100644 --- a/src/sentry/identity/discord/provider.py +++ b/src/sentry/identity/discord/provider.py @@ -1,7 +1,16 @@ -from sentry.auth.provider import Provider +from collections.abc import Callable, Sequence + +from sentry.identity.base import Provider +from sentry.identity.pipeline import IdentityPipeline from sentry.integrations.types import IntegrationProviderSlug +from sentry.pipeline.views.base import PipelineView class DiscordIdentityProvider(Provider): key = IntegrationProviderSlug.DISCORD.value name = "Discord" + + def get_pipeline_views( + self, + ) -> Sequence[PipelineView[IdentityPipeline] | Callable[[], PipelineView[IdentityPipeline]]]: + return [] diff --git a/src/sentry/identity/gcp/provider.py b/src/sentry/identity/gcp/provider.py index 9a9efe4c115c..da72e8389996 100644 --- a/src/sentry/identity/gcp/provider.py +++ b/src/sentry/identity/gcp/provider.py @@ -1,9 +1,11 @@ from __future__ import annotations import logging -from typing import Any +from collections.abc import Mapping +from typing import Any, cast import orjson +from django.core.exceptions import ImproperlyConfigured from sentry import options from sentry.auth.exceptions import IdentityNotValid @@ -28,7 +30,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"] = ( @@ -55,10 +57,16 @@ class GCPIdentityProvider(McpIdentityProvider, OAuth2Provider): ) def get_oauth_client_id(self) -> str: - return options.get("gcp.client-id") + client_id = options.get("gcp.client-id") + if not isinstance(client_id, str): + raise ImproperlyConfigured("gcp.client-id is not configured") + return client_id def get_oauth_client_secret(self) -> str: - return options.get("gcp.client-secret") + client_secret = options.get("gcp.client-secret") + if not isinstance(client_secret, str): + raise ImproperlyConfigured("gcp.client-secret is not configured") + return client_secret def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]: return [ @@ -80,11 +88,11 @@ def make_oauth_api_step(self, **kwargs: Any) -> OAuth2ApiStep: **kwargs, ) - def build_identity(self, state: dict[str, Any]) -> dict[str, Any]: - data = state.get("data", {}) + def build_identity(self, state: Mapping[str, object]) -> dict[str, Any]: + data = cast(Mapping[str, object], state.get("data", {})) try: - id_token = data["id_token"] + id_token = cast(str, data["id_token"]) except KeyError: raise IdentityNotValid("Missing id_token in OAuth response") diff --git a/src/sentry/identity/github/provider.py b/src/sentry/identity/github/provider.py index b3e896ee3b55..ca8097878594 100644 --- a/src/sentry/identity/github/provider.py +++ b/src/sentry/identity/github/provider.py @@ -1,7 +1,8 @@ import logging -from typing import Any +from collections.abc import Mapping +from typing import Any, cast -from django.core.exceptions import PermissionDenied +from django.core.exceptions import ImproperlyConfigured, PermissionDenied from requests.exceptions import RequestException from sentry import http, options @@ -18,7 +19,7 @@ logger = logging.getLogger(__name__) -def get_user_info(access_token): +def get_user_info(access_token: str) -> dict[str, object]: from sentry.integrations.github.constants import GITHUB_API_ACCEPT_HEADER with http.build_session() as session: @@ -27,19 +28,23 @@ def get_user_info(access_token): headers={"Accept": GITHUB_API_ACCEPT_HEADER, "Authorization": f"token {access_token}"}, ) resp.raise_for_status() - return resp.json() + data = resp.json() + if not isinstance(data, dict): + raise ValueError("GitHub user info is not a JSON object") + return data -def get_verified_primary_email(emails: list[dict[str, Any]]) -> str | None: +def get_verified_primary_email(emails: list[Any]) -> str | None: """Return the verified primary email from GitHub email records, else None.""" - verified_primary = [ - e["email"] - for e in emails - if isinstance(e, dict) and e.get("email") and e.get("verified") and e.get("primary") - ] - if not verified_primary: - return None - return verified_primary[0] + for entry in emails: + if not isinstance(entry, dict): + continue + if not (entry.get("email") and entry.get("verified") and entry.get("primary")): + continue + email = entry["email"] + if isinstance(email, str): + return email + return None def fetch_verified_primary_email(access_token: str) -> str | None: @@ -80,15 +85,22 @@ class GitHubIdentityProvider(OAuth2Provider): oauth_scopes = () - def get_oauth_client_id(self): - return options.get("github-app.client-id") - - def get_oauth_client_secret(self): - return options.get("github-app.client-secret") - - def build_identity(self, data): - data = data["data"] - access_token = data.get("access_token") + def get_oauth_client_id(self) -> str: + client_id = options.get("github-app.client-id") + if not isinstance(client_id, str): + raise ImproperlyConfigured("github-app.client-id is not configured") + return client_id + + def get_oauth_client_secret(self) -> str: + client_secret = options.get("github-app.client-secret") + if not isinstance(client_secret, str): + raise ImproperlyConfigured("github-app.client-secret is not configured") + return client_secret + + def build_identity(self, data: Mapping[str, object]) -> Mapping[str, object]: + # justification: provider OAuth responses are decoded JSON. + identity_data = cast(Mapping[str, object], data["data"]) + access_token = cast(str | None, identity_data.get("access_token")) if not access_token: raise PermissionDenied() user = get_user_info(access_token) @@ -102,7 +114,7 @@ def build_identity(self, data): "name": user["name"], "company": user["company"], "scopes": [], # GitHub apps do not have user scopes - "data": self.get_oauth_data(data), + "data": self.get_oauth_data(identity_data), } def post_link_identity(self, identity: dict[str, Any], user_id: int) -> None: diff --git a/src/sentry/identity/gitlab/provider.py b/src/sentry/identity/gitlab/provider.py index ca2e1677a18a..caa347897fd5 100644 --- a/src/sentry/identity/gitlab/provider.py +++ b/src/sentry/identity/gitlab/provider.py @@ -1,8 +1,9 @@ from __future__ import annotations import logging +from collections.abc import Mapping from time import time -from typing import Any +from typing import Any, cast import orjson @@ -22,7 +23,7 @@ ACCESS_TOKEN_LIFETIME_S = 2 * 60 * 60 -def get_oauth_data(payload): +def get_oauth_data(payload: Mapping[str, object]) -> dict[str, object]: data = {"access_token": payload["access_token"]} # https://docs.gitlab.com/ee/api/oauth2.html#2-requesting-access-token @@ -38,23 +39,23 @@ def get_oauth_data(payload): # Measured on our clock rather than from GitLab's created_at, since it's compared against # our clock. GitLab before 15.0 can issue a token without an expiry, and gives it one when # the instance upgrades; assuming the usual lifetime refreshes it often enough either way. - data["expires_in"] = int(payload.get("expires_in", ACCESS_TOKEN_LIFETIME_S)) - data["expires"] = int(time()) + data["expires_in"] + data["expires_in"] = int(cast(int, payload.get("expires_in", ACCESS_TOKEN_LIFETIME_S))) + data["expires"] = int(time()) + cast(int, data["expires_in"]) if "refresh_token" in payload: data["refresh_token"] = payload["refresh_token"] if "token_type" in payload: data["token_type"] = payload["token_type"] if "created_at" in payload: - data["created_at"] = int(payload["created_at"]) + data["created_at"] = int(cast(int, payload["created_at"])) return data -def get_user_info(access_token, installation_data): +def get_user_info(access_token: str, installation_data: Mapping[str, object]) -> dict[str, object]: with http.build_session() as session: resp = session.get( f"{installation_data['url']}/api/v4/user", headers={"Accept": "application/json", "Authorization": f"Bearer {access_token}"}, - verify=installation_data["verify_ssl"], + verify=cast(bool, installation_data["verify_ssl"]), ) try: resp.raise_for_status() @@ -70,7 +71,10 @@ def get_user_info(access_token, installation_data): }, ) raise - return resp.json() + data = resp.json() + if not isinstance(data, dict): + raise TypeError("GitLab returned a non-object response") + return data class GitlabIdentityProvider(OAuth2Provider): @@ -79,15 +83,17 @@ class GitlabIdentityProvider(OAuth2Provider): oauth_scopes = ("api",) - def build_identity(self, data): - data = data["data"] + def build_identity(self, data: Mapping[str, object]) -> Mapping[str, object]: + # justification: provider OAuth responses are decoded JSON. + identity_data = cast(Mapping[str, object], data["data"]) + user = cast(Mapping[str, object], identity_data["user"]) return { "type": IntegrationProviderSlug.GITLAB.value, - "id": data["user"]["id"], - "email": data["user"]["email"], - "scopes": sorted(data["scope"].split(",")), - "data": self.get_oauth_data(data), + "id": user["id"], + "email": user["email"], + "scopes": sorted(cast(str, identity_data["scope"]).split(",")), + "data": self.get_oauth_data(identity_data), } def get_refresh_token_params( diff --git a/src/sentry/identity/google/provider.py b/src/sentry/identity/google/provider.py index 10d74c57d9c5..2ac787e5a7b4 100644 --- a/src/sentry/identity/google/provider.py +++ b/src/sentry/identity/google/provider.py @@ -1,6 +1,8 @@ -from typing import Any +from collections.abc import Mapping +from typing import Any, cast import orjson +from django.core.exceptions import ImproperlyConfigured from sentry import options from sentry.auth.exceptions import IdentityNotValid @@ -33,17 +35,24 @@ class GoogleIdentityProvider(OAuth2Provider): oauth_scopes = ("email",) - def get_oauth_client_id(self): - return options.get("auth-google.client-id") + def get_oauth_client_id(self) -> str: + client_id = options.get("auth-google.client-id") + if not isinstance(client_id, str): + raise ImproperlyConfigured("auth-google.client-id is not configured") + return client_id - def get_oauth_client_secret(self): - return options.get("auth-google.client-secret") + def get_oauth_client_secret(self) -> str: + client_secret = options.get("auth-google.client-secret") + if not isinstance(client_secret, str): + raise ImproperlyConfigured("auth-google.client-secret is not configured") + return client_secret - def build_identity(self, state): - data = state.get("data", {}) + def build_identity(self, state: Mapping[str, object]) -> Mapping[str, object]: + # justification: provider OAuth responses are decoded JSON. + data = cast(Mapping[str, object], state.get("data", {})) try: - id_token = data["id_token"] + id_token = cast(str, data["id_token"]) except KeyError: raise IdentityNotValid("Missing id_token in OAuth response: %s" % data) diff --git a/src/sentry/identity/manager.py b/src/sentry/identity/manager.py index 8f2e739cb627..ac1b9ce11874 100644 --- a/src/sentry/identity/manager.py +++ b/src/sentry/identity/manager.py @@ -1,40 +1,44 @@ __all__ = ["IdentityManager"] +from collections.abc import Iterator + from sentry.exceptions import NotRegistered from sentry.identity.base import Provider class IdentityManager: - def __init__(self): - self.__values = {} - self._login_providers = {} + def __init__(self) -> None: + self.__values: dict[str, type[Provider]] = {} + self._login_providers: dict[str, type[Provider]] = {} - def __iter__(self): + def __iter__(self) -> Iterator[Provider]: return iter(self.all()) - def all(self): + def all(self) -> Iterator[Provider]: for key in self.__values.keys(): provider = self.get(key) is_configured = getattr(provider, "is_configured", None) if is_configured is None or is_configured(): yield provider - def get(self, key: str, **kwargs) -> Provider: + def get(self, key: str, **kwargs: object) -> Provider: try: cls = self.__values[key] except KeyError: raise NotRegistered(key) return cls(**kwargs) - def exists(self, key): + def exists(self, key: str) -> bool: return key in self.__values - def register(self, cls, login_provider_cls=None): + def register( + self, cls: type[Provider], login_provider_cls: type[Provider] | None = None + ) -> None: self.__values[cls.key] = cls if login_provider_cls: self._login_providers[login_provider_cls.key] = cls - def unregister(self, cls): + def unregister(self, cls: type[Provider]) -> None: try: if self.__values[cls.key] != cls: # don't allow unregistering of arbitrary provider @@ -44,5 +48,5 @@ def unregister(self, cls): return del self.__values[cls.key] - def is_login_provider(self, key) -> bool: + def is_login_provider(self, key: str) -> bool: return key in self._login_providers diff --git a/src/sentry/identity/oauth2.py b/src/sentry/identity/oauth2.py index f68c68aadd9b..24fc00db1c9b 100644 --- a/src/sentry/identity/oauth2.py +++ b/src/sentry/identity/oauth2.py @@ -2,8 +2,9 @@ import logging import secrets +from collections.abc import Mapping from time import time -from typing import Any +from typing import Any, cast from urllib.parse import parse_qsl, urlencode import orjson @@ -63,7 +64,8 @@ def _redirect_url(pipeline: IdentityPipeline) -> str: ) # Use configured redirect_url if specified for the pipeline if available - return pipeline.config.get("redirect_url", associate_url) + redirect_url = pipeline.config.get("redirect_url", associate_url) + return redirect_url if isinstance(redirect_url, str) else associate_url class OAuth2Provider(Provider): @@ -81,7 +83,7 @@ class OAuth2Provider(Provider): oauth_scopes: tuple[str, ...] = () - def _get_oauth_parameter(self, parameter_name): + def _get_oauth_parameter(self, parameter_name: str) -> str: """ Lookup an OAuth parameter for the provider. Depending on the context of the pipeline using the provider, the parameter may come from 1 of 3 places: @@ -97,39 +99,42 @@ def _get_oauth_parameter(self, parameter_name): """ try: prop = getattr(self, f"oauth_{parameter_name}") - if prop != "": + if isinstance(prop, str) and prop != "": return prop except AttributeError: pass if self.config.get(parameter_name): - return self.config.get(parameter_name) + # justification: provider config values are validated strings + return cast("str", self.config.get(parameter_name)) model = self.pipeline.provider_model if model and model.config.get(parameter_name) is not None: - return model.config.get(parameter_name) + # justification: provider config values are validated strings + return cast("str", model.config.get(parameter_name)) raise KeyError(f'Unable to resolve OAuth parameter "{parameter_name}"') - def get_oauth_access_token_url(self): + def get_oauth_access_token_url(self) -> str: return self._get_oauth_parameter("access_token_url") - def get_oauth_refresh_token_url(self): + def get_oauth_refresh_token_url(self) -> str: raise NotImplementedError - def get_oauth_authorize_url(self): + def get_oauth_authorize_url(self) -> str: return self._get_oauth_parameter("authorize_url") - def get_oauth_client_id(self): + def get_oauth_client_id(self) -> str: return self._get_oauth_parameter("client_id") - def get_oauth_client_secret(self): + def get_oauth_client_secret(self) -> str: return self._get_oauth_parameter("client_secret") - def get_oauth_scopes(self): - return self.config.get("oauth_scopes", self.oauth_scopes) + def get_oauth_scopes(self) -> tuple[str, ...]: + # justification: provider config stores the scopes list set when the provider is created. + return cast("tuple[str, ...]", 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]]: @@ -173,7 +178,7 @@ def get_refresh_token_params( def get_refresh_token_url(self) -> str: raise NotImplementedError - def get_oauth_data(self, payload): + def get_oauth_data(self, payload: Mapping[str, Any]) -> dict[str, Any]: data = {"access_token": payload["access_token"]} if "expires_in" in payload: data["expires"] = int(time()) + int(payload["expires_in"]) @@ -185,7 +190,11 @@ def get_oauth_data(self, payload): return data def get_refresh_token( - self, refresh_token, url: str, identity: Identity | RpcIdentity, **kwargs: Any + self, + refresh_token: str, + url: str, + identity: Identity | RpcIdentity, + **kwargs: Any, ) -> Response: data = self.get_refresh_token_params(refresh_token, identity, **kwargs) @@ -229,7 +238,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 @@ -366,17 +375,27 @@ def _exchange_token(self, code: str) -> dict[str, Any]: content_type = req.headers.get("Content-Type", "").lower() if content_type.startswith("application/x-www-form-urlencoded"): return dict(parse_qsl(body.decode("utf-8"))) - return orjson.loads(body) + data = orjson.loads(body) + if not isinstance(data, dict): + raise OAuth2ApiStepError("Expected a JSON object response") + return data except orjson.JSONDecodeError as e: raise OAuth2ApiStepError("Could not decode a JSON response, please try again.") from e 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): + def __init__( + self, + authorize_url: str | None = None, + client_id: str | int | None = None, + scope: str | None = None, + *args: Any, + **kwargs: Any, + ) -> None: super().__init__(*args, **kwargs) if authorize_url is not None: self.authorize_url = authorize_url @@ -385,13 +404,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,10 +442,17 @@ 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): + def __init__( + self, + access_token_url: str | None = None, + client_id: str | int | None = None, + client_secret: str | None = None, + *args: Any, + **kwargs: Any, + ) -> None: super().__init__(*args, **kwargs) if access_token_url is not None: self.access_token_url = access_token_url @@ -435,7 +461,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, @@ -492,7 +518,10 @@ def exchange_token( content_type = req.headers.get("Content-Type", "").lower() if content_type.startswith("application/x-www-form-urlencoded"): return dict(parse_qsl(body.decode("utf-8"))) - return orjson.loads(body) + data = orjson.loads(body) + if not isinstance(data, dict): + raise OAuth2ApiStepError("Expected a JSON object response") + return data except orjson.JSONDecodeError: lifecycle.record_failure( "json_error", diff --git a/src/sentry/identity/pipeline.py b/src/sentry/identity/pipeline.py index fae0e6b13fb6..f12924b1a586 100644 --- a/src/sentry/identity/pipeline.py +++ b/src/sentry/identity/pipeline.py @@ -3,6 +3,7 @@ import logging from collections.abc import Callable, Sequence from functools import cached_property +from typing import cast from django.contrib import messages from django.db import router, transaction @@ -80,7 +81,7 @@ def finish_pipeline(self) -> HttpResponseBase: linked_identity = Identity.objects.link_identity( user=self.request.user, idp=self.provider_model, - external_id=identity["id"], + external_id=cast(str, identity["id"]), should_reattach=False, defaults={ "scopes": identity.get("scopes", []), @@ -101,7 +102,9 @@ def finish_pipeline(self) -> HttpResponseBase: # Let providers react to a freshly linked identity (e.g. backfilling # derived mappings). Best-effort: never let it break the link flow. try: - self.provider.post_link_identity(identity, self.request.user.id) + self.provider.post_link_identity( + cast(dict[str, object], identity), self.request.user.id + ) except Exception: logger.exception( "identity.post_link_identity.failed", diff --git a/src/sentry/identity/providers/dummy.py b/src/sentry/identity/providers/dummy.py index a0c46ac25279..9f3e95efb777 100644 --- a/src/sentry/identity/providers/dummy.py +++ b/src/sentry/identity/providers/dummy.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from django.http import HttpResponse @@ -31,7 +32,7 @@ class DummyProvider(Provider): def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]: return [AskEmail()] - def build_identity(self, state): + def build_identity(self, state: Mapping[str, object]) -> Mapping[str, object]: return {"id": state["email"], "email": state["email"], "name": "Dummy"} def refresh_identity(self, identity: Identity | RpcIdentity, **kwargs: Any) -> None: diff --git a/src/sentry/identity/slack/provider.py b/src/sentry/identity/slack/provider.py index d0845fab4dbd..aacb5b10d58f 100644 --- a/src/sentry/identity/slack/provider.py +++ b/src/sentry/identity/slack/provider.py @@ -1,3 +1,8 @@ +from collections.abc import Mapping, Sequence +from typing import TypedDict, cast + +from django.core.exceptions import ImproperlyConfigured + from sentry import options from sentry.identity.oauth2 import OAuth2CallbackView, OAuth2LoginView, OAuth2Provider from sentry.identity.pipeline import IdentityPipeline @@ -7,6 +12,20 @@ PREFERRED_ORGANIZATION_ID_KEY = "preferred_organization_id" +class SlackOAuthUser(TypedDict): + id: str + email: str + + +class SlackOAuthData(TypedDict): + user: SlackOAuthUser + scope: str + + +class SlackOAuthPayload(TypedDict): + data: SlackOAuthData + + class SlackIdentityProvider(OAuth2Provider): key = IntegrationProviderSlug.SLACK.value name = "Slack" @@ -30,14 +49,25 @@ def get_oauth_authorize_url(self) -> str: def get_oauth_access_token_url(self) -> str: return "https://slack.com/api/oauth.v2.access" - def get_oauth_client_id(self): - return options.get("slack.client-id") - - def get_oauth_client_secret(self): - return options.get("slack.client-secret") - - def get_user_scopes(self): - return self.config.get("user_scopes", self.user_scopes) + def get_oauth_client_id(self) -> str: + client_id = options.get("slack.client-id") + if not isinstance(client_id, str): + raise ImproperlyConfigured("slack.client-id is not configured") + return client_id + + def get_oauth_client_secret(self) -> str: + client_secret = options.get("slack.client-secret") + if not isinstance(client_secret, str): + raise ImproperlyConfigured("slack.client-secret is not configured") + return client_secret + + def get_user_scopes(self) -> Sequence[str]: + scopes = self.config.get("user_scopes", self.user_scopes) + if isinstance(scopes, str): + return (scopes,) + if isinstance(scopes, (list, tuple)): + return scopes + return self.user_scopes def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]: return [ @@ -54,14 +84,15 @@ def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]: ), ] - def get_oauth_data(self, payload): + def get_oauth_data(self, payload: Mapping[str, object]) -> dict[str, object]: # TODO(epurkhiser): This flow isn't actually used right now in sentry. # In slack-bot world we would need to make an API call to the 'me' # endpoint to get their user ID here. return super().get_oauth_data(payload) - def build_identity(self, data): - data = data["data"] + def build_identity(self, data: Mapping[str, object]) -> dict[str, object]: + payload = cast(SlackOAuthPayload, data) + data = payload["data"] return { "type": IntegrationProviderSlug.SLACK.value, @@ -77,13 +108,19 @@ class SlackStagingIdentityProvider(SlackIdentityProvider): key = IntegrationProviderSlug.SLACK_STAGING.value name = "Slack (Staging)" - def get_oauth_client_id(self): - return options.get("slack-staging.client-id") + def get_oauth_client_id(self) -> str: + client_id = options.get("slack-staging.client-id") + if not isinstance(client_id, str): + raise ImproperlyConfigured("slack-staging.client-id is not configured") + return client_id - def get_oauth_client_secret(self): - return options.get("slack-staging.client-secret") + def get_oauth_client_secret(self) -> str: + client_secret = options.get("slack-staging.client-secret") + if not isinstance(client_secret, str): + raise ImproperlyConfigured("slack-staging.client-secret is not configured") + return client_secret - def build_identity(self, data): + def build_identity(self, data: Mapping[str, object]) -> dict[str, object]: production_identity = super().build_identity(data) production_identity["type"] = IntegrationProviderSlug.SLACK_STAGING.value return production_identity @@ -99,12 +136,18 @@ class SlackOAuth2LoginView(OAuth2LoginView): user_scope = "" - def __init__(self, authorize_url=None, client_id=None, scope=None, user_scope=None): + def __init__( + self, + authorize_url: str | None = None, + client_id: str | int | None = None, + scope: str | None = None, + user_scope: str | None = None, + ) -> None: super().__init__(authorize_url=authorize_url, client_id=client_id, scope=scope) if user_scope is not None: self.user_scope = user_scope - def get_authorize_params(self, state, redirect_uri): + def get_authorize_params(self, state: str, redirect_uri: str) -> dict[str, str | int | None]: data = super().get_authorize_params(state, redirect_uri) # XXX(meredith): Bot apps must be added manually to channels, and link unfurling diff --git a/src/sentry/identity/vsts/provider.py b/src/sentry/identity/vsts/provider.py index f784b2ef6c4a..70c5c4f97cb3 100644 --- a/src/sentry/identity/vsts/provider.py +++ b/src/sentry/identity/vsts/provider.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any +from collections.abc import Mapping +from typing import Any, cast from django.core.exceptions import PermissionDenied from requests import Response @@ -16,7 +17,7 @@ from sentry.utils.http import absolute_uri -def get_user_info(access_token): +def get_user_info(access_token: str) -> dict[str, object]: with http.build_session() as session: resp = session.get( "https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=1.0", @@ -24,7 +25,10 @@ def get_user_info(access_token): ) resp.raise_for_status() - user = resp.json() + payload = resp.json() + if not isinstance(payload, dict): + raise TypeError("VSTS returned a non-object profile") + user: dict[str, object] = payload user["uuid"] = user["id"] resp = session.get( @@ -41,7 +45,10 @@ def get_user_info(access_token): # profiles/me API above). To get the current user's descriptor, we call the "connection data" # REST API (this assumes we are authenticating with an access token issued to the user). # We will also see descriptors returned for every user in the "Get users" (Graph) REST API. - user["id"] = resp.json()["authenticatedUser"]["subjectDescriptor"] + connection_data = resp.json() + if not isinstance(connection_data, dict): + raise TypeError("VSTS returned a non-object connection data payload") + user["id"] = connection_data["authenticatedUser"]["subjectDescriptor"] return user @@ -59,11 +66,13 @@ class VSTSIdentityProvider(OAuth2Provider): oauth_access_token_url = "https://app.vssps.visualstudio.com/oauth2/token" oauth_authorize_url = "https://app.vssps.visualstudio.com/oauth2/authorize" - def get_oauth_client_id(self): - return options.get("vsts.client-id") + def get_oauth_client_id(self) -> str: + # justification: registered option values are strings; the option has no static default. + return cast("str", options.get("vsts.client-id")) - def get_oauth_client_secret(self): - return options.get("vsts.client-secret") + def get_oauth_client_secret(self) -> str: + # justification: registered option values are strings; the option has no static default. + return cast("str", options.get("vsts.client-secret")) def get_refresh_token_url(self) -> str: return self.oauth_access_token_url @@ -82,7 +91,7 @@ def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]: ), ] - def get_refresh_token_headers(self): + def get_refresh_token_headers(self) -> dict[str, str]: return {"Content-Type": "application/x-www-form-urlencoded", "Content-Length": "1654"} def get_refresh_token_params( @@ -116,12 +125,13 @@ def get_refresh_token_params( "redirect_uri": absolute_uri(oauth_redirect_url), } - def build_identity(self, data): - data = data["data"] - access_token = data.get("access_token") + # justification: provider OAuth responses are decoded JSON. + def build_identity(self, data: Mapping[str, object]) -> Mapping[str, object]: + identity_data = cast(Mapping[str, object], data["data"]) + access_token = identity_data.get("access_token") if not access_token: raise PermissionDenied() - user = get_user_info(access_token) + user = get_user_info(cast(str, access_token)) return { "type": IntegrationProviderSlug.AZURE_DEVOPS.value, @@ -129,7 +139,7 @@ def build_identity(self, data): "email": user["emailAddress"], "name": user["displayName"], "scopes": sorted(self.oauth_scopes), - "data": self.get_oauth_data(data), + "data": self.get_oauth_data(identity_data), } @@ -163,16 +173,18 @@ class VSTSNewIdentityProvider(OAuth2Provider): oauth_authorize_url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" # Using a new option - def get_oauth_client_id(self): - return options.get("vsts_new.client-id") + def get_oauth_client_id(self) -> str: + # justification: registered option values are strings; the option has no static default. + return cast("str", options.get("vsts_new.client-id")) - def get_oauth_client_secret(self): - return options.get("vsts_new.client-secret") + def get_oauth_client_secret(self) -> str: + # justification: registered option values are strings; the option has no static default. + return cast("str", options.get("vsts_new.client-secret")) def get_refresh_token_url(self) -> str: return self.oauth_access_token_url - def get_pipeline_views(self): + def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]: return [ # made a new view to override `get_authorize_params` for the new params needed for the oauth VSTSOAuth2LoginView( @@ -187,7 +199,7 @@ def get_pipeline_views(self): ), ] - def get_refresh_token_headers(self): + def get_refresh_token_headers(self) -> dict[str, str]: return {"Content-Type": "application/x-www-form-urlencoded", "Content-Length": "1654"} def get_refresh_token_params( @@ -204,12 +216,13 @@ def get_refresh_token_params( "refresh_token": refresh_token, } - def build_identity(self, data): - data = data["data"] - access_token = data.get("access_token") + # justification: provider OAuth responses are decoded JSON. + def build_identity(self, data: Mapping[str, object]) -> Mapping[str, object]: + identity_data = cast(Mapping[str, object], data["data"]) + access_token = identity_data.get("access_token") if not access_token: raise PermissionDenied() - user = get_user_info(access_token) + user = get_user_info(cast(str, access_token)) return { "type": IntegrationProviderSlug.AZURE_DEVOPS.value, @@ -217,12 +230,12 @@ def build_identity(self, data): "email": user["emailAddress"], "name": user["displayName"], "scopes": sorted(self.oauth_scopes), - "data": self.get_oauth_data(data), + "data": self.get_oauth_data(identity_data), } class VSTSOAuth2LoginView(OAuth2LoginView): - def get_authorize_params(self, state, redirect_uri): + def get_authorize_params(self, state: str, redirect_uri: str) -> dict[str, str | int | None]: params = { "client_id": self.client_id, "response_type": "code", diff --git a/src/sentry/incidents/charts.py b/src/sentry/incidents/charts.py index f846f847f441..eb2ca6d3271d 100644 --- a/src/sentry/incidents/charts.py +++ b/src/sentry/incidents/charts.py @@ -1,7 +1,7 @@ from collections.abc import Mapping from datetime import datetime, timedelta from functools import reduce -from typing import Any +from typing import Any, cast from django.utils import timezone @@ -80,7 +80,8 @@ def fetch_metric_alert_sessions_data( **query_params, }, ) - return resp.data + # justification: the incidents query client returns decoded JSON rows. + return cast("list[Any]", resp.data) except Exception as exc: logger.exception( "Failed to load sessions for chart: %s", @@ -147,7 +148,8 @@ def fetch_metric_issue_open_periods( }, ) - return resp.data + # justification: the incidents query client returns decoded JSON rows. + return cast("list[Any]", resp.data) except Exception as exc: logger.error( "Failed to load incidents for chart: %s", diff --git a/src/sentry/incidents/endpoints/serializers/workflow_engine_action_utils.py b/src/sentry/incidents/endpoints/serializers/workflow_engine_action_utils.py index c69f314b3263..60ac710880a6 100644 --- a/src/sentry/incidents/endpoints/serializers/workflow_engine_action_utils.py +++ b/src/sentry/incidents/endpoints/serializers/workflow_engine_action_utils.py @@ -62,7 +62,11 @@ def human_desc( return action_type_to_string.get(action_type) -def get_identifier_from_action(action_type, target_identifier, target_display=None): +def get_identifier_from_action( + action_type: int | None, + target_identifier: str, + target_display: str | None = None, +) -> int | str: if action_type in [ ActionService.PAGERDUTY.value, ActionService.SENTRY_APP.value, @@ -75,7 +79,9 @@ def get_identifier_from_action(action_type, target_identifier, target_display=No return target_display if target_display is not None else target_identifier -def get_input_channel_id(action_type, target_identifier=None): +def get_input_channel_id( + action_type: int | None, target_identifier: str | None = None +) -> str | None: """ Don't pass an inputChannelId value unless the action is for Slack """ diff --git a/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py b/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py index efc9107b60eb..c91d2ded2d13 100644 --- a/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py +++ b/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py @@ -2,7 +2,7 @@ from collections import defaultdict from collections.abc import Mapping, MutableMapping, Sequence -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from django.contrib.auth.models import AnonymousUser from django.db.models import Subquery @@ -345,7 +345,13 @@ def get_attrs( return result - def serialize(self, obj: Detector, attrs, user, **kwargs) -> AlertRuleSerializerResponse: + def serialize( + self, + obj: Detector, + attrs: Mapping[str, Any], # justification: serializer attrs are dynamic. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer kwargs are dynamic. + ) -> AlertRuleSerializerResponse: triggers = attrs.get("triggers", []) alert_rule_id = None @@ -363,13 +369,13 @@ def serialize(self, obj: Detector, attrs, user, **kwargs) -> AlertRuleSerializer "status": AlertRuleStatus.PENDING.value, "queryType": attrs.get("queryType"), "dataset": attrs.get("dataset"), - "query": attrs.get("query"), - "aggregate": attrs.get("aggregate"), + "query": attrs["query"], + "aggregate": attrs["aggregate"], "thresholdType": attrs.get("thresholdType"), "resolveThreshold": triggers[0].get("resolveThreshold") if triggers else None, - "timeWindow": attrs.get("timeWindow"), + "timeWindow": attrs["timeWindow"], "environment": attrs.get("environment"), - "resolution": attrs.get("resolution"), + "resolution": attrs["resolution"], "thresholdPeriod": 1, # unset on detectors "triggers": triggers, "projects": sorted(attrs.get("projects", [])), @@ -378,7 +384,7 @@ def serialize(self, obj: Detector, attrs, user, **kwargs) -> AlertRuleSerializer "comparisonDelta": comparison_delta / 60 if comparison_delta else None, "dateModified": obj.date_updated, "dateCreated": obj.date_added, - "createdBy": attrs.get("created_by"), + "createdBy": attrs["created_by"], "description": obj.description if obj.description else "", "sensitivity": attrs.get("sensitivity"), "seasonality": attrs.get("seasonality"), @@ -426,13 +432,21 @@ def get_attrs( return self.base_serializer.get_attrs(item_list, user, **kwargs) def serialize( - self, obj: Detector, attrs, user, **kwargs + self, + obj: Detector, + attrs: Mapping[str, Any], # justification: serializer attrs are dynamic. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer kwargs are dynamic. ) -> DetailedAlertRuleSerializerResponse: base_data = self.base_serializer.serialize(obj, attrs, user, **kwargs) - data: DetailedAlertRuleSerializerResponse = { - **base_data, - "snooze": not obj.enabled, - } + # justification: mypy cannot prove the base spread fills the subclass TypedDict. + data = cast( + "DetailedAlertRuleSerializerResponse", + { + **base_data, + "snooze": not obj.enabled, + }, + ) if not obj.enabled: data["snoozeForEveryone"] = True return data diff --git a/src/sentry/incidents/endpoints/utils.py b/src/sentry/incidents/endpoints/utils.py index 707f2b5b3928..d86a72c5be57 100644 --- a/src/sentry/incidents/endpoints/utils.py +++ b/src/sentry/incidents/endpoints/utils.py @@ -1,3 +1,5 @@ +from typing import cast + from sentry.incidents.models.alert_rule import AlertRule, AlertRuleThresholdType from sentry.workflow_engine.models.data_condition import Condition @@ -24,7 +26,8 @@ def translate_threshold(alert_rule: AlertRule, threshold: float | None) -> float return threshold threshold_type = AlertRuleThresholdType(alert_rule.threshold_type) - return threshold_translators[threshold_type](threshold) + # justification: the translator tables return floats for their threshold kind. + return cast("float | None", threshold_translators[threshold_type](threshold)) def translate_data_condition_type( @@ -41,4 +44,5 @@ def translate_data_condition_type( if comparison_delta is None: return float(threshold) - return data_condition_type_translators[condition_type](threshold) + # justification: the translator tables return floats for their condition kind. + return cast("float | None", data_condition_type_translators[condition_type](threshold)) diff --git a/src/sentry/incidents/logic.py b/src/sentry/incidents/logic.py index 4af9fdfbee94..dd59df7002fe 100644 --- a/src/sentry/incidents/logic.py +++ b/src/sentry/incidents/logic.py @@ -187,7 +187,8 @@ def get_column_from_aggregate( function = resolve_field(aggregate) if function.aggregate is not None: - return function.aggregate[1] + metric = function.aggregate[1] + return metric if isinstance(metric, str) else None return None diff --git a/src/sentry/incidents/metric_issue_detector.py b/src/sentry/incidents/metric_issue_detector.py index 2cb4fed2e69e..1894e43eea3f 100644 --- a/src/sentry/incidents/metric_issue_detector.py +++ b/src/sentry/incidents/metric_issue_detector.py @@ -1,5 +1,5 @@ from datetime import timedelta -from typing import Any +from typing import Any, cast from django.core.exceptions import ValidationError from parsimonious.exceptions import ParseError @@ -130,7 +130,8 @@ def validate_comparison( return value elif isinstance(value, dict): - return super().validate_comparison(value) + # justification: the parent comparison validator returns the comparison payload. + return cast("float | dict[str, Any]", super().validate_comparison(value)) else: raise serializers.ValidationError("A valid number or dict is required.") diff --git a/src/sentry/incidents/models/alert_rule.py b/src/sentry/incidents/models/alert_rule.py index c770dcfa4722..f4dede2682cb 100644 --- a/src/sentry/incidents/models/alert_rule.py +++ b/src/sentry/incidents/models/alert_rule.py @@ -162,8 +162,8 @@ def owner(self) -> Actor | None: @owner.setter def owner(self, actor: Actor | None) -> None: """Part of ActorOwned Protocol""" - self.team_id = None - self.user_id = None + setattr(self, "team_id", None) + setattr(self, "user_id", None) if actor and actor.is_user: self.user_id = actor.id if actor and actor.is_team: diff --git a/src/sentry/incidents/typings/metric_detector.py b/src/sentry/incidents/typings/metric_detector.py index a0e686910276..0976d56ce891 100644 --- a/src/sentry/incidents/typings/metric_detector.py +++ b/src/sentry/incidents/typings/metric_detector.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from datetime import datetime -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from pydantic import BaseModel @@ -36,7 +36,8 @@ def fetch_threshold_type( ) -> AlertRuleThresholdType | AnomalyDetectionThresholdType: condition_type = condition["type"] if condition_type == Condition.ANOMALY_DETECTION: - return condition["comparison"]["threshold_type"] + # justification: anomaly detection conditions store this enum's value. + return cast("AnomalyDetectionThresholdType", condition["comparison"]["threshold_type"]) return CONDITION_TO_ALERT_RULE_THRESHOLD_TYPE[condition_type] @@ -47,8 +48,7 @@ def fetch_alert_threshold(condition: dict[str, Any], group_status: int) -> float comparison_value = condition["comparison"] if group_status == GroupStatus.RESOLVED or group_status == GroupStatus.IGNORED: return None - else: - return comparison_value + return comparison_value if isinstance(comparison_value, (int, float)) else None def fetch_resolve_threshold(condition: dict[str, Any], group_status: int) -> float | None: @@ -61,14 +61,14 @@ def fetch_resolve_threshold(condition: dict[str, Any], group_status: int) -> flo return 0 comparison_value = condition["comparison"] if group_status == GroupStatus.RESOLVED or group_status == GroupStatus.IGNORED: - return comparison_value - else: - return None + return comparison_value if isinstance(comparison_value, (int, float)) else None + return None def fetch_sensitivity(condition: dict[str, Any]) -> str | None: if condition.get("type") == Condition.ANOMALY_DETECTION: - return condition.get("comparison", {}).get("sensitivity", None) + sensitivity = condition.get("comparison", {}).get("sensitivity", None) + return sensitivity if isinstance(sensitivity, str) else None return None diff --git a/src/sentry/incidents/utils/subscription_limits.py b/src/sentry/incidents/utils/subscription_limits.py index 8dfef179d475..c6d8e198bd74 100644 --- a/src/sentry/incidents/utils/subscription_limits.py +++ b/src/sentry/incidents/utils/subscription_limits.py @@ -57,8 +57,10 @@ def get_disallowed_metric_datasets(organization: Organization) -> list[str]: def get_max_metric_alert_subscriptions(organization: Organization) -> int: - if organization.id in options.get("metric_alerts.extended_max_subscriptions_orgs") and ( - extended_max_specs := options.get("metric_alerts.extended_max_subscriptions") + if ( + organization.id in options.get("metric_alerts.extended_max_subscriptions_orgs") + and (extended_max_specs := options.get("metric_alerts.extended_max_subscriptions")) + and isinstance(extended_max_specs, int) ): return extended_max_specs diff --git a/src/sentry/ingest/consumer/factory.py b/src/sentry/ingest/consumer/factory.py index 696f497a6886..0c82878da172 100644 --- a/src/sentry/ingest/consumer/factory.py +++ b/src/sentry/ingest/consumer/factory.py @@ -19,6 +19,7 @@ from sentry.utils.arroyo import MultiprocessingPool, run_task_with_multiprocessing from .attachment_event import decode_and_process_chunks, process_attachments_and_events +from .processors import IngestMessage from .simple_event import process_simple_event_message @@ -149,7 +150,7 @@ def create_with_partitions( # later step. assert self._attachments_pool is not None - processing_function = partial( + processing_function: Callable[[Message[IngestMessage | None]], None] = partial( process_attachments_and_events, reprocess_only_stuck_events=self.reprocess_only_stuck_events, reprocess_only_events_not_in_nodestore=self.reprocess_only_events_not_in_nodestore, @@ -162,7 +163,7 @@ def create_with_partitions( # to avoid arroyo from needlessly dispatching `None` messages. # However its currently not possible to make that `| None` disappear in the type. - def filter_fn(msg): + def filter_fn(msg: Message[FilteredPayload | IngestMessage | None]) -> bool: if not bool(msg.payload): return False diff --git a/src/sentry/ingest/consumer/processors.py b/src/sentry/ingest/consumer/processors.py index c54e7425ecf8..b505e4e13fec 100644 --- a/src/sentry/ingest/consumer/processors.py +++ b/src/sentry/ingest/consumer/processors.py @@ -1,8 +1,8 @@ import functools import logging import os -from collections.abc import Mapping, MutableMapping -from typing import Any +from collections.abc import Callable, Mapping, MutableMapping +from typing import Any, ParamSpec, TypeVar import orjson import sentry_sdk @@ -41,15 +41,18 @@ IngestMessage = Mapping[str, Any] +P = ParamSpec("P") +R = TypeVar("R") + class Retriable(Exception): pass -def trace_func(name: str): - def wrapper(f): +def trace_func(name: str) -> Callable[[Callable[P, R]], Callable[P, R]]: + def wrapper(f: Callable[P, R]) -> Callable[P, R]: @functools.wraps(f) - def inner(*args, **kwargs): + def inner(*args: P.args, **kwargs: P.kwargs) -> R: # First we check a local env var # if that's not set we check our conf file # if neither are set use 0 @@ -448,7 +451,7 @@ def process_userreport(message: IngestMessage, project: Project) -> bool: def collect_span_metrics( project: Project, data: MutableMapping[str, Any], -): +) -> None: if not features.has("organizations:am3-tier", project.organization) and not features.has( "organizations:dynamic-sampling", project.organization ): diff --git a/src/sentry/ingest/inbound_filters.py b/src/sentry/ingest/inbound_filters.py index a85781e9c7e7..6951a44ed446 100644 --- a/src/sentry/ingest/inbound_filters.py +++ b/src/sentry/ingest/inbound_filters.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import re from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass @@ -64,11 +66,11 @@ class FilterStatKeys: } -def get_filter_key(flt): +def get_filter_key(flt: _FilterSpec) -> str: return to_camel_case_name(flt.config_name.replace("-", "_")) -def get_all_filter_specs(): +def get_all_filter_specs() -> tuple[_FilterSpec, ...]: """ Return metadata about the filters known by Sentry. @@ -88,7 +90,12 @@ def get_all_filter_specs(): return tuple(filters) # returning tuple for backwards compatibility -def set_filter_state(filter_id, project: Project, state): +def set_filter_state( + # justification: decoded filter state JSON + filter_id: str, + project: Project, + state: Mapping[str, Any] | None, # justification: decoded filter state JSON +) -> bool | list[str]: flt = _filter_from_filter_id(filter_id) if flt is None: raise FilterNotRegistered(filter_id) @@ -97,7 +104,7 @@ def set_filter_state(filter_id, project: Project, state): if state is None: state = {} - option_val: object = "0" + option_val: str | list[str] = "0" if "active" in state: if state["active"]: option_val = "1" @@ -108,7 +115,9 @@ def set_filter_state(filter_id, project: Project, state): project=project, key=f"filters:{filter_id}", value=option_val ) - return option_val == "1" if option_val in ("0", "1") else option_val + if isinstance(option_val, list): + return option_val + return option_val == "1" else: # all boolean filters @@ -124,10 +133,10 @@ def set_filter_state(filter_id, project: Project, state): if state: inbound_filter_toggled.send(project=project, sender=flt) - return state.get("active", False) + return bool(state.get("active", False)) -def get_filter_state(filter_id, project): +def get_filter_state(filter_id: str, project: Project) -> bool | list[str]: """ Returns the filter state @@ -156,16 +165,17 @@ def get_filter_state(filter_id, project): return True if filter_state == "0": return False - return filter_state + # justification: the stored legacy-browser option holds a JSON list of subfilters. + return cast("bool | list[str]", filter_state) else: - return filter_state == "1" + return bool(filter_state == "1") class FilterNotRegistered(Exception): pass -def _filter_from_filter_id(filter_id): +def _filter_from_filter_id(filter_id: str) -> _FilterSpec | None: """ Returns the corresponding filter for a filter id or None if no filter with the given id found """ @@ -194,14 +204,18 @@ class _FilterSpec: config_name: the name under which it will be serialized in the config (if None id will be used) """ - def __init__(self, id, name, description, serializer_cls=None, config_name=None): + def __init__( + self, + id: str, + name: str, + description: str, + serializer_cls: type[serializers.Serializer] | None = None, + config_name: str | None = None, + ) -> None: self.id = id self.name = name self.description = description - if serializer_cls is None: - self.serializer_cls = _FilterSerializer - else: - self.serializer_cls = serializer_cls + self.serializer_cls: type[serializers.Serializer] = serializer_cls or _FilterSerializer if config_name is None: self.config_name = id @@ -209,7 +223,8 @@ def __init__(self, id, name, description, serializer_cls=None, config_name=None) self.config_name = config_name -def _get_filter_settings(project_config, flt): +# justification: relay project configs are decoded config payloads with a dynamic shape +def _get_filter_settings(project_config: Any, flt: _FilterSpec) -> Any: """ Gets the filter options from the relay config or the default option if not specified in the relay config diff --git a/src/sentry/ingest/transaction_clusterer/datasource/redis.py b/src/sentry/ingest/transaction_clusterer/datasource/redis.py index 40ad5fb4c247..9c21eb254210 100644 --- a/src/sentry/ingest/transaction_clusterer/datasource/redis.py +++ b/src/sentry/ingest/transaction_clusterer/datasource/redis.py @@ -2,7 +2,7 @@ import logging from collections.abc import Iterator, Mapping, Sequence -from typing import Any +from typing import Any, cast from django.conf import settings from sentry_redis_tools.clients import RedisCluster @@ -52,7 +52,8 @@ def get_redis_client() -> RedisCluster: def _get_all_keys(namespace: ClustererNamespace) -> Iterator[str]: client = get_redis_client() - return client.sscan_iter(_get_projects_key(namespace)) + # justification: sentry_redis_tools does not annotate sscan_iter. + return cast("Iterator[str]", client.sscan_iter(_get_projects_key(namespace))) def get_active_projects(namespace: ClustererNamespace) -> Iterator[Project]: @@ -101,7 +102,8 @@ def get_transaction_names(project: Project) -> Iterator[str]: client = get_redis_client() redis_key = _get_redis_key(ClustererNamespace.TRANSACTIONS, project) - return client.sscan_iter(redis_key) + # justification: sentry_redis_tools does not annotate sscan_iter. + return cast("Iterator[str]", client.sscan_iter(redis_key)) def clear_samples(namespace: ClustererNamespace, project: Project) -> None: @@ -145,7 +147,7 @@ def _should_store_transaction_name(event_data: Mapping[str, Any]) -> str | None: tags = event_data.get("tags") or [] if HTTP_404_TAG in tags: return None - return transaction_name + return transaction_name if isinstance(transaction_name, str) else None def _bump_rule_lifetime(project: Project, event_data: Mapping[str, Any]) -> None: @@ -153,7 +155,7 @@ def _bump_rule_lifetime(project: Project, event_data: Mapping[str, Any]) -> None _bump_rule_lifetime_inner(project, applied_rules) -def _bump_rule_lifetime_inner(project: Project, applied_rules: Sequence): +def _bump_rule_lifetime_inner(project: Project, applied_rules: Sequence[Sequence[str]]) -> None: from sentry.ingest.transaction_clusterer import rules as clusterer_rules if not applied_rules: diff --git a/src/sentry/ingest/transaction_clusterer/rule_validator.py b/src/sentry/ingest/transaction_clusterer/rule_validator.py index dc4c61f66876..3436cf36c972 100644 --- a/src/sentry/ingest/transaction_clusterer/rule_validator.py +++ b/src/sentry/ingest/transaction_clusterer/rule_validator.py @@ -30,7 +30,7 @@ def _normalize_rule(self, rule: ReplacementRule) -> ReplacementRule: def _is_all_stars(self) -> bool: return self._is_string_all_stars(self._rule) - def _is_string_all_stars(self, string) -> bool: + def _is_string_all_stars(self, string: str) -> bool: return set(string) <= self._char_domain def _is_schema_and_all_stars(self) -> bool: diff --git a/src/sentry/ingest/transaction_clusterer/tree.py b/src/sentry/ingest/transaction_clusterer/tree.py index 09a05b22de33..0044322937af 100644 --- a/src/sentry/ingest/transaction_clusterer/tree.py +++ b/src/sentry/ingest/transaction_clusterer/tree.py @@ -131,7 +131,7 @@ def _build_rule(path: list["Edge"]) -> ReplacementRule: Edge: TypeAlias = Union[str, Merged] -class Node(UserDict): +class Node(UserDict[Edge, "Node"]): """Keys in this dict are names of the children""" def paths(self, ancestors: list[Edge] | None = None) -> Iterable[list[Edge]]: diff --git a/src/sentry/ingestion_delay/activity.py b/src/sentry/ingestion_delay/activity.py index bd55da257bcb..8e93e8edf027 100644 --- a/src/sentry/ingestion_delay/activity.py +++ b/src/sentry/ingestion_delay/activity.py @@ -95,7 +95,7 @@ def get_accepted_outcomes( organization_id, project_ids, item_type, last_ingested_at, delay_seconds ) cached = cache.get(key) - if cached is not None: + if isinstance(cached, bool): metrics.incr("ingestion_delay.outcomes_cache", tags={"result": "hit"}) return cached diff --git a/src/sentry/insights/endpoints/starred_segments.py b/src/sentry/insights/endpoints/starred_segments.py index 2f464d170bb9..c554da43dd9d 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.", @@ -52,7 +59,7 @@ class OrganizationStarredServiceSpansEndpoint(OrganizationEndpoint): owner = ApiOwner.DATA_BROWSING permission_classes = (MemberPermission,) - def has_feature(self, organization, request): + def has_feature(self, organization: Organization, request: Request) -> bool: return features.has( "organizations:insights-modules-use-eap", organization, actor=request.user ) diff --git a/src/sentry/integrations/api/bases/doc_integrations.py b/src/sentry/integrations/api/bases/doc_integrations.py index b0d286d112fb..668cd38039cb 100644 --- a/src/sentry/integrations/api/bases/doc_integrations.py +++ b/src/sentry/integrations/api/bases/doc_integrations.py @@ -78,8 +78,12 @@ class DocIntegrationBaseEndpoint(DocIntegrationsBaseEndpoint): """ def convert_args( - self, request: Request, doc_integration_id_or_slug: int | str, *args, **kwargs - ): + self, + request: Request, + doc_integration_id_or_slug: int | str, + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: try: doc_integration = DocIntegration.objects.get( slug__id_or_slug=doc_integration_id_or_slug diff --git a/src/sentry/integrations/api/bases/external_actor.py b/src/sentry/integrations/api/bases/external_actor.py index 98364da365b8..3250843bafa9 100644 --- a/src/sentry/integrations/api/bases/external_actor.py +++ b/src/sentry/integrations/api/bases/external_actor.py @@ -1,5 +1,5 @@ from collections.abc import MutableMapping -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from django.db import IntegrityError from django.http import Http404 @@ -63,7 +63,9 @@ class ExternalActorResponse(TypedDict): integration_id: int -class ExternalActorSerializerBase(CamelSnakeModelSerializer): +class ExternalActorSerializerBase( + CamelSnakeModelSerializer[Any] # justification: create returns a (instance, created) pair. +): external_id = serializers.CharField( required=False, allow_null=True, help_text="The associated user ID for provider." ) @@ -80,7 +82,8 @@ class ExternalActorSerializerBase(CamelSnakeModelSerializer): @property def organization(self) -> Organization: - return self.context["organization"] + # justification: the view context carries the resolved organization. + return cast("Organization", self.context["organization"]) def validate_integration_id(self, integration_id: str) -> str: return validate_integration_id(integration_id, self.organization) diff --git a/src/sentry/integrations/api/endpoints/data_forwarding_details.py b/src/sentry/integrations/api/endpoints/data_forwarding_details.py index 5b9e90491b2b..afd33875960b 100644 --- a/src/sentry/integrations/api/endpoints/data_forwarding_details.py +++ b/src/sentry/integrations/api/endpoints/data_forwarding_details.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, cast from django.db import router, transaction from django.utils.decorators import method_decorator @@ -79,20 +79,22 @@ def convert_args( request: Request, organization_id_or_slug: int | str, data_forwarder_id: str, - *args, - **kwargs, - ): + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: args, kwargs = super().convert_args(request, organization_id_or_slug, *args, **kwargs) + organization = cast(Organization, kwargs["organization"]) + if request.method == "PUT" and not features.has( - "organizations:data-forwarding", kwargs["organization"] + "organizations:data-forwarding", organization ): raise PermissionDenied try: data_forwarder = DataForwarder.objects.get( id=to_valid_int_id("data_forwarder_id", data_forwarder_id, raise_404=True), - organization=kwargs["organization"], + organization=organization, ) except DataForwarder.DoesNotExist: raise ResourceDoesNotExist diff --git a/src/sentry/integrations/api/endpoints/data_forwarding_index.py b/src/sentry/integrations/api/endpoints/data_forwarding_index.py index 7ada0d16e8cf..0c0d72c8b025 100644 --- a/src/sentry/integrations/api/endpoints/data_forwarding_index.py +++ b/src/sentry/integrations/api/endpoints/data_forwarding_index.py @@ -21,6 +21,7 @@ DataForwarderSerializer, ) from sentry.integrations.models.data_forwarder import DataForwarder +from sentry.models.organization import Organization from sentry.web.decorators import set_referrer_policy @@ -54,7 +55,9 @@ class DataForwardingIndexEndpoint(OrganizationEndpoint): ) @set_referrer_policy("strict-origin-when-cross-origin") @method_decorator(never_cache) - def get(self, request: Request, organization) -> Response[list[DataForwarderResponse]]: + def get( + self, request: Request, organization: Organization + ) -> Response[list[DataForwarderResponse]]: """ Returns a list of data forwarders for an organization. """ @@ -81,7 +84,7 @@ def get(self, request: Request, organization) -> Response[list[DataForwarderResp ) @set_referrer_policy("strict-origin-when-cross-origin") @method_decorator(never_cache) - def post(self, request: Request, organization) -> Response[DataForwarderResponse]: + def post(self, request: Request, organization: Organization) -> Response[DataForwarderResponse]: """ Creates a new data forwarder for an organization. Only one data forwarder can be created per provider for a given organization. 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/endpoints/doc_integrations_index.py b/src/sentry/integrations/api/endpoints/doc_integrations_index.py index 5ca729c2c286..a4fc7c541458 100644 --- a/src/sentry/integrations/api/endpoints/doc_integrations_index.py +++ b/src/sentry/integrations/api/endpoints/doc_integrations_index.py @@ -27,7 +27,7 @@ class DocIntegrationsEndpoint(DocIntegrationsBaseEndpoint): "POST": ApiPublishStatus.PRIVATE, } - def get(self, request: Request): + def get(self, request: Request) -> Response: # TODO(schew2381): Change to is_active_staff once the feature flag is rolled out. if has_elevated_mode(request): queryset = DocIntegration.objects.all() @@ -40,7 +40,7 @@ def get(self, request: Request): on_results=lambda x: serialize(x, request.user, access=request.access), ) - def post(self, request: Request): + def post(self, request: Request) -> Response: # Override any incoming JSON for these fields data = request.data data["is_draft"] = True diff --git a/src/sentry/integrations/api/endpoints/integration_proxy.py b/src/sentry/integrations/api/endpoints/integration_proxy.py index 9097414b4d5f..2e7dd08ef16a 100644 --- a/src/sentry/integrations/api/endpoints/integration_proxy.py +++ b/src/sentry/integrations/api/endpoints/integration_proxy.py @@ -4,7 +4,7 @@ from collections import defaultdict from collections.abc import Generator, Mapping from enum import StrEnum -from typing import Any, MutableMapping, TypedDict +from typing import Any, MutableMapping, TypedDict, cast from urllib.parse import urljoin from django.conf import settings @@ -119,7 +119,7 @@ class IntegrationProxyRequestValidator: integration_installation: IntegrationInstallation client: IntegrationProxyClient - def __init__(self, request: HttpRequest): + def __init__(self, request: HttpRequest) -> None: self.request = request # Removes leading slashes as it can result in incorrect urls being generated self.proxy_path = trim_leading_slashes(request.headers.get(PROXY_PATH, "")) @@ -136,7 +136,7 @@ def __init__(self, request: HttpRequest): ) assert self.client is not None, "Expected validated request to have a client" - def _validate(self): + def _validate(self) -> None: """ Validates various components of the request to ensure the sender is both trustworthy, and the request can be authenticated using the appropriate @@ -151,7 +151,7 @@ def _validate(self): self._validate_and_set_integration_installation() self._validate_and_set_client() - def _validate_silo_mode(self): + def _validate_silo_mode(self) -> None: is_correct_silo = SiloMode.get_current_mode() == SiloMode.CONTROL if not is_correct_silo: raise IntegrationProxyRequestValidationException( @@ -174,7 +174,7 @@ def _get_integration_context(self) -> IntegrationProxyRequestValidationContext: "provider": integration.provider if integration else UNKNOWN_PROVIDER, } - def _validate_sender(self): + def _validate_sender(self) -> None: """ Validates the sender of the request and raises an exception if the sender is not trustworthy. A sender is considered trustworthy if they: @@ -210,7 +210,7 @@ def _validate_sender(self): }, ) - def _validate_and_set_organization_integration(self): + def _validate_and_set_organization_integration(self) -> None: """ Extracts the organization integration id from the request headers and resolves it to an active OrganizationIntegration, which is set on the validator. @@ -244,7 +244,7 @@ def _validate_and_set_organization_integration(self): self.organization_integration = org_integration - def _validate_and_set_integration_installation(self): + def _validate_and_set_integration_installation(self) -> None: """ Checks that the resolved organization integration points at an active integration, then instantiates that integration's installation. Both are set on the validator. @@ -272,7 +272,7 @@ def _validate_and_set_integration_installation(self): organization_id=org_integration.organization_id ) - def _validate_and_set_client(self): + def _validate_and_set_client(self) -> None: """ Acquires the API client from the installation and sets it on the validator. Raises an exception if the client is not valid. @@ -317,20 +317,22 @@ class InternalIntegrationProxyEndpoint(Endpoint): integration on behalf of credentials stored in the control silo. """ - def __init__(self): + _client: IntegrationProxyClient + + def __init__(self) -> None: super().__init__() self.log_extra = dict() self.provider = UNKNOWN_PROVIDER @property - def client(self): + def client(self) -> IntegrationProxyClient: """ We need to use a property decorator and setter here to overwrite it for tests. """ return self._client @client.setter - def client(self, client): + def client(self, client: IntegrationProxyClient) -> None: self._client = client def _add_metric( @@ -371,14 +373,18 @@ def _call_third_party_api( # default timeout when the header is absent or unparseable. timeout = decode_proxy_timeout(request.headers.get(PROXY_TIMEOUT_HEADER)) - resp: Response = self.client.request( - request.method, - self.proxy_path, - allow_text=True, - prepared_request=prepared_request, - raw_response=True, - stream=True, - timeout=timeout, + # justification: raw_response=True makes the client return the raw requests Response. + resp = cast( + "Response", + self.client.request( + request.method, + self.proxy_path, + allow_text=True, + prepared_request=prepared_request, + raw_response=True, + stream=True, + timeout=timeout, + ), ) def iter_response(response: Response) -> Generator[bytes]: @@ -408,7 +414,8 @@ def iter_response(response: Response) -> Generator[bytes]: ) @traces.trace(attributes={"sentry.op": "integration_proxy.http_method_not_allowed"}) - def http_method_not_allowed(self, request): + # justification: DRF's APIView stub limits the return to Response[Any], but this proxy returns Django responses + def http_method_not_allowed(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: """ Catch-all workaround instead of explicitly setting handlers for each method (GET, POST, etc.) """ @@ -438,6 +445,7 @@ def http_method_not_allowed(self, request): ) base_url = request.headers.get(PROXY_BASE_URL_HEADER) + assert base_url is not None base_url = base_url.rstrip("/") full_url = urljoin(f"{base_url}/", self.proxy_path) diff --git a/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py b/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py index 44caaf5dd0a0..bcdecbc62fc9 100644 --- a/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py +++ b/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py @@ -1,3 +1,6 @@ +from collections.abc import Mapping +from typing import cast + from django.http import Http404 from rest_framework import status from rest_framework.exceptions import NotFound @@ -13,10 +16,11 @@ from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig from sentry.integrations.services.integration import integration_service from sentry.integrations.source_code_management.repository import RepositoryIntegration +from sentry.models.organization import Organization from sentry.shared_integrations.exceptions import ApiError -def get_codeowner_contents(config): +def get_codeowner_contents(config: RepositoryProjectPathConfig) -> Mapping[str, str] | None: if not config.organization_integration_id: raise NotFound(detail="No associated integration") @@ -30,6 +34,7 @@ def get_codeowner_contents(config): install = integration.get_installation(organization_id=org_id) if isinstance(install, RepositoryIntegration): return install.get_codeowner_file(repository, ref=config.default_branch) + return None @cell_silo_endpoint @@ -40,11 +45,18 @@ class OrganizationCodeMappingCodeOwnersEndpoint(OrganizationEndpoint): } permission_classes = (OrganizationIntegrationsPermission,) - def convert_args(self, request: Request, organization_id_or_slug, config_id, *args, **kwargs): + def convert_args( + self, + request: Request, + organization_id_or_slug: str, + config_id: str, + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: args, kwargs = super().convert_args( request, organization_id_or_slug, config_id, *args, **kwargs ) - organization = kwargs["organization"] + organization = cast(Organization, kwargs["organization"]) try: kwargs["config"] = RepositoryProjectPathConfig.objects.select_related( @@ -59,7 +71,13 @@ def convert_args(self, request: Request, organization_id_or_slug, config_id, *ar return (args, kwargs) - def get(self, request: Request, config_id, organization, config) -> Response: + def get( + self, + request: Request, + config_id: str, + organization: Organization, + config: RepositoryProjectPathConfig, + ) -> Response: project = config.project_repository.project if not request.access.has_project_access(project): return self.respond(status=status.HTTP_403_FORBIDDEN) diff --git a/src/sentry/integrations/api/endpoints/organization_code_mappings.py b/src/sentry/integrations/api/endpoints/organization_code_mappings.py index c19417f62765..21fd6b804350 100644 --- a/src/sentry/integrations/api/endpoints/organization_code_mappings.py +++ b/src/sentry/integrations/api/endpoints/organization_code_mappings.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, cast from django.db import router, transaction from django.http import Http404 @@ -32,6 +32,7 @@ ) from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig from sentry.integrations.services.integration import integration_service +from sentry.integrations.services.integration.model import RpcOrganizationIntegration from sentry.integrations.source_code_management.path import normalize_repository_source_root from sentry.integrations.types import IntegrationProviderSlug from sentry.models.organization import Organization @@ -50,7 +51,9 @@ def validate_source_root(path: str) -> None: raise serializers.ValidationError(_(INVALID_SOURCE_ROOT_ERROR_MESSAGE)) -def gen_path_regex_field(*, validate_source: bool = False, help_text: str | None = None): +def gen_path_regex_field( + *, validate_source: bool = False, help_text: str | None = None +) -> serializers.RegexField: return serializers.RegexField( r"^[^\s'\"]+$", # may need to add more characters to prevent in the future required=True, @@ -64,7 +67,7 @@ def gen_path_regex_field(*, validate_source: bool = False, help_text: str | None BRANCH_NAME_ERROR_MESSAGE = "Branch name may only have letters, numbers, underscores, forward slashes, dashes, and periods. Branch name may not start or end with a forward slash." -class RepositoryProjectPathConfigSerializer(CamelSnakeModelSerializer): +class RepositoryProjectPathConfigSerializer(CamelSnakeModelSerializer[RepositoryProjectPathConfig]): repository_id = serializers.IntegerField( required=True, help_text="The ID of a repository belonging to the installed integration." ) @@ -99,16 +102,18 @@ class Meta: extra_kwargs: dict[str, Any] = {} @property - def org_integration(self): - return self.context["organization_integration"] + def org_integration(self) -> RpcOrganizationIntegration: + # justification: the view context carries the resolved organization integration. + return cast("RpcOrganizationIntegration", self.context["organization_integration"]) @property - def organization(self): - return self.context["organization"] + def organization(self) -> Organization: + # justification: the view context carries the resolved organization. + return cast("Organization", self.context["organization"]) - def validate(self, attrs): + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: query = RepositoryProjectPathConfig.objects.filter( - project_repository__project_id=attrs.get("project_id"), + project_repository__project_id=cast("str | int", attrs.get("project_id")), stack_root=attrs.get("stack_root"), source_root=attrs.get("source_root"), ) @@ -120,7 +125,7 @@ def validate(self, attrs): ) return attrs - def validate_repository_id(self, repository_id): + def validate_repository_id(self, repository_id: int) -> int: # validate repo exists on this org repo_query = Repository.objects.filter( id=repository_id, organization_id=self.organization.id @@ -133,14 +138,14 @@ def validate_repository_id(self, repository_id): raise serializers.ValidationError("Repository does not exist") return repository_id - def validate_project_id(self, project_id): + def validate_project_id(self, project_id: int) -> int: # validate project exists on this org project_query = Project.objects.filter(id=project_id, organization_id=self.organization.id) if not project_query.exists(): raise serializers.ValidationError("Project does not exist") return project_id - def validate_default_branch(self, default_branch): + def validate_default_branch(self, default_branch: str | None) -> str | None: # Get the integration to check if it's Perforce integration = integration_service.get_integration( integration_id=self.org_integration.integration_id, @@ -158,7 +163,7 @@ def validate_default_branch(self, default_branch): return default_branch - def create(self, validated_data): + def create(self, validated_data: dict[str, Any]) -> RepositoryProjectPathConfig: with transaction.atomic(using=router.db_for_write(RepositoryProjectPathConfig)): project_repo, _ = ProjectRepository.objects.get_or_create_with_source( project_id=validated_data.pop("project_id"), @@ -173,7 +178,9 @@ def create(self, validated_data): **validated_data, ) - def update(self, instance, validated_data): + def update( + self, instance: RepositoryProjectPathConfig, validated_data: dict[str, Any] + ) -> RepositoryProjectPathConfig: if "id" in validated_data: validated_data.pop("id") if self.instance: @@ -191,7 +198,7 @@ def update(self, instance, validated_data): for key, value in validated_data.items(): setattr(self.instance, key, value) self.instance.save() - return self.instance + return cast(RepositoryProjectPathConfig, self.instance) class CreateOrganizationCodeMappingRequestSerializer(RepositoryProjectPathConfigSerializer): @@ -204,15 +211,17 @@ class Meta(RepositoryProjectPathConfigSerializer.Meta): class OrganizationIntegrationMixin: - def get_organization_integration(self, organization, integration_id): + def get_organization_integration( + self, organization: Organization, integration_id: object + ) -> RpcOrganizationIntegration: org_integration = integration_service.get_organization_integration( - integration_id=integration_id, organization_id=organization.id + integration_id=cast(int, integration_id), organization_id=organization.id ) if not org_integration: raise Http404 return org_integration - def get_project(self, organization, project_id): + def get_project(self, organization: Organization, project_id: int | str) -> Project: try: return Project.objects.get(organization=organization, id=project_id) diff --git a/src/sentry/integrations/api/endpoints/organization_integration_request.py b/src/sentry/integrations/api/endpoints/organization_integration_request.py index 212a35b91c25..fe133caa271f 100644 --- a/src/sentry/integrations/api/endpoints/organization_integration_request.py +++ b/src/sentry/integrations/api/endpoints/organization_integration_request.py @@ -19,6 +19,8 @@ if TYPE_CHECKING: from django.utils.functional import _StrPromise + from sentry.models.organization import Organization + def get_provider_name(provider_type: str, provider_slug: str) -> str | _StrPromise | None: """ @@ -49,7 +51,7 @@ class OrganizationIntegrationRequestEndpoint(OrganizationRequestChangeEndpoint): "POST": ApiPublishStatus.PRIVATE, } - def post(self, request: Request, organization) -> Response: + def post(self, request: Request, organization: Organization) -> Response: """ Email the organization owners asking them to install an integration. ```````````````````````````````````````````````````````````````````` diff --git a/src/sentry/integrations/api/endpoints/organization_integration_serverless_functions.py b/src/sentry/integrations/api/endpoints/organization_integration_serverless_functions.py index bd704b745584..4c4d8452c9f8 100644 --- a/src/sentry/integrations/api/endpoints/organization_integration_serverless_functions.py +++ b/src/sentry/integrations/api/endpoints/organization_integration_serverless_functions.py @@ -44,7 +44,7 @@ class ServerlessFunctionResponse(TypedDict): enabled: bool -class ServerlessActionSerializer(CamelSnakeSerializer): +class ServerlessActionSerializer(CamelSnakeSerializer[dict[str, str]]): action = serializers.ChoiceField( ACTIONS, help_text="The action to perform on the serverless function." ) diff --git a/src/sentry/integrations/api/endpoints/organization_repositories.py b/src/sentry/integrations/api/endpoints/organization_repositories.py index 7e23d13cdd04..9f94462c5d16 100644 --- a/src/sentry/integrations/api/endpoints/organization_repositories.py +++ b/src/sentry/integrations/api/endpoints/organization_repositories.py @@ -1,3 +1,5 @@ +from typing import Any, cast + from django.db.models import Q from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema from rest_framework.request import Request @@ -136,7 +138,7 @@ def get( count_hits=True, ) - def post(self, request: Request, organization) -> Response: + def post(self, request: Request, organization: Organization) -> Response: if not request.user.is_authenticated: return Response(status=401) provider_id = request.data.get("provider") @@ -149,4 +151,5 @@ def post(self, request: Request, organization) -> Response: except KeyError: return Response({"error_type": "validation"}, status=400) provider = provider_cls(id=provider_id) - return provider.dispatch(request, organization) + # justification: repository providers dispatch to a DRF response. + return cast("Response[Any]", provider.dispatch(request, organization)) diff --git a/src/sentry/integrations/api/endpoints/organization_repository_commits.py b/src/sentry/integrations/api/endpoints/organization_repository_commits.py index 6f9d389a6172..9f1337782bcb 100644 --- a/src/sentry/integrations/api/endpoints/organization_repository_commits.py +++ b/src/sentry/integrations/api/endpoints/organization_repository_commits.py @@ -20,6 +20,7 @@ from sentry.apidocs.parameters import CursorQueryParam, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.commit import Commit +from sentry.models.organization import Organization from sentry.models.repository import Repository @@ -71,7 +72,7 @@ class OrganizationRepositoryCommitsEndpoint(OrganizationEndpoint): ], ) def get( - self, request: Request, organization, repo_id + self, request: Request, organization: Organization, repo_id: int | str ) -> Response[list[CommitSerializerResponse]]: """ List a Repository's Commits diff --git a/src/sentry/integrations/api/endpoints/organization_repository_settings.py b/src/sentry/integrations/api/endpoints/organization_repository_settings.py index f32efb048eed..47dcbbb3f8d5 100644 --- a/src/sentry/integrations/api/endpoints/organization_repository_settings.py +++ b/src/sentry/integrations/api/endpoints/organization_repository_settings.py @@ -51,7 +51,7 @@ class RepositorySettingsSerializer(serializers.Serializer): help_text="List of triggers for code review", ) - def validate(self, data): + def validate(self, data: dict[str, object]) -> dict[str, object]: if "enabledCodeReview" not in data and "codeReviewTriggers" not in data: raise serializers.ValidationError( "At least one of 'enabledCodeReview' or 'codeReviewTriggers' must be provided." @@ -185,7 +185,7 @@ def _make_audit_log_data( previous_triggers: set[str], updated_enabled_code_review: bool | None, updated_code_review_triggers: list[str] | None, - ) -> dict: + ) -> dict[str, object]: code_review_change = self._format_code_review_change( previous_triggers, updated_enabled_code_review, diff --git a/src/sentry/integrations/api/serializers/models/doc_integration_avatar.py b/src/sentry/integrations/api/serializers/models/doc_integration_avatar.py index a4d536d4110f..84c9823c797e 100644 --- a/src/sentry/integrations/api/serializers/models/doc_integration_avatar.py +++ b/src/sentry/integrations/api/serializers/models/doc_integration_avatar.py @@ -1,14 +1,22 @@ -from collections.abc import MutableMapping +from collections.abc import Mapping, MutableMapping from typing import Any +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.integrations.models.doc_integration_avatar import DocIntegrationAvatar +from sentry.users.models.user import User +from sentry.users.services.user import RpcUser @register(DocIntegrationAvatar) class DocIntegrationAvatarSerializer(Serializer[MutableMapping[str, Any]]): def serialize( - self, obj: DocIntegrationAvatar, attrs, user, **kwargs + self, + obj: DocIntegrationAvatar, + attrs: Mapping[Any, Any], # justification: serializer attrs and kwargs are dynamic. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer attrs and kwargs are dynamic. ) -> MutableMapping[str, Any]: return { "avatarType": obj.get_avatar_type_display(), diff --git a/src/sentry/integrations/api/serializers/models/external_issue.py b/src/sentry/integrations/api/serializers/models/external_issue.py index 09e42d04b68a..27eaee3edeeb 100644 --- a/src/sentry/integrations/api/serializers/models/external_issue.py +++ b/src/sentry/integrations/api/serializers/models/external_issue.py @@ -1,7 +1,7 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import Any +from collections.abc import Mapping, MutableMapping, Sequence +from typing import Any, cast from django.contrib.auth.models import AnonymousUser @@ -13,18 +13,21 @@ from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser - # Serializer for External Issues Model # Maps an external issue to to additional integration information such as key or name +# justification: serialized payload is dynamic. +ExternalIssueResponse = dict[str, Any] + + @register(ExternalIssue) -class ExternalIssueSerializer(Serializer): +class ExternalIssueSerializer(Serializer[ExternalIssueResponse]): def get_attrs( self, item_list: Sequence[ExternalIssue], user: User | RpcUser | AnonymousUser, **kwargs: Any, - ): - result = {} + ) -> MutableMapping[Any, Any]: # justification: serializer attrs and kwargs are dynamic. + result: dict[ExternalIssue, dict[str, object]] = {} for item in item_list: # Get the integration (e.g. Jira, GitHub, etc) associated with that issue integration = integration_service.get_integration( @@ -47,5 +50,12 @@ def get_attrs( return result - def serialize(self, obj, attrs, user, **kwargs): - return attrs + def serialize( + self, + obj: ExternalIssue, + attrs: Mapping[Any, Any], # justification: serializer attrs and kwargs are dynamic. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer attrs and kwargs are dynamic. + ) -> dict[str, Any]: # justification: serializer attrs and kwargs are dynamic. + # justification: attrs is the serializer's own per-item dict. + return cast(dict[str, Any], attrs) diff --git a/src/sentry/integrations/api/serializers/models/integration.py b/src/sentry/integrations/api/serializers/models/integration.py index 8fbd6424af7e..2cb276d03572 100644 --- a/src/sentry/integrations/api/serializers/models/integration.py +++ b/src/sentry/integrations/api/serializers/models/integration.py @@ -2,7 +2,7 @@ import logging from collections.abc import Mapping, MutableMapping, Sequence -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from django.contrib.auth.models import AnonymousUser @@ -186,35 +186,46 @@ def serialize( base = super().serialize(obj, attrs, user) if not include_config: - return {**base} + # justification: the config response only adds optional keys to the base payload. + return cast("IntegrationConfigSerializerResponse", base) if not self.organization_id: - return {**base, "configOrganization": []} + # justification: mypy cannot prove a spread plus optional keys satisfies the TypedDict. + return cast("IntegrationConfigSerializerResponse", {**base, "configOrganization": []}) try: installation = obj.get_installation(organization_id=self.organization_id) except NotImplementedError: # The integration may not implement a Installed Integration object # representation. - return {**base, "configOrganization": []} + # justification: mypy cannot prove a spread plus optional keys satisfies the TypedDict. + return cast("IntegrationConfigSerializerResponse", {**base, "configOrganization": []}) if organization is not None: installation.organization = organization # TicketRuleModal only needs the ticket-creation form for this request. if self.params.get("action") == "create": - return { - **base, - "configOrganization": [], - # This method comes from IssueBasicIntegration within the integration's installation class - "createIssueConfig": installation.get_create_issue_config( # type: ignore[attr-defined] - None, - user, - params=self.params, - ), - } + # justification: mypy cannot prove a spread plus optional keys satisfies the TypedDict. + return cast( + "IntegrationConfigSerializerResponse", + { + **base, + "configOrganization": [], + # This method comes from IssueBasicIntegration within the integration's installation class + "createIssueConfig": installation.get_create_issue_config( # type: ignore[attr-defined] + None, + user, + params=self.params, + ), + }, + ) - return {**base, "configOrganization": installation.get_organization_config()} + # justification: mypy cannot prove a spread plus optional keys satisfies the TypedDict. + return cast( + "IntegrationConfigSerializerResponse", + {**base, "configOrganization": installation.get_organization_config()}, + ) @register(OrganizationIntegration) diff --git a/src/sentry/integrations/api/serializers/models/integration_feature.py b/src/sentry/integrations/api/serializers/models/integration_feature.py index d912586dfe5a..2cdbd86a7223 100644 --- a/src/sentry/integrations/api/serializers/models/integration_feature.py +++ b/src/sentry/integrations/api/serializers/models/integration_feature.py @@ -40,7 +40,7 @@ def serialize( attrs: Mapping[Any, Any], user: User | RpcUser | AnonymousUser, has_target: bool = True, - **kwargs, + **kwargs: Any, # justification: serializer attrs and kwargs are dynamic. ) -> IntegrationFeatureResponse: data: IntegrationFeatureResponse = { "featureId": obj.feature, diff --git a/src/sentry/integrations/api/serializers/models/repository_project_path_config.py b/src/sentry/integrations/api/serializers/models/repository_project_path_config.py index 3f62996bcd13..edd92323dc8f 100644 --- a/src/sentry/integrations/api/serializers/models/repository_project_path_config.py +++ b/src/sentry/integrations/api/serializers/models/repository_project_path_config.py @@ -1,5 +1,7 @@ -from typing import TypedDict +from collections.abc import Mapping, MutableMapping, Sequence +from typing import Any, TypedDict +from django.contrib.auth.models import AnonymousUser from django.db.models import prefetch_related_objects from sentry.api.serializers import Serializer, register @@ -11,6 +13,8 @@ from sentry.integrations.services.integration import integration_service from sentry.integrations.services.integration.model import RpcIntegration from sentry.models.projectcodeowners import ProjectCodeOwners +from sentry.users.models.user import User +from sentry.users.services.user import RpcUser class RepositoryProjectPathConfigSerializerResponse(TypedDict): @@ -32,7 +36,12 @@ class RepositoryProjectPathConfigSerializerResponse(TypedDict): class RepositoryProjectPathConfigSerializer( Serializer[RepositoryProjectPathConfigSerializerResponse] ): - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[RepositoryProjectPathConfig], + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer attrs and kwargs are dynamic. + ) -> MutableMapping[Any, Any]: # justification: serializer attrs and kwargs are dynamic. if not item_list: return {} @@ -84,7 +93,11 @@ def get_attrs(self, item_list, user, **kwargs): } def serialize( - self, obj, attrs, user, **kwargs + self, + obj: RepositoryProjectPathConfig, + attrs: Mapping[Any, Any], # justification: serializer attrs and kwargs are dynamic. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer attrs and kwargs are dynamic. ) -> RepositoryProjectPathConfigSerializerResponse: integration = attrs.get("integration") 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..10f12cf9a305 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 ) @@ -274,7 +253,11 @@ class DataForwarderProjectSerializer(Serializer): overrides = serializers.JSONField(default=dict) is_enabled = serializers.BooleanField(default=True) - def __init__(self, *args, **kwargs): + def __init__( + self, + *args: Any, # justification: DRF serializer kwargs are dynamic. + **kwargs: Any, # justification: DRF serializer kwargs are dynamic. + ) -> None: super().__init__(*args, **kwargs) self._validated_data_forwarder: DataForwarder | None = None diff --git a/src/sentry/integrations/api/serializers/rest_framework/doc_integration.py b/src/sentry/integrations/api/serializers/rest_framework/doc_integration.py index b6914baa98c2..dee29943c9e3 100644 --- a/src/sentry/integrations/api/serializers/rest_framework/doc_integration.py +++ b/src/sentry/integrations/api/serializers/rest_framework/doc_integration.py @@ -1,4 +1,4 @@ -from collections.abc import MutableMapping +from collections.abc import Mapping, MutableMapping from typing import Any from django.db import router, transaction @@ -105,7 +105,9 @@ class DocIntegrationAvatarSerializer(Serializer): avatar_photo = AvatarField(required=True) avatar_type = serializers.ChoiceField(choices=(("upload", "upload"))) - def validate(self, attrs): + def validate( + self, attrs: Mapping[str, object] + ) -> Mapping[str, Any]: # justification: serializer attrs and kwargs are dynamic. attrs = super().validate(attrs) if not attrs.get("avatar_photo"): diff --git a/src/sentry/integrations/aws_lambda/client.py b/src/sentry/integrations/aws_lambda/client.py index c03187b18274..87dd044a9726 100644 --- a/src/sentry/integrations/aws_lambda/client.py +++ b/src/sentry/integrations/aws_lambda/client.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from typing import Any import boto3 import orjson @@ -15,7 +16,9 @@ class ConfigurationError(Exception): logger = logging.getLogger(__name__) -def gen_aws_client(account_number, region, aws_external_id, service_name="lambda"): +def gen_aws_client( + account_number: str, region: str, aws_external_id: str, service_name: str = "lambda" +) -> Any: """ account_number - account ID in AWS region - region in AWS diff --git a/src/sentry/integrations/aws_lambda/integration.py b/src/sentry/integrations/aws_lambda/integration.py index 00c742dc1fad..49ecb38f016c 100644 --- a/src/sentry/integrations/aws_lambda/integration.py +++ b/src/sentry/integrations/aws_lambda/integration.py @@ -2,7 +2,7 @@ import logging from collections.abc import Mapping -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from botocore.exceptions import ClientError from django.http.request import HttpRequest @@ -79,16 +79,19 @@ class AwsLambdaIntegration(IntegrationInstallation, ServerlessMixin): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self._client = None + self._client: Any = None @property - def region(self): - return self.metadata["region"] + def region(self) -> str: + region = self.metadata["region"] + if not isinstance(region, str): + raise TypeError("AWS Lambda integration metadata has no region") + return region @property - def client(self): + def client(self) -> Any: if not self._client: region = self.metadata["region"] account_number = self.metadata["account_number"] @@ -104,15 +107,18 @@ def client(self): def get_client(self) -> Any: return self.client - def get_one_lambda_function(self, name): + def get_one_lambda_function(self, name: str) -> dict[str, Any]: # https://boto3.amazonaws.com/v1/documentation/api/1.22.12/reference/services/lambda.html - return self.client.get_function(FunctionName=name)["Configuration"] + configuration = self.client.get_function(FunctionName=name)["Configuration"] + if not isinstance(configuration, dict): + raise TypeError("AWS Lambda function has no configuration") + return configuration - def get_serialized_lambda_function(self, name): + def get_serialized_lambda_function(self, name: str) -> dict[str, Any]: function = self.get_one_lambda_function(name) return self.serialize_lambda_function(function) - def serialize_lambda_function(self, function): + def serialize_lambda_function(self, function: Mapping[str, Any]) -> dict[str, Any]: layers = get_function_layer_arns(function) layer_arn = get_latest_layer_for_function(function) function_runtime = function["Runtime"] @@ -148,7 +154,7 @@ def serialize_lambda_function(self, function): } # ServerlessMixin interface - def get_serverless_functions(self): + def get_serverless_functions(self) -> list[dict[str, Any]]: """ Returns a list of serverless functions """ @@ -158,7 +164,7 @@ def get_serverless_functions(self): return [self.serialize_lambda_function(function) for function in functions] @wrap_lambda_updater() - def enable_function(self, target): + def enable_function(self, target: str) -> dict[str, Any]: function = self.get_one_lambda_function(target) config_data = self.get_config_data() project_id = config_data["default_project_id"] @@ -170,7 +176,7 @@ def enable_function(self, target): return self.get_serialized_lambda_function(target) @wrap_lambda_updater() - def disable_function(self, target): + def disable_function(self, target: str) -> dict[str, Any]: function = self.get_one_lambda_function(target) layer_arn = get_latest_layer_for_function(function) @@ -179,7 +185,7 @@ def disable_function(self, target): return self.get_serialized_lambda_function(target) @wrap_lambda_updater() - def update_function_to_latest_version(self, target): + def update_function_to_latest_version(self, target: str) -> dict[str, Any]: function = self.get_one_lambda_function(target) layer_arn = get_latest_layer_for_function(function) @@ -197,7 +203,7 @@ def update_function_to_latest_version(self, target): return self.get_serialized_lambda_function(target) -class ProjectSelectSerializer(CamelSnakeSerializer): +class ProjectSelectSerializer(CamelSnakeSerializer[dict[str, Any]]): project_id = IntegerField(required=True) @@ -227,7 +233,7 @@ def handle_post( return PipelineStepResult.advance() -class CloudFormationSerializer(CamelSnakeSerializer): +class CloudFormationSerializer(CamelSnakeSerializer[dict[str, Any]]): account_number = CharField(required=True) region = ChoiceField(choices=[(r, r) for r in ALL_AWS_REGIONS], required=True) aws_external_id = CharField(required=True) @@ -285,7 +291,7 @@ def handle_post( return PipelineStepResult.advance() -class FunctionSelectSerializer(CamelSnakeSerializer): +class FunctionSelectSerializer(CamelSnakeSerializer[dict[str, Any]]): enabled_functions = ListField(child=CharField(), required=True) @@ -293,9 +299,9 @@ class InstrumentationApiStep: step_name = "instrumentation" def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]: - account_number = pipeline.fetch_state("account_number") - region = pipeline.fetch_state("region") - aws_external_id = pipeline.fetch_state("aws_external_id") + account_number = cast("str", pipeline.fetch_state("account_number")) + region = cast("str", pipeline.fetch_state("region")) + aws_external_id = cast("str", pipeline.fetch_state("aws_external_id")) lambda_client = gen_aws_client(account_number, region, aws_external_id) lambda_functions = get_supported_functions(lambda_client) @@ -324,10 +330,10 @@ def handle_post( assert pipeline.organization is not None organization = pipeline.organization - account_number = pipeline.fetch_state("account_number") - region = pipeline.fetch_state("region") - project_id = pipeline.fetch_state("project_id") - aws_external_id = pipeline.fetch_state("aws_external_id") + account_number = cast("str", pipeline.fetch_state("account_number")) + region = cast("str", pipeline.fetch_state("region")) + project_id = cast("int", pipeline.fetch_state("project_id")) + aws_external_id = cast("str", pipeline.fetch_state("aws_external_id")) enabled_functions = validated_data["enabled_functions"] enabled_lambdas = {name: True for name in enabled_functions} @@ -342,7 +348,9 @@ def handle_post( fn for fn in lambda_functions if enabled_lambdas.get(fn["FunctionName"]) ] - def _enable_lambda(function): + def _enable_lambda( + function: dict[str, Any], + ) -> tuple[bool, dict[str, Any], Exception | None]: try: enable_single_lambda(lambda_client, function, sentry_project_dsn) return (True, function, None) @@ -362,12 +370,13 @@ def _enable_lambda(function): if success: success_count += 1 else: - err_message: str | _StrPromise = str(e) - is_custom_err, err_message = get_sentry_err_message(err_message) + error_text = str(e) + is_custom_err, message = get_sentry_err_message(error_text) + custom_message: str | _StrPromise = message if not is_custom_err: capture_exception(e) - err_message = _("Unknown Error") - failures.append({"name": name, "error": str(err_message)}) + custom_message = _("Unknown Error") + failures.append({"name": name, "error": str(custom_message)}) logger.info( "update_function_configuration.error", extra={ diff --git a/src/sentry/integrations/aws_lambda/utils.py b/src/sentry/integrations/aws_lambda/utils.py index f398dce63df3..0eeea344f37d 100644 --- a/src/sentry/integrations/aws_lambda/utils.py +++ b/src/sentry/integrations/aws_lambda/utils.py @@ -1,5 +1,7 @@ import re +from collections.abc import Callable, Mapping from functools import wraps +from typing import Any import sentry_sdk from django.conf import settings @@ -67,26 +69,27 @@ # 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): +def get_option_value(function: Mapping[str, Any], option: str) -> Any: region = parse_arn(function["FunctionArn"])["region"] runtime = function["Runtime"] @@ -134,36 +137,41 @@ 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]) -def _get_arn_from_layer(layer): +def _get_arn_from_layer(layer: object) -> str: # layer could be a string or dict if isinstance(layer, dict): - return layer["Arn"] - return layer + arn = layer.get("Arn") + if isinstance(arn, str): + return arn + raise TypeError("Lambda layer has no ARN") + if isinstance(layer, str): + return layer + raise TypeError("Lambda layer is neither a string nor an object") -def get_function_layer_arns(function): +def get_function_layer_arns(function: Mapping[str, Any]) -> list[str]: layers = function.get("Layers", []) return [_get_arn_from_layer(layer) for layer in layers] -def get_latest_layer_for_function(function): +def get_latest_layer_for_function(function: Mapping[str, Any]) -> str: region = parse_arn(function["FunctionArn"])["region"] return f"arn:aws:lambda:{region}:{get_option_value(function, OPTION_ACCOUNT_NUMBER)}:layer:{get_option_value(function, OPTION_LAYER_NAME)}:{get_option_value(function, OPTION_VERSION)}" -def get_latest_layer_version(function): +def get_latest_layer_version(function: Mapping[str, Any]) -> int: 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 @@ -179,7 +187,7 @@ def get_index_of_sentry_layer(layers, arn_to_match): return -1 -def get_supported_functions(lambda_client): +def get_supported_functions(lambda_client: Any) -> list[dict[str, Any]]: paginator = lambda_client.get_paginator("list_functions") response_iterator = paginator.paginate() functions = [] @@ -194,7 +202,7 @@ def get_supported_functions(lambda_client): ) -def get_dsn_for_project(organization_id, project_id): +def get_dsn_for_project(organization_id: int, project_id: int) -> str: enabled_dsn = project_key_service.get_project_key( organization_id=organization_id, project_id=project_id, role=ProjectKeyRole.store ) @@ -203,7 +211,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`. @@ -250,7 +258,12 @@ def get_node_options_for_layer(layer_name: str, layer_version: int | None) -> st return "--import @sentry/aws-serverless/awslambda-auto" -def enable_single_lambda(lambda_client, function, sentry_project_dsn, retries_left=3): +def enable_single_lambda( + lambda_client: Any, + function: Mapping[str, Any], + sentry_project_dsn: str, + retries_left: int = 3, +) -> Any: # find the latest layer for this function layer_arn = get_latest_layer_for_function(function) @@ -316,7 +329,7 @@ def enable_single_lambda(lambda_client, function, sentry_project_dsn, retries_le return update_lambda_with_retries(lambda_client, **lambda_kwargs) -def disable_single_lambda(lambda_client, function, layer_arn): +def disable_single_lambda(lambda_client: Any, function: Mapping[str, Any], layer_arn: str) -> Any: name = function["FunctionName"] runtime = function["Runtime"] layers = get_function_layer_arns(function) @@ -352,9 +365,8 @@ def disable_single_lambda(lambda_client, function, layer_arn): return update_lambda_with_retries(lambda_client, **lambda_kwargs) -def update_lambda_with_retries(lambda_client, **kwargs): +def update_lambda_with_retries(lambda_client: Any, **kwargs: Any) -> Any: 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 +380,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 +395,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 +404,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 +419,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 @@ -422,7 +434,7 @@ def get_exceeded_env_vars_limit_error(err_message): return env_vars_limit_error in err_message -def get_sentry_err_message(err_message): +def get_sentry_err_message(err_message: str) -> tuple[bool, str]: """ Check to see if an error matches a custom error and customizes the error message if it is a custom error @@ -441,15 +453,15 @@ def get_sentry_err_message(err_message): return False, err_message -def wrap_lambda_updater(): +def wrap_lambda_updater() -> Callable[[Callable[..., Any]], Callable[..., Any]]: """ Wraps any function that updates a layer Throws an IntegrationError for specific known errors """ - def inner(func): + def inner(func: Callable[..., Any]) -> Callable[..., Any]: @wraps(func) - def wrapped(*args, **kwargs): + def wrapped(*args: Any, **kwargs: Any) -> Any: try: return func(*args, **kwargs) except Exception as e: diff --git a/src/sentry/integrations/base.py b/src/sentry/integrations/base.py index f6c0be1e0a4f..337cf7d06e72 100644 --- a/src/sentry/integrations/base.py +++ b/src/sentry/integrations/base.py @@ -17,7 +17,7 @@ from sentry.integrations.errors import OrganizationIntegrationNotFound from sentry.integrations.models.external_actor import ExternalActor from sentry.integrations.models.integration import Integration -from sentry.integrations.types import IntegrationProviderSlug +from sentry.integrations.types import HaltReason, IntegrationProviderSlug from sentry.models.team import Team from sentry.organizations.services.organization import ( RpcOrganization, @@ -401,6 +401,9 @@ class IntegrationInstallation(abc.ABC): logger = logging.getLogger("sentry.integrations") + def is_broken_integration_error(self, exc: Exception) -> HaltReason | None: + return None + def __init__(self, model: RpcIntegration | Integration, organization_id: int) -> None: self.model = model self.organization_id = organization_id @@ -609,7 +612,7 @@ def is_response_success(resp: Any) -> bool: def is_response_error(resp: Any) -> bool: if not resp.status_code: return False - return resp.status_code >= 400 and resp.status_code != 429 and resp.status_code < 500 + return bool(resp.status_code >= 400 and resp.status_code != 429 and resp.status_code < 500) def get_integration_types(provider: str) -> list[IntegrationDomain]: diff --git a/src/sentry/integrations/bitbucket/client.py b/src/sentry/integrations/bitbucket/client.py index c5e908d196c6..e553325d9f72 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,42 @@ 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_mapping( + 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 get_workspace_hooks(self, workspace: str) -> MappingApiResponse: + return self.get_mapping(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_mapping( + 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}) + return self.get_mapping(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_mapping( 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_mapping( + BitbucketAPIPath.repository.format(repo=repo) + ) + return response def _get_all_from_paginated( self, @@ -138,12 +152,12 @@ def _get_all_from_paginated( params.setdefault("pagelen", self.page_size) output: list[dict[str, Any]] = [] - resp = self.get(path, params=params) + resp = self.get_mapping(path, params=params) output.extend(resp.get("values", [])) page_number = 1 while "next" in resp and page_number < page_number_limit: - resp = self.get(resp["next"]) + resp = self.get_mapping(resp["next"]) output.extend(resp.get("values", [])) page_number += 1 @@ -157,39 +171,56 @@ 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_mapping( 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_mapping( + 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_mapping( + 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_mapping( + 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)) + data = self.get_mapping( + 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 @@ -199,7 +230,7 @@ def compare_commits(self, repo, start_sha, end_sha): url = BitbucketAPIPath.repository_commits.format(repo=repo, revision=end_sha) while not done and len(commits) < 90: - data = self.get(url) + data = self.get_mapping(url) for commit in data["values"]: if commit["hash"] == start_sha: diff --git a/src/sentry/integrations/bitbucket/installed.py b/src/sentry/integrations/bitbucket/installed.py index 911b19508896..9c9d16e75be6 100644 --- a/src/sentry/integrations/bitbucket/installed.py +++ b/src/sentry/integrations/bitbucket/installed.py @@ -2,7 +2,7 @@ from uuid import UUID from django.http.request import HttpRequest -from django.http.response import HttpResponseBase +from django.http.response import HttpResponse from django.views.decorators.csrf import csrf_exempt from rest_framework.request import Request from rest_framework.response import Response @@ -36,10 +36,10 @@ class BitbucketInstalledEndpoint(Endpoint): permission_classes = () @csrf_exempt - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: return super().dispatch(request, *args, **kwargs) - def post(self, request: Request, *args, **kwargs) -> Response: + def post(self, request: Request, *args: object, **kwargs: object) -> Response: state = request.data if not state: return self.respond(status=400) diff --git a/src/sentry/integrations/bitbucket/integration.py b/src/sentry/integrations/bitbucket/integration.py index 6f2611de8cad..9635458648f4 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"] @@ -116,8 +125,9 @@ def get_client(self) -> BitbucketApiClient: # IntegrationInstallation methods - def error_message_from_json(self, data): - return data.get("error", {}).get("message", "unknown error") + def error_message_from_json(self, data: Mapping[str, object]) -> object: + error = cast(Mapping[str, object], data.get("error", {})) + return error.get("message", "unknown error") # RepositoryIntegration methods @@ -154,8 +164,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 +327,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..7a22f900d573 100644 --- a/src/sentry/integrations/bitbucket/issues.py +++ b/src/sentry/integrations/bitbucket/issues.py @@ -1,15 +1,18 @@ 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 +from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.source_code_management.issues import SourceCodeIssueIntegration from sentry.integrations.types import IntegrationIssueConfigField from sentry.integrations.utils.issue_url import get_issue_url_path from sentry.models.group import Group +from sentry.models.organization import Organization +from sentry.organizations.services.organization.model import RpcOrganization from sentry.organizations.services.organization.service import organization_service from sentry.shared_integrations.exceptions import ( ApiError, @@ -54,10 +57,11 @@ 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" + org: Organization | RpcOrganization if group: fields = super().get_create_issue_config(group, user, **kwargs) org = group.organization @@ -70,7 +74,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 +86,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,8 +117,10 @@ def get_create_issue_config( }, ] - def get_link_issue_config(self, group: Group, **kwargs) -> list[dict[str, Any]]: - params = kwargs.pop("params", {}) + def get_link_issue_config( + self, group: Group, **kwargs: object + ) -> list[IntegrationIssueConfigField]: + params = cast(Mapping[str, object], kwargs.pop("params", {})) default_repo, repo_choices = self.get_repository_choices(group, params) org = group.organization @@ -156,16 +163,18 @@ def raise_error(self, exc: Exception, identity: Identity | None = None) -> NoRet raise IntegrationConfigurationError(message) super().raise_error(exc, identity) - def create_issue(self, data, **kwargs): + def create_issue(self, data: Mapping[str, object], **kwargs: object) -> dict[str, object]: + data = dict(data) client = self.get_client() - if not data.get("repo"): + repo = data.get("repo") + if not repo: raise IntegrationFormError({"repo": ["Repository is required"]}) data["content"] = {"raw": data["description"]} del data["description"] try: - issue = client.create_issue(data.get("repo"), data) + issue = client.create_issue(cast(str, repo), data) except ApiError as e: self.raise_error(e) @@ -183,10 +192,11 @@ def get_issue_link_data(self, url: str) -> dict[str, str]: raise IntegrationFormError({"externalIssue": "Invalid Bitbucket issue URL"}) return {"repo": match[1], "externalIssue": match[2]} - def get_issue(self, issue_id, **kwargs): + def get_issue(self, issue_id: str | int, **kwargs: object) -> dict[str, object]: client = self.get_client() - repo = kwargs["data"].get("repo") - issue = client.get_issue(repo, issue_id) + data = cast(Mapping[str, object], kwargs["data"]) + repo = data.get("repo") + issue = client.get_issue(cast(str, repo), str(issue_id)) return { "key": issue["id"], "title": issue["title"], @@ -194,11 +204,11 @@ 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): - data = kwargs["data"] + def after_link_issue(self, external_issue: ExternalIssue, **kwargs: object) -> None: + data = cast(Mapping[str, object], kwargs["data"]) client = self.get_client() repo, issue_num = external_issue.key.split("#") @@ -217,9 +227,9 @@ def after_link_issue(self, external_issue, **kwargs): except ApiError as e: self.raise_error(e) - def search_issues(self, query: str | None, **kwargs) -> dict[str, Any]: + def search_issues(self, query: str | None, **kwargs: object) -> dict[str, Any]: client = self.get_client() - repo = kwargs["repo"] + repo = cast(str, kwargs["repo"]) resp = client.search_issues(repo, query) assert isinstance(resp, dict) return resp diff --git a/src/sentry/integrations/bitbucket/repository.py b/src/sentry/integrations/bitbucket/repository.py index 1bf2ed281c87..3a7e68123bfb 100644 --- a/src/sentry/integrations/bitbucket/repository.py +++ b/src/sentry/integrations/bitbucket/repository.py @@ -1,8 +1,8 @@ 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 typing import TYPE_CHECKING, Any, cast from sentry.integrations.services.repository.model import RpcRepository from sentry.integrations.services.repository.service import repository_service @@ -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 @@ -27,11 +29,17 @@ class BitbucketRepositoryProvider(IntegrationRepositoryProvider["BitbucketIntegr name = "Bitbucket" repo_provider = IntegrationProviderSlug.BITBUCKET.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]: + # justification: config values come from untrusted repository input + installation_id = cast(int | None, config.get("installation")) + installation = self.get_installation(installation_id, organization.id) client = installation.get_client() + # justification: config values come from untrusted repository input + identifier = cast(str, config["identifier"]) try: - repo = client.get_repo(config["identifier"]) + repo = client.get_repo(identifier) except Exception as e: installation.raise_error(e) else: @@ -39,7 +47,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 +55,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 +128,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() @@ -131,21 +139,29 @@ def on_delete_repository(self, repo): return raise - def _format_commits(self, repo, commit_list): - return [ - { - "id": c["hash"], - "repository": repo.name, - "author_email": parse_email(c["author"]["raw"]), - "author_name": parse_user_name(c["author"]["raw"]), - "message": c["message"], - "timestamp": self.format_date(c["date"]), - "patch_set": c.get("patch_set"), - } - for c in commit_list - ] + def _format_commits( + self, repo: Repository, commit_list: Sequence[Mapping[str, object]] + ) -> list[dict[str, object]]: + commits: list[dict[str, object]] = [] + for c in commit_list: + author = cast(Mapping[str, object], c["author"]) + raw_author = cast(str, author["raw"]) + commits.append( + { + "id": c["hash"], + "repository": repo.name, + "author_email": parse_email(raw_author), + "author_name": parse_user_name(raw_author), + "message": c["message"], + "timestamp": self.format_date(cast(str, c["date"])), + "patch_set": c.get("patch_set"), + } + ) + return commits - 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 +172,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..1f00749d7a12 100644 --- a/src/sentry/integrations/bitbucket/search.py +++ b/src/sentry/integrations/bitbucket/search.py @@ -1,5 +1,5 @@ import logging -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from rest_framework.response import Response @@ -21,9 +21,14 @@ T = TypeVar("T", bound=SourceCodeIssueIntegration) +if TYPE_CHECKING: + _SearchEndpointBase = SourceCodeSearchEndpoint[BitbucketIntegration] +else: + _SearchEndpointBase = SourceCodeSearchEndpoint + @control_silo_endpoint -class BitbucketSearchEndpoint(SourceCodeSearchEndpoint): +class BitbucketSearchEndpoint(_SearchEndpointBase): owner = ApiOwner.CODING_WORKFLOWS publish_status = { "GET": ApiPublishStatus.PRIVATE, @@ -34,11 +39,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/uninstalled.py b/src/sentry/integrations/bitbucket/uninstalled.py index 1c71042226e9..ee2d34469af7 100644 --- a/src/sentry/integrations/bitbucket/uninstalled.py +++ b/src/sentry/integrations/bitbucket/uninstalled.py @@ -1,5 +1,5 @@ from django.http.request import HttpRequest -from django.http.response import HttpResponseBase +from django.http.response import HttpResponse from django.views.decorators.csrf import csrf_exempt from rest_framework.request import Request from rest_framework.response import Response @@ -28,10 +28,10 @@ class BitbucketUninstalledEndpoint(Endpoint): permission_classes = () @csrf_exempt - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: return super().dispatch(request, *args, **kwargs) - def post(self, request: Request, *args, **kwargs) -> Response: + def post(self, request: Request, *args: object, **kwargs: object) -> Response: try: token = request.META["HTTP_AUTHORIZATION"].split(" ", 1)[1] except (KeyError, IndexError): diff --git a/src/sentry/integrations/bitbucket/webhook.py b/src/sentry/integrations/bitbucket/webhook.py index 4674241c14c9..2408f3d8c7fa 100644 --- a/src/sentry/integrations/bitbucket/webhook.py +++ b/src/sentry/integrations/bitbucket/webhook.py @@ -5,7 +5,7 @@ from abc import ABC from collections.abc import Mapping from datetime import timezone -from typing import Any +from typing import Any, cast import orjson from dateutil.parser import parse as parse_date @@ -116,12 +116,14 @@ class PushEventWebhook(BitbucketWebhook): def event_type(self) -> IntegrationWebhookEventType: return IntegrationWebhookEventType.PUSH - def __call__(self, event: Mapping[str, Any], **kwargs) -> None: + def __call__(self, event: Mapping[str, Any], **kwargs: object) -> None: authors = {} - if not (repo := kwargs.get("repo")): + repo = cast(Repository, kwargs.get("repo")) + if not repo: raise ValueError("Missing repo") - if not (organization := kwargs.get("organization")): + organization = cast(Organization, kwargs.get("organization")) + if not organization: raise ValueError("Missing organization") # while we're here, make sure repo data is up to date @@ -170,11 +172,11 @@ class BitbucketWebhookEndpoint(Endpoint): permission_classes = () _handlers: dict[str, type[BitbucketWebhook]] = {"repo:push": PushEventWebhook} - def get_handler(self, event_type) -> type[BitbucketWebhook] | None: + def get_handler(self, event_type: str) -> type[BitbucketWebhook] | None: return self._handlers.get(event_type) @method_decorator(csrf_exempt) - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: if request.method != "POST": return HttpResponse(status=405) diff --git a/src/sentry/integrations/bitbucket_server/client.py b/src/sentry/integrations/bitbucket_server/client.py index a8d881f08548..f79e2f0e94fc 100644 --- a/src/sentry/integrations/bitbucket_server/client.py +++ b/src/sentry/integrations/bitbucket_server/client.py @@ -1,5 +1,6 @@ import logging -from typing import Any, NotRequired, TypedDict +from collections.abc import Mapping, Sequence +from typing import Any, NotRequired, TypedDict, cast from urllib.parse import parse_qsl from oauthlib.oauth1 import SIGNATURE_RSA @@ -15,10 +16,17 @@ 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]] + isLastPage: NotRequired[bool] + nextPageStart: NotRequired[int] + + class BitbucketServerWebhookFields(TypedDict): name: str url: str @@ -46,14 +54,27 @@ class BitbucketServerSetupClient(ApiClient): authorize_url = "{}/plugins/servlet/oauth/authorize?oauth_token={}" integration_name = "bitbucket_server_setup" - def __init__(self, base_url, consumer_key, private_key, verify_ssl=True, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__( + self, + base_url: str, + consumer_key: str, + private_key: str, + verify_ssl: bool = True, + *, + integration_id: int | None = None, + logging_context: Mapping[str, object] | None = None, + ) -> None: + super().__init__( + integration_id=integration_id, + verify_ssl=verify_ssl, + logging_context=logging_context, + ) 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. @@ -62,14 +83,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. @@ -90,7 +113,7 @@ def get_access_token(self, request_token, verifier): resp = self.post(url, auth=auth, allow_text=True) return dict(parse_qsl(resp.text)) - def request(self, *args, **kwargs): + def request(self, *args: Any, **kwargs: Any) -> Any: # justification: shared request surface. """ Add OAuth1 RSA signatures. """ @@ -120,7 +143,7 @@ def __init__( self, integration: RpcIntegration | Integration, identity: RpcIdentity, - ): + ) -> None: self.base_url = integration.metadata["base_url"] self.identity = identity @@ -133,7 +156,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,37 +172,45 @@ 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: + return self.get_mapping( BitbucketServerAPIPath.repositories, params={"limit": 250, "permission": "REPO_ADMIN"}, ) - def search_repositories(self, query_string): - return self.get( + def search_repositories(self, query_string: str) -> MappingApiResponse: + return self.get_mapping( BitbucketServerAPIPath.repositories, params={"limit": 250, "permission": "REPO_ADMIN", "name": query_string}, ) def get_repo(self, project: str, repo: str) -> dict[str, Any]: - return self.get( + return self.get_mapping( BitbucketServerAPIPath.repository.format(project=project, repo=repo), ) def create_hook( self, project: str, repo: str, data: BitbucketServerWebhookData ) -> BitbucketServerWebhookResponse: - return self.post( - BitbucketServerAPIPath.repository_hooks.format(project=project, repo=repo), - data=data, + # justification: the webhook endpoint returns the declared response shape. + return cast( + "BitbucketServerWebhookResponse", + self.post_mapping( + BitbucketServerAPIPath.repository_hooks.format(project=project, repo=repo), + data=data, + ), ) def get_hook( self, project: str, repo: str, webhook_id: int | str ) -> BitbucketServerWebhookResponse: - return self.get( - BitbucketServerAPIPath.repository_hook.format( - project=project, repo=repo, id=webhook_id + # justification: the webhook endpoint returns the declared response shape. + return cast( + "BitbucketServerWebhookResponse", + self.get_mapping( + BitbucketServerAPIPath.repository_hook.format( + project=project, repo=repo, id=webhook_id + ), ), ) @@ -190,11 +221,15 @@ def update_hook( webhook_id: int | str, data: BitbucketServerWebhookData, ) -> BitbucketServerWebhookResponse: - return self.put( - BitbucketServerAPIPath.repository_hook.format( - project=project, repo=repo, id=webhook_id + # justification: the webhook endpoint returns the declared response shape. + return cast( + "BitbucketServerWebhookResponse", + self.put_mapping( + BitbucketServerAPIPath.repository_hook.format( + project=project, repo=repo, id=webhook_id + ), + data=data, ), - data=data, ) def delete_hook(self, project: str, repo: str, webhook_id: int | str) -> None: @@ -204,7 +239,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 +257,22 @@ 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( - BitbucketServerAPIPath.repository_commits.format(project=project, repo=repo), - params={"merges": "exclude", "limit": limit}, - )["values"] + def get_last_commits( + self, project: str, repo: str, limit: int = 10 + ) -> Sequence[Mapping[str, object]]: + # justification: the commits endpoint returns the declared values shape. + data = cast( + "_ValuesResponse", + self.get_mapping( + BitbucketServerAPIPath.repository_commits.format(project=project, repo=repo), + params={"merges": "exclude", "limit": limit}, + ), + ) + 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 +287,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,14 +303,15 @@ 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", start, extra={"uri": uri, "params": new_params}, ) - data = self.get(uri, params=new_params) + # justification: the values endpoint returns the declared values shape. + data = cast("_ValuesResponse", self.get_mapping(uri, params=new_params)) logger.debug( "%s values loaded", len(data["values"]), extra={"uri": uri, "params": new_params} ) diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 9919d5e3f93f..747ebd1d87cc 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 @@ -25,13 +25,12 @@ from sentry.integrations.services.repository import repository_service from sentry.integrations.services.repository.model import RpcRepository from sentry.integrations.source_code_management.repository import ( - HaltReason, RepositoryInfo, RepositoryIntegration, ) from sentry.integrations.source_code_management.url import url_is_under from sentry.integrations.tasks.migrate_repo import migrate_repo -from sentry.integrations.types import IntegrationProviderSlug +from sentry.integrations.types import HaltReason, IntegrationProviderSlug from sentry.integrations.utils.metrics import ( IntegrationPipelineViewEvent, IntegrationPipelineViewType, @@ -247,6 +246,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 @@ -269,8 +283,13 @@ def get_client(self) -> BitbucketServerClient: # IntegrationInstallation methods - def error_message_from_json(self, data): - return data.get("error", {}).get("message", "unknown error") + def error_message_from_json(self, data: Mapping[str, object]) -> str: + error = data.get("error") + if isinstance(error, Mapping): + message = error.get("message") + if isinstance(message, str): + return message + return "unknown error" def is_broken_integration_error(self, exc: Exception) -> HaltReason | None: if isinstance(exc, ApiError): @@ -293,6 +312,8 @@ def get_repositories( ) -> list[RepositoryInfo]: if not query: resp = self.get_client().get_repos() + # justification: Bitbucket Server repository lists are JSON objects. + repos = cast(BitbucketServerReposResponse, resp) return [ { @@ -302,10 +323,12 @@ def get_repositories( "repo": repo["slug"], "name": repo["project"]["name"] + "/" + repo["name"], } - for repo in resp.get("values", []) + for repo in repos.get("values", []) ] resp = self.get_client().search_repositories(query) + # justification: Bitbucket Server repository lists are JSON objects. + repos = cast(BitbucketServerReposResponse, resp) return [ { @@ -315,7 +338,7 @@ def get_repositories( "repo": repo["slug"], "name": repo["project"]["name"] + "/" + repo["name"], } - for repo in resp.get("values", []) + for repo in repos.get("values", []) ] def has_repo_access(self, repo: RpcRepository) -> bool: @@ -364,7 +387,7 @@ def extract_source_path_from_source_url(self, repo: Repository, url: str) -> str # Bitbucket Server only methods @property - def username(self): + def username(self) -> str: return self.model.name @@ -441,7 +464,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/bitbucket_server/repository.py b/src/sentry/integrations/bitbucket_server/repository.py index 49e21953f3e4..8c052b42e041 100644 --- a/src/sentry/integrations/bitbucket_server/repository.py +++ b/src/sentry/integrations/bitbucket_server/repository.py @@ -2,9 +2,9 @@ import logging import secrets -from collections.abc import Mapping +from collections.abc import Mapping, MutableMapping, Sequence from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypeAlias from django.core.cache import cache @@ -12,8 +12,11 @@ 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.repository import Repository from sentry.organizations.services.organization.model import RpcOrganization from sentry.plugins.providers.integration_repository import ( + CommitData, + CommitPatchFile, IntegrationRepositoryProvider, RepositoryConfig, ) @@ -21,10 +24,15 @@ from sentry.utils.hashlib import md5_text if TYPE_CHECKING: + from sentry.integrations.bitbucket_server.client import BitbucketServerClient from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegration # NOQA logger = logging.getLogger(__name__) +# justification: provider API configs and commit payloads are decoded JSON. +JsonMapping: TypeAlias = Mapping[str, Any] +JsonMutableMapping: TypeAlias = MutableMapping[str, Any] # justification: decoded JSON. + class BitbucketServerRepositoryProvider( IntegrationRepositoryProvider["BitbucketServerIntegration"] @@ -32,7 +40,9 @@ class BitbucketServerRepositoryProvider( name = "Bitbucket Server" repo_provider = IntegrationProviderSlug.BITBUCKET_SERVER.value - def get_repository_data(self, organization, config): + def get_repository_data( + self, organization: RpcOrganization, config: JsonMutableMapping + ) -> JsonMutableMapping: installation = self.get_installation(config.get("installation"), organization.id) client = installation.get_client() try: @@ -48,7 +58,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: Mapping[str, Any] + self, organization: RpcOrganization, data: JsonMapping ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) return { @@ -120,7 +130,7 @@ def on_create_repository(self, repo: RpcRepository, organization: RpcOrganizatio }, ) - def on_delete_repository(self, repo): + def on_delete_repository(self, repo: Repository) -> None: webhook_id = repo.config.get("webhook_id") if not webhook_id: return @@ -134,7 +144,12 @@ def on_delete_repository(self, repo): return raise - def _format_commits(self, client, repo, commit_list): + def _format_commits( + self, + client: BitbucketServerClient, + repo: Repository, + commit_list: Sequence[JsonMapping], + ) -> list[CommitData]: return [ { "id": c["id"], @@ -150,7 +165,9 @@ def _format_commits(self, client, 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[JsonMapping]: installation = self.get_installation(repo.integration_id, repo.organization_id) client = installation.get_client() @@ -165,10 +182,12 @@ def compare_commits(self, repo, start_sha, end_sha): except Exception as e: installation.raise_error(e) - def repository_external_slug(self, repo): + def repository_external_slug(self, repo: Repository) -> str: return repo.name - def _get_patchset(self, client, project, repo, sha): + def _get_patchset( + self, client: BitbucketServerClient, project: str, repo: str, sha: str + ) -> list[CommitPatchFile]: """ Get the modified files for a commit """ @@ -181,12 +200,12 @@ def _get_patchset(self, client, project, repo, sha): return self._transform_patchset(commit_files) - def _transform_patchset(self, values): + def _transform_patchset(self, values: Sequence[JsonMapping]) -> list[CommitPatchFile]: """Convert the patch data from Bitbucket into our internal format See sentry.models.Release.set_commits """ - changes = [] + changes: list[CommitPatchFile] = [] for change in values: if change["type"] == "MODIFY": changes.append({"path": change["path"]["toString"], "type": "M"}) diff --git a/src/sentry/integrations/bitbucket_server/webhook.py b/src/sentry/integrations/bitbucket_server/webhook.py index c2dd70cabae5..025be50979d3 100644 --- a/src/sentry/integrations/bitbucket_server/webhook.py +++ b/src/sentry/integrations/bitbucket_server/webhook.py @@ -5,7 +5,7 @@ from collections.abc import Mapping from datetime import datetime, timezone from enum import StrEnum -from typing import Any +from typing import Any, TypeAlias from uuid import uuid4 import orjson @@ -41,6 +41,9 @@ from sentry.web.frontend.base import cell_silo_view logger = logging.getLogger("sentry.webhooks") + +# justification: webhook payloads are decoded JSON. +WebhookPayload: TypeAlias = Mapping[str, Any] release_repair_debounce = load_redis_script("utils/locking/delete_lock.lua") PROVIDER_NAME = "integrations:bitbucket_server" @@ -151,10 +154,10 @@ def trigger_webhook_repair( class BitbucketServerWebhook(SCMWebhook, ABC): @property - def provider(self): + def provider(self) -> str: return IntegrationProviderSlug.BITBUCKET_SERVER.value - def update_repo_data(self, repo, event): + def update_repo_data(self, repo: Repository, event: WebhookPayload) -> None: """ Given a webhook payload, update stored repo data if needed. """ @@ -169,7 +172,11 @@ class PushEventWebhook(BitbucketServerWebhook): def event_type(self) -> IntegrationWebhookEventType: return IntegrationWebhookEventType.PUSH - def __call__(self, event: Mapping[str, Any], **kwargs) -> None: + def __call__( + self, + event: WebhookPayload, + **kwargs: Any, # justification: webhook dispatch context. + ) -> None: authors = {} if not ( @@ -264,11 +271,11 @@ class BitbucketServerWebhookEndpoint(Endpoint): _handlers: dict[str, type[BitbucketServerWebhook]] = {"repo:refs_changed": PushEventWebhook} - def get_handler(self, event_type) -> type[BitbucketServerWebhook] | None: + def get_handler(self, event_type: str) -> type[BitbucketServerWebhook] | None: return self._handlers.get(event_type) @method_decorator(csrf_exempt) - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: if request.method != "POST": return HttpResponse(status=405) diff --git a/src/sentry/integrations/claude_code/integration.py b/src/sentry/integrations/claude_code/integration.py index de5b2bf5c1ea..2238d47fec9f 100644 --- a/src/sentry/integrations/claude_code/integration.py +++ b/src/sentry/integrations/claude_code/integration.py @@ -10,7 +10,7 @@ import hashlib import logging from collections.abc import Mapping, MutableMapping -from typing import Any, Literal +from typing import Any, Literal, cast from django.conf import settings as django_settings from django.http.request import HttpRequest @@ -42,6 +42,7 @@ from sentry.seer.autofix.utils import CodingAgentState from sentry.shared_integrations.exceptions import IntegrationConfigurationError from sentry.utils.imports import import_string +from sentry.utils.locking.lock import Lock logger = logging.getLogger(__name__) @@ -65,7 +66,8 @@ def _get_client_class() -> type[Any]: "CLAUDE_CODE_CLIENT_CLASS is not configured. " "The Claude Code client is not available in this environment." ) - return import_string(class_path) + # justification: CLAUDE_CODE_CLIENT_CLASS names the client class configured for this environment. + return cast("type[Any]", import_string(class_path)) FEATURES = [ @@ -155,7 +157,7 @@ def _delete_legacy_integrations(organization_id: int, external_id: str) -> None: integration.delete() -class ClaudeCodeApiKeySerializer(CamelSnakeSerializer): +class ClaudeCodeApiKeySerializer(CamelSnakeSerializer[dict[str, str]]): api_key = CharField(required=True, max_length=255) @@ -306,7 +308,7 @@ def _get_metadata(self) -> ClaudeCodeIntegrationMetadata: """Parse and return the integration metadata.""" return ClaudeCodeIntegrationMetadata.parse_obj(self.model.metadata or {}) - def _vault_metadata_lock(self): + def _vault_metadata_lock(self) -> Lock: return locks.get( f"claude_code:vault:{self.model.id}", duration=VAULT_METADATA_LOCK_DURATION_S, @@ -373,6 +375,10 @@ def _resolve_vault(self, client: Any, request: CodingAgentLaunchRequest) -> str if request.repository.organization_id is not None: vault_metadata["organization_id"] = str(request.repository.organization_id) vault_id = client.create_vault(metadata=vault_metadata) + if not isinstance(vault_id, str): + raise IntegrationConfigurationError( + "Claude Code client returned an invalid vault id" + ) metadata.installation_vault_ids[str(installation_id)] = vault_id self._persist_metadata(metadata) return vault_id @@ -423,7 +429,11 @@ def launch(self, request: CodingAgentLaunchRequest) -> CodingAgentState: client = self.get_client() vault_id = self._resolve_vault(client, request) - state = client.launch(webhook_url=webhook_url, request=request, vault_id=vault_id) + # justification: the configured Claude Code client returns a CodingAgentState. + state = cast( + "CodingAgentState", + client.launch(webhook_url=webhook_url, request=request, vault_id=vault_id), + ) state.integration_id = self.model.id self._sync_client_ids(client) diff --git a/src/sentry/integrations/cursor/integration.py b/src/sentry/integrations/cursor/integration.py index 90d12353b4e9..88e5680b2b2b 100644 --- a/src/sentry/integrations/cursor/integration.py +++ b/src/sentry/integrations/cursor/integration.py @@ -78,7 +78,7 @@ def resolve_integration_name(metadata: CursorIntegrationMetadata) -> str: ) -class CursorApiKeySerializer(CamelSnakeSerializer): +class CursorApiKeySerializer(CamelSnakeSerializer[dict[str, str]]): api_key = CharField(required=True, max_length=255) @@ -256,7 +256,7 @@ def _update_display_name( ) metadata.display_name = display_name or None - def get_client(self): + def get_client(self) -> CursorAgentClient: return CursorAgentClient( api_key=self.api_key, webhook_secret=self.webhook_secret, diff --git a/src/sentry/integrations/cursor/models.py b/src/sentry/integrations/cursor/models.py index cf904bcde430..262bf754efb1 100644 --- a/src/sentry/integrations/cursor/models.py +++ b/src/sentry/integrations/cursor/models.py @@ -12,7 +12,7 @@ class CursorApiKeyMetadata(BaseModel): class CursorAgentLaunchRequestPrompt(BaseModel): text: str - images: list[dict] = [] + images: list[dict[str, object]] = [] class CursorAgentSource(BaseModel): diff --git a/src/sentry/integrations/cursor/webhooks/handler.py b/src/sentry/integrations/cursor/webhooks/handler.py index c841d1f173c8..ccf2e138b584 100644 --- a/src/sentry/integrations/cursor/webhooks/handler.py +++ b/src/sentry/integrations/cursor/webhooks/handler.py @@ -55,7 +55,7 @@ class CursorWebhookEndpoint(Endpoint): organization_id: int @method_decorator(csrf_exempt) - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: if request.method != "POST": raise MethodNotAllowed(request.method or "unknown") return super().dispatch(request, *args, **kwargs) diff --git a/src/sentry/integrations/cursor_origin/client.py b/src/sentry/integrations/cursor_origin/client.py index 1aa8343fe05e..04259d0a5772 100644 --- a/src/sentry/integrations/cursor_origin/client.py +++ b/src/sentry/integrations/cursor_origin/client.py @@ -4,7 +4,7 @@ from base64 import b64decode from collections.abc import Mapping, Sequence from datetime import UTC, datetime, timedelta -from typing import Any, Literal, NotRequired, TypedDict +from typing import Any, Literal, NotRequired, TypedDict, cast from django.utils import timezone from requests import PreparedRequest @@ -239,10 +239,12 @@ def authorize_request(self, prepared_request: PreparedRequest) -> PreparedReques return prepared_request def get_app(self) -> OriginApp: - return self.get("/app") + # justification: Origin returns the declared OriginApp shape. + return cast("OriginApp", self.get_mapping("/app")) def get_installation(self, installation_id: str) -> OriginInstallation: - return self.get(f"/app/installations/{installation_id}") + # justification: Origin returns the declared OriginInstallation shape. + return cast("OriginInstallation", self.get_mapping(f"/app/installations/{installation_id}")) def delete_installation(self, installation_id: str) -> None: """Remove the installation on Origin. Responds with an empty body.""" @@ -389,7 +391,7 @@ def _paginate[T]( if page_token: request_params["pageToken"] = page_token - response = self.get(path, params=request_params) + response = self.get_mapping(path, params=request_params) results.extend(response[collection_key]) if limit is not None and len(results) >= limit: return results[:limit] @@ -407,7 +409,8 @@ def get_repositories(self, query: str | None = None) -> list[OriginRepositorySum return self._paginate("/installation/repos", "repositories", params=params) def get_repo(self, repo_full_name: str) -> OriginRepository: - return self.get(f"/repos/{repo_full_name}") + # justification: Origin returns the declared OriginRepository shape. + return cast("OriginRepository", self.get_mapping(f"/repos/{repo_full_name}")) def get_branches(self, repo_full_name: str) -> list[OriginBranch]: return self._paginate(f"/repos/{repo_full_name}/branches", "branches") @@ -423,7 +426,8 @@ def get_commits( def get_commit(self, repo_full_name: str, sha: str) -> OriginCommit: """Return a commit with aggregate stats.""" - return self.get(f"/repos/{repo_full_name}/commits/{sha}") + # justification: Origin returns the declared OriginCommit shape. + return cast("OriginCommit", self.get_mapping(f"/repos/{repo_full_name}/commits/{sha}")) def get_commit_files(self, repo_full_name: str, sha: str) -> list[OriginCommitFile]: """Return the files changed by a commit.""" @@ -433,7 +437,10 @@ def get_commit_files(self, repo_full_name: str, sha: str) -> list[OriginCommitFi def compare_commits(self, repo_full_name: str, base: str, head: str) -> OriginComparison: """Return how `head` relates to `base`.""" - return self.get(f"/repos/{repo_full_name}/compare/{base}...{head}") + # justification: Origin returns the declared OriginComparison shape. + return cast( + "OriginComparison", self.get_mapping(f"/repos/{repo_full_name}/compare/{base}...{head}") + ) def get_compare_files( self, repo_full_name: str, base: str, head: str @@ -452,7 +459,7 @@ def get_tree_response( would otherwise surface as an opaque failure. """ try: - response = self.get( + response = self.get_mapping( f"/repos/{repo_full_name}/git/trees/{tree_sha}", params={"recursive": "true"} ) except ApiError as e: @@ -471,7 +478,8 @@ def get_tree(self, repo_full_name: str, tree_sha: str) -> list[OriginTreeEntry]: return entries def get_blob(self, repo_full_name: str, sha: str) -> OriginBlob: - return self.get(f"/repos/{repo_full_name}/git/blobs/{sha}") + # justification: Origin returns the declared OriginBlob shape. + return cast("OriginBlob", self.get_mapping(f"/repos/{repo_full_name}/git/blobs/{sha}")) def get_languages( self, repo_full_name: str, tree: list[dict[str, Any]] | None = None @@ -497,7 +505,10 @@ def get_contents( params: dict[str, Any] = {"path": path} if ref: params["ref"] = ref - return self.get(f"/repos/{repo_full_name}/contents", params=params) + # justification: Origin returns the declared OriginContents shape. + return cast( + "OriginContents", self.get_mapping(f"/repos/{repo_full_name}/contents", params=params) + ) def check_file(self, repo: Repository, path: str, version: str | None) -> object | None: # Origin has no HEAD route for contents, so this is a GET whose body we discard. diff --git a/src/sentry/integrations/cursor_origin/integration.py b/src/sentry/integrations/cursor_origin/integration.py index 998038bd626f..1f87fd6b0e87 100644 --- a/src/sentry/integrations/cursor_origin/integration.py +++ b/src/sentry/integrations/cursor_origin/integration.py @@ -33,13 +33,12 @@ from sentry.integrations.services.repository.model import RpcRepository from sentry.integrations.source_code_management.repo_trees import RepoTreesIntegration from sentry.integrations.source_code_management.repository import ( - HaltReason, RepositoryInfo, RepositoryIntegration, ) from sentry.integrations.source_code_management.sync_repos import sync_repos_for_org from sentry.integrations.source_code_management.url import url_is_under -from sentry.integrations.types import IntegrationProviderSlug +from sentry.integrations.types import HaltReason, IntegrationProviderSlug from sentry.models.repository import Repository from sentry.organizations.services.organization.model import RpcOrganization from sentry.pipeline.views.base import ApiPipelineSteps diff --git a/src/sentry/integrations/data_forwarding/amazon_sqs/forwarder.py b/src/sentry/integrations/data_forwarding/amazon_sqs/forwarder.py index c1f1bf6bc27b..37e17d588cc6 100644 --- a/src/sentry/integrations/data_forwarding/amazon_sqs/forwarder.py +++ b/src/sentry/integrations/data_forwarding/amazon_sqs/forwarder.py @@ -30,9 +30,12 @@ def get_event_payload( # We do this since serialize(event) returns datetime objects, and taskbroker doesn't support # those as arguments. This dump -> load step gets around it, and leaves the payload being # forwarded in the same format it has always been. - return orjson.loads( + payload = orjson.loads( orjson.dumps(serialize(event), option=orjson.OPT_UTC_Z | orjson.OPT_NON_STR_KEYS) ) + if not isinstance(payload, dict): + raise TypeError("Serialized event is not a JSON object") + return payload def is_unrecoverable_client_error(self, error: ClientError) -> bool: error_str = str(error) @@ -85,13 +88,14 @@ def forward_event( "region_name": region, } - def s3_put_object(*args, **kwargs): + def s3_put_object(*args: object, **kwargs: object) -> object: s3_client = boto3.client( service_name="s3", config=Config(signature_version="s3v4"), **boto3_args ) - return s3_client.put_object(*args, **kwargs) + response: object = s3_client.put_object(*args, **kwargs) + return response - def sqs_send_message(message): + def sqs_send_message(message: str) -> object: client = boto3.client(service_name="sqs", **boto3_args) send_message_args = {"QueueUrl": queue_url, "MessageBody": message} @@ -103,7 +107,8 @@ def sqs_send_message(message): # if content based de-duplication is not enabled, we need to provide a # MessageDeduplicationId send_message_args["MessageDeduplicationId"] = uuid4().hex - return client.send_message(**send_message_args) + response: object = client.send_message(**send_message_args) + return response # Upload to S3 if configured (for large payloads) if s3_bucket: diff --git a/src/sentry/integrations/data_forwarding/splunk/forwarder.py b/src/sentry/integrations/data_forwarding/splunk/forwarder.py index 069225611976..72c3d03aedee 100644 --- a/src/sentry/integrations/data_forwarding/splunk/forwarder.py +++ b/src/sentry/integrations/data_forwarding/splunk/forwarder.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from collections.abc import Mapping from typing import Any from sentry import tagstore @@ -9,6 +10,7 @@ from sentry.services.eventstore.models import Event, GroupEvent from sentry.shared_integrations.client.base import BaseApiClient from sentry.shared_integrations.exceptions import ApiError, ApiHostError, ApiTimeoutError +from sentry.shared_integrations.response.base import BaseApiResponse from sentry.utils.anonymizeip import anonymize_ip from sentry.utils.hashlib import md5_text @@ -21,14 +23,14 @@ class SplunkApiClient(BaseApiClient): plugin_name = "splunk" allow_redirects = False - def __init__(self, endpoint, token): + def __init__(self, endpoint: str, token: str) -> None: self.endpoint = endpoint self.token = token super().__init__(verify_ssl=False) - def request(self, data): + def request(self, data: Mapping[str, object]) -> BaseApiResponse: headers = {"Authorization": f"Splunk {self.token}"} - return self._request( + response = self._request( path=self.endpoint, method="post", data=data, @@ -37,6 +39,7 @@ def request(self, data): timeout=5, allow_text=True, ) + return response class SplunkForwarder(BaseDataForwarder): @@ -56,12 +59,12 @@ def get_host_for_splunk(self, event: Event | GroupEvent) -> str | None: user_interface = event.interfaces.get("user") if user_interface: host = user_interface.ip_address - if host: + if isinstance(host, str) and host: return host return None - def initialize_variables(self, event: Event | GroupEvent, config: dict[str, Any]): + def initialize_variables(self, event: Event | GroupEvent, config: dict[str, Any]) -> None: self.project_token = config.get("token") self.project_index = config.get("index") self.project_instance = config.get("instance_url") @@ -76,7 +79,7 @@ def get_rl_key(self, event: Event | GroupEvent) -> str: return f"{self.provider.value}:{md5_text(self.project_token).hexdigest()}" def get_event_payload_properties(self, event: Event | GroupEvent) -> dict[str, Any]: - props = { + props: dict[str, object] = { "event_id": event.event_id, "issue_id": event.group_id, "project_id": event.project.slug, diff --git a/src/sentry/integrations/datadog/client.py b/src/sentry/integrations/datadog/client.py index 21d9d9c91f5b..7f3a8ed0e3f2 100644 --- a/src/sentry/integrations/datadog/client.py +++ b/src/sentry/integrations/datadog/client.py @@ -60,7 +60,10 @@ def validate_datadog_credentials(api_key: str, app_key: str, site: str) -> str: try: body = orjson.loads(safe_urlread(resp)) - return body["data"]["relationships"]["org"]["data"]["id"] + org_id = body["data"]["relationships"]["org"]["data"]["id"] + if not isinstance(org_id, str): + raise TypeError("Datadog org id is not a string") + return org_id except (KeyError, IndexError, TypeError, orjson.JSONDecodeError): raise IntegrationConfigurationError( "Datadog returned an unexpected response while validating credentials." diff --git a/src/sentry/integrations/discord/client.py b/src/sentry/integrations/discord/client.py index 31945747df40..d3324e5e05bb 100644 --- a/src/sentry/integrations/discord/client.py +++ b/src/sentry/integrations/discord/client.py @@ -52,7 +52,7 @@ class DiscordClient(ApiClient): _METRICS_USER_ERROR_KEY: str = "sentry.integrations.discord.failure.user_error" _METRICS_RATE_LIMIT_KEY: str = "sentry.integrations.discord.failure.rate_limit" - def __init__(self): + def __init__(self) -> None: super().__init__() self.application_id = options.get("discord.application-id") self.client_secret = options.get("discord.client-secret") @@ -76,10 +76,15 @@ def has_application_commands(self) -> bool: return bool(response) def get_guild_name(self, guild_id: str) -> str: - response = self.get(GUILD_URL.format(guild_id=guild_id), headers=self.prepare_auth_header()) - return response["name"] + response = self.get_mapping( + GUILD_URL.format(guild_id=guild_id), headers=self.prepare_auth_header() + ) + name = response.get("name") + if not isinstance(name, str): + raise ApiError("Expected a guild name") + return name - def get_access_token(self, code: str, url: str): + def get_access_token(self, code: str, url: str) -> str: data = { "client_id": self.application_id, "client_secret": self.client_secret, @@ -91,16 +96,22 @@ def get_access_token(self, code: str, url: str): headers = { "Content-Type": "application/x-www-form-urlencoded", } - response = self.post(TOKEN_URL, json=False, data=urlencode(data), headers=headers) - return response["access_token"] + response = self.post_mapping(TOKEN_URL, json=False, data=urlencode(data), headers=headers) + access_token = response.get("access_token") + if not isinstance(access_token, str): + raise ApiError("Expected an access token") + return access_token - def get_user_id(self, access_token: str): + def get_user_id(self, access_token: str) -> str: headers = {"Authorization": f"Bearer {access_token}"} - response = self.get( + response = self.get_mapping( USER_URL, headers=headers, ) - return response["id"] + user_id = response.get("id") + if not isinstance(user_id, str): + raise ApiError("Expected a user id") + return user_id def check_user_bot_installation_permission(self, access_token: str, guild_id: str) -> bool: headers = {"Authorization": f"Bearer {access_token}"} @@ -180,7 +191,7 @@ def _handle_failure( Do extra logic to handle an error from Discord """ - discord_error_response: dict | None = None + discord_error_response: dict[str, object] | None = None if resp is not None: # Try to get the additional error code that Discord sent us to help determine what specific error happened try: diff --git a/src/sentry/integrations/discord/integration.py b/src/sentry/integrations/discord/integration.py index ad4610fcfbdc..8e80cca6613e 100644 --- a/src/sentry/integrations/discord/integration.py +++ b/src/sentry/integrations/discord/integration.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, TypedDict from urllib.parse import urlencode from django.http.request import HttpRequest @@ -84,7 +84,7 @@ ) -class DiscordIntegration(IntegrationInstallation, IntegrationNotificationClient): +class DiscordIntegration(IntegrationInstallation, IntegrationNotificationClient[DiscordRenderable]): def get_client(self) -> DiscordClient: return DiscordClient() @@ -142,13 +142,25 @@ def uninstall(self) -> None: return -class DiscordOAuthApiSerializer(CamelSnakeSerializer): +class DiscordOAuthApiData(TypedDict): + code: str + state: str + guild_id: str + + +class DiscordOAuthApiSerializer(CamelSnakeSerializer[DiscordOAuthApiData]): code = CharField(required=True) state = CharField(required=True) guild_id = CharField(required=True) -class DiscordInitialDataSerializer(CamelSnakeSerializer): +class DiscordInitialData(TypedDict, total=False): + code: str + guild_id: str + use_configure: str + + +class DiscordInitialDataSerializer(CamelSnakeSerializer[DiscordInitialData]): """Initial pipeline data for App Directory-originated Discord installs. When a user installs from Discord's App Directory, Discord initiates OAuth diff --git a/src/sentry/integrations/discord/message_builder/issues.py b/src/sentry/integrations/discord/message_builder/issues.py index 5b6f7fad71d9..aeaf90a1e747 100644 --- a/src/sentry/integrations/discord/message_builder/issues.py +++ b/src/sentry/integrations/discord/message_builder/issues.py @@ -127,6 +127,7 @@ def build_tag_fields( continue labeled_value = tagstore.backend.get_tag_value_label(key, value) + assert labeled_value is not None fields.append( DiscordMessageEmbedField( std_key, diff --git a/src/sentry/integrations/discord/requests/base.py b/src/sentry/integrations/discord/requests/base.py index 5f49d95a7b97..b2d74bfb5428 100644 --- a/src/sentry/integrations/discord/requests/base.py +++ b/src/sentry/integrations/discord/requests/base.py @@ -98,7 +98,13 @@ def user_id(self) -> str | None: user_source = self._data.get("member", None) if user_source is None: user_source = self._data - return user_source["user"]["id"] # type: ignore[index] + if not isinstance(user_source, Mapping): + return None + user = user_source.get("user") + if not isinstance(user, Mapping): + return None + user_id = user.get("id") + return user_id if isinstance(user_id, str) else None except (AttributeError, TypeError, KeyError): return None @@ -135,7 +141,7 @@ def response_url(self) -> str | None: return None return f"{DISCORD_BASE_URL}/webhooks/{application_id}/{token}" - def _get_context(self): + def _get_context(self) -> None: context = integration_service.get_integration_identity_context( integration_provider=IntegrationProviderSlug.DISCORD.value, integration_external_id=self.guild_id, @@ -239,7 +245,7 @@ def has_identity(self) -> bool: def _log_request(self) -> None: self._info("discord.request") - def _info(self, key: str, extra=None) -> None: + def _info(self, key: str, extra: dict[str, object] | None = None) -> None: if not extra: extra = {**self.logging_data} logger.info(key, extra=extra) diff --git a/src/sentry/integrations/example/integration.py b/src/sentry/integrations/example/integration.py index 296d84e37085..dcdf87e0fdc5 100644 --- a/src/sentry/integrations/example/integration.py +++ b/src/sentry/integrations/example/integration.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, cast from django.http.request import HttpRequest from rest_framework.fields import CharField @@ -22,19 +22,24 @@ from sentry.integrations.services.repository.model import RpcRepository from sentry.integrations.source_code_management.issues import SourceCodeIssueIntegration from sentry.integrations.source_code_management.repository import ( + RepositoryClient, RepositoryInfo, RepositoryIntegration, ) +from sentry.integrations.types import IntegrationIssueConfigField +from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.models.repository import Repository from sentry.organizations.services.organization.model import RpcOrganization from sentry.pipeline.types import PipelineStepResult from sentry.pipeline.views.base import ApiPipelineSteps from sentry.shared_integrations.exceptions import IntegrationError +from sentry.users.models.user import User from sentry.users.services.user import RpcUser from sentry.users.services.user.service import user_service -class ExampleSetupSerializer(CamelSnakeSerializer): +class ExampleSetupSerializer(CamelSnakeSerializer[dict[str, str]]): """Validates the POST data submitted during the setup step. CamelSnakeSerializer automatically converts between camelCase (frontend) and snake_case (backend).""" @@ -97,13 +102,15 @@ class ExampleIntegration(RepositoryIntegration, SourceCodeIssueIntegration, Issu def integration_name(self) -> str: return "example" - def get_client(self): - pass + def get_client(self) -> RepositoryClient: + return cast(RepositoryClient, None) - def get_issue_url(self, key) -> str: + def get_issue_url(self, key: str) -> str: return f"https://example/issues/{key}" - def create_comment(self, issue_id, user_id, group_note): + def create_comment( + self, issue_id: str, user_id: int, group_note: Activity + ) -> Mapping[str, str]: user = user_service.get_user(user_id) assert user is not None attribution = f"{user.name} wrote:\n\n" @@ -118,15 +125,20 @@ def get_persisted_default_config_fields(self) -> list[str]: def get_persisted_user_default_config_fields(self) -> list[str]: return ["assignedTo", "reportedBy"] - def get_create_issue_config(self, group, user, **kwargs): + def get_create_issue_config( + self, group: Group | None, user: User | RpcUser, **kwargs: object + ) -> list[IntegrationIssueConfigField]: kwargs["link_referrer"] = "example_integration" fields = super().get_create_issue_config(group, user, **kwargs) + assert group is not None default = self.get_project_defaults(group.project_id) example_project_field = self.generate_example_project_field(default) return fields + [example_project_field] - def generate_example_project_field(self, default_fields): - project_field = { + def generate_example_project_field( + self, default_fields: Mapping[str, object] + ) -> IntegrationIssueConfigField: + project_field: IntegrationIssueConfigField = { "name": "project", "label": "Project", "choices": [("1", "Project 1"), ("2", "Project 2")], @@ -139,13 +151,15 @@ def generate_example_project_field(self, default_fields): return project_field - def get_link_issue_config(self, group, **kwargs): + def get_link_issue_config( + self, group: Group, **kwargs: object + ) -> list[IntegrationIssueConfigField]: fields = super().get_link_issue_config(group, **kwargs) default = self.get_project_defaults(group.project_id) example_project_field = self.generate_example_project_field(default) return fields + [example_project_field] - def create_issue(self, data, **kwargs): + def create_issue(self, data: Mapping[str, object], **kwargs: object) -> Mapping[str, object]: if "assignee" not in data: raise IntegrationError("Assignee is required") return { @@ -154,7 +168,7 @@ def create_issue(self, data, **kwargs): "description": "This is a test external issue description", } - def get_issue(self, issue_id, **kwargs): + def get_issue(self, issue_id: str | int, **kwargs: object) -> Mapping[str, object]: return { "key": issue_id, "title": "This is a test external issue title", @@ -193,7 +207,7 @@ def get_resolve_sync_action(self, data: Mapping[str, Any]) -> ResolveSyncAction: should_unresolve=category != "done", ) - def get_issue_display_name(self, external_issue) -> str: + def get_issue_display_name(self, external_issue: ExternalIssue) -> str: return f"display name: {external_issue.key}" def get_stacktrace_link( @@ -216,7 +230,9 @@ def extract_source_path_from_source_url(self, repo: Repository, url: str) -> str def has_repo_access(self, repo: RpcRepository) -> bool: return False - def search_issues(self, query: str | None, **kwargs): + def search_issues( + self, query: str | None, **kwargs: object + ) -> list[dict[str, object]] | dict[str, object]: return [] @@ -242,7 +258,7 @@ class ExampleIntegrationProvider(IntegrationProvider): def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]: return [ExampleSetupApiStep()] - def get_config(self): + def get_config(self) -> list[IntegrationIssueConfigField]: return [{"name": "name", "label": "Name", "type": "text", "required": True}] def post_install( @@ -257,7 +273,7 @@ def post_install( def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: return {"external_id": state.get("name", "example")} - def setup(self): + def setup(self) -> None: """ Executed once Sentry has been initialized at runtime. diff --git a/src/sentry/integrations/example/repository.py b/src/sentry/integrations/example/repository.py index a2fb40f2e0aa..ac7567e92ae0 100644 --- a/src/sentry/integrations/example/repository.py +++ b/src/sentry/integrations/example/repository.py @@ -1,6 +1,7 @@ -from collections.abc import Mapping, MutableMapping +from collections.abc import Mapping, MutableMapping, Sequence from typing import Any +from sentry.integrations.services.repository.model import RpcRepository from sentry.organizations.services.organization.model import RpcOrganization from sentry.plugins.providers import IntegrationRepositoryProvider from sentry.plugins.providers.integration_repository import RepositoryConfig @@ -18,7 +19,9 @@ def get_repository_data( config["integration_id"] = installation.model.id return config - def compare_commits(self, repo, start_sha, end_sha): + def compare_commits( + self, repo: RpcRepository, start_sha: str | None, end_sha: str + ) -> Sequence[Mapping[str, object]]: return [ {"id": "62de626b7c7cfb8e77efb4273b1a3df4123e6216", "repository": repo.name}, {"id": "58de626b7c7cfb8e77efb4273b1a3df4123e6345", "repository": repo.name}, diff --git a/src/sentry/integrations/gcp/client.py b/src/sentry/integrations/gcp/client.py index ab62be06fbd0..49930fe88478 100644 --- a/src/sentry/integrations/gcp/client.py +++ b/src/sentry/integrations/gcp/client.py @@ -2,7 +2,7 @@ import logging import secrets -from typing import Any, TypedDict +from typing import Any, TypedDict, cast import google.auth import orjson @@ -165,7 +165,11 @@ def verify_gcp_connection( raise IntegrationError("Failed to verify GCP connection. Please try again.") try: - return orjson.loads(response.data) + result = orjson.loads(response.data) + if not isinstance(result, dict): + raise TypeError("GCP verify connection returned a non-object response") + # justification: the verify endpoint returns the GcpVerifyConnectionResult fields. + return cast("GcpVerifyConnectionResult", result) except orjson.JSONDecodeError: logger.error("gcp.verify_connection_invalid_response") raise IntegrationError("Failed to verify GCP connection. Please try again.") diff --git a/src/sentry/integrations/github/blame.py b/src/sentry/integrations/github/blame.py index abeb0bf9ecdd..6cfead7ea2b3 100644 --- a/src/sentry/integrations/github/blame.py +++ b/src/sentry/integrations/github/blame.py @@ -55,7 +55,7 @@ def is_graphql_response(response: object) -> TypeIs[GitHubGraphQlResponse]: return isinstance(response, MappingApiResponse) and ("data" in response or "errors" in response) -FilePathMapping = dict[str, dict[str, OrderedSet]] +FilePathMapping = dict[str, dict[str, "OrderedSet[str]"]] GitHubRepositoryResponse = dict[str, GitHubRefResponse] diff --git a/src/sentry/integrations/github/client.py b/src/sentry/integrations/github/client.py index ffa31a5342eb..359141be4fe4 100644 --- a/src/sentry/integrations/github/client.py +++ b/src/sentry/integrations/github/client.py @@ -3,11 +3,11 @@ import contextlib import logging import time -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence from datetime import datetime, timedelta from enum import StrEnum from functools import cached_property -from typing import Any, TypedDict +from typing import Any, TypedDict, cast import orjson import sentry_sdk @@ -63,6 +63,8 @@ ApiRateLimitedError, UnknownHostError, ) +from sentry.shared_integrations.response.base import BaseApiResponse +from sentry.shared_integrations.response.validation import dict_items from sentry.silo.base import control_silo_function from sentry.silo.util import PROXY_PATH, trim_leading_slashes from sentry.utils import metrics @@ -136,6 +138,15 @@ class CachedRepo(TypedDict): archived: bool | None +def _int_values(mapping: Mapping[str, Any], *, context: str) -> dict[str, int]: + values: dict[str, int] = {} + for key, value in mapping.items(): + if not isinstance(value, int): + raise ApiError(f"Expected an integer {context} for {key!r}") + values[key] = value + return values + + class GithubRateLimitInfo: def __init__(self, info: dict[str, int]) -> None: self.limit = info["limit"] @@ -212,6 +223,31 @@ class GitHubApiRequestType(StrEnum): UPDATE_ISSUE_STATUS = "update_issue_status" +class GithubInstallationAccount(TypedDict): + login: str + avatar_url: str + + +class GithubUserInstallation(TypedDict): + id: int + account: GithubInstallationAccount + target_type: str + + +class GithubUserInstallations(TypedDict): + installations: list[GithubUserInstallation] + + +class GithubUserOrganization(TypedDict): + login: str + + +class GithubUserOrganizationMembership(TypedDict): + organization: GithubUserOrganization + role: str + state: str + + class GithubSetupApiClient(IntegrationProxyClient): """ API Client that doesn't require an installation. @@ -243,7 +279,7 @@ def get_installation_info(self, installation_id: int | str) -> dict[str, Any]: Authentication: JWT Docs: https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-an-installation-for-the-authenticated-app """ - return self.get( + return self.get_mapping( f"/app/installations/{installation_id}", api_request_type=GitHubApiRequestType.GET_INSTALLATION_INFO, ) @@ -253,33 +289,42 @@ def get_user_info(self) -> dict[str, Any]: Authentication: Access Token Docs: https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user """ - return self.get("/user", api_request_type=GitHubApiRequestType.GET_USER_INFO) + return self.get_mapping("/user", api_request_type=GitHubApiRequestType.GET_USER_INFO) - def get_user_info_installations(self): + def get_user_info_installations(self) -> GithubUserInstallations: """ Authentication: Access Token Docs: https://docs.github.com/en/rest/apps/installations?apiVersion=2022-11-28#list-app-installations-accessible-to-the-user-access-token """ - return self.get( - "/user/installations", - api_request_type=GitHubApiRequestType.GET_USER_INFO_INSTALLATIONS, + # justification: GitHub's REST API defines this response shape. + return cast( + GithubUserInstallations, + self.get( + "/user/installations", + api_request_type=GitHubApiRequestType.GET_USER_INFO_INSTALLATIONS, + ), ) - def get_organization_memberships_for_user(self): + def get_organization_memberships_for_user(self) -> list[GithubUserOrganizationMembership]: """ Authentication: Access Token Docs: https://docs.github.com/en/rest/orgs/members?apiVersion=2022-11-28#get-an-organization-membership-for-the-authenticated-user """ - return self.get( - "/user/memberships/orgs", - api_request_type=GitHubApiRequestType.GET_ORGANIZATION_MEMBERSHIPS_FOR_USER, + # justification: GitHub's REST API defines this response shape. + return cast( + list[GithubUserOrganizationMembership], + self.get( + "/user/memberships/orgs", + api_request_type=GitHubApiRequestType.GET_ORGANIZATION_MEMBERSHIPS_FOR_USER, + ), ) class GithubProxyClient(IntegrationProxyClient): integration: Integration | RpcIntegration # late init - def request(self, *args: Any, **kwargs: Any): + # justification: proxy forwards arbitrary API request arguments and returns. + def request(self, *args: Any, **kwargs: Any) -> Any: credentials_set = kwargs.pop("credentials_set", None) if credentials_set == "application": if "headers" not in kwargs: @@ -337,7 +382,7 @@ def _refresh_access_token(self) -> AccessTokenData | None: "integration_id": self.integration.id, }, ) - data = self.post( + data = self.post_mapping( f"/app/installations/{self._get_installation_id()}/access_tokens", api_request_type=GitHubApiRequestType.REFRESH_ACCESS_TOKEN, ) @@ -512,7 +557,7 @@ def get_last_commits(self, repo: str, end_sha: str, per_page: int = 20) -> Seque see https://docs.github.com/en/rest/commits/commits#list-commits-on-a-repository using end_sha as parameter. """ - return self.get_cached( + return self.get_cached_sequence( f"/repos/{repo}/commits", params={"sha": end_sha, "per_page": per_page}, api_request_type=GitHubApiRequestType.GET_COMMITS, @@ -533,13 +578,17 @@ def repo_hooks(self, repo: str) -> Sequence[Any]: """ https://docs.github.com/en/rest/webhooks/repos#list-repository-webhooks """ - return self.get(f"/repos/{repo}/hooks", api_request_type=GitHubApiRequestType.REPO_HOOKS) + return self.get_sequence( + f"/repos/{repo}/hooks", api_request_type=GitHubApiRequestType.REPO_HOOKS + ) def get_commits(self, repo: str) -> Sequence[Any]: """ https://docs.github.com/en/rest/commits/commits#list-commits """ - return self.get(f"/repos/{repo}/commits", api_request_type=GitHubApiRequestType.GET_COMMITS) + return self.get_sequence( + f"/repos/{repo}/commits", api_request_type=GitHubApiRequestType.GET_COMMITS + ) def get_commit(self, repo: str, sha: str) -> Any: """ @@ -575,15 +624,16 @@ def get_merge_commit_sha_from_commit(self, repo: Repository, sha: str) -> str | ) return None - return pull_request.get("merge_commit_sha") + merge_commit_sha = pull_request.get("merge_commit_sha") + return merge_commit_sha if isinstance(merge_commit_sha, str) else None - def get_pull_request_from_commit(self, repo: str, sha: str) -> Any: + def get_pull_request_from_commit(self, repo: str, sha: str) -> Sequence[Mapping[str, Any]]: """ https://docs.github.com/en/rest/commits/commits#list-pull-requests-associated-with-a-commit Returns the merged pull request that introduced the commit to the repository. If the commit is not present in the default branch, will only return open pull requests associated with the commit. """ - return self.get( + return self.get_sequence( f"/repos/{repo}/commits/{sha}/pulls", api_request_type=GitHubApiRequestType.GET_PULL_REQUEST_FROM_COMMIT, ) @@ -643,9 +693,12 @@ def get_languages(self, repo: str, tree: list[dict[str, Any]] | None = None) -> :returns: {"Python": 50000, "JavaScript": 30000, ...} Keys are GitHub Linguist names, values are bytes of code. """ - return self.get( - f"/repos/{repo}/languages", - api_request_type=GitHubApiRequestType.GET_LANGUAGES, + return _int_values( + self.get_mapping( + f"/repos/{repo}/languages", + api_request_type=GitHubApiRequestType.GET_LANGUAGES, + ), + context="language byte count", ) # https://docs.github.com/en/rest/rate-limit?apiVersion=2022-11-28 @@ -659,11 +712,16 @@ def get_rate_limit(self, specific_resource: str = "core") -> GithubRateLimitInfo integration_id=self.integration.id, organization_id=self.organization_id, ).capture(): - return GithubRateLimitInfo( - self.get("/rate_limit", api_request_type=GitHubApiRequestType.GET_RATE_LIMIT)[ - "resources" - ][specific_resource] + response = self.get_mapping( + "/rate_limit", api_request_type=GitHubApiRequestType.GET_RATE_LIMIT ) + resources = response["resources"] + if not isinstance(resources, Mapping): + raise ApiError("Expected GitHub rate limit resources") + resource = resources[specific_resource] + if not isinstance(resource, Mapping): + raise ApiError(f"Expected GitHub rate limit resource {specific_resource!r}") + return GithubRateLimitInfo(_int_values(resource, context="rate limit")) # This method is used by RepoTreesIntegration def get_remaining_api_requests(self) -> int: @@ -681,7 +739,7 @@ def get_tree(self, repo_full_name: str, tree_sha: str) -> list[dict[str, Any]]: ).capture() as lifecycle: try: # We do not cache this call since it is a rather large object - contents: dict[str, Any] = self.get( + contents = self.get_mapping( f"/repos/{repo_full_name}/git/trees/{tree_sha}", # Will cause all objects or subtrees referenced by the tree specified in :tree_sha params={"recursive": 1}, @@ -701,7 +759,7 @@ def get_tree(self, repo_full_name: str, tree_sha: str) -> list[dict[str, Any]]: "The tree for %s has been truncated. Use different a approach for retrieving contents of tree.", repo_full_name, ) - return contents["tree"] + return dict_items(contents["tree"], context="tree entry") # Used by RepoTreesIntegration def should_count_api_error(self, error: ApiError, extra: dict[str, str]) -> bool: @@ -795,8 +853,9 @@ def get_repos_cached(self, ttl: int = 300, parallel: bool = False) -> list[Cache """ cache_key = f"github:repos:{self.integration.id}" cached = cache.get(cache_key) - if cached is not None: - return cached + if isinstance(cached, list) and all(isinstance(repo, dict) for repo in cached): + # justification: the cache round-trips the CachedRepo dicts written below. + return cast("list[CachedRepo]", cached) all_repos = self.get_repos(parallel=parallel) repos: list[CachedRepo] = [ @@ -819,7 +878,7 @@ def search_repositories(self, query: bytes) -> Mapping[str, Sequence[Any]]: NOTE: All search APIs (except code search) share a rate limit of 30 requests/minute """ - return self.get( + return self.get_mapping( "/search/repositories", params={"q": query}, api_request_type=GitHubApiRequestType.SEARCH_REPOSITORIES, @@ -850,7 +909,7 @@ def _search_issue_field( api_request_type: GitHubApiRequestType, ) -> list[Any]: owner, repo_name = repo.split("/", 1) - response = self.post( + response = self.post_mapping( path="/graphql", data={ "query": graphql_query, @@ -867,7 +926,7 @@ def _search_issue_field( raise ApiRateLimitedError("GitHub rate limit exceeded") nodes = get_path(response, "data", "repository", "results", "nodes") - if nodes is None: + if not isinstance(nodes, list): message = "\n".join(error.get("message", "") for error in errors).strip() raise ApiError(message or "Invalid GitHub GraphQL response") @@ -908,8 +967,16 @@ def _get_with_pagination( if page_number_limit is None or page_number_limit > self.page_number_limit: page_number_limit = self.page_number_limit - def page_items(resp: Any) -> Any: - return resp[response_key] if response_key else resp + def page_items(response: object) -> Sequence[Any]: + if response_key: + if not isinstance(response, Mapping): + raise ApiError("Expected a mapping response for paginated results") + items = response[response_key] + else: + items = response + if not isinstance(items, (list, tuple)): + raise ApiError("Expected a list response for paginated results") + return items with traces.start_span( name=f"{self.integration_type}.http_response.pagination.{self.name}", @@ -939,7 +1006,7 @@ def page_items(resp: Any) -> Any: # must forward it too -- it hardcodes only per_page and page, so # params supplied any way other than via ``path`` would be # dropped from pages 2..N. - def fetch_page(page_number: int) -> Any: + def fetch_page(page_number: int) -> BaseApiResponse: return self.get( path, params={"per_page": self.page_size, "page": page_number}, @@ -1021,7 +1088,7 @@ def search_issues(self, query: str) -> Mapping[str, Sequence[Mapping[str, Any]]] NOTE: All search APIs (except code search) share a rate limit of 30 requests/minute """ - return self.get( + return self.get_mapping( "/search/issues", params={"q": query}, api_request_type=GitHubApiRequestType.SEARCH_ISSUES, @@ -1135,7 +1202,7 @@ def update_pr_comment( pr_comment: PullRequestComment, data: dict[str, Any], ) -> Any: - return self.update_comment(repo.name, pr.key, pr_comment.external_id, data) + return self.update_comment(repo.name, pr.key, str(pr_comment.external_id), data) def get_comment_reactions(self, repo: str, comment_id: str) -> list[Any]: """ @@ -1187,7 +1254,7 @@ def get_contents(self, repo: str, path: str, ref: str | None = None) -> Any: """ return self.get(f"/repos/{repo}/contents/{path}", params={"ref": ref} if ref else {}) - def check_file(self, repo: Repository, path: str, version: str | None) -> object | None: + def check_file(self, repo: Repository, path: str, version: str | None) -> BaseApiResponse: return self.head_cached( path=f"/repos/{repo.name}/contents/{path}", params={"ref": version}, @@ -1204,19 +1271,24 @@ def get_file( from base64 import b64decode headers = {"Content-Type": "application/raw; charset=utf-8"} if codeowners else {} - contents = self.get( + if codeowners: + raw_contents = self.get( + path=f"/repos/{repo.name}/contents/{path}", + params={"ref": ref}, + raw_response=True, + headers=headers, + api_request_type=GitHubApiRequestType.GET_FILE, + ) + if not raw_contents.ok: + raise ApiError.from_response(raw_contents) + return raw_contents.content.decode("utf-8") + + contents = self.get_mapping( path=f"/repos/{repo.name}/contents/{path}", params={"ref": ref}, - raw_response=True if codeowners else False, headers=headers, api_request_type=GitHubApiRequestType.GET_FILE, ) - - if codeowners: - if not contents.ok: - raise ApiError.from_response(contents) - return contents.content.decode("utf-8") - return b64decode(contents["content"]).decode("utf-8") def get_blame_for_files( @@ -1369,7 +1441,7 @@ def _fetch_pull_request_status_batch( self, pull_requests: Sequence[PullRequestStatusRequest] ) -> dict[PullRequestStatusRequest, PullRequestStatusResult]: data = create_pull_request_status_query(pull_requests) - response = self.post( + response = self.post_mapping( path="/graphql", data=data, allow_text=False, @@ -1550,7 +1622,7 @@ def __init__( ) @contextlib.contextmanager - def referrer(self, referrer: str): + def referrer(self, referrer: str) -> Iterator[None]: prev = self.__referrer self.__referrer = referrer try: diff --git a/src/sentry/integrations/github/installation.py b/src/sentry/integrations/github/installation.py index 964a6b5d0d5a..a297e02dbe5b 100644 --- a/src/sentry/integrations/github/installation.py +++ b/src/sentry/integrations/github/installation.py @@ -30,7 +30,7 @@ class GitHubIntegrationsInstallationEndpoint(Endpoint): permission_classes = (SentryIsAuthenticated,) - def get(self, request: Request, installation_id): + def get(self, request: Request, installation_id: str) -> HttpResponse: try: integration = Integration.objects.get( provider=IntegrationProviderSlug.GITHUB.value, diff --git a/src/sentry/integrations/github/integration.py b/src/sentry/integrations/github/integration.py index 379bc755095e..995f6a474e78 100644 --- a/src/sentry/integrations/github/integration.py +++ b/src/sentry/integrations/github/integration.py @@ -46,13 +46,12 @@ ) from sentry.integrations.source_code_management.repo_trees import RepoTreesIntegration from sentry.integrations.source_code_management.repository import ( - HaltReason, RepositoryInfo, RepositoryIntegration, ) from sentry.integrations.source_code_management.url import url_is_under from sentry.integrations.tasks.migrate_repo import migrate_repo -from sentry.integrations.types import IntegrationProviderSlug +from sentry.integrations.types import HaltReason, IntegrationProviderSlug from sentry.integrations.utils.metrics import ( IntegrationPipelineViewEvent, IntegrationPipelineViewType, @@ -371,14 +370,15 @@ def has_repo_access(self, repo: RpcRepository) -> bool: return False return True - def search_issues(self, query: str | None, **kwargs) -> dict[str, Any]: + # justification: framework kwargs + def search_issues(self, query: str | None, **kwargs: Any) -> dict[str, Any]: if query is None: query = "" resp = self.get_client().search_issues(query) assert isinstance(resp, dict) return resp - def get_account_id(self): + def get_account_id(self) -> str | int: installation_metadata = self.model.metadata github_account_id = installation_metadata.get("account_id") @@ -391,6 +391,8 @@ def get_account_id(self): ) github_account_id = updated_installation_info["account"]["id"] + if not isinstance(github_account_id, (str, int)): + raise ValueError("GitHub installation info has no account id") installation_metadata["account_id"] = github_account_id integration_service.update_integration( integration_id=self.model.id, metadata=installation_metadata @@ -418,7 +420,7 @@ def on_create_or_update_comment_error(self, api_error: ApiError, metrics_base: s return False - def get_config_data(self): + def get_config_data(self) -> dict[str, Any]: # justification: decoded GitHub JSON config = self.org_integration.config project_mappings = IntegrationExternalProject.objects.filter( organization_integration_id=self.org_integration.id @@ -837,7 +839,7 @@ def get_install_app_url() -> str: return f"https://github.com/apps/{slugify(name)}" -def record_event(event: IntegrationPipelineViewType): +def record_event(event: IntegrationPipelineViewType) -> IntegrationPipelineViewEvent: return IntegrationPipelineViewEvent( event, IntegrationDomain.SOURCE_CODE_MANAGEMENT, GitHubIntegrationProvider.key ) @@ -1027,7 +1029,7 @@ def validate_github_installation( pipeline.bind_state("installation_id", chosen_installation_id) installation_id = pipeline.fetch_state("installation_id") - if installation_id is None: + if not isinstance(installation_id, str): return None pending_deletion = OrganizationIntegration.objects.filter( @@ -1063,7 +1065,11 @@ def validate_github_installation( return installation_id -class InitialDataSerializer(CamelSnakeSerializer): +class InitialDataValidatedData(TypedDict): + installation_id: NotRequired[str] + + +class InitialDataSerializer(CamelSnakeSerializer[InitialDataValidatedData]): """Validates initial data for provider-initiated GitHub installs. When a user installs the GitHub App directly from GitHub, the redirect @@ -1084,7 +1090,7 @@ class OAuthLoginValidatedData(TypedDict): installation_id: NotRequired[str] -class OAuthLoginSerializer(CamelSnakeSerializer): +class OAuthLoginSerializer(CamelSnakeSerializer[OAuthLoginValidatedData]): code = CharField(required=True) state = CharField(required=True) # GitHub includes installation_id in the OAuth callback URL when the user @@ -1167,7 +1173,7 @@ class OrgSelectionValidatedData(TypedDict): installation_id: NotRequired[str] -class OrgSelectionSerializer(CamelSnakeSerializer): +class OrgSelectionSerializer(CamelSnakeSerializer[OrgSelectionValidatedData]): chosen_installation_id = CharField(required=False, allow_blank=True) installation_id = CharField(required=False) diff --git a/src/sentry/integrations/github/issue_sync.py b/src/sentry/integrations/github/issue_sync.py index 52f2ec206a39..d0ffce709986 100644 --- a/src/sentry/integrations/github/issue_sync.py +++ b/src/sentry/integrations/github/issue_sync.py @@ -2,13 +2,14 @@ import logging from collections.abc import Mapping -from typing import Any +from typing import Any, cast from sentry.integrations.mixins.issues import IssueSyncIntegration, ResolveSyncAction from sentry.integrations.models.external_actor import ExternalActor from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.services.integration import integration_service from sentry.integrations.types import EXTERNAL_PROVIDERS_REVERSE, ExternalProviderEnum +from sentry.models.activity import Activity from sentry.shared_integrations.exceptions import ApiError from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service @@ -201,7 +202,7 @@ def get_resolve_sync_action(self, data: Mapping[str, Any]) -> ResolveSyncAction: return ResolveSyncAction.UNRESOLVE return ResolveSyncAction.NOOP - def create_comment_attribution(self, user_id, comment_text): + def create_comment_attribution(self, user_id: int, comment_text: str) -> str: user = user_service.get_user(user_id) username = "Unknown User" if user is None else user.name @@ -210,20 +211,32 @@ def create_comment_attribution(self, user_id, comment_text): quoted_text = "\n".join(f"> {line}" for line in comment_text.split("\n")) return f"{attribution}{quoted_text}" - def update_comment(self, issue_id, user_id, group_note): + def update_comment( + self, issue_id: str, user_id: int, group_note: Activity + ) -> dict[str, object]: quoted_comment = self.create_comment_attribution(user_id, group_note.data["text"]) repo, issue_number = issue_id.rsplit("#", 1) - return self.get_client().update_comment( - repo, issue_number, group_note.data["external_id"], {"body": quoted_comment} + # justification: the GitHub client returns the updated comment payload. + return cast( + "dict[str, object]", + self.get_client().update_comment( + repo, issue_number, group_note.data["external_id"], {"body": quoted_comment} + ), ) - def create_comment(self, issue_id, user_id, group_note): + def create_comment( + self, issue_id: str, user_id: int, group_note: Activity + ) -> dict[str, object]: # GitHub uses markdown syntax directly without needing special formatting comment = group_note.data["text"] quoted_comment = self.create_comment_attribution(user_id, comment) repo, issue_number = issue_id.rsplit("#", 1) - return self.get_client().create_comment(repo, issue_number, {"body": quoted_comment}) + # justification: the GitHub client returns the created comment payload. + return cast( + "dict[str, object]", + self.get_client().create_comment(repo, issue_number, {"body": quoted_comment}), + ) diff --git a/src/sentry/integrations/github/issues.py b/src/sentry/integrations/github/issues.py index 3aa1894b323e..d00af342e5d3 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 @@ -18,7 +18,9 @@ from sentry.issues.grouptype import GroupCategory from sentry.issues.issue_occurrence import IssueOccurrence from sentry.models.group import Group +from sentry.models.organization import Organization from sentry.models.repository import Repository +from sentry.organizations.services.organization.model import RpcOrganization from sentry.organizations.services.organization.service import organization_service from sentry.services.eventstore.models import Event, GroupEvent from sentry.shared_integrations.exceptions import ( @@ -116,7 +118,9 @@ def get_generic_issue_body(self, occurrence: IssueOccurrence) -> str: return body[:-2] - def get_group_description(self, group: Group, event: Event | GroupEvent, **kwargs: Any) -> str: + def get_group_description( + self, group: Group, event: Event | GroupEvent | None, **kwargs: Any + ) -> str: output = self.get_group_link(group, **kwargs) if isinstance(event, GroupEvent) and event.occurrence is not None: @@ -158,7 +162,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 @@ -174,6 +178,7 @@ def get_create_issue_config( """ kwargs["link_referrer"] = "github_integration" + org: Organization | RpcOrganization if group: fields = super().get_create_issue_config(group, user, **kwargs) org = group.organization @@ -185,7 +190,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 {} @@ -293,7 +299,9 @@ def create_issue(self, data: Mapping[str, Any], **kwargs: Any) -> Mapping[str, A "repo": repo, } - def get_link_issue_config(self, group: Group, **kwargs: Any) -> list[dict[str, Any]]: + def get_link_issue_config( + self, group: Group, **kwargs: Any + ) -> list[IntegrationIssueConfigField]: params = kwargs.pop("params", {}) default_repo, repo_choices = self.get_repository_choices(group, params, PAGE_LIMIT) @@ -374,7 +382,7 @@ def get_issue_link_data(self, url: str) -> dict[str, str]: raise IntegrationFormError({"repo": "Repository does not belong to this installation"}) return {"repo": repo.name, "externalIssue": match[3]} - def get_issue(self, issue_id: str, **kwargs: Any) -> Mapping[str, Any]: + def get_issue(self, issue_id: str | int, **kwargs: Any) -> Mapping[str, Any]: data = kwargs["data"] repo = data.get("repo") issue_num = data.get("externalIssue") diff --git a/src/sentry/integrations/github/search.py b/src/sentry/integrations/github/search.py index 05f672f9edfa..7e607a407c7b 100644 --- a/src/sentry/integrations/github/search.py +++ b/src/sentry/integrations/github/search.py @@ -1,4 +1,4 @@ -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from rest_framework.response import Response @@ -19,9 +19,14 @@ PAGE_LIMIT = 1 +if TYPE_CHECKING: + _SearchEndpointBase = SourceCodeSearchEndpoint[GitHubIntegration | GitHubEnterpriseIntegration] +else: + _SearchEndpointBase = SourceCodeSearchEndpoint + @control_silo_endpoint -class GithubSharedSearchEndpoint(SourceCodeSearchEndpoint): +class GithubSharedSearchEndpoint(_SearchEndpointBase): """NOTE: This endpoint is a shared search endpoint for Github and Github Enterprise integrations.""" @property @@ -29,11 +34,15 @@ def repository_field(self) -> str: return "repo" @property - def integration_provider(self): + def integration_provider(self) -> str | None: return None @property - def installation_class(self): + def installation_class( + self, + ) -> ( + type[GitHubIntegration] | tuple[type[GitHubIntegration], type[GitHubEnterpriseIntegration]] + ): return (GitHubIntegration, GitHubEnterpriseIntegration) def handle_search_issues(self, installation: T, query: str, repo: str | None) -> Response: diff --git a/src/sentry/integrations/github/tasks/link_all_repos.py b/src/sentry/integrations/github/tasks/link_all_repos.py index f0d7eb038ad5..d5ebe4cf5909 100644 --- a/src/sentry/integrations/github/tasks/link_all_repos.py +++ b/src/sentry/integrations/github/tasks/link_all_repos.py @@ -48,7 +48,7 @@ def link_all_repos( integration_key: str, integration_id: int, organization_id: int, -): +) -> None: with SCMIntegrationInteractionEvent( interaction_type=SCMIntegrationInteractionType.LINK_ALL_REPOS, integration_id=integration_id, diff --git a/src/sentry/integrations/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/github/utils.py b/src/sentry/integrations/github/utils.py index c0760c43bce7..8f7369a14ce0 100644 --- a/src/sentry/integrations/github/utils.py +++ b/src/sentry/integrations/github/utils.py @@ -3,13 +3,19 @@ import calendar import datetime import time +from collections.abc import Mapping +from typing import Protocol from urllib.parse import parse_qs, urlparse -from rest_framework.response import Response - from sentry import options from sentry.utils import jwt + +class HeaderResponse(Protocol): + @property + def headers(self) -> Mapping[str, str] | None: ... + + # Copilot acts as a bot but has no ``[bot]`` suffix or ``Bot`` user type. GITHUB_COPILOT_LOGIN = "Copilot" @@ -45,14 +51,14 @@ def get_jwt(github_id: str | None = None, github_private_key: str | None = None) return jwt.encode(payload, github_private_key, algorithm="RS256") -def get_next_link(response: Response) -> str | None: +def get_next_link(response: HeaderResponse) -> str | None: """Github uses a `link` header to inform pagination. The relation parameter can be prev, next, first or last Read more here: https://docs.github.com/en/rest/guides/using-pagination-in-the-rest-api?apiVersion=2022-11-28#using-link-headers """ - link_option: str | None = response.headers.get("link") + link_option = (response.headers or {}).get("link") if link_option is None: return None @@ -69,7 +75,7 @@ def get_next_link(response: Response) -> str | None: return None -def get_last_page_number(response: Response) -> int | None: +def get_last_page_number(response: HeaderResponse) -> int | None: """Return the page number advertised by Github's `rel="last"` link. For offset-paginated endpoints Github includes a `rel="last"` relation in @@ -81,7 +87,7 @@ def get_last_page_number(response: Response) -> int | None: advertise a last page (for example, cursor-based pagination), in which case the caller should fall back to following the `next` links serially. """ - link_option: str | None = response.headers.get("link") + link_option = (response.headers or {}).get("link") if link_option is None: return None diff --git a/src/sentry/integrations/github/webhook.py b/src/sentry/integrations/github/webhook.py index cdd1873665be..8a23fa8fdc4c 100644 --- a/src/sentry/integrations/github/webhook.py +++ b/src/sentry/integrations/github/webhook.py @@ -9,7 +9,7 @@ from collections.abc import Mapping, MutableMapping, Sequence from contextlib import nullcontext from datetime import timezone -from typing import Any, Protocol +from typing import Any, Protocol, cast import orjson import sentry_sdk @@ -413,7 +413,8 @@ def get_external_id(self, username: str) -> str: return f"github:{username}" def get_idp_external_id(self, integration: RpcIntegration, host: str | None = None) -> str: - return options.get("github-app.id") + # justification: the registered option carries the GitHub App id. + return cast("str", options.get("github-app.id")) def maybe_create_external_actor( self, @@ -1436,7 +1437,8 @@ def get_logging_data(self) -> dict[str, Any] | None: } def get_secret(self) -> str | None: - return options.get("github-app.webhook-secret") + secret = options.get("github-app.webhook-secret") + return secret if isinstance(secret, str) else None def post(self, request: HttpRequest) -> HttpResponse: with action_context_scope(ActionSource.GITHUB, resolve_action_actor(request)): diff --git a/src/sentry/integrations/github_enterprise/client.py b/src/sentry/integrations/github_enterprise/client.py index 7b11b1784f11..75273747c568 100644 --- a/src/sentry/integrations/github_enterprise/client.py +++ b/src/sentry/integrations/github_enterprise/client.py @@ -1,12 +1,28 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + from sentry.integrations.github.client import GitHubBaseClient from sentry.integrations.github.utils import get_jwt from sentry.integrations.types import IntegrationProviderSlug +if TYPE_CHECKING: + from sentry.integrations.models.integration import Integration + from sentry.integrations.services.integration.model import RpcIntegration + class GitHubEnterpriseApiClient(GitHubBaseClient): integration_name = IntegrationProviderSlug.GITHUB_ENTERPRISE.value - def __init__(self, base_url, integration, app_id, private_key, verify_ssl, org_integration_id): + def __init__( + self, + base_url: str, + integration: Integration | RpcIntegration, + app_id: str, + private_key: str, + verify_ssl: bool, + org_integration_id: int | None, + ) -> None: self._is_cloud_style: bool = base_url == "github.com" or base_url.endswith(".ghe.com") if self._is_cloud_style: self.base_url = f"https://api.{base_url}" @@ -28,7 +44,8 @@ def build_url(self, path: str) -> str: return super().build_url(path) def _get_installation_id(self) -> str: - return self.integration.metadata["installation_id"] + # justification: the installation id is stored as a string or integer and only formatted into URLs. + return cast("str", self.integration.metadata["installation_id"]) - def _get_jwt(self): + def _get_jwt(self) -> str: return get_jwt(github_id=self.app_id, github_private_key=self.private_key) diff --git a/src/sentry/integrations/github_enterprise/integration.py b/src/sentry/integrations/github_enterprise/integration.py index 69481eca614e..7d5005b71d08 100644 --- a/src/sentry/integrations/github_enterprise/integration.py +++ b/src/sentry/integrations/github_enterprise/integration.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping, MutableMapping -from typing import Any +from typing import Any, cast from urllib.parse import urlparse from django.http.request import HttpRequest @@ -38,12 +38,11 @@ from sentry.integrations.services.repository import RpcRepository from sentry.integrations.source_code_management.commit_context import CommitContextIntegration from sentry.integrations.source_code_management.repository import ( - HaltReason, RepositoryInfo, RepositoryIntegration, ) from sentry.integrations.source_code_management.url import url_is_under -from sentry.integrations.types import IntegrationProviderSlug +from sentry.integrations.types import HaltReason, IntegrationProviderSlug from sentry.models.repository import Repository from sentry.organizations.services.organization import organization_service from sentry.organizations.services.organization.model import RpcOrganization @@ -69,7 +68,7 @@ def _api_base_url(url: str) -> str: return f"https://{url}/api/v3" -def get_user_info(url, access_token): +def get_user_info(url: str, access_token: str) -> Any: with http.build_session() as session: resp = session.get( f"{_api_base_url(url)}/user", @@ -203,7 +202,7 @@ def integration_name(self) -> str: def integration_id(self) -> int: return self.model.id - def get_client(self): + def get_client(self) -> GitHubEnterpriseApiClient: if not self.org_integration: raise IntegrationError("Organization Integration does not exist") @@ -317,8 +316,8 @@ def extract_source_path_from_source_url(self, repo: Repository, url: str) -> str _, source_path = parse_github_blob_url(repo.url, url) return source_path - def search_issues(self, query: str | None, **kwargs): - return self.get_client().search_issues(query) + def search_issues(self, query: str | None, **kwargs: Any) -> Any: + return self.get_client().search_issues(cast("str", query)) def has_repo_access(self, repo: RpcRepository) -> bool: # TODO: define this, used to migrate repositories @@ -347,7 +346,7 @@ def on_create_or_update_comment_error(self, api_error: ApiError, metrics_base: s def _get_debug_metadata_keys(self) -> list[str]: return ["domain_name", "installation_id", "account_type"] - def get_config_data(self): + def get_config_data(self) -> dict[str, Any]: config = self.org_integration.config project_mappings = IntegrationExternalProject.objects.filter( organization_integration_id=self.org_integration.id @@ -545,7 +544,7 @@ def update_organization_config(self, data: MutableMapping[str, Any]) -> None: self.org_integration = org_integration -class GHEInstallationConfigSerializer(CamelSnakeSerializer): +class GHEInstallationConfigSerializer(CamelSnakeSerializer[dict[str, Any]]): url = URLField(required=True) id = CharField(required=True) name = CharField(required=True) @@ -595,13 +594,14 @@ def handle_post( return PipelineStepResult.advance() -class GHEAppInstallSerializer(CamelSnakeSerializer): +class GHEAppInstallSerializer(CamelSnakeSerializer[dict[str, Any]]): installation_id = CharField(required=False, allow_blank=True, default="") def _get_app_install_url(installation_data: Mapping[str, Any]) -> str: - if installation_data.get("public_link"): - return installation_data["public_link"] + public_link = installation_data.get("public_link") + if isinstance(public_link, str) and public_link: + return public_link url = installation_data.get("url") name = installation_data.get("name") @@ -687,7 +687,12 @@ def post_install( ) -> None: pass - def _get_ghe_installation_info(self, installation_data, access_token, installation_id): + def _get_ghe_installation_info( + self, + installation_data: Mapping[str, Any], + access_token: str, + installation_id: str, + ) -> dict[str, Any] | None: headers = {"Accept": GITHUB_API_ACCEPT_HEADER} headers.update( jwt.authorization_header( @@ -721,6 +726,8 @@ def _get_ghe_installation_info(self, installation_data, access_token, installati # verify that user actually has access to the installation for installation in user_installations_resp["installations"]: if installation["id"] == installation_resp["id"]: + if not isinstance(installation_resp, dict): + raise TypeError("GitHub Enterprise installation is not an object") return installation_resp return None @@ -786,7 +793,7 @@ def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: "idp_config": state["oauth_config_information"], } - def setup(self): + def setup(self) -> None: from sentry.plugins.base import bindings bindings.add( diff --git a/src/sentry/integrations/github_enterprise/repository.py b/src/sentry/integrations/github_enterprise/repository.py index 010eae12ba21..0f00a83564de 100644 --- a/src/sentry/integrations/github_enterprise/repository.py +++ b/src/sentry/integrations/github_enterprise/repository.py @@ -1,6 +1,8 @@ from collections.abc import Mapping -from typing import Any +from typing import Any, cast +from sentry.integrations.base import IntegrationInstallation +from sentry.integrations.github.client import GitHubBaseClient from sentry.integrations.github.repository import GitHubRepositoryProvider from sentry.integrations.services.integration import integration_service from sentry.integrations.types import IntegrationProviderSlug @@ -18,7 +20,9 @@ class GitHubEnterpriseRepositoryProvider(GitHubRepositoryProvider): # github enterprise has non-unique repository IDs so we'll have to handle transfers differently can_transfer_repositories = False - def _validate_repo(self, client, installation, repo): + def _validate_repo( + self, client: GitHubBaseClient, installation: IntegrationInstallation, repo: str + ) -> Mapping[str, object]: try: repo_data = client.get_repo(repo) except Exception as e: @@ -30,7 +34,8 @@ def _validate_repo(self, client, installation, repo): except ApiError: raise IntegrationError(f"You must grant Sentry access to {repo}") - return repo_data + # justification: the GitHub client returns the repository payload. + return cast("Mapping[str, object]", repo_data) def build_repository_config( self, organization: RpcOrganization, data: Mapping[str, Any] diff --git a/src/sentry/integrations/github_enterprise/webhook.py b/src/sentry/integrations/github_enterprise/webhook.py index 5ee3287981c7..a7b37157dea3 100644 --- a/src/sentry/integrations/github_enterprise/webhook.py +++ b/src/sentry/integrations/github_enterprise/webhook.py @@ -5,6 +5,8 @@ import logging import re import time +from collections.abc import Mapping +from typing import cast import orjson import sentry_sdk @@ -96,9 +98,11 @@ def get_host(request: HttpRequest) -> str | None: return None -def get_installation_metadata(event, host): +def get_installation_metadata( + event: Mapping[str, object], host: str | None +) -> Mapping[str, object] | None: if not host: - return + return None external_id = get_github_external_id(event=event, host=host) integration = integration_service.get_integration( external_id=external_id, @@ -108,8 +112,9 @@ def get_installation_metadata(event, host): ) if integration is None: metrics.incr("integrations.github_enterprise.does_not_exist") - return - return integration.metadata["installation"] + return None + installation = integration.metadata["installation"] + return installation if isinstance(installation, Mapping) else None class GitHubEnterpriseWebhook: @@ -209,10 +214,10 @@ def _get_host(self, request: HttpRequest) -> str | None: return get_host(request) # https://developer.github.com/webhooks/ - def get_handler(self, event_type): + def get_handler(self, event_type: str) -> type[GitHubWebhook] | None: return self._handlers.get(event_type) - def is_valid_signature(self, method, body, secret, signature): + def is_valid_signature(self, method: str, body: bytes, secret: str, signature: str) -> bool: if method != "sha1" and method != "sha256": raise UnsupportedSignatureAlgorithmError() @@ -228,16 +233,17 @@ def is_valid_signature(self, method, body, secret, signature): return constant_time_compare(expected, signature) @method_decorator(csrf_exempt) - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: if request.method != "POST": return HttpResponse(status=405) return super().dispatch(request, *args, **kwargs) - def get_secret(self, event, host): + def get_secret(self, event: Mapping[str, object], host: str | None) -> str | None: metadata = get_installation_metadata(event, host) if metadata: - return metadata.get("webhook_secret") + # justification: integration metadata is decoded JSON. + return cast("str | None", metadata.get("webhook_secret")) else: return None @@ -446,7 +452,7 @@ class GitHubEnterpriseWebhookEndpoint(GitHubEnterpriseWebhookBase): } @method_decorator(csrf_exempt) - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: if request.method != "POST": return HttpResponse(status=405) @@ -464,7 +470,7 @@ def _get_host(self, request: HttpRequest) -> str: return "github.com" @method_decorator(csrf_exempt) - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: if request.method != "POST": return HttpResponse(status=405) return super().dispatch(request, *args, **kwargs) diff --git a/src/sentry/integrations/gitlab/client.py b/src/sentry/integrations/gitlab/client.py index 28f79991cc52..72febd77d08d 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,8 @@ 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.shared_integrations.response.base import BaseApiResponse from sentry.silo.base import SiloMode, control_silo_function from sentry.users.models.identity import Identity from sentry.utils import metrics @@ -67,6 +69,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 +91,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 +107,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 +115,12 @@ def get_group(self, group): """ group = quote(group, safe="") path = GitLabApiClientPath.group.format(group=group) - return self.get(path) + response: MappingApiResponse = self.get_mapping(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 +139,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 +241,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) -> BaseApiResponse: 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 = 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) -> BaseApiResponse: try: response = super().request(*args, **kwargs) except ApiUnauthorized: @@ -262,7 +273,7 @@ def _issue_request_with_auto_token_refresh(self, *args: Any, **kwargs: Any): return response - def _attempt_request_after_refreshing_token(self, *args: Any, **kwargs: Any): + def _attempt_request_after_refreshing_token(self, *args: Any, **kwargs: Any) -> BaseApiResponse: assert not self.is_refreshing_token, "A token refresh is already occurring" self.is_refreshing_token = True try: @@ -277,31 +288,37 @@ 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_mapping(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_sequence( + GitLabApiClientPath.users, params={"username": username} + ) + return response - def search_projects(self, group=None, query=None, simple=True): + def search_projects( + self, group: str | None = None, query: str | None = None, simple: bool = True + ) -> list[Mapping[str, Any]]: """Get projects See https://docs.gitlab.com/ee/api/groups.html#list-a-group-s-projects and https://docs.gitlab.com/ee/api/projects.html#list-all-projects """ - def gen_params(page_number, page_size): + def gen_params(page_number: int, page_size: int) -> Mapping[str, str | int | bool]: # Simple param returns limited fields for the project. # Really useful, because we often don't need most of the project information - params = { + params: dict[str, str | int | bool | None] = { "search": query, "simple": simple, "order_by": "last_activity_at", @@ -314,10 +331,13 @@ def gen_params(page_number, page_size): extra_params = {"membership": True} params.update(extra_params) - return params + return cast( + "Mapping[str, str | int | bool]", params + ) # justification: requests drops None query values. - def get_results(resp): - return resp + def get_results(resp: object) -> Sequence[Mapping[str, Any]]: + # justification: API pages are decoded JSON. + return cast("Sequence[Mapping[str, Any]]", resp) if group: path = GitLabApiClientPath.group_projects.format(group=group) @@ -326,174 +346,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_mapping( + 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_mapping( 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_mapping( + 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_sequence( 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_mapping( 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_mapping( 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_sequence( 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_mapping( 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_mapping( 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_mapping( 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_sequence( 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_mapping( 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_mapping( 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_mapping( 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_sequence( 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_mapping( 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_mapping( 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_sequence( 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_sequence( 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_mapping( 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_mapping( GitLabApiClientPath.merge_request_discussion_notes.format( project_id=safe_quote(project_id), pr_key=pr_key, @@ -501,11 +557,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_mapping( GitLabApiClientPath.merge_request_discussion.format( project_id=safe_quote(project_id), pr_key=pr_key, @@ -513,19 +570,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_mapping( 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 +596,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_mapping(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_mapping( 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_sequence( 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_mapping( 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_mapping( 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_sequence( 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_mapping( 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_mapping( GitLabApiClientPath.merge_request_note_award.format( project_id=safe_quote(project_id), pr_key=pr_key, @@ -604,32 +680,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_sequence( + 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_mapping(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_mapping(path, data={"state_event": state}) + return response def _project_webhook_data(self) -> dict[str, Any]: hook_uri = reverse("sentry-extensions-gitlab-webhook") @@ -645,30 +733,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_mapping( 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_sequence(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 +768,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_mapping( 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 +793,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 +817,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 +827,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_mapping( 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_mapping( 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_mapping( 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 +864,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_mapping(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_sequence(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_mapping( 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 +895,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_sequence( 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 +910,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_sequence(path) # Filter out non-merged merge requests merge_requests = [] @@ -817,23 +923,32 @@ 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_mapping( + 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_sequence(path) + return response def check_file(self, repo: Repository, path: str, version: str | None) -> object | None: """Fetch a file for stacktrace linking @@ -852,13 +967,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_mapping( 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 +1013,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_sequence(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 +1031,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_sequence( 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_sequence( 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_sequence( 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..b33e03c7da5c 100644 --- a/src/sentry/integrations/gitlab/integration.py +++ b/src/sentry/integrations/gitlab/integration.py @@ -3,7 +3,7 @@ import logging import secrets from collections.abc import Mapping, MutableMapping -from typing import Any +from typing import Any, cast from urllib.parse import urlparse from django.db import router, transaction @@ -37,13 +37,12 @@ PRCommentWorkflow, ) from sentry.integrations.source_code_management.repository import ( - HaltReason, RepositoryInfo, RepositoryIntegration, ) from sentry.integrations.source_code_management.sync_repos import sync_repos_for_org from sentry.integrations.source_code_management.url import url_is_under -from sentry.integrations.types import IntegrationProviderSlug +from sentry.integrations.types import HaltReason, IntegrationProviderSlug from sentry.models.group import Group from sentry.models.organization import Organization from sentry.models.pullrequest import PullRequest @@ -170,7 +169,7 @@ def get_client(self) -> GitLabApiClient: return GitLabApiClient(self) # IntegrationInstallation methods - def error_message_from_json(self, data): + def error_message_from_json(self, data: Mapping[str, Any]) -> object: """ Extract error messages from gitlab API errors. Generic errors come in the `error` key while validation errors @@ -182,6 +181,7 @@ def error_message_from_json(self, data): return data["message"] if "error" in data: return data["error"] + return None def is_broken_integration_error(self, exc: Exception) -> HaltReason | None: # GitLab's get_repositories does not wrap errors in IntegrationError, @@ -494,16 +494,18 @@ def _get_debug_metadata_keys(self) -> list[str]: # Gitlab only functions - def get_group_id(self): - return self.model.metadata["group_id"] + def get_group_id(self) -> str: + # justification: integration metadata is decoded JSON. + return cast(str, self.model.metadata["group_id"]) - def search_projects(self, query): + def search_projects(self, query: str | None) -> list[Mapping[str, object]]: client = self.get_client() group_id = self.get_group_id() return client.search_projects(group_id, query) # TODO(cathy): define in issue ABC - def search_issues(self, query: str | None, **kwargs) -> list[dict[str, Any]]: + # justification: forwards provider hook context. + def search_issues(self, query: str | None, **kwargs: Any) -> list[dict[str, Any]]: client = self.get_client() project_id = kwargs["project_id"] iids = kwargs["iids"] @@ -565,7 +567,7 @@ def get_comment_data( } -class InstallationConfigSerializer(CamelSnakeSerializer): +class InstallationConfigSerializer(CamelSnakeSerializer[dict[str, Any]]): url = URLField(required=False, default="https://gitlab.com") group = CharField(required=True, allow_blank=False) include_subgroups = BooleanField(required=False, default=False) @@ -678,7 +680,9 @@ def post_install( force=True, ) - def get_group_info(self, access_token, installation_data): + def get_group_info( + self, access_token: str, installation_data: Mapping[str, Any] + ) -> Mapping[str, Any]: client = GitLabSetupApiClient( base_url=installation_data["url"], access_token=access_token, @@ -688,7 +692,8 @@ def get_group_info(self, access_token, installation_data): requested_group = installation_data["group"] try: resp = client.get_group(requested_group) - return resp.json + # justification: response JSON is decoded. + return cast("Mapping[str, Any]", resp.json) except ApiError as e: self.get_logger().info( "gitlab.installation.get-group-info-failure", @@ -820,7 +825,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/issue_sync.py b/src/sentry/integrations/gitlab/issue_sync.py index 6d77e1d4b9b5..5e26a655f04a 100644 --- a/src/sentry/integrations/gitlab/issue_sync.py +++ b/src/sentry/integrations/gitlab/issue_sync.py @@ -2,17 +2,18 @@ import logging from collections.abc import Mapping -from typing import Any +from typing import Any, cast from urllib.parse import quote from sentry import features from sentry.integrations.gitlab.types import GitLabIssueAction, GitLabIssueStatus -from sentry.integrations.mixins.issues import IssueSyncIntegration, ResolveSyncAction +from sentry.integrations.mixins.issues import IssuePayload, IssueSyncIntegration, ResolveSyncAction from sentry.integrations.models.external_actor import ExternalActor from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.models.integration_external_project import IntegrationExternalProject from sentry.integrations.services.integration import integration_service from sentry.integrations.types import EXTERNAL_PROVIDERS_REVERSE, ExternalProviderEnum +from sentry.models.activity import Activity from sentry.shared_integrations.exceptions import ApiError from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service @@ -276,7 +277,7 @@ def get_resolve_sync_action(self, data: Mapping[str, Any]) -> ResolveSyncAction: return ResolveSyncAction.NOOP - def get_config_data(self): + def get_config_data(self) -> dict[str, Any]: config = self.org_integration.config project_mappings = IntegrationExternalProject.objects.filter( organization_integration_id=self.org_integration.id @@ -291,7 +292,7 @@ def get_config_data(self): config["sync_status_forward"] = sync_status_forward return config - def create_comment_attribution(self, user_id, comment_text): + def create_comment_attribution(self, user_id: int, comment_text: str) -> str: user = user_service.get_user(user_id) username = "Unknown User" if user is None else user.name @@ -299,7 +300,9 @@ def create_comment_attribution(self, user_id, comment_text): quoted_text = "\n".join(f"> {line}" for line in comment_text.split("\n")) return f"{attribution}{quoted_text}" - def update_comment(self, issue_id, user_id, group_note): + def update_comment( + self, issue_id: str, user_id: int, group_note: Activity + ) -> IssuePayload | None: quoted_comment = self.create_comment_attribution(user_id, group_note.data["text"]) project_id, parsed_issue_id = self.split_external_issue_key(issue_id) @@ -316,14 +319,18 @@ def update_comment(self, issue_id, user_id, group_note): encoded_project_id = quote(project_id, safe="") - return self.get_client().update_comment( - encoded_project_id, - parsed_issue_id, - group_note.data["external_id"], - {"body": quoted_comment}, + # justification: the GitLab client returns the updated note payload. + return cast( + "Mapping[str, Any] | None", + self.get_client().update_comment( + encoded_project_id, + parsed_issue_id, + group_note.data["external_id"], + {"body": quoted_comment}, + ), ) - def create_comment(self, issue_id, user_id, group_note): + def create_comment(self, issue_id: str, user_id: int, group_note: Activity) -> IssuePayload: comment = group_note.data["text"] quoted_comment = self.create_comment_attribution(user_id, comment) @@ -337,10 +344,15 @@ def create_comment(self, issue_id, user_id, group_note): "external_issue_key": parsed_issue_id, }, ) - return None + # justification: invalid external keys yield no comment, as callers expect. + return cast("IssuePayload", None) encoded_project_id = quote(project_id, safe="") - return self.get_client().create_comment( - encoded_project_id, parsed_issue_id, {"body": quoted_comment} + # justification: the GitLab client returns the created note payload. + return cast( + "Mapping[str, Any]", + self.get_client().create_comment( + encoded_project_id, parsed_issue_id, {"body": quoted_comment} + ), ) diff --git a/src/sentry/integrations/gitlab/issues.py b/src/sentry/integrations/gitlab/issues.py index db8dc6faf5fc..9645a8cb5157 100644 --- a/src/sentry/integrations/gitlab/issues.py +++ b/src/sentry/integrations/gitlab/issues.py @@ -2,10 +2,12 @@ import re from collections.abc import Mapping, Sequence -from typing import Any +from typing import Any, cast from django.urls import reverse +from sentry.integrations.mixins.issues import IssuePayload +from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.source_code_management.issues import SourceCodeIssueIntegration from sentry.integrations.types import IntegrationIssueConfigField from sentry.integrations.utils.issue_url import get_issue_url_path @@ -27,7 +29,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 +41,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: object + ) -> tuple[str, list[tuple[str, str]]]: if group: defaults = self.get_project_defaults(group.project_id) else: @@ -55,7 +59,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 +70,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 @@ -91,7 +96,7 @@ def get_create_issue_config( *fields, ] - def create_issue(self, data, **kwargs): + def create_issue(self, data: IssuePayload, **kwargs: object) -> IssuePayload: client = self.get_client() project_id = data.get("project") @@ -124,7 +129,8 @@ def create_issue(self, data, **kwargs): "metadata": {"display_name": project_and_issue_iid}, } - def after_link_issue(self, external_issue, **kwargs): + # justification: forwards provider hook context. + def after_link_issue(self, external_issue: ExternalIssue, **kwargs: Any) -> None: data = kwargs["data"] project_id, issue_id = data.get("externalIssue", "").split("#") if not (project_id and issue_id): @@ -141,7 +147,9 @@ 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: Any + ) -> list[IntegrationIssueConfigField]: params = kwargs.pop("params", {}) default_project, project_choices = self.get_projects_and_default(group, params, **kwargs) @@ -194,7 +202,8 @@ def get_issue_link_data(self, url: str) -> dict[str, str]: ) return {"externalIssue": f"{match[1]}#{match[2]}"} - def get_issue(self, issue_id, **kwargs): + def get_issue(self, issue_id: str | int, **kwargs: object) -> IssuePayload: + issue_id = cast(str, issue_id) project_id, issue_num = issue_id.split("#") client = self.get_client() @@ -220,5 +229,8 @@ def get_issue(self, issue_id, **kwargs): "metadata": {"display_name": project_and_issue_iid}, } - def get_issue_display_name(self, external_issue): - return external_issue.metadata["display_name"] + def get_issue_display_name(self, external_issue: ExternalIssue) -> str: + display_name = external_issue.metadata["display_name"] + if not isinstance(display_name, str): + raise TypeError("GitLab issue display name is not a string") + return display_name diff --git a/src/sentry/integrations/gitlab/repository.py b/src/sentry/integrations/gitlab/repository.py index d6ffcf1f0816..4beaaf2d685c 100644 --- a/src/sentry/integrations/gitlab/repository.py +++ b/src/sentry/integrations/gitlab/repository.py @@ -1,36 +1,86 @@ 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: NotRequired[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]: + # justification: repository setup config is validated by the provider. + setup_config = cast(GitLabRepositorySetupConfig, config) + installation = self.get_installation(setup_config.get("installation"), organization.id) client = installation.get_client() - repo_id = config["identifier"] - instance = installation.model.metadata["instance"] + repo_id = setup_config["identifier"] + # justification: integration metadata is written by the install pipeline. + instance = cast(str, installation.model.metadata["instance"]) try: - project = client.get_project(repo_id) + # justification: GitLab project responses carry these keys. + project = cast(GitLabProject, client.get_project(repo_id)) except Exception as e: raise installation.raise_error(e) config.update( @@ -125,7 +175,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 +191,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) + # justification: GitLab commit list responses are JSON arrays. + commits = cast( + Sequence[GitLabCommit], + client.get_last_commits(repo.config["project_id"], end_sha), + ) + return self._format_commits(client, repo, commits) else: - res = client.compare_commits(repo.config["project_id"], start_sha, end_sha) - return self._format_commits(client, repo, res["commits"]) + # justification: GitLab comparison responses are JSON objects. + comparison = cast( + Mapping[str, object], + client.compare_commits(repo.config["project_id"], start_sha, end_sha), + ) + # justification: GitLab comparison commits are JSON arrays. + commits = cast(Sequence[GitLabCommit], comparison["commits"]) + return self._format_commits(client, repo, commits) except Exception as e: raise installation.raise_error(e) - 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 +237,18 @@ 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) + # justification: GitLab diff responses are JSON arrays. + 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 +262,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/search.py b/src/sentry/integrations/gitlab/search.py index 56dabf054042..ded6819a0a21 100644 --- a/src/sentry/integrations/gitlab/search.py +++ b/src/sentry/integrations/gitlab/search.py @@ -1,4 +1,4 @@ -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from rest_framework.response import Response @@ -13,19 +13,24 @@ T = TypeVar("T", bound=SourceCodeIssueIntegration) +if TYPE_CHECKING: + _SearchEndpointBase = SourceCodeSearchEndpoint[GitlabIntegration] +else: + _SearchEndpointBase = SourceCodeSearchEndpoint + @control_silo_endpoint -class GitlabIssueSearchEndpoint(SourceCodeSearchEndpoint): +class GitlabIssueSearchEndpoint(_SearchEndpointBase): @property def repository_field(self) -> str: return "project" @property - def integration_provider(self): + def integration_provider(self) -> str: return IntegrationProviderSlug.GITLAB.value @property - def installation_class(self): + def installation_class(self) -> type[GitlabIntegration]: return GitlabIntegration def handle_search_issues(self, installation: T, query: str, repo: str | None) -> Response: diff --git a/src/sentry/integrations/gitlab/tasks.py b/src/sentry/integrations/gitlab/tasks.py index 91ece08aa6f1..50749e8bcc20 100644 --- a/src/sentry/integrations/gitlab/tasks.py +++ b/src/sentry/integrations/gitlab/tasks.py @@ -10,6 +10,7 @@ from sentry import options from sentry.constants import ObjectStatus +from sentry.integrations.gitlab.client import GitLabApiClient from sentry.integrations.gitlab.metrics import ( GitLabTaskEvent, GitLabTaskInteractionType, @@ -41,7 +42,9 @@ ) -def _discard_project_webhook(client, project_id: int | str, hook_id: int | str) -> None: +def _discard_project_webhook( + client: GitLabApiClient, project_id: int | str, hook_id: int | str +) -> None: # Retrying the task would not come back here: it re-reads the repository and either # halts or reconciles the hook the repository now holds. So this is the only chance # to delete the hook, and a failure only leaves it orphaned. diff --git a/src/sentry/integrations/gitlab/types.py b/src/sentry/integrations/gitlab/types.py index 668e14f05598..c68b5d0a1e51 100644 --- a/src/sentry/integrations/gitlab/types.py +++ b/src/sentry/integrations/gitlab/types.py @@ -8,7 +8,7 @@ class GitLabIssueAction(StrEnum): CLOSE = "close" @classmethod - def values(cls): + def values(cls) -> list[str]: return [action.value for action in cls] @@ -17,6 +17,6 @@ class GitLabIssueStatus(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/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..a405acce1fef 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,9 @@ 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: dict[str, object] +) -> tuple[str, str] | HttpResponse: token = "" try: # Munge the token to extract the integration external_id. @@ -207,7 +209,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 +225,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 +254,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: Any) -> None: if not ( (organization := kwargs.get("organization")) and (integration := kwargs.get("integration")) @@ -465,7 +467,7 @@ class MergeEventWebhook(GitlabWebhook): handle_merge_request_event, ) - def __call__(self, event: Mapping[str, Any], **kwargs): + def __call__(self, event: Mapping[str, Any], **kwargs: Any) -> None: if not ( (organization := kwargs.get("organization")) and (integration := kwargs.get("integration")) @@ -613,7 +615,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: Any) -> None: if not ( (organization := kwargs.get("organization")) and (integration := kwargs.get("integration")) @@ -666,7 +668,7 @@ class PushEventWebhook(GitlabWebhook): EVENT_TYPE = IntegrationWebhookEventType.PUSH - def __call__(self, event: Mapping[str, Any], **kwargs): + def __call__(self, event: Mapping[str, Any], **kwargs: Any) -> None: if not ( (organization := kwargs.get("organization")) and (integration := kwargs.get("integration")) @@ -764,7 +766,7 @@ class GitlabWebhookEndpoint(Endpoint): } @method_decorator(csrf_exempt) - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: if request.method != "POST": return HttpResponse(status=405, reason="HTTP method not supported.") diff --git a/src/sentry/integrations/issue_alert_image_builder.py b/src/sentry/integrations/issue_alert_image_builder.py index 80a4c1adaf51..9c6efbf157f8 100644 --- a/src/sentry/integrations/issue_alert_image_builder.py +++ b/src/sentry/integrations/issue_alert_image_builder.py @@ -1,5 +1,6 @@ import logging from collections.abc import Callable +from typing import cast import sentry_sdk from django.conf import settings @@ -37,7 +38,7 @@ def __init__(self, group: Group, provider: ExternalProviderEnum) -> None: self.group = group self.provider = provider self.cache_key = f"chartcuterie-image:{self.group.id}" - self.tags = { + self.tags: dict[str, str | int] = { "provider": self.provider, "issue_category": self.group.issue_category, } @@ -86,7 +87,7 @@ def get_image_url(self) -> str | None: # We don't want to regenerate the image if another type of notification is sending the same one # For example slack notification and email notification for the same issue cache.set(self.cache_key, image_url, timeout=60 * 5) - return image_url + return image_url if isinstance(image_url, str) else None # This would only happen if we support the issue type, but chartcuterie failed to generate the image logger.warning( @@ -105,7 +106,9 @@ def _get_endpoint_regression_image_url(self) -> str | None: ) return None transaction_name = utils.escape_transaction(event.transaction) - period = get_relative_time(anchor=get_approx_start_time(self.group), relative_days=14) + period = get_relative_time( + anchor=cast(int, get_approx_start_time(self.group)), relative_days=14 + ) resp = client.get( auth=ApiKey(organization_id=organization.id, scope_list=["org:read"]), user=None, @@ -140,7 +143,9 @@ def _get_function_regression_image_url(self) -> str | None: ) return None - period = get_relative_time(anchor=get_approx_start_time(self.group), relative_days=14) + period = get_relative_time( + anchor=cast(int, get_approx_start_time(self.group)), relative_days=14 + ) resp = client.get( auth=ApiKey(organization_id=organization.id, scope_list=["org:read"]), user=None, diff --git a/src/sentry/integrations/jira/actions/create_ticket.py b/src/sentry/integrations/jira/actions/create_ticket.py index fc6b49afa8e8..ae459fa52719 100644 --- a/src/sentry/integrations/jira/actions/create_ticket.py +++ b/src/sentry/integrations/jira/actions/create_ticket.py @@ -27,4 +27,6 @@ def generate_footer(self, rule_url: str) -> str: def translate_integration(self, integration: RpcIntegration) -> str: name = integration.metadata.get("domain_name", integration.name) + if not isinstance(name, str): + name = integration.name return name.replace(".atlassian.net", "") diff --git a/src/sentry/integrations/jira/client.py b/src/sentry/integrations/jira/client.py index a521e792bbad..d0572a9c814b 100644 --- a/src/sentry/integrations/jira/client.py +++ b/src/sentry/integrations/jira/client.py @@ -1,5 +1,6 @@ import datetime import logging +from collections.abc import Mapping from typing import Any from urllib.parse import parse_qs, urlparse, urlsplit @@ -12,6 +13,7 @@ from sentry.integrations.utils.atlassian_connect import get_query_hash from sentry.integrations.utils.jira import parse_jira_issue_key from sentry.shared_integrations.exceptions import ApiError +from sentry.shared_integrations.response import MappingApiResponse, SequenceApiResponse from sentry.utils import jwt from sentry.utils.http import absolute_uri @@ -62,7 +64,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 +73,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,28 +97,28 @@ 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 """ return "query" - def get_issue(self, issue_id): - return self.get(self.ISSUE_URL % (issue_id,)) + def get_issue(self, issue_id: str) -> MappingApiResponse: + return self.get_mapping(self.ISSUE_URL % (issue_id,)) - def search_issues(self, query): + def search_issues(self, query: str) -> MappingApiResponse: 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 @@ -124,34 +126,39 @@ def search_issues(self, query): else: q = query.replace('"', '\\"') jql = f'text ~ "{q}"' - return self.get(self.SEARCH_URL, params={"jql": jql, "fields": "*all"}) + return self.get_mapping(self.SEARCH_URL, params={"jql": jql, "fields": "*all"}) - def create_comment(self, issue_key, comment): - return self.post(self.COMMENTS_URL % issue_key, data={"body": comment}) + def create_comment(self, issue_key: str, comment: str) -> MappingApiResponse: + return self.post_mapping(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: str, comment_id: str, comment: str) -> MappingApiResponse: + return self.put_mapping(self.COMMENT_URL % (issue_key, comment_id), data={"body": comment}) - def get_projects_paginated(self, params: dict[str, Any] | None = None): - response = self.get(self.PROJECTS_PAGINATED_URL, params=params) - return response + def get_projects_paginated( + self, params: Mapping[str, object] | None = None + ) -> MappingApiResponse: + return self.get_mapping(self.PROJECTS_PAGINATED_URL, params=params) - def get_projects_list(self): + def get_projects_list(self) -> SequenceApiResponse: """deprecated - please use paginated projects endpoint""" - return self.get_cached(self.PROJECT_URL) + return self.get_cached_sequence(self.PROJECT_URL) - def get_project_key_for_id(self, project_id) -> str: + def get_project_key_for_id(self, project_id: str | int) -> str: if not project_id: return "" projects = self.get_projects_list() for project in projects: - if project["id"] == project_id: - return project["key"] + if not isinstance(project, Mapping): + continue + if project.get("id") == project_id: + key = project.get("key") + if isinstance(key, str): + return key return "" - def get_create_meta_for_project(self, project): + def get_create_meta_for_project(self, project: str | int) -> MappingApiResponse | None: params = {"expand": "projects.issuetypes.fields", "projectIds": project} - metas = self.get_cached(self.META_URL, params=params) + metas = self.get_cached_mapping(self.META_URL, params=params) # We saw an empty JSON response come back from the API :( if not metas: logger.info( @@ -161,11 +168,17 @@ def get_create_meta_for_project(self, project): return None # XXX(dcramer): document how this is possible, if it even is - if len(metas["projects"]) > 1: + projects = metas.get("projects") + if not isinstance(projects, list): + raise ApiError("Expected a projects list") + if len(projects) > 1: raise ApiError(f"More than one project found matching {project}.") try: - return metas["projects"][0] + first_project = projects[0] + if not isinstance(first_project, Mapping): + raise ApiError("Expected a project object") + return MappingApiResponse(first_project) except IndexError: logger.info( "jira.get-create-meta.key-error", @@ -173,75 +186,82 @@ def get_create_meta_for_project(self, project): ) return None - def get_versions(self, project): - return self.get_cached(self.VERSIONS_URL % project) + def get_versions(self, project: str | int) -> SequenceApiResponse: + return self.get_cached_sequence(self.VERSIONS_URL % project) - def get_priorities(self): - return self.get_cached(self.PRIORITIES_URL) + def get_priorities(self) -> SequenceApiResponse: + return self.get_cached_sequence(self.PRIORITIES_URL) - def search_users_for_project(self, project, username): + def search_users_for_project(self, project: str | int, username: str) -> SequenceApiResponse: # Jira Server wants a project key, while cloud is indifferent. project_key = self.get_project_key_for_id(project) - return self.get_cached( + return self.get_cached_sequence( self.USERS_URL, params={"project": project_key, self.user_query_param(): username} ) - def search_users_for_issue(self, issue_key, email): - return self.get_cached( + def search_users_for_issue(self, issue_key: str, email: str) -> SequenceApiResponse: + return self.get_cached_sequence( 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) -> MappingApiResponse: user_id_get_param = self.user_id_get_param() - return self.get_cached(self.USER_URL, params={user_id_get_param: user_id}) + return self.get_cached_mapping(self.USER_URL, params={user_id_get_param: user_id}) - def create_issue(self, raw_form_data): + def create_issue(self, raw_form_data: Mapping[str, object]) -> MappingApiResponse: data = {"fields": raw_form_data} - return self.post(self.CREATE_URL, data=data) + return self.post_mapping(self.CREATE_URL, data=data) - def get_server_info(self): - return self.get(self.SERVER_INFO_URL) + def get_server_info(self) -> MappingApiResponse: + return self.get_mapping(self.SERVER_INFO_URL) - def get_valid_statuses(self): - return self.get_cached(self.STATUS_URL) + def get_valid_statuses(self) -> SequenceApiResponse: + return self.get_cached_sequence(self.STATUS_URL) - def get_transitions(self, issue_key): - return self.get(self.TRANSITION_URL % issue_key)["transitions"] + def get_transitions(self, issue_key: str) -> SequenceApiResponse: + response = self.get_mapping(self.TRANSITION_URL % issue_key) + transitions = response.get("transitions") + if not isinstance(transitions, list): + raise ApiError("Expected a transitions list") + return SequenceApiResponse(transitions) - def transition_issue(self, issue_key, transition_id): - return self.post( + def transition_issue(self, issue_key: str, transition_id: str) -> MappingApiResponse: + return self.post_mapping( self.TRANSITION_URL % issue_key, data={"transition": {"id": transition_id}} ) - def assign_issue(self, key, name_or_account_id): + def assign_issue(self, key: str, name_or_account_id: str) -> MappingApiResponse: user_id_field = self.user_id_field() - return self.put(self.ASSIGN_URL % key, data={user_id_field: name_or_account_id}) + return self.put_mapping(self.ASSIGN_URL % key, data={user_id_field: name_or_account_id}) - def set_issue_property(self, issue_key, badge_num): + def set_issue_property(self, issue_key: str, badge_num: int) -> MappingApiResponse: module_key = "sentry-issues-glance" properties_key = f"com.atlassian.jira.issue:{JIRA_KEY}:{module_key}:status" data = {"type": "badge", "value": {"label": badge_num}} - return self.put(self.PROPERTIES_URL % (issue_key, properties_key), data=data) + return self.put_mapping(self.PROPERTIES_URL % (issue_key, properties_key), data=data) - def get_email(self, account_id): - user = self.get_cached(self.EMAIL_URL, params={"accountId": account_id}) - return user.get("email") + def get_email(self, account_id: str | None) -> str | None: + user = self.get_cached_mapping(self.EMAIL_URL, params={"accountId": account_id}) + email = user.get("email") + return email if isinstance(email, str) else None - def get_field_autocomplete(self, name, value): + def get_field_autocomplete(self, name: str, value: str) -> MappingApiResponse: if name.startswith(CUSTOMFIELD_PREFIX): # Transform `customfield_0123` into `cf[0123]` cf_id = name[len(CUSTOMFIELD_PREFIX) :] jql_name = f"cf[{cf_id}]" else: jql_name = name - return self.get_cached( + return self.get_cached_mapping( self.AUTOCOMPLETE_URL, params={"fieldName": jql_name, "fieldValue": value} ) def get_project_statuses(self, project_id: str, paginate: bool = False) -> dict[str, Any]: if not paginate: # TODO: Remove this after rolling out lazy status feature flag fully - return dict(self.get_cached(self.STATUS_SEARCH_URL, params={"projectId": project_id})) + return dict( + self.get_cached_mapping(self.STATUS_SEARCH_URL, params={"projectId": project_id}) + ) values = self.get_with_pagination( self.STATUS_SEARCH_URL, diff --git a/src/sentry/integrations/jira/endpoints/search.py b/src/sentry/integrations/jira/endpoints/search.py index 05a58b3aedfc..2acb434a8365 100644 --- a/src/sentry/integrations/jira/endpoints/search.py +++ b/src/sentry/integrations/jira/endpoints/search.py @@ -107,7 +107,7 @@ def get( ).capture() as lifecycle: lifecycle.add_extra("field", field) try: - response = jira_client.search_users_for_project( + users_response = jira_client.search_users_for_project( request.GET.get("project", ""), query ) except (ApiUnauthorized, ApiError) as e: @@ -115,7 +115,8 @@ def get( return Response({"detail": "Unable to fetch users from Jira"}, status=400) user_tuples = filter( - None, [build_user_choice(user, jira_client.user_id_field()) for user in response] + None, + [build_user_choice(user, jira_client.user_id_field()) for user in users_response], ) users = [{"value": user_id, "label": display} for user_id, display in user_tuples] return Response(users) diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py index 96739e112adb..d032a57bd59b 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, MutableMapping, 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 @@ -35,6 +35,7 @@ from sentry.integrations.mixins.issues import ( MAX_CHAR, IntegrationSyncTargetNotFound, + IssuePayload, IssueSyncIntegration, ResolveSyncAction, ) @@ -48,6 +49,7 @@ from sentry.integrations.utils.jira import parse_jira_issue_key from sentry.issues.grouptype import GroupCategory from sentry.issues.issue_occurrence import IssueOccurrence +from sentry.models.activity import Activity from sentry.models.group import Group from sentry.pipeline.types import PipelineStepResult from sentry.pipeline.views.base import ApiPipelineSteps @@ -246,7 +248,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]]: @@ -489,7 +491,9 @@ def _get_organization_config_default_values(self) -> list[dict[str, Any]]: }, ] - def update_organization_config(self, data): + def update_organization_config( + self, data: MutableMapping[str, Any] + ) -> Mapping[str, Any] | None: """ Update the configuration field for an organization integration. @@ -540,7 +544,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] @@ -705,17 +711,18 @@ def _reconcile_project_status_mappings( return _MappingReconcileResult(audit=audit_data, remaining_count=remaining_count) - def get_config_data(self): + def get_config_data(self) -> Mapping[str, Any]: # 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,11 +744,9 @@ def get_config_data(self): ) return config - def sync_metadata(self): + def sync_metadata(self) -> None: client = self.get_client() - server_info = {} - projects = [] try: server_info = client.get_server_info() projects = client.get_projects_list() @@ -762,7 +767,9 @@ def sync_metadata(self): integration_id=self.model.id, name=name, metadata=metadata ) - def get_link_issue_config(self, group, **kwargs): + def get_link_issue_config( + self, group: Group, **kwargs: object + ) -> list[IntegrationIssueConfigField]: fields = super().get_link_issue_config(group, **kwargs) org = group.organization autocomplete_url = reverse("sentry-extensions-jira-search", args=[org.slug, self.model.id]) @@ -778,11 +785,12 @@ 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): - return self.org_integration.config.get(self.issues_ignored_fields_key, []) + def get_persisted_ignored_fields(self) -> list[str]: + ignored = self.org_integration.config.get(self.issues_ignored_fields_key, []) + return ignored if isinstance(ignored, list) else [] def get_feedback_issue_body(self, occurrence: IssueOccurrence) -> str: messages = [ @@ -802,17 +810,21 @@ 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 = event.occurrence + assert occurrence is not None 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 - def get_group_description(self, group, event, **kwargs): + def get_group_description( + self, group: Group, event: GroupEvent | None, **kwargs: object + ) -> str: output = [] if group.issue_category == GroupCategory.FEEDBACK: output = [ @@ -842,7 +854,7 @@ def get_group_description(self, group, event, **kwargs): output.extend(["", "{code}", body, "{code}"]) return "\n".join(output) - def get_client(self): + def get_client(self) -> JiraCloudClient: logging_context = {"org_id": self.organization_id} if self.organization_id is not None: @@ -866,13 +878,13 @@ def get_issue_link_data(self, url: str) -> dict[str, str]: raise IntegrationFormError({"externalIssue": "Invalid Jira issue URL"}) return {"externalIssue": key.upper()} - def get_issue(self, issue_id, **kwargs): + def get_issue(self, issue_id: str | int, **kwargs: object) -> IssuePayload: """ Jira installation's implementation of IssueSyncIntegration's `get_issue`. """ client = self.get_client() try: - issue = client.get_issue(issue_id) + issue = client.get_issue(str(issue_id)) except ApiError as e: self.raise_error(e) @@ -885,7 +897,7 @@ 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: str, user_id: int, group_note: Activity) -> IssuePayload: # https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=texteffects comment = group_note.data["text"] quoted_comment = self.create_comment_attribution(user_id, comment) @@ -900,14 +912,16 @@ 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 attribution = f"{username} 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: str, user_id: int, group_note: Activity + ) -> IssuePayload | None: quoted_comment = self.create_comment_attribution(user_id, group_note.data["text"]) try: return self.get_client().update_comment( @@ -922,7 +936,8 @@ def update_comment(self, issue_id, user_id, group_note): "There was an error updating a comment on the Jira issue." ) from e - def search_issues(self, query: str | None, **kwargs) -> dict[str, Any]: + def search_issues(self, query: str | None, **kwargs: object) -> dict[str, Any]: + assert query is not None, "search_issues requires a query string" try: resp = self.get_client().search_issues(query) assert isinstance(resp, dict) @@ -930,7 +945,7 @@ def search_issues(self, query: str | None, **kwargs) -> dict[str, Any]: except ApiError as e: self.raise_error(e) - def make_choices(self, values): + def make_choices(self, values: Sequence[Mapping[str, Any]]) -> list[tuple[object, str]]: if not values: return [] results = [] @@ -948,27 +963,28 @@ def make_choices(self, values): value = item["label"] else: continue - results.append((key, value)) + results.append((key, cast("str", value))) return results - def error_message_from_json(self, data): + def error_message_from_json(self, data: Mapping[str, Any]) -> str: message = "" if data.get("errorMessages"): message = " ".join(data["errorMessages"]) - if data.get("errors"): + errors = data.get("errors") + if errors: if message: message += " " - message += " ".join(f"{k}: {v}" for k, v in data.get("errors").items()) + message += " ".join(f"{k}: {v}" for k, v in errors.items()) return message - def error_fields_from_json(self, data): + def error_fields_from_json(self, data: Mapping[str, Any]) -> dict[str, list[Any]] | None: errors = data.get("errors") error_messages = data.get("errorMessages") if not errors and not error_messages: return None - error_data = {} + error_data: dict[str, list[Any]] = {} if error_messages: # These may or may not contain field specific errors, so we manually # map them @@ -978,7 +994,7 @@ def error_fields_from_json(self, data): error_data[key] = [message] if errors: - for key, error in data.get("errors").items(): + for key, error in (errors or {}).items(): error_data[key] = [error] if not error_data: @@ -986,13 +1002,15 @@ 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 """ return reverse("sentry-extensions-jira-search", args=[org_slug, self.model.id]) - def build_dynamic_field(self, field_meta, group=None): + def build_dynamic_field( + self, field_meta: Mapping[str, Any], group: Group | None = None + ) -> dict[str, Any] | None: """ Builds a field based on Jira's meta field information """ @@ -1007,7 +1025,7 @@ def build_dynamic_field(self, field_meta, group=None): or schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES["select"] ): fieldtype = "select" - fkwargs["choices"] = self.make_choices(field_meta.get("allowedValues")) + fkwargs["choices"] = self.make_choices(field_meta.get("allowedValues") or []) elif ( # Assignee and reporter fields field_meta.get("autoCompleteUrl") @@ -1037,14 +1055,14 @@ def build_dynamic_field(self, field_meta, group=None): fkwargs.update( { "multiple": True, - "choices": self.make_choices(field_meta.get("allowedValues")), + "choices": self.make_choices(field_meta.get("allowedValues") or []), "default": "", } ) elif schema["type"] == "option" and len(field_meta.get("allowedValues", [])): fieldtype = "select" fkwargs.update( - {"choices": self.make_choices(field_meta.get("allowedValues")), "default": ""} + {"choices": self.make_choices(field_meta.get("allowedValues") or []), "default": ""} ) # break this out, since multiple field types could additionally @@ -1056,7 +1074,7 @@ def build_dynamic_field(self, field_meta, group=None): fkwargs["type"] = fieldtype return fkwargs - def get_issue_type_meta(self, issue_type, meta): + def get_issue_type_meta(self, issue_type: str | None, meta: dict[str, Any]) -> dict[str, Any]: self.parse_jira_issue_metadata(meta) issue_types = meta["issuetypes"] issue_type_meta = None @@ -1068,9 +1086,16 @@ def get_issue_type_meta(self, issue_type, meta): if not issue_type_meta: issue_type_meta = issue_types[0] + if not isinstance(issue_type_meta, dict): + raise TypeError("Jira issue type metadata is not an object") return issue_type_meta - def get_issue_create_meta(self, client, project_id, jira_projects): + def get_issue_create_meta( + self, + client: JiraCloudClient, + project_id: str | None, + jira_projects: Sequence[dict[str, Any]], + ) -> dict[str, Any]: meta = None if project_id: meta = self.fetch_issue_create_meta(client, project_id) @@ -1111,7 +1136,9 @@ def get_issue_create_meta(self, client, project_id, jira_projects): "Ensure that your project permissions are correct." ) - def fetch_issue_create_meta(self, client, project_id): + def fetch_issue_create_meta( + self, client: JiraCloudClient, project_id: str + ) -> dict[str, Any] | None: try: meta = client.get_create_meta_for_project(project_id) except ApiUnauthorized: @@ -1139,7 +1166,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: object + ) -> 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 @@ -1157,7 +1186,7 @@ def get_create_issue_config(self, group: Group | None, user: RpcUser | User, **k """ kwargs = kwargs or {} kwargs["link_referrer"] = "jira_integration" - params = kwargs.get("params", {}) + params = cast("Mapping[str, Any]", kwargs.get("params", {})) fields = [] defaults = {} if group: @@ -1260,7 +1289,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": @@ -1299,7 +1331,7 @@ def get_create_issue_config(self, group: Group | None, user: RpcUser | User, **k return fields def _clean_and_transform_issue_data( - self, issue_metadata: JiraIssueTypeMetadata, data: dict[str, Any] + self, issue_metadata: JiraIssueTypeMetadata, data: Mapping[str, Any] ) -> Any: client = self.get_client() transformed_data = transform_fields( @@ -1307,7 +1339,7 @@ def _clean_and_transform_issue_data( ) return transformed_data - def create_issue(self, data, **kwargs): + def create_issue(self, data: IssuePayload, **kwargs: object) -> IssuePayload: client = self.get_client() # protect against mis-configured integration submitting a form without an # issuetype assigned. @@ -1432,7 +1464,10 @@ def sync_assignee_outbound( raise IntegrationSyncTargetNotFound("No matching Jira user found.") try: id_field = client.user_id_field() - client.assign_issue(external_issue.key, jira_user and jira_user.get(id_field)) + # justification: Jira unassigns when the assignee is None; the client signature is narrower than the API. + client.assign_issue( + external_issue.key, cast("str", jira_user and jira_user.get(id_field)) + ) except ApiUnauthorized as e: raise IntegrationConfigurationError( "Insufficient permissions to assign user to the Jira issue." @@ -1502,7 +1537,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"} @@ -1543,7 +1578,7 @@ class JiraInstallParams(TypedDict): metadata: dict[str, Any] -class JiraInitialDataSerializer(CamelSnakeSerializer): +class JiraInitialDataSerializer(CamelSnakeSerializer[JiraInstallParams]): """Initial pipeline data for Jira Cloud Marketplace installs. The configure link carries a single `signed_params` blob. We unsign it, @@ -1578,7 +1613,7 @@ def validate(self, attrs: dict[str, Any]) -> JiraInstallParams: } -class JiraConfirmSerializer(CamelSnakeSerializer): +class JiraConfirmSerializer(CamelSnakeSerializer[dict[str, Any]]): state = CharField(required=True) diff --git a/src/sentry/integrations/jira/utils/api.py b/src/sentry/integrations/jira/utils/api.py index ca4f14ea0c54..964f5afc1015 100644 --- a/src/sentry/integrations/jira/utils/api.py +++ b/src/sentry/integrations/jira/utils/api.py @@ -5,7 +5,6 @@ from typing import Any from rest_framework import status -from rest_framework.response import Response from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration import integration_service @@ -15,6 +14,7 @@ from sentry.integrations.utils.sync import sync_group_assignee_inbound from sentry.integrations.utils.webhook_viewer_context import webhook_viewer_context from sentry.shared_integrations.exceptions import ApiError +from sentry.shared_integrations.response import MappingApiResponse from ...mixins.issues import IssueSyncIntegration from ...project_management.metrics import ( @@ -42,7 +42,9 @@ def changelog_items(data: Mapping[str, Any]) -> list[Mapping[str, Any]]: return items if isinstance(items, list) else [] -def set_badge(integration: RpcIntegration, issue_key: str, group_link_num: int) -> Response: +def set_badge( + integration: RpcIntegration, issue_key: str, group_link_num: int +) -> MappingApiResponse: client = _get_client(integration) return client.set_issue_property(issue_key, group_link_num) diff --git a/src/sentry/integrations/jira/utils/create_issue_schema_transformers.py b/src/sentry/integrations/jira/utils/create_issue_schema_transformers.py index 64f90bfbf834..487378ac1a3a 100644 --- a/src/sentry/integrations/jira/utils/create_issue_schema_transformers.py +++ b/src/sentry/integrations/jira/utils/create_issue_schema_transformers.py @@ -83,8 +83,10 @@ def get_transformer_for_field( def transform_fields( - user_id_field: str, jira_fields: Iterable[JiraField], **data -) -> Mapping[str, Any]: + user_id_field: str, + jira_fields: Iterable[JiraField], + **data: Any, # justification: alert configuration values are heterogeneous JSON. +) -> Mapping[str, object]: transformed_data = {} # Special handling for fields that don't map cleanly to the transformer logic diff --git a/src/sentry/integrations/jira/views/base.py b/src/sentry/integrations/jira/views/base.py index f60fc3641145..f95623496cf8 100644 --- a/src/sentry/integrations/jira/views/base.py +++ b/src/sentry/integrations/jira/views/base.py @@ -1,5 +1,6 @@ from csp.middleware import CSPMiddleware from django.conf import settings +from django.http import HttpResponse from django.views.generic import View from sentry import options @@ -14,7 +15,7 @@ class JiraSentryUIBaseView(View): html_file: str # abstract - def get_response(self, context): + def get_response(self, context: dict[str, object]) -> HttpResponse: """ Wrap the HTML rendered using the template at `self.html_file` in a Response and add the requisite CSP headers before returning it. diff --git a/src/sentry/integrations/jira/views/sentry_installation.py b/src/sentry/integrations/jira/views/sentry_installation.py index 1fb107a319ca..68daacf71d5b 100644 --- a/src/sentry/integrations/jira/views/sentry_installation.py +++ b/src/sentry/integrations/jira/views/sentry_installation.py @@ -1,5 +1,5 @@ +from django.http import HttpResponse from rest_framework.request import Request -from rest_framework.response import Response from sentry.integrations.types import IntegrationProviderSlug from sentry.integrations.utils.atlassian_connect import ( @@ -24,7 +24,7 @@ class JiraSentryInstallationView(JiraSentryUIBaseView): html_file = "sentry/integrations/jira-config.html" - def get(self, request: Request, *args, **kwargs) -> Response: + def get(self, request: Request, *args: object, **kwargs: object) -> HttpResponse: try: integration = get_integration_from_request(request, IntegrationProviderSlug.JIRA.value) except AtlassianConnectTokenExpiredError: diff --git a/src/sentry/integrations/jira/views/sentry_issue_details.py b/src/sentry/integrations/jira/views/sentry_issue_details.py index 8938881d3542..abcf2396ec93 100644 --- a/src/sentry/integrations/jira/views/sentry_issue_details.py +++ b/src/sentry/integrations/jira/views/sentry_issue_details.py @@ -8,10 +8,10 @@ import sentry_sdk from django.db.models.query import QuerySet +from django.http import HttpResponse from django.http.request import HttpRequest from django.http.response import HttpResponseBase from rest_framework.request import Request -from rest_framework.response import Response from sentry.api.serializers import serialize from sentry.api.serializers.models.group_stream import StreamGroupSerializer @@ -108,8 +108,8 @@ class JiraSentryIssueDetailsView(JiraSentryUIBaseView): html_file = "sentry/integrations/jira-issue.html" - def handle_groups(self, groups: QuerySet[Group]) -> Response: - response_context = {"groups": [build_context(group) for group in groups]} + def handle_groups(self, groups: QuerySet[Group]) -> HttpResponse: + response_context: dict[str, object] = {"groups": [build_context(group) for group in groups]} logger.info( "issue_hook.response", @@ -118,17 +118,19 @@ def handle_groups(self, groups: QuerySet[Group]) -> Response: return self.get_response(response_context) - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponseBase: try: return super().dispatch(request, *args, **kwargs) except ApiError as exc: # Sometime set_badge() will fail to connect. response_option = handle_jira_api_error(exc, " to set badge") if response_option: - return self.get_response(response_option) + return self.get_response(dict(response_option)) raise - def get(self, request: Request, issue_key, *args, **kwargs) -> Response: + def get( + self, request: Request, issue_key: str, *args: object, **kwargs: object + ) -> HttpResponse: scope = sentry_sdk.get_isolation_scope() try: @@ -199,8 +201,8 @@ class JiraSentryIssueDetailsControlView(JiraSentryUIBaseView): html_file = "sentry/integrations/jira-issue-list.html" - def handle_groups(self, groups: list[RpcExternalIssueGroupMetadata]) -> Response: - response_context = {"groups": [dict(group) for group in groups]} + def handle_groups(self, groups: list[RpcExternalIssueGroupMetadata]) -> HttpResponse: + response_context: dict[str, object] = {"groups": [dict(group) for group in groups]} logger.info( "issue_hook.response", @@ -209,17 +211,19 @@ def handle_groups(self, groups: list[RpcExternalIssueGroupMetadata]) -> Response return self.get_response(response_context) - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponseBase: try: return super().dispatch(request, *args, **kwargs) except ApiError as exc: # Sometime set_badge() will fail to connect. response_option = handle_jira_api_error(exc, " to set badge") if response_option: - return self.get_response(response_option) + return self.get_response(dict(response_option)) raise - def get(self, request: Request, issue_key, *args, **kwargs) -> Response: + def get( + self, request: Request, issue_key: str, *args: object, **kwargs: object + ) -> HttpResponse: scope = sentry_sdk.get_isolation_scope() try: diff --git a/src/sentry/integrations/jira/webhooks/base.py b/src/sentry/integrations/jira/webhooks/base.py index b9689c5465a9..d3317a15bf87 100644 --- a/src/sentry/integrations/jira/webhooks/base.py +++ b/src/sentry/integrations/jira/webhooks/base.py @@ -6,7 +6,7 @@ from typing import Any from django.http.request import HttpRequest -from django.http.response import HttpResponseBase +from django.http.response import HttpResponse from django.views.decorators.csrf import csrf_exempt from psycopg2 import OperationalError from rest_framework import status @@ -37,7 +37,7 @@ class JiraWebhookBase(Endpoint, abc.ABC): provider = IntegrationProviderSlug.JIRA.value @csrf_exempt - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: return super().dispatch(request, *args, **kwargs) def handle_exception_with_details( diff --git a/src/sentry/integrations/jira/webhooks/installed.py b/src/sentry/integrations/jira/webhooks/installed.py index 105ea199112b..e6dbdf4b669e 100644 --- a/src/sentry/integrations/jira/webhooks/installed.py +++ b/src/sentry/integrations/jira/webhooks/installed.py @@ -39,7 +39,7 @@ class JiraSentryInstalledWebhook(JiraWebhookBase): Webhook hit by Jira whenever someone installs the Sentry integration in their Jira instance. """ - def post(self, request: Request, *args, **kwargs) -> Response: + def post(self, request: Request, *args: object, **kwargs: object) -> Response: with IntegrationPipelineViewEvent( interaction_type=IntegrationPipelineViewType.VERIFY_INSTALLATION, domain=IntegrationDomain.PROJECT_MANAGEMENT, diff --git a/src/sentry/integrations/jira/webhooks/issue_updated.py b/src/sentry/integrations/jira/webhooks/issue_updated.py index 9473751c0f75..1e51c6f67583 100644 --- a/src/sentry/integrations/jira/webhooks/issue_updated.py +++ b/src/sentry/integrations/jira/webhooks/issue_updated.py @@ -68,7 +68,7 @@ def handle_exception_with_details( return super().handle_exception_with_details(request, exc, handler_context, scope) - def post(self, request: Request, *args, **kwargs) -> Response: + def post(self, request: Request, *args: object, **kwargs: object) -> Response: token = self.get_token(request) rpc_integration = get_integration_from_jwt( token=token, diff --git a/src/sentry/integrations/jira/webhooks/uninstalled.py b/src/sentry/integrations/jira/webhooks/uninstalled.py index c1387154d945..a2d284cb927d 100644 --- a/src/sentry/integrations/jira/webhooks/uninstalled.py +++ b/src/sentry/integrations/jira/webhooks/uninstalled.py @@ -23,7 +23,7 @@ class JiraSentryUninstalledWebhook(JiraWebhookBase): Webhook hit by Jira whenever someone uninstalls the Sentry integration from their Jira instance. """ - def post(self, request: Request, *args, **kwargs) -> Response: + def post(self, request: Request, *args: object, **kwargs: object) -> Response: token = self.get_token(request) rpc_integration = get_integration_from_jwt( token=token, diff --git a/src/sentry/integrations/jira_server/actions/create_ticket.py b/src/sentry/integrations/jira_server/actions/create_ticket.py index acc04255fba1..aea81f7aee9f 100644 --- a/src/sentry/integrations/jira_server/actions/create_ticket.py +++ b/src/sentry/integrations/jira_server/actions/create_ticket.py @@ -26,4 +26,5 @@ def generate_footer(self, rule_url: str) -> str: return f"This ticket was automatically created by Sentry via [{self.action_context.origin.label}|{absolute_uri(rule_url)}]" def translate_integration(self, integration: RpcIntegration) -> str: - return integration.metadata.get("domain_name", integration.name) + name = integration.metadata.get("domain_name", integration.name) + return name if isinstance(name, str) else integration.name diff --git a/src/sentry/integrations/jira_server/client.py b/src/sentry/integrations/jira_server/client.py index 306bafdbe659..28cb31cd5100 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 @@ -21,6 +22,8 @@ IntegrationPipelineViewType, ) from sentry.shared_integrations.exceptions import ApiError +from sentry.shared_integrations.response import MappingApiResponse, SequenceApiResponse +from sentry.shared_integrations.response.base import BaseApiResponse from sentry.silo.base import control_silo_function from sentry.utils import jwt from sentry.utils.http import absolute_uri @@ -63,7 +66,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 +81,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 @@ -103,10 +106,10 @@ def user_id_field(self) -> str: def user_query_param(self) -> str: return "username" - def get_issue(self, issue_id): - return self.get(self.ISSUE_URL % (issue_id,)) + def get_issue(self, issue_id: str) -> MappingApiResponse: + return self.get_mapping(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,43 +117,63 @@ 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"}, + # justification: Jira search returns a JSON object. + 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]: + # justification: Jira comment creation returns a JSON 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]: + # justification: Jira comment update returns a JSON object. + return cast( + dict[str, object], + self.put(self.COMMENT_URL % (issue_key, comment_id), data={"body": comment}), + ) - def get_projects_list(self, cached: bool = True): + def get_projects_list(self, cached: bool = True) -> SequenceApiResponse: if not cached: - return self.get(self.PROJECT_URL) - return self.get_cached(self.PROJECT_URL) + return self.get_sequence(self.PROJECT_URL) + return self.get_cached_sequence(self.PROJECT_URL) - def get_issue_types(self, project_id): - # Get a list of issue types for the given project - return self.get_cached(self.ISSUE_TYPES_URL % (project_id)) + def get_issue_types(self, project_id: str | int) -> MappingApiResponse: + # Get the issue types for the given project, wrapped in a values list. + return self.get_cached_mapping(self.ISSUE_TYPES_URL % (project_id)) - def get_issue_fields(self, project_id, issue_type_id): + def get_issue_fields( + self, project_id: str | int, issue_type_id: str | int + ) -> MappingApiResponse: # Get a list of fields for the issue type and project - return self.get_cached(self.ISSUE_FIELDS_URL % (project_id, issue_type_id)) + return self.get_cached_mapping(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() for project in projects: - if project["id"] == project_id: - return project["key"] + if not isinstance(project, Mapping): + continue + if project.get("id") == project_id: + key = project.get("key") + if isinstance(key, str): + return 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): + def get_priorities(self) -> SequenceApiResponse: """ XXX(schew2381): There is an existing bug where we fetch and show all project priorities instead of scoping them to the selected project. This is fine when manually creating a Jira Server issue b/c we surface that @@ -166,60 +189,70 @@ def get_priorities(self): https://docs.atlassian.com/software/jira/docs/api/REST/9.11.0/#api/2/project/{projectKeyOrId}/priorityscheme-getAssignedPriorityScheme """ - return self.get_cached(self.PRIORITIES_URL) + return self.get_cached_sequence(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): - return self.get_cached( + def search_users_for_issue(self, issue_key: str, email: str) -> SequenceApiResponse: + return self.get_cached_sequence( 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): + def create_issue(self, raw_form_data: Mapping[str, object]) -> MappingApiResponse: data = {"fields": raw_form_data} - return self.post(self.CREATE_URL, data=data) + return self.post_mapping(self.CREATE_URL, data=data) - def get_server_info(self): - return self.get(self.SERVER_INFO_URL) + def get_server_info(self) -> MappingApiResponse: + return self.get_mapping(self.SERVER_INFO_URL) - def get_valid_statuses(self): - return self.get_cached(self.STATUS_URL) + def get_valid_statuses(self) -> SequenceApiResponse: + return self.get_cached_sequence(self.STATUS_URL) - def get_transitions(self, issue_key): - return self.get(self.TRANSITION_URL % issue_key)["transitions"] + def get_transitions(self, issue_key: str) -> SequenceApiResponse: + response = self.get_mapping(self.TRANSITION_URL % issue_key) + transitions = response.get("transitions") + if not isinstance(transitions, list): + raise ApiError("Expected a transitions list") + return SequenceApiResponse(transitions) - def transition_issue(self, issue_key, transition_id): - return self.post( + def transition_issue(self, issue_key: str, transition_id: str) -> MappingApiResponse: + return self.post_mapping( self.TRANSITION_URL % issue_key, data={"transition": {"id": transition_id}} ) - def assign_issue(self, key, name_or_account_id): + def assign_issue(self, key: str, name_or_account_id: str) -> MappingApiResponse: user_id_field = self.user_id_field() - return self.put(self.ASSIGN_URL % key, data={user_id_field: name_or_account_id}) + return self.put_mapping(self.ASSIGN_URL % key, data={user_id_field: name_or_account_id}) - def set_issue_property(self, issue_key, badge_num): + def set_issue_property(self, issue_key: str, badge_num: int) -> MappingApiResponse: module_key = "sentry-issues-glance" properties_key = f"com.atlassian.jira.issue:{JIRA_KEY}:{module_key}:status" data = {"type": "badge", "value": {"label": badge_num}} - return self.put(self.PROPERTIES_URL % (issue_key, properties_key), data=data) + return self.put_mapping(self.PROPERTIES_URL % (issue_key, properties_key), data=data) - def get_field_autocomplete(self, name, value): + def get_field_autocomplete(self, name: str, value: str) -> MappingApiResponse: if name.startswith(CUSTOMFIELD_PREFIX): # Transform `customfield_0123` into `cf[0123]` cf_id = name[len(CUSTOMFIELD_PREFIX) :] jql_name = f"cf[{cf_id}]" else: jql_name = name - return self.get_cached( + return self.get_cached_mapping( self.AUTOCOMPLETE_URL, params={"fieldName": jql_name, "fieldValue": value} ) @@ -240,13 +273,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 +290,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 +320,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"], @@ -312,7 +351,7 @@ def create_issue_webhook(self, external_id, secret, credentials): webhook_url = self.WEBHOOK_URL # Query the server version to determine which webhook endpoint to use - server_info = self.get(self.SERVER_INFO_URL, auth=auth) + server_info = self.get_mapping(self.SERVER_INFO_URL, auth=auth) server_version = server_info.get("version") server_major_version = server_version.split(".")[0] if server_version else None lifecycle.add_extra("server_major_version", server_major_version) @@ -323,9 +362,11 @@ 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) + # justification: Jira webhook creation returns a JSON object. + return cast(dict[str, object], self.post(webhook_url, auth=auth, data=data)) - def request(self, *args, **kwargs): + # justification: forwards arbitrary Django request arguments. + def request(self, *args: Any, **kwargs: Any) -> BaseApiResponse: """ Add OAuth1 RSA signatures. """ @@ -337,4 +378,5 @@ def request(self, *args, **kwargs): signature_type="auth_header", decoding=None, ) - return self._request(*args, **kwargs) + # justification: forwarding *args defeats the typed _request overloads. + return cast("BaseApiResponse", self._request(*args, **kwargs)) diff --git a/src/sentry/integrations/jira_server/integration.py b/src/sentry/integrations/jira_server/integration.py index dbf63b83647d..b326a7031fed 100644 --- a/src/sentry/integrations/jira_server/integration.py +++ b/src/sentry/integrations/jira_server/integration.py @@ -2,8 +2,8 @@ import logging import re -from collections.abc import Mapping, Sequence -from typing import Any, NotRequired, TypedDict +from collections.abc import Mapping, MutableMapping, Sequence +from typing import Any, NotRequired, TypedDict, cast from urllib.parse import urlparse from cryptography.hazmat.backends import default_backend @@ -26,7 +26,11 @@ ) from sentry.integrations.jira_server.utils.choice import build_user_choice from sentry.integrations.mixins import ResolveSyncAction -from sentry.integrations.mixins.issues import IntegrationSyncTargetNotFound, IssueSyncIntegration +from sentry.integrations.mixins.issues import ( + IntegrationSyncTargetNotFound, + IssuePayload, + IssueSyncIntegration, +) from sentry.integrations.models.external_actor import ExternalActor from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.models.integration_external_project import IntegrationExternalProject @@ -44,10 +48,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, @@ -342,13 +348,13 @@ class JiraServerIntegration(IssueSyncIntegration): issues_ignored_fields_key = "issues_ignored_fields" resolution_strategy_key = "resolution_strategy" - def get_client(self): + def get_client(self) -> JiraServerClient: try: return JiraServerClient(integration=self.model, identity=self.default_identity) 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, @@ -463,7 +469,7 @@ def get_organization_config(self): return configuration - def update_organization_config(self, data): + def update_organization_config(self, data: MutableMapping[str, Any]) -> None: """ Update the configuration field for an organization integration. """ @@ -513,7 +519,7 @@ def update_organization_config(self, data): if org_integration is not None: self.org_integration = org_integration - def get_config_data(self): + def get_config_data(self) -> Mapping[str, Any]: config = self.org_integration.config project_mappings = integration_service.get_integration_external_projects( organization_id=self.org_integration.organization_id, @@ -555,7 +561,9 @@ def sync_metadata(self) -> None: metadata=self.model.metadata, ) - def get_link_issue_config(self, group, **kwargs): + def get_link_issue_config( + self, group: Group, **kwargs: object + ) -> list[IntegrationIssueConfigField]: fields = super().get_link_issue_config(group, **kwargs) org = group.organization autocomplete_url = reverse( @@ -593,13 +601,16 @@ 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): - return self.org_integration.config.get(self.issues_ignored_fields_key, []) + def get_persisted_ignored_fields(self) -> list[str]: + ignored = self.org_integration.config.get(self.issues_ignored_fields_key, []) + return ignored if isinstance(ignored, list) else [] - 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, @@ -619,12 +630,12 @@ def get_issue_link_data(self, url: str) -> dict[str, str]: raise IntegrationFormError({"externalIssue": "Invalid Jira issue URL"}) return {"externalIssue": key.upper()} - def get_issue(self, issue_id, **kwargs): + def get_issue(self, issue_id: str | int, **kwargs: object) -> IssuePayload: """ Jira installation's implementation of IssueSyncIntegration's `get_issue`. """ client = self.get_client() - issue = client.get_issue(issue_id) + issue = client.get_issue(str(issue_id)) fields = issue.get("fields", {}) return { "key": issue_id, @@ -634,7 +645,9 @@ 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) @@ -646,13 +659,16 @@ 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 ) - def search_issues(self, query: str | None, **kwargs) -> dict[str, Any]: + def search_issues(self, query: str | None, **kwargs: object) -> dict[str, Any]: + assert query is not None, "search_issues requires a query string" try: resp = self.get_client().search_issues(query) assert isinstance(resp, dict) @@ -660,7 +676,7 @@ def search_issues(self, query: str | None, **kwargs) -> dict[str, Any]: except ApiError as e: self.raise_error(e) - def make_choices(self, values): + def make_choices(self, values: Sequence[Mapping[str, Any]]) -> list[tuple[object, str]]: if not values: return [] results = [] @@ -678,30 +694,33 @@ def make_choices(self, values): value = item["label"] else: continue - results.append((key, value)) + results.append((key, cast("str", value))) return results - def error_message_from_json(self, data): + def error_message_from_json(self, data: Mapping[str, Any]) -> str: message = "" if data.get("errorMessages"): message = " ".join(data["errorMessages"]) - if data.get("errors"): + errors = data.get("errors") + if errors: if message: message += " " - message += " ".join(f"{k}: {v}" for k, v in data.get("errors").items()) + message += " ".join(f"{k}: {v}" for k, v in errors.items()) return message - def error_fields_from_json(self, data): + def error_fields_from_json(self, data: Mapping[str, Any]) -> dict[str, list[Any]] | None: errors = data.get("errors") if not errors: return None - return {key: [error] for key, error in data.get("errors").items()} + return {key: [error] for key, error in 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): + def build_dynamic_field( + self, field_meta: Mapping[str, Any], group: Group | None = None + ) -> dict[str, Any] | None: """ Builds a field based on Jira's meta field information """ @@ -716,7 +735,7 @@ def build_dynamic_field(self, field_meta, group=None): or schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES["select"] ): fieldtype = "select" - fkwargs["choices"] = self.make_choices(field_meta.get("allowedValues")) + fkwargs["choices"] = self.make_choices(field_meta.get("allowedValues") or []) elif ( # Assignee and reporter fields field_meta.get("autoCompleteUrl") @@ -729,15 +748,15 @@ def build_dynamic_field(self, field_meta, group=None): ): fieldtype = "select" if group is not None: - organization = group.organization + organization_slug = group.organization.slug else: ctx = organization_service.get_organization_by_id( id=self.organization_id, include_teams=False, include_projects=False ) assert ctx is not None - organization = ctx.organization + organization_slug = ctx.organization.slug - fkwargs["url"] = self.search_url(organization.slug) + fkwargs["url"] = self.search_url(organization_slug) fkwargs["choices"] = [] elif schema["type"] in ["timetracking"]: # TODO: Implement timetracking (currently unsupported altogether) @@ -750,14 +769,14 @@ def build_dynamic_field(self, field_meta, group=None): fkwargs.update( { "multiple": True, - "choices": self.make_choices(field_meta.get("allowedValues")), + "choices": self.make_choices(field_meta.get("allowedValues") or []), "default": "", } ) elif schema["type"] == "option" and len(field_meta.get("allowedValues", [])): fieldtype = "select" fkwargs.update( - {"choices": self.make_choices(field_meta.get("allowedValues")), "default": ""} + {"choices": self.make_choices(field_meta.get("allowedValues") or []), "default": ""} ) # break this out, since multiple field types could additionally @@ -769,7 +788,7 @@ def build_dynamic_field(self, field_meta, group=None): fkwargs["type"] = fieldtype return fkwargs - def get_projects(self, cached=True): + def get_projects(self, cached: bool = True) -> list[dict[str, Any]]: client = self.get_client() no_projects_error_message = "Could not fetch project list from Jira Server. Ensure that Jira Server is available and your account is still active." try: @@ -796,7 +815,9 @@ def get_projects(self, cached=True): return jira_projects @all_silo_function - def get_create_issue_config(self, group: Group | None, user: User | RpcUser, **kwargs): + def get_create_issue_config( + self, group: Group | None, user: User | RpcUser, **kwargs: object + ) -> 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 @@ -815,7 +836,7 @@ def get_create_issue_config(self, group: Group | None, user: User | RpcUser, **k kwargs = kwargs or {} kwargs["link_referrer"] = "jira_server_integration" - params = kwargs.get("params", {}) + params = cast("Mapping[str, Any]", kwargs.get("params", {})) fields = [] defaults = {} if group: @@ -865,12 +886,15 @@ def get_create_issue_config(self, group: Group | None, user: User | RpcUser, **k # return a form with just the project selector and a special form field to show the error return [ project_field, - { - "name": "error", - "type": "blank", - "help": "Could not fetch issue creation metadata from Jira Server. Ensure that" - " the integration user has access to the requested project.", - }, + cast( + IntegrationIssueConfigField, + { + "name": "error", + "type": "blank", + "help": "Could not fetch issue creation metadata from Jira Server. Ensure that" + " the integration user has access to the requested project.", + }, + ), ] issue_type_choices_formatted = [ @@ -946,7 +970,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": @@ -984,7 +1011,7 @@ def get_create_issue_config(self, group: Group | None, user: User | RpcUser, **k return fields - def create_issue(self, data, **kwargs): + def create_issue(self, data: IssuePayload, **kwargs: object) -> IssuePayload: """ Get the (cached) "createmeta" from Jira to use as a "schema". Clean up the Jira issue by removing all fields that aren't enumerated by this @@ -1293,7 +1320,10 @@ def sync_assignee_outbound( raise IntegrationSyncTargetNotFound("No matching Jira Server user found") try: id_field = client.user_id_field() - client.assign_issue(external_issue.key, jira_user and jira_user.get(id_field)) + # justification: Jira unassigns when the assignee is None; the client signature is narrower than the API. + client.assign_issue( + external_issue.key, cast("str", jira_user and jira_user.get(id_field)) + ) except ApiUnauthorized as e: logger.info( "jira_server.user-assignment-unauthorized", @@ -1362,7 +1392,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 +1406,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 +1479,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..7ac72c3a2d5e 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, @@ -81,7 +81,7 @@ def get( return Response(users) try: - response = jira_client.get_field_autocomplete(name=field, value=query) + autocomplete = jira_client.get_field_autocomplete(name=field, value=query) except (ApiUnauthorized, ApiError): return Response( {"detail": f"Unable to fetch autocomplete for {field} from Jira"}, @@ -93,6 +93,6 @@ def get( # Jira's response will highlight the matching substring in the name using HTML formatting. "label": BeautifulSoup(result["displayName"], "html.parser").get_text(), } - for result in response["results"] + for result in autocomplete["results"] ] return Response(choices) diff --git a/src/sentry/integrations/jira_server/webhooks.py b/src/sentry/integrations/jira_server/webhooks.py index cbe62078433b..2c5279f0d231 100644 --- a/src/sentry/integrations/jira_server/webhooks.py +++ b/src/sentry/integrations/jira_server/webhooks.py @@ -1,9 +1,10 @@ from __future__ import annotations import logging +from typing import Any from django.core.exceptions import ObjectDoesNotExist -from django.http.request import HttpRequest +from django.http import HttpRequest, HttpResponse from django.views.decorators.csrf import csrf_exempt from rest_framework.request import Request from rest_framework.response import Response @@ -82,10 +83,12 @@ class JiraServerIssueUpdatedWebhook(Endpoint): permission_classes = () @csrf_exempt - def dispatch(self, request: HttpRequest, *args, **kwargs) -> Response: + # justification: framework kwargs + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: return super().dispatch(request, *args, **kwargs) - def post(self, request: Request, token, *args, **kwargs) -> Response: + # justification: framework kwargs + def post(self, request: Request, token: str, *args: Any, **kwargs: Any) -> Response: clear_organization_info() extra: dict[str, object] = {} try: diff --git a/src/sentry/integrations/messaging/linkage.py b/src/sentry/integrations/messaging/linkage.py index 7a0008e2d598..61c31d7153f0 100644 --- a/src/sentry/integrations/messaging/linkage.py +++ b/src/sentry/integrations/messaging/linkage.py @@ -176,7 +176,8 @@ def dispatch(self, request: HttpRequest, signed_params: str) -> HttpResponseBase dispatch_kwargs = {k: v for (k, v) in dispatch_kwargs.items() if v is not None} return super().dispatch(request, **dispatch_kwargs) - def get(self, request: Request, *args, **kwargs) -> HttpResponse: + # justification: Django view kwargs carry the pipeline params mapping. + def get(self, request: Request, *args: object, **kwargs: Any) -> HttpResponse: params = kwargs["params"] context = {"organization": params["organization"]} integration = params.get("integration") @@ -220,7 +221,7 @@ def post(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse: ) return render_to_response(success_template, request=request, context=success_context) - def _send_nudge_notification(self, organization: RpcOrganization, request: Request): + def _send_nudge_notification(self, organization: RpcOrganization, request: Request) -> None: # TODO: Delete this if no longer needed user: User = request.user # type: ignore[assignment] diff --git a/src/sentry/integrations/middleware/hybrid_cloud/parser.py b/src/sentry/integrations/middleware/hybrid_cloud/parser.py index b6791fa2e1cd..aba9beb7e1db 100644 --- a/src/sentry/integrations/middleware/hybrid_cloud/parser.py +++ b/src/sentry/integrations/middleware/hybrid_cloud/parser.py @@ -94,7 +94,7 @@ def __init__(self, request: HttpRequest, response_handler: ResponseHandler): # Common Helpers - def ensure_control_silo(self): + def ensure_control_silo(self) -> None: with MiddlewareOperationEvent( operation_type=MiddlewareOperationType.ENSURE_CONTROL_SILO, integration_name=self.provider, @@ -413,7 +413,7 @@ def mailbox_event_type(self, data: dict[str, Any]) -> str | None: """ return None - def get_response_from_first_cell(self): + def get_response_from_first_cell(self) -> HttpResponseBase: cells = self.get_cells_from_organizations() first_cell = cells[0] response_map = self.get_responses_from_cell_silos(cells=[first_cell]) @@ -432,9 +432,11 @@ def get_response_from_first_cell(self): if cell_result.error is not None: # We want to fail loudly so that devs know this error happened on the cell silo (for now) raise SiloClientError(cell_result.error) + if cell_result.response is None: + raise SiloClientError("Cell returned no response") return cell_result.response - def get_response_from_all_cells(self): + def get_response_from_all_cells(self) -> HttpResponseBase: cells = self.get_cells_from_organizations() response_map = self.get_responses_from_cell_silos(cells=cells) successful_responses = [ @@ -450,7 +452,10 @@ def get_response_from_all_cells(self): f"{cell}: {result.error}" for cell, result in response_map.items() ) raise SiloClientError("No successful cell responses", error_map_str) - return successful_responses[0].response + response = successful_responses[0].response + if response is None: + raise SiloClientError("Cell returned no response") + return response # Required Overrides diff --git a/src/sentry/integrations/mixins/issues.py b/src/sentry/integrations/mixins/issues.py index 82a3de0f8424..2cc2de08956a 100644 --- a/src/sentry/integrations/mixins/issues.py +++ b/src/sentry/integrations/mixins/issues.py @@ -3,10 +3,10 @@ import enum import logging from abc import ABC, abstractmethod -from collections.abc import Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence from copy import deepcopy from operator import attrgetter -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias from sentry import features from sentry.auth.principal import has_user @@ -22,15 +22,23 @@ from sentry.integrations.utils.external_issues import maybe_generate_external_issue_details from sentry.issues.grouptype import GroupCategory from sentry.issues.issue_occurrence import IssueOccurrence +from sentry.models.activity import Activity from sentry.models.group import Group 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 Event, GroupEvent from sentry.shared_integrations.exceptions import IntegrationError, IntegrationFormError from sentry.silo.base import all_silo_function, cell_silo_function from sentry.users.models.user import User from sentry.users.services.user import RpcUser from sentry.users.services.user_option import get_option_from_list, user_option_service + +# justification: provider issue payloads and defaults are decoded JSON. +IssuePayload: TypeAlias = Mapping[str, Any] +IssueDefaults: TypeAlias = dict[str, Any] # justification: decoded JSON. +IssueSearchResults: TypeAlias = ( # justification: decoded JSON. + list[dict[str, Any]] | dict[str, Any] +) from sentry.utils.http import absolute_uri from sentry.utils.safe import safe_execute @@ -70,15 +78,21 @@ def from_resolve_unresolve( class IssueBasicIntegration(IntegrationInstallation, ABC): - def should_sync(self, attribute, sync_source: AssignmentSource | None = None) -> bool: + def should_sync(self, attribute: str, sync_source: AssignmentSource | None = None) -> bool: return False - def get_group_title(self, group, event, **kwargs): + def get_group_title( + self, group: Group | None, event: Event | GroupEvent | None, **kwargs: object + ) -> str: # Imported lazily to avoid a circular import: sentry.notifications.utils # imports sentry.utils.committers -> sentry.api.serializers -> this module. from sentry.notifications.utils import get_notification_group_title - return get_notification_group_title(group, event, **kwargs) + assert event is not None + max_length = kwargs.get("max_length") + if isinstance(max_length, int): + return get_notification_group_title(group, event, max_length) + return get_notification_group_title(group, event) @abstractmethod def get_issue_url(self, key: str) -> str: @@ -87,7 +101,10 @@ def get_issue_url(self, key: str) -> str: """ raise NotImplementedError - def get_group_body(self, group, event, **kwargs): + def get_group_body( + self, group: Group, event: Event | GroupEvent | None, **kwargs: object + ) -> str: + assert event is not None result = [] for interface in event.interfaces.values(): output = safe_execute(interface.to_string, event) @@ -116,10 +133,11 @@ def get_feedback_issue_body(self, occurrence: IssueOccurrence) -> str: return body.rstrip("\n") # remove the last new line - def get_group_link(self, group, **kwargs): - params = {} - if kwargs.get("link_referrer"): - params["referrer"] = kwargs.get("link_referrer") + def get_group_link(self, group: Group, **kwargs: object) -> list[str]: + params: dict[str, str] = {} + link_referrer = kwargs.get("link_referrer") + if isinstance(link_referrer, str) and link_referrer: + params["referrer"] = link_referrer if group.issue_category == GroupCategory.FEEDBACK: return [ @@ -130,7 +148,9 @@ def get_group_link(self, group, **kwargs): f"Sentry Issue: [{group.qualified_short_id}]({absolute_uri(group.get_absolute_url(params=params))})" ] - def get_group_description(self, group, event, **kwargs): + def get_group_description( + self, group: Group, event: GroupEvent | None, **kwargs: object + ) -> str: output = self.get_group_link(group, **kwargs) if ( event @@ -149,7 +169,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, @@ -194,7 +214,9 @@ def get_create_issue_config( }, ] - def get_link_issue_config(self, group, **kwargs): + def get_link_issue_config( + self, group: Group, **kwargs: object + ) -> list[IntegrationIssueConfigField]: """ Used by the `GroupIntegrationDetailsEndpoint` to create an `ExternalIssue` using title/description obtained from calling @@ -210,14 +232,16 @@ def get_persisted_default_config_fields(self) -> Sequence[str]: """ return [] - def get_persisted_user_default_config_fields(self): + def get_persisted_user_default_config_fields(self) -> Sequence[str]: """ Returns a list of field names that should have their last used values persisted on a per-project, per-user basis. """ return [] - def store_issue_last_defaults(self, project: Project, user: RpcUser | User, data): + def store_issue_last_defaults( + self, project: Project, user: RpcUser | User, data: IssuePayload + ) -> None: """ Stores the last used field defaults on a per-project basis. This accepts a dict of values that will be filtered to keys returned by @@ -261,7 +285,7 @@ def store_issue_last_defaults(self, project: Project, user: RpcUser | User, data user_id=user.id, value=new_user_defaults, **user_option_key ) - def get_defaults(self, project: Project, user: User | RpcUser): + def get_defaults(self, project: Project, user: User | RpcUser) -> IssueDefaults: project_defaults = ( {} if not self.org_integration @@ -283,7 +307,7 @@ def get_defaults(self, project: Project, user: User | RpcUser): return {**project_defaults, **user_defaults} @abstractmethod - def create_issue(self, data, **kwargs): + def create_issue(self, data: IssuePayload, **kwargs: object) -> IssuePayload: """ Create an issue via the provider's API and return the issue key, title and description. @@ -308,7 +332,7 @@ def get_issue_link_data(self, url: str) -> dict[str, str]: ) @abstractmethod - def get_issue(self, issue_id, **kwargs): + def get_issue(self, issue_id: str | int, **kwargs: object) -> IssuePayload: """ Get an issue via the provider's API and return the issue key, title and description. @@ -327,10 +351,14 @@ def get_issue(self, issue_id, **kwargs): raise NotImplementedError @abstractmethod - def search_issues(self, query: str | None, **kwargs) -> list[dict[str, Any]] | dict[str, Any]: + def search_issues(self, query: str | None, **kwargs: object) -> IssueSearchResults: raise NotImplementedError - def after_link_issue(self, external_issue, **kwargs): + def after_link_issue( + self, + external_issue: ExternalIssue, + **kwargs: Any, # justification: provider hook context. + ) -> None: """ Takes the external issue that has been linked via `get_issue`. @@ -338,13 +366,16 @@ def after_link_issue(self, external_issue, **kwargs): a comment for a linked issue. """ - def make_external_key(self, data): + def make_external_key(self, data: IssuePayload) -> str: """ Takes result of `get_issue` or `create_issue` and returns the formatted key """ - return data["key"] + key = data["key"] + if not isinstance(key, str): + raise TypeError("Issue payload key is not a string") + return key - def get_issue_display_name(self, external_issue): + def get_issue_display_name(self, external_issue: ExternalIssue) -> str: """ Returns the display name of the issue. @@ -353,7 +384,9 @@ def get_issue_display_name(self, external_issue): """ return "" - def map_external_issues_to_annotations(self, external_issues): + def map_external_issues_to_annotations( + self, external_issues: Iterable[ExternalIssue] + ) -> list[dict[str, str]]: annotations = [] for ei in external_issues: link = self.get_issue_url(ei.key) @@ -362,13 +395,18 @@ def map_external_issues_to_annotations(self, external_issues): return annotations - def get_comment_id(self, comment): - return comment["id"] + def get_comment_id(self, comment: IssuePayload) -> str: + comment_id = comment["id"] + if not isinstance(comment_id, str): + raise TypeError("Issue comment id is not a string") + return comment_id - def create_comment(self, issue_id, user_id, group_note): - pass + def create_comment(self, issue_id: str, user_id: int, group_note: Activity) -> IssuePayload: + raise NotImplementedError - def update_comment(self, issue_id, user_id, group_note): + def update_comment( + self, issue_id: str, user_id: int, group_note: Activity + ) -> IssuePayload | None: pass @@ -479,7 +517,7 @@ def sync_status_inbound(self, issue_key: str, data: Mapping[str, Any]) -> None: } ) - def migrate_issues(self): + def migrate_issues(self) -> None: """ Migrate the corresponding plugin's issues to the integration and disable the plugins. """ diff --git a/src/sentry/integrations/mixins/serverless.py b/src/sentry/integrations/mixins/serverless.py index 607537ae4c22..ed2d73a25aa8 100644 --- a/src/sentry/integrations/mixins/serverless.py +++ b/src/sentry/integrations/mixins/serverless.py @@ -1,15 +1,15 @@ class ServerlessMixin: - def get_serverless_functions(self): + def get_serverless_functions(self) -> list[dict[str, object]]: """ Returns a list of serverless functions """ raise NotImplementedError - def enable_function(self, target): + def enable_function(self, target: str) -> dict[str, object]: raise NotImplementedError - def disable_function(self, target): + def disable_function(self, target: str) -> dict[str, object]: raise NotImplementedError - def update_function_to_latest_version(self, target): + def update_function_to_latest_version(self, target: str) -> dict[str, object]: raise NotImplementedError diff --git a/src/sentry/integrations/models/data_forwarder_project.py b/src/sentry/integrations/models/data_forwarder_project.py index 1ff250c40cde..f0a27bb88fd8 100644 --- a/src/sentry/integrations/models/data_forwarder_project.py +++ b/src/sentry/integrations/models/data_forwarder_project.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import cast + from django.db import models from sentry.backup.scopes import RelocationScope @@ -29,7 +31,7 @@ class Meta: db_table = "sentry_dataforwarderproject" unique_together = (("data_forwarder", "project"),) - def get_config(self) -> dict: + def get_config(self) -> dict[str, str]: """ Get the configuration by merging the base config with project overrides. """ @@ -38,4 +40,5 @@ def get_config(self) -> dict: effective_config = base_config.copy() effective_config.update(self.overrides) - return effective_config + # justification: overrides are string field/value pairs merged into the config. + return cast("dict[str, str]", effective_config) diff --git a/src/sentry/integrations/models/doc_integration_avatar.py b/src/sentry/integrations/models/doc_integration_avatar.py index 80c2e1478523..0a47a383c1fe 100644 --- a/src/sentry/integrations/models/doc_integration_avatar.py +++ b/src/sentry/integrations/models/doc_integration_avatar.py @@ -29,5 +29,5 @@ class Meta: url_path = "doc-integration-avatar" - def get_cache_key(self, size) -> str: + def get_cache_key(self, size: int) -> str: return f"doc_integration_avatar:{self.doc_integration_id}:{size}" diff --git a/src/sentry/integrations/models/external_actor.py b/src/sentry/integrations/models/external_actor.py index 3af34f8644be..98e3c0f87f22 100644 --- a/src/sentry/integrations/models/external_actor.py +++ b/src/sentry/integrations/models/external_actor.py @@ -52,7 +52,9 @@ class ExternalActor(Model): # The unique identifier i.e user ID, channel ID. external_id = models.TextField(null=True) # Data source used to create the ExternalActor row. None if manually created via the UI / API. - source = BoundedPositiveIntegerField(choices=ExternalActorSource.as_choices(), null=True) + source = BoundedPositiveIntegerField[int | None, int | None]( + choices=ExternalActorSource.as_choices(), null=True + ) class Meta: app_label = "sentry" @@ -69,7 +71,9 @@ class Meta: ), ] - def delete(self, *args, **kwargs): + def delete( + self, using: str | None = None, keep_parents: bool = False + ) -> tuple[int, dict[str, int]]: from sentry.integrations.services.integration import integration_service # TODO: Extract this out of the delete method into the endpoint / controller instead. @@ -85,14 +89,14 @@ def delete(self, *args, **kwargs): team_id=team.id, provider=ExternalProviders(self.provider) ) - return super().delete(*args, **kwargs) + return super().delete(using=using, keep_parents=keep_parents) -def process_resource_change(instance: ExternalActor, **kwargs): +def process_resource_change(instance: ExternalActor, **kwargs: object) -> None: from sentry.models.project import Project from sentry.tasks.codeowners import update_code_owners_schema - def _spawn_task(): + def _spawn_task() -> None: update_code_owners_schema.apply_async( kwargs={ "organization": instance.organization_id, diff --git a/src/sentry/integrations/models/integration.py b/src/sentry/integrations/models/integration.py index 3eb6cba39950..f721f9c33e3b 100644 --- a/src/sentry/integrations/models/integration.py +++ b/src/sentry/integrations/models/integration.py @@ -57,7 +57,7 @@ class Integration(DefaultFieldsModelExisting): # Organization-specific metadata is only safe when the provider guarantees that the # Integration instance belongs to one organization. metadata = EncryptedJSONField(default=dict) - status = BoundedPositiveIntegerField( + status = BoundedPositiveIntegerField[int | None, int | None]( default=ObjectStatus.ACTIVE, choices=ObjectStatus.as_choices(), null=True ) # A place to store non-senstive data for debugging or querying @@ -83,7 +83,7 @@ def has_feature(self, feature: IntegrationFeatures) -> bool: return has_feature(instance=self, feature=feature) - def delete(self, *args, **kwds): + def delete(self, *args: Any, **kwds: Any) -> Any: with outbox_context( transaction.atomic(using=router.db_for_write(OrganizationIntegration)), flush=False ): @@ -191,7 +191,7 @@ def add_organization( ) return None - def disable(self): + def disable(self) -> None: """ Disable this integration """ diff --git a/src/sentry/integrations/models/organization_integration.py b/src/sentry/integrations/models/organization_integration.py index 1e01879c6be1..5e3ee265add1 100644 --- a/src/sentry/integrations/models/organization_integration.py +++ b/src/sentry/integrations/models/organization_integration.py @@ -29,7 +29,7 @@ class OrganizationIntegration(ReplicatedControlModel): integration = FlexibleForeignKey("sentry.Integration") config = models.JSONField(default=dict) - default_auth_id = BoundedPositiveIntegerField(db_index=True, null=True) + default_auth_id = BoundedPositiveIntegerField[int | None, int | None](db_index=True, null=True) status = BoundedPositiveIntegerField( default=ObjectStatus.ACTIVE, choices=ObjectStatus.as_choices() ) diff --git a/src/sentry/integrations/models/repository_project_path_config.py b/src/sentry/integrations/models/repository_project_path_config.py index 13311aa42d95..b812d1a608d4 100644 --- a/src/sentry/integrations/models/repository_project_path_config.py +++ b/src/sentry/integrations/models/repository_project_path_config.py @@ -52,14 +52,14 @@ def __repr__(self) -> str: ) -def process_resource_change(instance: RepositoryProjectPathConfig, **kwargs): +def process_resource_change(instance: RepositoryProjectPathConfig, **kwargs: object) -> None: if instance._skip_post_save: return from sentry.models.project import Project from sentry.tasks.codeowners import update_code_owners_schema - def _spawn_update_schema_task(): + def _spawn_update_schema_task() -> None: """ We need to re-apply the updated code mapping against any CODEOWNERS file that uses this mapping. """ diff --git a/src/sentry/integrations/models/utils.py b/src/sentry/integrations/models/utils.py index f81a2faaff9e..601967c674e1 100644 --- a/src/sentry/integrations/models/utils.py +++ b/src/sentry/integrations/models/utils.py @@ -35,5 +35,5 @@ def has_feature(instance: Integration | RpcIntegration, feature: IntegrationFeat return feature in instance.get_provider().features -def get_redis_key(sentryapp: SentryApp | RpcSentryApp, org_id) -> str: +def get_redis_key(sentryapp: SentryApp | RpcSentryApp, org_id: int) -> str: return f"sentry-app-error:{sentryapp.id}:{org_id}" diff --git a/src/sentry/integrations/msteams/actions/form.py b/src/sentry/integrations/msteams/actions/form.py index 758321c1d95d..5332a4ba8551 100644 --- a/src/sentry/integrations/msteams/actions/form.py +++ b/src/sentry/integrations/msteams/actions/form.py @@ -17,7 +17,8 @@ class MsTeamsNotifyServiceForm(forms.Form): channel = forms.CharField(widget=forms.TextInput()) channel_id = forms.HiddenInput() - def __init__(self, *args, **kwargs): + # justification: Django form kwargs are dynamic. + def __init__(self, *args: Any, **kwargs: Any) -> None: self._team_list = [(i.id, i.name) for i in kwargs.pop("integrations")] super().__init__(*args, **kwargs) diff --git a/src/sentry/integrations/msteams/actions/notification.py b/src/sentry/integrations/msteams/actions/notification.py index 5bd49aea8eba..feac167deaef 100644 --- a/src/sentry/integrations/msteams/actions/notification.py +++ b/src/sentry/integrations/msteams/actions/notification.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Generator, Sequence +from typing import Any from sentry.integrations.messaging.metrics import ( MessagingInteractionEvent, @@ -29,7 +30,8 @@ class MsTeamsNotifyServiceAction(IntegrationEventAction): provider = IntegrationProviderSlug.MSTEAMS.value integration_key = "team" - def __init__(self, *args, **kwargs): + # justification: Django form kwargs are dynamic. + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.form_fields = { "team": { @@ -88,7 +90,7 @@ def send_notification(event: GroupEvent, futures: Sequence[RuleFuture]) -> None: ) yield self.future(send_notification, key=key) - def render_label(self): + def render_label(self) -> str: return self.label.format( team=self.get_integration_name(), channel=self.get_option("channel") ) diff --git a/src/sentry/integrations/msteams/card_builder/notifications.py b/src/sentry/integrations/msteams/card_builder/notifications.py index ec4faf22e8f5..e1d18750b872 100644 --- a/src/sentry/integrations/msteams/card_builder/notifications.py +++ b/src/sentry/integrations/msteams/card_builder/notifications.py @@ -21,6 +21,7 @@ from .block import ( Action, ActionType, + AdaptiveCard, ColumnSetBlock, TextBlock, TextSize, @@ -106,7 +107,7 @@ def create_action_blocks(actions: Sequence[MessageAction]) -> list[Action]: return action_blocks - def build_notification_card(self): + def build_notification_card(self) -> AdaptiveCard: fields = [self.create_attachment_title_block(), self.create_description_block()] # TODO: Add support for notification actions. diff --git a/src/sentry/integrations/msteams/client.py b/src/sentry/integrations/msteams/client.py index c36621cfcd97..be5dfdeba58e 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 NotRequired, TypedDict, cast from urllib.parse import urlencode from requests import PreparedRequest @@ -18,6 +19,7 @@ from sentry.integrations.types import IntegrationProviderSlug from sentry.shared_integrations.client.proxy import IntegrationProxyClient, infer_org_integration from sentry.shared_integrations.exceptions import ApiError, IntegrationError +from sentry.shared_integrations.response.base import BaseApiResponse from sentry.silo.base import SiloMode, control_silo_function from sentry.utils import metrics @@ -27,6 +29,32 @@ CLOCK_SKEW = 60 * 5 +class _TeamChannel(TypedDict, total=False): + id: str + name: str + + +class _TeamMember(TypedDict): + id: str + name: str + tenantId: str + + +class _PagedMembersResponse(TypedDict): + members: list[_TeamMember] + continuationToken: NotRequired[str] + + +class _TokenResponse(TypedDict): + access_token: str + expires_in: int + + +class _OpenIDConfig(TypedDict): + jwks_uri: str + id_token_signing_alg_values_supported: list[str] + + # MsTeamsClientABC abstract client does not handle setting the base url or auth token class MsTeamsClientABC(ApiClient, ABC): integration_name = IntegrationProviderSlug.MSTEAMS.value @@ -37,29 +65,38 @@ class MsTeamsClientABC(ApiClient, ABC): CONVERSATION_URL = "/v3/conversations" MEMBER_URL = "/v3/conversations/%s/pagedmembers" - def get_channel_list(self, team_id: str): - resp = self.get(self.CHANNEL_URL % team_id) - return resp.get("conversations") + def get_channel_list(self, team_id: str) -> list[_TeamChannel]: + resp = self.get_mapping(self.CHANNEL_URL % team_id) + # justification: the Teams conversations response carries the conversation list. + return cast("list[_TeamChannel]", resp.get("conversations")) - def get_member_list(self, team_id: str, continuation_token: str | None = None): + def get_member_list( + self, team_id: str, continuation_token: str | None = None + ) -> _PagedMembersResponse: url = self.MEMBER_URL % team_id params: dict[str, int | str] = {"pageSize": 500} if continuation_token: params["continuationToken"] = continuation_token - return self.get(url, params=params) + # justification: the paged members endpoint returns the declared shape. + return cast("_PagedMembersResponse", self.get_mapping(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 = self.post_mapping(self.CONVERSATION_URL, data=data) + # justification: the conversations response contract includes an id string. + return cast(str, resp["id"]) - def send_message(self, conversation_id: str, data): - 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 +105,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,12 +122,18 @@ 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, + ) -> BaseApiResponse: headers = {"Authorization": f"Bearer {self.access_token}"} return self._request(method, path, headers=headers, data=data, params=params) @@ -97,7 +142,7 @@ def request(self, method, path, data=None, params=None): class MsTeamsClient(MsTeamsClientABC, IntegrationProxyClient): integration_name = IntegrationProviderSlug.MSTEAMS.value - def request(self, *args, **kwargs): + def request(self, *args: object, **kwargs: object) -> BaseApiResponse: try: return super().request(*args, **kwargs) except ApiError as error: @@ -105,7 +150,7 @@ def request(self, *args, **kwargs): raise translate_msteams_api_error(error) - 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 +159,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 +201,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 @@ -166,7 +211,7 @@ def __init__(self, client_id, client_secret): if tenant_id: self.base_url = f"https://login.microsoftonline.com/{tenant_id}" - def exchange_token(self): + def exchange_token(self) -> _TokenResponse: headers = {"Content-Type": "application/x-www-form-urlencoded"} data = { "client_id": self.client_id, @@ -174,7 +219,11 @@ def exchange_token(self): "grant_type": "client_credentials", "scope": "https://api.botframework.com/.default", } - return self.post(self.TOKEN_URL, data=urlencode(data), headers=headers, json=False) + # justification: the token endpoint returns the declared response shape. + return cast( + "_TokenResponse", + self.post_mapping(self.TOKEN_URL, data=urlencode(data), headers=headers, json=False), + ) class TokenData(TypedDict): @@ -222,5 +271,6 @@ class MsTeamsJwtClient(ApiClient): cache_time = 60 * 60 * 24 OPEN_ID_CONFIG_URL = "https://login.botframework.com/v1/.well-known/openidconfiguration" - def get_open_id_config(self): - return self.get_cached(self.OPEN_ID_CONFIG_URL) + def get_open_id_config(self) -> _OpenIDConfig: + # justification: the OpenID configuration endpoint returns the declared shape. + return cast("_OpenIDConfig", self.get_cached_mapping(self.OPEN_ID_CONFIG_URL)) diff --git a/src/sentry/integrations/msteams/integration.py b/src/sentry/integrations/msteams/integration.py index 83f66028eae2..0652a36b9b8e 100644 --- a/src/sentry/integrations/msteams/integration.py +++ b/src/sentry/integrations/msteams/integration.py @@ -2,7 +2,7 @@ import logging from collections.abc import Mapping -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from django.core.signing import BadSignature, SignatureExpired from django.http.request import HttpRequest @@ -90,7 +90,7 @@ ) -class MsTeamsIntegration(IntegrationInstallation, IntegrationNotificationClient): +class MsTeamsIntegration(IntegrationInstallation, IntegrationNotificationClient[AdaptiveCard]): def get_client(self) -> MsTeamsClient: return MsTeamsClient(self.model) @@ -124,7 +124,7 @@ class MsTeamsInstallParams(TypedDict): installation_type: str -class MsTeamsInitialDataSerializer(CamelSnakeSerializer): +class MsTeamsInitialDataSerializer(CamelSnakeSerializer[MsTeamsInstallParams]): """Initial pipeline data for Microsoft Teams installs. The Sentry bot in Teams renders a card with a single `signed_params` blob @@ -136,14 +136,18 @@ class MsTeamsInitialDataSerializer(CamelSnakeSerializer): def validate(self, attrs: dict[str, Any]) -> MsTeamsInstallParams: try: - return unsign(attrs["signed_params"], max_age=INSTALL_EXPIRATION_TIME, salt=SALT) + params = unsign(attrs["signed_params"], max_age=INSTALL_EXPIRATION_TIME, salt=SALT) + if not isinstance(params, dict): + raise BadSignature("Installation link payload is not an object") + # justification: the signed payload carries the MsTeamsInstallParams fields. + return cast("MsTeamsInstallParams", params) except SignatureExpired: raise serializers.ValidationError("Installation link expired") except BadSignature: raise serializers.ValidationError("Invalid installation link") -class MsTeamsAdvanceSerializer(CamelSnakeSerializer): +class MsTeamsAdvanceSerializer(CamelSnakeSerializer[dict[str, object]]): state = CharField(required=True) diff --git a/src/sentry/integrations/msteams/metrics.py b/src/sentry/integrations/msteams/metrics.py index a3625f6ec481..8763d7fa61f4 100644 --- a/src/sentry/integrations/msteams/metrics.py +++ b/src/sentry/integrations/msteams/metrics.py @@ -1,3 +1,5 @@ +from typing import NoReturn + from sentry.integrations.utils.metrics import EventLifecycle from sentry.shared_integrations.exceptions import ( ApiError, @@ -41,7 +43,7 @@ def record_lifecycle_termination_level( lifecycle.record_failure(translated_error) -def translate_msteams_api_error(error: ApiError) -> None: +def translate_msteams_api_error(error: ApiError) -> NoReturn: if isinstance(error, (ApiUnauthorized, ApiRateLimitedError)): # 401 Unauthorized means expired/invalid credentials — a configuration issue, not a failure. # TODO(ecosystem): We should batch rate-limiting on a per-organization basis diff --git a/src/sentry/integrations/msteams/unlink_identity.py b/src/sentry/integrations/msteams/unlink_identity.py index 9f68829b8d04..175dbff083b3 100644 --- a/src/sentry/integrations/msteams/unlink_identity.py +++ b/src/sentry/integrations/msteams/unlink_identity.py @@ -14,7 +14,7 @@ from .utils import get_preinstall_client -def build_unlinking_url(conversation_id, service_url, teams_user_id): +def build_unlinking_url(conversation_id: str, service_url: str, teams_user_id: str) -> str: signed_params = sign( salt=SALT, conversation_id=conversation_id, diff --git a/src/sentry/integrations/msteams/utils.py b/src/sentry/integrations/msteams/utils.py index 0f5c10080778..5e04b0c81e4b 100644 --- a/src/sentry/integrations/msteams/utils.py +++ b/src/sentry/integrations/msteams/utils.py @@ -2,6 +2,8 @@ import enum import logging +from collections.abc import Mapping +from typing import cast from sentry.incidents.typings.metric_detector import ( AlertContext, @@ -33,11 +35,12 @@ class ACTION_TYPE(str, enum.Enum): UNASSIGN = "5" -def channel_filter(channel, name): +def channel_filter(channel: Mapping[str, object], name: str) -> bool: # the general channel has no name in the list # retrieved from the REST API call - if channel.get("name"): - return name.lower() == channel.get("name").lower() + channel_name = channel.get("name") + if channel_name: + return name.lower() == cast(str, channel_name).lower() else: return name.lower() == "general" @@ -67,7 +70,7 @@ def find_channel_id(integration: Integration | RpcIntegration, name: str) -> str # handle searching for channels first channel_list = client.get_channel_list(team_id) - filtered_channels = list(filter(lambda x: channel_filter(x, name), channel_list)) + filtered_channels = [channel for channel in channel_list if channel_filter(channel, name)] if len(filtered_channels) > 0: return filtered_channels[0].get("id") @@ -138,7 +141,7 @@ def send_incident_alert_notification( return success -def get_preinstall_client(service_url): +def get_preinstall_client(service_url: str) -> MsTeamsPreInstallClient: # may want try/catch here since this makes an external API call access_token = get_token_data()["access_token"] return MsTeamsPreInstallClient(access_token, service_url) diff --git a/src/sentry/integrations/msteams/webhook.py b/src/sentry/integrations/msteams/webhook.py index ec6a9c885346..7e176b21c944 100644 --- a/src/sentry/integrations/msteams/webhook.py +++ b/src/sentry/integrations/msteams/webhook.py @@ -123,7 +123,7 @@ class MsTeamsIntegrationUnassign(MsTeamsIntegrationAnalytics): analytics.register(MsTeamsIntegrationUnassign) -def verify_signature(request) -> bool: +def verify_signature(request: Request) -> bool: # docs for jwt authentication here: https://docs.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0#bot-to-connector token = request.META.get("HTTP_AUTHORIZATION", "").replace("Bearer ", "") if not token: @@ -220,7 +220,7 @@ class MsTeamsWebhookEndpoint(Endpoint): permission_classes = () provider = IntegrationProviderSlug.MSTEAMS.value - def __init__(self, **kwargs) -> None: + def __init__(self, **kwargs: object) -> None: super().__init__(**kwargs) self._event_handlers: dict[MsTeamsEvents, Callable[[Request], Response]] = { MsTeamsEvents.MESSAGE: self._handle_message_event, @@ -230,7 +230,7 @@ def __init__(self, **kwargs) -> None: } @csrf_exempt - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: return super().dispatch(request, *args, **kwargs) def post(self, request: Request) -> Response: @@ -368,7 +368,7 @@ def _handle_unknown_event(self, request: Request) -> Response: def _verify_webhook_request(self, request: Request) -> bool: return verify_signature(request) - def _handle_personal_member_add(self, request: Request): + def _handle_personal_member_add(self, request: Request) -> Response: if not options.get("msteams.personal-installation-link.enabled"): return self.respond(status=204) @@ -517,7 +517,12 @@ def _make_action_data(self, data: Mapping[str, Any], user_id: int) -> dict[str, "unassign": MsTeamsIntegrationUnassign, } - def _issue_state_change(self, group: Group, identity: RpcIdentity, data) -> Response: + def _issue_state_change( + self, + group: Group, + identity: RpcIdentity, + data: Mapping[str, Any], # justification: webhook payload is JSON. + ) -> Response: event_write_key = ApiKey( organization_id=group.project.organization_id, scope_list=["event:write"] ) @@ -756,11 +761,17 @@ def integration_spec(self) -> MessagingIntegrationSpec: @property def conversation_id(self) -> str: - return self.data["conversation"]["id"] + conversation_id = self.data["conversation"]["id"] + if not isinstance(conversation_id, str): + raise TypeError("MSTeams conversation id is not a string") + return conversation_id @property def teams_user_id(self) -> str: - return self.data["from"]["id"] + user_id = self.data["from"]["id"] + if not isinstance(user_id, str): + raise TypeError("MSTeams user id is not a string") + return user_id def help_handler(self, input: CommandInput) -> IntegrationResponse[AdaptiveCard]: return IntegrationResponse( diff --git a/src/sentry/integrations/on_call/spec.py b/src/sentry/integrations/on_call/spec.py index a56fce8710ca..4a5d230a6e1f 100644 --- a/src/sentry/integrations/on_call/spec.py +++ b/src/sentry/integrations/on_call/spec.py @@ -7,30 +7,30 @@ class OnCallSpec(ABC): @property @abstractmethod - def provider_slug(self): + def provider_slug(self) -> IntegrationProviderSlug: raise NotImplementedError @property @abstractmethod - def action_service(self): + def action_service(self) -> ActionService: raise NotImplementedError class OpsgenieOnCallSpec(OnCallSpec): @property - def provider_slug(self): + def provider_slug(self) -> IntegrationProviderSlug: return IntegrationProviderSlug.OPSGENIE @property - def action_service(self): + def action_service(self) -> ActionService: return ActionService.OPSGENIE class PagerDutyOnCallSpec(OnCallSpec): @property - def provider_slug(self): + def provider_slug(self) -> IntegrationProviderSlug: return IntegrationProviderSlug.PAGERDUTY @property - def action_service(self): + def action_service(self) -> ActionService: return ActionService.PAGERDUTY diff --git a/src/sentry/integrations/opsgenie/actions/form.py b/src/sentry/integrations/opsgenie/actions/form.py index a750ac4968be..3ad370f79651 100644 --- a/src/sentry/integrations/opsgenie/actions/form.py +++ b/src/sentry/integrations/opsgenie/actions/form.py @@ -36,7 +36,8 @@ class OpsgenieNotifyTeamForm(forms.Form): account = forms.ChoiceField(choices=(), widget=forms.Select()) team = forms.ChoiceField(required=False, choices=(), widget=forms.Select()) - def __init__(self, *args, **kwargs): + # justification: Django form kwargs are dynamic. + def __init__(self, *args: Any, **kwargs: Any) -> None: self.org_id = kwargs.pop("org_id") self._integrations = [(i.id, i.name) for i in kwargs.pop("integrations")] self._teams = kwargs.pop("teams") diff --git a/src/sentry/integrations/opsgenie/actions/notification.py b/src/sentry/integrations/opsgenie/actions/notification.py index 30337788f502..3e5ea6b81109 100644 --- a/src/sentry/integrations/opsgenie/actions/notification.py +++ b/src/sentry/integrations/opsgenie/actions/notification.py @@ -2,7 +2,7 @@ import logging from collections.abc import Generator, Sequence -from typing import cast +from typing import Any, cast import sentry_sdk @@ -32,7 +32,8 @@ class OpsgenieNotifyTeamAction(IntegrationEventAction): provider = IntegrationProviderSlug.OPSGENIE.value integration_key = "account" - def __init__(self, *args, **kwargs): + # justification: Django form kwargs are dynamic. + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.form_fields = { "account": { diff --git a/src/sentry/integrations/opsgenie/client.py b/src/sentry/integrations/opsgenie/client.py index 2471e4495cc3..49a1a98b4c9f 100644 --- a/src/sentry/integrations/opsgenie/client.py +++ b/src/sentry/integrations/opsgenie/client.py @@ -1,8 +1,8 @@ from __future__ import annotations import logging -from collections.abc import Sequence -from typing import Literal +from collections.abc import Mapping, Sequence +from typing import Literal, NotRequired, TypedDict from sentry.integrations.client import ApiClient from sentry.integrations.models.integration import Integration @@ -16,6 +16,7 @@ from sentry.notifications.utils.links import create_link_to_workflow from sentry.services.eventstore.models import Event, GroupEvent from sentry.shared_integrations.exceptions import ApiError +from sentry.shared_integrations.response.base import BaseApiResponse OPSGENIE_API_VERSION = "v2" # Defaults to P3 if null, but we can be explicit - https://docs.opsgenie.com/docs/alert-api @@ -23,6 +24,17 @@ OpsgeniePriority = Literal["P1", "P2", "P3", "P4", "P5"] + +class _IssueAlertPayload(TypedDict): + message: str | None + source: str + priority: OpsgeniePriority | None + details: dict[str, object] + tags: list[str] + alias: NotRequired[str] + entity: NotRequired[str] + + logger = logging.getLogger("sentry.integrations.opsgenie") # Opsgenie/JSM caps the total size of `details`, so keep the flags entry bounded. @@ -53,10 +65,10 @@ def __init__(self, integration: RpcIntegration | Integration, integration_key: s super().__init__(integration_id=self.integration.id) @property - def metadata(self): + def metadata(self) -> Mapping[str, object]: return self.integration.metadata - def _get_auth_headers(self): + 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,17 +110,17 @@ def _get_workflow_links( def build_issue_alert_payload( self, - data, + data: Event | GroupEvent, rules: Sequence[NotificationOrigin], event: Event | GroupEvent, group: Group | None, priority: OpsgeniePriority | None = "P3", notification_uuid: str | None = None, - ): + ) -> _IssueAlertPayload: feature_flags = format_feature_flags_detail(get_event_feature_flags(event)) feature_flags_context = {"Feature Flags": feature_flags} if feature_flags else {} - payload = { + payload: _IssueAlertPayload = { "message": event.message or event.title, "source": "Sentry", "priority": priority, @@ -148,7 +160,7 @@ def build_issue_alert_payload( } return payload - def send_notification(self, data): + def send_notification(self, data: Mapping[str, object]) -> BaseApiResponse: headers = self._get_auth_headers() with record_event(OnCallInteractionType.CREATE).capture() as lifecycle: try: @@ -158,7 +170,7 @@ def send_notification(self, data): raise # TODO(iamrajjoshi): We need to delete this method during notification platform - def send_metric_alert_notification(self, data): + def send_metric_alert_notification(self, data: Mapping[str, object]) -> BaseApiResponse: headers = self._get_auth_headers() # If closing an alert (when Sentry alert was resolved) diff --git a/src/sentry/integrations/opsgenie/integration.py b/src/sentry/integrations/opsgenie/integration.py index b355e9c16fca..820331897083 100644 --- a/src/sentry/integrations/opsgenie/integration.py +++ b/src/sentry/integrations/opsgenie/integration.py @@ -192,7 +192,7 @@ def _get_debug_metadata_keys(self) -> list[str]: ] -class OpsgenieInstallationApiSerializer(CamelSnakeSerializer): +class OpsgenieInstallationApiSerializer(CamelSnakeSerializer[dict[str, str]]): base_url = ChoiceField(choices=BASE_URL_CHOICES, required=True) provider = CharField(required=True) api_key = CharField(required=False, allow_blank=True, default="") diff --git a/src/sentry/integrations/opsgenie/utils.py b/src/sentry/integrations/opsgenie/utils.py index ac5e674f0dcf..22965421acf0 100644 --- a/src/sentry/integrations/opsgenie/utils.py +++ b/src/sentry/integrations/opsgenie/utils.py @@ -79,11 +79,13 @@ def get_team( if not org_integration: return None teams = org_integration.config.get("team_table") - if not teams: + if not isinstance(teams, list): return None for team in teams: - if team["id"] == team_id: - return team + if not isinstance(team, dict): + continue + if team.get("id") == team_id: + return {str(key): str(value) for key, value in team.items()} return None diff --git a/src/sentry/integrations/pagerduty/actions/notification.py b/src/sentry/integrations/pagerduty/actions/notification.py index 3e012a58b8ee..64e909e25176 100644 --- a/src/sentry/integrations/pagerduty/actions/notification.py +++ b/src/sentry/integrations/pagerduty/actions/notification.py @@ -63,7 +63,8 @@ def _get_service(self) -> PagerDutyService | None: return None for pds in oi.config.get("pagerduty_services", []): if str(pds["id"]) == str(self.get_option("service")): - return pds + # justification: org integration config stores PagerDutyService payloads. + return cast("PagerDutyService", pds) return None def after( diff --git a/src/sentry/integrations/pagerduty/client.py b/src/sentry/integrations/pagerduty/client.py index 39debaca24e4..e9963df74750 100644 --- a/src/sentry/integrations/pagerduty/client.py +++ b/src/sentry/integrations/pagerduty/client.py @@ -3,14 +3,13 @@ from enum import StrEnum from typing import Any -from requests import Response - from sentry.api.serializers import ExternalEventSerializer, serialize from sentry.integrations.client import ApiClient from sentry.integrations.on_call.metrics import OnCallInteractionType from sentry.integrations.pagerduty.metrics import record_event from sentry.integrations.types import IntegrationProviderSlug from sentry.services.eventstore.models import Event, GroupEvent +from sentry.shared_integrations.response.base import BaseApiResponse type PagerDutyEventPayload = dict[str, Any] @@ -48,7 +47,7 @@ def request(self, *args: Any, **kwargs: Any) -> Any: kwargs.setdefault("headers", {"Content-Type": "application/json"}) return self._request(*args, **kwargs) - def send_trigger(self, data: PagerDutyEventPayload) -> Response: + def send_trigger(self, data: PagerDutyEventPayload) -> BaseApiResponse: with record_event(OnCallInteractionType.CREATE).capture(): return self.post("/", data=data) diff --git a/src/sentry/integrations/pagerduty/integration.py b/src/sentry/integrations/pagerduty/integration.py index a68a2bd4adc0..54327642f44d 100644 --- a/src/sentry/integrations/pagerduty/integration.py +++ b/src/sentry/integrations/pagerduty/integration.py @@ -179,7 +179,9 @@ def _get_debug_metadata_keys(self) -> list[str]: @property def services(self) -> list[PagerDutyServiceDict]: if self.org_integration: - return self.org_integration.config.get("pagerduty_services", []) + services = self.org_integration.config.get("pagerduty_services", []) + if isinstance(services, list): + return services return [] diff --git a/src/sentry/integrations/pagerduty/utils.py b/src/sentry/integrations/pagerduty/utils.py index b4672d73fe09..236beaf9fea9 100644 --- a/src/sentry/integrations/pagerduty/utils.py +++ b/src/sentry/integrations/pagerduty/utils.py @@ -74,7 +74,8 @@ def get_services( ) -> list[PagerDutyServiceDict]: if not org_integration: return [] - return org_integration.config.get("pagerduty_services", []) + services = org_integration.config.get("pagerduty_services", []) + return services if isinstance(services, list) else [] def get_service( diff --git a/src/sentry/integrations/perforce/client.py b/src/sentry/integrations/perforce/client.py index cef5faf783fe..a9df46260680 100644 --- a/src/sentry/integrations/perforce/client.py +++ b/src/sentry/integrations/perforce/client.py @@ -6,7 +6,7 @@ from collections.abc import Generator, Sequence from contextlib import contextmanager from datetime import datetime, timezone -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration @@ -336,7 +336,8 @@ def check_file(self, repo: Repository, path: str, version: str | None) -> object # Verify result contains actual file data (not just warnings) # When exception_level=1, warnings are returned in result list if result and len(result) > 0 and "depotFile" in result[0]: - return result[0] + # justification: p4 returns decoded command output. + return cast("object", result[0]) return None except P4Exception: diff --git a/src/sentry/integrations/perforce/repository.py b/src/sentry/integrations/perforce/repository.py index 233150265a8f..6cd4e972d33e 100644 --- a/src/sentry/integrations/perforce/repository.py +++ b/src/sentry/integrations/perforce/repository.py @@ -3,7 +3,7 @@ import logging from collections.abc import Mapping, MutableMapping, Sequence from datetime import datetime, timezone -from typing import Any +from typing import Any, cast from sentry.integrations.perforce.client import ( P4ChangeInfo, @@ -53,7 +53,8 @@ def _get_client_from_repo(self, repo: Repository) -> PerforceClient: raise NotImplementedError("Integration not found") installation = integration.get_installation(organization_id=repo.organization_id) - return installation.get_client() + # justification: the Perforce integration installation returns a PerforceClient. + return cast("PerforceClient", installation.get_client()) def get_repository_data( self, organization: Organization, config: MutableMapping[str, Any] @@ -286,4 +287,5 @@ def pull_request_url(self, repo: Repository, pull_request: PullRequest) -> str | def repository_external_slug(self, repo: Repository) -> str: """Get external slug for repository.""" - return repo.config.get("depot_path", repo.name) + depot_path = repo.config.get("depot_path", repo.name) + return depot_path if isinstance(depot_path, str) else repo.name diff --git a/src/sentry/integrations/pipeline.py b/src/sentry/integrations/pipeline.py index b1fee6aacbc7..0011f3664d0e 100644 --- a/src/sentry/integrations/pipeline.py +++ b/src/sentry/integrations/pipeline.py @@ -385,10 +385,10 @@ def _finish_pipeline(self, data: IntegrationData) -> HttpResponseBase: return self._get_redirect_response(redirect_url_format=redirect_url_format) return self._dialog_success(org_integration) - def _dialog_success(self, org_integration) -> HttpResponseBase: + def _dialog_success(self, org_integration: OrganizationIntegration) -> HttpResponseBase: return self._dialog_response(serialize(org_integration, self.request.user), True) - def _dialog_response(self, data, success) -> HttpResponseBase: + def _dialog_response(self, data: object, success: bool) -> HttpResponseBase: document_origin = "document.origin" if features.has("system:multi-region"): document_origin = f'"{generate_organization_url(self.organization.slug)}"' diff --git a/src/sentry/integrations/services/github_copilot_identity/service.py b/src/sentry/integrations/services/github_copilot_identity/service.py index 4cc082e51652..91cd40915eae 100644 --- a/src/sentry/integrations/services/github_copilot_identity/service.py +++ b/src/sentry/integrations/services/github_copilot_identity/service.py @@ -4,6 +4,7 @@ # defined, because we want to reflect on type annotations and avoid forward references. import abc +from typing import cast from django.conf import settings @@ -25,7 +26,8 @@ def get_local_implementation(cls) -> RpcService: class_path = getattr(settings, "GITHUB_COPILOT_IDENTITY_SERVICE", None) if class_path is None: return _DefaultGitHubCopilotIdentityService() - impl_class = import_string(class_path) + # justification: the setting names an RpcService implementation class. + impl_class = cast("type[RpcService]", import_string(class_path)) return impl_class() @rpc_method diff --git a/src/sentry/integrations/slack/actions/notification.py b/src/sentry/integrations/slack/actions/notification.py index 44fe8f6d7543..2c70d09c1cd7 100644 --- a/src/sentry/integrations/slack/actions/notification.py +++ b/src/sentry/integrations/slack/actions/notification.py @@ -70,7 +70,7 @@ def _build_notification_blocks( self, event: GroupEvent, rules: Sequence[NotificationOrigin], - tags: set, + tags: set[str], integration: RpcIntegration, notification_uuid: str | None = None, send_nudge: bool = False, @@ -161,12 +161,12 @@ def _send_notification( self, event: GroupEvent, futures: Sequence[RuleFuture], - tags: set, + tags: set[str], integration: RpcIntegration, channel: str, notification_uuid: str | None = None, notification_message_object: NewNotificationActionNotificationMessage | None = None, - save_notification_method: Callable | None = None, + save_notification_method: Callable[..., None] | None = None, thread_ts: str | None = None, send_nudge: bool = False, ) -> None: @@ -224,7 +224,7 @@ def _send_notification_action_notification( self, event: GroupEvent, futures: Sequence[RuleFuture], - tags: set, + tags: set[str], integration: RpcIntegration, channel: str, notification_uuid: str | None = None, diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 49ebd4519ab1..11b2772c5ff4 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -81,7 +81,11 @@ ) -class SlackIntegration(NotifyBasicMixin, IntegrationInstallation, IntegrationNotificationClient): +class SlackIntegration( + NotifyBasicMixin, + IntegrationInstallation, + IntegrationNotificationClient[SlackRenderable, dict[str, Any]], +): def get_client(self) -> SlackSdkClient: return SlackSdkClient(integration_id=self.model.id) @@ -232,7 +236,7 @@ def has_history_scope(self, channel_id: str) -> bool: scopes=self.model.metadata.get("scopes"), ) - def get_conversations_info(self, *, channel_id: str) -> dict: + def get_conversations_info(self, *, channel_id: str) -> dict[str, Any]: return workspace.get_conversations_info(integration_id=self.model.id, channel_id=channel_id) def get_thread_history( diff --git a/src/sentry/integrations/slack/message_builder/base/block.py b/src/sentry/integrations/slack/message_builder/base/block.py index af471bcc1417..551f7835fcb6 100644 --- a/src/sentry/integrations/slack/message_builder/base/block.py +++ b/src/sentry/integrations/slack/message_builder/base/block.py @@ -50,7 +50,7 @@ def get_markdown_quote_block(text: str, max_block_text_length: int) -> SlackBloc @staticmethod def get_tags_block( - tags: Sequence[Mapping[str, str | bool]], block_id: dict[str, Any] | None = None + tags: Sequence[Mapping[str, str | bool | None]], block_id: dict[str, Any] | None = None ) -> SlackBlock: text = "" for tag in tags: @@ -77,16 +77,18 @@ def get_divider() -> SlackBlock: return {"type": "divider"} @staticmethod - def get_external_select_action(action, initial_option): - action = { + def get_external_select_action( + action: MessageAction, initial_option: Mapping[str, object] | None + ) -> SlackBlock: + block: SlackBlock = { "type": "external_select", "placeholder": {"type": "plain_text", "text": action.label, "emoji": True}, "action_id": action.action_id if action.action_id else action.name, } if initial_option: - action["initial_option"] = initial_option + block["initial_option"] = initial_option - return action + return block @staticmethod def get_button_action(action: MessageAction) -> SlackBlock: diff --git a/src/sentry/integrations/slack/message_builder/issues.py b/src/sentry/integrations/slack/message_builder/issues.py index 5fc1c8ed4170..bddd4fcde624 100644 --- a/src/sentry/integrations/slack/message_builder/issues.py +++ b/src/sentry/integrations/slack/message_builder/issues.py @@ -2,8 +2,8 @@ import logging from collections.abc import Callable, Mapping, Sequence -from datetime import datetime -from typing import Any, TypedDict +from datetime import date, datetime +from typing import Any, TypedDict, cast import orjson from django.core.exceptions import ObjectDoesNotExist @@ -86,15 +86,20 @@ def get_group_users_count(group: Group, rules: list[NotificationOrigin] | None = # NOTE: if this starts getting large and functions get complicated, # pull things out into their own functions -SUPPORTED_CONTEXT_DATA: dict[NotificationContextField, Callable] = { +ContextDataFunc = Callable[[Group, list[NotificationOrigin] | None], str | int | date] + + +SUPPORTED_CONTEXT_DATA: dict[NotificationContextField, ContextDataFunc] = { NotificationContextField.EVENTS: lambda group, rules: get_group_global_count(group), NotificationContextField.USERS_AFFECTED: get_group_users_count, - NotificationContextField.STATE: lambda group, rules: SUBSTATUS_TO_STR.get(group.substatus, "") + NotificationContextField.STATE: lambda group, rules: ( + SUBSTATUS_TO_STR.get(group.substatus, "") if group.substatus is not None else "" + ) .replace("_", " ") .title(), NotificationContextField.FIRST_SEEN: lambda group, rules: time_since(group.first_seen), NotificationContextField.APPROX_START_TIME: lambda group, rules: datetime.fromtimestamp( - get_approx_start_time(group=group) + cast(int, get_approx_start_time(group=group)) ).strftime("%Y-%m-%d %H:%M:%S"), # format moment into YYYY-mm-dd h:m:s } @@ -164,7 +169,7 @@ def format_release_tag(value: str, event: Event | GroupEvent | None) -> str: def get_tags( event_for_tags: Event | GroupEvent | None, tags: set[str] | list[tuple[str]] | None = None, -) -> Sequence[Mapping[str, str | bool]]: +) -> Sequence[Mapping[str, str | bool | None]]: """Get tag keys and values for block kit""" fields = [] if not tags: @@ -183,6 +188,7 @@ def get_tags( continue labeled_value = tagstore.backend.get_tag_value_label(key, value) if std_key == "release": + assert labeled_value is not None labeled_value = format_release_tag(labeled_value, event_for_tags) fields.append( { @@ -207,7 +213,7 @@ def get_context(group: Group, rules: list[NotificationOrigin] | None = None) -> if NotificationContextField.STATE in context: state = SUPPORTED_CONTEXT_DATA[NotificationContextField.STATE](group, rules) if NotificationContextField.EVENTS in context: - event_count = SUPPORTED_CONTEXT_DATA[NotificationContextField.EVENTS](group, rules) + event_count = str(SUPPORTED_CONTEXT_DATA[NotificationContextField.EVENTS](group, rules)) if (state and state == "New") or (event_count and int(event_count) <= 1): if NotificationContextField.EVENTS in context: @@ -219,7 +225,7 @@ def get_context(group: Group, rules: list[NotificationOrigin] | None = None) -> for c in context: if c in SUPPORTED_CONTEXT_DATA: - v = SUPPORTED_CONTEXT_DATA[c](group, rules) + v = SUPPORTED_CONTEXT_DATA[NotificationContextField(c)](group, rules) if v: context_text += f"{c}: *{v}* " @@ -485,7 +491,7 @@ def get_culprit_block(self, event_or_group: Event | GroupEvent | Group) -> Slack return self.get_context_block(event_or_group.culprit) return None - def get_text_block(self, text) -> SlackBlock: + def get_text_block(self, text: str) -> SlackBlock: if self.group.issue_category == GroupCategory.FEEDBACK: max_block_text_length = USER_FEEDBACK_MAX_BLOCK_TEXT_LENGTH else: diff --git a/src/sentry/integrations/slack/requests/action.py b/src/sentry/integrations/slack/requests/action.py index 73d6e095e310..eb8ac92bd400 100644 --- a/src/sentry/integrations/slack/requests/action.py +++ b/src/sentry/integrations/slack/requests/action.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from collections.abc import Mapping from typing import Any import orjson @@ -142,5 +143,8 @@ def get_action_ts(self) -> str | None: actions = self.data.get("actions", []) if actions and isinstance(actions, list) and len(actions) > 0: (action,) = actions - return action.get("action_ts") + if isinstance(action, Mapping): + action_ts = action.get("action_ts") + if isinstance(action_ts, str): + return action_ts return None diff --git a/src/sentry/integrations/slack/requests/base.py b/src/sentry/integrations/slack/requests/base.py index 34f8814c3619..904c66406a47 100644 --- a/src/sentry/integrations/slack/requests/base.py +++ b/src/sentry/integrations/slack/requests/base.py @@ -24,6 +24,11 @@ _logger = logging.getLogger(__name__) +def string_field(data: Mapping[str, object], key: str, default: str = "") -> str: + value = data.get(key, default) + return value if isinstance(value, str) else default + + def _get_field_id_option(data: Mapping[str, Any], field_name: str) -> str | None: id_option: str | None = data.get(f"{field_name}_id") or get_path(data, field_name, "id") return id_option @@ -137,7 +142,7 @@ def channel_id(self) -> str | None: @property def response_url(self) -> str: - return self.data.get("response_url", "") + return string_field(self.data, "response_url") @property def team_id(self) -> str | None: @@ -282,15 +287,15 @@ def dm_data(self) -> Mapping[str, Any]: @property def type(self) -> str: - return self.dm_data.get("type", "") + return string_field(self.dm_data, "type") @property def text(self) -> str: - return self.dm_data.get("text", "") + return string_field(self.dm_data, "text") @property def channel_name(self) -> str: - return self.dm_data.get("channel_name", "") + return string_field(self.dm_data, "channel_name") def get_command_and_args(self) -> tuple[str, Sequence[str]]: command = self.text.lower().split() diff --git a/src/sentry/integrations/slack/requests/event.py b/src/sentry/integrations/slack/requests/event.py index 4a0402b0d3e9..2caeb059f381 100644 --- a/src/sentry/integrations/slack/requests/event.py +++ b/src/sentry/integrations/slack/requests/event.py @@ -11,7 +11,11 @@ from sentry.integrations.messaging.metrics import SeerSlackHaltReason from sentry.integrations.services.integration import integration_service from sentry.integrations.services.integration.model import RpcIntegration -from sentry.integrations.slack.requests.base import SlackDMRequest, SlackRequestError +from sentry.integrations.slack.requests.base import ( + SlackDMRequest, + SlackRequestError, + string_field, +) from sentry.integrations.slack.unfurl.handlers import match_link from sentry.integrations.slack.unfurl.types import LinkType from sentry.integrations.slack.utils.constants import SlackScope @@ -46,7 +50,8 @@ def has_dashboard_links(links: list[str]) -> bool: def is_event_challenge(data: Mapping[str, Any]) -> bool: - return data.get("type", "") == "url_verification" + event_type = data.get("type", "") + return isinstance(event_type, str) and event_type == "url_verification" class SeerResolutionResult(NamedTuple): @@ -334,25 +339,29 @@ def resolve_seer_organization(self) -> SeerResolutionResult: @property def dm_data(self) -> Mapping[str, Any]: - return self.data.get("event", {}) + event = self.data.get("event", {}) + return event if isinstance(event, Mapping) else {} @property def channel_id(self) -> str: if self.is_assistant_thread_event: - return self.dm_data.get("assistant_thread", {}).get("channel_id", "") - return self.dm_data.get("channel", "") + thread = self.dm_data.get("assistant_thread", {}) + return string_field(thread, "channel_id") if isinstance(thread, Mapping) else "" + return string_field(self.dm_data, "channel") @property def user_id(self) -> str: if self.is_assistant_thread_event: - return self.dm_data.get("assistant_thread", {}).get("user_id", "") - return self.dm_data.get("user", "") + thread = self.dm_data.get("assistant_thread", {}) + return string_field(thread, "user_id") if isinstance(thread, Mapping) else "" + return string_field(self.dm_data, "user") @property def thread_ts(self) -> str: if self.is_assistant_thread_event: - return self.dm_data.get("assistant_thread", {}).get("thread_ts", "") - return self.dm_data.get("thread_ts", "") + thread = self.dm_data.get("assistant_thread", {}) + return string_field(thread, "thread_ts") if isinstance(thread, Mapping) else "" + return string_field(self.dm_data, "thread_ts") @property def has_assistant_scope(self) -> bool: diff --git a/src/sentry/integrations/slack/sdk_client.py b/src/sentry/integrations/slack/sdk_client.py index b1c105cbd2a8..e1de403bffd3 100644 --- a/src/sentry/integrations/slack/sdk_client.py +++ b/src/sentry/integrations/slack/sdk_client.py @@ -1,6 +1,8 @@ import logging +from collections.abc import Callable from functools import wraps from types import FunctionType +from typing import cast from slack_sdk import WebClient from slack_sdk.errors import SlackApiError @@ -49,9 +51,9 @@ def is_response_fatal(response: SlackResponse) -> bool: return False -def wrapper(method: FunctionType): +def wrapper(method: FunctionType) -> Callable[..., SlackResponse]: @wraps(method) - def wrapped(*args, **kwargs): + def wrapped(*args: object, **kwargs: object) -> SlackResponse: try: response = method(*args, **kwargs) track_response_data(response=response, method=method.__name__) @@ -69,19 +71,20 @@ def wrapped(*args, **kwargs): ) raise - return response + # justification: the wrapped slack_sdk method returns a SlackResponse. + return cast("SlackResponse", response) return wrapped -def wrap_api_call(cls): +def wrap_api_call(cls: type) -> None: for name, attribute in vars(cls).items(): if isinstance(attribute, FunctionType) and attribute.__name__ in SLACK_SDK_WRAP_METHODS: setattr(cls, name, wrapper(attribute)) class MetaClass(type): - def __new__(meta, name, bases, dct): + def __new__(meta, name: str, bases: tuple[type, ...], dct: dict[str, object]) -> type: cls = super().__new__(meta, name, bases, dct) for parent in cls.__bases__: for base in parent.__bases__: diff --git a/src/sentry/integrations/slack/service.py b/src/sentry/integrations/slack/service.py index 085e04db5479..8b48aa763b37 100644 --- a/src/sentry/integrations/slack/service.py +++ b/src/sentry/integrations/slack/service.py @@ -450,7 +450,7 @@ def _get_notification_message_to_send(self, activity: Activity) -> str | None: ) return None - return text_description + return text_description if isinstance(text_description, str) else None def notify_recipient( self, diff --git a/src/sentry/integrations/slack/tasks/send_notifications_on_activity.py b/src/sentry/integrations/slack/tasks/send_notifications_on_activity.py index 6ec5305e9de6..bc5c10017e20 100644 --- a/src/sentry/integrations/slack/tasks/send_notifications_on_activity.py +++ b/src/sentry/integrations/slack/tasks/send_notifications_on_activity.py @@ -24,7 +24,7 @@ processing_deadline_duration=30, silo_mode=SiloMode.CELL, ) -def send_activity_notifications_to_slack_threads(activity_id) -> None: +def send_activity_notifications_to_slack_threads(activity_id: int) -> None: log_params = {"activity_id": activity_id} try: @@ -55,7 +55,7 @@ def send_activity_notifications_to_slack_threads(activity_id) -> None: ) -def activity_created_receiver(instance, created, **kwargs) -> None: +def activity_created_receiver(instance: Activity, created: bool, **kwargs: object) -> None: """ If an activity is created for an issue, this will trigger, and we can kick off an async process """ diff --git a/src/sentry/integrations/slack/threads/activity_notifications.py b/src/sentry/integrations/slack/threads/activity_notifications.py index 10c1a04ec9ce..4db2878e8c38 100644 --- a/src/sentry/integrations/slack/threads/activity_notifications.py +++ b/src/sentry/integrations/slack/threads/activity_notifications.py @@ -70,7 +70,7 @@ def get_link(self) -> str: }, ) link = "" - return link + return link if isinstance(link, str) else "" def get_provider(self) -> str: """ @@ -92,7 +92,11 @@ def get_provider(self) -> str: ) provider = self.DEFAULT_PROVIDER_FALLBACK_TEXT - return provider.lower() + return ( + provider.lower() + if isinstance(provider, str) + else self.DEFAULT_PROVIDER_FALLBACK_TEXT.lower() + ) def get_ticket_number(self) -> str: """ @@ -112,7 +116,7 @@ def get_ticket_number(self) -> str: ) ticket_number = "" - return ticket_number + return ticket_number if isinstance(ticket_number, str) else "" def get_formatted_provider_name(self) -> str: # Make sure to make the proper noun have correct capitalization diff --git a/src/sentry/integrations/slack/unfurl/dashboards.py b/src/sentry/integrations/slack/unfurl/dashboards.py index 4684406273ae..df383e13a4f1 100644 --- a/src/sentry/integrations/slack/unfurl/dashboards.py +++ b/src/sentry/integrations/slack/unfurl/dashboards.py @@ -145,10 +145,12 @@ def _unfurl_dashboards( if not org: continue + assert org.id is not None widget = _get_widget(org.id, args["dashboard_id"], args["widget_index"]) if widget is None: continue + assert widget.widget_type is not None config = _WIDGET_TYPE_TO_CONFIG.get(widget.widget_type) if config is None: continue @@ -245,6 +247,7 @@ def build_widget_timeseries_params( widget: DashboardWidget, url_params: QueryDict ) -> list[dict[str, str | list[str]]]: """Build one timeseries param dict per widget query.""" + assert widget.widget_type is not None config = _WIDGET_TYPE_TO_CONFIG.get(widget.widget_type) if config is None: raise ValueError(f"Unsupported widget type: {widget.widget_type}") diff --git a/src/sentry/integrations/slack/utils/channel.py b/src/sentry/integrations/slack/utils/channel.py index 23498d145133..359bbdb7ce8e 100644 --- a/src/sentry/integrations/slack/utils/channel.py +++ b/src/sentry/integrations/slack/utils/channel.py @@ -363,7 +363,8 @@ def check_for_channel( sample_rate=1.0, tags={"type": "chat_deleteScheduledMessage"}, ) - return msg_response["channel"] + channel = msg_response["channel"] + return channel if isinstance(channel, str) else None except SlackApiError as e: metrics.incr( SLACK_UTILS_CHANNEL_FAILURE_DATADOG_METRIC, diff --git a/src/sentry/integrations/slack/utils/errors.py b/src/sentry/integrations/slack/utils/errors.py index e842e5933b01..802acd6acd03 100644 --- a/src/sentry/integrations/slack/utils/errors.py +++ b/src/sentry/integrations/slack/utils/errors.py @@ -1,5 +1,7 @@ import logging +from collections.abc import Callable from dataclasses import dataclass +from typing import cast from sentry_sdk import capture_message from slack_sdk.errors import SlackApiError, SlackRequestError @@ -7,6 +9,14 @@ _logger = logging.getLogger(__name__) +class TypedSlackApiError(SlackApiError): + """SlackApiError with the constructor contract slack_sdk omits.""" + + def __init__(self, message: str, response: object) -> None: + # justification: slack_sdk does not annotate SlackApiError.__init__. + cast("Callable[[str, object], None]", super().__init__)(message, response) + + @dataclass(frozen=True, eq=True) class SlackSdkErrorCategory: message: str diff --git a/src/sentry/integrations/slack/views/link_identity.py b/src/sentry/integrations/slack/views/link_identity.py index 679b08f9e732..796e24209bad 100644 --- a/src/sentry/integrations/slack/views/link_identity.py +++ b/src/sentry/integrations/slack/views/link_identity.py @@ -44,7 +44,7 @@ def pop_link_identity_response_url(integration_id: int, slack_user_id: str) -> s response_url = cache.get(key) if response_url: cache.delete(key) - return response_url + return response_url if isinstance(response_url, str) else None def build_linking_url( diff --git a/src/sentry/integrations/slack/webhooks/action.py b/src/sentry/integrations/slack/webhooks/action.py index c721bf6df768..070803b11192 100644 --- a/src/sentry/integrations/slack/webhooks/action.py +++ b/src/sentry/integrations/slack/webhooks/action.py @@ -186,7 +186,7 @@ def _is_message(data: Mapping[str, Any]) -> bool: """ Bot posted messages will not have the type at all. """ - return data.get("original_message", {}).get("type") == "message" + return bool(data.get("original_message", {}).get("type") == "message") @all_silo_endpoint # Only LINK_IDENTITY is handled at control diff --git a/src/sentry/integrations/slack/webhooks/options_load.py b/src/sentry/integrations/slack/webhooks/options_load.py index db5a60a499c3..fe4ab86f4a20 100644 --- a/src/sentry/integrations/slack/webhooks/options_load.py +++ b/src/sentry/integrations/slack/webhooks/options_load.py @@ -37,7 +37,7 @@ class SlackOptionsLoadEndpoint(Endpoint): permission_classes = () slack_request_class = SlackOptionsLoadRequest - def is_substring(self, string, substring): + def is_substring(self, string: str, substring: str) -> bool: # in case either have special characters, we want to preserve the strings # as is, so we escape both before applying re.match substring = re.escape(substring) diff --git a/src/sentry/integrations/slack/workspace.py b/src/sentry/integrations/slack/workspace.py index 6c2cb0e21d40..97755ac5c994 100644 --- a/src/sentry/integrations/slack/workspace.py +++ b/src/sentry/integrations/slack/workspace.py @@ -9,8 +9,8 @@ from __future__ import annotations import logging -from collections.abc import Sequence -from typing import Any +from collections.abc import Mapping, Sequence +from typing import Any, cast from slack_sdk.errors import SlackApiError from slack_sdk.web import SlackResponse @@ -26,7 +26,7 @@ _logger = logging.getLogger("sentry.integrations.slack") -def get_conversations_info(*, integration_id: int, channel_id: str) -> dict: +def get_conversations_info(*, integration_id: int, channel_id: str) -> dict[str, object]: """Fetch conversations.info. Returns an empty dict on failure.""" try: client = SlackSdkClient(integration_id=integration_id) @@ -81,8 +81,11 @@ def has_history_scope( if SlackScope.CHANNELS_HISTORY in installed and SlackScope.GROUPS_HISTORY in installed: return True - channel_info = get_conversations_info(integration_id=integration_id, channel_id=channel_id).get( - "channel", {} + channel_info = cast( + Mapping[str, object], + get_conversations_info(integration_id=integration_id, channel_id=channel_id).get( + "channel", {} + ), ) is_channel = channel_info.get("is_channel", False) is_private = channel_info.get("is_private", False) diff --git a/src/sentry/integrations/source_code_management/commit_context.py b/src/sentry/integrations/source_code_management/commit_context.py index 92193c4ce092..ece13fe77545 100644 --- a/src/sentry/integrations/source_code_management/commit_context.py +++ b/src/sentry/integrations/source_code_management/commit_context.py @@ -528,7 +528,10 @@ def _get_top_5_issues_by_count_snuba( .set_limit(5) ), ) - return raw_snql_query(request, referrer=self.referrer.value)["data"] + data = raw_snql_query(request, referrer=self.referrer.value)["data"] + if not isinstance(data, list): + raise ValueError("Snuba returned no data array") + return data def _get_top_5_issues_by_count_eap( self, issue_ids: list[int], project: Project diff --git a/src/sentry/integrations/source_code_management/issues.py b/src/sentry/integrations/source_code_management/issues.py index 8905f7e76d63..ec3ca4309cfe 100644 --- a/src/sentry/integrations/source_code_management/issues.py +++ b/src/sentry/integrations/source_code_management/issues.py @@ -94,9 +94,11 @@ def get_project_defaults(self, project_id: int | str) -> dict[str, str]: if not self.org_integration: return {} - return self.org_integration.config.get("project_issue_defaults", {}).get( - str(project_id), {} - ) + defaults = self.org_integration.config.get("project_issue_defaults", {}) + if not isinstance(defaults, dict): + return {} + project_defaults = defaults.get(str(project_id), {}) + return project_defaults if isinstance(project_defaults, dict) else {} def create_default_repo_choice(self, default_repo: str) -> tuple[str, str]: """ diff --git a/src/sentry/integrations/source_code_management/repo_trees.py b/src/sentry/integrations/source_code_management/repo_trees.py index a2498bc0712f..c06a2e4e0bf5 100644 --- a/src/sentry/integrations/source_code_management/repo_trees.py +++ b/src/sentry/integrations/source_code_management/repo_trees.py @@ -356,7 +356,8 @@ def get_repo_files_from_cache(self, repo_full_name: str) -> list[str] | None: """ key = f"{self.integration_name}:repo:{repo_full_name}:source-code" if cache.has_key(key): - return cache.get(key, []) + files = cache.get(key, []) + return files if isinstance(files, list) else [] return None diff --git a/src/sentry/integrations/source_code_management/repository.py b/src/sentry/integrations/source_code_management/repository.py index a1ad02993db0..75dd9b2a325c 100644 --- a/src/sentry/integrations/source_code_management/repository.py +++ b/src/sentry/integrations/source_code_management/repository.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from collections.abc import Mapping -from typing import Any, Generic, Literal, NotRequired, TypedDict, TypeVar +from typing import Any, Generic, NotRequired, TypedDict, TypeVar from urllib.parse import quote as urlquote from urllib.parse import unquote, urlparse, urlunparse @@ -16,7 +16,7 @@ SCMIntegrationInteractionEvent, SCMIntegrationInteractionType, ) -from sentry.integrations.types import IntegrationProviderSlug +from sentry.integrations.types import HaltReason, IntegrationProviderSlug from sentry.models.repository import Repository from sentry.shared_integrations.exceptions import ( ApiConnectionResetError, @@ -33,20 +33,6 @@ ) from sentry.users.models.identity import Identity -HaltReason = Literal[ - "configuration_error", - "connection_reset", - "host_timeout", - "host_unreachable", - "identity_not_found", - "identity_not_valid", - "installation_suspended", - "rate_limited", - "too_many_redirects", - "unauthorized", - "unsupported_response", -] - class RepositoryInfo(TypedDict): """Common shape returned by get_repositories() across all SCM providers.""" diff --git a/src/sentry/integrations/source_code_management/search.py b/src/sentry/integrations/source_code_management/search.py index 4a66a92a8a26..a9c4f19d500b 100644 --- a/src/sentry/integrations/source_code_management/search.py +++ b/src/sentry/integrations/source_code_management/search.py @@ -52,9 +52,7 @@ def integration_provider(self) -> str | None: @property @abstractmethod - def installation_class( - self, - ) -> type[T]: + def installation_class(self) -> type[T] | tuple[type[T], ...]: raise NotImplementedError @abstractmethod diff --git a/src/sentry/integrations/source_code_management/sync_repos.py b/src/sentry/integrations/source_code_management/sync_repos.py index 2c726ece2614..4e3ecfc1ae22 100644 --- a/src/sentry/integrations/source_code_management/sync_repos.py +++ b/src/sentry/integrations/source_code_management/sync_repos.py @@ -27,9 +27,9 @@ ) from sentry.integrations.source_code_management.repo_audit import log_repo_change from sentry.integrations.source_code_management.repository import ( - HaltReason, RepositoryIntegration, ) +from sentry.integrations.types import HaltReason from sentry.integrations.utils.metrics import IntegrationEventLifecycle from sentry.locks import locks from sentry.organizations.services.organization import organization_service diff --git a/src/sentry/integrations/tasks/sync_status_inbound.py b/src/sentry/integrations/tasks/sync_status_inbound.py index be204d31c28b..0573e2ea9166 100644 --- a/src/sentry/integrations/tasks/sync_status_inbound.py +++ b/src/sentry/integrations/tasks/sync_status_inbound.py @@ -37,14 +37,18 @@ logger = logging.getLogger(__name__) +# justification: resolution kwargs vary by strategy and feed Django's update(). +ResolutionParams = dict[str, Any] + + def get_resolutions_and_activity_data_for_groups( affected_groups: Iterable[Group], resolution_strategy: str | None, - activity_data: dict, + activity_data: dict[str, object], organization_id: int, -): +) -> tuple[dict[int, ResolutionParams], ActivityType, dict[str, object]]: activity_data = activity_data.copy() - resolutions_by_group_id = {} + resolutions_by_group_id: dict[int, ResolutionParams] = {} activity_type = ActivityType.SET_RESOLVED # If the resolution strategy is set to resolve in the next release or current release if resolution_strategy in [ diff --git a/src/sentry/integrations/time_utils.py b/src/sentry/integrations/time_utils.py index 7dce4a756272..3855a3ed2074 100644 --- a/src/sentry/integrations/time_utils.py +++ b/src/sentry/integrations/time_utils.py @@ -10,7 +10,7 @@ DAY_IN_SEC = 24 * 60 * 60 -def get_approx_start_time(group: Group): +def get_approx_start_time(group: Group) -> int | None: event = group.get_recommended_event_for_environments() if event is None: return None @@ -19,7 +19,7 @@ def get_approx_start_time(group: Group): if occurrence is None: return None - regression_time = occurrence.evidence_data.get("breakpoint", None) + regression_time: int | None = occurrence.evidence_data.get("breakpoint", None) return regression_time diff --git a/src/sentry/integrations/types.py b/src/sentry/integrations/types.py index f45aabce09ef..b3c689ed6561 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 Any, Generic, Literal, NotRequired, TypedDict, TypeVar from sentry.hybridcloud.rpc import ValueEqualityEnum @@ -12,9 +12,10 @@ class IntegrationIssueConfigField(TypedDict): name: str label: str type: str - default: NotRequired[Any] - defaultValue: NotRequired[Any] + default: NotRequired[Any] # justification: integration form values are arbitrary JSON. + defaultValue: NotRequired[Any] # justification: integration form values are arbitrary JSON. required: NotRequired[bool] + # justification: choice values are arbitrary JSON. choices: NotRequired[Sequence[tuple[Any, str]]] url: NotRequired[str] updatesForm: NotRequired[bool] @@ -184,4 +185,19 @@ class IntegrationResponse(Generic[T]): interaction_result: EventLifecycleOutcome response: T outcome_reason: str | Exception | None = None - context_data: dict | None = None + context_data: dict[str, object] | None = None + + +HaltReason = Literal[ + "configuration_error", + "connection_reset", + "host_timeout", + "host_unreachable", + "identity_not_found", + "identity_not_valid", + "installation_suspended", + "rate_limited", + "too_many_redirects", + "unauthorized", + "unsupported_response", +] diff --git a/src/sentry/integrations/utils/hostname.py b/src/sentry/integrations/utils/hostname.py index 9ddf187a92c9..682b89147628 100644 --- a/src/sentry/integrations/utils/hostname.py +++ b/src/sentry/integrations/utils/hostname.py @@ -18,10 +18,14 @@ def instance_hostname(integration: Integration | RpcIntegration) -> str: raise InstanceHostnameError( f"Missing domain_name for github_enterprise integration {integration.id}" ) + if not isinstance(domain_name, str): + raise InstanceHostnameError( + f"Missing domain_name for github_enterprise integration {integration.id}" + ) return domain_name.split("/")[0] case IntegrationProviderSlug.GITLAB.value: instance = integration.metadata.get("instance") - if not instance: + if not isinstance(instance, str) or not instance: raise InstanceHostnameError( f"Missing instance for gitlab integration {integration.id}" ) diff --git a/src/sentry/integrations/utils/metrics.py b/src/sentry/integrations/utils/metrics.py index bde83bf94e46..b0cc16a9b6b8 100644 --- a/src/sentry/integrations/utils/metrics.py +++ b/src/sentry/integrations/utils/metrics.py @@ -214,7 +214,7 @@ def record_event( logger.warning(key, **log_params) @staticmethod - def _report_flow_error(message) -> None: + def _report_flow_error(message: str) -> None: logger.warning("EventLifecycle flow error: %s", message) def _terminate( @@ -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/client.py b/src/sentry/integrations/vsts/client.py index efcf486b2eb8..5f88d3db4bd8 100644 --- a/src/sentry/integrations/vsts/client.py +++ b/src/sentry/integrations/vsts/client.py @@ -2,7 +2,7 @@ from collections.abc import Mapping, Sequence from time import time -from typing import TYPE_CHECKING, Any, Union +from typing import TYPE_CHECKING, Any, Union, cast from urllib.parse import quote from requests import PreparedRequest @@ -17,6 +17,7 @@ from sentry.integrations.types import IntegrationProviderSlug from sentry.models.repository import Repository from sentry.shared_integrations.client.proxy import IntegrationProxyClient +from sentry.shared_integrations.response.validation import dict_items from sentry.silo.base import control_silo_function from sentry.users.models.identity import Identity from sentry.utils.http import absolute_uri @@ -130,7 +131,14 @@ def __init__(self, base_url: str, oauth_redirect_url: str, access_token: str): self.oauth_redirect_url = oauth_redirect_url self.access_token = access_token - def request(self, method, path, data=None, params=None, api_preview: bool = False) -> Any: + def request( + self, + method: str, + path: str, + data: Mapping[str, Any] | None = None, + params: Mapping[str, Any] | None = None, + api_preview: bool = False, + ) -> Any: headers = prepare_headers( api_version=self.api_version, method=method, @@ -140,7 +148,7 @@ def request(self, method, path, data=None, params=None, api_preview: bool = Fals return self._request(method, path, headers=headers, data=data, params=params) def create_subscription(self, shared_secret: str) -> dict[str, Any]: - return self.post( + return self.post_mapping( VstsApiPath.subscriptions.format(instance=self.base_url), data=_create_subscription_data(shared_secret), ) @@ -165,7 +173,7 @@ def __init__( super().__init__(org_integration_id=org_integration_id) @property - def identity(self): + def identity(self) -> Identity: if self._identity: return self._identity if self.identity_id is None: @@ -230,7 +238,7 @@ def authorize_request( return prepared_request def create_subscription(self, shared_secret: str) -> dict[str, Any]: - return self.post( + return self.post_mapping( VstsApiPath.subscriptions.format(instance=self.base_url), data=_create_subscription_data(shared_secret), ) @@ -251,7 +259,7 @@ def create_work_item( if comment: data.append({"op": "add", "path": FIELD_MAP["comment"], "value": comment}) - return self.patch( + return self.patch_mapping( VstsApiPath.work_items_create.format( instance=self.base_url, project=project, type=item_type ), @@ -297,17 +305,19 @@ def update_work_item( if comment is not UNSET and comment: data.append({"op": "add", "path": FIELD_MAP["comment"], "value": comment}) - return self.patch(VstsApiPath.work_items.format(instance=self.base_url, id=id), data=data) + return self.patch_mapping( + VstsApiPath.work_items.format(instance=self.base_url, id=id), data=data + ) def get_work_item(self, id: int) -> dict[str, Any]: - return self.get(VstsApiPath.work_items.format(instance=self.base_url, id=id)) + return self.get_mapping(VstsApiPath.work_items.format(instance=self.base_url, id=id)) def get_work_item_states(self, project: str) -> dict[str, Any]: # XXX: Until we add the option to enter the 'WorkItemType' for syncing status changes from # Sentry to Azure DevOps, we need will attempt to use the sequence below. There are certain # ADO configurations which don't have 'Bug' or 'Issue', hence iterating until we find a match. for check_type in ("Bug", "Issue", "Task"): - response = self.get( + response = self.get_mapping( VstsApiPath.work_item_states.format( instance=self.base_url, project=project, @@ -320,12 +330,12 @@ def get_work_item_states(self, project: str) -> dict[str, Any]: return response def get_work_item_categories(self, project: str) -> dict[str, Any]: - return self.get( + return self.get_mapping( VstsApiPath.work_item_categories.format(instance=self.base_url, project=project) ) def get_repo(self, name_or_id: str, project: str | None = None) -> dict[str, Any]: - return self.get( + return self.get_mapping( VstsApiPath.repository.format( instance=self.base_url, project=f"{project}/" if project else "", @@ -334,7 +344,7 @@ def get_repo(self, name_or_id: str, project: str | None = None) -> dict[str, Any ) def get_repos(self, project: str | None = None) -> dict[str, Any]: - return self.get( + return self.get_mapping( VstsApiPath.repositories.format( instance=self.base_url, project=f"{project}/" if project else "" ), @@ -342,27 +352,26 @@ def get_repos(self, project: str | None = None) -> dict[str, Any]: ) def get_commits(self, repo_id: str, commit: str, limit: int = 100) -> dict[str, Any]: - return self.get( + return self.get_mapping( VstsApiPath.commits.format(instance=self.base_url, repo_id=repo_id), params={"commit": commit, "$top": limit}, ) def get_commit(self, repo_id: str, commit: str) -> dict[str, Any]: - return self.get( + return self.get_mapping( VstsApiPath.commit.format(instance=self.base_url, repo_id=repo_id, commit_id=commit) ) def get_commit_filechanges(self, repo_id: str, commit: str) -> list[dict[str, Any]]: - resp = self.get( + resp = self.get_mapping( VstsApiPath.commits_changes.format( instance=self.base_url, repo_id=repo_id, commit_id=commit ) ) - changes = resp["changes"] - return changes + return dict_items(resp["changes"], context="commit change") def get_commit_range(self, repo_id: str, start_sha: str, end_sha: str) -> dict[str, Any]: - return self.post( + return self.post_mapping( VstsApiPath.commits_batch.format(instance=self.base_url, repo_id=repo_id), data={ "itemVersion": {"versionType": "commit", "version": start_sha}, @@ -371,7 +380,7 @@ def get_commit_range(self, repo_id: str, start_sha: str, end_sha: str) -> dict[s ) def get_project(self, project_id: str) -> dict[str, Any]: - return self.get( + return self.get_mapping( VstsApiPath.project.format(instance=self.base_url, project_id=project_id), params={"stateFilter": "WellFormed"}, ) @@ -399,46 +408,50 @@ def get_users(self, account_name: str, continuation_token: str | None = None) -> Gets Users with access to a given account/organization https://docs.microsoft.com/en-us/rest/api/azure/devops/graph/users/list?view=azure-devops-rest-4.1 """ - return self.get( + return self.get_mapping( VstsApiPath.users.format(account_name=account_name), api_preview=True, params={"continuationToken": continuation_token}, ) def get_subscription(self, subscription_id: str) -> dict[str, Any]: - return self.get( + return self.get_mapping( VstsApiPath.subscription.format(instance=self.base_url, subscription_id=subscription_id) ) def delete_subscription(self, subscription_id: str) -> dict[str, Any]: - return self.delete( + return self.delete_mapping( VstsApiPath.subscription.format(instance=self.base_url, subscription_id=subscription_id) ) def update_subscription(self, subscription_id: str) -> dict[str, Any]: - return self.put( + return self.put_mapping( VstsApiPath.subscription.format(instance=self.base_url, subscription_id=subscription_id) ) def search_issues(self, account_name: str, query: str | None = None) -> dict[str, Any]: - return self.post( + return self.post_mapping( VstsApiPath.work_item_search.format(account_name=account_name), data={"searchText": query, "$top": 1000}, api_preview=True, ) def check_file(self, repo: Repository, path: str, version: str | None) -> object | None: - return self.get_cached( - path=VstsApiPath.items.format( - instance=repo.config["instance"], - project=quote(repo.config["project"]), - repo_id=quote(repo.config["name"]), + # justification: cache round-trips the JSON response payload. + return cast( + "object | None", + self.get_cached( + path=VstsApiPath.items.format( + instance=repo.config["instance"], + project=quote(repo.config["project"]), + repo_id=quote(repo.config["name"]), + ), + params={ + "path": path, + "api-version": "7.0", + "versionDescriptor.version": version, + }, ), - params={ - "path": path, - "api-version": "7.0", - "versionDescriptor.version": version, - }, ) def get_file( diff --git a/src/sentry/integrations/vsts/integration.py b/src/sentry/integrations/vsts/integration.py index da42736990aa..c42f4f0d0d00 100644 --- a/src/sentry/integrations/vsts/integration.py +++ b/src/sentry/integrations/vsts/integration.py @@ -35,13 +35,12 @@ from sentry.integrations.services.integration import integration_service from sentry.integrations.services.repository import RpcRepository, repository_service from sentry.integrations.source_code_management.repository import ( - HaltReason, RepositoryInfo, RepositoryIntegration, ) from sentry.integrations.source_code_management.url import url_is_under from sentry.integrations.tasks.migrate_repo import migrate_repo -from sentry.integrations.types import IntegrationProviderSlug +from sentry.integrations.types import HaltReason, IntegrationProviderSlug from sentry.integrations.utils.metrics import ( IntegrationPipelineHaltReason, IntegrationPipelineViewEvent, @@ -439,14 +438,18 @@ def _check_domain_name(self, default_identity: RpcIdentity) -> None: @property def instance(self) -> str: - return self.model.metadata["domain_name"] + domain_name = self.model.metadata["domain_name"] + if not isinstance(domain_name, str): + raise TypeError("VSTS integration metadata has no domain name") + return domain_name @property def default_project(self) -> str | None: try: - return self.model.metadata["default_project"] + default_project = self.model.metadata["default_project"] except KeyError: return None + return default_project if isinstance(default_project, str) else None class VstsInitialDataSerializer(CamelSnakeSerializer[dict[str, Any]]): @@ -499,8 +502,10 @@ def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> oauth_data = pipeline.fetch_state("oauth_data") or {} access_token: str = oauth_data["access_token"] user = get_user_info(access_token) + user_uuid = user["uuid"] + assert isinstance(user_uuid, str) - accounts = get_accounts(access_token, user["uuid"]) + accounts = get_accounts(access_token, user_uuid) extra = { "organization_id": pipeline.organization.id if pipeline.organization else None, "user_id": request.user.id, @@ -648,7 +653,8 @@ def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: "metadata": {"domain_name": base_url, "scopes": scopes}, "user_identity": { "type": IntegrationProviderSlug.AZURE_DEVOPS.value, - "external_id": user["id"], + # justification: VSTS profile id is a string. + "external_id": str(user["id"]), "scopes": scopes, "data": oauth_data, }, @@ -799,7 +805,10 @@ def get_base_url(cls, access_token: str, account_id: str) -> str: "Authorization": f"Bearer {access_token}", }, ) - return response.json()["locationUrl"] + location_url = response.json()["locationUrl"] + if not isinstance(location_url, str): + raise ValueError("VSTS operation response has no location URL") + return location_url def setup(self) -> None: from sentry.plugins.base import bindings diff --git a/src/sentry/integrations/vsts/issues.py b/src/sentry/integrations/vsts/issues.py index e0630ef6cc1f..c2784e9419b5 100644 --- a/src/sentry/integrations/vsts/issues.py +++ b/src/sentry/integrations/vsts/issues.py @@ -2,14 +2,13 @@ import re from abc import ABC -from collections.abc import Mapping, MutableMapping, Sequence -from typing import TYPE_CHECKING, Any, NoReturn +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any, NoReturn, cast from urllib.parse import ParseResult from django.urls import reverse from django.utils.translation import gettext as _ from mistune import markdown -from rest_framework.response import Response from sentry import options from sentry.constants import ObjectStatus @@ -161,7 +160,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 = [] @@ -201,8 +200,10 @@ def get_create_issue_config( *fields, ] - def get_link_issue_config(self, group: Group, **kwargs: Any) -> Sequence[Mapping[str, str]]: - fields: Sequence[MutableMapping[str, str]] = super().get_link_issue_config(group, **kwargs) + def get_link_issue_config( + self, group: Group, **kwargs: Any + ) -> list[IntegrationIssueConfigField]: + fields = super().get_link_issue_config(group, **kwargs) org = group.organization autocomplete_url = reverse("sentry-extensions-vsts-search", args=[org.slug, self.model.id]) for field in fields: @@ -282,7 +283,7 @@ def account_and_path(parsed_url: ParseResult) -> tuple[str | None, str]: ) return {"externalIssue": match[1]} - def get_issue(self, issue_id: int, **kwargs: Any) -> Mapping[str, Any]: + def get_issue(self, issue_id: str | int, **kwargs: Any) -> Mapping[str, Any]: client = self.get_client() work_item = client.get_work_item(issue_id) return { @@ -438,12 +439,19 @@ def _get_done_statuses(self, project: str) -> set[str]: return {state["name"] for state in all_states if state["category"] in self.done_categories} def get_issue_display_name(self, external_issue: ExternalIssue) -> str: - return (external_issue.metadata or {}).get("display_name", "") + display_name = (external_issue.metadata or {}).get("display_name", "") + return display_name if isinstance(display_name, str) else "" - def create_comment(self, issue_id: int, user_id: int, group_note: Activity) -> Response: + def create_comment( + self, issue_id: str, user_id: int, group_note: Activity + ) -> dict[str, Any]: # justification: work item payload is decoded JSON. comment = group_note.data["text"] quoted_comment = self.create_comment_attribution(user_id, comment) - return self.get_client().update_work_item(issue_id, comment=quoted_comment) + # justification: the VSTS client returns the updated work item payload. + return cast( + "dict[str, Any]", + self.get_client().update_work_item(issue_id, comment=quoted_comment), + ) def create_comment_attribution(self, user_id: int, comment_text: str) -> str: # VSTS uses markdown or xml @@ -455,11 +463,11 @@ def create_comment_attribution(self, user_id: int, comment_text: str) -> str: quoted_comment = f"{attribution}
{comment_text}
" return quoted_comment - def update_comment(self, issue_id: int, user_id: int, group_note: str) -> None: + def update_comment(self, issue_id: str, user_id: int, group_note: Activity) -> None: # Azure does not support updating comments. pass - def search_issues(self, query: str | None, **kwargs) -> dict[str, Any]: + def search_issues(self, query: str | None, **kwargs: object) -> dict[str, Any]: client = self.get_client() integration = integration_service.get_integration( diff --git a/src/sentry/integrations/vsts/search.py b/src/sentry/integrations/vsts/search.py index ed870b86b1ea..35b149f231b6 100644 --- a/src/sentry/integrations/vsts/search.py +++ b/src/sentry/integrations/vsts/search.py @@ -1,4 +1,4 @@ -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from rest_framework.response import Response @@ -11,15 +11,20 @@ T = TypeVar("T", bound=SourceCodeIssueIntegration) +if TYPE_CHECKING: + _SearchEndpointBase = SourceCodeSearchEndpoint[VstsIntegration] +else: + _SearchEndpointBase = SourceCodeSearchEndpoint + @control_silo_endpoint -class VstsSearchEndpoint(SourceCodeSearchEndpoint): +class VstsSearchEndpoint(_SearchEndpointBase): @property - def integration_provider(self): + def integration_provider(self) -> str: return IntegrationProviderSlug.AZURE_DEVOPS.value @property - def installation_class(self): + def installation_class(self) -> type[VstsIntegration]: return VstsIntegration def handle_search_issues(self, installation: T, query: str, repo: str | None) -> Response: diff --git a/src/sentry/interfaces/base.py b/src/sentry/interfaces/base.py index 60258022ac8e..58e8cbaf84e4 100644 --- a/src/sentry/interfaces/base.py +++ b/src/sentry/interfaces/base.py @@ -1,9 +1,9 @@ from __future__ import annotations import logging -from collections.abc import Mapping +from collections.abc import Iterator, Mapping from html import escape -from typing import Any, ClassVar, Self +from typing import Any, ClassVar, Self, cast from django.conf import settings from django.utils.functional import classproperty @@ -28,7 +28,8 @@ def get_interface(name: str) -> type[Interface]: except Exception: raise ValueError(f"Unable to load interface: {name}") - return interface + # justification: SENTRY_INTERFACES maps names to Interface subclasses. + return cast("type[Interface]", interface) def get_interfaces(event: Mapping[str, Any]) -> dict[str, Interface]: @@ -71,42 +72,43 @@ def __init__(self, data: dict[str, Any] | None = None): self._data = data or {} @classproperty - def path(cls): + def path(cls: type[Interface]) -> str: """The 'path' of the interface which is the root key in the data.""" return cls.__name__.lower() @classproperty - def external_type(cls): + def external_type(cls: type[Interface]) -> str: """The external name of the interface. This is mostly the same as path with some small differences (message, debugmeta). Also used as the display name for grouping strategy hints. """ return cls.path - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(self, type(other)): return False - return self._data == other._data + return self._data == getattr(other, "_data", None) - def __getstate__(self): + def __getstate__(self) -> dict[str, Any]: # justification: decoded JSON return {"_data": self._data} - def __setstate__(self, state): + def __setstate__(self, state: Mapping[str, Any]) -> None: # justification: decoded JSON self.__dict__.update(state) if not hasattr(self, "_data"): self._data = {} - def __getattr__(self, name): + def __getattr__(self, name: str) -> Any: # justification: decoded JSON return self._data[name] - def __setattr__(self, name, value) -> None: + def __setattr__(self, name: str, value: Any) -> None: # justification: decoded JSON if name == "_data": self.__dict__["_data"] = value else: self._data[name] = value @classmethod - def to_python(cls, data) -> Self | None: + # justification: interfaces consume raw decoded JSON whose shape differs per subclass + def to_python(cls, data: Any) -> Self | None: """Creates a python interface object from the given raw data. This function can assume fully normalized and valid data. It can create @@ -118,20 +120,30 @@ def to_python(cls, data) -> Self | None: return cls(data) - def get_raw_data(self): + def get_raw_data(self) -> dict[str, Any]: # justification: decoded JSON """Returns the underlying raw data.""" return self._data - def get_api_context(self, is_public=False, platform=None): - return self.to_json() - - def get_api_meta(self, meta, is_public=False, platform=None): + def get_api_context( + self, is_public: bool = False, platform: str | None = None + ) -> Mapping[str, Any]: # justification: decoded JSON + # justification: interface payloads are mappings at this level. + return cast("Mapping[str, Any]", self.to_json()) + + def get_api_meta( + # justification: decoded JSON + self, + meta: Mapping[str, Any], # justification: decoded JSON + is_public: bool = False, + platform: str | None = None, + ) -> Mapping[str, Any] | None: # justification: decoded JSON return meta - def to_json(self): + # justification: some interfaces serialize to an object and others to a list + def to_json(self) -> Any: return prune_empty_keys(self._data) - def get_title(self): + def get_title(self) -> str: return _(type(self).__name__) def get_display_score(self) -> int: @@ -140,13 +152,13 @@ def get_display_score(self) -> int: def get_score(self) -> int: return self.score - def iter_tags(self): + def iter_tags(self) -> Iterator[tuple[str, str]]: return iter(()) - def to_string(self, event) -> str: + def to_string(self, event: Any) -> str: # justification: decoded JSON return "" - def to_email_html(self, event, **kwargs): + def to_email_html(self, event: Any, **kwargs: Any) -> str: # justification: framework kwargs body = self.to_string(event) if not body: return "" diff --git a/src/sentry/interfaces/breadcrumbs.py b/src/sentry/interfaces/breadcrumbs.py index a1a8942b0bad..1ac737782cf7 100644 --- a/src/sentry/interfaces/breadcrumbs.py +++ b/src/sentry/interfaces/breadcrumbs.py @@ -1,5 +1,9 @@ __all__ = ("Breadcrumbs",) +from collections.abc import Mapping +from datetime import datetime +from typing import Any, Self + from sentry.interfaces.base import Interface from sentry.utils.dates import parse_timestamp, to_datetime from sentry.utils.json import prune_empty_keys @@ -26,7 +30,7 @@ class Breadcrumbs(Interface): score = 800 @classmethod - def to_python(cls, data): + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON values = [] for index, crumb in enumerate(get_path(data, "values", filter=True, default=())): # TODO(ja): Handle already invalid and None breadcrumbs @@ -34,7 +38,7 @@ def to_python(cls, data): return super().to_python({"values": values}) - def to_json(self): + def to_json(self) -> dict[str, Any]: # justification: decoded JSON return prune_empty_keys( { "values": [ @@ -56,9 +60,10 @@ def to_json(self): ) @classmethod - def normalize_crumb(cls, crumb): + # justification: decoded JSON + def normalize_crumb(cls, crumb: Mapping[str, Any]) -> dict[str, Any]: crumb = dict(crumb) - ts = parse_timestamp(crumb.get("timestamp")) + ts: datetime | None = parse_timestamp(crumb.get("timestamp")) if ts: crumb["timestamp"] = ts.timestamp() else: @@ -69,8 +74,10 @@ def normalize_crumb(cls, crumb): return crumb - def get_api_context(self, is_public=False, platform=None): - def _convert(x): + def get_api_context( + self, is_public: bool = False, platform: str | None = None + ) -> Mapping[str, Any]: # justification: decoded JSON + def _convert(x: Mapping[str, Any]) -> dict[str, Any]: # justification: decoded JSON return { "type": x["type"], "timestamp": x["timestamp"] and to_datetime(x["timestamp"]), @@ -83,7 +90,13 @@ def _convert(x): return {"values": [_convert(v) for v in self.values]} - def get_api_meta(self, meta, is_public=False, platform=None): + def get_api_meta( + # justification: decoded JSON + self, + meta: Mapping[str, Any], # justification: decoded JSON + is_public: bool = False, + platform: str | None = None, + ) -> Mapping[str, Any] | None: # justification: decoded JSON if meta and "values" not in meta: return {"values": meta} else: diff --git a/src/sentry/interfaces/contexts.py b/src/sentry/interfaces/contexts.py index 7a9a821756a1..0deb31021089 100644 --- a/src/sentry/interfaces/contexts.py +++ b/src/sentry/interfaces/contexts.py @@ -1,7 +1,8 @@ from __future__ import annotations import string -from typing import Any, ClassVar, TypeVar +from collections.abc import Iterator, Mapping +from typing import Any, ClassVar, Self, TypeVar, cast import sentry_sdk from django.utils.encoding import force_str @@ -18,13 +19,15 @@ class _IndexFormatter(string.Formatter): - def format_field(self, value, format_spec): + def format_field(self, value: Any, format_spec: str) -> str: # justification: decoded JSON if not format_spec and isinstance(value, bool): return value and "yes" or "no" - return string.Formatter.format_field(self, value, format_spec) + # justification: the base formatter returns the formatted field as a string. + return cast("str", super().format_field(value, format_spec)) -def format_index_expr(format_string, data): +# justification: decoded JSON +def format_index_expr(format_string: str, data: Mapping[str, Any]) -> str: return str(_IndexFormatter().vformat(str(format_string), (), data).strip()) @@ -83,9 +86,9 @@ class ContextType: type: str """This should match the `type` key in context object""" - def __init__(self, alias, data): + def __init__(self, alias: str, data: Mapping[str, Any]) -> None: # justification: decoded JSON self.alias = alias - ctx_data = {} + ctx_data: dict[str, Any] = {} # justification: decoded JSON for key, value in data.items(): # we use a simple check here, rather than ' in set()' to avoid # issues with maps/lists. @@ -99,13 +102,14 @@ def __init__(self, alias, data): ctx_data[force_str(key)] = self.change_type(value) self.data = ctx_data - def to_json(self): + def to_json(self) -> dict[str, Any]: # justification: decoded JSON rv = dict(self.data) rv["type"] = self.type return prune_empty_keys(rv) @classmethod - def values_for_data(cls, data): + # justification: decoded JSON + def values_for_data(cls, data: Mapping[str, Any]) -> list[Mapping[str, Any]]: rv = [] for context in (data.get("contexts") or {}).values(): if context and context.get("type") == cls.type: @@ -113,16 +117,18 @@ def values_for_data(cls, data): return rv @classmethod - def primary_value_for_data(cls, data): + # justification: decoded JSON + def primary_value_for_data(cls, data: Mapping[str, Any]) -> Mapping[str, Any] | None: val = get_path(data, "contexts", cls.type) - if val and val.get("type") == cls.type: + if isinstance(val, Mapping) and val.get("type") == cls.type: return val rv = cls.values_for_data(data) if len(rv) == 1: return rv[0] + return None - def iter_tags(self): + def iter_tags(self) -> Iterator[tuple[str, str]]: if self.context_to_tag_mapping: for field, f_string in self.context_to_tag_mapping.items(): try: @@ -135,7 +141,8 @@ def iter_tags(self): else: yield (f"{self.alias}.{field}", value) - def change_type(self, value: int | float | list | dict) -> Any: + # justification: context values come from decoded JSON payloads + def change_type(self, value: Any) -> Any: if isinstance(value, (float, int)) and len(str_value := force_str(value)) > 15: return str_value if isinstance(value, list): @@ -258,7 +265,7 @@ class Contexts(Interface): score = 800 @classmethod - def to_python(cls, data): + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON rv = {} # Note the alias is the key of the context entry @@ -271,7 +278,8 @@ def to_python(cls, data): return super().to_python(rv) @classmethod - def normalize_context(cls, alias, data): + # justification: decoded JSON + def normalize_context(cls, alias: str, data: Mapping[str, Any]) -> ContextType: ctx_type = data.get("type", alias) try: ctx_cls = context_types.get(ctx_type, DefaultContextType) @@ -282,15 +290,15 @@ def normalize_context(cls, alias, data): raise return ctx_cls(alias, data) - def iter_contexts(self): - return self._data.values() + def iter_contexts(self) -> Iterator[ContextType]: + return iter(self._data.values()) - def to_json(self): + def to_json(self) -> dict[str, Any]: # justification: decoded JSON rv = {} for alias, inst in self._data.items(): rv[alias] = inst.to_json() return rv - def iter_tags(self): + def iter_tags(self) -> Iterator[tuple[str, str]]: for inst in self.iter_contexts(): yield from inst.iter_tags() diff --git a/src/sentry/interfaces/debug_meta.py b/src/sentry/interfaces/debug_meta.py index e6e77a95e72a..c7d26b3d40b7 100644 --- a/src/sentry/interfaces/debug_meta.py +++ b/src/sentry/interfaces/debug_meta.py @@ -1,5 +1,7 @@ __all__ = ("DebugMeta",) +from typing import Any, Self + from sentry.interfaces.base import Interface from sentry.utils.json import prune_empty_keys @@ -23,7 +25,7 @@ class DebugMeta(Interface): external_type = "debugmeta" @classmethod - def to_python(cls, data): + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON return super().to_python( { "images": data.get("images", None) or [], @@ -32,7 +34,7 @@ def to_python(cls, data): } ) - def to_json(self): + def to_json(self) -> dict[str, Any]: # justification: decoded JSON return prune_empty_keys( { "images": self.images or None, diff --git a/src/sentry/interfaces/exception.py b/src/sentry/interfaces/exception.py index fa6fe2549510..7c75a6c6ef60 100644 --- a/src/sentry/interfaces/exception.py +++ b/src/sentry/interfaces/exception.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any +from collections.abc import Iterator, Mapping +from typing import Any, Self, cast from sentry.interfaces.base import Interface from sentry.interfaces.stacktrace import Stacktrace @@ -10,7 +11,8 @@ __all__ = ("Exception", "Mechanism", "upgrade_legacy_mechanism") -def upgrade_legacy_mechanism(data): +# justification: decoded JSON +def upgrade_legacy_mechanism(data: dict[str, Any] | None) -> dict[str, Any] | None: """ Conversion from mechanism objects sent by old sentry-cocoa SDKs. It assumes "type": "generic" and moves "posix_signal", "mach_exception" into "meta". @@ -127,7 +129,7 @@ class Mechanism(Interface): """ @classmethod - def to_python(cls, data): + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON for key in ( "type", "synthetic", @@ -145,7 +147,7 @@ def to_python(cls, data): return super().to_python(data) - def to_json(self): + def to_json(self) -> dict[str, Any]: # justification: decoded JSON return prune_empty_keys( { "type": self.type, @@ -162,7 +164,7 @@ def to_json(self): } ) - def iter_tags(self): + def iter_tags(self) -> Iterator[tuple[str, str]]: yield (self.path, self.type) if self.handled is not None: @@ -172,7 +174,8 @@ def __repr__(self) -> str: return f"{type(self).__name__} -> id:{self.exception_id}, parent_id:{self.parent_id}, source:{self.source}, type:{self.type}" -def uncontribute_non_stacktrace_variants(variants): +# justification: decoded JSON +def uncontribute_non_stacktrace_variants(variants: dict[str, Any]) -> dict[str, Any]: """If we have multiple variants and at least one has a stacktrace, we want to mark all non stacktrace variants non contributing. The reason for this is that otherwise we end up in very generic grouping which has @@ -236,7 +239,7 @@ class SingleException(Interface): grouping_variants = ["system", "app"] @classmethod - def to_python(cls, data): + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON if get_path(data, "stacktrace", "frames", filter=True): stacktrace = Stacktrace.to_python(data["stacktrace"]) else: @@ -273,7 +276,7 @@ def to_python(cls, data): return super().to_python(new_data) - def to_json(self): + def to_json(self) -> dict[str, Any]: # justification: decoded JSON mechanism = ( isinstance(self.mechanism, Mechanism) and self.mechanism.to_json() @@ -306,7 +309,9 @@ def to_json(self): } ) - def get_api_context(self, is_public=False, platform=None): + def get_api_context( + self, is_public: bool = False, platform: str | None = None + ) -> Mapping[str, Any]: # justification: decoded JSON mechanism = ( isinstance(self.mechanism, Mechanism) and self.mechanism.get_api_context(is_public=is_public, platform=platform) @@ -339,7 +344,13 @@ def get_api_context(self, is_public=False, platform=None): "rawType": self.raw_type, } - def get_api_meta(self, meta, is_public=False, platform=None): + def get_api_meta( + # justification: decoded JSON + self, + meta: Mapping[str, Any], # justification: decoded JSON + is_public: bool = False, + platform: str | None = None, + ) -> Mapping[str, Any] | None: # justification: decoded JSON mechanism_meta = ( self.mechanism.get_api_meta(meta["mechanism"], is_public=is_public, platform=platform) if isinstance(self.mechanism, Mechanism) and meta.get("mechanism") @@ -405,20 +416,23 @@ class Exception(Interface): score = 2000 grouping_variants = ["system", "app"] - def exceptions(self): - return get_path(self.values, filter=True) + def exceptions(self) -> list[SingleException | None]: + exceptions = get_path(self.values, filter=True) + return exceptions if isinstance(exceptions, list) else [] - def __getitem__(self, key): + def __getitem__( + self, key: int | slice + ) -> SingleException | None | list[SingleException | None]: return self.exceptions()[key] - def __iter__(self): + def __iter__(self) -> Iterator[SingleException | None]: return iter(self.exceptions()) def __len__(self) -> int: return len(self.exceptions()) @classmethod - def to_python(cls, data): + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON values = [] for i, v in enumerate(get_path(data, "values", default=[])): if not v: @@ -432,7 +446,7 @@ def to_python(cls, data): # TODO(ja): Fix all following methods when to_python is refactored. All # methods below might throw if None exceptions are in ``values``. - def to_json(self): + def to_json(self) -> dict[str, Any]: # justification: decoded JSON return prune_empty_keys( { "values": [v and v.to_json() for v in self.values] or None, @@ -440,7 +454,9 @@ def to_json(self): } ) - def get_api_context(self, is_public=False, platform=None): + def get_api_context( + self, is_public: bool = False, platform: str | None = None + ) -> Mapping[str, Any]: # justification: decoded JSON return { "values": [ v.get_api_context(is_public=is_public, platform=platform) for v in self.values if v @@ -451,7 +467,13 @@ def get_api_context(self, is_public=False, platform=None): "excOmitted": self.exc_omitted, } - def get_api_meta(self, meta, is_public=False, platform=None): + def get_api_meta( + # justification: decoded JSON + self, + meta: Mapping[str, Any], # justification: decoded JSON + is_public: bool = False, + platform: str | None = None, + ) -> Mapping[str, Any] | None: # justification: decoded JSON if not meta: return meta @@ -469,7 +491,7 @@ def get_api_meta(self, meta, is_public=False, platform=None): return {"values": result} - def to_string(self, event) -> str: + def to_string(self, event: Any) -> str: # justification: decoded JSON if not self.values: return "" @@ -488,13 +510,30 @@ def to_string(self, event) -> str: ) return "".join(output).strip() - def get_stacktrace(self, *args, **kwargs): + def get_stacktrace( + self, + event: Any, # justification: decoded JSON + system_frames: bool = True, + newest_first: bool | None = None, + max_frames: int | None = None, + header: bool = True, + ) -> str: exc = self.values[-1] if exc.stacktrace: - return exc.stacktrace.get_stacktrace(*args, **kwargs) + # justification: the stacktrace renders to a string. + return cast( + "str", + exc.stacktrace.get_stacktrace( + event, + system_frames=system_frames, + newest_first=newest_first, + max_frames=max_frames, + header=header, + ), + ) return "" - def iter_tags(self): + def iter_tags(self) -> Iterator[tuple[str, str]]: if not self.values or not self.values[-1]: return 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/interfaces/http.py b/src/sentry/interfaces/http.py index 3f403d62ae38..130d0b9a73de 100644 --- a/src/sentry/interfaces/http.py +++ b/src/sentry/interfaces/http.py @@ -1,5 +1,7 @@ __all__ = ("Http",) +from collections.abc import Mapping +from typing import Any, Self from urllib.parse import parse_qsl from django.utils.translation import gettext as _ @@ -10,14 +12,15 @@ from sentry.web.helpers import render_to_string -def format_headers(value): +# justification: decoded JSON +def format_headers(value: Any) -> tuple[list[tuple[str, str]], str | None] | tuple[()]: if not value: return () if isinstance(value, dict): value = value.items() - result = [] + result: list[tuple[str, str]] = [] cookie_header = None for k, v in value: # If a header value is a list of header, @@ -36,7 +39,8 @@ def format_headers(value): return result, cookie_header -def format_cookies(value): +# justification: decoded JSON +def format_cookies(value: Any) -> list[tuple[str | bytes, str | bytes]] | tuple[()]: if not value: return () @@ -49,7 +53,7 @@ def format_cookies(value): return [(fix_broken_encoding(k.strip()), fix_broken_encoding(v)) for k, v in value] -def fix_broken_encoding(value): +def fix_broken_encoding(value: str | bytes) -> str | bytes: """ Strips broken characters that can't be represented at all in utf8. This prevents our parsers from breaking elsewhere. @@ -99,7 +103,7 @@ class Http(Interface): FORM_TYPE = "application/x-www-form-urlencoded" @classmethod - def to_python(cls, data): + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON data.setdefault("query_string", []) for key in ( "api_target", @@ -116,7 +120,7 @@ def to_python(cls, data): return super().to_python(data) - def to_json(self): + def to_json(self) -> dict[str, Any]: # justification: decoded JSON return prune_empty_keys( { "apiTarget": self.api_target, @@ -133,16 +137,19 @@ def to_json(self): ) @property - def full_url(self): + def full_url(self) -> str | None: url = self.url + if not isinstance(url, str): + return None if url: if self.query_string: url = url + "?" + safe_urlencode(get_path(self.query_string, filter=True)) - if self.fragment: - url = url + "#" + self.fragment + fragment = self.fragment + if isinstance(fragment, str) and fragment: + url = url + "#" + fragment return url - def to_email_html(self, event, **kwargs): + def to_email_html(self, event: Any, **kwargs: Any) -> str: # justification: framework kwargs return render_to_string( "sentry/partial/interfaces/http_email.html", { @@ -155,10 +162,12 @@ def to_email_html(self, event, **kwargs): }, ) - def get_title(self): + def get_title(self) -> str: return _("Request") - def get_api_context(self, is_public=False, platform=None): + def get_api_context( + self, is_public: bool = False, platform: str | None = None + ) -> Mapping[str, Any]: # justification: decoded JSON if is_public: return {} @@ -184,7 +193,13 @@ def get_api_context(self, is_public=False, platform=None): } return data - def get_api_meta(self, meta, is_public=False, platform=None): + def get_api_meta( + # justification: decoded JSON + self, + meta: Mapping[str, Any], # justification: decoded JSON + is_public: bool = False, + platform: str | None = None, + ) -> Mapping[str, Any] | None: # justification: decoded JSON if is_public: return None diff --git a/src/sentry/interfaces/message.py b/src/sentry/interfaces/message.py index beb384c4068a..87b984bdea06 100644 --- a/src/sentry/interfaces/message.py +++ b/src/sentry/interfaces/message.py @@ -1,12 +1,14 @@ __all__ = ("Message",) +from typing import Any, Self + import orjson from sentry.interfaces.base import Interface from sentry.utils.json import prune_empty_keys -def stringify(value): +def stringify(value: Any) -> str | bytes | None: # justification: decoded JSON if isinstance(value, str): return value @@ -36,16 +38,17 @@ class Message(Interface): external_type = "message" @classmethod - def to_python(cls, data): + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON for key in ("message", "formatted", "params"): data.setdefault(key, None) return super().to_python(data) - def to_json(self): + def to_json(self) -> dict[str, Any]: # justification: decoded JSON return prune_empty_keys( {"message": self.message, "formatted": self.formatted, "params": self.params or None} ) - def to_string(self, event) -> str: - return self.formatted or self.message + def to_string(self, event: Any) -> str: # justification: decoded JSON + text = self.formatted or self.message + return text if isinstance(text, str) else "" diff --git a/src/sentry/interfaces/sdk.py b/src/sentry/interfaces/sdk.py index dad115b9d0ce..81b805f09baf 100644 --- a/src/sentry/interfaces/sdk.py +++ b/src/sentry/interfaces/sdk.py @@ -1,6 +1,7 @@ __all__ = ("Sdk",) -from typing import TypedDict +from collections.abc import Mapping +from typing import Any, Self, TypedDict from sentry.interfaces.base import Interface from sentry.utils.json import prune_empty_keys @@ -29,13 +30,13 @@ class Sdk(Interface): """ @classmethod - def to_python(cls, data): + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON for key in ("name", "version", "integrations", "packages"): data.setdefault(key, None) return super().to_python(data) - def to_json(self): + def to_json(self) -> dict[str, Any]: # justification: decoded JSON return prune_empty_keys( { "name": self.name, @@ -45,8 +46,16 @@ def to_json(self): } ) - def get_api_context(self, is_public=False, platform=None) -> EventSdkApiContext: + def get_api_context( + self, is_public: bool = False, platform: str | None = None + ) -> EventSdkApiContext: return {"name": self.name, "version": self.version} - def get_api_meta(self, meta, is_public=False, platform=None): + def get_api_meta( + # justification: decoded JSON + self, + meta: Mapping[str, Any], # justification: decoded JSON + is_public: bool = False, + platform: str | None = None, + ) -> Mapping[str, Any] | None: # justification: decoded JSON return {"": meta.get(""), "name": meta.get("name"), "version": meta.get("version")} diff --git a/src/sentry/interfaces/security.py b/src/sentry/interfaces/security.py index 80eea33fefd7..dd004bacab95 100644 --- a/src/sentry/interfaces/security.py +++ b/src/sentry/interfaces/security.py @@ -1,3 +1,5 @@ +from typing import Any, Self + import orjson from django.utils.functional import cached_property @@ -167,30 +169,30 @@ class Csp(SecurityReport): title = "CSP Report" @classmethod - def to_python(cls, data): + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON data.setdefault("document_uri", None) data.setdefault("violated_directive", None) data.setdefault("blocked_uri", None) data.setdefault("effective_directive", None) return super().to_python(data) - def to_string(self, event) -> str: + def to_string(self, event: Any) -> str: # justification: decoded JSON return orjson.dumps( {"csp-report": self.get_api_context()}, option=orjson.OPT_UTC_Z | orjson.OPT_NON_STR_KEYS, ).decode() - def to_email_html(self, event, **kwargs): + def to_email_html(self, event: Any, **kwargs: Any) -> str: # justification: framework kwargs return render_to_string( "sentry/partial/interfaces/csp_email.html", {"data": self.get_api_context()} ) @cached_property - def normalized_blocked_uri(self): + def normalized_blocked_uri(self) -> str | None: return csp.normalize_value(self.blocked_uri) @cached_property - def local_script_violation_type(self): + def local_script_violation_type(self) -> str | None: """ If this is a locally-sourced script-src error, gives the type. """ diff --git a/src/sentry/interfaces/spans.py b/src/sentry/interfaces/spans.py index a48bbfa7684a..ecec4fae6778 100644 --- a/src/sentry/interfaces/spans.py +++ b/src/sentry/interfaces/spans.py @@ -1,5 +1,8 @@ __all__ = ("Spans", "Span") +from collections.abc import Iterator +from typing import Any, Self + from sentry.interfaces.base import Interface SPAN_KEYS = ( @@ -40,7 +43,7 @@ class Span(Interface): """ @classmethod - def to_python(cls, data): + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON for key in SPAN_KEYS: data.setdefault(key, None) @@ -57,12 +60,12 @@ class Spans(Interface): path = "spans" @classmethod - def to_python(cls, data): + def to_python(cls, data: list[Any]) -> Self | None: # justification: decoded JSON spans = [Span.to_python(span) for span in data if span is not None] return super().to_python({"spans": spans}) - def __iter__(self): + def __iter__(self) -> Iterator[Span]: return iter(self.spans) - def to_json(self): + def to_json(self) -> list[Any]: # justification: decoded JSON return [span.to_json() for span in self] diff --git a/src/sentry/interfaces/stacktrace.py b/src/sentry/interfaces/stacktrace.py index bc5ae8179871..01acf6027fc5 100644 --- a/src/sentry/interfaces/stacktrace.py +++ b/src/sentry/interfaces/stacktrace.py @@ -1,5 +1,7 @@ import math +from collections.abc import Iterator, Mapping, Sequence from enum import Enum +from typing import Any, Self from django.utils.translation import gettext as _ @@ -18,7 +20,7 @@ class StacktraceOrder(str, Enum): MOST_RECENT_FIRST = "2" -def max_addr(cur, addr): +def max_addr(cur: int | None, addr: str | None) -> int | None: if addr is None: return cur length = len(addr) - 2 @@ -27,13 +29,13 @@ def max_addr(cur, addr): return cur -def pad_hex_addr(addr, length): +def pad_hex_addr(addr: str | None, length: int | None) -> str | None: if length is None or addr is None: return addr return "0x" + addr[2:].rjust(length, "0") -def trim_package(pkg): +def trim_package(pkg: str | None) -> str: if not pkg: return "?" pkg = pkg.split("/")[-1] @@ -42,7 +44,7 @@ def trim_package(pkg): return pkg -def to_hex_addr(addr): +def to_hex_addr(addr: int | str | None) -> str | None: if addr is None: return None elif isinstance(addr, int): @@ -58,7 +60,12 @@ def to_hex_addr(addr): return rv -def get_context(lineno, context_line, pre_context=None, post_context=None): +def get_context( + lineno: int | None, + context_line: str | None, + pre_context: Sequence[str] | None = None, + post_context: Sequence[str] | None = None, +) -> list[tuple[int, str | None]]: if lineno is None: return [] @@ -66,7 +73,7 @@ def get_context(lineno, context_line, pre_context=None, post_context=None): return [] lineno = int(lineno) - context = [] + context: list[tuple[int, str | None]] = [] start_lineno = lineno - len(pre_context or []) if pre_context: start_lineno = lineno - len(pre_context) @@ -92,7 +99,8 @@ def get_context(lineno, context_line, pre_context=None, post_context=None): return context -def is_newest_frame_first(event): +# justification: event payloads are decoded JSON consumed by many interfaces +def is_newest_frame_first(event: Any) -> bool: # TODO: Investigate if we should keep this special-casing for python, since we don't special # case it anywhere else we check stacktrace order. If we do remove it, we might consider # ditching this helper altogether, and just checking and using the option value directly in the @@ -113,11 +121,11 @@ def is_newest_frame_first(event): return newest_first -def is_url(filename): +def is_url(filename: str) -> bool: return filename.startswith(("file:", "http:", "https:", "applewebdata:")) -def validate_bool(value, required=True): +def validate_bool(value: bool | None, required: bool = True) -> bool | None: if required: assert value in (True, False) else: @@ -125,7 +133,7 @@ def validate_bool(value, required=True): return value -def handle_nan(value): +def handle_nan(value: float) -> str | float: "Remove nan values that can't be json encoded" if isinstance(value, float): if value == float("inf"): @@ -141,7 +149,7 @@ class Frame(Interface): grouping_variants = ["system", "app"] @classmethod - def to_python(cls, data): + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON for key in ( "abs_path", "colno", @@ -174,7 +182,7 @@ def to_python(cls, data): return super().to_python(data) - def to_json(self): + def to_json(self) -> dict[str, Any]: # justification: decoded JSON return prune_empty_keys( { "abs_path": self.abs_path or None, @@ -206,7 +214,12 @@ def to_json(self): } ) - def get_api_context(self, is_public=False, platform=None, pad_addr=None): + def get_api_context( + self, + is_public: bool = False, + platform: str | None = None, + pad_addr: int | None = None, + ) -> dict[str, Any]: # justification: decoded JSON from sentry.stacktraces.functions import ( get_function_name_for_frame, get_source_link_for_frame, @@ -214,7 +227,7 @@ def get_api_context(self, is_public=False, platform=None, pad_addr=None): function = get_function_name_for_frame(self, platform) source_link = get_source_link_for_frame(self) - data = { + data: dict[str, Any] = { # justification: decoded JSON "filename": self.filename, "absPath": self.abs_path, "module": self.module, @@ -271,9 +284,15 @@ def get_api_context(self, is_public=False, platform=None, pad_addr=None): return data - def get_meta_context(self, meta, is_public=False, platform=None): + def get_meta_context( + # justification: decoded JSON + self, + meta: Mapping[str, Any], # justification: decoded JSON + is_public: bool = False, + platform: str | None = None, + ) -> Mapping[str, Any] | None: # justification: decoded JSON if not meta: - return + return None return { "filename": meta.get("filename"), @@ -302,7 +321,7 @@ def get_meta_context(self, meta, is_public=False, platform=None): "sourceLink": meta.get("source_link"), } - def is_url(self): + def is_url(self) -> bool: if not self.abs_path: return False # URLs can be generated such that they are: @@ -312,13 +331,13 @@ def is_url(self): return True return is_url(self.abs_path) - def is_caused_by(self): + def is_caused_by(self) -> bool: # XXX(dcramer): don't compute hash using frames containing the 'Caused by' # text as it contains an exception value which may may contain dynamic # values (see raven-java#125) - return self.filename.startswith("Caused by: ") + return bool(self.filename.startswith("Caused by: ")) - def is_unhashable_module(self, platform): + def is_unhashable_module(self, platform: str | None) -> bool: # Fix for the case where module is a partial copy of the URL # and should not be hashed if ( @@ -332,13 +351,13 @@ def is_unhashable_module(self, platform): return True return False - def is_unhashable_function(self): + def is_unhashable_function(self) -> bool: # TODO(dcramer): lambda$ is Java specific # TODO(dcramer): [Anonymous is PHP specific (used for things like SQL # queries and JSON data) - return self.function.startswith(("lambda$", "[Anonymous")) + return bool(self.function.startswith(("lambda$", "[Anonymous"))) - def to_string(self, event) -> str: + def to_string(self, event: Any) -> str: # justification: decoded JSON if event.platform is not None: choices = [event.platform] else: @@ -462,11 +481,11 @@ class Stacktrace(Interface): score = 1950 grouping_variants = ["system", "app"] - def __iter__(self): + def __iter__(self) -> Iterator[Frame]: return iter(self.frames) @classmethod - def to_python(cls, data): + def to_python(cls, data: Mapping[str, Any]) -> Self | None: # justification: decoded JSON data = dict(data) frame_list = [] for i, f in enumerate(data.get("frames") or []): @@ -479,7 +498,7 @@ def to_python(cls, data): return super().to_python(data) - def get_has_system_frames(self): + def get_has_system_frames(self) -> bool: # This is a simplified logic from how the normalizer works. # Because this always works on normalized data we do not have to # consider the "all frames are in_app" case. The normalizer lives @@ -487,14 +506,16 @@ def get_has_system_frames(self): # care of that. return any(frame.in_app for frame in self.frames) - def get_longest_address(self): + def get_longest_address(self) -> int | None: rv = None for frame in self.frames: rv = max_addr(rv, frame.instruction_addr) rv = max_addr(rv, frame.symbol_addr) return rv - def get_api_context(self, is_public=False, platform=None): + def get_api_context( + self, is_public: bool = False, platform: str | None = None + ) -> Mapping[str, Any]: # justification: decoded JSON longest_addr = self.get_longest_address() frame_list = [ @@ -509,7 +530,13 @@ def get_api_context(self, is_public=False, platform=None): "hasSystemFrames": self.get_has_system_frames(), } - def get_api_meta(self, meta, is_public=False, platform=None): + def get_api_meta( + # justification: decoded JSON + self, + meta: Mapping[str, Any], # justification: decoded JSON + is_public: bool = False, + platform: str | None = None, + ) -> Mapping[str, Any] | None: # justification: decoded JSON if not meta: return meta @@ -527,7 +554,7 @@ def get_api_meta(self, meta, is_public=False, platform=None): "registers": meta.get("registers"), } - def to_json(self): + def to_json(self) -> dict[str, Any]: # justification: decoded JSON return prune_empty_keys( { "frames": [f and f.to_json() for f in self.frames] or None, @@ -536,12 +563,17 @@ def to_json(self): } ) - def to_string(self, event) -> str: + def to_string(self, event: Any) -> str: # justification: decoded JSON return self.get_stacktrace(event, system_frames=False, max_frames=10) def get_stacktrace( - self, event, system_frames=True, newest_first=None, max_frames=None, header=True - ): + self, + event: Any, # justification: decoded JSON + system_frames: bool = True, + newest_first: bool | None = None, + max_frames: int | None = None, + header: bool = True, + ) -> str: if newest_first is None: newest_first = is_newest_frame_first(event) diff --git a/src/sentry/interfaces/template.py b/src/sentry/interfaces/template.py index d13d5b30d308..aa2ee5b38837 100644 --- a/src/sentry/interfaces/template.py +++ b/src/sentry/interfaces/template.py @@ -1,5 +1,8 @@ __all__ = ("Template",) +from collections.abc import Mapping, Sequence +from typing import Any, Self + from sentry.interfaces.base import Interface from sentry.interfaces.stacktrace import get_context @@ -32,7 +35,7 @@ class Template(Interface): score = 1100 @classmethod - def to_python(cls, data): + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON for key in ( "abs_path", "filename", @@ -45,7 +48,7 @@ def to_python(cls, data): return super().to_python(data) - def to_string(self, event) -> str: + def to_string(self, event: Any) -> str: # justification: decoded JSON context = get_context( lineno=self.lineno, context_line=self.context_line, @@ -57,13 +60,16 @@ def to_string(self, event) -> str: return "\n".join(result) - def get_traceback(self, event, context): + # justification: decoded JSON + def get_traceback(self, event: Any, context: Sequence[tuple[int, str | None]]) -> str: result = [event.message, "", f'File "{self.filename}", line {self.lineno}', ""] result.extend([n[1].strip("\n") if n[1] else "" for n in context]) return "\n".join(result) - def get_api_context(self, is_public=False, platform=None): + def get_api_context( + self, is_public: bool = False, platform: str | None = None + ) -> Mapping[str, Any]: # justification: decoded JSON return { "lineNo": self.lineno, "filename": self.filename, @@ -75,7 +81,13 @@ def get_api_context(self, is_public=False, platform=None): ), } - def get_api_meta(self, meta, is_public=False, platform=None): + def get_api_meta( + # justification: decoded JSON + self, + meta: Mapping[str, Any], # justification: decoded JSON + is_public: bool = False, + platform: str | None = None, + ) -> Mapping[str, Any] | None: # justification: decoded JSON return { "": meta.get(""), "lineNo": meta.get("lineno"), diff --git a/src/sentry/interfaces/threads.py b/src/sentry/interfaces/threads.py index 86cfb65720ef..393cb183769b 100644 --- a/src/sentry/interfaces/threads.py +++ b/src/sentry/interfaces/threads.py @@ -1,3 +1,6 @@ +from collections.abc import Mapping, Sequence +from typing import Any, Self + from sentry.interfaces.base import Interface from sentry.interfaces.stacktrace import Stacktrace from sentry.utils.json import prune_empty_keys @@ -6,13 +9,15 @@ __all__ = ("Threads",) -def get_stacktrace(value, path): +# justification: decoded JSON +def get_stacktrace(value: Mapping[str, Any], path: Sequence[str | int]) -> Stacktrace | None: # Special case: if the thread has no frames we set the # stacktrace to none. Otherwise this will fail really # badly. subvalue = get_path(value, *path) if subvalue and subvalue.get("frames"): return Stacktrace.to_python(subvalue) + return None class Threads(Interface): @@ -20,8 +25,8 @@ class Threads(Interface): grouping_variants = ["system", "app"] @classmethod - def to_python(cls, data): - threads = [] + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON + threads: list[dict[str, Any]] = [] # justification: decoded JSON for i, thread in enumerate(data.get("values") or ()): if thread is None: @@ -43,8 +48,9 @@ def to_python(cls, data): return super().to_python({"values": threads}) - def to_json(self): - def export_thread(data): + def to_json(self) -> dict[str, Any]: # justification: decoded JSON + # justification: decoded JSON + def export_thread(data: Mapping[str, Any] | None) -> dict[str, Any] | None: if data is None: return None @@ -65,8 +71,10 @@ def export_thread(data): return prune_empty_keys({"values": [export_thread(x) for x in self.values]}) - def get_api_context(self, is_public=False, platform=None): - def export_thread(data): + def get_api_context( + self, is_public: bool = False, platform: str | None = None + ) -> Mapping[str, Any]: # justification: decoded JSON + def export_thread(data: Mapping[str, Any]) -> dict[str, Any]: # justification: decoded JSON rv = { "id": data["id"], "current": data["current"], @@ -85,7 +93,13 @@ def export_thread(data): return {"values": [export_thread(x) for x in self.values]} - def get_meta_context(self, meta, is_public=False, platform=None): + def get_meta_context( + # justification: decoded JSON + self, + meta: Mapping[str, Any], # justification: decoded JSON + is_public: bool = False, + platform: str | None = None, + ) -> Mapping[str, Any] | None: # justification: decoded JSON if meta and "values" not in meta: return {"values": meta} else: diff --git a/src/sentry/interfaces/user.py b/src/sentry/interfaces/user.py index 3d0ea8e43368..53f9a1010c1e 100644 --- a/src/sentry/interfaces/user.py +++ b/src/sentry/interfaces/user.py @@ -1,7 +1,8 @@ __all__ = ("User",) -from typing import Any, TypedDict +from collections.abc import Mapping +from typing import Any, Self, TypedDict from sentry.interfaces.base import Interface from sentry.interfaces.geo import Geo @@ -41,7 +42,7 @@ class User(Interface): display_score = 2020 @classmethod - def to_python(cls, data): + def to_python(cls, data: dict[str, Any]) -> Self | None: # justification: decoded JSON data = data.copy() for key in ("id", "email", "username", "ip_address", "name", "geo", "data"): data.setdefault(key, None) @@ -50,7 +51,7 @@ def to_python(cls, data): return super().to_python(data) - def to_json(self): + def to_json(self) -> dict[str, Any]: # justification: decoded JSON return prune_empty_keys( { "id": self.id, @@ -63,7 +64,9 @@ def to_json(self): } ) - def get_api_context(self, is_public=False, platform=None) -> EventUserApiContext: + def get_api_context( + self, is_public: bool = False, platform: str | None = None + ) -> EventUserApiContext: return { "id": self.id, "email": self.email, @@ -74,7 +77,13 @@ def get_api_context(self, is_public=False, platform=None) -> EventUserApiContext "data": self.data, } - def get_api_meta(self, meta, is_public=False, platform=None): + def get_api_meta( + # justification: decoded JSON + self, + meta: Mapping[str, Any], # justification: decoded JSON + is_public: bool = False, + platform: str | None = None, + ) -> Mapping[str, Any] | None: # justification: decoded JSON return { "": meta.get(""), "id": meta.get("id"), @@ -86,13 +95,19 @@ def get_api_meta(self, meta, is_public=False, platform=None): "data": meta.get("data"), } - def get_display_name(self): - return self.email or self.username + def get_display_name(self) -> str | None: + for value in (self.email, self.username): + if isinstance(value, str) and value: + return value + return None - def get_label(self): - return self.name or self.email or self.username or self.id or self.ip_address + def get_label(self) -> str | None: + for value in (self.name, self.email, self.username, self.id, self.ip_address): + if isinstance(value, str) and value: + return value + return None - def to_email_html(self, event, **kwargs): + def to_email_html(self, event: Any, **kwargs: Any) -> str: # justification: framework kwargs context = { "user_id": self.id, "user_email": self.email, diff --git a/src/sentry/investigations/contracts.py b/src/sentry/investigations/contracts.py index 1c97bfc5fa44..b71a36f12bc8 100644 --- a/src/sentry/investigations/contracts.py +++ b/src/sentry/investigations/contracts.py @@ -30,7 +30,8 @@ class RelaxedContractSerializer(serializers.Serializer[Any]): def to_internal_value(self, data: Any) -> dict[str, Any]: - validated = super().to_internal_value(data) + # justification: DRF returns the validated field mapping. + validated = cast("dict[str, Any]", super().to_internal_value(data)) if not isinstance(data, dict): return validated passthrough = {key: value for key, value in data.items() if key not in self.fields} diff --git a/src/sentry/investigations/models/orchestration.py b/src/sentry/investigations/models/orchestration.py index acd89b38e566..915edaff585a 100644 --- a/src/sentry/investigations/models/orchestration.py +++ b/src/sentry/investigations/models/orchestration.py @@ -173,7 +173,7 @@ class InvestigationOrchestrationCommand(DefaultFieldsModel): "service_accounts.ServiceAccount", null=True, on_delete="SET_NULL" ) expected_workflow_version = BoundedPositiveIntegerField() - resulting_workflow_version = BoundedPositiveIntegerField(null=True) + resulting_workflow_version = BoundedPositiveIntegerField[int | None, int | None](null=True) type = models.CharField(max_length=64) payload: models.Field[dict[str, Any], dict[str, Any]] = models.JSONField( default=dict, db_default={} diff --git a/src/sentry/investigations/services/action_kinds/connect_telemetry.py b/src/sentry/investigations/services/action_kinds/connect_telemetry.py index 38bf25bcb118..4010e329a1fc 100644 --- a/src/sentry/investigations/services/action_kinds/connect_telemetry.py +++ b/src/sentry/investigations/services/action_kinds/connect_telemetry.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Literal, TypedDict +from typing import Literal, TypedDict, cast from django.contrib.auth.models import AnonymousUser from django.db import router, transaction @@ -53,7 +53,11 @@ def telemetry_available(organization: Organization, provider: TelemetryProvider) def _provider(action_request: NotebookActionRequest) -> TelemetryProvider: payload = action_request.action_payload assert payload is not None - return payload["provider"] + # justification: notebook action payloads carry the selected telemetry provider. + provider = cast("TelemetryProvider", payload["provider"]) + if provider not in ("datadog", "gcp"): + raise ValueError(f"Unsupported telemetry provider: {provider!r}") + return provider def telemetry_provider_for_integration(integration_provider: str) -> TelemetryProvider | None: diff --git a/src/sentry/investigations/services/action_kinds/update_detector.py b/src/sentry/investigations/services/action_kinds/update_detector.py index 7b329465c69b..493c3e3dd291 100644 --- a/src/sentry/investigations/services/action_kinds/update_detector.py +++ b/src/sentry/investigations/services/action_kinds/update_detector.py @@ -287,7 +287,7 @@ def update(self, changes: DetectorChanges) -> DetectorUpdate: def is_dynamic_detector(detector: Detector) -> bool: # Saving a dynamic monitor calls Seer's anomaly detection inline, so we leave those alone. - return detector.config.get("detection_type") == AlertRuleDetectionType.DYNAMIC + return bool(detector.config.get("detection_type") == AlertRuleDetectionType.DYNAMIC) def target_detectors(organization_id: int, detector_ids: Collection[int]) -> dict[int, Detector]: diff --git a/src/sentry/investigations/services/orchestration.py b/src/sentry/investigations/services/orchestration.py index 37751ef2e894..39530c4118cb 100644 --- a/src/sentry/investigations/services/orchestration.py +++ b/src/sentry/investigations/services/orchestration.py @@ -802,7 +802,7 @@ def accept_orchestration_command( raise InvestigationConflictError( "Request ID was already used for a different command." ) - if run.seer_run_id is None and _is_run_retry(existing.type, existing.payload): + if not run.seer_run_id and _is_run_retry(existing.type, existing.payload): _schedule_orchestration_create(run.id) elif ( existing.status == InvestigationOrchestrationCommandStatus.FAILED @@ -825,7 +825,7 @@ def accept_orchestration_command( run, request_id=request_id, command_type=command_type, payload=payload ) - if run.seer_run_id is None and _is_run_retry(command_type, payload): + if not run.seer_run_id and _is_run_retry(command_type, payload): command = InvestigationOrchestrationCommand.objects.create( orchestration_run=run, request_id=request_id, diff --git a/src/sentry/investigations/services/orchestration_events.py b/src/sentry/investigations/services/orchestration_events.py index 3f9be65d1656..098b6a152a6c 100644 --- a/src/sentry/investigations/services/orchestration_events.py +++ b/src/sentry/investigations/services/orchestration_events.py @@ -3,7 +3,7 @@ import hashlib from copy import deepcopy from dataclasses import dataclass -from typing import Any +from typing import Any, cast from uuid import UUID, uuid5 from django.db import IntegrityError, router, transaction @@ -119,7 +119,8 @@ def _validate_projection(projection: dict[str, Any]) -> dict[str, Any]: validator = OrchestrationProjectionSerializer(data=projection) if not validator.is_valid(): raise serializers.ValidationError({"payload": validator.errors}) # type: ignore[dict-item] - return validator.validated_data + # justification: DRF returns the validated projection mapping. + return cast("dict[str, Any]", validator.validated_data) def _projection_evidence_project_ids(projection: dict[str, Any]) -> set[int]: @@ -174,7 +175,8 @@ def _validate_event_payload( validator = schema(data=payload, context={"log_drops": log_drops}) if not validator.is_valid(): raise serializers.ValidationError({"payload": validator.errors}) - return validator.validated_data + # justification: DRF returns the validated payload mapping. + return cast("dict[str, Any]", validator.validated_data) def _stored_event_payload(event: dict[str, Any]) -> dict[str, Any]: diff --git a/src/sentry/issue_detection/detectors/consecutive_db_detector.py b/src/sentry/issue_detection/detectors/consecutive_db_detector.py index 8b10ff0781d4..264af9aac884 100644 --- a/src/sentry/issue_detection/detectors/consecutive_db_detector.py +++ b/src/sentry/issue_detection/detectors/consecutive_db_detector.py @@ -257,7 +257,7 @@ def on_complete(self) -> None: self._validate_and_store_performance_problem() def is_creation_allowed(self) -> bool: - return self.settings["detection_enabled"] + return bool(self.settings["detection_enabled"]) @classmethod def is_event_eligible(cls, event: dict[str, Any], project: Project | None = None) -> bool: diff --git a/src/sentry/issue_detection/detectors/consecutive_http_detector.py b/src/sentry/issue_detection/detectors/consecutive_http_detector.py index 79293ae0d946..ca7aac62dde1 100644 --- a/src/sentry/issue_detection/detectors/consecutive_http_detector.py +++ b/src/sentry/issue_detection/detectors/consecutive_http_detector.py @@ -209,4 +209,4 @@ def on_complete(self) -> None: self._validate_and_store_performance_problem() def is_creation_allowed(self) -> bool: - return self.settings["detection_enabled"] + return bool(self.settings["detection_enabled"]) diff --git a/src/sentry/issue_detection/detectors/http_overhead_detector.py b/src/sentry/issue_detection/detectors/http_overhead_detector.py index 49535332faef..d62e62912592 100644 --- a/src/sentry/issue_detection/detectors/http_overhead_detector.py +++ b/src/sentry/issue_detection/detectors/http_overhead_detector.py @@ -201,4 +201,4 @@ def on_complete(self) -> None: self._store_performance_problem(location) def is_creation_allowed(self) -> bool: - return self.settings["detection_enabled"] + return bool(self.settings["detection_enabled"]) diff --git a/src/sentry/issue_detection/detectors/io_main_thread_detector.py b/src/sentry/issue_detection/detectors/io_main_thread_detector.py index 187e23c1ff76..639833176d92 100644 --- a/src/sentry/issue_detection/detectors/io_main_thread_detector.py +++ b/src/sentry/issue_detection/detectors/io_main_thread_detector.py @@ -114,7 +114,7 @@ def on_complete(self) -> None: ) def is_creation_allowed(self) -> bool: - return self.settings["detection_enabled"] + return bool(self.settings["detection_enabled"]) class FileIOMainThreadDetector(BaseIOMainThreadDetector): @@ -173,7 +173,8 @@ def _deobfuscate_function(self, frame: dict[str, Any]) -> str: ) return ".".join([func.method for func in functions]) else: - return frame.get("function", "") + function = frame.get("function", "") + return function if isinstance(function, str) else "" def _fingerprint(self, span_list: list[Span]) -> str: call_stack_strings = [] diff --git a/src/sentry/issue_detection/detectors/large_http_payload_detector.py b/src/sentry/issue_detection/detectors/large_http_payload_detector.py index fe774bcfb9dc..5bfaeb6ed531 100644 --- a/src/sentry/issue_detection/detectors/large_http_payload_detector.py +++ b/src/sentry/issue_detection/detectors/large_http_payload_detector.py @@ -139,4 +139,4 @@ def _fingerprint(self, span: Span) -> str: return f"1-{PerformanceLargeHTTPPayloadGroupType.type_id}-{hashed_url_paths}" def is_creation_allowed(self) -> bool: - return self.settings["detection_enabled"] + return bool(self.settings["detection_enabled"]) diff --git a/src/sentry/issue_detection/detectors/mn_plus_one_db_span_detector.py b/src/sentry/issue_detection/detectors/mn_plus_one_db_span_detector.py index 64d514f2745a..e90b42c2c4ae 100644 --- a/src/sentry/issue_detection/detectors/mn_plus_one_db_span_detector.py +++ b/src/sentry/issue_detection/detectors/mn_plus_one_db_span_detector.py @@ -5,7 +5,7 @@ from collections import deque from collections.abc import Sequence from random import randint -from typing import Any +from typing import Any, cast from sentry.issue_detection.base import DetectorType, PerformanceDetector from sentry.issue_detection.detectors.utils import ( @@ -404,7 +404,8 @@ def _find_common_parent_span(self, spans: Sequence[Span]) -> Span | None: all_spans = self.event.get("spans") or [] for span in all_spans: if span.get("span_id") == common_parent_span_id: - return span + # justification: event spans are decoded Span payloads. + return cast("Span", span) return None def _fingerprint(self, db_hash: str, parent_span: Span) -> str: @@ -453,7 +454,7 @@ def __init__( ) def is_creation_allowed(self) -> bool: - return self.settings["detection_enabled"] + return bool(self.settings["detection_enabled"]) def visit_span(self, span: Span) -> None: self.state, performance_problem = self.state.next(span) diff --git a/src/sentry/issue_detection/detectors/n_plus_one_api_calls_detector.py b/src/sentry/issue_detection/detectors/n_plus_one_api_calls_detector.py index eabc88173f35..4942e56084ef 100644 --- a/src/sentry/issue_detection/detectors/n_plus_one_api_calls_detector.py +++ b/src/sentry/issue_detection/detectors/n_plus_one_api_calls_detector.py @@ -73,7 +73,7 @@ def visit_span(self, span: Span) -> None: self.spans = [span] def is_creation_allowed(self) -> bool: - return self.settings["detection_enabled"] + return bool(self.settings["detection_enabled"]) @classmethod def is_event_eligible(cls, event: dict[str, Any], project: Project | None = None) -> bool: diff --git a/src/sentry/issue_detection/detectors/n_plus_one_db_span_detector.py b/src/sentry/issue_detection/detectors/n_plus_one_db_span_detector.py index 02de358261dd..5413b24ca9dc 100644 --- a/src/sentry/issue_detection/detectors/n_plus_one_db_span_detector.py +++ b/src/sentry/issue_detection/detectors/n_plus_one_db_span_detector.py @@ -72,7 +72,7 @@ def __init__( self.potential_parents[root_span.get("span_id")] = root_span def is_creation_allowed(self) -> bool: - return self.settings["detection_enabled"] + return bool(self.settings["detection_enabled"]) def visit_span(self, span: Span) -> None: span_id = span.get("span_id", None) @@ -236,6 +236,8 @@ def _maybe_store_problem(self) -> None: def _is_slower_than_threshold(self) -> bool: duration_threshold = self.settings["duration_threshold"] + if not isinstance(duration_threshold, (int, float)): + raise TypeError("Span detector duration_threshold must be a number") return total_span_time(self.n_spans) >= duration_threshold def _metrics_for_extra_matching_spans(self) -> None: diff --git a/src/sentry/issue_detection/detectors/query_injection_detector.py b/src/sentry/issue_detection/detectors/query_injection_detector.py index aafa720358bd..802c450dbca4 100644 --- a/src/sentry/issue_detection/detectors/query_injection_detector.py +++ b/src/sentry/issue_detection/detectors/query_injection_detector.py @@ -123,7 +123,7 @@ def visit_span(self, span: Span) -> None: ) def is_creation_allowed(self) -> bool: - return self.settings["detection_enabled"] + return bool(self.settings["detection_enabled"]) def _is_span_eligible(self, span: Span) -> bool: if not span.get("span_id"): diff --git a/src/sentry/issue_detection/detectors/render_blocking_asset_span_detector.py b/src/sentry/issue_detection/detectors/render_blocking_asset_span_detector.py index a5595e3d489a..88868fd931da 100644 --- a/src/sentry/issue_detection/detectors/render_blocking_asset_span_detector.py +++ b/src/sentry/issue_detection/detectors/render_blocking_asset_span_detector.py @@ -52,7 +52,7 @@ def __init__( self.fcp_value = fcp_value def is_creation_allowed(self) -> bool: - return self.settings["detection_enabled"] + return bool(self.settings["detection_enabled"]) def visit_span(self, span: Span) -> None: if not self.fcp: @@ -151,7 +151,7 @@ def _is_blocking_render(self, span: Span) -> bool: span_duration = get_span_duration(span) fcp_ratio_threshold = self.settings["fcp_ratio_threshold"] - return span_duration / self.fcp > fcp_ratio_threshold + return bool(span_duration / self.fcp > fcp_ratio_threshold) def _fingerprint(self, span: Span) -> str: resource_url_hash = fingerprint_resource_span(span) diff --git a/src/sentry/issue_detection/detectors/slow_db_query_detector.py b/src/sentry/issue_detection/detectors/slow_db_query_detector.py index 557a1d15a074..64c1d0056c8d 100644 --- a/src/sentry/issue_detection/detectors/slow_db_query_detector.py +++ b/src/sentry/issue_detection/detectors/slow_db_query_detector.py @@ -102,7 +102,7 @@ def visit_span(self, span: Span) -> None: ) def is_creation_allowed(self) -> bool: - return self.settings["detection_enabled"] + return bool(self.settings["detection_enabled"]) def _is_span_eligible(self, span: Span) -> bool: description = get_span_description(span) diff --git a/src/sentry/issue_detection/detectors/span_first/base.py b/src/sentry/issue_detection/detectors/span_first/base.py index a38ea2aba564..2c38a3e0de5e 100644 --- a/src/sentry/issue_detection/detectors/span_first/base.py +++ b/src/sentry/issue_detection/detectors/span_first/base.py @@ -68,4 +68,4 @@ def is_creation_allowed(self) -> bool: Returns whether this detector is allowed to emit problems. By deefault, reads `detection_enabled` from settings; override for more nuanced gating. """ - return self._settings["detection_enabled"] + return bool(self._settings["detection_enabled"]) diff --git a/src/sentry/issue_detection/detectors/span_first/run_detectors.py b/src/sentry/issue_detection/detectors/span_first/run_detectors.py index 49fb8484800e..054ccc78981b 100644 --- a/src/sentry/issue_detection/detectors/span_first/run_detectors.py +++ b/src/sentry/issue_detection/detectors/span_first/run_detectors.py @@ -4,7 +4,7 @@ from collections import defaultdict from collections.abc import Sequence from types import NoneType -from typing import Any +from typing import Any, cast from sentry.issue_detection.base import DetectorType from sentry.issue_detection.detectors.span_first.base import SpanFirstDetector @@ -357,7 +357,8 @@ def _strip_unnecessary_problem_data( ): del evidence_data["repeating_spans_compact"] - return problem_dict + # justification: the caller handed in the problem dictionary to strip in place. + return cast("dict[str, Any]", problem_dict) def _log_mismatch( diff --git a/src/sentry/issue_detection/detectors/sql_injection_detector.py b/src/sentry/issue_detection/detectors/sql_injection_detector.py index 08334bb81911..805dd6317a70 100644 --- a/src/sentry/issue_detection/detectors/sql_injection_detector.py +++ b/src/sentry/issue_detection/detectors/sql_injection_detector.py @@ -221,7 +221,7 @@ def visit_span(self, span: Span) -> None: ) def is_creation_allowed(self) -> bool: - return self.settings["detection_enabled"] + return bool(self.settings["detection_enabled"]) def _is_span_eligible(self, span: Span) -> bool: if not span.get("span_id"): diff --git a/src/sentry/issue_detection/detectors/uncompressed_asset_detector.py b/src/sentry/issue_detection/detectors/uncompressed_asset_detector.py index 322bece006b0..97edb15d3c83 100644 --- a/src/sentry/issue_detection/detectors/uncompressed_asset_detector.py +++ b/src/sentry/issue_detection/detectors/uncompressed_asset_detector.py @@ -153,7 +153,7 @@ def _fingerprint(self, span: Span) -> str: return f"1-{PerformanceUncompressedAssetsGroupType.type_id}-{resource_span}" def is_creation_allowed(self) -> bool: - return self.settings["detection_enabled"] + return bool(self.settings["detection_enabled"]) @classmethod def is_event_eligible(cls, event: dict[str, Any], project: Project | None = None) -> bool: diff --git a/src/sentry/issue_detection/types.py b/src/sentry/issue_detection/types.py index e1fb577b0e43..03b3c6903292 100644 --- a/src/sentry/issue_detection/types.py +++ b/src/sentry/issue_detection/types.py @@ -22,6 +22,8 @@ class Span(TypedDict, total=False): description: str hash: str parent_span_id: str + project_id: int + organization_id: int data: dict[str, Any] | None tags: dict[str, Any] | None sentry_tags: SentryTags diff --git a/src/sentry/issues/action_log/types.py b/src/sentry/issues/action_log/types.py index d1f9337be7ae..2687914ec8c4 100644 --- a/src/sentry/issues/action_log/types.py +++ b/src/sentry/issues/action_log/types.py @@ -279,7 +279,10 @@ def get_user_visible_types(cls) -> frozenset[GroupActionType]: def json_dict(self) -> dict[str, Any]: """The action as JSON-safe data for storage; unlike dict(), datetimes become ISO 8601.""" - return orjson.loads(self.json()) + data = orjson.loads(self.json()) + if not isinstance(data, dict): + raise ValueError("Action JSON is not an object") + return data class ViewAction(GroupAction): diff --git a/src/sentry/issues/attributes.py b/src/sentry/issues/attributes.py index 67ea37cc987b..1f4b15c13135 100644 --- a/src/sentry/issues/attributes.py +++ b/src/sentry/issues/attributes.py @@ -4,13 +4,14 @@ from datetime import datetime from enum import Enum from functools import partial +from typing import cast import urllib3 from arroyo import Topic as ArroyoTopic from arroyo.backends.kafka import KafkaPayload, KafkaProducer from django.conf import settings from django.db import router, transaction -from django.db.models import F, Window +from django.db.models import F, Model, Window from django.db.models.functions import Rank from django.db.models.signals import post_delete, post_save from django.dispatch import receiver @@ -20,6 +21,7 @@ from sentry.models.group import Group from sentry.models.groupassignee import GroupAssignee from sentry.models.groupowner import GroupOwner, GroupOwnerType +from sentry.models.project import Project from sentry.signals import issue_assigned, issue_deleted, issue_unassigned, post_update from sentry.utils import json, metrics, snuba from sentry.utils.arroyo_producer import ( @@ -206,7 +208,7 @@ def _bulk_retrieve_snapshot_values( "priority": group_value.priority, "first_release": group_value.first_release_id, "first_seen": group_value.first_seen.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - "num_comments": group_value.num_comments, + "num_comments": group_value.num_comments or 0, "timestamp": datetime.now().isoformat(), "assignee_user_id": assignee["user_id"] if assignee else None, "assignee_team_id": assignee["team_id"] if assignee else None, @@ -221,12 +223,20 @@ def _bulk_retrieve_snapshot_values( return snapshots +def _string_set(value: object) -> set[str] | None: + if value is None: + return None + return set(cast("Iterable[str]", value)) + + @receiver( post_save, sender=Group, dispatch_uid="post_save_log_group_attributes_changed", weak=False ) -def post_save_log_group_attributes_changed(instance, sender, created, *args, **kwargs) -> None: +def post_save_log_group_attributes_changed( + instance: Group, sender: type[Group], created: bool, *args: object, **kwargs: object +) -> None: try: - if set(kwargs.get("update_fields") or ()) == {"num_comments"}: + if _string_set(kwargs.get("update_fields")) == {"num_comments"}: _log_group_attributes_changed(Operation.UPDATED, "group", "num_comments") transaction.on_commit( partial(safe_execute, bulk_send_snapshot_values, [instance.id], None), @@ -237,37 +247,46 @@ def post_save_log_group_attributes_changed(instance, sender, created, *args, **k _log_group_attributes_changed(Operation.CREATED, "group", None) send_snapshot_values(None, instance, False) else: - if process_update_fields(kwargs.get("update_fields", set())): + if process_update_fields(_string_set(kwargs.get("update_fields"))): send_snapshot_values(None, instance, False) except Exception: logger.exception("failed to log group attributes after group post_save") @receiver(post_update, sender=Group, dispatch_uid="post_update_group", weak=False) -def post_update_group(sender, updated_fields, model_ids, *args, **kwargs) -> None: +def post_update_group( + sender: type[Model], + updated_fields: list[str], + model_ids: list[int], + *args: object, + **kwargs: object, +) -> None: try: - updated_fields = process_update_fields(updated_fields) - if updated_fields: + changed_fields = process_update_fields(updated_fields) + if changed_fields: bulk_send_snapshot_values(model_ids, None) except Exception: logger.exception("failed to log group attributes after group_owner updated") -def process_update_fields(updated_fields) -> set[str]: +def process_update_fields(updated_fields: Iterable[str] | None) -> set[str]: + fields: set[str] if not updated_fields: # we have no guarantees update_fields is used everywhere save() is called # we'll need to assume any of the attributes are updated in that case - updated_fields = {"all"} + fields = {"all"} else: VALID_FIELDS = {"status", "substatus", "num_comments", "priority", "first_release"} - updated_fields = VALID_FIELDS.intersection(updated_fields or ()) - if updated_fields: - _log_group_attributes_changed(Operation.UPDATED, "group", "-".join(sorted(updated_fields))) - return updated_fields + fields = VALID_FIELDS.intersection(updated_fields) + if fields: + _log_group_attributes_changed(Operation.UPDATED, "group", "-".join(sorted(fields))) + return fields @issue_deleted.connect(weak=False) -def on_issue_deleted_log_deleted(group, user, delete_type, **kwargs) -> None: +def on_issue_deleted_log_deleted( + group: Group, user: object, delete_type: str, **kwargs: object +) -> None: try: _log_group_attributes_changed(Operation.DELETED, "group", "all") send_snapshot_values(None, group, True) @@ -276,7 +295,9 @@ def on_issue_deleted_log_deleted(group, user, delete_type, **kwargs) -> None: @issue_assigned.connect(weak=False) -def on_issue_assigned_log_group_assignee_attributes_changed(project, group, user, **kwargs) -> None: +def on_issue_assigned_log_group_assignee_attributes_changed( + project: Project, group: Group, user: object, **kwargs: object +) -> None: try: _log_group_attributes_changed(Operation.UPDATED, "group_assignee", "all") send_snapshot_values(None, group, False) @@ -286,7 +307,7 @@ def on_issue_assigned_log_group_assignee_attributes_changed(project, group, user @issue_unassigned.connect(weak=False) def on_issue_unassigned_log_group_assignee_attributes_changed( - project, group, user, **kwargs + project: Project, group: Group, user: object, **kwargs: object ) -> None: try: _log_group_attributes_changed(Operation.DELETED, "group_assignee", "all") @@ -299,7 +320,12 @@ def on_issue_unassigned_log_group_assignee_attributes_changed( post_save, sender=GroupOwner, dispatch_uid="post_save_log_group_owner_changed", weak=False ) def post_save_log_group_owner_changed( - instance, sender, created, update_fields, *args, **kwargs + instance: GroupOwner, + sender: type[GroupOwner], + created: bool, + update_fields: object, + *args: object, + **kwargs: object, ) -> None: try: _log_group_attributes_changed( @@ -313,7 +339,9 @@ def post_save_log_group_owner_changed( @receiver( post_delete, sender=GroupOwner, dispatch_uid="post_delete_log_group_owner_changed", weak=False ) -def post_delete_log_group_owner_changed(instance, sender, *args, **kwargs) -> None: +def post_delete_log_group_owner_changed( + instance: GroupOwner, sender: type[GroupOwner], *args: object, **kwargs: object +) -> None: try: _log_group_attributes_changed(Operation.DELETED, "group_owner", "all") send_snapshot_values(instance.group_id, None, False) diff --git a/src/sentry/issues/auto_source_code_config/constants.py b/src/sentry/issues/auto_source_code_config/constants.py index 4aa9a9ffe1d4..08952d4bb0b3 100644 --- a/src/sentry/issues/auto_source_code_config/constants.py +++ b/src/sentry/issues/auto_source_code_config/constants.py @@ -1,7 +1,7 @@ from __future__ import annotations -from collections.abc import Mapping -from typing import Any +from collections.abc import Callable, Sequence +from typing import TypedDict from sentry.integrations.types import IntegrationProviderSlug @@ -15,8 +15,19 @@ ] STACK_ROOT_MAX_LEVEL = 4 +SourceRootsResolver = Callable[[str, Sequence[str] | None], tuple[str, str] | None] + + +class PlatformConfigDict(TypedDict, total=False): + extensions: Sequence[str] + dry_run: bool + extract_filename_from_module: bool + create_in_app_stack_trace_rules: bool + source_roots_resolver: SourceRootsResolver + + # The extensions do not need to be exhaustive but only include the ones that show up in stacktraces -PLATFORMS_CONFIG: dict[str, Mapping[str, Any]] = { +PLATFORMS_CONFIG: dict[str, PlatformConfigDict] = { # C#, F#, VB, PowerShell, C# Script, F# Script "csharp": {"extensions": ["cs", "fs", "vb", "ps1", "csx", "fsx"]}, "go": {"extensions": ["go"]}, diff --git a/src/sentry/issues/auto_source_code_config/utils/platform.py b/src/sentry/issues/auto_source_code_config/utils/platform.py index 6a367045e374..18ea7e5be683 100644 --- a/src/sentry/issues/auto_source_code_config/utils/platform.py +++ b/src/sentry/issues/auto_source_code_config/utils/platform.py @@ -1,9 +1,9 @@ from collections.abc import Callable, Sequence -from typing import Any +from typing import cast from sentry.models.organization import Organization -from ..constants import PLATFORMS_CONFIG +from ..constants import PLATFORMS_CONFIG, PlatformConfigDict SourceRootsResolver = Callable[[str, Sequence[str] | None], tuple[str, str] | None] @@ -33,9 +33,10 @@ def get_supported_extensions() -> list[str]: return list(extensions) -def get_platform_config(platform: str) -> dict[str, Any]: +def get_platform_config(platform: str) -> PlatformConfigDict: """Return the platform config for the given platform""" - return dict(PLATFORMS_CONFIG[platform]) + # justification: dict() copies the TypedDict entries into a fresh dict. + return cast("PlatformConfigDict", dict(PLATFORMS_CONFIG[platform])) class PlatformConfig: diff --git a/src/sentry/issues/derived/framework.py b/src/sentry/issues/derived/framework.py index aa109d4ee7fb..763e67d1f6dc 100644 --- a/src/sentry/issues/derived/framework.py +++ b/src/sentry/issues/derived/framework.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from datetime import datetime from enum import IntEnum, StrEnum -from typing import Any, ClassVar, Final, Literal, Protocol, override, runtime_checkable +from typing import Any, ClassVar, Final, Literal, Protocol, cast, override, runtime_checkable class _Missing: @@ -66,7 +66,8 @@ class Codec[T]: """ def _validate(self, value: Any) -> T: - return value + # justification: the identity codec accepts and returns the same value. + return cast("T", value) def to_json(self, value: T) -> Any: return self._validate(value) @@ -208,7 +209,8 @@ def __init__( self._version: Final[int] = version self._default = default self._default_factory = default_factory - self._codec = codec or IDENTITY_CODEC + # justification: the identity codec accepts and returns the same value. + self._codec: Codec[T] = codec or cast("Codec[T]", IDENTITY_CODEC) self._hash = hash((name, version)) @property @@ -273,7 +275,8 @@ def __init__(self, data: dict[Feature[Any], Any] | None = None) -> None: self._data: dict[Feature[Any], Any] = data if data is not None else {} def __getitem__[T](self, key: Feature[T]) -> T: - return self._data[key] + # justification: values are stored under the feature that declares their type. + return cast("T", self._data[key]) def __setitem__[T](self, key: Feature[T], value: T) -> None: self._data[key] = value @@ -333,7 +336,8 @@ def __init__(self, data: dict[Feature[Any], Any], allowed: frozenset[Feature[Any def __getitem__[T](self, key: Feature[T]) -> T: if key not in self._allowed: raise KeyError(f"Feature {key.name!r} is not accessible in this view") - return self._data[key] + # justification: values are stored under the feature that declares their type. + return cast("T", self._data[key]) def __contains__(self, key: object) -> bool: return key in self._allowed and key in self._data diff --git a/src/sentry/issues/endpoints/event_grouping_info.py b/src/sentry/issues/endpoints/event_grouping_info.py index 49e09d9d9efa..2921f347e81d 100644 --- a/src/sentry/issues/endpoints/event_grouping_info.py +++ b/src/sentry/issues/endpoints/event_grouping_info.py @@ -10,6 +10,7 @@ from sentry.grouping.api import load_grouping_config from sentry.grouping.grouping_info import get_grouping_info from sentry.interfaces.stacktrace import StacktraceOrder +from sentry.models.project import Project from sentry.services import eventstore from sentry.users.services.user_option import user_option_service from sentry.users.services.user_option.service import get_option_from_list @@ -22,7 +23,7 @@ class EventGroupingInfoEndpoint(ProjectEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: HttpRequest, project, event_id) -> HttpResponse: + def get(self, request: HttpRequest, project: Project, event_id: str) -> HttpResponse: """ Returns the grouping information for an event ````````````````````````````````````````````` diff --git a/src/sentry/issues/endpoints/event_owners.py b/src/sentry/issues/endpoints/event_owners.py index 1b969c851f11..9e9f3f97cf47 100644 --- a/src/sentry/issues/endpoints/event_owners.py +++ b/src/sentry/issues/endpoints/event_owners.py @@ -10,6 +10,7 @@ from sentry.api.bases.project import ProjectEndpoint from sentry.api.serializers import serialize from sentry.api.serializers.models.actor import ActorSerializer +from sentry.models.project import Project from sentry.models.projectownership import ProjectOwnership from sentry.services import eventstore from sentry.types.actor import Actor @@ -22,7 +23,7 @@ class EventOwnersEndpoint(ProjectEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, project, event_id) -> Response: + def get(self, request: Request, project: Project, event_id: str) -> Response: """ Retrieve suggested owners information for an event `````````````````````````````````````````````````` diff --git a/src/sentry/issues/endpoints/event_reprocessable.py b/src/sentry/issues/endpoints/event_reprocessable.py index e863fa8d989b..756177817a59 100644 --- a/src/sentry/issues/endpoints/event_reprocessable.py +++ b/src/sentry/issues/endpoints/event_reprocessable.py @@ -6,6 +6,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint +from sentry.models.project import Project from sentry.reprocessing2 import CannotReprocess, pull_event_data @@ -16,7 +17,7 @@ class EventReprocessableEndpoint(ProjectEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, project, event_id) -> Response: + def get(self, request: Request, project: Project, event_id: str) -> Response: """ Retrieve information about whether an event can be reprocessed. ``````````````````````````````````````````````````````````````` diff --git a/src/sentry/issues/endpoints/group_attachments.py b/src/sentry/issues/endpoints/group_attachments.py index 5617eec191f0..275610c44aaa 100644 --- a/src/sentry/issues/endpoints/group_attachments.py +++ b/src/sentry/issues/endpoints/group_attachments.py @@ -77,7 +77,7 @@ class GroupAttachmentsEndpoint(GroupEndpoint): suggested_api="sentry-api-0-organization-group-group-attachments", url_names=["sentry-api-0-group-attachments"], ) - def get(self, request: Request, group) -> Response: + def get(self, request: Request, group: Group) -> Response: """ List Event Attachments `````````````````````` diff --git a/src/sentry/issues/endpoints/group_current_release.py b/src/sentry/issues/endpoints/group_current_release.py index 307a3f4d5416..101be95c3312 100644 --- a/src/sentry/issues/endpoints/group_current_release.py +++ b/src/sentry/issues/endpoints/group_current_release.py @@ -1,3 +1,5 @@ +from collections.abc import Sequence + from rest_framework.request import Request from rest_framework.response import Response from sentry_sdk import traces @@ -11,6 +13,8 @@ from sentry.api.serializers.models.grouprelease import GroupReleaseWithStatsSerializer from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.issues.endpoints.bases.group import GroupEndpoint +from sentry.models.environment import Environment +from sentry.models.group import Group from sentry.models.grouprelease import GroupRelease from sentry.models.releaseenvironment import ReleaseEnvironment from sentry.models.releases.release_project import ReleaseProject @@ -23,7 +27,9 @@ class GroupCurrentReleaseEndpoint(GroupEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def _get_current_release(self, group, environments): + def _get_current_release( + self, group: Group, environments: Sequence[Environment] + ) -> GroupRelease | None: release_projects = ReleaseProject.objects.filter(project_id=group.project_id).values_list( "release_id", flat=True ) @@ -56,7 +62,7 @@ def _get_current_release(self, group, environments): suggested_api="sentry-api-0-organization-group-group-current-release", url_names=["sentry-api-0-group-current-release"], ) - def get(self, request: Request, group) -> Response: + def get(self, request: Request, group: Group) -> Response: """Get the current release in the group's project. Find the most recent release in the project associated with the issue diff --git a/src/sentry/issues/endpoints/group_events.py b/src/sentry/issues/endpoints/group_events.py index e7668c8237a7..a935022dba3c 100644 --- a/src/sentry/issues/endpoints/group_events.py +++ b/src/sentry/issues/endpoints/group_events.py @@ -223,4 +223,4 @@ def _get_search_query( else: query = None - return query + return query if isinstance(query, str) else None diff --git a/src/sentry/issues/endpoints/group_integration_details.py b/src/sentry/issues/endpoints/group_integration_details.py index 17788de07580..54e6e65676a2 100644 --- a/src/sentry/issues/endpoints/group_integration_details.py +++ b/src/sentry/issues/endpoints/group_integration_details.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 django.contrib.auth.models import AnonymousUser from django.db import IntegrityError, router, transaction @@ -44,6 +44,7 @@ ProjectManagementEvent, ) from sentry.integrations.services.integration import RpcIntegration, integration_service +from sentry.integrations.types import IntegrationIssueConfigField from sentry.issues.action_log import ( action_context_scope, publish_action, @@ -90,8 +91,8 @@ def _get_external_issue_link_lock( class IntegrationIssueConfigResponse(IntegrationSerializerResponse, total=False): # Exactly one of these is present on a given response, selected by the `action` # query param: `linkIssueConfig` for `link`, `createIssueConfig` for `create`. - linkIssueConfig: list[dict[str, Any]] - createIssueConfig: list[dict[str, Any]] + linkIssueConfig: list[IntegrationIssueConfigField] + createIssueConfig: list[IntegrationIssueConfigField] class ExternalIssueLinkResponse(TypedDict): @@ -103,7 +104,7 @@ class ExternalIssueLinkResponse(TypedDict): class IntegrationIssueConfigSerializer(Serializer[IntegrationIssueConfigResponse]): - def __init__(self, action: str, config: list[dict[str, Any]]) -> None: + def __init__(self, action: str, config: list[IntegrationIssueConfigField]) -> None: self.action = action self.config = config @@ -115,9 +116,10 @@ def serialize( **kwargs: Any, ) -> IntegrationIssueConfigResponse: base = IntegrationSerializer().serialize(obj, attrs, user) + # justification: mypy cannot prove a spread plus one optional key satisfies the TypedDict. if self.action == "link": - return {**base, "linkIssueConfig": self.config} - return {**base, "createIssueConfig": self.config} + return cast("IntegrationIssueConfigResponse", {**base, "linkIssueConfig": self.config}) + return cast("IntegrationIssueConfigResponse", {**base, "createIssueConfig": self.config}) ACTION_PARAM = OpenApiParameter( diff --git a/src/sentry/issues/endpoints/group_integrations.py b/src/sentry/issues/endpoints/group_integrations.py index 30693837aa8b..affb66315296 100644 --- a/src/sentry/issues/endpoints/group_integrations.py +++ b/src/sentry/issues/endpoints/group_integrations.py @@ -2,7 +2,7 @@ from collections import defaultdict from collections.abc import Mapping, MutableMapping, Sequence -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from django.contrib.auth.models import AnonymousUser from drf_spectacular.utils import OpenApiParameter, extend_schema @@ -33,6 +33,7 @@ from sentry.models.grouplink import GroupLink from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser +from sentry.utils.cursors import CursorResult class LinkedExternalIssueResponse(TypedDict): @@ -137,7 +138,9 @@ class GroupIntegrationsEndpoint(GroupEndpoint): suggested_api="sentry-api-0-organization-group-group-integrations", url_names=["sentry-api-0-group-integrations"], ) - def get(self, request: Request, group) -> Response[list[IntegrationIssueSerializerResponse]]: + def get( + self, request: Request, group: Group + ) -> Response[list[IntegrationIssueSerializerResponse]]: """ List the organization's issue-tracker integrations and the external issues linked to this Sentry issue through each integration. The external issue's @@ -175,5 +178,10 @@ def get(self, request: Request, group) -> Response[list[IntegrationIssueSerializ ) ) - self.add_cursor_headers(request, response, result) + self.add_cursor_headers( + # justification: the RPC pagination result mirrors CursorResult's cursor fields. + request, + response, + cast("CursorResult[Any]", result), + ) return response diff --git a/src/sentry/issues/endpoints/group_reprocessing.py b/src/sentry/issues/endpoints/group_reprocessing.py index 45a83ac16691..4ae115895ad4 100644 --- a/src/sentry/issues/endpoints/group_reprocessing.py +++ b/src/sentry/issues/endpoints/group_reprocessing.py @@ -6,6 +6,7 @@ from sentry.api.helpers.deprecation import deprecated from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.issues.endpoints.bases.group import GroupEndpoint +from sentry.models.group import Group from sentry.tasks.reprocessing2 import reprocess_group @@ -20,7 +21,7 @@ class GroupReprocessingEndpoint(GroupEndpoint): suggested_api="sentry-api-0-organization-group-group-reprocessing", url_names=["sentry-api-0-group-reprocessing"], ) - def post(self, request: Request, group) -> Response: + def post(self, request: Request, group: Group) -> Response: """ Reprocess a group ````````````````` diff --git a/src/sentry/issues/endpoints/group_stats.py b/src/sentry/issues/endpoints/group_stats.py index 8432613e0eb8..c4348cf72605 100644 --- a/src/sentry/issues/endpoints/group_stats.py +++ b/src/sentry/issues/endpoints/group_stats.py @@ -10,6 +10,7 @@ from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.issues.endpoints.bases.group import GroupEndpoint from sentry.models.environment import Environment +from sentry.models.group import Group from sentry.tsdb.base import TSDBModel @@ -24,7 +25,7 @@ class GroupStatsEndpoint(GroupEndpoint, StatsMixin): suggested_api="sentry-api-0-organization-group-group-stats", url_names=["sentry-api-0-group-stats"], ) - def get(self, request: Request, group) -> Response: + def get(self, request: Request, group: Group) -> Response: try: environment_id = get_environment_id(request, group.project.organization_id) except Environment.DoesNotExist: diff --git a/src/sentry/issues/endpoints/group_tagkey_details.py b/src/sentry/issues/endpoints/group_tagkey_details.py index 3df61a18a28a..613835f26bd6 100644 --- a/src/sentry/issues/endpoints/group_tagkey_details.py +++ b/src/sentry/issues/endpoints/group_tagkey_details.py @@ -22,6 +22,7 @@ from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.issues.endpoints.bases.group import GroupEndpoint from sentry.models.environment import Environment +from sentry.models.group import Group from sentry.ratelimits.config import RateLimitConfig from sentry.tagstore.types import TagKeySerializer, TagKeySerializerResponse from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -71,12 +72,12 @@ class GroupTagKeyDetailsEndpoint(GroupEndpoint): suggested_api="sentry-api-0-organization-group-group-tag-key-details", url_names=["sentry-api-0-group-tag-key-details"], ) - def get(self, request: Request, group, key) -> Response[TagKeySerializerResponse]: + def get(self, request: Request, group: Group, key: str) -> Response[TagKeySerializerResponse]: """ Returns the values and aggregate details of a given tag key related to an issue. """ lookup_key = tagstore.backend.prefix_reserved_key(key) - tenant_ids = {"organization_id": group.project.organization_id} + tenant_ids: dict[str, int | str] = {"organization_id": group.project.organization_id} try: environment_id = get_environment_id(request, group.project.organization_id) except Environment.DoesNotExist: diff --git a/src/sentry/issues/endpoints/group_tagkey_values.py b/src/sentry/issues/endpoints/group_tagkey_values.py index 7f34e1a9d037..237cc54db262 100644 --- a/src/sentry/issues/endpoints/group_tagkey_values.py +++ b/src/sentry/issues/endpoints/group_tagkey_values.py @@ -23,6 +23,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.issues.endpoints.bases.group import GroupEndpoint +from sentry.models.group import Group from sentry.ratelimits.config import RateLimitConfig from sentry.tagstore.types import TagValueSerializerResponse from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -75,7 +76,9 @@ class GroupTagKeyValuesEndpoint(GroupEndpoint): suggested_api="sentry-api-0-organization-group-group-tag-key-values", url_names=["sentry-api-0-group-tag-key-values"], ) - def get(self, request: Request, group, key) -> Response[list[TagValueSerializerResponse]]: + def get( + self, request: Request, group: Group, key: str + ) -> Response[list[TagValueSerializerResponse]]: """ List a Tag's Values """ @@ -88,7 +91,7 @@ def get(self, request: Request, group, key) -> Response[list[TagValueSerializerR lookup_key = tagstore.backend.prefix_reserved_key(key) environment_ids = [e.id for e in get_environments(request, group.project.organization)] - tenant_ids = {"organization_id": group.project.organization_id} + tenant_ids: dict[str, int | str] = {"organization_id": group.project.organization_id} try: tagstore.backend.get_group_tag_key( group, diff --git a/src/sentry/issues/endpoints/group_user_reports.py b/src/sentry/issues/endpoints/group_user_reports.py index 2d9d59dce948..7f4a31842356 100644 --- a/src/sentry/issues/endpoints/group_user_reports.py +++ b/src/sentry/issues/endpoints/group_user_reports.py @@ -10,6 +10,7 @@ from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.issues.endpoints.bases.group import GroupEndpoint from sentry.models.environment import Environment +from sentry.models.group import Group from sentry.models.userreport import UserReport @@ -24,7 +25,7 @@ class GroupUserReportsEndpoint(GroupEndpoint): suggested_api="sentry-api-0-organization-group-group-user-reports", url_names=["sentry-api-0-group-user-reports"], ) - def get(self, request: Request, group) -> Response: + def get(self, request: Request, group: Group) -> Response: """ List User Reports ````````````````` diff --git a/src/sentry/issues/endpoints/organization_event_details.py b/src/sentry/issues/endpoints/organization_event_details.py index e657c6b9b97e..f5b150084996 100644 --- a/src/sentry/issues/endpoints/organization_event_details.py +++ b/src/sentry/issues/endpoints/organization_event_details.py @@ -24,6 +24,7 @@ from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import ObjectStatus +from sentry.models.organization import Organization from sentry.models.project import Project from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import SnubaParams @@ -39,7 +40,7 @@ class OrganizationEventDetailsResponse(SqlFormatEventSerializerResponse): projectSlug: str -def add_comparison_to_event(event: BaseEvent, average_columns: list[str]): +def add_comparison_to_event(event: BaseEvent, average_columns: list[str]) -> None: if "spans" not in event.data: return group_to_span_map = defaultdict(list) @@ -151,7 +152,7 @@ def convert_args( }, ) def get( - self, request: Request, organization, project: Project, event_id + self, request: Request, organization: Organization, project: Project, event_id: str ) -> Response[OrganizationEventDetailsResponse] | Response[DetailResponse] | Response[None]: """ Return details on an individual event, looked up by project and event ID. The diff --git a/src/sentry/issues/endpoints/organization_group_index.py b/src/sentry/issues/endpoints/organization_group_index.py index 7d9c882f5a01..b5b1c979182c 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 = [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/issues/endpoints/organization_group_search_view_details_starred.py b/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py index c141a9e6ca10..97bb82139828 100644 --- a/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py +++ b/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py @@ -16,7 +16,7 @@ class StarViewSerializer(serializers.Serializer): starred = serializers.BooleanField(required=True) position = serializers.IntegerField(required=False) - def validate(self, data): + def validate(self, data: dict[str, object]) -> dict[str, object]: if not data["starred"] and "position" in data: raise serializers.ValidationError("Position is only allowed when starring a view.") return data diff --git a/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py b/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py index 03d4ce4d19bb..87e57e3858ac 100644 --- a/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py +++ b/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py @@ -20,7 +20,7 @@ class MemberPermission(OrganizationPermission): class GroupSearchViewStarredOrderSerializer(serializers.Serializer): view_ids = serializers.ListField(child=serializers.IntegerField(), required=True, min_length=0) - def validate_view_ids(self, view_ids): + def validate_view_ids(self, view_ids: list[int]) -> list[int]: if len(view_ids) != len(set(view_ids)): raise serializers.ValidationError("Single view cannot take up multiple positions") diff --git a/src/sentry/issues/endpoints/organization_issue_metrics.py b/src/sentry/issues/endpoints/organization_issue_metrics.py index d991ce846484..4f5f622e3f6e 100644 --- a/src/sentry/issues/endpoints/organization_issue_metrics.py +++ b/src/sentry/issues/endpoints/organization_issue_metrics.py @@ -3,9 +3,9 @@ from datetime import datetime, timedelta from heapq import nlargest from itertools import chain -from typing import TypedDict +from typing import TypedDict, cast -from django.db.models import Count, DateTimeField, F, Func, Q +from django.db.models import Count, DateTimeField, F, Func, Manager, Q, QuerySet from django.db.models.functions import Extract from drf_spectacular.utils import extend_schema from rest_framework.exceptions import ParseError @@ -135,12 +135,12 @@ def get(self, request: Request, organization: Organization) -> Response: raise ParseError("The specified granularity is too precise. Increase your interval.") def gen_ts( - qs, + qs: QuerySet[Group] | Manager[Group], group_by: list[str], date_column_name: str, axis: str, ) -> list[TimeSeriesResult]: - qs = make_timeseries_query( + rows = make_timeseries_query( qs, projects, environments, @@ -154,11 +154,13 @@ def gen_ts( grouped_counter: collections.defaultdict[str, int] = collections.defaultdict(int) grouped_series: dict[str, list[TimeSeries]] = collections.defaultdict(list) - for row in qs: - grouping = [row[g] for g in group_by] + for row in rows: + grouping = [str(row[g]) for g in group_by] key = "||||".join(grouping) - grouped_counter[key] += row["value"] - grouped_series[key].append({"timestamp": row["timestamp"], "value": row["value"]}) + grouped_counter[key] += int(row["value"]) + grouped_series[key].append( + {"timestamp": float(row["timestamp"]), "value": int(row["value"])} + ) # Group the smallest series into the "other" bucket. if len(grouped_series) > 4: @@ -245,7 +247,7 @@ def gen_ts( def make_timeseries_query( - qs, + qs: QuerySet[Group] | Manager[Group], projects: list[Project], environments: list[int], type_filter: Q, @@ -254,7 +256,7 @@ def make_timeseries_query( source: str, start: datetime, end: datetime, -): +) -> QuerySet[Group, dict[str, str | int | float]]: environment_filter = ( Q(groupenvironment__environment_id=environments[0]) if environments else Q() ) @@ -281,7 +283,7 @@ def make_timeseries_query( order_by.append("timestamp") values.append("timestamp") - qs = ( + rows = ( qs.filter( environment_filter, type_filter, @@ -293,7 +295,8 @@ def make_timeseries_query( .values(*values) .annotate(value=Count("id")) ) - return qs + # justification: annotate() with dynamic kwargs erases the queryset type. + return cast("QuerySet[Group, dict[str, str | int | float]]", rows) def make_timeseries_result( diff --git a/src/sentry/issues/endpoints/organization_issue_timeseries.py b/src/sentry/issues/endpoints/organization_issue_timeseries.py index e326e4b96b4f..30ac43c274eb 100644 --- a/src/sentry/issues/endpoints/organization_issue_timeseries.py +++ b/src/sentry/issues/endpoints/organization_issue_timeseries.py @@ -2,9 +2,9 @@ from collections.abc import Iterator from datetime import datetime, timedelta from heapq import nlargest -from typing import Any +from typing import Any, cast -from django.db.models import Count, DateTimeField, F, Func, Q +from django.db.models import Count, DateTimeField, F, Func, Q, QuerySet from django.db.models.functions import Extract from drf_spectacular.utils import extend_schema from rest_framework.exceptions import ParseError @@ -30,6 +30,7 @@ from sentry.models.group import Group, GroupStatus from sentry.models.organization import Organization from sentry.models.project import Project +from sentry.search.events.types import SnubaRow from sentry.snuba.discover import create_groupby_dict, create_result_key from sentry.utils.dates import get_rollup_from_request @@ -122,12 +123,12 @@ def get(self, request: Request, organization: Organization) -> Response: raise ParseError(f"Unknown axis: {axis}") def gen_ts( - qs, + qs: QuerySet[Group], group_by: list[str], date_column_name: str, axis: str, ) -> list[TimeSeries]: - qs = make_timeseries_query( + rows = make_timeseries_query( qs, projects, environments, @@ -142,7 +143,7 @@ def gen_ts( grouped_counter: collections.defaultdict[str, int] = collections.defaultdict(int) groupby_key_to_dict: dict[str, list[GroupBy]] = {} grouped_series: dict[str, list[Row]] = collections.defaultdict(list) - for row in qs: + for row in rows: key = create_result_key(row, group_by, {}) groupby_dict = create_groupby_dict(row, group_by, {}) for groupby_item in groupby_dict: @@ -238,7 +239,7 @@ def gen_ts( def make_timeseries_query( - qs, + qs: QuerySet[Group], projects: list[Project], environments: list[int], type_filter: Q, @@ -247,7 +248,7 @@ def make_timeseries_query( source: str, start: datetime, end: datetime, -): +) -> QuerySet[Group, SnubaRow]: environment_filter = ( Q(groupenvironment__environment_id=environments[0]) if environments else Q() ) @@ -288,7 +289,7 @@ def make_timeseries_query( .values(*values) .annotate(value=Count("id")) ) - return qs + return cast(QuerySet[Group, SnubaRow], qs) def make_timeseries_result( diff --git a/src/sentry/issues/endpoints/organization_issues_count.py b/src/sentry/issues/endpoints/organization_issues_count.py index c52e78166981..a40f4978c890 100644 --- a/src/sentry/issues/endpoints/organization_issues_count.py +++ b/src/sentry/issues/endpoints/organization_issues_count.py @@ -1,3 +1,6 @@ +from collections.abc import Sequence +from typing import Any, cast + from rest_framework.request import Request from rest_framework.response import Response from sentry_sdk import traces @@ -11,7 +14,9 @@ from sentry.api.helpers.group_index.validators import ValidationError from sentry.api.utils import get_date_range_from_params from sentry.issues.issue_search import convert_query_values, parse_search_query +from sentry.models.environment import Environment from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.organizations.services.organization.model import RpcOrganization from sentry.ratelimits.config import RateLimitConfig from sentry.snuba import discover @@ -42,10 +47,17 @@ class OrganizationIssuesCountEndpoint(OrganizationEndpoint): ) def _count( - self, request: Request, query, organization, projects, environments, extra_query_kwargs=None - ): + self, + request: Request, + query: str, + organization: Organization | RpcOrganization, + projects: Sequence[Project], + environments: Sequence[Environment] | None, + # justification: heterogeneous query values + extra_query_kwargs: dict[str, Any] | None = None, + ) -> int: with traces.start_span(name="_count", attributes={"sentry.op": "_count"}): - query_kwargs = { + query_kwargs: dict[str, Any] = { # justification: heterogeneous query values "projects": projects, "referrer": Referrer.API_ORGANIZATION_ISSUES_COUNT, } @@ -55,7 +67,9 @@ def _count( search_filters = convert_query_values( parse_search_query(query), projects, request.user, environments ) - validate_search_filter_permissions(organization, search_filters, request.user) + validate_search_filter_permissions( + cast("Organization", organization), search_filters, request.user + ) query_kwargs["search_filters"] = search_filters if extra_query_kwargs is not None: @@ -75,7 +89,7 @@ def _count( }, ): result = search.backend.query(**query_kwargs) - return result.hits + return cast("int", result.hits) def get(self, request: Request, organization: Organization | RpcOrganization) -> Response: stats_period = request.GET.get("groupStatsPeriod") @@ -91,7 +105,7 @@ def get(self, request: Request, organization: Organization | RpcOrganization) -> return Response([]) queries = request.GET.getlist("query") - response = {} + response: dict[str, int] = {} for query in queries: try: count = self._count( diff --git a/src/sentry/issues/endpoints/organization_shortid.py b/src/sentry/issues/endpoints/organization_shortid.py index fe6985cb8d3b..7814580c3e2a 100644 --- a/src/sentry/issues/endpoints/organization_shortid.py +++ b/src/sentry/issues/endpoints/organization_shortid.py @@ -123,6 +123,7 @@ def get(self, request: Request, group: Group) -> Response[ShortIdLookupResponse] """ Resolve a short ID to the project slug and group details. """ + assert group.qualified_short_id is not None return Response( { "organizationSlug": group.project.organization.slug, diff --git a/src/sentry/issues/endpoints/project_event_details.py b/src/sentry/issues/endpoints/project_event_details.py index 61012136da01..d1046ccfe2a0 100644 --- a/src/sentry/issues/endpoints/project_event_details.py +++ b/src/sentry/issues/endpoints/project_event_details.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any +from typing import Any, cast import sentry_sdk from drf_spectacular.utils import extend_schema @@ -106,7 +106,8 @@ def wrap_event_response( event_data["nextEventID"] = next_event_id event_data["previousEventID"] = prev_event_id - return event_data + # justification: the serializer output is the group event details response. + return cast("GroupEventDetailsResponse", event_data) @extend_schema(tags=["Events"]) diff --git a/src/sentry/issues/endpoints/project_issues_resolved_in_release.py b/src/sentry/issues/endpoints/project_issues_resolved_in_release.py index 54eaa5bc277b..b74f8b33bc77 100644 --- a/src/sentry/issues/endpoints/project_issues_resolved_in_release.py +++ b/src/sentry/issues/endpoints/project_issues_resolved_in_release.py @@ -10,6 +10,7 @@ from sentry.api.serializers import serialize from sentry.api.serializers.models.group import GroupSerializer from sentry.models.group import Group +from sentry.models.project import Project @cell_silo_endpoint @@ -20,7 +21,7 @@ class ProjectIssuesResolvedInReleaseEndpoint(ProjectEndpoint): } permission_classes = (ProjectPermission,) - def get(self, request: Request, project, version) -> Response: + def get(self, request: Request, project: Project, version: str) -> Response: """ List issues to be resolved in a particular release `````````````````````````````````````````````````` diff --git a/src/sentry/issues/endpoints/project_ownership.py b/src/sentry/issues/endpoints/project_ownership.py index 3244989868f4..36ae8a210b4e 100644 --- a/src/sentry/issues/endpoints/project_ownership.py +++ b/src/sentry/issues/endpoints/project_ownership.py @@ -1,4 +1,5 @@ -from typing import Any +from collections.abc import Sequence +from typing import Any, cast from django.utils import timezone from drf_spectacular.utils import extend_schema @@ -21,7 +22,11 @@ from sentry.apidocs.examples import ownership_examples from sentry.apidocs.parameters import GlobalParams from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors -from sentry.issues.ownership.grammar import CODEOWNERS, create_schema_from_issue_owners +from sentry.issues.ownership.grammar import ( + CODEOWNERS, + OwnershipRule, + create_schema_from_issue_owners, +) from sentry.models.organizationmemberteam import OrganizationMemberTeam from sentry.models.project import Project from sentry.models.projectownership import ProjectOwnership @@ -58,7 +63,7 @@ class ProjectOwnershipRequestSerializer(serializers.Serializer): ) @staticmethod - def _validate_no_codeowners(rules): + def _validate_no_codeowners(rules: Sequence[OwnershipRule]) -> None: """ codeowner matcher types cannot be added via ProjectOwnership, only through codeowner specific serializers @@ -69,7 +74,7 @@ def _validate_no_codeowners(rules): {"raw": "Codeowner type paths can only be added by importing CODEOWNER files"} ) - def _validate_team_ownership(self, rules): + def _validate_team_ownership(self, rules: Sequence[OwnershipRule]) -> None: """ Validate that the user has permission to assign ownership to the teams referenced in the rules. If the organization has open team membership enabled, any user can assign ownership. @@ -118,7 +123,7 @@ def _validate_team_ownership(self, rules): {"raw": "You do not have permission to assign ownership to one or more teams."} ) - def get_max_length(self): + def get_max_length(self) -> int: organization = self.context["ownership"].project.organization if features.has("organizations:ownership-size-limit-xlarge", organization): return XLARGE_MAX_RAW_LENGTH @@ -126,7 +131,7 @@ def get_max_length(self): return LARGE_MAX_RAW_LENGTH return DEFAULT_MAX_RAW_LENGTH - def validate_autoAssignment(self, value): + def validate_autoAssignment(self, value: str) -> str: if value not in [ "Auto Assign to Suspect Commits", "Auto Assign to Issue Owner", @@ -135,7 +140,7 @@ def validate_autoAssignment(self, value): raise serializers.ValidationError({"autoAssignment": "Invalid selection."}) return value - def validate(self, attrs): + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: if "raw" not in attrs: return attrs @@ -195,9 +200,10 @@ def save(self, **kwargs: Any) -> ProjectOwnership: ownership.last_updated = now ownership.save() - return ownership + # justification: the ownership row is resolved above. + return cast("ProjectOwnership", ownership) - def __modify_auto_assignment(self, ownership): + def __modify_auto_assignment(self, ownership: ProjectOwnership) -> bool: auto_assignment = self.validated_data.get("autoAssignment") if auto_assignment is None: @@ -282,7 +288,7 @@ def rename_schema_identifier_for_parsing(self, ownership: ProjectOwnership) -> N responses={200: ProjectOwnershipSerializer}, examples=ownership_examples.GET_PROJECT_OWNERSHIP, ) - def get(self, request: Request, project) -> Response[ProjectOwnershipResponse]: + def get(self, request: Request, project: Project) -> Response[ProjectOwnershipResponse]: """ Returns details on a project's ownership configuration. """ diff --git a/src/sentry/issues/endpoints/project_performance_issue_settings.py b/src/sentry/issues/endpoints/project_performance_issue_settings.py index 8600bb40623d..e54f687f1c01 100644 --- a/src/sentry/issues/endpoints/project_performance_issue_settings.py +++ b/src/sentry/issues/endpoints/project_performance_issue_settings.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable, Mapping from enum import Enum from typing import Any @@ -236,7 +237,9 @@ def get_management_options() -> list[str]: ] -def get_disabled_threshold_options(payload, current_settings): +def get_disabled_threshold_options( + payload: Iterable[str], current_settings: Mapping[str, object] +) -> list[str]: """ Returns the option keys that are disabled, based on the current management settings. """ @@ -245,7 +248,11 @@ def get_disabled_threshold_options(payload, current_settings): for option in payload: if option not in management_options: manage_detector_setting = thresholds_to_manage_map.get(option) - is_threshold_enabled = current_settings.get(manage_detector_setting) + is_threshold_enabled = ( + current_settings.get(manage_detector_setting) + if manage_detector_setting is not None + else None + ) if not is_threshold_enabled: options.append(option) return options @@ -279,12 +286,12 @@ class ProjectPerformanceIssueSettingsEndpoint(ProjectEndpoint): } permission_classes = (ProjectSettingPermission,) - def has_feature(self, project, request) -> bool: + def has_feature(self, project: Project, request: Request) -> bool: return features.has( "organizations:performance-view", project.organization, actor=request.user ) - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project: Project) -> Response: """ Retrieve performance issue settings `````````````````` diff --git a/src/sentry/issues/endpoints/project_user_issue.py b/src/sentry/issues/endpoints/project_user_issue.py index 3effe6dd7443..161f2f402793 100644 --- a/src/sentry/issues/endpoints/project_user_issue.py +++ b/src/sentry/issues/endpoints/project_user_issue.py @@ -1,4 +1,5 @@ from datetime import UTC, datetime +from typing import Any, cast from uuid import uuid4 from drf_spectacular.utils import extend_schema @@ -19,9 +20,12 @@ from sentry.models.organization import Organization from sentry.models.project import Project +# justification: user issue payloads are decoded JSON of mixed types. +_UserIssueData = dict[str, Any] + class BaseUserIssueFormatter: - def __init__(self, data: dict): + def __init__(self, data: _UserIssueData): self.data = data def get_issue_type(self) -> type[GroupType]: @@ -36,10 +40,10 @@ def get_issue_subtitle(self) -> str: def create_fingerprint(self) -> list[str]: raise NotImplementedError - def get_tags(self) -> dict: + def get_tags(self) -> dict[str, str]: raise NotImplementedError - def get_evidence(self) -> tuple[dict, list[IssueEvidence]]: + def get_evidence(self) -> tuple[_UserIssueData, list[IssueEvidence]]: raise NotImplementedError @@ -57,10 +61,10 @@ def create_fingerprint(self) -> list[str]: # By default, return a random UUID for a unique group return [uuid4().hex] - def get_tags(self) -> dict: - return {"transaction": self.data.get("transaction")} + def get_tags(self) -> dict[str, str]: + return {"transaction": cast(str, self.data.get("transaction"))} - def get_evidence(self) -> tuple[dict, list[IssueEvidence]]: + def get_evidence(self) -> tuple[_UserIssueData, list[IssueEvidence]]: transaction = self.data.get("transaction", "") evidence_data = { @@ -99,7 +103,7 @@ def create_fingerprint(self) -> list[str]: uuid = uuid4().hex return [f"insights-web-vitals-{vital}-{transaction}-{uuid}"] - def get_tags(self) -> dict: + def get_tags(self) -> dict[str, str]: vital = self.data.get("vital", "") transaction = self.data.get("transaction", "") return { @@ -109,7 +113,7 @@ def get_tags(self) -> dict: vital: str(self.data.get("value", "")), } - def get_evidence(self) -> tuple[dict, list[IssueEvidence]]: + def get_evidence(self) -> tuple[_UserIssueData, list[IssueEvidence]]: vital = self.data.get("vital", "") score = self.data.get("score") transaction = self.data.get("transaction", "") @@ -198,12 +202,12 @@ class ProjectUserIssueEndpoint(ProjectEndpoint): } owner = ApiOwner.DATA_BROWSING - def get_formatter(self, data: dict) -> BaseUserIssueFormatter: + def get_formatter(self, data: _UserIssueData) -> BaseUserIssueFormatter: if data.get("issueType") == WebVitalsGroup.slug: return WebVitalsUserIssueFormatter(data) return DefaultUserIssueFormatter(data) - def get_serializer(self, data: dict) -> serializers.Serializer: + def get_serializer(self, data: _UserIssueData) -> serializers.Serializer: if data.get("issueType") == WebVitalsGroup.slug: return WebVitalsIssueDataSerializer(data=data) return ProjectUserIssueRequestSerializer(data=data) diff --git a/src/sentry/issues/endpoints/team_all_unresolved_issues.py b/src/sentry/issues/endpoints/team_all_unresolved_issues.py index cbee4436be15..18f8af8eebb4 100644 --- a/src/sentry/issues/endpoints/team_all_unresolved_issues.py +++ b/src/sentry/issues/endpoints/team_all_unresolved_issues.py @@ -143,10 +143,10 @@ def calculate_unresolved_counts( agg_project_precounts = { project.id: copy.deepcopy(date_series_dict) for project in project_list } - for r in deduped_historical_issue_status_changes: - bucket = agg_project_precounts[r["project"]][r["bucket"].isoformat()] - bucket["open"] += r.get("open", 0) - bucket["closed"] += r.get("closed", 0) + for deduped in deduped_historical_issue_status_changes: + bucket = agg_project_precounts[deduped["project"]][deduped["bucket"].isoformat()] + bucket["open"] += deduped.get("open", 0) + bucket["closed"] += deduped.get("closed", 0) agg_project_counts = {} for project, precounts in agg_project_precounts.items(): diff --git a/src/sentry/issues/escalating/issue_velocity.py b/src/sentry/issues/escalating/issue_velocity.py index 0ce20c45892e..545dc53c96cf 100644 --- a/src/sentry/issues/escalating/issue_velocity.py +++ b/src/sentry/issues/escalating/issue_velocity.py @@ -150,7 +150,10 @@ def calculate_threshold(project: Project) -> float | None: ) return None - return result[0][THRESHOLD_QUANTILE["name"]] + value = result[0][THRESHOLD_QUANTILE["name"]] + if not isinstance(value, (int, float)): + return None + return float(value) def update_threshold( @@ -255,5 +258,5 @@ def get_latest_threshold(project: Project) -> float: return threshold -def get_redis_client() -> RedisCluster | StrictRedis: +def get_redis_client() -> RedisCluster[str] | StrictRedis[str]: return redis_clusters.get(settings.SENTRY_ESCALATION_THRESHOLDS_REDIS_CLUSTER) diff --git a/src/sentry/issues/grouptype.py b/src/sentry/issues/grouptype.py index 91243fcc5586..4a29500ca155 100644 --- a/src/sentry/issues/grouptype.py +++ b/src/sentry/issues/grouptype.py @@ -801,7 +801,7 @@ class WebVitalsGroup(GroupType): # TODO: Rename to WebVitalsGroupType def should_create_group( grouptype: type[GroupType], - client: RedisCluster | StrictRedis, + client: RedisCluster[str] | StrictRedis[str], grouphash: str, project: Project, ) -> bool: diff --git a/src/sentry/issues/highlights.py b/src/sentry/issues/highlights.py index b675e6aeb8e9..5dc13111cb87 100644 --- a/src/sentry/issues/highlights.py +++ b/src/sentry/issues/highlights.py @@ -1,5 +1,5 @@ import re -from typing import TypedDict +from typing import TypedDict, cast from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field @@ -12,21 +12,23 @@ @extend_schema_field(field=OpenApiTypes.OBJECT) -class HighlightContextField(serializers.Field): +class HighlightContextField( + serializers.Field[dict[str, list[str]], object, dict[str, list[str]], object] +): def to_internal_value(self, data: object) -> dict[str, list[str]]: if not isinstance(data, dict): raise serializers.ValidationError("Expected a dictionary.") for key, value in data.items(): - if not VALID_KEY_PATTERN.match(key): + if not VALID_KEY_PATTERN.match(cast(str, key)): raise serializers.ValidationError(f"Key '{key}' is invalid.") if not isinstance(value, list) or not all(isinstance(item, str) for item in value): raise serializers.ValidationError(f"Value for '{key}' must be a list of strings.") # Remove duplicates data[key] = list(set(value)) - return data + return cast("dict[str, list[str]]", data) - def to_representation(self, value): + def to_representation(self, value: dict[str, list[str]]) -> dict[str, list[str]]: return value diff --git a/src/sentry/issues/issue_search.py b/src/sentry/issues/issue_search.py index f5ba1c3398f2..bde9145ae881 100644 --- a/src/sentry/issues/issue_search.py +++ b/src/sentry/issues/issue_search.py @@ -1,8 +1,9 @@ from __future__ import annotations from collections.abc import Callable, Iterable, Mapping, Sequence +from datetime import datetime from functools import partial -from typing import overload +from typing import cast, overload from django.contrib.auth.models import AnonymousUser @@ -88,17 +89,17 @@ [ Iterable[str], Sequence[Project], - User, + User | RpcUser | AnonymousUser | None, Sequence[Environment] | None, ], - str | list[str] | list[RpcUser | Team | None] | list[RpcUser] | list[int], + str | list[str] | list[RpcUser | Team | None] | list[RpcUser] | list[int] | list[float], ] def convert_actor_or_none_value( value: Iterable[str], projects: Sequence[Project], - user: User, + user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, ) -> list[RpcUser | Team | None]: # TODO: This will make N queries. This should be ok, we don't typically have large @@ -115,7 +116,7 @@ def convert_actor_or_none_value( def convert_user_value( value: Iterable[str], projects: Sequence[Project], - user: User, + user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, ) -> list[RpcUser]: # TODO: This will make N queries. This should be ok, we don't typically have large @@ -126,7 +127,7 @@ def convert_user_value( def convert_release_value( value: Iterable[str], projects: Sequence[Project], - user: User, + user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, ) -> str | list[str]: # TODO: This will make N queries. This should be ok, we don't typically have large @@ -143,7 +144,7 @@ def convert_release_value( def convert_first_release_value( value: Iterable[str], projects: Sequence[Project], - user: User, + user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, ) -> list[str]: # TODO: This will make N queries. This should be ok, we don't typically have large @@ -157,7 +158,7 @@ def convert_first_release_value( def convert_substatus_value( value: Iterable[str | int], projects: Sequence[Project], - user: User, + user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, ) -> list[int]: parsed = [] @@ -172,7 +173,7 @@ def convert_substatus_value( def convert_status_value( value: Iterable[str | int], projects: Sequence[Project], - user: User, + user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, ) -> list[int]: parsed = [] @@ -187,7 +188,7 @@ def convert_status_value( def convert_category_value( value: Iterable[str], projects: Sequence[Project], - user: User, + user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, ) -> list[int]: """Convert a value like 'error' or 'performance' to the GroupType value for issue lookup""" @@ -203,7 +204,7 @@ def convert_category_value( def convert_priority_value( value: Iterable[str], projects: Sequence[Project], - user: User, + user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, ) -> list[int]: """Convert a value like 'high' or 'medium' to the Priority value for issue lookup""" @@ -219,7 +220,7 @@ def convert_priority_value( def convert_type_value( value: Iterable[str], projects: Sequence[Project], - user: User, + user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, ) -> list[int]: """Convert a value like 'error' or 'performance_n_plus_one_db_queries' to the GroupType value for issue lookup""" @@ -235,7 +236,7 @@ def convert_type_value( def convert_device_class_value( value: Iterable[str], projects: Sequence[Project], - user: User, + user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, ) -> list[str]: """Convert high, medium, and low to the underlying device class values""" @@ -251,9 +252,9 @@ def convert_device_class_value( def convert_seer_actionability_value( value: Iterable[str], projects: Sequence[Project], - user: User, + user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, -): +) -> list[float]: """Convert high, medium, and low to fixability score thresholds""" results: list[float] = [] for fixable in value: @@ -267,7 +268,7 @@ def convert_seer_actionability_value( def convert_issue_progress_value( value: Iterable[str], projects: Sequence[Project], - user: User, + user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, ) -> list[str]: results: list[str] = [] @@ -297,7 +298,7 @@ def convert_autofix_state_value( def convert_detector_value( value: Iterable[str], projects: Sequence[Project], - user: User, + user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, ) -> list[str]: """Convert detector ID(s) to a list of strings for consistent handling""" @@ -333,8 +334,8 @@ def convert_query_values( projects: Sequence[Project], user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, - value_converters=value_converters, - allow_aggregate_filters=False, + value_converters: Mapping[str, ValueConverter] = value_converters, + allow_aggregate_filters: bool = False, ) -> list[SearchFilter]: ... @@ -345,8 +346,8 @@ def convert_query_values( projects: Sequence[Project], user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, - value_converters=value_converters, - allow_aggregate_filters=False, + value_converters: Mapping[str, ValueConverter] = value_converters, + allow_aggregate_filters: bool = False, ) -> Sequence[AggregateFilter | SearchFilter]: ... @@ -356,8 +357,8 @@ def convert_query_values( projects: Sequence[Project], user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, - value_converters=value_converters, - allow_aggregate_filters=False, + value_converters: Mapping[str, ValueConverter] = value_converters, + allow_aggregate_filters: bool = False, ) -> Sequence[QueryToken]: ... @@ -367,8 +368,8 @@ def convert_query_values( projects: Sequence[Project], user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, - value_converters=value_converters, - allow_aggregate_filters=False, + value_converters: Mapping[str, ValueConverter] = value_converters, + allow_aggregate_filters: bool = False, ) -> Sequence[ParsedTerm]: ... @@ -377,8 +378,8 @@ def convert_query_values( projects: Sequence[Project], user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, - value_converters=value_converters, - allow_aggregate_filters=False, + value_converters: Mapping[str, ValueConverter] = value_converters, + allow_aggregate_filters: bool = False, ) -> Sequence[QueryToken | str]: """ Accepts a collection of SearchFilter objects and converts their values into @@ -426,16 +427,20 @@ def convert_search_filter( if search_filter.key.name in value_converters: converter = value_converters[search_filter.key.name] - new_value = converter( - to_list(search_filter.value.raw_value), projects, user, environments - ) + # justification: converter keys hold string search values. + raw_value = cast(str | list[str], search_filter.value.raw_value) + new_value = converter(to_list(raw_value), projects, user, environments) if isinstance(new_value, list): operator = "IN" if search_filter.operator in EQUALITY_OPERATORS else "NOT IN" else: operator = "=" if search_filter.operator in EQUALITY_OPERATORS else "!=" + # justification: converter output is consumed through SearchValue.value. + search_value = cast( + str | float | datetime | Sequence[float] | Sequence[str] | None, new_value + ) search_filter = search_filter._replace( - value=SearchValue(new_value), + value=SearchValue(search_value), operator=operator, ) elif isinstance(search_filter, AggregateFilter) and not allow_aggregate_filters: diff --git a/src/sentry/issues/models/groupactionlogentry.py b/src/sentry/issues/models/groupactionlogentry.py index 7a4c9e9d1a76..78d2915927ee 100644 --- a/src/sentry/issues/models/groupactionlogentry.py +++ b/src/sentry/issues/models/groupactionlogentry.py @@ -44,7 +44,7 @@ class GroupActionLogEntry(Model): # The project the group belongs to. project_id = BoundedBigIntegerField() # The group_id before any merges, if this entry was migrated. - original_group_id = BoundedBigIntegerField(null=True) + original_group_id = BoundedBigIntegerField[int | None, int | None](null=True) # A GroupActionType value. type = BoundedPositiveIntegerField( diff --git a/src/sentry/issues/ownership/grammar.py b/src/sentry/issues/ownership/grammar.py index 0000eb59fed6..d669adf23507 100644 --- a/src/sentry/issues/ownership/grammar.py +++ b/src/sentry/issues/ownership/grammar.py @@ -3,7 +3,7 @@ import re from collections import namedtuple from collections.abc import Callable, Iterable, Mapping, Sequence -from typing import TYPE_CHECKING, Any, NamedTuple, NotRequired, TypedDict +from typing import TYPE_CHECKING, Any, NamedTuple, NotRequired, TypedDict, cast from parsimonious.exceptions import ParseError from parsimonious.grammar import Grammar @@ -318,7 +318,8 @@ def visit_team_prefix(self, node: Node, children: Sequence[Any]) -> bool: return bool(children) def visit_any_identifier(self, node: Node, children: Sequence[Any]) -> Node: - return children[0] + # justification: the grammar always parses an identifier into the first child. + return cast("Node", children[0]) def visit_identifier(self, node: Node, children: Sequence[Any]) -> str: return str(node.text) diff --git a/src/sentry/issues/update_inbox.py b/src/sentry/issues/update_inbox.py index d8ab4c588afa..6dba088391af 100644 --- a/src/sentry/issues/update_inbox.py +++ b/src/sentry/issues/update_inbox.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence -from typing import Any +from typing import Any, cast from sentry.issues.ongoing import bulk_transition_group_to_ongoing from sentry.models.group import Group, GroupStatus @@ -45,7 +45,8 @@ def update_inbox( if group.substatus != GroupSubStatus.ONGOING and group.status == GroupStatus.UNRESOLVED: bulk_transition_group_to_ongoing( group.status, - group.substatus, + # justification: a null substatus filters as IS NULL in the ORM. + cast("int", group.substatus), [group.id], activity_data={"manually": True}, ) diff --git a/src/sentry/killswitches.py b/src/sentry/killswitches.py index 54723fea42bb..2536fc8a4afe 100644 --- a/src/sentry/killswitches.py +++ b/src/sentry/killswitches.py @@ -317,7 +317,9 @@ def get_killswitch_value(killswitch_name: str) -> KillswitchConfig: return normalize_value(killswitch_name, raw_option_value) -def killswitch_matches_context(killswitch_name: str, context: Context, emit_metrics=True) -> bool: +def killswitch_matches_context( + killswitch_name: str, context: Context, emit_metrics: bool = True +) -> bool: option_value = get_killswitch_value(killswitch_name) assert set(ALL_KILLSWITCH_OPTIONS[killswitch_name].fields) == set(context) return value_matches(killswitch_name, option_value, context, emit_metrics) @@ -327,7 +329,7 @@ def value_matches( killswitch_name: str, option_value: KillswitchConfig, context: Context, - emit_metrics=True, + emit_metrics: bool = True, ) -> bool: decision = False for condition in option_value: diff --git a/src/sentry/lang/dart/utils.py b/src/sentry/lang/dart/utils.py index 5ea0a016ea81..084846ccfe1f 100644 --- a/src/sentry/lang/dart/utils.py +++ b/src/sentry/lang/dart/utils.py @@ -66,7 +66,7 @@ def get_debug_meta_image_ids(event: dict[str, Any]) -> set[str]: } -def generate_dart_symbols_map(debug_ids: list[str], project: Project): +def generate_dart_symbols_map(debug_ids: list[str], project: Project) -> dict[str, str] | None: """ Fetches and returns the dart symbols mapping for the first available debug_id. There should only be one mapping file per Flutter build, so we return the first mapping found. @@ -159,7 +159,7 @@ def replace_symbol(match: re.Match[str]) -> str: exception["value"] = new_value -def has_native_frames_in_stacktraces(data) -> bool: +def has_native_frames_in_stacktraces(data: Mapping[str, object]) -> bool: for stacktrace_info in find_stacktraces_in_data(data): frames = stacktrace_info.get_frames() if frames and any(frame.get("platform") == "native" for frame in frames): diff --git a/src/sentry/lang/java/exceptions.py b/src/sentry/lang/java/exceptions.py index faebbf694cf3..b7508cabf698 100644 --- a/src/sentry/lang/java/exceptions.py +++ b/src/sentry/lang/java/exceptions.py @@ -1,6 +1,6 @@ import re -from collections.abc import Mapping -from typing import Any +from collections.abc import Mapping, Sequence +from typing import Any, NotRequired, TypedDict from sentry.utils.safe import get_path @@ -43,10 +43,19 @@ ) +class ProcessableException(TypedDict): + type: str + value: NotRequired[str] + module: NotRequired[str | None] + raw_module: NotRequired[str | None] + raw_type: NotRequired[str | None] + raw_value: NotRequired[str] + + class Exceptions: def __init__(self, data: Any): - self._processable_exceptions: list[dict[str, Any]] = [] - self._processable_exceptions_with_values: list[tuple[Any, list[Any]]] = [] + self._processable_exceptions: list[ProcessableException] = [] + self._processable_exceptions_with_values: list[tuple[ProcessableException, list[str]]] = [] for exc in get_path(data, "exception", "values", filter=True, default=()): if exc.get("type", None): @@ -62,10 +71,10 @@ def __init__(self, data: Any): if class_matches: self._processable_exceptions_with_values.append((exc, class_matches)) - def get_processable_exceptions(self): + def get_processable_exceptions(self) -> list[ProcessableException]: return self._processable_exceptions - def get_exception_class_names(self): + def get_exception_class_names(self) -> list[str]: """ Returns a flattened list of all class names found in the exception values. """ @@ -75,21 +84,23 @@ def get_exception_class_names(self): for class_name in class_names ] - def get_processable_exceptions_with_values(self): + def get_processable_exceptions_with_values( + self, + ) -> list[tuple[ProcessableException, list[str]]]: return self._processable_exceptions_with_values def deobfuscate_and_save( - self, classes: dict[str, str] | None, mapped_exceptions: list[dict[str, Any]] - ): + self, classes: dict[str, str] | None, mapped_exceptions: Sequence[Mapping[str, str]] + ) -> None: """ Deobfuscates all exception values, module and type in-place """ # Deobfuscate exception module and type first - for raw_exc, exc in zip(self._processable_exceptions, mapped_exceptions): + for raw_exc, mapped_exc in zip(self._processable_exceptions, mapped_exceptions): raw_exc["raw_module"] = raw_exc.get("module") raw_exc["raw_type"] = raw_exc.get("type") - raw_exc["module"] = exc["module"] - raw_exc["type"] = exc["type"] + raw_exc["module"] = mapped_exc["module"] + raw_exc["type"] = mapped_exc["type"] # Deobfuscate exception values # Note: replace each candidate where it was matched rather than by searching for its diff --git a/src/sentry/lang/java/processing.py b/src/sentry/lang/java/processing.py index b46f0058d7e3..3c529e4142b2 100644 --- a/src/sentry/lang/java/processing.py +++ b/src/sentry/lang/java/processing.py @@ -279,7 +279,8 @@ def process_jvm_stacktraces(symbolicator: Symbolicator, data: Any) -> Any: } classes = response.get("classes") - view_hierarchies.deobfuscate_and_save(classes) + if classes is not None: + view_hierarchies.deobfuscate_and_save(classes) exceptions.deobfuscate_and_save(classes, response["exceptions"]) return data diff --git a/src/sentry/lang/java/proguard.py b/src/sentry/lang/java/proguard.py index c586d09dec35..a48daa0f07b0 100644 --- a/src/sentry/lang/java/proguard.py +++ b/src/sentry/lang/java/proguard.py @@ -2,6 +2,6 @@ from sentry_sdk import traces -def open_proguard_mapper(*args, **kwargs): +def open_proguard_mapper(path: str) -> ProguardMapper: with traces.start_span(name="proguard.open", attributes={"sentry.op": "proguard.open"}): - return ProguardMapper.open(*args, **kwargs) + return ProguardMapper.open(path) diff --git a/src/sentry/lang/java/utils.py b/src/sentry/lang/java/utils.py index 7fd54091924e..406fd82575db 100644 --- a/src/sentry/lang/java/utils.py +++ b/src/sentry/lang/java/utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from sentry.stacktraces.processing import StacktraceInfo @@ -14,15 +15,15 @@ JAVA_PLATFORMS = ("java",) -def is_valid_proguard_image(image): - return bool(image) and image.get("type") == "proguard" and image.get("uuid") is not None +def is_valid_proguard_image(image: Mapping[str, object] | None) -> bool: + return image is not None and image.get("type") == "proguard" and image.get("uuid") is not None -def is_valid_jvm_image(image): - return bool(image) and image.get("type") == "jvm" and image.get("debug_id") is not None +def is_valid_jvm_image(image: Mapping[str, object] | None) -> bool: + return image is not None and image.get("type") == "jvm" and image.get("debug_id") is not None -def has_proguard_file(data): +def has_proguard_file(data: Mapping[str, object]) -> bool: """ Checks whether an event contains a proguard file """ diff --git a/src/sentry/lang/java/view_hierarchies.py b/src/sentry/lang/java/view_hierarchies.py index 6d23c7c27809..55ec563cb8e5 100644 --- a/src/sentry/lang/java/view_hierarchies.py +++ b/src/sentry/lang/java/view_hierarchies.py @@ -43,7 +43,7 @@ def get_window_class_names(self) -> list[str]: return class_names - def deobfuscate_and_save(self, class_names: dict[str, str]): + def deobfuscate_and_save(self, class_names: dict[str, str]) -> None: """ Deobfuscates all view hierarchies in-place and persists any changes made. """ @@ -70,7 +70,7 @@ def deobfuscate_and_save(self, class_names: dict[str, str]): store_attachments_for_event(self._project, self._event, attachments, timeout=CACHE_TIMEOUT) -def _deobfuscate_view_hierarchy(view_hierarchy: Any, class_names: dict[str, str]): +def _deobfuscate_view_hierarchy(view_hierarchy: Any, class_names: dict[str, str]) -> None: """ Deobfuscates a view hierarchy in-place. diff --git a/src/sentry/lang/javascript/cache.py b/src/sentry/lang/javascript/cache.py index 827215c2f806..91b4259ec2b4 100644 --- a/src/sentry/lang/javascript/cache.py +++ b/src/sentry/lang/javascript/cache.py @@ -1,3 +1,5 @@ +from typing import Any + from symbolic.sourcemap import SourceView from sentry.utils.strings import codec_lookup @@ -5,38 +7,38 @@ __all__ = ["SourceCache", "SourceMapCache"] -def is_utf8(codec): +def is_utf8(codec: str) -> bool: name = codec_lookup(codec).name return name in ("utf-8", "ascii") class SourceCache: - def __init__(self): - self._cache = {} - self._errors = {} - self._aliases = {} + def __init__(self) -> None: + self._cache: dict[str, SourceView] = {} + self._errors: dict[str, list[str]] = {} + self._aliases: dict[str, str] = {} - def __contains__(self, url): + def __contains__(self, url: str) -> bool: url = self._get_canonical_url(url) return url in self._cache - def _get_canonical_url(self, url): + def _get_canonical_url(self, url: str) -> str: if url in self._aliases: url = self._aliases[url] return url - def get(self, url): + def get(self, url: str) -> SourceView | None: return self._cache.get(self._get_canonical_url(url)) - def get_errors(self, url): + def get_errors(self, url: str) -> list[str]: url = self._get_canonical_url(url) return self._errors.get(url, []) - def alias(self, alias, target): + def alias(self, alias: str, target: str) -> None: if alias != target: self._aliases[alias] = target - def add(self, url, source, encoding=None): + def add(self, url: str, source: SourceView | str | bytes, encoding: str | None = None) -> None: url = self._get_canonical_url(url) if not isinstance(source, SourceView): @@ -53,7 +55,7 @@ def add(self, url, source, encoding=None): source = SourceView.from_bytes(source) self._cache[url] = source - def add_error(self, url, error): + def add_error(self, url: str, error: str) -> None: url = self._get_canonical_url(url) self._errors.setdefault(url, []) self._errors[url].append(error) @@ -68,23 +70,25 @@ class SourceMapCache: - a source map's url and the map's contents. """ - def __init__(self): - self._cache = {} - self._mapping = {} + def __init__(self) -> None: + self._cache: dict[str, Any] = {} # justification: decoded javascript JSON + self._mapping: dict[str, str] = {} - def __contains__(self, sourcemap_url): + def __contains__(self, sourcemap_url: str) -> bool: return sourcemap_url in self._cache - def link(self, url, sourcemap_url): + def link(self, url: str, sourcemap_url: str) -> None: self._mapping[url] = sourcemap_url - def add(self, sourcemap_url, sourcemap_view): + # justification: decoded javascript JSON + def add(self, sourcemap_url: str, sourcemap_view: Any) -> None: self._cache[sourcemap_url] = sourcemap_view - def get(self, sourcemap_url): + def get(self, sourcemap_url: str) -> Any: # justification: decoded javascript JSON return self._cache.get(sourcemap_url) - def get_link(self, url): + # justification: decoded javascript JSON + def get_link(self, url: str) -> tuple[str | None, Any | None]: sourcemap_url = self._mapping.get(url) if sourcemap_url: sourcemap = self.get(sourcemap_url) diff --git a/src/sentry/lang/javascript/errorlocale.py b/src/sentry/lang/javascript/errorlocale.py index a8f39fd22882..7ccb5e184515 100644 --- a/src/sentry/lang/javascript/errorlocale.py +++ b/src/sentry/lang/javascript/errorlocale.py @@ -1,7 +1,8 @@ import functools import os import re -from collections.abc import Mapping +from collections.abc import Mapping, MutableMapping +from typing import Any from sentry.utils.safe import get_path @@ -57,14 +58,14 @@ def find_translation(message: str) -> tuple[str, str | None] | tuple[None, None] return (None, None) -def format_message(message, data): +def format_message(message: str, data: str) -> str: return message.replace("%s", data) message_type_regexp = re.compile("^(?P[a-zA-Z]*Error): (?P.*)") -def translate_message(original_message): +def translate_message(original_message: object) -> object: if not isinstance(original_message, str): return original_message @@ -95,7 +96,8 @@ def translate_message(original_message): return format_message(translated_message, format_string_data) -def translate_exception(data): +# justification: decoded javascript JSON +def translate_exception(data: MutableMapping[str, Any]) -> MutableMapping[str, Any]: message = get_path(data, "logentry", "message") if message: data["logentry"]["message"] = translate_message(message) diff --git a/src/sentry/lang/javascript/errormapping.py b/src/sentry/lang/javascript/errormapping.py index 434fde263d19..5b21d55ac623 100644 --- a/src/sentry/lang/javascript/errormapping.py +++ b/src/sentry/lang/javascript/errormapping.py @@ -4,6 +4,8 @@ import random import re import time +from collections.abc import Callable, MutableMapping +from typing import Any from urllib.parse import parse_qsl import orjson @@ -34,21 +36,25 @@ error_processors: dict[str, Processor] = {} -def is_expired(ts): +# justification: decoded javascript JSON +ProcessorFunc = Callable[[dict[str, Any], re.Match[str], dict[str, Any]], bool] + + +def is_expired(ts: float) -> bool: return ts > (time.time() - SOFT_TIMEOUT - random.random() * SOFT_TIMEOUT_FUZZINESS) class Processor: - def __init__(self, vendor: str, mapping_url, regex, func): + def __init__(self, vendor: str, mapping_url: str, regex: str, func: ProcessorFunc) -> None: self.vendor: str = vendor self.mapping_url = mapping_url self.regex = re.compile(regex) self.func = func - def load_mapping(self): + def load_mapping(self) -> dict[str, str]: key = f"javascript.errormapping:{self.vendor}" mapping = cache.get(key) - cached_rv = None + cached_rv: dict[str, str] | None = None if mapping is not None: ts, cached_rv = orjson.loads(mapping) if not is_expired(ts): @@ -64,6 +70,8 @@ def load_mapping(self): # Make sure we only get a 2xx to prevent caching bad data response.raise_for_status() data = response.json() + if not isinstance(data, dict): + raise TypeError("Source map reference response is not an object") cache.set(key, orjson.dumps([time.time(), data]).decode(), HARD_TIMEOUT) except Exception: if cached_rv is None: @@ -71,7 +79,7 @@ def load_mapping(self): return cached_rv return data - def try_process(self, exc): + def try_process(self, exc: dict[str, Any]) -> bool: # justification: decoded javascript JSON if not exc.get("value"): return False match = self.regex.search(exc["value"]) @@ -81,8 +89,8 @@ def try_process(self, exc): return self.func(exc, match, mapping) -def minified_error(vendor, mapping_url, regex): - def decorator(f): +def minified_error(vendor: str, mapping_url: str, regex: str) -> Callable[[ProcessorFunc], None]: + def decorator(f: ProcessorFunc) -> None: error_processors[vendor] = Processor(vendor, mapping_url, regex, f) return decorator @@ -93,7 +101,12 @@ def decorator(f): mapping_url=REACT_MAPPING_URL, regex=REACT_ERROR_REGEX, ) -def process_react_exception(exc, match, mapping): +def process_react_exception( + # justification: decoded javascript JSON + exc: dict[str, Any], + match: re.Match[str], + mapping: dict[str, str], +) -> bool: error_id, qs = match.groups() msg_format = mapping.get(error_id) if msg_format is None: @@ -114,7 +127,8 @@ def process_react_exception(exc, match, mapping): return True -def rewrite_exception(data): +# justification: decoded javascript JSON +def rewrite_exception(data: MutableMapping[str, Any]) -> bool: """Rewrite an exception in an event if needed. Updates the exception in place and returns `True` if a modification was performed or `False` otherwise. diff --git a/src/sentry/lang/javascript/preprocessing.py b/src/sentry/lang/javascript/preprocessing.py index e10ec275d6f2..42666b5e6b28 100644 --- a/src/sentry/lang/javascript/preprocessing.py +++ b/src/sentry/lang/javascript/preprocessing.py @@ -1,5 +1,8 @@ from __future__ import annotations +from collections.abc import Mapping, MutableMapping +from typing import Any + from sentry.stacktraces.processing import find_stacktraces_in_data from sentry.utils.safe import get_path @@ -8,14 +11,15 @@ from .utils import generate_module -def preprocess_event(data): +# justification: decoded javascript JSON +def preprocess_event(data: MutableMapping[str, Any]) -> MutableMapping[str, Any]: rewrite_exception(data) translate_exception(data) generate_modules(data) return data -def generate_modules(data): +def generate_modules(data: Mapping[str, Any]) -> None: # justification: decoded javascript JSON for info in find_stacktraces_in_data(data): for frame in get_path(info.stacktrace, "frames", filter=True, default=()): platform = frame.get("platform") or data["platform"] diff --git a/src/sentry/lang/javascript/processing.py b/src/sentry/lang/javascript/processing.py index 6cb01ea89650..9541f932f1ed 100644 --- a/src/sentry/lang/javascript/processing.py +++ b/src/sentry/lang/javascript/processing.py @@ -1,5 +1,6 @@ import logging import re +from collections.abc import Mapping, Sequence from typing import Any from sentry.lang.javascript.utils import JAVASCRIPT_PLATFORMS @@ -18,7 +19,11 @@ NON_BUILTIN_PATH_REGEX = re.compile(r"^((app|webpack|[a-zA-Z]|https?):|/)") -def _merge_frame_context(new_frame, symbolicated): +def _merge_frame_context( + # justification: decoded javascript JSON + new_frame: Mapping[str, Any], + symbolicated: Mapping[str, Any], # justification: decoded javascript JSON +) -> dict[str, Any]: # justification: decoded javascript JSON new_frame = dict(new_frame) symbolicated = dict(symbolicated) @@ -32,7 +37,8 @@ def _merge_frame_context(new_frame, symbolicated): return new_frame -def _merge_frame(new_frame, symbolicated): +# justification: decoded javascript JSON +def _merge_frame(new_frame: Mapping[str, Any], symbolicated: Mapping[str, Any]) -> dict[str, Any]: new_frame = dict(new_frame) symbolicated = dict(symbolicated) @@ -69,7 +75,8 @@ def _merge_frame(new_frame, symbolicated): # TODO: Change this error handling to be JS-specific? -def _handle_response_status(event_data, response_json): +# justification: decoded javascript JSON +def _handle_response_status(event_data: Any, response_json: Any) -> bool | None: if not response_json: error = SymbolicationFailed(type=EventErrorType.NATIVE_INTERNAL_FAILURE) elif response_json["status"] == "completed": @@ -84,9 +91,10 @@ def _handle_response_status(event_data, response_json): error = SymbolicationFailed(type=EventErrorType.NATIVE_INTERNAL_FAILURE) write_error(error, event_data) + return None -def is_sourcemap_image(image): +def is_sourcemap_image(image: Any) -> bool: # justification: decoded javascript JSON return ( bool(image) and image.get("type") == "sourcemap" @@ -95,17 +103,21 @@ def is_sourcemap_image(image): ) -def sourcemap_images_from_data(data): - return get_path(data, "debug_meta", "images", default=(), filter=is_sourcemap_image) +# justification: decoded javascript JSON +def sourcemap_images_from_data(data: Mapping[str, Any]) -> list[dict[str, Any]]: + images = get_path(data, "debug_meta", "images", default=(), filter=is_sourcemap_image) + return images if isinstance(images, list) else [] # 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:") -def map_symbolicator_process_js_errors(errors): +def map_symbolicator_process_js_errors( + errors: Sequence[dict[str, Any]] | None, # justification: decoded javascript JSON +) -> list[dict[str, Any]]: # justification: decoded javascript JSON if errors is None: return [] @@ -170,7 +182,8 @@ def map_symbolicator_process_js_errors(errors): return mapped_errors -def _handles_frame(frame, data): +# justification: decoded javascript JSON +def _handles_frame(frame: Mapping[str, Any], data: Mapping[str, Any]) -> bool: abs_path = frame.get("abs_path") platform = frame.get("platform") or data.get("platform", "unknown") @@ -193,16 +206,17 @@ 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 | None) -> bool: return platform == "node" and not NON_BUILTIN_PATH_REGEX.match(abs_path) # We want to make sure that some specific frames are always marked as non-inapp prior to going into grouping. -def _normalize_nonhandled_frame(frame, data): +# justification: decoded javascript JSON +def _normalize_nonhandled_frame(frame: dict[str, Any], data: Mapping[str, Any]) -> dict[str, Any]: abs_path = frame.get("abs_path") if abs_path and (_is_native_frame(abs_path) or _is_built_in(abs_path, data.get("platform"))): @@ -214,7 +228,7 @@ def _normalize_nonhandled_frame(frame, data): FRAME_FIELDS = ("platform", "abs_path", "lineno", "colno", "function") -def _normalize_frame(raw_frame: Any) -> dict: +def _normalize_frame(raw_frame: Any) -> dict[str, Any]: # justification: decoded javascript JSON frame = {} for key in FRAME_FIELDS: if (value := raw_frame.get(key)) is not None: @@ -323,7 +337,7 @@ def process_js_stacktraces(symbolicator: Symbolicator, data: Any) -> Any: # Port from symbolicator-js -def is_in_app(frame): +def is_in_app(frame: Mapping[str, Any]) -> bool | None: # justification: decoded javascript JSON """ Determine if a frame is part of the application code. Returns None if we can't determine, otherwise returns a boolean. diff --git a/src/sentry/lang/javascript/utils.py b/src/sentry/lang/javascript/utils.py index b25ddab69b57..c1e781fdb760 100644 --- a/src/sentry/lang/javascript/utils.py +++ b/src/sentry/lang/javascript/utils.py @@ -18,7 +18,7 @@ def get_source_context( - source: list[bytes], lineno: int, context=LINES_OF_CONTEXT + source: list[bytes], lineno: int, context: int = LINES_OF_CONTEXT ) -> tuple[list[bytes] | None, bytes | None, list[bytes] | None]: if not source: return None, None, None @@ -49,7 +49,7 @@ def get_source_context( return pre_context or None, context_line, post_context or None -def trim_line(line: str, column=0) -> str: +def trim_line(line: str, column: int = 0) -> str: """ Trims a line down to a goal of 140 characters, with a little wiggle room to be sensible and tries to trim around the given diff --git a/src/sentry/lang/native/applecrashreport.py b/src/sentry/lang/native/applecrashreport.py index f167c72a8dbe..f9d75ba6bc86 100644 --- a/src/sentry/lang/native/applecrashreport.py +++ b/src/sentry/lang/native/applecrashreport.py @@ -1,5 +1,6 @@ import posixpath from collections.abc import Mapping +from typing import Any from sentry_sdk import traces from symbolic.common import parse_addr @@ -21,13 +22,13 @@ class AppleCrashReport: def __init__( self, - threads=None, - context=None, - debug_images=None, - symbolicated=False, - exceptions=None, - prioritized_thread_id=None, - ): + threads: list[dict[str, Any]] | None = None, + context: Mapping[str, Any] | None = None, + debug_images: list[dict[str, Any]] | None = None, + symbolicated: bool = False, + exceptions: list[dict[str, Any]] | None = None, + prioritized_thread_id: int | str | None = None, + ) -> None: """ Create an Apple crash report from the provided data. @@ -40,7 +41,7 @@ def __init__( self.prioritized_thread_id = ( str(prioritized_thread_id) if prioritized_thread_id is not None else None ) - self.image_addrs_to_vmaddrs = {} + self.image_addrs_to_vmaddrs: dict[int, int] = {} # Remove frames that don't have an `instruction_addr` and convert # sundry addresses into numbers. @@ -91,7 +92,7 @@ def __str__(self) -> str: return "\n\n".join(rv) + "\n\nEOF" @traces.trace - def _get_meta_header(self): + def _get_meta_header(self) -> str: return "OS Version: {} {} ({})\nReport Version: {}".format( get_path(self.context, "os", "name"), get_path(self.context, "os", "version"), @@ -137,7 +138,7 @@ def _get_padded_hex_value(self, value: str) -> str: return value @traces.trace - def _get_crashed_thread_registers(self): + def _get_crashed_thread_registers(self) -> str: rv = [] exception = get_path(self.exceptions, 0) if not exception: @@ -179,7 +180,7 @@ def _get_crashed_thread_registers(self): return "\n".join(rv) @traces.trace - def _get_exception_info(self): + def _get_exception_info(self) -> str: rv = [] # We only have one exception at a time @@ -217,7 +218,7 @@ def _get_exception_info(self): return "\n".join(rv) @traces.trace - def get_threads_apple_string(self): + def get_threads_apple_string(self) -> str: rv = [] exceptions = self.exceptions or [] threads = self.threads or [] @@ -248,7 +249,7 @@ def get_threads_apple_string(self): return "\n\n".join(thread_string for _, thread_string in rv) - def get_thread_apple_string(self, thread_info): + def get_thread_apple_string(self, thread_info: Mapping[str, Any]) -> str | None: rv = [] stacktrace = get_path(thread_info, "stacktrace") if stacktrace is None: @@ -278,7 +279,12 @@ def get_thread_apple_string(self, thread_info): thread_string = f"Thread {thread_id}{thread_name_string}{thread_crashed_thread}\n" return thread_string + "\n".join(rv) - def _convert_frame_to_apple_string(self, frame, next=None, number=0): + def _convert_frame_to_apple_string( + self, + frame: Mapping[str, Any], + next: Mapping[str, Any] | None = None, + number: int = 0, + ) -> str: frame_instruction_addr = frame["instruction_addr"] frame_image_addr = frame.get("image_addr", 0) slide_value = self._get_slide_value(frame_image_addr) @@ -310,11 +316,11 @@ def _convert_frame_to_apple_string(self, frame, next=None, number=0): offset, ) - def _get_slide_value(self, image_addr): + def _get_slide_value(self, image_addr: int) -> int: return self.image_addrs_to_vmaddrs.get(image_addr, 0) @traces.trace - def get_binary_images_apple_string(self): + def get_binary_images_apple_string(self) -> str: # We don't need binary images on symbolicated crashreport if self.symbolicated or not self.debug_images: return "" @@ -327,7 +333,7 @@ def get_binary_images_apple_string(self): ) return "Binary Images:\n" + "\n".join(binary_images) - def _convert_debug_meta_to_binary_image_row(self, debug_image): + def _convert_debug_meta_to_binary_image_row(self, debug_image: Mapping[str, Any]) -> str: slide_value = debug_image.get("image_vmaddr", 0) image_addr = debug_image["image_addr"] + slide_value image_size = debug_image.get("image_size") diff --git a/src/sentry/lang/native/error.py b/src/sentry/lang/native/error.py index 5054c5ff4737..bfb27581dbb3 100644 --- a/src/sentry/lang/native/error.py +++ b/src/sentry/lang/native/error.py @@ -1,6 +1,8 @@ from __future__ import annotations import logging +from collections.abc import MutableMapping +from typing import Any, Protocol from sentry.lang.native.utils import image_name from sentry.models.eventerror import EventErrorType @@ -33,8 +35,19 @@ logger = logging.getLogger(__name__) +class _NativeDebugImage(Protocol): + debug_id: object + name: str | None + arch: str | None + + 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 +64,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 +101,7 @@ def __str__(self) -> str: return "".join(rv) -def write_error(e, data): +def write_error(e: SymbolicationFailed, data: MutableMapping[str, Any]) -> 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/flamegraphs.py b/src/sentry/lang/native/flamegraphs.py index 2ffe7369ab54..55c423b7f8d2 100644 --- a/src/sentry/lang/native/flamegraphs.py +++ b/src/sentry/lang/native/flamegraphs.py @@ -20,7 +20,7 @@ MAX_SAMPLE_COUNT = 9007199254740991 -class _StringField(serializers.Field): +class _StringField(serializers.Field[str, object, str, object]): default_error_messages = {"invalid": "Not a valid string."} def to_internal_value(self, data: Any) -> str: @@ -165,7 +165,9 @@ def save(self, attachment_index: int, payload: dict[str, Any]) -> bool: return False attachment = self._attachments[attachment_index] metadata = attachment.meta() - metadata.update(data=serialized, chunks=None, size=len(serialized)) + metadata["data"] = serialized + metadata["chunks"] = None + metadata["size"] = len(serialized) # Checkpoint only this attachment, without reading or rewriting unrelated payloads. checkpoint = dict(self._event) store_attachments_for_event( diff --git a/src/sentry/lang/native/processing.py b/src/sentry/lang/native/processing.py index 0698ee1916f1..d38ef16b1595 100644 --- a/src/sentry/lang/native/processing.py +++ b/src/sentry/lang/native/processing.py @@ -2,7 +2,7 @@ import logging import posixpath -from collections.abc import Mapping +from collections.abc import Mapping, MutableMapping, Sequence from typing import Any import sentry_sdk @@ -84,7 +84,9 @@ ] -def _merge_frame(new_frame, symbolicated, platform="native"): +def _merge_frame( + new_frame: dict[str, Any], symbolicated: Mapping[str, Any], platform: str = "native" +) -> None: # il2cpp events which have the "csharp" platform have good (C#) names # coming from the SDK, we do not want to override those with bad (mangled) C++ names. if platform != "csharp" and symbolicated.get("function"): @@ -143,7 +145,9 @@ def _merge_frame(new_frame, symbolicated, platform="native"): frame_meta["symbolicator_status"] = symbolicated["status"] -def _handle_image_status(status, image, os, data): +def _handle_image_status( + status: str, image: dict[str, Any], os: str | None, data: MutableMapping[str, Any] +) -> None: if status in ("found", "unused"): return elif status == "unsupported": @@ -195,7 +199,12 @@ def _handle_image_status(status, image, os, data): write_error(error, data) -def _merge_image(raw_image, complete_image, os, data): +def _merge_image( + raw_image: dict[str, Any], + complete_image: Mapping[str, Any], + os: str | None, + data: MutableMapping[str, Any], +) -> None: statuses = set() # Set image data from symbolicator as symbolicator might know more @@ -210,7 +219,9 @@ def _merge_image(raw_image, complete_image, os, data): _handle_image_status(status, raw_image, os, data) -def _handle_response_status(event_data, response_json): +def _handle_response_status( + event_data: MutableMapping[str, Any], response_json: Mapping[str, Any] +) -> bool | None: if not response_json: error = SymbolicationFailed(type=EventErrorType.NATIVE_INTERNAL_FAILURE) elif response_json["status"] == "completed": @@ -225,9 +236,10 @@ def _handle_response_status(event_data, response_json): error = SymbolicationFailed(type=EventErrorType.NATIVE_INTERNAL_FAILURE) write_error(error, event_data) + return None -def _merge_system_info(data, system_info): +def _merge_system_info(data: dict[str, Any], system_info: Mapping[str, Any]) -> None: set_path(data, "contexts", "os", "type", value="os") # Required by "get_os_from_event" os_name = system_info.get("os_name") @@ -251,7 +263,7 @@ def _merge_system_info(data, system_info): setdefault_path(data, "contexts", "device", "model", value=device_model) -def _merge_full_response(data, response): +def _merge_full_response(data: dict[str, Any], response: Mapping[str, Any]) -> None: data["platform"] = "native" # Specifically for Unreal events: Do not overwrite the level as it has already been set in Relay when merging the context. if response.get("crashed") is not None and data.get("level") is None: @@ -385,7 +397,7 @@ def process_applecrashreport(symbolicator: Symbolicator, data: Any) -> Any: return data -def _handles_frame(data, frame): +def _handles_frame(data: Mapping[str, Any], frame: Mapping[str, Any] | None) -> bool: if not frame: return False @@ -398,11 +410,11 @@ def _handles_frame(data, frame): def get_frames_for_symbolication( - frames, - data, - modules, - adjustment=None, -): + frames: Sequence[Mapping[str, Any]], + data: Mapping[str, Any], + modules: Sequence[Mapping[str, Any]], + adjustment: str | None = None, +) -> list[dict[str, Any]]: modules_by_debug_id = None rv = [] adjustment = adjustment or "auto" @@ -419,7 +431,7 @@ def get_frames_for_symbolication( # expand it, we keep None which is absolute. That's not great but # at least won't do damage. addr_mode = s_frame.pop("addr_mode", None) - sanitized_addr_mode = None + sanitized_addr_mode: str | None = None # None and abs mean absolute addressing explicitly. if addr_mode in (None, "abs"): @@ -646,7 +658,7 @@ def process_native_stacktraces(symbolicator: Symbolicator, data: Any) -> Any: return data -def emit_apple_symbol_stats(apple_symbol_stats, data): +def emit_apple_symbol_stats(apple_symbol_stats: Mapping[str, Any], data: Mapping[str, Any]) -> None: os_name = get_path(data, "contexts", "os", "name") or get_path( data, "contexts", "os", "raw_description" ) @@ -753,7 +765,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..c9d7f49f61fa 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 Callable, Iterator from copy import deepcopy -from typing import Any +from typing import Any, cast import google.auth import jsonschema @@ -19,6 +20,7 @@ from sentry import features, options from sentry.auth.system import get_system_token +from sentry.models.organization import Organization from sentry.models.project import Project from sentry.utils import redis, safe from sentry.utils.dates import deprecated_utcnow @@ -252,13 +254,13 @@ EVENT_ID_HEADER = "x-sentry-event-id" -def _redact_schema(schema: dict, keys_to_redact: list[str]) -> dict: +def _redact_schema(schema: dict[str, Any], keys_to_redact: list[str]) -> dict[str, Any]: """ Returns a deepcopy of the input schema, overriding any keys in keys_to_redact with HIDDEN_SECRET_SCHEMA. Works on nested dictionaries. """ - def override_key(schema: dict, keys_to_redact: list[str]) -> None: + def override_key(schema: dict[str, Any], keys_to_redact: list[str]) -> None: for key, value in schema.items(): if key in keys_to_redact: schema[key] = HIDDEN_SECRET_SCHEMA @@ -307,13 +309,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): @@ -336,11 +338,11 @@ def get_internal_url_prefix() -> str: for replacement in replacements: internal_url_prefix = internal_url_prefix.replace(replacement, "host.docker.internal") - assert internal_url_prefix + assert isinstance(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 +372,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 +413,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 +425,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. @@ -431,7 +433,9 @@ def is_internal_source_id(source_id: str): return source_id.startswith("sentry") -def normalize_user_source(source, project_id=None, event_id=None): +def normalize_user_source( + source: dict[str, Any], project_id: int | None = None, event_id: str | None = None +) -> dict[str, Any]: """Sources supplied from the user frontend might not match the format that symbolicator expects. For instance we currently do not permit headers to be configured in the UI, but we allow basic auth to be configured for HTTP. @@ -464,7 +468,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. """ @@ -481,7 +485,9 @@ def secret_fields(source_type): yield from [] -def validate_sources(sources, schema=SOURCES_WITHOUT_APPSTORE_CONNECT): +def validate_sources( + sources: list[dict[str, Any]], schema: dict[str, Any] = SOURCES_WITHOUT_APPSTORE_CONNECT +) -> None: """ Validates sources against the JSON schema and checks that their IDs are ok. @@ -500,7 +506,8 @@ def validate_sources(sources, schema=SOURCES_WITHOUT_APPSTORE_CONNECT): ids.add(source["id"]) -def parse_sources(config, filter_appconnect): +def parse_sources(config: Any, filter_appconnect: bool) -> list[dict[str, Any]]: + # justification: project option decodes to a JSON string or an empty list """ Parses the given sources in the config string (from JSON). """ @@ -513,6 +520,9 @@ def parse_sources(config, filter_appconnect): except Exception as e: raise InvalidSourcesError("Sources are not valid serialised JSON") from e + if not isinstance(sources, list): + raise InvalidSourcesError("Sources are not a JSON array") + # remove App Store Connect sources (we don't need them in Symbolicator) if filter_appconnect: sources = [src for src in sources if src.get("type") != "appStoreConnect"] @@ -522,7 +532,9 @@ def parse_sources(config, filter_appconnect): return sources -def parse_backfill_sources(sources_json, original_sources): +def parse_backfill_sources( + sources_json: str | None, original_sources: list[dict[str, Any]] +) -> list[dict[str, Any]]: """ Parses a json string of sources passed in from a client and backfills any redacted secrets by finding their previous values stored in original_sources. @@ -536,6 +548,9 @@ def parse_backfill_sources(sources_json, original_sources): except Exception as e: raise InvalidSourcesError("Sources are not valid serialised JSON") from e + if not isinstance(sources, list): + raise InvalidSourcesError("Sources are not a JSON array") + orig_by_id = {src["id"]: src for src in original_sources} for source in sources: @@ -546,7 +561,9 @@ def parse_backfill_sources(sources_json, original_sources): return sources -def backfill_source(source, original_sources_by_id): +def backfill_source( + source: dict[str, Any], original_sources_by_id: dict[str, dict[str, Any]] +) -> None: """ Backfills redacted secrets in a source by finding their previous values stored in original_sources_by_id. @@ -583,7 +600,7 @@ def redact_source_secrets(config_sources: Any) -> Any: return redacted_sources -def get_sources_for_project(project, event_id=None): +def get_sources_for_project(project: Project, event_id: str | None = None) -> list[dict[str, Any]]: """ Returns a list of symbol sources for this project. """ @@ -619,7 +636,9 @@ def get_sources_for_project(project, event_id=None): # processing at this point. logger.exception("Invalid symbolicator source config") - def resolve_alias(source, organization): + def resolve_alias( + source: dict[str, Any], organization: Organization + ) -> Iterator[dict[str, Any]]: for key in source.get("sources") or (): other_source = settings.SENTRY_BUILTIN_SOURCES.get(key) if other_source: @@ -628,7 +647,9 @@ def resolve_alias(source, organization): else: yield fetch_token_for_gcp_source_if_necessary(other_source, organization) - def fetch_token_for_gcp_source_if_necessary(source, organization): + def fetch_token_for_gcp_source_if_necessary( + source: dict[str, Any], organization: Organization + ) -> dict[str, Any]: if source.get("type") == "gcs": if "client_email" in source and "private_key" in source: return source @@ -671,7 +692,7 @@ def fetch_token_for_gcp_source_if_necessary(source, organization): # Expire the cached token 10 minutes earlier so that we can confidently pass it # to symbolicator with its configured timeout of 5 minutes @ttl_cache(ttl=TOKEN_TTL_SECONDS - 600) -def get_gcp_token(client_email): +def get_gcp_token(client_email: str | None) -> str | None: # Fetch the regular credentials for GCP source_credentials, _ = google.auth.default() @@ -688,13 +709,11 @@ def get_gcp_token(client_email): target_credentials.refresh(Request()) - if target_credentials.token is None: - return None - - return target_credentials.token + token = target_credentials.token + return token if isinstance(token, str) else None -def reverse_aliases_map(builtin_sources): +def reverse_aliases_map(builtin_sources: dict[str, dict[str, Any]]) -> dict[str, str]: """Returns a map of source IDs to their original un-aliased source ID. :param builtin_sources: The value of `settings.SENTRY_BUILTIN_SOURCES`. @@ -717,7 +736,9 @@ def reverse_aliases_map(builtin_sources): return reverse_aliases -def filter_ignored_sources(sources, reversed_alias_map=None): +def filter_ignored_sources( + sources: list[dict[str, Any]], reversed_alias_map: dict[str, str] | None = None +) -> list[dict[str, Any]]: """ Filters out sources that are meant to be blocked based on a global killswitch. If any sources were de-aliased, a reverse mapping of { unaliased id: alias } should be provided for this to @@ -745,7 +766,7 @@ def filter_ignored_sources(sources, reversed_alias_map=None): return filtered -def redact_internal_sources(response): +def redact_internal_sources(response: dict[str, Any]) -> None: """Redacts information about internal sources from a response. Symbolicator responses can contain a section about DIF object file candidates where were @@ -760,7 +781,7 @@ def redact_internal_sources(response): redact_internal_sources_from_module(module) -def redact_internal_sources_from_module(module): +def redact_internal_sources_from_module(module: dict[str, Any]) -> None: """Redacts information about internal sources from a single module. This in-place redacts candidates from only a single module of the symbolicator response. @@ -797,7 +818,7 @@ def redact_internal_sources_from_module(module): sources_other.add(source_id) new_candidates.append(candidate) - def should_keep(candidate): + def should_keep(candidate: dict[str, Any]) -> bool: """Returns `False` if the candidate should be kept in the list of candidates. This removes the candidates with a status of ``notfound`` *if* they also have @@ -811,7 +832,9 @@ def should_keep(candidate): module["candidates"] = [c for c in new_candidates if should_keep(c)] -def sources_for_symbolication(project, event_id=None): +def sources_for_symbolication( + project: Project, event_id: str | None = None +) -> tuple[list[dict[str, Any]], Callable[[dict[str, Any]], dict[str, Any]]]: """ Returns a list of symbol sources to attach to a native symbolication request, as well as a closure to post-process the resulting JSON response. @@ -836,7 +859,7 @@ def sources_for_symbolication(project, event_id=None): # never be used. sources = filter_ignored_sources(sources, reverse_source_aliases) - def _process_response(json): + def _process_response(json: dict[str, Any]) -> dict[str, Any]: """Post-processes the JSON response. This modifies the candidates list from Symbolicator responses to undo aliased @@ -865,7 +888,7 @@ def _process_response(json): return (sources, _process_response) -def collect_apple_symbol_stats(json): +def collect_apple_symbol_stats(json: dict[str, Any]) -> None: eligible_symbols = 0 neither_has_symbol = 0 both_have_symbol = 0 diff --git a/src/sentry/lang/native/symbolicator.py b/src/sentry/lang/native/symbolicator.py index 1438d8b8b155..5399b51774cb 100644 --- a/src/sentry/lang/native/symbolicator.py +++ b/src/sentry/lang/native/symbolicator.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from datetime import timedelta from enum import Enum -from typing import Any +from typing import Any, cast from urllib.parse import urljoin import orjson @@ -153,8 +153,8 @@ def _process( task_name: str, path: str, kwargs_cb: Callable[[], dict[str, Any]] | None = None, - **kwargs: Any, - ) -> Any: + **kwargs: Any, # justification: symbolicator task results are decoded JSON. + ) -> dict[str, Any]: """ This function will submit a symbolication task to a Symbolicator and handle polling it using the `SymbolicatorSession`. @@ -172,7 +172,8 @@ def _process( ) task_id: str | None = None - json_response = None + # justification: symbolicator task results are decoded JSON. + json_response: dict[str, Any] | None = None backoff = Backoff(BACKOFF_INITIAL, BACKOFF_MAX) @@ -227,8 +228,8 @@ def _process( return json_response def process_minidump( - self, platform: str, minidump: CachedAttachment, rewrite_first_module: list[Any] - ): + self, platform: str | None, minidump: CachedAttachment, rewrite_first_module: list[Any] + ) -> dict[str, Any]: (sources, process_response) = sources_for_symbolication(self.project, self.event_id) scraping_config = get_scraping_config(self.project) @@ -277,7 +278,9 @@ def cb() -> dict[str, Any]: res = self._process("process_minidump", "minidump", data=data, files=files) return process_response(res) - def process_applecrashreport(self, platform: str, report: CachedAttachment): + def process_applecrashreport( + self, platform: str | None, report: CachedAttachment + ) -> dict[str, Any]: (sources, process_response) = sources_for_symbolication(self.project, self.event_id) scraping_config = get_scraping_config(self.project) @@ -320,8 +323,14 @@ def cb() -> dict[str, Any]: return process_response(res) def process_payload( - self, platform, stacktraces, modules, frame_order, signal=None, apply_source_context=True - ): + self, + platform: str | None, + stacktraces: list[dict[str, Any]], + modules: list[dict[str, Any]], + frame_order: FrameOrder, + signal: int | None = None, + apply_source_context: bool = True, + ) -> dict[str, Any]: """ Process a native event by symbolicating its frames. @@ -336,7 +345,7 @@ def process_payload( """ (sources, process_response) = sources_for_symbolication(self.project, self.event_id) scraping_config = get_scraping_config(self.project) - json = { + json: dict[str, Any] = { "platform": platform, "sources": sources, "options": { @@ -358,8 +367,15 @@ def process_payload( return process_response(res) def process_js( - self, platform, stacktraces, modules, release, dist, frame_order, apply_source_context=True - ): + self, + platform: str | None, + stacktraces: list[dict[str, Any]], + modules: list[dict[str, Any]], + release: str | None, + dist: str | None, + frame_order: FrameOrder, + apply_source_context: bool = True, + ) -> dict[str, Any]: """ Process a JS event by remapping its frames with sourcemaps. @@ -398,14 +414,14 @@ def process_js( def process_jvm( self, - platform, - exceptions, - stacktraces, - modules, - classes, - frame_order, - apply_source_context=True, - ): + platform: str | None, + exceptions: list[dict[str, Any]], + stacktraces: list[dict[str, Any]], + modules: list[dict[str, Any]], + classes: list[str], + frame_order: FrameOrder, + apply_source_context: bool = True, + ) -> dict[str, Any]: """ Process a JVM event by remapping its frames and exceptions with ProGuard. @@ -442,10 +458,12 @@ def _should_extract_variables(self) -> bool: This just reads the "organizations:native-variable-extraction" feature flag. """ - return features.has("organizations:native-variable-extraction", self.project.organization) + return bool( + features.has("organizations:native-variable-extraction", self.project.organization) + ) def _should_apply_source_server_info(self) -> bool: - return self.project.get_option("sentry:enable_native_source_server_mapping") + return bool(self.project.get_option("sentry:enable_native_source_server_mapping")) class TaskIdNotFound(Exception): @@ -468,39 +486,39 @@ class SymbolicatorSession: def __init__( self, - url=None, - project_id=None, - event_id=None, - timeout=None, - ): + url: str | None = None, + project_id: str | None = None, + event_id: str | None = None, + timeout: float | None = None, + ) -> None: self.url = url self.project_id = project_id self.event_id = event_id self.timeout = timeout - self.session = None + self.session: Session | None = None self.reset_worker_id() - def __enter__(self): + def __enter__(self) -> SymbolicatorSession: self.open() return self - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.close() - def open(self): + def open(self) -> None: if self.session is None: self.session = Session() - def close(self): + def close(self) -> None: if self.session is not None: self.session.close() self.session = None - def _request(self, method, path, **kwargs): + def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: if not self.session: raise RuntimeError("Session not opened") - url = urljoin(self.url, path) + url = urljoin(cast(str, self.url), path) # required for load balancing kwargs.setdefault("headers", {})["x-sentry-project-id"] = self.project_id @@ -515,7 +533,9 @@ def _request(self, method, path, **kwargs): with metrics.timer( "events.symbolicator.session.request", tags={"attempt": attempts} ): - response = self.session.request(method, url, timeout=self.timeout + 1, **kwargs) + response = self.session.request( + method, url, timeout=cast(float, self.timeout) + 1, **kwargs + ) metrics.incr( "events.symbolicator.status_code", @@ -537,7 +557,10 @@ def _request(self, method, path, **kwargs): raise ServiceUnavailable() if response.ok: - json = response.json() + payload = response.json() + if not isinstance(payload, dict): + raise ValueError("Symbolicator returned a non-object response") + json = payload if json["status"] != "pending": metrics.distribution( @@ -574,7 +597,7 @@ def _request(self, method, path, **kwargs): time.sleep(wait) wait *= 2.0 - def create_task(self, path, **kwargs): + def create_task(self, path: str, **kwargs: Any) -> dict[str, Any]: params = {"timeout": self.timeout, "scope": self.project_id} with metrics.timer( @@ -583,12 +606,12 @@ def create_task(self, path, **kwargs): ): return self._request(method="post", path=path, params=params, **kwargs) - def query_task(self, task_id): + def query_task(self, task_id: str) -> dict[str, Any]: params = {"timeout": self.timeout, "scope": self.project_id} task_url = f"requests/{task_id}" with metrics.timer("events.symbolicator.query_task"): return self._request("get", task_url, params=params) - def reset_worker_id(self): + def reset_worker_id(self) -> None: self.worker_id = uuid.uuid4().hex diff --git a/src/sentry/lang/native/teapot.py b/src/sentry/lang/native/teapot.py index 33422cf1538a..f9a9ef3e005d 100644 --- a/src/sentry/lang/native/teapot.py +++ b/src/sentry/lang/native/teapot.py @@ -202,7 +202,10 @@ def attempt() -> dict[str, Any]: raise TeapotUnavailable(detail) try: - return resp.json() + payload = resp.json() + if not isinstance(payload, dict): + raise ValueError("teapot returned a non-object body") + return payload except ValueError as e: raise TeapotUnavailable(f"teapot returned non-JSON body: {e}") from e @@ -212,7 +215,7 @@ def should_retry(i: int, exc: Exception) -> bool: return i < attempts and isinstance(exc, _RetryableTeapotError) def backoff(i: int) -> float: - return min(RETRY_BACKOFF_SECONDS * 2 ** (i - 1), RETRY_BACKOFF_MAX_SECONDS) + return min(RETRY_BACKOFF_SECONDS * (1 << (i - 1)), RETRY_BACKOFF_MAX_SECONDS) try: return ConditionalRetryPolicy(should_retry, backoff)(attempt) diff --git a/src/sentry/lang/native/utils.py b/src/sentry/lang/native/utils.py index a3cbecfcc69a..b4c50daa4bf4 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 @@ -48,8 +48,10 @@ def is_native_image(image): ) -def native_images_from_data(data): - return get_path(data, "debug_meta", "images", default=(), filter=is_native_image) +def native_images_from_data(data: Mapping[str, object]) -> list[dict[str, Any]]: + # justification: debug images are decoded event JSON + images = get_path(data, "debug_meta", "images", default=(), filter=is_native_image) + return images if isinstance(images, list) else [] def is_native_event(data: Mapping[str, Any], stacktraces: list[StacktraceInfo]) -> bool: @@ -71,32 +73,42 @@ def has_native_stacktraces(stacktraces: list[StacktraceInfo]) -> bool: return False -def image_name(pkg): +@overload +def image_name(pkg: str) -> str: ... + + +@overload +def image_name(pkg: None) -> None: ... + + +def image_name(pkg: str | None) -> str | None: 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. """ os = get_path(event, "contexts", "os") - if os and os.get("type") == "os": - if (os_name := os.get("name")) is not None: + if isinstance(os, Mapping) and os.get("type") == "os": + os_name = os.get("name") + if isinstance(os_name, str): return os_name.lower() sdk_info = get_path(event, "debug_meta", "sdk_info") - if sdk_info: - if (sdk_name := sdk_info.get("sdk_name")) is not None: + if isinstance(sdk_info, Mapping): + sdk_name = sdk_info.get("sdk_name") + if isinstance(sdk_name, str): return sdk_name.lower() 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 +153,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 +161,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 +172,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 +224,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/loader/browsersdkversion.py b/src/sentry/loader/browsersdkversion.py index 52facfb57f40..70acf305d020 100644 --- a/src/sentry/loader/browsersdkversion.py +++ b/src/sentry/loader/browsersdkversion.py @@ -2,6 +2,8 @@ import logging import os import re +from collections.abc import Sequence +from typing import TYPE_CHECKING, TypedDict, cast import orjson from django.conf import settings @@ -9,25 +11,35 @@ import sentry +if TYPE_CHECKING: + from sentry.models.project import Project + from sentry.models.projectkey import ProjectKey + logger = logging.getLogger("sentry") _version_regexp = re.compile(r"^\d+\.\d+\.\d+$") # We really only want stable releases LOADER_FOLDER = os.path.abspath(os.path.join(os.path.dirname(sentry.__file__), "loader")) +class _SDKRegistry(TypedDict, total=False): + versions: list[str] + + @functools.lru_cache(maxsize=10) -def load_registry(path): +def load_registry(path: str) -> _SDKRegistry | None: if "/" in path: return None fn = os.path.join(LOADER_FOLDER, path + ".json") try: with open(fn, "rb") as f: - return orjson.loads(f.read()) + registry = orjson.loads(f.read()) except OSError: return None + # justification: registry files contain the _SDKRegistry object written by the loader build. + return cast("_SDKRegistry", registry) if isinstance(registry, dict) else None -def get_highest_browser_sdk_version(versions): +def get_highest_browser_sdk_version(versions: Sequence[str]) -> Version: full_versions = [x for x in versions if _version_regexp.match(x)] return ( max(map(Version, full_versions)) @@ -36,11 +48,11 @@ def get_highest_browser_sdk_version(versions): ) -def get_all_browser_sdk_version_versions(): +def get_all_browser_sdk_version_versions() -> list[str]: return ["latest", "11.x", "10.x", "9.x", "8.x", "7.x", "6.x", "5.x", "4.x"] -def get_all_browser_sdk_version_choices(): +def get_all_browser_sdk_version_choices() -> tuple[tuple[str, str], ...]: versions = get_all_browser_sdk_version_versions() rv = [] @@ -49,7 +61,7 @@ def get_all_browser_sdk_version_choices(): return tuple(rv) -def get_browser_sdk_version_choices(project): +def get_browser_sdk_version_choices(project: "Project") -> tuple[tuple[str, str], ...]: versions = get_available_sdk_versions_for_project(project) rv = [] @@ -58,14 +70,14 @@ def get_browser_sdk_version_choices(project): return tuple(rv) -def load_version_from_file(): +def load_version_from_file() -> list[str]: data = load_registry("_registry") if data: return data.get("versions", []) return [] -def match_selected_version_to_browser_sdk_version(selected_version): +def match_selected_version_to_browser_sdk_version(selected_version: str) -> Version: versions = load_version_from_file() if selected_version == "latest": # "latest" as an option is phased out before the v8 release of the JS SDK, meaning that we pin people to the latest pre-v8-version when they have "latest" selected @@ -76,7 +88,7 @@ def match_selected_version_to_browser_sdk_version(selected_version): ) -def get_browser_sdk_version(project_key) -> Version: +def get_browser_sdk_version(project_key: "ProjectKey") -> Version: selected_version = get_selected_browser_sdk_version(project_key) try: @@ -86,15 +98,22 @@ def get_browser_sdk_version(project_key) -> Version: return Version(settings.JS_SDK_LOADER_SDK_VERSION) -def get_selected_browser_sdk_version(project_key): - return project_key.data.get("browserSdkVersion") or get_default_sdk_version_for_project( - project_key.project - ) +def get_selected_browser_sdk_version(project_key: "ProjectKey") -> str: + version = project_key.data.get("browserSdkVersion") + if isinstance(version, str): + return version + return get_default_sdk_version_for_project(project_key.project) -def get_default_sdk_version_for_project(project): - return project.get_option("sentry:default_loader_version") +def get_default_sdk_version_for_project(project: "Project") -> str: + version = project.get_option("sentry:default_loader_version") + if not isinstance(version, str): + raise TypeError("sentry:default_loader_version must be a string") + return version -def get_available_sdk_versions_for_project(project): - return project.get_option("sentry:loader_available_sdk_versions") +def get_available_sdk_versions_for_project(project: "Project") -> list[str]: + versions = project.get_option("sentry:loader_available_sdk_versions") + if not isinstance(versions, list): + raise TypeError("sentry:loader_available_sdk_versions must be a list") + return versions diff --git a/src/sentry/loader/dynamic_sdk_options.py b/src/sentry/loader/dynamic_sdk_options.py index b964d8286714..99568b74d346 100644 --- a/src/sentry/loader/dynamic_sdk_options.py +++ b/src/sentry/loader/dynamic_sdk_options.py @@ -1,4 +1,9 @@ from enum import Enum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sentry.models.project import Project + from sentry.models.projectkey import ProjectKey class DynamicSdkLoaderOption(str, Enum): @@ -9,12 +14,16 @@ class DynamicSdkLoaderOption(str, Enum): HAS_LOGS_AND_METRICS = "hasLogsAndMetrics" -def get_dynamic_sdk_loader_option(project_key, option: DynamicSdkLoaderOption, default=False): +def get_dynamic_sdk_loader_option( + project_key: "ProjectKey", option: DynamicSdkLoaderOption, default: bool = False +) -> bool: dynamic_sdk_loader_options = project_key.data.get("dynamicSdkLoaderOptions", {}) - return dynamic_sdk_loader_options.get(option.value, default) + if not isinstance(dynamic_sdk_loader_options, dict): + return default + return bool(dynamic_sdk_loader_options.get(option.value, default)) -def get_default_loader_data(project): +def get_default_loader_data(project: "Project") -> dict[str, object]: dynamic_sdk_loader_options = project.get_option("sentry:default_loader_options", None) if dynamic_sdk_loader_options is not None: diff --git a/src/sentry/logging/handlers.py b/src/sentry/logging/handlers.py index 60b8408ec7a8..5313b6a89ea7 100644 --- a/src/sentry/logging/handlers.py +++ b/src/sentry/logging/handlers.py @@ -3,7 +3,8 @@ import logging import random import re -from typing import Any +from collections.abc import MutableMapping +from typing import Any, TextIO from django.utils.timezone import now from structlog import get_logger @@ -57,7 +58,12 @@ def _json_encoder(*, skipkeys: bool = False) -> json.JSONEncoder: class JSONRenderer: - def __call__(self, logger, name, event_dict): + def __call__( + self, + logger: object, + name: str, + event_dict: MutableMapping[str, Any], # justification: structlog event values are dynamic. + ) -> str: try: return _json_encoder_no_skipkeys.encode(event_dict) except Exception: @@ -70,7 +76,12 @@ def __call__(self, logger, name, event_dict): class HumanRenderer: - def __call__(self, logger, name, event_dict): + def __call__( + self, + logger: object, + name: str, + event_dict: MutableMapping[str, Any], # justification: structlog event values are dynamic. + ) -> str: level = event_dict.pop("level") real_level = level.upper() if isinstance(level, str) else logging.getLevelName(level) base = "{} [{}] {}: {}".format( @@ -84,7 +95,7 @@ def __call__(self, logger, name, event_dict): return "{}{}".format(base, (" (%s)" % join if join else "")) -class StructLogHandler(logging.StreamHandler): +class StructLogHandler(logging.StreamHandler[TextIO]): def get_log_kwargs(self, record: logging.LogRecord) -> dict[str, Any]: kwargs = {k: v for k, v in vars(record).items() if k not in throwaways and v is not None} kwargs.update( @@ -143,14 +154,14 @@ class MessageContainsFilter(logging.Filter): contains -- a string or list of strings to match """ - def __init__(self, contains): + def __init__(self, contains: str | list[str]) -> None: if not isinstance(contains, list): contains = [contains] if not all(isinstance(c, str) for c in contains): raise TypeError("'contains' must be a string or list of strings") self.contains = contains - def filter(self, record): + def filter(self, record: logging.LogRecord) -> bool: message = record.getMessage() return any(c in message for c in self.contains) @@ -160,7 +171,7 @@ def filter(self, record): class MetricsLogHandler(logging.Handler): - def emit(self, record, logger=None): + def emit(self, record: logging.LogRecord, logger: logging.Logger | None = None) -> None: """ Turn something like: > django.request.Forbidden (CSRF cookie not set.): /account diff --git a/src/sentry/mail/__init__.py b/src/sentry/mail/__init__.py index a60cd72f43e4..ca4a2ed3cac7 100644 --- a/src/sentry/mail/__init__.py +++ b/src/sentry/mail/__init__.py @@ -1,4 +1,6 @@ -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, cast from django.conf import settings @@ -7,9 +9,13 @@ from .analytics import * # NOQA Importing this in __init__ so that analytics.register runs. from .notifications import * # NOQA Importing this in __init__ so that @register runs. +if TYPE_CHECKING: + from .adapter import MailAdapter + -def load_mail_adapter() -> Any: - return import_string(settings.SENTRY_MAIL_ADAPTER_BACKEND)() +def load_mail_adapter() -> MailAdapter: + # justification: the configured mail adapter backend is set by deployment settings. + return cast("MailAdapter", import_string(settings.SENTRY_MAIL_ADAPTER_BACKEND)()) -mail_adapter = load_mail_adapter() +mail_adapter: MailAdapter = load_mail_adapter() diff --git a/src/sentry/mail/adapter.py b/src/sentry/mail/adapter.py index c5031bfaa883..89cb6dea511b 100644 --- a/src/sentry/mail/adapter.py +++ b/src/sentry/mail/adapter.py @@ -77,7 +77,8 @@ def rule_notify( if digests.backend.enabled(project) and not skip_digests: def get_digest_option(key: str) -> int | None: - return ProjectOption.objects.get_value(project, get_digest_option_key("mail", key)) + value = ProjectOption.objects.get_value(project, get_digest_option_key("mail", key)) + return value if isinstance(value, int) else None digest_key = unsplit_key(project, target_type, target_identifier, fallthrough_choice) extra["digest_key"] = digest_key diff --git a/src/sentry/mail/notifications.py b/src/sentry/mail/notifications.py index 21431f965a80..1786c9152aa6 100644 --- a/src/sentry/mail/notifications.py +++ b/src/sentry/mail/notifications.py @@ -2,7 +2,7 @@ import logging from collections.abc import Iterable, Mapping, MutableMapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar, cast import orjson from django.utils.encoding import force_str @@ -48,9 +48,13 @@ def get_headers(notification: BaseNotification, context: Mapping[str, Any]) -> M def build_subject_prefix(project: Project) -> str: - return force_str( - ProjectOption.objects.get_value(project, "mail:subject_prefix") - or options.get("mail.subject-prefix") + # justification: force_str returns a str for the configured prefix. + return cast( + "str", + force_str( + ProjectOption.objects.get_value(project, "mail:subject_prefix") + or options.get("mail.subject-prefix") + ), ) diff --git a/src/sentry/management/commands/api_endpoint_owners.py b/src/sentry/management/commands/api_endpoint_owners.py index 1cd26a732f1f..5d2190f637a0 100644 --- a/src/sentry/management/commands/api_endpoint_owners.py +++ b/src/sentry/management/commands/api_endpoint_owners.py @@ -1,7 +1,9 @@ from __future__ import annotations +from typing import cast + import orjson -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandParser from sentry.api.endpoint_owners import build_owner_map @@ -9,7 +11,7 @@ class Command(BaseCommand): help = "Write which team owns each API endpoint, as JSON" - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument( "--output", dest="output", @@ -18,7 +20,7 @@ def add_arguments(self, parser): help="The file to write the owners map to. Prints to stdout when left out.", ) - def handle(self, **options): + def handle(self, **options: object) -> None: curr_map = build_owner_map() # {path: {class_name: owner}} @@ -36,6 +38,6 @@ def handle(self, **options): if not options["output"]: self.stdout.write(contents) else: - with open(options["output"], "w") as fh: + with open(cast(str, options["output"]), "w") as fh: fh.write(contents) self.stdout.write(f"Wrote {len(curr_map)} endpoints to {options['output']}") diff --git a/src/sentry/management/commands/check_notifications.py b/src/sentry/management/commands/check_notifications.py index 2693a46aec0e..5b76493ad5f1 100644 --- a/src/sentry/management/commands/check_notifications.py +++ b/src/sentry/management/commands/check_notifications.py @@ -1,12 +1,20 @@ -from django.core.management.base import BaseCommand, CommandError +from typing import cast + +from django.core.management.base import ( + BaseCommand, + CommandError, + CommandParser, + OutputWrapper, +) from sentry.mail import mail_adapter from sentry.models.organization import Organization from sentry.models.project import Project +from sentry.users.models.user import User from sentry.utils.email import get_email_addresses -def handle_project(project: Project, stream) -> None: +def handle_project(project: Project, stream: OutputWrapper) -> None: """ For every user that should receive ISSUE_ALERT notifications for a given project, write a map of usernames to email addresses to the given stream @@ -15,7 +23,8 @@ def handle_project(project: Project, stream) -> None: stream.write("# Project: %s\n" % project) users = mail_adapter.get_sendable_user_objects(project) - users_map = {user.id: user for user in users} + # justification: recipients are resolved to User rows before this point. + users_map = cast("dict[int, User]", {user.id: user for user in users}) emails = get_email_addresses(users_map.keys(), project) for user_id, email in emails.items(): stream.write(f"{users_map[user_id].username}: {email}\n") @@ -24,7 +33,7 @@ def handle_project(project: Project, stream) -> None: class Command(BaseCommand): help = "Dump addresses that would get an email notification" - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument( "--organization", action="store", type=int, dest="organization", default=0, help="" ) @@ -32,14 +41,16 @@ def add_arguments(self, parser): "--project", action="store", type=int, dest="project", default=0, help="" ) - def handle(self, *args, **options): + def handle(self, *args: object, **options: object) -> None: if not (options["project"] or options["organization"]): raise CommandError("Must specify either a project or organization") if options["organization"]: - projects = list(Organization.objects.get(pk=options["organization"]).project_set.all()) + projects = list( + Organization.objects.get(pk=cast(int, options["organization"])).project_set.all() + ) else: - projects = [Project.objects.get(pk=options["project"])] + projects = [Project.objects.get(pk=cast(int, options["project"]))] for project in projects: handle_project(project, self.stdout) diff --git a/src/sentry/management/commands/collectstatic.py b/src/sentry/management/commands/collectstatic.py index 377879694aeb..4e5b4d795b3b 100644 --- a/src/sentry/management/commands/collectstatic.py +++ b/src/sentry/management/commands/collectstatic.py @@ -1,4 +1,5 @@ import os +from collections.abc import Iterable from hashlib import md5 from itertools import chain from operator import itemgetter @@ -10,7 +11,7 @@ VERSION_PATH = "version" -def checksum(file_): +def checksum(file_: tuple[str, str]) -> str: hasher = md5() with open(file_[1], "rb") as fp: buf = fp.read(BUFFER_SIZE) @@ -20,7 +21,7 @@ def checksum(file_): return hasher.hexdigest() -def get_bundle_version(files): +def get_bundle_version(files: Iterable[tuple[str, str]]) -> str: hasher = md5() for (short, _), sum in zip(files, map(checksum, files)): echo(f"{sum} {short}") @@ -29,7 +30,7 @@ def get_bundle_version(files): class Command(BaseCommand): - def collect(self): + def collect(self) -> dict[str, list[str]]: try: os.remove(self.storage.path(VERSION_PATH)) except OSError: diff --git a/src/sentry/management/commands/create_sample_event.py b/src/sentry/management/commands/create_sample_event.py index 578032cd3323..a2c819e1405b 100644 --- a/src/sentry/management/commands/create_sample_event.py +++ b/src/sentry/management/commands/create_sample_event.py @@ -1,10 +1,12 @@ -from django.core.management.base import BaseCommand, CommandError +from typing import cast + +from django.core.management.base import BaseCommand, CommandError, CommandParser class Command(BaseCommand): help = "Creates a sample event in Sentry (if applicable)" - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: ( parser.add_argument( "--project", dest="project", help="project ID or team-slug/project-slug" @@ -12,7 +14,7 @@ def add_arguments(self, parser): ) (parser.add_argument("--platform", dest="platform"),) - def handle(self, **options): + def handle(self, **options: object) -> None: from django.conf import settings from sentry.models.project import Project @@ -21,19 +23,20 @@ def handle(self, **options): if not options["project"]: project = Project.objects.get(id=settings.SENTRY_PROJECT) else: - if options["project"].isdigit(): - project = Project.objects.get(id=options["project"]) - elif "/" in options["project"]: - t_slug, p_slug = options["project"].split("/", 1) + if cast(str, options["project"]).isdigit(): + project = Project.objects.get(id=cast(str | int, options["project"])) + elif "/" in cast(str, options["project"]): + t_slug, p_slug = cast(str, options["project"]).split("/", 1) project = Project.objects.get(slug=p_slug, teams__slug=t_slug) else: raise CommandError( "Project must be specified as team-slug/project-slug or a project id" ) - platform = options["platform"] + platform = cast(str | None, options["platform"]) event = create_sample_event(project, platform) if not event: raise CommandError(f"Unable to create an event for platform {platform!r}") + assert event.group is not None self.stdout.write(f"Event created: {event.group.get_absolute_url()}") diff --git a/src/sentry/management/commands/createsuperuser.py b/src/sentry/management/commands/createsuperuser.py index 9dc13719ed3e..e7e60378d9df 100644 --- a/src/sentry/management/commands/createsuperuser.py +++ b/src/sentry/management/commands/createsuperuser.py @@ -4,7 +4,7 @@ class Command(DjangoCommand): help = "Performs any pending database migrations and upgrades" - def handle(self, **options): + def handle(self, **options: object) -> None: from sentry.runner import call_command call_command("sentry.runner.commands.createuser.createuser", superuser=True) diff --git a/src/sentry/management/commands/generate_controlsilo_urls.py b/src/sentry/management/commands/generate_controlsilo_urls.py index 5b66c8911525..b9963c6bcd4f 100644 --- a/src/sentry/management/commands/generate_controlsilo_urls.py +++ b/src/sentry/management/commands/generate_controlsilo_urls.py @@ -3,13 +3,14 @@ import functools import importlib import re -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass +from typing import Any, cast from django.conf import settings from django.core.exceptions import ViewDoesNotExist from django.core.management import CommandError -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandParser from django.urls import URLPattern, URLResolver from sentry.silo.base import SiloMode @@ -25,7 +26,7 @@ @dataclass class PatternInfo: - callable: Callable + callable: Callable[..., object] pattern: str name: str | None @@ -34,7 +35,9 @@ def url_name(self) -> str: return self.name or "" -def describe_pattern(pattern): +# justification: URL patterns are duck-typed Django objects below this point, including +# custom pattern classes that expose _get_callback/_get_url_patterns. +def describe_pattern(pattern: Any) -> str: # justification: duck-typed Django URL patterns. return str(pattern.pattern) @@ -61,7 +64,7 @@ def simplify_regex(pattern: str) -> str: class Command(BaseCommand): help = "Generate a list of URL patterns served by control silo endpoints" - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument( "--format", choices=["text", "js"], @@ -83,9 +86,9 @@ def add_arguments(self, parser): help="The settings attribute with URL configuration.", ) - def handle(self, **options): + def handle(self, **options: object) -> None: try: - urlconf = importlib.import_module(getattr(settings, options["urlconf"])) + urlconf = importlib.import_module(getattr(settings, cast(str, options["urlconf"]))) except ImportError as e: self.stdout.write(f"Failed to load URL configuration {e}") raise CommandError("Could not load URL configuration") @@ -141,13 +144,13 @@ def handle(self, **options): simple_pattern = simplify_regex(info.pattern) url_patterns.append(simple_pattern) - contents = self.render(url_patterns, options["format"]) + contents = self.render(url_patterns, cast(str, options["format"])) if not options["output"]: self.stdout.write(contents) else: self.stdout.write(f"Writing {options['output']} file..") - with open(options["output"], "w") as fh: + with open(cast(str, options["output"]), "w") as fh: fh.write(contents) self.stdout.write("All done") @@ -167,8 +170,13 @@ def render(self, url_patterns: list[str], format: str) -> str: raise TypeError(f"Invalid format chosen {format}") + # justification: Django URLconfs mix URLPattern/URLResolver with duck-typed + # pattern objects, so the tree has no static element type. def extract_views_from_urlpatterns( - self, urlpatterns, base: str = "", namespace: str | None = None + self, + urlpatterns: Iterable[Any], # justification: duck-typed Django URL patterns. + base: str = "", + namespace: str | None = None, ) -> list[PatternInfo]: # Inspired by # https://github.com/django-extensions/django-extensions/blob/main/django_extensions/management/commands/show_urls.py diff --git a/src/sentry/management/commands/generate_reset_password_link.py b/src/sentry/management/commands/generate_reset_password_link.py index 919dd64cb440..a824586ce5eb 100644 --- a/src/sentry/management/commands/generate_reset_password_link.py +++ b/src/sentry/management/commands/generate_reset_password_link.py @@ -1,7 +1,7 @@ import sys from click import echo -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandParser from django.utils import timezone from sentry.users.models.lostpasswordhash import LostPasswordHash @@ -11,7 +11,7 @@ class Command(BaseCommand): help = "Generate a link for a user to reset their password" - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument( "--noinput", dest="noinput", @@ -20,7 +20,7 @@ def add_arguments(self, parser): help="Dont ask for confirmation before merging accounts.", ) - def handle(self, username, **options): + def handle(self, username: str, **options: object) -> None: users = find_users(username, with_valid_password=False) if not users: sys.stdout.write("No account found with given username.\n") diff --git a/src/sentry/management/commands/makemigrations.py b/src/sentry/management/commands/makemigrations.py index 3543df5d8ad9..cb7adc651050 100644 --- a/src/sentry/management/commands/makemigrations.py +++ b/src/sentry/management/commands/makemigrations.py @@ -47,7 +47,7 @@ class Command(makemigrations.Command): on master that doesn't exist in a branch. """ - def handle(self, *app_labels, **options): + def handle(self, *app_labels: str, **options: object) -> None: if not options["name"] and not options.get("check_changes"): self.stderr.write( "Please name your migrations using `-n `. " diff --git a/src/sentry/management/commands/merge_users.py b/src/sentry/management/commands/merge_users.py index c810dd2f3e58..6f972693b8dc 100644 --- a/src/sentry/management/commands/merge_users.py +++ b/src/sentry/management/commands/merge_users.py @@ -2,7 +2,7 @@ import sys from functools import reduce -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand, CommandError, CommandParser from django.db.models import Q from sentry.models.organization import Organization @@ -12,7 +12,7 @@ class Command(BaseCommand): help = "Attempts to repair any invalid data within Sentry" - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument( "--organization", help="Find all potential duplicate users within that organization." ) @@ -31,11 +31,11 @@ def add_arguments(self, parser): help="Don't remove merged accounts.", ) - def _get_organization_user_sets(self, organization): + def _get_organization_user_sets(self, organization: Organization) -> list[list[User]]: # TODO: This logic cannot work in the hybrid cloud world, entire process needs redesign. raise NotImplementedError - def _confirm_merge(self, primary_user, other_users): + def _confirm_merge(self, primary_user: User, other_users: list[User]) -> bool: message = "Merge {} into {}? [Yn] ".format( ", ".join(o.username for o in other_users), primary_user.username ) @@ -46,7 +46,7 @@ def _confirm_merge(self, primary_user, other_users): elif response == "n": return False - def handle(self, *usernames, **options): + def handle(self, *usernames: str, **options: object) -> None: assert usernames or options.get("organization") noinput = options.get("noinput", False) diff --git a/src/sentry/metrics/datadog.py b/src/sentry/metrics/datadog.py index f07590eed05d..9ecaf175a3da 100644 --- a/src/sentry/metrics/datadog.py +++ b/src/sentry/metrics/datadog.py @@ -1,6 +1,7 @@ __all__ = ["DatadogMetricsBackend"] -from typing import Any +from collections.abc import Callable +from typing import Any, cast from datadog import initialize from datadog.threadstats.base import ThreadStats @@ -8,6 +9,10 @@ from django.utils.functional import cached_property from .base import MetricsBackend, Tags +from .datadog_stats import DatadogStats + +# justification: datadog does not ship type hints for the ThreadStats constructor. +_ThreadStats = cast("Callable[[], DatadogStats]", ThreadStats) class DatadogMetricsBackend(MetricsBackend): @@ -29,8 +34,8 @@ def __del__(self) -> None: pass @cached_property - def stats(self) -> ThreadStats: - instance = ThreadStats() + def stats(self) -> DatadogStats: + instance = _ThreadStats() instance.start() return instance diff --git a/src/sentry/metrics/datadog_stats.py b/src/sentry/metrics/datadog_stats.py new file mode 100644 index 000000000000..7773effc6bcf --- /dev/null +++ b/src/sentry/metrics/datadog_stats.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Protocol + + +class DatadogStats(Protocol): + _container_id: str | None + + def start(self) -> None: ... + def stop(self) -> bool: ... + def increment( + self, + metric_name: str, + value: float | int = 1, + timestamp: float | None = None, + tags: Sequence[str] | None = None, + sample_rate: float = 1, + host: str | None = None, + ) -> None: ... + def decrement( + self, + metric_name: str, + value: float | int = 1, + timestamp: float | None = None, + tags: Sequence[str] | None = None, + sample_rate: float = 1, + host: str | None = None, + ) -> None: ... + def timing( + self, + metric_name: str, + value: float, + timestamp: float | None = None, + tags: Sequence[str] | None = None, + sample_rate: float = 1, + host: str | None = None, + ) -> None: ... + def gauge( + self, + metric_name: str, + value: float, + timestamp: float | None = None, + tags: Sequence[str] | None = None, + sample_rate: float = 1, + host: str | None = None, + ) -> None: ... + def distribution( + self, + metric_name: str, + value: float, + timestamp: float | None = None, + tags: Sequence[str] | None = None, + sample_rate: float = 1, + host: str | None = None, + ) -> None: ... + def set( + self, + metric_name: str, + value: str | int, + timestamp: float | None = None, + tags: Sequence[str] | None = None, + sample_rate: float = 1, + host: str | None = None, + ) -> None: ... + def event( + self, + title: str, + message: str, + alert_type: str | None = None, + aggregation_key: str | None = None, + source_type_name: str | None = None, + date_happened: int | None = None, + priority: str | None = None, + tags: Sequence[str] | None = None, + hostname: str | None = None, + ) -> None: ... + def disable_telemetry(self) -> None: ... + def enable_background_sender( + self, sender_queue_size: int, sender_queue_timeout: int + ) -> None: ... + def wait_for_pending(self) -> None: ... diff --git a/src/sentry/metrics/dogstatsd.py b/src/sentry/metrics/dogstatsd.py index aa1d6c81b0b5..66715243ba0e 100644 --- a/src/sentry/metrics/dogstatsd.py +++ b/src/sentry/metrics/dogstatsd.py @@ -1,14 +1,18 @@ import atexit -from typing import Any +from typing import Any, cast from datadog import initialize -from datadog.dogstatsd.base import statsd +from datadog.dogstatsd.base import statsd as _statsd from datadog.util.hostname import get_hostname from .base import MetricsBackend, Tags +from .datadog_stats import DatadogStats __all__ = ["DogStatsdMetricsBackend"] +# justification: datadog does not ship type hints for its module-level statsd client. +statsd = cast("DatadogStats", _statsd) + # Set the maximum number of packets to queue for the sender. # How may packets to queue before blocking or dropping the packet if the packet queue is already full. # 0 means unlimited. diff --git a/src/sentry/metrics/precise_dogstatsd.py b/src/sentry/metrics/precise_dogstatsd.py index 8ee3edba0d28..537578e42ffe 100644 --- a/src/sentry/metrics/precise_dogstatsd.py +++ b/src/sentry/metrics/precise_dogstatsd.py @@ -1,10 +1,11 @@ import atexit -from typing import Any +from typing import Any, cast from datadog.dogstatsd.base import DogStatsd from datadog.util.hostname import get_hostname from .base import MetricsBackend, Tags +from .datadog_stats import DatadogStats __all__ = ["PreciseDogStatsdMetricsBackend"] @@ -33,7 +34,8 @@ def __init__(self, prefix: str | None = None, **kwargs: Any) -> None: if port := kwargs.get("statsd_port"): instance_kwargs["port"] = int(port) - self.statsd = DogStatsd(**instance_kwargs) + # justification: datadog does not ship type hints for DogStatsd. + self.statsd: DatadogStats = cast("DatadogStats", DogStatsd(**instance_kwargs)) # Origin detection is enabled after 0.45 by default. # Disable it since it silently fails. diff --git a/src/sentry/metrics/sentry_sdk.py b/src/sentry/metrics/sentry_sdk.py index ae9c9e0830bb..f93ffac5c556 100644 --- a/src/sentry/metrics/sentry_sdk.py +++ b/src/sentry/metrics/sentry_sdk.py @@ -28,7 +28,9 @@ def _should_send(self, key: str) -> bool: def _should_sample_experimental(self) -> bool: """Sample based on passed in sample rate, can't use options as they hit the db too much.""" - return self._experimental_sample_rate >= 1.0 or random() < self._experimental_sample_rate + return bool( + self._experimental_sample_rate >= 1.0 or random() < self._experimental_sample_rate + ) def incr( self, diff --git a/src/sentry/middleware/ai_agent.py b/src/sentry/middleware/ai_agent.py index 8e866f4afdde..c56972f26dae 100644 --- a/src/sentry/middleware/ai_agent.py +++ b/src/sentry/middleware/ai_agent.py @@ -3,6 +3,7 @@ import json # noqa: S003 - need stdlib json for indent support import logging import re +from collections.abc import Callable from typing import Any from django.http import HttpRequest, HttpResponse @@ -104,7 +105,7 @@ class AIAgentMiddleware: 3. Request is anonymous (no authenticated user, no auth token) """ - def __init__(self, get_response): + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): self.get_response = get_response def __call__(self, request: HttpRequest) -> HttpResponse: diff --git a/src/sentry/middleware/auth.py b/src/sentry/middleware/auth.py index e2fc08d87bf8..8858db82d77e 100644 --- a/src/sentry/middleware/auth.py +++ b/src/sentry/middleware/auth.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import cast + from django.conf import settings from django.contrib.auth import get_user as auth_get_user from django.contrib.auth.models import AnonymousUser @@ -20,6 +22,7 @@ ViewerContextAuthentication, ) from sentry.auth.principal import set_request_principal +from sentry.users.models.user import User from sentry.users.models.userip import UserIP from sentry.utils.auth import AuthUserPasswordExpired, logger @@ -33,7 +36,7 @@ ) -def get_user(request): +def get_user(request: HttpRequest) -> User | AnonymousUser: if not hasattr(request, "_cached_user"): user = auth_get_user(request) # If the user bound to this request matches a real user, @@ -60,8 +63,9 @@ def get_user(request): user = AnonymousUser() else: UserIP.log(user, request.META["REMOTE_ADDR"]) - request._cached_user = user - return request._cached_user + setattr(request, "_cached_user", user) + cached_user: User | AnonymousUser = getattr(request, "_cached_user") + return cached_user class AuthenticationMiddleware(MiddlewareMixin): @@ -110,6 +114,7 @@ def process_exception( if isinstance(exception, AuthUserPasswordExpired): from sentry.users.web.accounts import expired - return expired(request, exception.user) + # justification: the expired-account view returns a Django response. + return cast("HttpResponseBase", expired(request, exception.user)) else: return None diff --git a/src/sentry/middleware/customer_domain.py b/src/sentry/middleware/customer_domain.py index 78180d3d24e1..7c4afbbebfd1 100644 --- a/src/sentry/middleware/customer_domain.py +++ b/src/sentry/middleware/customer_domain.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) -def _org_exists(slug): +def _org_exists(slug: str | None) -> bool: if not slug: return False return ( @@ -28,25 +28,26 @@ def _org_exists(slug): ) -def _query_string(request): +def _query_string(request: HttpRequest) -> str: qs = request.META.get("QUERY_STRING") or "" if qs: qs = f"?{qs}" return qs -def _resolve_activeorg(request): +def _resolve_activeorg(request: HttpRequest) -> str | None: subdomain = request.subdomain session = getattr(request, "session", None) if _org_exists(subdomain): # Assume subdomain is an org slug being accessed return subdomain elif session and "activeorg" in session and _org_exists(session["activeorg"]): - return session["activeorg"] + activeorg = session["activeorg"] + return activeorg if isinstance(activeorg, str) else None return None -def _resolve_redirect_url(request, activeorg): +def _resolve_redirect_url(request: HttpRequest, activeorg: str) -> str | None: subdomain = request.subdomain redirect_subdomain = subdomain != activeorg redirect_url = "" @@ -114,8 +115,8 @@ def __call__(self, request: HttpRequest) -> HttpResponseBase: del session["activeorg"] return self.get_response(request) auth.set_active_org(request, activeorg) - redirect_url = _resolve_redirect_url(request, activeorg) - if redirect_url is not None and len(redirect_url) > 0: - logger.info("customer_domain.redirect", extra={"location": redirect_url}) - return HttpResponseRedirect(redirect_url) + activeorg_redirect_url = _resolve_redirect_url(request, activeorg) + if activeorg_redirect_url is not None and len(activeorg_redirect_url) > 0: + logger.info("customer_domain.redirect", extra={"location": activeorg_redirect_url}) + return HttpResponseRedirect(activeorg_redirect_url) return self.get_response(request) diff --git a/src/sentry/middleware/demo_mode_guard.py b/src/sentry/middleware/demo_mode_guard.py index be514f644845..6167f9d76ab6 100644 --- a/src/sentry/middleware/demo_mode_guard.py +++ b/src/sentry/middleware/demo_mode_guard.py @@ -9,11 +9,12 @@ from sentry.demo_mode.utils import is_demo_mode_enabled, is_demo_org from sentry.organizations.services.organization import organization_service +from sentry.organizations.services.organization.model import RpcOrganization logger = logging.getLogger(__name__) -def _get_org(slug): +def _get_org(slug: str | None) -> RpcOrganization | None: if not slug: return None if ( diff --git a/src/sentry/middleware/devtoolbar.py b/src/sentry/middleware/devtoolbar.py index 6028178aa815..3b8979570675 100644 --- a/src/sentry/middleware/devtoolbar.py +++ b/src/sentry/middleware/devtoolbar.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Callable import sentry_sdk from django.http import HttpRequest, HttpResponse @@ -13,10 +14,10 @@ class DevToolbarAnalyticsMiddleware: - def __init__(self, get_response): + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: self.get_response = get_response - def __call__(self, request): + def __call__(self, request: HttpRequest) -> HttpResponse: response = self.get_response(request) try: # Note ordering of conditions to reduce option queries. GET contains the query params, regardless of method. 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/middleware/superuser.py b/src/sentry/middleware/superuser.py index db794056ca8f..934aeed68277 100644 --- a/src/sentry/middleware/superuser.py +++ b/src/sentry/middleware/superuser.py @@ -71,6 +71,6 @@ def _extract_email(self, request: Request) -> str | None: if not staff_email_suffix: return None email = getattr(request.user, "email", None) - if email and email.endswith(staff_email_suffix): + if isinstance(email, str) and email.endswith(staff_email_suffix): return email return None diff --git a/src/sentry/migrations/1209_update_if_syntax_on_dashboard_spans.py b/src/sentry/migrations/1209_update_if_syntax_on_dashboard_spans.py index 343e598cc53c..679e9a7604eb 100644 --- a/src/sentry/migrations/1209_update_if_syntax_on_dashboard_spans.py +++ b/src/sentry/migrations/1209_update_if_syntax_on_dashboard_spans.py @@ -5,7 +5,7 @@ import logging import re from re import Match -from typing import Any, Union +from typing import cast, Any, Union from django.db import migrations from django.db.backends.base.schema import BaseDatabaseSchemaEditor @@ -268,7 +268,8 @@ def visit_parens(self, node: Node, children: Any) -> Any: @staticmethod def parse_operator(operator: Any) -> str: - return operator[0].expr_name + # justification: sqlparse nodes expose the operator name as a string. + return cast("str", operator[0].expr_name) def visit_add_sub_operator(self, node: Node, children: Any) -> str: return self.parse_operator(self.strip_spaces(children)) diff --git a/src/sentry/models/apiauthorization.py b/src/sentry/models/apiauthorization.py index 91a6ffe600fd..e00b3144145e 100644 --- a/src/sentry/models/apiauthorization.py +++ b/src/sentry/models/apiauthorization.py @@ -52,7 +52,7 @@ class Meta: __repr__ = sane_repr("user_id", "application_id") def get_relocation_scope(self) -> RelocationScope: - if self.application_id is not None: + if getattr(self, "application_id", None) is not None: # TODO(getsentry/team-ospo#188): this should be extension scope once that gets added. return RelocationScope.Global diff --git a/src/sentry/models/apitoken.py b/src/sentry/models/apitoken.py index 064ad4613120..76790ba13c0d 100644 --- a/src/sentry/models/apitoken.py +++ b/src/sentry/models/apitoken.py @@ -512,7 +512,7 @@ def refresh(self, expires_at: datetime | None = None) -> None: raise TokenRefreshError("token refresh already in progress") def get_relocation_scope(self) -> RelocationScope: - if self.application_id is not None: + if getattr(self, "application_id", None) is not None: # TODO(getsentry/team-ospo#188): this should be extension scope once that gets added. return RelocationScope.Global diff --git a/src/sentry/models/artifactbundle.py b/src/sentry/models/artifactbundle.py index d71207c51978..bd3c4899d315 100644 --- a/src/sentry/models/artifactbundle.py +++ b/src/sentry/models/artifactbundle.py @@ -3,7 +3,8 @@ import zipfile from collections.abc import Callable, Iterable, Mapping from enum import Enum -from typing import IO, Any +from types import TracebackType +from typing import IO, Any, Self from django.db import models from django.db.models.signals import post_delete @@ -107,13 +108,13 @@ def get_release_associations( ] @classmethod - def get_ident(cls, url, dist=None): + def get_ident(cls, url: str, dist: str | None = None) -> str: if dist is not None: return sha1_text(url + "\x00\x00" + dist).hexdigest() return sha1_text(url).hexdigest() -def delete_file_for_artifact_bundle(instance, **kwargs): +def delete_file_for_artifact_bundle(instance: ArtifactBundle, **kwargs: object) -> None: from sentry.models.files import File from sentry.tasks.assemble import AssembleTask, delete_assemble_status @@ -212,7 +213,7 @@ class Meta: class ArtifactBundleArchive: """Read-only view of uploaded ZIP artifact bundle.""" - def __init__(self, fileobj: IO, build_memory_map: bool = True): + def __init__(self, fileobj: IO[bytes], build_memory_map: bool = True) -> None: self._fileobj = fileobj self._zip_file = zipfile.ZipFile(self._fileobj) self._entries_by_debug_id: dict[tuple[str, SourceFileType], tuple[str, str, dict[str, Any]]] @@ -225,13 +226,18 @@ def __init__(self, fileobj: IO, build_memory_map: bool = True): if build_memory_map: self._build_memory_maps() - 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() @@ -244,17 +250,20 @@ def infolist(self) -> list[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, Any]: manifest_bytes = self.read("manifest.json") - return json.loads(manifest_bytes.decode("utf-8")) + manifest = json.loads(manifest_bytes.decode("utf-8")) + if not isinstance(manifest, dict): + raise ValueError("Artifact bundle manifest is not a JSON object") + return manifest @staticmethod - def normalize_headers(headers: dict) -> dict: + def normalize_headers(headers: dict[str, object]) -> dict[str, object]: return {k.lower(): v for k, v in headers.items()} @staticmethod - def normalize_debug_id(debug_id: str | None) -> str | None: - if debug_id is None: + def normalize_debug_id(debug_id: object) -> str | None: + if not isinstance(debug_id, str): return None try: @@ -262,7 +271,7 @@ def normalize_debug_id(debug_id: str | None) -> str | None: except SymbolicError: return None - def _build_memory_maps(self): + def _build_memory_maps(self) -> None: files = self.manifest.get("files", {}) for file_path, info in files.items(): url = info.get("url") @@ -270,8 +279,9 @@ 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: - debug_id = self.normalize_debug_id(debug_id) + raw_debug_id = headers.get("debug-id") + if raw_debug_id is not None: + debug_id = self.normalize_debug_id(raw_debug_id) file_type = info.get("type") if ( debug_id is not None @@ -294,7 +304,7 @@ def get_all_urls(self) -> list[str]: def get_all_debug_ids(self) -> Iterable[tuple[str, SourceFileType]]: return self._entries_by_debug_id.keys() - def has_debug_ids(self): + def has_debug_ids(self) -> bool: return len(self._entries_by_debug_id) > 0 def get_files_without_debug_ids(self) -> list[tuple[str, dict[str, Any]]]: @@ -323,25 +333,30 @@ def extract_bundle_id(self) -> str | None: return bundle_id - def get_files(self) -> dict[str, dict]: - return self.manifest.get("files", {}) + def get_files(self) -> dict[str, dict[str, Any]]: + files = self.manifest.get("files", {}) + if not isinstance(files, dict): + raise ValueError("Artifact bundle manifest files are not a JSON object") + return files - def get_file_by_url(self, url: str) -> tuple[IO, dict]: + def get_file_by_url(self, url: str) -> tuple[IO[bytes], dict[str, Any]]: file_path, info = self._entries_by_url[url] return self._zip_file.open(file_path), info.get("headers", {}) def get_file_by_debug_id( self, debug_id: str, source_file_type: SourceFileType - ) -> tuple[IO[bytes], dict]: + ) -> tuple[IO[bytes], dict[str, Any]]: file_path, _, info = self._entries_by_debug_id[debug_id, source_file_type] return self._zip_file.open(file_path), info.get("headers", {}) - def get_file(self, file_path: str) -> tuple[IO, dict]: + def get_file(self, file_path: str) -> tuple[IO[bytes], dict[str, Any]]: files = self.manifest.get("files", {}) file_info = files.get(file_path, {}) return self._zip_file.open(file_path), file_info.get("headers", {}) - def get_files_by(self, block: Callable[[str, dict], bool]) -> dict[str, dict]: + def get_files_by( + self, block: Callable[[str, dict[str, Any]], bool] + ) -> dict[str, dict[str, Any]]: files = self.manifest.get("files", {}) results = {} @@ -351,8 +366,8 @@ def get_files_by(self, block: Callable[[str, dict], bool]) -> dict[str, dict]: return results - def get_files_by_url_or_debug_id(self, query: str | None) -> dict[str, dict]: - def filter_function(_: str, info: dict) -> bool: + def get_files_by_url_or_debug_id(self, query: str | None) -> dict[str, dict[str, Any]]: + def filter_function(_: str, info: dict[str, Any]) -> bool: if query is None: return True @@ -396,8 +411,9 @@ def get_file_url_by_debug_id( return None - def get_file_url_by_file_path(self, file_path): + def get_file_url_by_file_path(self, file_path: str) -> str | None: files = self.manifest.get("files", {}) file_info = files.get(file_path, {}) - return file_info.get("url") + url = file_info.get("url") + return url if isinstance(url, str) else None diff --git a/src/sentry/models/auditlogentry.py b/src/sentry/models/auditlogentry.py index b357f01e25fd..52599a1f121b 100644 --- a/src/sentry/models/auditlogentry.py +++ b/src/sentry/models/auditlogentry.py @@ -2,8 +2,11 @@ import logging import re +from collections.abc import Iterable +from typing import TYPE_CHECKING from django.db import models +from django.db.models.base import ModelBase from django.utils import timezone from sentry.audit_log.services.log import AuditLogEvent @@ -20,17 +23,20 @@ from sentry.db.models.fields.jsonfield import LegacyTextJSONField from sentry.users.services.user.service import user_service +if TYPE_CHECKING: + from sentry.users.models.user import User + MAX_ACTOR_LABEL_LENGTH = 64 logger = logging.getLogger(__name__) -def is_scim_token_actor(actor): +def is_scim_token_actor(actor: User) -> bool: scim_prefix = "scim-internal-integration-" return scim_prefix in actor.get_display_name() -def format_scim_token_actor_name(actor): +def format_scim_token_actor_name(actor: User) -> str: scim_regex = re.compile( r".*([0-9a-fA-F]{6})\-[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{7}" ) @@ -52,7 +58,7 @@ class AuditLogEntry(Model): ) # if the entry was created via an api key actor_key = FlexibleForeignKey("sentry.ApiKey", null=True, blank=True) - target_object = BoundedBigIntegerField(null=True) + target_object = BoundedBigIntegerField[int | None, int | None](null=True) target_user = FlexibleForeignKey( "sentry.User", null=True, @@ -76,13 +82,25 @@ class Meta: __repr__ = sane_repr("organization_id", "type") - def save(self, *args, **kwargs): + def save( + self, + *, + force_insert: bool | tuple[ModelBase, ...] = False, + force_update: bool = False, + using: str | None = None, + update_fields: Iterable[str] | None = None, + ) -> None: # trim label to the max length self._apply_actor_label() self.actor_label = self.actor_label[:MAX_ACTOR_LABEL_LENGTH] if self.actor_label else "" - super().save(*args, **kwargs) + super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) - def _apply_actor_label(self): + def _apply_actor_label(self) -> None: if not self.actor_label: assert self.actor_id or self.actor_key or self.ip_address if self.actor_id: @@ -153,7 +171,7 @@ def from_event(cls, event: AuditLogEvent) -> AuditLogEntry: actor_key_id=event.actor_key_id, ) - def get_actor_name(self): + def get_actor_name(self) -> str | None: if self.actor: # fix display name if needed if is_scim_token_actor(self.actor): diff --git a/src/sentry/models/authidentity.py b/src/sentry/models/authidentity.py index e8ae3884b73e..c1b92f20d1e1 100644 --- a/src/sentry/models/authidentity.py +++ b/src/sentry/models/authidentity.py @@ -98,11 +98,11 @@ class Meta: def __str__(self) -> str: return self.ident - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, object]: return {"user_id": self.user_id, "data": self.data} - def get_display_name(self): + def get_display_name(self) -> str: return self.user.get_display_name() - def get_label(self): + def get_label(self) -> str: return self.user.get_label() diff --git a/src/sentry/models/authprovider.py b/src/sentry/models/authprovider.py index bb6419587f2b..9f9d47efa937 100644 --- a/src/sentry/models/authprovider.py +++ b/src/sentry/models/authprovider.py @@ -2,7 +2,7 @@ import logging from collections.abc import Mapping -from typing import Any +from typing import TYPE_CHECKING, Any from django.db import models from django.utils import timezone @@ -20,6 +20,10 @@ from sentry.hybridcloud.outbox.category import OutboxCategory, OutboxScope from sentry.types.cell import find_cells_for_orgs +if TYPE_CHECKING: + from sentry.auth.provider import Provider + from sentry.users.models.user import User + logger = logging.getLogger("sentry.authprovider") SCIM_INTERNAL_INTEGRATION_OVERVIEW = ( @@ -39,7 +43,7 @@ class AuthProvider(ReplicatedControlModel): config = models.JSONField(default=dict) date_added = models.DateTimeField(default=timezone.now) - sync_time = BoundedPositiveIntegerField(null=True) + sync_time = BoundedPositiveIntegerField[int | None, int | None](null=True) last_sync = models.DateTimeField(null=True) default_role = BoundedPositiveIntegerField(default=50) @@ -90,7 +94,7 @@ class Meta: def __str__(self) -> str: return self.provider - def get_provider(self): + def get_provider(self) -> Provider: from sentry.auth import manager return manager.get(self.provider, **self.config) @@ -99,10 +103,10 @@ def get_provider(self): def provider_name(self) -> str: return self.get_provider().name - def get_scim_token(self): + def get_scim_token(self) -> str | None: return get_scim_token(self.flags.scim_enabled, self.organization_id, self.provider) - def enable_scim(self, user): + def enable_scim(self, user: User) -> None: from sentry.sentry_apps.logic import SentryAppCreator from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation from sentry.sentry_apps.models.sentry_app_installation_for_provider import ( @@ -165,7 +169,7 @@ def outboxes_for_reset_idp_flags(self) -> list[ControlOutbox]: for region_name in find_cells_for_orgs([self.organization_id]) ] - def disable_scim(self): + def disable_scim(self) -> None: from sentry.sentry_apps.models.sentry_app_installation_for_provider import ( SentryAppInstallationForProvider, ) @@ -192,7 +196,7 @@ def disable_scim(self): pass self.flags.scim_enabled = False - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, object]: provider = self.provider # NOTE(isabella): for both standard fly SSO and fly-non-partner SSO, we should record the # provider as "fly" in the audit log entry data; the only difference between the two is diff --git a/src/sentry/models/avatars/base.py b/src/sentry/models/avatars/base.py index 74a97d616ad3..57639a5a4417 100644 --- a/src/sentry/models/avatars/base.py +++ b/src/sentry/models/avatars/base.py @@ -1,12 +1,14 @@ from __future__ import annotations +from collections.abc import Iterable, Mapping from io import BytesIO -from typing import ClassVar, Self +from typing import IO, ClassVar, Self from urllib.parse import urljoin from uuid import uuid4 from django.core.exceptions import ObjectDoesNotExist from django.db import models, router +from django.db.models.base import ModelBase from django.utils.encoding import force_bytes from PIL import Image @@ -43,12 +45,24 @@ class Meta: url_path = "avatar" - 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 not self.ident: self.ident = uuid4().hex - return super().save(*args, **kwargs) - - def get_file(self): + super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) + + def get_file(self) -> File | ControlFile | None: file_id = getattr(self, self.file_write_fk(), None) if file_id is None: return None @@ -63,22 +77,24 @@ def get_file(self): self.update(**update) return None - def delete(self, *args, **kwargs): + def delete( + self, using: str | None = None, keep_parents: bool = False + ) -> tuple[int, dict[str, int]]: file = self.get_file() if file: file.delete() - return super().delete(*args, **kwargs) + return super().delete(using=using, keep_parents=keep_parents) - def get_cache_key(self, size): + def get_cache_key(self, size: int) -> str: raise NotImplementedError - def clear_cached_photos(self): + def clear_cached_photos(self) -> None: cache.delete_many([self.get_cache_key(x) for x in self.ALLOWED_SIZES]) - def get_cached_photo(self, size): + def get_cached_photo(self, size: int) -> bytes | None: file = self.get_file() if not file: - return + return None if size not in self.ALLOWED_SIZES: size = min(self.ALLOWED_SIZES, key=lambda x: abs(x - size)) cache_key = self.get_cache_key(size) @@ -90,7 +106,7 @@ def get_cached_photo(self, size): image.save(image_file, "PNG") photo = image_file.getvalue() cache.set(cache_key, photo) - return photo + return photo if isinstance(photo, bytes) else None def file_class(self) -> type[File] | type[ControlFile]: return File @@ -122,11 +138,20 @@ def absolute_url(self) -> str: silo_limit = getattr(cls._meta, "silo_limit", None) if silo_limit is not None and SiloMode.CELL in silo_limit.modes: url_base = get_local_locality().to_url("") + if not isinstance(url_base, str): + raise TypeError("system.url-prefix is not configured") return urljoin(url_base, f"/{self.url_path}/{self.ident}/") @classmethod - def save_avatar(cls, relation, type, avatar=None, filename=None, color=None) -> Self: + def save_avatar( + cls, + relation: Mapping[str, Model], + type: str, + avatar: IO[bytes] | str | None = None, + filename: str = "", + color: bool | None = None, + ) -> Self: if avatar: # Create an instance of the current class so we can # access where new files should be stored. @@ -149,9 +174,11 @@ def save_avatar(cls, relation, type, avatar=None, filename=None, color=None) -> using=router.db_for_write(cls), ): if relation.get("sentry_app") and color is not None: - instance, created = cls.objects.get_or_create(**relation, color=color) + instance, created = cls.objects.get_or_create( + defaults=None, **relation, color=color + ) else: - instance, created = cls.objects.get_or_create(**relation) + instance, created = cls.objects.get_or_create(defaults=None, **relation) file = instance.get_file() if file and photo: file.delete() diff --git a/src/sentry/models/avatars/control_base.py b/src/sentry/models/avatars/control_base.py index 494101e1870d..d6bab08b7243 100644 --- a/src/sentry/models/avatars/control_base.py +++ b/src/sentry/models/avatars/control_base.py @@ -4,7 +4,7 @@ class ControlAvatarBase(AvatarBase): - control_file_id = BoundedBigIntegerField(unique=True, null=True) + control_file_id = BoundedBigIntegerField[int | None, int | None](unique=True, null=True) class Meta: abstract = True @@ -25,5 +25,5 @@ def file_write_fk(self) -> str: """Prefer controlfile as user/sentryapp avatars are stored in control silo""" return "control_file_id" - def get_file_id(self) -> int: + def get_file_id(self) -> int | None: return self.control_file_id diff --git a/src/sentry/models/avatars/organization_avatar.py b/src/sentry/models/avatars/organization_avatar.py index 8ee4c8275e60..995814e73296 100644 --- a/src/sentry/models/avatars/organization_avatar.py +++ b/src/sentry/models/avatars/organization_avatar.py @@ -30,7 +30,7 @@ class OrganizationAvatar(AvatarBase, ReplicatedCellModel): FILE_TYPE = "avatar.file" - file_id = BoundedBigIntegerField(unique=True, null=True) + file_id = BoundedBigIntegerField[int | None, int | None](unique=True, null=True) organization = FlexibleForeignKey("sentry.Organization", unique=True, related_name="avatar") avatar_type = models.PositiveSmallIntegerField(default=0, choices=AVATAR_TYPES) @@ -39,7 +39,7 @@ class Meta: app_label = "sentry" db_table = "sentry_organizationavatar" - def get_cache_key(self, size) -> str: + def get_cache_key(self, size: int) -> str: return f"org_avatar:{self.organization_id}:{size}" def handle_async_replication(self, shard_identifier: int) -> None: diff --git a/src/sentry/models/avatars/team_avatar.py b/src/sentry/models/avatars/team_avatar.py index e5af018bbb6b..3ee3a71bc9e6 100644 --- a/src/sentry/models/avatars/team_avatar.py +++ b/src/sentry/models/avatars/team_avatar.py @@ -25,7 +25,7 @@ class TeamAvatar(AvatarBase): FILE_TYPE = "avatar.file" - file_id = BoundedBigIntegerField(unique=True, null=True) + file_id = BoundedBigIntegerField[int | None, int | None](unique=True, null=True) team = FlexibleForeignKey("sentry.Team", unique=True, related_name="avatar") avatar_type = models.PositiveSmallIntegerField(default=0, choices=AVATAR_TYPES) @@ -35,7 +35,7 @@ class Meta: url_path = "team-avatar" - def get_cache_key(self, size): + def get_cache_key(self, size: int) -> str: return f"team_avatar:{self.team_id}:{size}" def absolute_url(self) -> str: diff --git a/src/sentry/models/code_review_event.py b/src/sentry/models/code_review_event.py index 6c2e0383710c..9a047cb5f858 100644 --- a/src/sentry/models/code_review_event.py +++ b/src/sentry/models/code_review_event.py @@ -80,7 +80,7 @@ class CodeReviewEvent(DefaultFieldsModel): # Seer callback data seer_run_id = models.CharField(max_length=64, null=True) - comments_posted = BoundedPositiveIntegerField(null=True) + comments_posted = BoundedPositiveIntegerField[int | None, int | None](null=True) review_result = models.JSONField(null=True) # raw Seer response payload class Meta: diff --git a/src/sentry/models/commit.py b/src/sentry/models/commit.py index 8ea741ade4ae..e1f14b36d2ef 100644 --- a/src/sentry/models/commit.py +++ b/src/sentry/models/commit.py @@ -73,13 +73,13 @@ def save(self, *args: Any, **kwargs: Any) -> None: super().save(*args, **kwargs) @cached_property - def title(self): + def title(self) -> str: if not self.message: return "" return self.message.splitlines()[0] @cached_property - def short_id(self): + def short_id(self) -> str: if len(self.key) == 40: return self.key[:7] return self.key diff --git a/src/sentry/models/commitfilechange.py b/src/sentry/models/commitfilechange.py index f8e1594c593f..6f6f2390e6d4 100644 --- a/src/sentry/models/commitfilechange.py +++ b/src/sentry/models/commitfilechange.py @@ -46,7 +46,7 @@ def is_valid_type(value: str) -> bool: return value in COMMIT_FILE_CHANGE_TYPES -def process_resource_change(instance, **kwargs): +def process_resource_change(instance: CommitFileChange, **kwargs: Any) -> None: from sentry.integrations.bitbucket.integration import BitbucketIntegration from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegration from sentry.integrations.github.integration import GitHubIntegration @@ -54,7 +54,7 @@ def process_resource_change(instance, **kwargs): from sentry.integrations.vsts.integration import VstsIntegration from sentry.tasks.codeowners import code_owners_auto_sync - def _spawn_task(): + def _spawn_task() -> None: filepaths = ( set(GitHubIntegration.codeowners_locations) | set(GitlabIntegration.codeowners_locations) diff --git a/src/sentry/models/counter.py b/src/sentry/models/counter.py index a2a0cbb83fdf..266af47e2a42 100644 --- a/src/sentry/models/counter.py +++ b/src/sentry/models/counter.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from django.conf import settings from django.db import connections, transaction from sentry_sdk import traces @@ -18,6 +22,9 @@ from sentry.utils.locking import UnableToAcquireLock from sentry.utils.redis import redis_clusters +if TYPE_CHECKING: + from sentry.models.project import Project + LOW_WATER_RATIO = 0.2 """ If the number of short ids in Redis is less than this ratio, we will refill the block. @@ -41,7 +48,7 @@ class Meta: db_table = "sentry_projectcounter" @classmethod - def increment(cls, project, delta=1) -> int: + def increment(cls, project: Project, delta: int = 1) -> int: """Increments a counter. This can never decrement.""" if delta == 1: # only use the cache path if delta is 1, as in other cases we're trying to resolve @@ -53,7 +60,7 @@ def increment(cls, project, delta=1) -> int: @traces.trace @metrics.wraps("counter.increment_project_counter_in_cache") -def increment_project_counter_in_cache(project, using="default") -> int: +def increment_project_counter_in_cache(project: Project, using: str = "default") -> int: redis_key = make_short_id_counter_key(project.id) redis = redis_clusters.get("default") @@ -86,7 +93,9 @@ def increment_project_counter_in_cache(project, using="default") -> int: @traces.trace @metrics.wraps("counter.increment_project_counter_in_database") -def increment_project_counter_in_database(project, delta=1, using="default") -> int: +def increment_project_counter_in_database( + project: Project, delta: int = 1, using: str = "default" +) -> int: """This method primarily exists so that south code can use it.""" if delta <= 0: raise ValueError("There is only one way, and that's up.") @@ -116,7 +125,10 @@ def increment_project_counter_in_database(project, delta=1, using="default") -> [project.id, delta, delta], ) - project_counter = cur.fetchone()[0] + row = cur.fetchone() + if row is None or not isinstance(row[0], int): + raise TypeError("Project counter returned no integer") + project_counter = row[0] if statement_timeout is not None: cur.execute( @@ -133,7 +145,9 @@ def increment_project_counter_in_database(project, delta=1, using="default") -> retry=None, # No retries since we want to try again on next counter increment silo_mode=SiloMode.CELL, ) -def refill_cached_short_ids(project_id, block_size: int, using="default", **kwargs) -> None: +def refill_cached_short_ids( + project_id: int, block_size: int, using: str = "default", **kwargs: object +) -> None: """Refills the Redis short-id counter block for a project.""" from sentry.models.project import Project diff --git a/src/sentry/models/dashboard.py b/src/sentry/models/dashboard.py index 5c845efabaa9..2230a60f417c 100644 --- a/src/sentry/models/dashboard.py +++ b/src/sentry/models/dashboard.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from collections.abc import Iterable from typing import Any, ClassVar import sentry_sdk @@ -75,7 +76,7 @@ def get_favorite_dashboard( def reorder_favorite_dashboards( self, organization: Organization, user_id: int, new_dashboard_positions: list[int] - ): + ) -> None: """ Reorders the positions of favorited dashboards for a user in an organization. Does NOT add or remove favorited dashboards. Also normalizes the positions @@ -381,11 +382,11 @@ class Dashboard(Model): ) organization = FlexibleForeignKey("sentry.Organization") date_added = models.DateTimeField(default=timezone.now) - visits = BoundedBigIntegerField(null=True, default=1) + visits = BoundedBigIntegerField[int | None, int | None](null=True, default=1) last_visited = models.DateTimeField(null=True, default=timezone.now) projects = models.ManyToManyField("sentry.Project", through=DashboardProject) filters: models.Field[dict[str, Any] | None, dict[str, Any] | None] = JSONField(null=True) - prebuilt_id = BoundedPositiveIntegerField(null=True, db_default=None) + prebuilt_id = BoundedPositiveIntegerField[int | None, int | None](null=True, db_default=None) MAX_WIDGETS = 30 @@ -423,7 +424,7 @@ class Meta: __repr__ = sane_repr("organization", "title") @property - def favorited_by(self): + def favorited_by(self) -> Iterable[int]: """ @deprecated Use the DashboardFavoriteUser object manager instead. """ @@ -433,7 +434,7 @@ def favorited_by(self): return user_ids @favorited_by.setter - def favorited_by(self, user_ids): + def favorited_by(self, user_ids: Iterable[int]) -> None: """ @deprecated Use the DashboardFavoriteUser object manager instead. """ diff --git a/src/sentry/models/dashboard_permissions.py b/src/sentry/models/dashboard_permissions.py index 21df9dad4a4b..442647cb3ec4 100644 --- a/src/sentry/models/dashboard_permissions.py +++ b/src/sentry/models/dashboard_permissions.py @@ -37,7 +37,7 @@ class DashboardPermissions(Model): "sentry.Dashboard", on_delete=models.CASCADE, related_name="permissions" ) - def has_edit_permissions(self, user_id): + def has_edit_permissions(self, user_id: int | None) -> bool: if self.is_editable_by_everyone: return True if user_id == self.dashboard.created_by_id: diff --git a/src/sentry/models/dashboard_widget.py b/src/sentry/models/dashboard_widget.py index eb45ca7cc70f..69a1f74a155f 100644 --- a/src/sentry/models/dashboard_widget.py +++ b/src/sentry/models/dashboard_widget.py @@ -27,24 +27,26 @@ class TypesClass: TYPES: list[tuple[int, str]] @classmethod - def as_choices(cls): + def as_choices(cls) -> list[tuple[int, str]]: return [(k, str(v)) for k, v in cls.TYPES] @classmethod - def as_text_choices(cls): + def as_text_choices(cls) -> list[tuple[str, str]]: return [(str(v), str(v)) for _, v in cls.TYPES] @classmethod - def get_type_name(cls, num): + def get_type_name(cls, num: int) -> str | None: for id, name in cls.TYPES: if id == num: return name + return None @classmethod - def get_id_for_type_name(cls, type_name): + def get_id_for_type_name(cls, type_name: str) -> int | None: for id, name in cls.TYPES: if type_name == name: return id + return None class DashboardWidgetTypes(TypesClass): @@ -99,7 +101,7 @@ class DashboardWidgetLegendType(str, Enum): BREAKDOWN = "breakdown" @classmethod - def as_text_choices(cls): + def as_text_choices(cls) -> list[tuple[str, str]]: return [(member.value, member.value) for member in cls] @@ -156,11 +158,11 @@ class DatasetSourcesTypes(Enum): SPAN_MIGRATION_VERSION_5 = 11 @classmethod - def as_choices(cls): + def as_choices(cls) -> tuple[tuple[int, str], ...]: return tuple((source.value, source.name.lower()) for source in cls) @classmethod - def as_text_choices(cls): + def as_text_choices(cls) -> tuple[tuple[str, int], ...]: return tuple((source.name.lower(), source.value) for source in cls) @@ -331,7 +333,7 @@ class OnDemandExtractionState(models.TextChoices): date_modified = models.DateTimeField(default=timezone.now) date_added = models.DateTimeField(default=timezone.now, db_default=Now()) - def can_extraction_be_auto_overridden(self): + def can_extraction_be_auto_overridden(self) -> bool: """Determines whether tasks can override extraction state""" if self.extraction_state == self.OnDemandExtractionState.DISABLED_MANUAL: # Manually disabling a widget will cause it to stay off until manually re-enabled. @@ -347,7 +349,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. """ @@ -369,17 +371,19 @@ class DashboardWidget(Model): __relocation_scope__ = RelocationScope.Organization dashboard = FlexibleForeignKey("sentry.Dashboard") - order = BoundedPositiveIntegerField(null=True) + order = BoundedPositiveIntegerField[int | None, int | None](null=True) title = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) thresholds = JSONField(null=True) interval = models.CharField(max_length=10, null=True) display_type = BoundedPositiveIntegerField(choices=DashboardWidgetDisplayTypes.as_choices()) date_added = models.DateTimeField(default=timezone.now) - widget_type = BoundedPositiveIntegerField(choices=DashboardWidgetTypes.as_choices(), null=True) + widget_type = BoundedPositiveIntegerField[int | None, int | None]( + choices=DashboardWidgetTypes.as_choices(), null=True + ) limit = models.IntegerField(null=True) detail: models.Field[dict[str, Any], dict[str, Any]] = JSONField(null=True) - discover_widget_split = BoundedPositiveIntegerField( + discover_widget_split = BoundedPositiveIntegerField[int | None, int | None]( choices=DashboardWidgetTypes.as_choices(), null=True ) diff --git a/src/sentry/models/debugfile.py b/src/sentry/models/debugfile.py index 97890c9cd0be..1f24e667ae0d 100644 --- a/src/sentry/models/debugfile.py +++ b/src/sentry/models/debugfile.py @@ -11,7 +11,7 @@ import tempfile import uuid import zipfile -from collections.abc import Container, Iterable, Mapping +from collections.abc import Callable, Container, Iterable, Mapping from datetime import datetime from typing import ( IO, @@ -19,6 +19,8 @@ Any, BinaryIO, ClassVar, + cast, + overload, ) from django.db import models @@ -97,6 +99,16 @@ def _dif_file_extension(file_format: str, file_type: str | None) -> str: return "" +@overload +def _normalize_code_id(code_id: None) -> None: ... +@overload +def _normalize_code_id(code_id: str) -> str: ... +def _normalize_code_id(code_id: str | None) -> str | None: + # justification: symbolic does not annotate normalize_code_id, unlike normalize_debug_id. + normalize = cast("Callable[[str | None], str | None]", normalize_code_id) + return normalize(code_id) + + def wasm_legacy_code_id(code_id: str) -> str | None: """For a 32-character debug ID, computes the "legacy" code ID by prepending `"10"`. @@ -109,7 +121,7 @@ def wasm_legacy_code_id(code_id: str) -> str | None: if len(code_id) != 32: return None try: - return normalize_code_id(f"10{code_id}") + return _normalize_code_id(f"10{code_id}") except SymbolicError: return None @@ -126,7 +138,7 @@ def wasm_legacy_debug_id(debug_id: str) -> str | None: if len(debug_id) != 36: return None try: - return normalize_debug_id(f"10{normalize_code_id(debug_id)[:-2]}") + return normalize_debug_id(f"10{_normalize_code_id(debug_id)[:-2]}") except SymbolicError: return None @@ -207,7 +219,7 @@ class ProjectDebugFile(Model): checksum = models.CharField(max_length=40, null=True, db_index=True) object_name = models.TextField() cpu_name = models.CharField(max_length=40) - project_id = BoundedBigIntegerField(null=True, db_index=True) + project_id = BoundedBigIntegerField[int | None, int | None](null=True, db_index=True) debug_id = models.CharField(max_length=64, db_column="uuid") code_id = models.CharField(max_length=64, null=True) data = LegacyTextJSONField(default=dict, null=True) @@ -224,7 +236,7 @@ class ProjectDebugFile(Model): # Mirrors `file.headers["Content-Type"]` for files stored in Objectstore. content_type = models.TextField(null=True) # Mirrors `file.size` for files stored in Objectstore. - file_size = BoundedBigIntegerField(null=True) + file_size = BoundedBigIntegerField[int | None, int | None](null=True) # Mirrors `file.timestamp` for files stored in Objectstore. date_created = models.DateTimeField(null=True) @@ -269,7 +281,8 @@ def get_content_type(self) -> str: assert self.content_type is not None return str(self.content_type) if self.file is not None: - return self.file.headers.get("Content-Type", "unknown") + content_type = self.file.headers.get("Content-Type", "unknown") + return content_type if isinstance(content_type, str) else "unknown" raise ValueError("ProjectDebugFile has neither file nor storage_path") def get_file_size(self) -> int: @@ -277,6 +290,7 @@ def get_file_size(self) -> int: assert self.file_size is not None return int(self.file_size) if self.file is not None: + assert self.file.size is not None return self.file.size raise ValueError("ProjectDebugFile has neither file nor storage_path") @@ -1095,7 +1109,10 @@ def create_files_from_dif_zip( class DIFCache: @property def cache_path(self) -> str: - return options.get("dsym.cache-path") + cache_path = options.get("dsym.cache-path") + if not isinstance(cache_path, str): + raise TypeError("dsym.cache-path must be a string") + return cache_path def get_project_path(self, project: Project) -> str: return os.path.join(self.cache_path, str(project.id)) diff --git a/src/sentry/models/deletedentry.py b/src/sentry/models/deletedentry.py index 41e3fed96cfa..2d3d9d6326b0 100644 --- a/src/sentry/models/deletedentry.py +++ b/src/sentry/models/deletedentry.py @@ -10,7 +10,7 @@ class DeletedEntry(Model): actor_label = models.CharField(max_length=64, null=True) # if the entry was created via a user - actor_id = BoundedBigIntegerField(null=True) + actor_id = BoundedBigIntegerField[int | None, int | None](null=True) # if the entry was created via an api key actor_key = models.CharField(max_length=32, null=True) diff --git a/src/sentry/models/deletedproject.py b/src/sentry/models/deletedproject.py index b3514ad92202..5ce551633930 100644 --- a/src/sentry/models/deletedproject.py +++ b/src/sentry/models/deletedproject.py @@ -19,7 +19,7 @@ class DeletedProject(DeletedEntry): slug = models.CharField(max_length=PROJECT_SLUG_MAX_LENGTH, null=True) name = models.CharField(max_length=200, null=True) - organization_id = BoundedBigIntegerField(null=True) + organization_id = BoundedBigIntegerField[int | None, int | None](null=True) organization_name = models.CharField(max_length=64, null=True) organization_slug = models.CharField(max_length=50, null=True) diff --git a/src/sentry/models/deletedteam.py b/src/sentry/models/deletedteam.py index ae780d243c27..c9736396b952 100644 --- a/src/sentry/models/deletedteam.py +++ b/src/sentry/models/deletedteam.py @@ -18,7 +18,7 @@ class DeletedTeam(DeletedEntry): name = models.CharField(max_length=64, null=True) slug = models.CharField(max_length=50, null=True) - organization_id = BoundedBigIntegerField(null=True) + organization_id = BoundedBigIntegerField[int | None, int | None](null=True) organization_name = models.CharField(max_length=64, null=True) organization_slug = models.CharField(max_length=50, null=True) diff --git a/src/sentry/models/deploy.py b/src/sentry/models/deploy.py index aa2897c9bbae..ff2080fe89c2 100644 --- a/src/sentry/models/deploy.py +++ b/src/sentry/models/deploy.py @@ -33,11 +33,11 @@ class Meta: db_table = "sentry_deploy" @staticmethod - def get_lock_key(deploy_id): + def get_lock_key(deploy_id: int) -> str: return "deploy-notify:%s" % deploy_id @classmethod - def notify_if_ready(cls, deploy_id, fetch_complete=False): + def notify_if_ready(cls, deploy_id: int, fetch_complete: bool = False) -> None: """ create activity and send deploy notifications if they haven't been sent diff --git a/src/sentry/models/environment.py b/src/sentry/models/environment.py index e8e9a20e67a3..1ebcf6cfc779 100644 --- a/src/sentry/models/environment.py +++ b/src/sentry/models/environment.py @@ -1,5 +1,5 @@ import re -from typing import ClassVar, Self +from typing import TYPE_CHECKING, ClassVar, Self, cast from urllib.parse import unquote from django.db import models @@ -22,6 +22,9 @@ OK_NAME_PATTERN = re.compile(ENVIRONMENT_NAME_PATTERN) +if TYPE_CHECKING: + from sentry.models.project import Project + @cell_silo_model class EnvironmentProject(Model): @@ -56,7 +59,7 @@ class Meta: __repr__ = sane_repr("organization_id", "name") @classmethod - def is_valid_name(cls, value): + def is_valid_name(cls, value: str) -> bool: """Limit length and reject problematic bytes If you change the rules here also update the event + monitor check-in ingestion schema in Relay. @@ -66,17 +69,17 @@ def is_valid_name(cls, value): return OK_NAME_PATTERN.match(value) is not None @classmethod - def get_cache_key(cls, organization_id, name) -> str: + def get_cache_key(cls, organization_id: int, name: str) -> str: return f"env:2:{organization_id}:{md5_text(name).hexdigest()}" @classmethod - def get_name_or_default(cls, name): + def get_name_or_default(cls, name: str | None) -> str: if name: return name[:ENVIRONMENT_NAME_MAX_LENGTH] return "" @classmethod - def get_for_organization_id(cls, organization_id, name): + def get_for_organization_id(cls, organization_id: int, name: str | None) -> Self: name = cls.get_name_or_default(name) cache_key = cls.get_cache_key(organization_id, name) @@ -86,10 +89,17 @@ def get_for_organization_id(cls, organization_id, name): env = cls.objects.get(name=name, organization_id=organization_id) cache.set(cache_key, env, 3600) - return env + # justification: the cache only stores Environment instances under this key. + return cast("Self", env) @classmethod - def get_or_create(cls, project, name, metrics_tags=None, project_metrics_tags=None): + def get_or_create( + cls, + project: "Project", + name: str | None, + metrics_tags: dict[str, str] | None = None, + project_metrics_tags: dict[str, str] | None = None, + ) -> Self: with metrics.timer("models.environment.get_or_create") as timer_tags: name = cls.get_name_or_default(name) @@ -117,9 +127,15 @@ def get_or_create(cls, project, name, metrics_tags=None, project_metrics_tags=No # separately as the `environmentproject` model. env.add_project(project, metrics_tags=project_metrics_tags) - return env + # justification: the cache only stores Environment instances under this key. + return cast("Self", env) - def add_project(self, project, is_hidden=None, metrics_tags=None): + def add_project( + self, + project: "Project", + is_hidden: bool | None = None, + metrics_tags: dict[str, str] | None = None, + ) -> None: cache_key = f"envproj:c:{self.id}:{project.id}" if cache.get(cache_key) is None: @@ -138,7 +154,7 @@ def add_project(self, project, is_hidden=None, metrics_tags=None): metrics_tags[DATA_ACCESS_TAG] = DataAccessTagValues.CACHE_HIT.value @staticmethod - def get_name_from_path_segment(segment): + def get_name_from_path_segment(segment: str) -> str: # In cases where the environment name is passed as a URL path segment, # the (case-sensitive) string "none" represents the empty string # environment name for historic reasons (see commit b09858f.) In all diff --git a/src/sentry/models/eventattachment.py b/src/sentry/models/eventattachment.py index 03086af4cf83..c20ddc1ab0e1 100644 --- a/src/sentry/models/eventattachment.py +++ b/src/sentry/models/eventattachment.py @@ -110,7 +110,7 @@ class EventAttachmentBase(Model): # attachment and file metadata: name = models.TextField() content_type = models.TextField(null=True) - size = BoundedIntegerField(null=True) + size = BoundedIntegerField[int | None, int | None](null=True) sha1 = models.CharField(max_length=40, null=True) date_expires = models.DateTimeField( @@ -202,7 +202,7 @@ class EventAttachment(EventAttachmentBase): An attachment belonging to an event that has been ingested. """ - group_id = BoundedBigIntegerField(null=True, db_index=True) + group_id = BoundedBigIntegerField[int | None, int | None](null=True, db_index=True) event_id = models.CharField(max_length=32, db_index=True) type = models.CharField(max_length=64, db_index=True) date_added = models.DateTimeField(default=timezone.now, db_index=True) @@ -284,7 +284,7 @@ def get_blob_stream(self, accept_encoding: list[str]) -> BlobStream: @classmethod def putfile(cls, project_id: int, attachment: CachedAttachment) -> PutfileResult: - content_type = normalize_content_type(attachment.content_type, attachment.name) + content_type = normalize_content_type(attachment.content_type, attachment.name or "") if attachment.size == 0: return PutfileResult(content_type=content_type, size=0, sha1=sha1().hexdigest()) if attachment.stored_id is not None: @@ -402,7 +402,7 @@ def track_dropped_outcome(self) -> None: # left to report the drop to. return - kwargs = dict( + track_outcome( org_id=organization_id, project_id=self.project_id, key_id=None, # DSN is unknown at this point @@ -410,15 +410,17 @@ def track_dropped_outcome(self) -> None: reason="missing_event", timestamp=self.date_added, # matches accepted outcome event_id=self.event_id, - ) - - track_outcome( - **kwargs, category=DataCategory.ATTACHMENT, quantity=self.size or 1, ) track_outcome( - **kwargs, + org_id=organization_id, + project_id=self.project_id, + key_id=None, + outcome=Outcome.INVALID, + reason="missing_event", + timestamp=self.date_added, + event_id=self.event_id, category=DataCategory.ATTACHMENT_ITEM, quantity=1, ) diff --git a/src/sentry/models/eventerror.py b/src/sentry/models/eventerror.py index 16c6fde848d7..2f30e05f6ca5 100644 --- a/src/sentry/models/eventerror.py +++ b/src/sentry/models/eventerror.py @@ -67,6 +67,10 @@ class EventErrorApiContext(TypedDict): data: dict[str, Any] +# justification: event error payloads carry arbitrary SDK-supplied values. +ErrorData = dict[str, Any] + + class EventError: _messages: dict[str, str] = { EventErrorType.UNKNOWN_ERROR: "Unknown error", @@ -114,22 +118,25 @@ class EventError: } @classmethod - def get_message(cls, data): + def get_message(cls, data: ErrorData) -> str: return cls(data).message - def __init__(self, data): + def __init__(self, data: ErrorData) -> None: self._data = data @property - def type(self): - return self._data["type"] + def type(self) -> str: + error_type = self._data["type"] + if not isinstance(error_type, str): + raise TypeError("Event error type is not a string") + return error_type @property - def data(self): + def data(self) -> ErrorData: return {k: v for k, v in self._data.items() if k != "type"} @property - def message(self): + def message(self) -> str: return self._messages.get(self._data["type"], self._messages["unknown_error"]) def get_api_context(self) -> EventErrorApiContext: diff --git a/src/sentry/models/featureadoption.py b/src/sentry/models/featureadoption.py index e379cdc3f6e8..0802d2158cd4 100644 --- a/src/sentry/models/featureadoption.py +++ b/src/sentry/models/featureadoption.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import logging +from collections.abc import Iterable from datetime import timedelta -from typing import ClassVar, cast +from typing import TYPE_CHECKING, ClassVar, cast import rb from django.conf import settings @@ -14,6 +17,10 @@ from sentry.db.models import FlexibleForeignKey, Model, cell_silo_model, sane_repr from sentry.db.models.fields.jsonfield import LegacyTextJSONField from sentry.db.models.manager.base import BaseManager + +if TYPE_CHECKING: + from sentry.models.organization import Organization + from sentry.utils.redis import ( get_dynamic_cluster_from_options, is_instance_rb_cluster, @@ -123,7 +130,7 @@ class FeatureAdoptionRedisBackend: - def __init__(self, key_tpl=FEATURE_ADOPTION_REDIS_KEY, **options): + def __init__(self, key_tpl: str = FEATURE_ADOPTION_REDIS_KEY, **options: object) -> None: self.key_tpl = key_tpl self.is_redis_cluster, self.cluster, _config = get_dynamic_cluster_from_options( "SENTRY_FEATURE_ADOPTION_CACHE_OPTIONS", options @@ -138,15 +145,15 @@ def get_client(self, key: str) -> rb.Cluster | RedisCluster: else: raise AssertionError("unreachable") - def in_cache(self, organization_id, feature_id): + def in_cache(self, organization_id: int, feature_id: int) -> bool: org_key = self.key_tpl.format(organization_id) - return self.get_client(org_key).sismember(org_key, feature_id) + return bool(self.get_client(org_key).sismember(org_key, feature_id)) - def get_all_cache(self, organization_id): + def get_all_cache(self, organization_id: int) -> set[int]: org_key = self.key_tpl.format(organization_id) return {int(v) for v in self.get_client(org_key).smembers(org_key)} - def bulk_set_cache(self, organization_id, *args): + def bulk_set_cache(self, organization_id: int, *args: int) -> bool: if not args: return False @@ -164,19 +171,19 @@ class FeatureAdoptionManager(BaseManager["FeatureAdoption"]): build_instance_from_options(settings.SENTRY_FEATURE_ADOPTION_CACHE_OPTIONS), ) - def in_cache(self, organization_id, feature_id): + def in_cache(self, organization_id: int, feature_id: int) -> bool: return self.cache_backend.in_cache(organization_id, feature_id) - def set_cache(self, organization_id, feature_id): + def set_cache(self, organization_id: int, feature_id: int) -> bool: return self.bulk_set_cache(organization_id, feature_id) - def get_all_cache(self, organization_id): + def get_all_cache(self, organization_id: int) -> set[int]: return self.cache_backend.get_all_cache(organization_id) - def bulk_set_cache(self, organization_id, *args): + def bulk_set_cache(self, organization_id: int, *args: int) -> bool: return self.cache_backend.bulk_set_cache(organization_id, *args) - def record(self, organization_id, feature_slug, **kwargs): + def record(self, organization_id: int, feature_slug: str, **kwargs: object) -> bool: try: feature_id = manager.get_by_slug(feature_slug).id except UnknownFeature as e: @@ -194,7 +201,9 @@ def record(self, organization_id, feature_slug, **kwargs): return False - def bulk_record(self, organization_id, feature_slugs, **kwargs): + def bulk_record( + self, organization_id: int, feature_slugs: Iterable[str], **kwargs: object + ) -> bool: features = [] try: @@ -224,7 +233,7 @@ def bulk_record(self, organization_id, feature_slugs, **kwargs): return self.bulk_set_cache(organization_id, *incomplete_feature_ids) - def get_by_slug(self, organization, slug): + def get_by_slug(self, organization: Organization, slug: str) -> FeatureAdoption | None: return self.filter( organization=organization, feature_id=manager.get_by_slug(slug).id ).first() diff --git a/src/sentry/models/files/abstractfile.py b/src/sentry/models/files/abstractfile.py index e6e2931cfaa8..897ee95271ea 100644 --- a/src/sentry/models/files/abstractfile.py +++ b/src/sentry/models/files/abstractfile.py @@ -6,9 +6,10 @@ import mmap import os import tempfile -from collections.abc import Sequence +from collections.abc import Callable, Iterable, Sequence from hashlib import sha1 -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from types import TracebackType +from typing import IO, TYPE_CHECKING, Any, Generic, Protocol, TypeVar, cast from django.core.files.base import ContentFile from django.core.files.base import File as FileObj @@ -27,14 +28,35 @@ from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor logger = logging.getLogger(__name__) +LoggerType = logging.Logger | type[nooplogger] + + +class _Blob(Protocol): + size: int | None + + def getfile(self) -> FileObj[bytes]: ... + + +class _BlobIndex(Protocol): + offset: int + blob: _Blob class ChunkedFileBlobIndexWrapper: - def __init__(self, indexes, mode=None, prefetch=False, prefetch_to=None, delete=True): + def __init__( + self, + indexes: Iterable[AbstractFileBlobIndex], + mode: str | None = None, + prefetch: bool = False, + prefetch_to: str | None = None, + delete: bool = True, + ) -> None: # eager load from database incase its a queryset - self._indexes = list(indexes) - self._curfile = None - self._curidx = None + self._indexes: list[Any] = list( + indexes + ) # justification: concrete index subclasses provide the blob relation. + self._curfile: FileObj[bytes] | tempfile._TemporaryFileWrapper[bytes] | None = None + self._curidx: _BlobIndex | None = None if prefetch: self.prefetched = True self._prefetch(prefetch_to, delete) @@ -43,17 +65,22 @@ def __init__(self, indexes, mode=None, prefetch=False, prefetch_to=None, delete= self.mode = mode self.open() - def __enter__(self): + def __enter__(self) -> ChunkedFileBlobIndexWrapper: return self - def __exit__(self, exc_type, exc_value, tb) -> None: + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> 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) @@ -74,14 +101,14 @@ def _nextidx(self) -> None: old_file.close() @property - def size(self): + def size(self) -> int: return sum(i.blob.size for i in self._indexes) def open(self) -> None: self.closed = False self.seek(0) - def _prefetch(self, prefetch_to=None, delete=True): + def _prefetch(self, prefetch_to: str | None = None, delete: bool = True) -> None: size = self.size f = tempfile.NamedTemporaryFile(prefix="._prefetch-", dir=prefetch_to, delete=delete) if size == 0: @@ -95,7 +122,7 @@ def _prefetch(self, prefetch_to=None, delete=True): mem = mmap.mmap(f.fileno(), size) - def fetch_file(offset, getfile) -> None: + def fetch_file(offset: int, getfile: Callable[[], IO[bytes]]) -> None: with getfile() as sf: while True: chunk = sf.read(65535) @@ -118,7 +145,7 @@ def close(self) -> None: self._curidx = None self.closed = True - def _seek(self, pos): + def _seek(self, pos: int) -> int | None: if self.closed: raise ValueError("I/O operation on closed file") @@ -130,7 +157,7 @@ def _seek(self, pos): raise OSError("Invalid argument") if pos == 0 and not self._indexes: # Empty file, there's no seeking to be done. - return + return None for n, idx in enumerate(self._indexes[::-1]): if idx.offset <= pos: @@ -143,8 +170,9 @@ def _seek(self, pos): assert self._curfile is not None assert self._curidx is not None self._curfile.seek(pos - self._curidx.offset) + return None - def seek(self, pos, whence=io.SEEK_SET): + def seek(self, pos: int, whence: int = io.SEEK_SET) -> int | None: if whence == io.SEEK_SET: return self._seek(pos) if whence == io.SEEK_CUR: @@ -154,7 +182,7 @@ def seek(self, pos, whence=io.SEEK_SET): raise ValueError(f"Invalid value for whence: {whence}") - def tell(self): + def tell(self) -> int: if self.closed: raise ValueError("I/O operation on closed file") if self.prefetched: @@ -166,7 +194,7 @@ def tell(self): assert self._curfile is not None return self._curidx.offset + self._curfile.tell() - def read(self, n=-1): + def read(self, n: int = -1) -> bytes: if self.closed: raise ValueError("I/O operation on closed file") @@ -199,7 +227,7 @@ def read(self, n=-1): BlobIndexType = TypeVar("BlobIndexType", bound=AbstractFileBlobIndex) -BlobType = TypeVar("BlobType", bound=AbstractFileBlob) +BlobType = TypeVar("BlobType", bound="AbstractFileBlob[Any]") if TYPE_CHECKING: # Django doesn't permit models to have parent classes that are Generic @@ -221,13 +249,15 @@ class AbstractFile(Model, _Parent[BlobIndexType, BlobType]): type = models.CharField(max_length=64) timestamp = models.DateTimeField(default=timezone.now, db_index=True) headers = LegacyTextJSONField(default=dict) - size = WrappingU32IntegerField(null=True) + size = WrappingU32IntegerField[int | None, int | None](null=True) checksum = models.CharField(max_length=40, null=True, db_index=True) class Meta: abstract = True - blobs: models.ManyToManyField + blobs: models.ManyToManyField[ + Any, Any + ] # justification: concrete subclasses narrow to their blob and through models. @abc.abstractmethod def _blob_index_records(self) -> Sequence[BlobIndexType]: ... @@ -236,7 +266,7 @@ def _blob_index_records(self) -> Sequence[BlobIndexType]: ... def _create_blob_index(self, blob: BlobType, offset: int) -> BlobIndexType: ... @abc.abstractmethod - def _create_blob_from_file(self, contents: ContentFile, logger: Any) -> BlobType: ... + def _create_blob_from_file(self, contents: ContentFile[bytes], logger: Any) -> BlobType: ... @abc.abstractmethod def _get_blobs_by_id(self, blob_ids: Sequence[int]) -> models.QuerySet[BlobType]: ... @@ -244,7 +274,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,16 +290,16 @@ 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: + def save_to(self, path: str) -> None: """Fetches the file and emplaces it at a certain location. The write is done atomically to a tempfile first and then moved over. If the directory does not exist it is created. @@ -299,7 +335,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. @@ -329,7 +371,9 @@ def putfile(self, fileobj, blob_size=DEFAULT_BLOB_SIZE, commit=True, logger=noop return results @traces.trace - def assemble_from_file_blob_ids(self, file_blob_ids, checksum): + def assemble_from_file_blob_ids( + self, file_blob_ids: Sequence[int], checksum: str | None + ) -> tempfile._TemporaryFileWrapper[bytes]: """ This creates a file, from file blobs and returns a temp file with the contents. @@ -383,7 +427,7 @@ def assemble_from_file_blob_ids(self, file_blob_ids, checksum): return tf @traces.trace - def delete(self, *args, **kwargs): + def delete(self, *args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]: blob_ids = [blob.id for blob in self.blobs.all()] ret = super().delete(*args, **kwargs) diff --git a/src/sentry/models/files/abstractfileblob.py b/src/sentry/models/files/abstractfileblob.py index bc1c9c31751a..ffbcc20e24ca 100644 --- a/src/sentry/models/files/abstractfileblob.py +++ b/src/sentry/models/files/abstractfileblob.py @@ -1,10 +1,13 @@ from __future__ import annotations +import logging from abc import abstractmethod +from collections.abc import Iterable from threading import Semaphore -from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar +from typing import IO, TYPE_CHECKING, Any, Generic, Self, TypeVar, cast from uuid import uuid4 +from django.core.files.base import File as FileObj from django.db import IntegrityError, models, router, transaction from django.utils import timezone from sentry_sdk import traces @@ -23,8 +26,13 @@ MULTI_BLOB_UPLOAD_CONCURRENCY = 8 +LoggerType = logging.Logger | type[nooplogger] + BlobOwnerType = TypeVar("BlobOwnerType", bound=AbstractFileBlobOwner) +if TYPE_CHECKING: + from sentry.models.organization import Organization + if TYPE_CHECKING: # Django doesn't permit models to have parent classes that are Generic @@ -43,7 +51,7 @@ class AbstractFileBlob(Model, _Parent[BlobOwnerType]): __relocation_scope__ = RelocationScope.Excluded path = models.TextField(null=True) - size = WrappingU32IntegerField(null=True) + size = WrappingU32IntegerField[int | None, int | None](null=True) checksum = models.CharField(max_length=40, unique=True) timestamp = models.DateTimeField(default=timezone.now, db_index=True) @@ -63,7 +71,12 @@ def _storage_config(cls) -> dict[str, Any] | None: @classmethod @traces.trace - def from_files(cls, files, organization=None, logger=nooplogger) -> None: + def from_files( + cls, + files: Iterable[IO[bytes] | tuple[IO[bytes], str | None]], + organization: Organization | None = None, + logger: LoggerType = nooplogger, + ) -> None: """A faster version of `from_file` for multiple files at the time. If an organization is provided it will also create `FileBlobOwner` entries. Files can be a list of files or tuples of file and checksum. @@ -84,7 +97,7 @@ def from_files(cls, files, organization=None, logger=nooplogger) -> None: blobs_to_save = [] semaphore = Semaphore(value=MULTI_BLOB_UPLOAD_CONCURRENCY) - def _upload_and_pend_chunk(fileobj, size, checksum) -> None: + def _upload_and_pend_chunk(fileobj: IO[bytes], size: int, checksum: str) -> None: logger.debug( "FileBlob.from_files._upload_and_pend_chunk.start", extra={"checksum": checksum, "size": size}, @@ -117,7 +130,7 @@ def _save_blob(blob: Self) -> None: saved_path = blob.path blob = cls.objects.get(checksum=blob.checksum) storage = get_storage(cls._storage_config()) - storage.delete(saved_path) + storage.delete(cast(str, saved_path)) blob._ensure_blob_owned(organization) logger.debug("FileBlob.from_files._save_blob.end", extra={"path": blob.path}) @@ -174,7 +187,12 @@ def _flush_blobs() -> None: @classmethod @traces.trace - def from_file_with_organization(cls, fileobj, organization=None, logger=nooplogger) -> Self: + def from_file_with_organization( + cls, + fileobj: IO[bytes], + organization: Organization | None = None, + logger: LoggerType = nooplogger, + ) -> Self: """ Retrieve a single FileBlob instances for the given file and binds it to an organization via the FileBlobOwner. """ @@ -185,7 +203,7 @@ def from_file_with_organization(cls, fileobj, organization=None, logger=nooplogg @classmethod @traces.trace - def from_file(cls, fileobj, logger=nooplogger) -> Self: + def from_file(cls, fileobj: IO[bytes], logger: LoggerType = nooplogger) -> Self: """ Retrieve a single FileBlob instances for the given file. """ @@ -215,7 +233,7 @@ def from_file(cls, fileobj, logger=nooplogger) -> Self: return blob @classmethod - def generate_unique_path(cls): + def generate_unique_path(cls) -> str: # We intentionally do not use checksums as path names to avoid concurrency issues # when we attempt concurrent uploads for any reason. uuid_hex = uuid4().hex @@ -223,7 +241,7 @@ def generate_unique_path(cls): return "/".join(pieces) @traces.trace - def delete(self, *args, **kwargs): + def delete(self, *args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]: if self.path: transaction.on_commit( lambda: self._delete_file_task().delay(path=self.path, checksum=self.checksum), @@ -231,7 +249,7 @@ def delete(self, *args, **kwargs): ) return super().delete(*args, **kwargs) - def getfile(self): + def getfile(self) -> FileObj[bytes]: """ Return a file-like object for this File's content. @@ -244,7 +262,7 @@ def getfile(self): storage = get_storage(self._storage_config()) return storage.open(self.path) - def _ensure_blob_owned(self, organization): + def _ensure_blob_owned(self, organization: Organization | None) -> None: """ Ensures that the FileBlob is owned by the given organization. """ diff --git a/src/sentry/models/files/control_file.py b/src/sentry/models/files/control_file.py index 04ee22d2492c..0d0349041dd5 100644 --- a/src/sentry/models/files/control_file.py +++ b/src/sentry/models/files/control_file.py @@ -34,7 +34,9 @@ def _blob_index_records(self) -> Sequence[ControlFileBlobIndex]: def _create_blob_index(self, blob: ControlFileBlob, offset: int) -> ControlFileBlobIndex: return ControlFileBlobIndex.objects.create(file=self, blob=blob, offset=offset) - def _create_blob_from_file(self, contents: ContentFile, logger: Any) -> ControlFileBlob: + def _create_blob_from_file( + self, contents: "ContentFile[bytes]", logger: Any + ) -> ControlFileBlob: return ControlFileBlob.from_file(contents, logger) def _get_blobs_by_id(self, blob_ids: Sequence[int]) -> models.QuerySet[ControlFileBlob]: diff --git a/src/sentry/models/files/file.py b/src/sentry/models/files/file.py index 27925a2733df..2b57d34f7f9b 100644 --- a/src/sentry/models/files/file.py +++ b/src/sentry/models/files/file.py @@ -36,7 +36,7 @@ def _blob_index_records(self) -> Sequence[FileBlobIndex]: def _create_blob_index(self, blob: FileBlob, offset: int) -> FileBlobIndex: return FileBlobIndex.objects.create(file=self, blob=blob, offset=offset) - def _create_blob_from_file(self, contents: ContentFile, logger: Any) -> FileBlob: + def _create_blob_from_file(self, contents: "ContentFile[bytes]", logger: Any) -> FileBlob: return FileBlob.from_file(contents, logger) def _get_blobs_by_id(self, blob_ids: Sequence[int]) -> models.QuerySet[FileBlob]: diff --git a/src/sentry/models/files/utils.py b/src/sentry/models/files/utils.py index 5cdfcae24567..75d626421367 100644 --- a/src/sentry/models/files/utils.py +++ b/src/sentry/models/files/utils.py @@ -4,7 +4,7 @@ import time from datetime import timedelta from hashlib import sha1 -from typing import IO, TYPE_CHECKING, TypeVar +from typing import IO, TYPE_CHECKING, Any, TypeVar, cast from django.conf import settings from django.core.files.storage import Storage @@ -15,7 +15,7 @@ if TYPE_CHECKING: from sentry.models.files.abstractfileblob import AbstractFileBlob -FileModelT = TypeVar("FileModelT", bound="AbstractFileBlob") +FileModelT = TypeVar("FileModelT", bound="AbstractFileBlob[Any]") ONE_DAY = 60 * 60 * 24 ONE_DAY_AND_A_HALF = int(ONE_DAY * 1.5) @@ -74,7 +74,9 @@ class AssembleChecksumMismatch(Exception): pass -def get_storage(config=None): +def get_storage( + config: dict[str, Any] | None = None, +) -> Storage: # justification: storage backend options are dynamic. if config is not None: backend = config["backend"] options = config["options"] @@ -87,11 +89,12 @@ def get_storage(config=None): except KeyError: pass - storage = import_string(backend) + # justification: the backend setting names a Storage subclass. + storage = cast("type[Storage]", import_string(backend)) return storage(**options) -def get_relocation_storage(config=None) -> Storage: +def get_relocation_storage(config: dict[str, Any] | None = None) -> Storage: backend = settings.SENTRY_RELOCATION_FILE_STORAGE_BACKEND relocation = settings.SENTRY_RELOCATION_FILE_STORAGE_CONFIG @@ -100,11 +103,12 @@ def get_relocation_storage(config=None) -> Storage: except KeyError: pass - storage = import_string(backend) + # justification: the backend setting names a Storage subclass. + storage = cast("type[Storage]", import_string(backend)) return storage(**relocation) -def get_profiles_storage(config=None) -> Storage: +def get_profiles_storage(config: dict[str, Any] | None = None) -> Storage: backend = settings.SENTRY_PROFILES_FILE_STORAGE_BACKEND profiles = settings.SENTRY_PROFILES_FILE_STORAGE_CONFIG @@ -113,11 +117,12 @@ def get_profiles_storage(config=None) -> Storage: except KeyError: pass - storage = import_string(backend) + # justification: the backend setting names a Storage subclass. + storage = cast("type[Storage]", import_string(backend)) return storage(**profiles) -def clear_cached_files(cache_path): +def clear_cached_files(cache_path: str) -> None: try: cache_folders = os.listdir(cache_path) except OSError: diff --git a/src/sentry/models/group.py b/src/sentry/models/group.py index f7b191e6bcf8..1bab4330bfca 100644 --- a/src/sentry/models/group.py +++ b/src/sentry/models/group.py @@ -9,11 +9,12 @@ from enum import Enum from functools import reduce from operator import or_ -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, cast from django.core.cache import cache from django.db import models from django.db.models import Q, QuerySet +from django.db.models.base import ModelBase from django.db.models.signals import pre_save from django.dispatch import receiver from django.utils import timezone @@ -43,7 +44,7 @@ from sentry.db.models.fields.jsonfield import LegacyTextJSONField from sentry.db.models.manager.base import BaseManager from sentry.db.models.manager.base_query_set import BaseQuerySet -from sentry.issues.grouptype import GroupCategory, get_group_type_by_type_id +from sentry.issues.grouptype import GroupCategory, GroupType, get_group_type_by_type_id from sentry.issues.priority import ( PRIORITY_TO_GROUP_HISTORY_STATUS, PriorityChangeReason, @@ -74,7 +75,9 @@ from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration import RpcIntegration from sentry.models.environment import Environment + from sentry.models.project import Project from sentry.models.team import Team + from sentry.search.events.types import SnubaParams from sentry.users.services.user import RpcUser logger = logging.getLogger(__name__) @@ -103,7 +106,7 @@ def parse_short_id(short_id_s: str) -> ShortId | None: return ShortId(slug, short_id) -def looks_like_short_id(value): +def looks_like_short_id(value: str | None) -> bool: return _short_id_re.match((value or "").strip()) is not None @@ -183,6 +186,9 @@ def get_group_with_redirect( # TODO(dcramer): pull in enum library +GroupTypeClass = type[GroupType] + + class GroupStatus: UNRESOLVED = 0 RESOLVED = 1 @@ -450,7 +456,7 @@ def get_recommended_event( def _get_replay_id_from_event(event: Event) -> str | None: replay_id = get_path(event.data, "contexts", "replay", "replay_id") - if replay_id: + if isinstance(replay_id, str) and replay_id: return replay_id return event.get_tag("replayId") @@ -526,7 +532,7 @@ def _select_event_with_existing_replay( class GroupManager(BaseManager["Group"]): use_for_related_fields = True - def get_queryset(self): + def get_queryset(self) -> BaseQuerySet[Group, Group]: return super().get_queryset().with_post_update_signal(True) def by_qualified_short_id( @@ -535,7 +541,7 @@ def by_qualified_short_id( short_id: str, *, project_ids: Collection[int] | None, - ): + ) -> Group: return self.by_qualified_short_id_bulk( organization_id, [short_id], project_ids=project_ids )[0] @@ -632,7 +638,7 @@ def by_qualified_short_id_bulk( raise Group.DoesNotExist() return groups - def from_event_id(self, project, event_id): + def from_event_id(self, project: Project, event_id: str) -> Group: """Resolves the 32 character event_id string into a Group for which it is found.""" group_id = None @@ -649,7 +655,12 @@ def from_event_id(self, project, event_id): return self.get(id=group_id) - def filter_by_event_id(self, project_ids, event_id, tenant_ids=None): + def filter_by_event_id( + self, + project_ids: Sequence[int], + event_id: str, + tenant_ids: dict[str, int | str] | None = None, + ) -> QuerySet[Group]: events = eventstore.backend.get_events( filter=eventstore.Filter( event_ids=[event_id], @@ -799,7 +810,7 @@ def from_share_id(self, share_id: str) -> Group: return self.get(id__in=GroupShare.objects.filter(uuid=share_id).values_list("group_id")[:1]) - def filter_to_team(self, team): + def filter_to_team(self, team: Team) -> QuerySet[Group]: from sentry.models.groupassignee import GroupAssignee from sentry.models.project import Project @@ -853,7 +864,7 @@ class Group(Model): culprit = models.CharField( max_length=MAX_CULPRIT_LENGTH, blank=True, null=True, db_column="view" ) - num_comments = BoundedPositiveIntegerField(default=0, null=True) + num_comments = BoundedPositiveIntegerField[int | None, int | None](default=0, null=True) platform = models.CharField(max_length=64, null=True) status = BoundedPositiveIntegerField( default=GroupStatus.UNRESOLVED, @@ -864,7 +875,7 @@ class Group(Model): ), db_index=True, ) - substatus = BoundedIntegerField( + substatus = BoundedIntegerField[int | None, int | None]( null=True, choices=( (GroupSubStatus.UNTIL_ESCALATING, _("Until escalating")), @@ -886,7 +897,7 @@ class Group(Model): # deprecated, do not use. GroupShare has superseded is_public = models.BooleanField(default=False, null=True) data = LegacyTextJSONField(null=True) - short_id = BoundedBigIntegerField(null=True) + short_id = BoundedBigIntegerField[int | None, int | None](null=True) type = BoundedPositiveIntegerField( default=DEFAULT_TYPE_ID, db_default=DEFAULT_TYPE_ID, db_index=True ) @@ -924,7 +935,13 @@ class Meta: def __str__(self) -> str: return f"({self.times_seen}) {self.title}" - 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 not self.last_seen: self.last_seen = timezone.now() if not self.first_seen: @@ -935,9 +952,14 @@ def save(self, *args, **kwargs): self.message = strip(self.message) if self.message: self.message = truncatechars(self.message.splitlines()[0], 255) - if self.times_seen is None: + if cast("int | None", self.times_seen) is None: self.times_seen = 1 - super().save(*args, **kwargs) + super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) def get_absolute_url( self, @@ -966,26 +988,27 @@ def get_absolute_url( query = urlencode(params) return organization.absolute_url(path, query=query) - def get_absolute_api_url(self): + def get_absolute_api_url(self) -> str: path = f"/issues/{self.id}/" return self.organization.absolute_api_url(path=path) @property - def qualified_short_id(self): + def qualified_short_id(self) -> str | None: if self.short_id is not None: return f"{self.project.slug.upper()}-{base32_encode(self.short_id)}" + return None - def is_ignored(self): + def is_ignored(self) -> bool: return self.get_status() == GroupStatus.IGNORED - def is_unresolved(self): + def is_unresolved(self) -> bool: return self.get_status() == GroupStatus.UNRESOLVED - def is_resolved(self): + def is_resolved(self) -> bool: return self.get_status() == GroupStatus.RESOLVED - def has_replays(self): - def make_snuba_params_for_replay_count_query(): + def has_replays(self) -> bool: + def make_snuba_params_for_replay_count_query() -> SnubaParams: return SnubaParams( organization=self.project.organization, projects=[self.project], @@ -996,7 +1019,7 @@ def make_snuba_params_for_replay_count_query(): teams=[], ) - def _cache_key(issue_id) -> str: + def _cache_key(issue_id: int) -> str: return f"group:has_replays:{issue_id}" from sentry.replays.usecases.replay_counts import get_replay_counts @@ -1011,7 +1034,7 @@ def _cache_key(issue_id) -> str: return False cached_has_replays = cache.get(_cache_key(self.id)) - if cached_has_replays is not None: + if isinstance(cached_has_replays, bool): metrics.incr( "group.has_replays.cached", tags={ @@ -1046,7 +1069,7 @@ def _cache_key(issue_id) -> str: return has_replays - def get_status(self): + def get_status(self) -> int: # XXX(dcramer): GroupSerializer reimplements this logic from sentry.models.groupsnooze import GroupSnooze @@ -1063,7 +1086,7 @@ def get_status(self): return status - def get_share_id(self): + def get_share_id(self) -> str | None: from sentry.models.groupshare import GroupShare try: @@ -1224,13 +1247,14 @@ def get_last_release( use_cache=use_cache, ) - def get_event_type(self): + def get_event_type(self) -> eventtypes.EventTypeStr: """ Return the type of this issue. See ``sentry.eventtypes``. """ - return self.data.get("type", "default") + # justification: `data` is decoded event JSON; the type is validated on ingest. + return cast(eventtypes.EventTypeStr, self.data.get("type", "default")) def get_event_metadata(self) -> dict[str, Any]: """ @@ -1238,7 +1262,10 @@ def get_event_metadata(self) -> dict[str, Any]: See ``sentry.eventtypes``. """ - return self.data["metadata"] + metadata = self.data["metadata"] + if not isinstance(metadata, dict): + raise TypeError("metadata is not a JSON object") + return metadata @property def title(self) -> str: @@ -1246,18 +1273,18 @@ def title(self) -> str: event_type = self.get_event_type() # TODO: It may be that we don't have to restrict this to just default and error types - if title and event_type in ["default", "error"]: + if isinstance(title, str) and title and event_type in ["default", "error"]: return title event_type_instance = eventtypes.get(event_type)() return event_type_instance.get_title(self.get_event_metadata()) - def location(self): + def location(self) -> str | None: et = eventtypes.get(self.get_event_type())() return et.get_location(self.get_event_metadata()) @property - def organization(self): + def organization(self) -> Organization: return self.project.organization @property @@ -1265,18 +1292,19 @@ def sdk(self) -> str | None: """returns normalized SDK name""" try: - return self.get_event_metadata()["sdk"]["name_normalized"] + sdk_name = self.get_event_metadata()["sdk"]["name_normalized"] except KeyError: return None + return sdk_name if isinstance(sdk_name, str) else None def get_email_subject(self) -> str: return f"{self.qualified_short_id} - {self.title}" def count_users_seen( self, - referrer=Referrer.TAGSTORE_GET_GROUPS_USER_COUNTS.value, + referrer: str = Referrer.TAGSTORE_GET_GROUPS_USER_COUNTS.value, environment_ids: list[int] | None = None, - ): + ) -> int: return tagstore.backend.get_groups_user_counts( [self.project_id], [self.id], @@ -1315,20 +1343,22 @@ def times_seen_pending(self) -> int: return getattr(self, "_times_seen_pending", 0) @times_seen_pending.setter - def times_seen_pending(self, times_seen: int): + def times_seen_pending(self, times_seen: int) -> None: self._times_seen_pending = times_seen @property - def issue_type(self): + def issue_type(self) -> GroupTypeClass: return get_group_type_by_type_id(self.type) @property - def issue_category(self): + def issue_category(self) -> GroupCategory: return GroupCategory(self.issue_type.category) @receiver(pre_save, sender=Group, dispatch_uid="pre_save_group_default_substatus", weak=False) -def pre_save_group_default_substatus(instance, sender, *args, **kwargs): +def pre_save_group_default_substatus( + instance: Group, sender: type[Group], *args: object, **kwargs: object +) -> None: # TODO(snigdha): Replace the logging with a ValueError once we are confident that this is working as expected. if instance: if instance.status == GroupStatus.IGNORED: diff --git a/src/sentry/models/groupenvironment.py b/src/sentry/models/groupenvironment.py index 28eac7d98a15..562fa0d46378 100644 --- a/src/sentry/models/groupenvironment.py +++ b/src/sentry/models/groupenvironment.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import Any + from django.db.models import DO_NOTHING, DateTimeField, Index from django.db.models.signals import post_delete from django.utils import timezone @@ -35,11 +39,17 @@ class Meta: __repr__ = sane_repr("group_id", "environment_id") @classmethod - def _get_cache_key(self, group_id, environment_id) -> str: + def _get_cache_key(self, group_id: int, environment_id: int) -> str: return f"groupenv:1:{group_id}:{environment_id}" @classmethod - def get_or_create(cls, group_id, environment_id, defaults=None, metrics_tags=None): + def get_or_create( + cls, + group_id: int, + environment_id: int, + defaults: dict[str, Any] | None = None, # justification: untyped model defaults + metrics_tags: dict[str, str | int] | None = None, + ) -> tuple[GroupEnvironment, bool]: cache_key = cls._get_cache_key(group_id, environment_id) instance = cache.get(cache_key) if instance is None: diff --git a/src/sentry/models/grouphash.py b/src/sentry/models/grouphash.py index 20ca890dafe2..b32470763dac 100644 --- a/src/sentry/models/grouphash.py +++ b/src/sentry/models/grouphash.py @@ -37,7 +37,7 @@ # Django signals we can listen for (see end of file). We also don't have to override the model's # `update` method because our base model class already does that, with a method that calls the # queryset's `update`. -class GroupHashQuerySet(BaseQuerySet): +class GroupHashQuerySet(BaseQuerySet["GroupHash"]): def update(self, **kwargs: Any) -> int: if not options.get("grouping.use_ingest_grouphash_caching"): return super().update(**kwargs) @@ -79,8 +79,10 @@ class State: group = FlexibleForeignKey("sentry.Group", null=True) # not-null => the event should be discarded - group_tombstone_id = BoundedPositiveIntegerField(db_index=True, null=True) - state = BoundedPositiveIntegerField( + group_tombstone_id = BoundedPositiveIntegerField[int | None, int | None]( + db_index=True, null=True + ) + state = BoundedPositiveIntegerField[int | None, int | None]( choices=[(State.LOCKED_IN_MIGRATION, _("Locked (Migration in Progress)"))], null=True ) date_added = models.DateTimeField(default=timezone.now, null=True) @@ -121,7 +123,10 @@ def get_associated_fingerprint(self) -> list[str] | None: return None try: - return json.loads(self.metadata.hashing_metadata["fingerprint"]) + fingerprint = json.loads(self.metadata.hashing_metadata["fingerprint"]) + if not isinstance(fingerprint, list): + return None + return [value for value in fingerprint if isinstance(value, str)] # Fingerprints used to be stored by stringifying them rather than jsonifying them, and the # stringified ones can't be parsed and will cause this error diff --git a/src/sentry/models/grouphistory.py b/src/sentry/models/grouphistory.py index 2fa092c8bf6a..3a69d4837216 100644 --- a/src/sentry/models/grouphistory.py +++ b/src/sentry/models/grouphistory.py @@ -189,7 +189,9 @@ class GroupHistory(Model): project = FlexibleForeignKey("sentry.Project", db_constraint=False) release = FlexibleForeignKey("sentry.Release", null=True, db_constraint=False) - user_id = HybridCloudForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete="SET_NULL") + user_id = HybridCloudForeignKey[int | None, int | None]( + settings.AUTH_USER_MODEL, null=True, on_delete="SET_NULL" + ) team = FlexibleForeignKey("sentry.Team", null=True, on_delete=models.SET_NULL) status = BoundedPositiveIntegerField( @@ -238,7 +240,7 @@ def owner(self) -> Actor | None: @owner.setter def owner(self, actor: Actor | None) -> None: """Part of ActorOwned protocol""" - self.team_id = None + setattr(self, "team_id", None) self.user_id = None if actor and actor.is_user: self.user_id = actor.id diff --git a/src/sentry/models/grouplink.py b/src/sentry/models/grouplink.py index 57a669e61a7c..1337096edb40 100644 --- a/src/sentry/models/grouplink.py +++ b/src/sentry/models/grouplink.py @@ -23,7 +23,9 @@ class GroupLinkManager(BaseManager["GroupLink"]): - def get_group_issues(self, group: Group, external_issue_id: int | None = None) -> QuerySet: + def get_group_issues( + self, group: Group, external_issue_id: int | None = None + ) -> QuerySet[GroupLink]: kwargs = dict( group=group, project_id=group.project_id, diff --git a/src/sentry/models/groupmeta.py b/src/sentry/models/groupmeta.py index 283a0794d272..fdefe65bc0d4 100644 --- a/src/sentry/models/groupmeta.py +++ b/src/sentry/models/groupmeta.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import threading -from typing import ClassVar +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast from django.core.signals import request_finished from django.db import models @@ -8,45 +11,51 @@ from sentry.db.models import FlexibleForeignKey, Model, cell_silo_model, sane_repr from sentry.db.models.manager.base import BaseManager +if TYPE_CHECKING: + from sentry.models.group import Group + ERR_CACHE_MISSING = "Cache not populated for instance id=%s" +_Default = TypeVar("_Default") + class GroupMetaCacheNotPopulated(Exception): pass class GroupMetaManager(BaseManager["GroupMeta"]): - def __init__(self, *args, **kwargs): + # justification: forwards arbitrary Django manager constructor arguments. + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.__local_cache = threading.local() - def __getstate__(self): + def __getstate__(self) -> dict[str, object]: d = self.__dict__.copy() d.pop("_GroupMetaManager__local_cache", None) return d - def __setstate__(self, state): + def __setstate__(self, state: Mapping[str, object]) -> None: self.__dict__.update(state) self.__local_cache = threading.local() - def _get_cache(self): + def _get_group_meta_cache(self) -> dict[int, dict[str, str]]: if not hasattr(self.__local_cache, "value"): self.__local_cache.value = {} - return self.__local_cache.value + return cast(dict[int, dict[str, str]], self.__local_cache.value) - def _set_cache(self, value): + def _set_group_meta_cache(self, value: dict[int, dict[str, str]]) -> None: self.__local_cache.value = value - __cache = property(_get_cache, _set_cache) + __cache = property(_get_group_meta_cache, _set_group_meta_cache) - def contribute_to_class(self, model, name): + def contribute_to_class(self, model: type[models.Model], name: str) -> None: super().contribute_to_class(model, name) request_finished.connect(self.clear_local_cache) - def clear_local_cache(self, **kwargs): + def clear_local_cache(self, **kwargs: object) -> None: self.__cache = {} - def populate_cache(self, instance_list): + def populate_cache(self, instance_list: Sequence[Group]) -> None: for group in instance_list: self.__cache.setdefault(group.id, {}) @@ -54,7 +63,9 @@ def populate_cache(self, instance_list): for group_id, key, value in results: self.__cache[group_id][key] = value - def get_value_bulk(self, instance_list, key, default=None): + def get_value_bulk( + self, instance_list: Sequence[Group], key: str, default: _Default | None = None + ) -> dict[Group, str | _Default | None]: results = {} for instance in instance_list: try: @@ -64,21 +75,24 @@ def get_value_bulk(self, instance_list, key, default=None): results[instance] = inst_cache.get(key, default) return results - def get_value(self, instance, key, default=None): + def get_value( + self, instance: Group, key: str, default: _Default | None = None + ) -> str | _Default | None: try: inst_cache = self.__cache[instance.id] except KeyError: raise GroupMetaCacheNotPopulated(ERR_CACHE_MISSING % (instance.id,)) - return inst_cache.get(key, default) + # justification: the per-instance cache maps keys to stored GroupMeta values. + return cast("str | _Default | None", inst_cache.get(key, default)) - def unset_value(self, instance, key): + def unset_value(self, instance: Group, key: str) -> None: self.filter(group=instance, key=key).delete() try: del self.__cache[instance.id][key] except KeyError: pass - def set_value(self, instance, key, value): + def set_value(self, instance: Group, key: str, value: str) -> None: self.update_or_create(group=instance, key=key, defaults={"value": value}) self.__cache.setdefault(instance.id, {}) self.__cache[instance.id][key] = value diff --git a/src/sentry/models/groupopenperiod.py b/src/sentry/models/groupopenperiod.py index e115d63adb97..2477914a221f 100644 --- a/src/sentry/models/groupopenperiod.py +++ b/src/sentry/models/groupopenperiod.py @@ -41,7 +41,10 @@ class TsTzRange(models.Func): def get_group_types_without_open_periods() -> Collection[int]: - return options.get("workflow_engine.group.type_id.open_periods_type_denylist") + type_ids = options.get("workflow_engine.group.type_id.open_periods_type_denylist") + if not isinstance(type_ids, list): + raise TypeError("Open-period type denylist must be a list") + return type_ids def should_create_open_periods(type_id: int) -> bool: diff --git a/src/sentry/models/groupopenperiodactivity.py b/src/sentry/models/groupopenperiodactivity.py index c9ec89e19073..2965b505d2b7 100644 --- a/src/sentry/models/groupopenperiodactivity.py +++ b/src/sentry/models/groupopenperiodactivity.py @@ -34,7 +34,7 @@ class GroupOpenPeriodActivity(DefaultFieldsModel): group_open_period = FlexibleForeignKey("sentry.GroupOpenPeriod") # OpenPeriodActivityType - type: models.Field = models.IntegerField() + type: models.Field[int, int] = models.IntegerField() # The priority associated with this activity row. # Can be None if the row corresponds to open period closure. value = models.IntegerField(null=True) diff --git a/src/sentry/models/groupowner.py b/src/sentry/models/groupowner.py index b936b718c026..aef5eaedcde7 100644 --- a/src/sentry/models/groupowner.py +++ b/src/sentry/models/groupowner.py @@ -1,10 +1,10 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Sequence +from collections.abc import Callable, Sequence from datetime import datetime from enum import Enum, StrEnum -from typing import ClassVar, TypedDict +from typing import TYPE_CHECKING, Any, ClassVar, TypedDict, cast from django.conf import settings from django.db import models @@ -21,13 +21,18 @@ from sentry.models.group import Group from sentry.utils.cache import cache +if TYPE_CHECKING: + from sentry.types.actor import Actor + READ_CACHE_DURATION = 3600 -ISSUE_OWNERS_DEBOUNCE_KEY = lambda group_id: f"owner_exists:1:{group_id}" +ISSUE_OWNERS_DEBOUNCE_KEY: Callable[[int], str] = lambda group_id: f"owner_exists:1:{group_id}" ISSUE_OWNERS_DEBOUNCE_DURATION = 60 * 60 * 24 -ASSIGNEE_EXISTS_KEY = lambda group_id: f"assignee_exists:1:{group_id}" +ASSIGNEE_EXISTS_KEY: Callable[[int], str] = lambda group_id: f"assignee_exists:1:{group_id}" ASSIGNEE_EXISTS_DURATION = 60 * 60 * 24 ASSIGNEE_DOES_NOT_EXIST_DURATION = 60 -PROJECT_OWNERSHIP_VERSION_KEY = lambda project_id: f"ownership_version:1:{project_id}" +PROJECT_OWNERSHIP_VERSION_KEY: Callable[[int], str] = ( + lambda project_id: f"ownership_version:1:{project_id}" +) PROJECT_OWNERSHIP_VERSION_DURATION = 60 * 60 * 24 @@ -64,7 +69,10 @@ class OwnersSerialized(TypedDict): class GroupOwnerManager(BaseManager["GroupOwner"]): def update_or_create_and_preserve_context( - self, lookup_kwargs: dict, defaults: dict, context_defaults: dict + self, + lookup_kwargs: dict[str, object], + defaults: dict[str, object], + context_defaults: dict[str, object], ) -> tuple[GroupOwner, bool]: """ update_or_create doesn't have great support for json fields like context. @@ -147,12 +155,17 @@ class Meta: ), ] - def save(self, *args, **kwargs): + def save( + self, + *args: object, + # justification: Django's Model.save keyword-only arguments cannot be expressed by **kwargs. + **kwargs: Any, + ) -> None: keys = [k for k in (self.user_id, self.team_id) if k is not None] assert len(keys) != 2, "Must have team or user or neither, not both" super().save(*args, **kwargs) - def owner_id(self): + def owner_id(self) -> str | None: if self.user_id: return f"user:{self.user_id}" @@ -164,7 +177,7 @@ def owner_id(self): raise NotImplementedError("Unknown Owner") - def owner(self): + def owner(self) -> Actor | None: from sentry.types.actor import Actor if not self.owner_id(): @@ -173,7 +186,9 @@ def owner(self): return Actor.from_identifier(self.owner_id()) @classmethod - def get_autoassigned_owner(cls, group_id, project_id, autoassignment_types): + def get_autoassigned_owner( + cls, group_id: int, project_id: int, autoassignment_types: Sequence[int] + ) -> GroupOwner | bool: """ Non-cached read access to find the autoassigned GroupOwner. """ @@ -194,14 +209,14 @@ def get_autoassigned_owner(cls, group_id, project_id, autoassignment_types): return issue_owner @classmethod - def invalidate_debounce_issue_owners_evaluation_cache(cls, group_id): + def invalidate_debounce_issue_owners_evaluation_cache(cls, group_id: int) -> None: """ Clear the debounce issue owners cache for a specific group. """ cache.delete(ISSUE_OWNERS_DEBOUNCE_KEY(group_id)) @classmethod - def invalidate_assignee_exists_cache(cls, group_id): + def invalidate_assignee_exists_cache(cls, group_id: int) -> None: """ Clear the assignee exists cache for a specific group. """ @@ -233,7 +248,8 @@ def get_project_ownership_version(cls, project_id: int) -> float | None: Returns the timestamp when ownership rules were last changed, or None if not set. """ - return cache.get(PROJECT_OWNERSHIP_VERSION_KEY(project_id)) + version = cache.get(PROJECT_OWNERSHIP_VERSION_KEY(project_id)) + return float(version) if isinstance(version, (int, float)) else None def get_owner_details(group_list: Sequence[Group]) -> dict[int, list[OwnersSerialized]]: @@ -246,7 +262,7 @@ def get_owner_details(group_list: Sequence[Group]) -> dict[int, list[OwnersSeria owner_details[go.group_id].append( OwnersSerialized( type=GROUP_OWNER_TYPE[GroupOwnerType(go.type)], - owner=go.owner().identifier, + owner=cast("Actor", go.owner()).identifier, date_added=go.date_added, ), ) diff --git a/src/sentry/models/groupredirect.py b/src/sentry/models/groupredirect.py index 506ec76c4c48..a68a80f61c2f 100644 --- a/src/sentry/models/groupredirect.py +++ b/src/sentry/models/groupredirect.py @@ -23,12 +23,12 @@ class GroupRedirect(Model): __relocation_scope__ = RelocationScope.Excluded - organization_id = BoundedBigIntegerField(null=True) + organization_id = BoundedBigIntegerField[int | None, int | None](null=True) group = FlexibleForeignKey( "sentry.Group", related_name="primary_group_of_redirect", db_constraint=False ) previous_group_id = BoundedBigIntegerField(unique=True) - previous_short_id = BoundedBigIntegerField(null=True) + previous_short_id = BoundedBigIntegerField[int | None, int | None](null=True) previous_project_slug = models.SlugField(null=True) date_added = models.DateTimeField(default=timezone.now, null=True) diff --git a/src/sentry/models/grouprelease.py b/src/sentry/models/grouprelease.py index 83c2bfbbbe83..21cbc952e5b4 100644 --- a/src/sentry/models/grouprelease.py +++ b/src/sentry/models/grouprelease.py @@ -1,5 +1,7 @@ -from datetime import timedelta -from typing import Any +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, cast from django.db import models from django.utils import timezone @@ -12,11 +14,17 @@ cell_silo_model, sane_repr, ) +from sentry.metrics.base import MutableTags from sentry.models.metric_tags import DATA_ACCESS_TAG, DataAccessTagValues from sentry.tasks.process_buffer import buffer_incr from sentry.utils.cache import cache from sentry.utils.hashlib import md5_text +if TYPE_CHECKING: + from sentry.models.environment import Environment + from sentry.models.group import Group + from sentry.models.release import Release + @cell_silo_model class GroupRelease(Model): @@ -45,13 +53,21 @@ def save(self, *args: Any, **kwargs: Any) -> None: super().save(*args, **kwargs) @classmethod - def get_cache_key(cls, group_id, release_id, environment): + def get_cache_key(cls, group_id: int, release_id: int, environment: str) -> str: return "grouprelease:1:{}:{}".format( group_id, md5_text(f"{release_id}:{environment}").hexdigest() ) @classmethod - def get_or_create(cls, group, release, environment, datetime, metrics_tags=None, **kwargs): + def get_or_create( + cls, + group: Group, + release: Release, + environment: Environment, + datetime: datetime, + metrics_tags: MutableTags | None = None, + **kwargs: object, + ) -> GroupRelease: cache_key = cls.get_cache_key(group.id, release.id, environment.name) instance = cache.get(cache_key) @@ -88,4 +104,5 @@ def get_or_create(cls, group, release, environment, datetime, metrics_tags=None, metrics_tags[DATA_ACCESS_TAG] = data_access cache.set(cache_key, instance, 3600) - return instance + # justification: the cache only stores GroupRelease instances under this key. + return cast("GroupRelease", instance) diff --git a/src/sentry/models/groupresolution.py b/src/sentry/models/groupresolution.py index 7b428c0203da..943887b7de40 100644 --- a/src/sentry/models/groupresolution.py +++ b/src/sentry/models/groupresolution.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from datetime import datetime + import orjson from django.db import models from django.utils import timezone @@ -15,12 +19,20 @@ cell_silo_model, sane_repr, ) +from sentry.models.group import Group from sentry.models.release import Release, follows_semver_versioning_scheme from sentry.models.releases.constants import DB_VERSION_LENGTH from sentry.models.releases.util import release_order_date from sentry.utils import metrics +def _version_raw(version: str) -> str: + version_raw = parse_release(version, json_loads=orjson.loads).get("version_raw") + if not isinstance(version_raw, str): + raise RelayError(f"Release {version!r} has no parseable version_raw") + return version_raw + + @cell_silo_model class GroupResolution(Model): """ @@ -47,11 +59,11 @@ class Status: # This release field represents the future release version associated with a group when the # user chooses "resolve in future release" future_release_version = models.CharField(max_length=DB_VERSION_LENGTH, null=True, blank=True) - type = BoundedPositiveIntegerField( + type = BoundedPositiveIntegerField[int | None, int | None]( choices=((Type.in_next_release, "in_next_release"), (Type.in_release, "in_release")), null=True, ) - actor_id = BoundedPositiveIntegerField(null=True) + actor_id = BoundedPositiveIntegerField[int | None, int | None](null=True) datetime = models.DateTimeField(default=timezone.now, db_index=True) status = BoundedPositiveIntegerField( default=Status.pending, @@ -71,14 +83,16 @@ class Meta: __repr__ = sane_repr("group_id", "release_id") @classmethod - def has_resolution(cls, group, release): + def has_resolution(cls, group: Group, release: Release | None) -> bool: """ Determine if a resolution exists for the given group and release. This is used to suggest if a regression has occurred. """ - def compare_release_dates_for_in_next_release(res_release, res_release_datetime, release): + def compare_release_dates_for_in_next_release( + res_release: int, res_release_datetime: datetime, release: Release + ) -> bool: """ Helper function that compares release versions based on date for `GroupResolution.Type.in_next_release` @@ -141,12 +155,8 @@ def compare_release_dates_for_in_next_release(res_release, res_release_datetime, # If current_release_version == release.version => 0 # If current_release_version < release.version => -1 # If current_release_version > release.version => 1 - current_release_raw = parse_release( - current_release_version, json_loads=orjson.loads - ).get("version_raw") - release_raw = parse_release(release.version, json_loads=orjson.loads).get( - "version_raw" - ) + current_release_raw = _version_raw(current_release_version) + release_raw = _version_raw(release.version) if compare_version_relay(current_release_raw, release_raw) >= 0: return True @@ -156,9 +166,7 @@ def compare_release_dates_for_in_next_release(res_release, res_release_datetime, # comparing the event release against the resolved-in # release: if the event is on an older version than the fix, # the resolution still holds. - res_release_raw = parse_release( - res_release_version, json_loads=orjson.loads - ).get("version_raw") + res_release_raw = _version_raw(res_release_version) return compare_version_relay(res_release_raw, release_raw) == 1 except RelayError: ... @@ -202,12 +210,8 @@ def compare_release_dates_for_in_next_release(res_release, res_release_datetime, try: # A resolution only exists if the resolved release is greater (in semver # terms) than the provided release - res_release_raw = parse_release( - res_release_version, json_loads=orjson.loads - ).get("version_raw") - release_raw = parse_release(release.version, json_loads=orjson.loads).get( - "version_raw" - ) + res_release_raw = _version_raw(res_release_version) + release_raw = _version_raw(release.version) return compare_version_relay(res_release_raw, release_raw) == 1 except RelayError: ... diff --git a/src/sentry/models/groupsnooze.py b/src/sentry/models/groupsnooze.py index 0f0cae378566..d4ee0c286ef7 100644 --- a/src/sentry/models/groupsnooze.py +++ b/src/sentry/models/groupsnooze.py @@ -48,12 +48,12 @@ class GroupSnooze(Model): group = FlexibleForeignKey("sentry.Group", unique=True) until = models.DateTimeField(null=True) - count = BoundedPositiveIntegerField(null=True) - window = BoundedPositiveIntegerField(null=True) - user_count = BoundedPositiveIntegerField(null=True) - user_window = BoundedPositiveIntegerField(null=True) + count = BoundedPositiveIntegerField[int | None, int | None](null=True) + window = BoundedPositiveIntegerField[int | None, int | None](null=True) + user_count = BoundedPositiveIntegerField[int | None, int | None](null=True) + user_window = BoundedPositiveIntegerField[int | None, int | None](null=True) state: models.Field[dict[str, Any] | None, dict[str, Any] | None] = JSONField(null=True) - actor_id = BoundedPositiveIntegerField(null=True) + actor_id = BoundedPositiveIntegerField[int | None, int | None](null=True) objects: ClassVar[BaseManager[Self]] = BaseManager(cache_fields=("group",)) @@ -97,6 +97,8 @@ def is_valid( return True def test_frequency_rates(self) -> bool: + assert self.window is not None + assert self.count is not None cache_key = f"groupsnooze:v1:{self.id}:test_frequency_rate:events_seen_counter" cache_ttl = self.window * 60 # Redis TTL in seconds (window is in minutes) @@ -157,6 +159,8 @@ def test_user_rates_or_counts(self, group: Group) -> bool: return True def test_user_rates(self) -> bool: + assert self.user_window is not None + assert self.user_count is not None cache_key = f"groupsnooze:v1:{self.id}:test_user_rate:events_seen_counter" cache_ttl = self.user_window * 60 # Redis TTL in seconds (window is in minutes) @@ -199,6 +203,7 @@ def test_user_rates(self) -> bool: return True def test_user_counts(self, group: Group) -> bool: + assert self.user_count is not None cache_key = f"groupsnooze:v1:{self.id}:test_user_counts:events_seen_counter" CACHE_TTL = 3600 # Redis TTL in seconds diff --git a/src/sentry/models/groupsubscription.py b/src/sentry/models/groupsubscription.py index 6d9dee3ffbcd..0fc044914d48 100644 --- a/src/sentry/models/groupsubscription.py +++ b/src/sentry/models/groupsubscription.py @@ -83,8 +83,12 @@ def subscribe_actor( return self.subscribe(group, actor, reason) if isinstance(actor, Team): # subscribe the members of the team - team_users_ids = list(actor.member_set.values_list("user_id", flat=True)) - return self.bulk_subscribe(group=group, user_ids=team_users_ids, reason=reason) + team_user_ids = actor.member_set.values_list("user_id", flat=True) + return self.bulk_subscribe( + group=group, + user_ids=[user_id for user_id in team_user_ids if user_id is not None], + reason=reason, + ) raise NotImplementedError("Unknown actor type: %r" % type(actor)) diff --git a/src/sentry/models/grouptombstone.py b/src/sentry/models/grouptombstone.py index f5017f8ba85f..0cbf766cd82b 100644 --- a/src/sentry/models/grouptombstone.py +++ b/src/sentry/models/grouptombstone.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import cast from django.db import models from django.utils import timezone @@ -35,10 +35,10 @@ class GroupTombstone(Model): ) message = models.TextField() culprit = models.CharField(max_length=MAX_CULPRIT_LENGTH, blank=True, null=True) - data: models.Field[dict[str, Any] | None, dict[str, Any]] = GzippedDictField( + data: models.Field[dict[str, object] | None, dict[str, object] | None] = GzippedDictField( blank=True, null=True ) - actor_id = BoundedPositiveIntegerField(null=True) + actor_id = BoundedPositiveIntegerField[int | None, int | None](null=True) times_seen = BoundedPositiveIntegerField(db_default=0) last_seen = models.DateTimeField(auto_now_add=True, null=True) @@ -46,18 +46,18 @@ class Meta: app_label = "sentry" db_table = "sentry_grouptombstone" - def get_event_type(self): + def get_event_type(self) -> str: """ Return the type of this issue. See ``sentry.eventtypes``. """ - return self.data.get("type", "default") + return cast(dict[str, str], self.data).get("type", "default") - def get_event_metadata(self): + def get_event_metadata(self) -> dict[str, object]: """ Return the metadata of this issue. See ``sentry.eventtypes``. """ - return self.data["metadata"] + return cast(dict[str, dict[str, object]], self.data)["metadata"] diff --git a/src/sentry/models/importchunk.py b/src/sentry/models/importchunk.py index f9f71bb0542b..be7c991e6c5c 100644 --- a/src/sentry/models/importchunk.py +++ b/src/sentry/models/importchunk.py @@ -39,11 +39,11 @@ class BaseImportChunk(DefaultFieldsModelExisting): # The minimum assigned pk (inclusive) imported by this chunk. Nullable because it is possible # that no insertions were performed, with all writes being merges or overwrites instead. - min_inserted_pk = BoundedBigIntegerField(null=True) + min_inserted_pk = BoundedBigIntegerField[int | None, int | None](null=True) # The maximum assigned pk (inclusive) imported by this chunk. Nullable because it is possible # that no insertions were performed, with all writes being merges or overwrites instead. - max_inserted_pk = BoundedBigIntegerField(null=True) + max_inserted_pk = BoundedBigIntegerField[int | None, int | None](null=True) # A JSON object map from original pks in the source blob to the pks they were assigned when # being inserted into the actual database. diff --git a/src/sentry/models/latestreporeleaseenvironment.py b/src/sentry/models/latestreporeleaseenvironment.py index 9c2e0a0a89df..7df81c67a965 100644 --- a/src/sentry/models/latestreporeleaseenvironment.py +++ b/src/sentry/models/latestreporeleaseenvironment.py @@ -16,9 +16,9 @@ class LatestRepoReleaseEnvironment(Model): environment_id = BoundedBigIntegerField(db_index=True) release_id = BoundedBigIntegerField(db_index=True) # deploy_id and commit_id are nullable but in practice always have a value - deploy_id = BoundedBigIntegerField(null=True) + deploy_id = BoundedBigIntegerField[int | None, int | None](null=True) # commit_id is the id of the ReleaseHeadCommit associated with the given release - commit_id = BoundedBigIntegerField(null=True, db_index=True) + commit_id = BoundedBigIntegerField[int | None, int | None](null=True, db_index=True) class Meta: app_label = "sentry" diff --git a/src/sentry/models/organization.py b/src/sentry/models/organization.py index 024807594ef9..19a19872e9c9 100644 --- a/src/sentry/models/organization.py +++ b/src/sentry/models/organization.py @@ -47,6 +47,7 @@ if TYPE_CHECKING: from sentry.models.options.organization_option import OrganizationOptionManager + from sentry.users.models.user import User NON_MEMBER_SCOPES = frozenset(["org:write", "project:write", "team:write"]) ORGANIZATION_NAME_MAX_LENGTH = 64 @@ -66,11 +67,11 @@ def __str__(self) -> str: return self.name @property - def label(self): + def label(self) -> str: return OrganizationStatus_labels[self] @classmethod - def as_choices(cls): + def as_choices(cls) -> tuple[tuple[int, str], ...]: result = [] for name, member in cls.__members__.items(): # an alias @@ -108,7 +109,9 @@ def get_for_team_ids(self, team_ids: Sequence[int]) -> BaseQuerySet[Organization id__in=Team.objects.filter(id__in=team_ids).values("organization"), ) - def get_for_user(self, user, scope=None, only_visible=True) -> list[Organization]: + def get_for_user( + self, user: User | RpcUser, scope: str | None = None, only_visible: bool = True + ) -> list[Organization]: """ Returns a set of all organizations a user has access to. """ @@ -225,7 +228,7 @@ class Meta: __repr__ = sane_repr("owner_id", "name", "slug") @classmethod - def get_default(cls): + def get_default(cls) -> Organization: """ Return the organization used in single organization mode. """ @@ -240,7 +243,7 @@ def __str__(self) -> str: snowflake_redis_key = "organization_snowflake_key" - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: # justification: framework kwargs save_callback = lambda: super(Organization, self).save(*args, **kwargs) slugify_target = None if not self.slug: @@ -252,7 +255,9 @@ def save(self, *args, **kwargs): return target = slugify_target.lower().replace("_", "-").strip("-") - pick_slug = lambda: slugify_instance(self, target, reserved=RESERVED_ORGANIZATION_SLUGS) + pick_slug: Callable[[], None] = lambda: slugify_instance( + self, target, reserved=RESERVED_ORGANIZATION_SLUGS + ) if self.slug: # Explicit slugs shouldn't be modified or retried if there's a conflict. @@ -275,10 +280,11 @@ def _save_organization(self, save_callback: Callable[[], object]) -> None: save_callback() @classmethod - def reserve_snowflake_id(cls): + def reserve_snowflake_id(cls) -> int: return generate_snowflake_id(cls.snowflake_redis_key) - def delete(self, *args, **kwargs): + # justification: framework kwargs + def delete(self, *args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]: if self.is_default: raise Exception("You cannot delete the default organization.") return super().delete(*args, **kwargs) @@ -305,20 +311,21 @@ def handle_async_deletion( ) @cached_property - def is_default(self): + def is_default(self) -> bool: if not settings.SENTRY_SINGLE_ORGANIZATION: return False 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): + def get_audit_log_data(self) -> dict[str, Any]: # justification: Django hook payloads return { "id": self.id, "slug": self.slug, @@ -343,7 +350,7 @@ def get_default_owner(self) -> RpcUser: return self._default_owner @property - def default_owner_id(self): + def default_owner_id(self) -> int | None: """ Similar to get_default_owner but won't raise a key error if there is no owner. Used for analytics primarily. @@ -429,7 +436,7 @@ def get_bulk_owner_profiles( if user_id in profile_table } - def has_single_owner(self): + def has_single_owner(self) -> bool: owners = list( self.get_members_with_org_roles([roles.get_top_dog().id])[:2].values_list( "id", flat=True @@ -441,7 +448,7 @@ def get_members_with_org_roles( self, roles: Collection[str], include_null_users: bool = False, - ): + ) -> BaseQuerySet[Any]: # justification: Django hook payloads members_with_role = self.member_set.filter(role__in=roles) if not include_null_users: members_with_role = members_with_role.filter(user_id__isnull=False, user_is_active=True) @@ -455,7 +462,8 @@ def option_manager(self) -> OrganizationOptionManager: return OrganizationOption.objects - def _handle_requirement_change(self, request, task): + # justification: Django hook payloads + def _handle_requirement_change(self, request: Any, task: Any) -> None: from sentry.auth.principal import acting_user_id # circular import actor_id = acting_user_id(request.user) @@ -475,7 +483,7 @@ def _handle_requirement_change(self, request, task): using=router.db_for_write(Organization), ) - def handle_2fa_required(self, request): + def handle_2fa_required(self, request: Any) -> None: # justification: Django hook payloads from sentry.tasks.auth.auth import remove_2fa_non_compliant_members self._handle_requirement_change(request, remove_2fa_non_compliant_members) diff --git a/src/sentry/models/organizationmapping.py b/src/sentry/models/organizationmapping.py index 2215096fabdf..4b96da0cc033 100644 --- a/src/sentry/models/organizationmapping.py +++ b/src/sentry/models/organizationmapping.py @@ -47,7 +47,9 @@ class OrganizationMapping(Model): idempotency_key = models.CharField(max_length=IDEMPOTENCY_KEY_LENGTH) cell_name = models.CharField(max_length=CELL_NAME_LENGTH, db_column="region_name") - status = BoundedBigIntegerField(choices=OrganizationStatus.as_choices(), null=True) + status = BoundedBigIntegerField[int | None, int | None]( + choices=OrganizationStatus.as_choices(), null=True + ) # Replicated from the Organization.flags attribute allow_joinleave = models.BooleanField(default=False, db_default=False) 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/organizationmemberinvite.py b/src/sentry/models/organizationmemberinvite.py index ddfb968a8498..35dd55ff3bd3 100644 --- a/src/sentry/models/organizationmemberinvite.py +++ b/src/sentry/models/organizationmemberinvite.py @@ -1,7 +1,10 @@ +from __future__ import annotations + import secrets +from collections.abc import Iterable, Sequence from datetime import datetime, timedelta from enum import Enum -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict from django.conf import settings from django.db import models, router, transaction @@ -22,6 +25,14 @@ INVITE_DAYS_VALID = 30 +if TYPE_CHECKING: + from django.utils.functional import _StrPromise # fake type added by django-stubs + + from sentry.models.apikey import ApiKey + from sentry.roles.manager import OrganizationRole + from sentry.users.models.user import User + from sentry.users.services.user import RpcUser + __all__ = ("OrganizationMemberInvite",) @@ -31,7 +42,7 @@ class InviteStatus(Enum): REQUESTED_TO_JOIN = 2 @classmethod - def as_choices(cls): + def as_choices(cls) -> Sequence[tuple[int, _StrPromise]]: return ( (InviteStatus.APPROVED.value, _("Approved")), ( @@ -52,11 +63,11 @@ def as_choices(cls): ERR_JOIN_REQUESTS_DISABLED = "Your organization does not allow requests to join." -def default_expiration(): +def default_expiration() -> datetime: return timezone.now() + timedelta(days=INVITE_DAYS_VALID) -def generate_token(): +def generate_token() -> str: return secrets.token_hex(nbytes=32) @@ -74,7 +85,7 @@ class OrganizationMemberInviteResponse(TypedDict): dateCreated: datetime inviteStatus: str inviterName: str | None - teams: list[dict] + teams: list[dict[str, int | str | None]] @cell_silo_model @@ -120,42 +131,42 @@ class Meta: __repr__ = sane_repr("organization_id", "email", "role") - def get_invite_link(self, referrer: str | None = None): + def get_invite_link(self, referrer: str | None = None) -> None: pass - def send_invite_email(self, referrer: str | None = None): + def send_invite_email(self, referrer: str | None = None) -> None: pass - def generate_token(self): + def generate_token(self) -> str: return secrets.token_hex(nbytes=32) - def regenerate_token(self): + def regenerate_token(self) -> None: self.token = self.generate_token() self.refresh_expires_at() - 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: return invite_status_names[self.invite_status] - def set_org_role(self, orgRole: str): + def set_org_role(self, orgRole: str) -> None: self.role = orgRole self.save() - def set_teams(self, teams: list[Team]): + def set_teams(self, teams: list[Team]) -> None: team_data = [] for team in teams: team_data.append({"id": team.id, "slug": team.slug, "role": None}) self.organization_member_team_data = team_data self.save() - def validate_invitation(self, allowed_roles): + def validate_invitation(self, 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. @@ -177,7 +188,13 @@ def validate_invitation(self, allowed_roles): ) return True - def approve_invite_request(self, approving_user, api_key=None, ip_address=None, referrer=None): + def approve_invite_request( + self, + approving_user: User | RpcUser | None, + 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 """ @@ -200,7 +217,13 @@ def approve_invite_request(self, approving_user, api_key=None, ip_address=None, event=(audit_log.get_event_id("MEMBER_INVITE")), ) - def remove_invite_from_db(self, acting_user, event_name, api_key=None, ip_address=None): + def remove_invite_from_db( + self, + acting_user: User | RpcUser | None, + event_name: str, + api_key: ApiKey | None = None, + ip_address: str | None = None, + ) -> None: """ Remove a member invite obejct from the DB and send an audit log entry """ @@ -222,19 +245,19 @@ def remove_invite_from_db(self, acting_user, event_name, api_key=None, ip_addres ) @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 token_expired(self): + def token_expired(self) -> bool: return self.token_expires_at <= timezone.now() def write_relocation_import( @@ -252,7 +275,7 @@ def write_relocation_import( return super().write_relocation_import(scope, flags) - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, object]: teams = self.organization_member_team_data return { "email": self.email, diff --git a/src/sentry/models/organizationmembermapping.py b/src/sentry/models/organizationmembermapping.py index 2799945ea722..442e4c814a4a 100644 --- a/src/sentry/models/organizationmembermapping.py +++ b/src/sentry/models/organizationmembermapping.py @@ -27,7 +27,7 @@ class OrganizationMemberMapping(Model): __relocation_scope__ = RelocationScope.Excluded organization_id = HybridCloudForeignKey("sentry.Organization", on_delete="CASCADE") - organizationmember_id = BoundedBigIntegerField(db_index=True, null=True) + organizationmember_id = BoundedBigIntegerField[int | None, int | None](db_index=True, null=True) date_added = models.DateTimeField(default=timezone.now) role = models.CharField(max_length=32, default=str(organization_roles.get_default().id)) diff --git a/src/sentry/models/organizationonboardingtask.py b/src/sentry/models/organizationonboardingtask.py index f85438f805dd..0bbe0c5e1b73 100644 --- a/src/sentry/models/organizationonboardingtask.py +++ b/src/sentry/models/organizationonboardingtask.py @@ -62,7 +62,7 @@ def record( organization_id: int, task: int, status: OnboardingTaskStatus = OnboardingTaskStatus.COMPLETE, - **kwargs, + **kwargs: object, ) -> bool: """Record the completion of an onboarding task. Caches the completion. Returns whether the task was created or not.""" if status != OnboardingTaskStatus.COMPLETE: @@ -118,7 +118,7 @@ class AbstractOnboardingTask(Model): (OnboardingTaskStatus.SKIPPED, "skipped"), ) - STATUS_KEY_MAP = dict(STATUS_CHOICES) + STATUS_KEY_MAP: dict[int, str] = dict(STATUS_CHOICES) STATUS_LOOKUP_BY_KEY = {v: k for k, v in STATUS_CHOICES} organization = FlexibleForeignKey("sentry.Organization") @@ -162,7 +162,7 @@ class OrganizationOnboardingTask(AbstractOnboardingTask): # Used in the API to map IDs to string keys. This keeps things # a bit more maintainable on the frontend. - TASK_KEY_MAP = dict(TASK_CHOICES) + TASK_KEY_MAP: dict[int, str] = dict(TASK_CHOICES) TASK_LOOKUP_BY_KEY = {v: k for k, v in TASK_CHOICES} task = BoundedPositiveIntegerField(choices=TASK_CHOICES) diff --git a/src/sentry/models/organizationslugreservation.py b/src/sentry/models/organizationslugreservation.py index 35ea7578e945..5d08ca743ca9 100644 --- a/src/sentry/models/organizationslugreservation.py +++ b/src/sentry/models/organizationslugreservation.py @@ -36,7 +36,7 @@ class OrganizationSlugReservation(ControlOutboxProducingModel): slug = models.SlugField(unique=True, null=False) organization_id = HybridCloudForeignKey("sentry.organization", null=False, on_delete="CASCADE") - user_id = BoundedBigIntegerField(db_index=True, null=True) + user_id = BoundedBigIntegerField[int | None, int | None](db_index=True, null=True) cell_name = models.CharField(max_length=CELL_NAME_LENGTH, null=False, db_column="region_name") reservation_type = BoundedBigIntegerField( choices=OrganizationSlugReservationType.as_choices(), diff --git a/src/sentry/models/orgauthtoken.py b/src/sentry/models/orgauthtoken.py index 2208c71bb682..e1925d53133b 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.") @@ -80,16 +80,16 @@ class Meta: def __str__(self) -> str: return force_str(self.token_hashed) - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, object]: return {"name": self.name, "scopes": self.get_scopes()} def get_allowed_origins(self) -> list[str]: return [] - def get_scopes(self): + def get_scopes(self) -> list[str]: return self.scope_list - def has_scope(self, scope): + def has_scope(self, scope: str) -> bool: return scope in self.get_scopes() def is_active(self) -> bool: diff --git a/src/sentry/models/owner_base.py b/src/sentry/models/owner_base.py index 42eb6f801589..992db70c6f5e 100644 --- a/src/sentry/models/owner_base.py +++ b/src/sentry/models/owner_base.py @@ -17,7 +17,9 @@ class OwnerModel(Model): owner_user_id = HybridCloudForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete="SET_NULL" ) - owner_team_id = BoundedBigIntegerField(null=True, blank=True, db_index=True) + owner_team_id = BoundedBigIntegerField[int | None, int | None]( + null=True, blank=True, db_index=True + ) class Meta: abstract = True diff --git a/src/sentry/models/project.py b/src/sentry/models/project.py index b1f9ae5979ab..1bcbf8dc2154 100644 --- a/src/sentry/models/project.py +++ b/src/sentry/models/project.py @@ -2,11 +2,12 @@ import logging from collections import defaultdict -from collections.abc import Callable, Collection, Iterable -from typing import TYPE_CHECKING, Any, ClassVar +from collections.abc import Callable, Collection, Iterable, MutableMapping +from typing import TYPE_CHECKING, Any, ClassVar, cast from uuid import uuid1 from django.conf import settings +from django.contrib.auth.models import AnonymousUser from django.db import IntegrityError, models, router, transaction from django.db.models import Count, Q, QuerySet, Subquery from django.db.models.signals import pre_delete @@ -55,6 +56,7 @@ if TYPE_CHECKING: from sentry.models.options.project_option import ProjectOptionManager from sentry.models.organization import Organization + from sentry.models.organizationmember import OrganizationMember from sentry.users.models.user import User # NOTE: @@ -195,19 +197,25 @@ def get_by_users(self, users: Iterable[User | RpcUser]) -> dict[int, set[int]]: projects_by_user_id[user_id].add(project_id) return projects_by_user_id - def get_for_user_ids(self, user_ids: Collection[int]) -> QuerySet: + def get_for_user_ids(self, user_ids: Collection[int]) -> QuerySet[Project]: """Returns the QuerySet of all projects that a set of Users have access to.""" return self.filter( status=ObjectStatus.ACTIVE, teams__organizationmember__user_id__in=user_ids, ) - def get_for_team_ids(self, team_ids: Collection[int] | Subquery) -> QuerySet: + def get_for_team_ids(self, team_ids: Collection[int] | Subquery) -> QuerySet[Project]: """Returns the QuerySet of all projects that a set of Teams have access to.""" return self.filter(status=ObjectStatus.ACTIVE, teams__in=team_ids) # TODO(dcramer): we might want to cache this per user - def get_for_user(self, team, user, scope=None, _skip_team_check=False): + def get_for_user( + self, + team: Team, + user: User | RpcUser | AnonymousUser, + scope: str | None = None, + _skip_team_check: bool = False, + ) -> list[Project]: if not (user and user.is_authenticated): return [] @@ -406,7 +414,7 @@ def _save_project(self, save_callback: Callable[[], object]) -> None: else: save_callback() - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: save_callback = lambda: super(Project, self).save(*args, **kwargs) if getattr(self, "id", None) is not None or self.slug: self._save_project(save_callback) @@ -426,7 +434,7 @@ def save(self, *args, **kwargs): ) ) - def get_absolute_url(self, params=None): + def get_absolute_url(self, params: MutableMapping[str, str | int] | None = None) -> str: path = f"/organizations/{self.organization.slug}/issues/" params = {} if params is None else params params["project"] = self.id @@ -435,7 +443,7 @@ def get_absolute_url(self, params=None): query = urlencode(params) return self.organization.absolute_url(path, query=query) - def is_internal_project(self): + def is_internal_project(self) -> bool: for value in (settings.SENTRY_FRONTEND_PROJECT, settings.SENTRY_PROJECT): if str(self.id) == str(value) or str(self.slug) == str(value): return True @@ -464,14 +472,14 @@ def delete_option(self, key: str) -> None: self.option_manager.unset_value(self, key) @property - def color(self): + def color(self) -> str: if self.forced_color is not None: return f"#{self.forced_color}" assert self.slug is not None return get_hashed_color(self.slug.upper()) @property - def member_set(self): + def member_set(self) -> QuerySet[OrganizationMember]: """:returns a QuerySet of all Users that belong to this Project""" from sentry.models.organizationmember import OrganizationMember @@ -486,9 +494,9 @@ def member_set(self): def get_members_as_rpc_users(self) -> list[RpcUser]: member_ids = self.member_set.values_list("user_id", flat=True) - return user_service.get_many_by_id(ids=list(member_ids)) + return user_service.get_many_by_id(ids=cast(list[int], list(member_ids))) - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, object]: return { "id": self.id, "slug": self.slug, @@ -497,7 +505,7 @@ def get_audit_log_data(self): "public": self.public, } - def get_full_name(self): + def get_full_name(self) -> str: return self.slug def transfer_to(self, organization: Organization) -> None: @@ -796,7 +804,7 @@ def resolve_environment_id(environment_id: int | None) -> int | None: ei.organization_id = organization.id ExternalIssue.objects.bulk_update(external_issues, ["organization_id"]) - def add_team(self, team): + def add_team(self, team: Team) -> bool: from sentry.models.projectteam import ProjectTeam try: @@ -807,7 +815,7 @@ def add_team(self, team): else: return True - def remove_team(self, team): + def remove_team(self, team: Team) -> None: from sentry.incidents.models.alert_rule import AlertRule from sentry.models.projectteam import ProjectTeam from sentry.models.rule import Rule @@ -816,11 +824,11 @@ def remove_team(self, team): AlertRule.objects.fetch_for_project(self).filter(team_id=team.id).update(team_id=None) Rule.objects.filter(owner_team_id=team.id, project=self).update(owner_team_id=None) - def get_security_token(self): + def get_security_token(self) -> str: lock = locks.get(self.get_lock_key(), duration=5, name="project_security_token") with TimedRetryPolicy(10)(lock.acquire): security_token = self.get_option("sentry:token", None) - if security_token is None: + if not isinstance(security_token, str): security_token = uuid1().hex self.update_option("sentry:token", security_token) return security_token @@ -865,7 +873,7 @@ def copy_settings_from(self, project_id: int) -> bool: # add settings from other project to self for setting in model.objects.filter(project_id=project_id): - setting.pk = None + setattr(setting, "pk", None) setting.project_id = self.id setting.save() @@ -888,7 +896,7 @@ def copy_settings_from(self, project_id: int) -> bool: return True @staticmethod - def is_valid_platform(value): + def is_valid_platform(value: object) -> bool: return not value or value == "other" or value in GETTING_STARTED_DOCS_PLATFORMS @staticmethod @@ -912,7 +920,7 @@ def outbox_for_update(project_identifier: int, organization_identifier: int) -> object_identifier=project_identifier, ) - def delete(self, *args, **kwargs): + def delete(self, *args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]: # There is no foreign key relationship so we have to manually cascade. notifications_service.remove_notification_settings_for_project(project_id=self.id) @@ -937,7 +945,7 @@ def normalize_before_relocation_import( # A `Global` restore implies a blanket restoration of all data. In such a case, we want to # ensure that project IDs remain unchanged, so that recovering users do not need to mint new # DSNs post-recovery. - if scope == ImportScope.Global: + if scope == ImportScope.Global and old_pk is not None: self.pk = old_pk return old_pk diff --git a/src/sentry/models/projectcodeowners.py b/src/sentry/models/projectcodeowners.py index 2a4dd98e9ac8..66248f202b17 100644 --- a/src/sentry/models/projectcodeowners.py +++ b/src/sentry/models/projectcodeowners.py @@ -2,6 +2,7 @@ import logging from collections.abc import Iterable +from typing import cast import sentry_sdk from django.db import models @@ -145,13 +146,13 @@ def update_schema(self, organization: Organization, raw: str | None = None) -> N return -def modify_date_updated(instance, **kwargs): - if instance.id is None: +def modify_date_updated(instance: ProjectCodeOwners, **kwargs: object) -> None: + if cast("int | None", instance.id) is None: return instance.date_updated = timezone.now() -def process_resource_change(instance, change, **kwargs): +def process_resource_change(instance: ProjectCodeOwners, change: str, **kwargs: object) -> None: from sentry.models.groupowner import GroupOwner cache.set( diff --git a/src/sentry/models/projectkey.py b/src/sentry/models/projectkey.py index a8b123cec29a..d81367f625b4 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,12 @@ from sentry.silo.base import SiloMode from sentry.tasks.relay import schedule_invalidate_project_config +if TYPE_CHECKING: + from rest_framework.request import Request + + from sentry.models.organization import Organization + from sentry.models.project import Project + _token_re = re.compile(r"^[a-f0-9]{32}$") # TODO(dcramer): pull in enum library @@ -49,12 +55,12 @@ 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" ) - def for_request(self, request): + def for_request(self, request: Request) -> models.QuerySet[ProjectKey]: """Return objects that the given request user is allowed to access""" from sentry.auth.superuser import is_active_superuser @@ -112,8 +118,8 @@ class roles(TypedClassBitField): ) date_added = models.DateTimeField(default=timezone.now, null=True) - rate_limit_count = BoundedPositiveIntegerField(null=True) - rate_limit_window = BoundedPositiveIntegerField(null=True) + rate_limit_count = BoundedPositiveIntegerField[int | None, int | None](null=True) + rate_limit_window = BoundedPositiveIntegerField[int | None, int | None](null=True) objects: ClassVar[ProjectKeyManager] = ProjectKeyManager( cache_fields=("public_key", "secret_key"), @@ -152,15 +158,15 @@ 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 - def from_dsn(cls, dsn): + def from_dsn(cls, dsn: str) -> ProjectKey: urlparts = urlparse(dsn) public_key = urlparts.username @@ -172,10 +178,10 @@ def from_dsn(cls, dsn): if not project_id.isdecimal() or int(project_id) > I64_MAX: raise ProjectKey.DoesNotExist("ProjectKey matching query does not exist.") - return ProjectKey.objects.get(public_key=public_key, project=project_id) + return ProjectKey.objects.get(public_key=public_key, project=int(project_id)) @classmethod - def get_default(cls, project): + def get_default(cls, project: Project | int) -> ProjectKey | None: return cls.objects.filter( project=project, roles=models.F("roles").bitor(cls.roles.store), @@ -183,11 +189,11 @@ def get_default(cls, project): ).first() @property - def is_active(self): + def is_active(self) -> bool: 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 +226,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 +235,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: @@ -245,53 +251,53 @@ def get_dsn(self, domain=None, secure=True, public=False): return f"{urlparts.scheme}://{key}@{urlparts.netloc + urlparts.path}/{self.project_id}" @property - def organization_id(self): + def organization_id(self) -> int: return self.project.organization_id @property - def organization(self): + def organization(self) -> Organization: return self.project.organization @property - 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 +305,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 @@ -330,7 +336,7 @@ def get_endpoint(self) -> str: # Apply the org's relay-dsn-endpoint override when set. override = self.organization.get_option("sentry:relay_dsn_endpoint") - if override: + if isinstance(override, str) and override: return override endpoint = settings.SENTRY_ENDPOINT @@ -368,7 +374,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 +385,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/projectownership.py b/src/sentry/models/projectownership.py index 4fb3bc18a06a..d2c4a16dbfa9 100644 --- a/src/sentry/models/projectownership.py +++ b/src/sentry/models/projectownership.py @@ -69,7 +69,7 @@ class Meta: __repr__ = sane_repr("project_id", "is_active") @classmethod - def get_cache_key(self, project_id) -> str: + def get_cache_key(self, project_id: int) -> str: return f"projectownership_project_id:1:{project_id}" @classmethod @@ -93,7 +93,7 @@ def get_combined_schema( return ownership.schema @classmethod - def get_ownership_cached(cls, project_id): + def get_ownership_cached(cls, project_id: int) -> ProjectOwnership | None: """ Cached read access to projectownership. @@ -164,7 +164,7 @@ def get_owners( @classmethod def _hydrate_rules( cls, project_id: int, rules: Sequence[Rule], type: str = OwnerRuleType.OWNERSHIP_RULE.value - ): + ) -> list[tuple[Rule, Sequence[Team | RpcUser], str]]: """ Get the last matching rule to take the most precedence. """ @@ -174,7 +174,7 @@ def _hydrate_rules( for key, val in resolve_actors({owner for owner in owners}, project_id).items() if val } - result = [ + result: list[tuple[Rule, Sequence[Team | RpcUser], str]] = [ ( rule, Actor.resolve_many([actors[owner] for owner in rule.owners if owner in actors]), @@ -242,7 +242,7 @@ def get_issue_owners( return rules_with_owners @classmethod - def _get_autoassignment_types(cls, ownership): + def _get_autoassignment_types(cls, ownership: ProjectOwnership) -> list[int]: from sentry.models.groupowner import GroupOwnerType autoassignment_types = [] @@ -267,7 +267,7 @@ def handle_auto_assignment( group: Group | None = None, organization_id: int | None = None, force_autoassign: bool = False, - logging_extra: dict[str, str | bool | int] | None = None, + logging_extra: dict[str, object] | None = None, ) -> None: """ Get the auto-assign owner for a project if there are any. @@ -309,17 +309,17 @@ def handle_auto_assignment( group.id, project_id, autoassignment_types ) - if issue_owner is False: + if not isinstance(issue_owner, GroupOwner): return logging_extra["issue_owner"] = issue_owner - owner = issue_owner.owner() - if not owner: + actor = issue_owner.owner() + if not actor: return - logging_extra["owner"] = owner + logging_extra["owner"] = actor try: - owner = owner.resolve() + owner = actor.resolve() except (User.DoesNotExist, Team.DoesNotExist): logger.info("handle_auto_assignment.no_resolved_owner", extra=logging_extra) return @@ -352,11 +352,6 @@ def handle_auto_assignment( if not auto_assigned and not force_autoassign: return - if not isinstance(owner, Team) and not isinstance(owner, RpcUser): - logging_extra["owner_type"] = str(type(owner)) - logger.info("handle_auto_assignment.unknown_owner_type", extra=logging_extra) - return - if ( isinstance(owner, Team) and GroupAssignee.objects.filter(group=group, team=owner.id).exists() @@ -445,7 +440,7 @@ def _matching_ownership_rules( return [rule for rule in rules if rule.test(data, munged_data)] -def process_resource_change(instance, change, **kwargs): +def process_resource_change(instance: ProjectOwnership, change: str, **kwargs: object) -> None: from sentry.models.groupowner import GroupOwner from sentry.models.projectownership import ProjectOwnership diff --git a/src/sentry/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/projectteam.py b/src/sentry/models/projectteam.py index 51f3c31ea96d..ce65dfcc2f29 100644 --- a/src/sentry/models/projectteam.py +++ b/src/sentry/models/projectteam.py @@ -42,11 +42,11 @@ class Meta: __repr__ = sane_repr("project_id", "team_id") -def process_resource_change(instance: ProjectTeam, **kwargs): +def process_resource_change(instance: ProjectTeam, **kwargs: object) -> None: from sentry.models.project import Project from sentry.tasks.codeowners import update_code_owners_schema - def _spawn_task(): + def _spawn_task() -> None: try: update_code_owners_schema.apply_async( kwargs={ diff --git a/src/sentry/models/pullrequest.py b/src/sentry/models/pullrequest.py index 2cb841ea68d8..75ec68145e39 100644 --- a/src/sentry/models/pullrequest.py +++ b/src/sentry/models/pullrequest.py @@ -435,7 +435,8 @@ def get_external_url(self) -> str | None: return None provider_cls = bindings.get("integration-repository.provider").get(provider_id) provider = provider_cls(provider_id) - return provider.pull_request_url(repository, self) + url = provider.pull_request_url(repository, self) + return url if isinstance(url, str) else None def is_unused(self, cutoff_date: datetime) -> bool: """ diff --git a/src/sentry/models/recentsearch.py b/src/sentry/models/recentsearch.py index 172153c8fffa..79f5f05814ce 100644 --- a/src/sentry/models/recentsearch.py +++ b/src/sentry/models/recentsearch.py @@ -6,6 +6,8 @@ from sentry.backup.scopes import RelocationScope from sentry.db.models import FlexibleForeignKey, Model, cell_silo_model, sane_repr from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey +from sentry.models.organization import Organization +from sentry.users.models.user import User from sentry.utils.hashlib import md5_text MAX_RECENT_SEARCHES = 30 @@ -35,7 +37,7 @@ class Meta: __repr__ = sane_repr("organization_id", "user_id", "type", "query") -def remove_excess_recent_searches(organization, user, search_type): +def remove_excess_recent_searches(organization: Organization, user: User, search_type: int) -> None: """ Remove any excess recent searches. We do this by sorting by `last_seen` descending and removing any rows after the `MAX_RECENT_SEARCHES` row. In @@ -48,6 +50,6 @@ def remove_excess_recent_searches(organization, user, search_type): @receiver(pre_save, sender=RecentSearch) -def issue_saved(sender, instance, **kwargs): +def issue_saved(sender: type[Model], instance: RecentSearch, **kwargs: object) -> None: if not instance.query_hash: instance.query_hash = md5_text(instance.query).hexdigest() diff --git a/src/sentry/models/relay.py b/src/sentry/models/relay.py index 833cfb88ef21..6135484594b4 100644 --- a/src/sentry/models/relay.py +++ b/src/sentry/models/relay.py @@ -1,6 +1,10 @@ -from typing import Any +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any from django.db import models +from django.db.models import QuerySet from django.utils import timezone from django.utils.functional import cached_property from sentry_relay.auth import PublicKey @@ -11,6 +15,9 @@ from sentry.backup.scopes import RelocationScope from sentry.db.models import Model, cell_silo_model +if TYPE_CHECKING: + from sentry.models.organization import Organization + @cell_silo_model class RelayUsage(OverwritableConfigMixin, Model): @@ -57,10 +64,10 @@ class Meta: db_table = "sentry_relay" @cached_property - def public_key_object(self): + def public_key_object(self) -> PublicKey: return PublicKey.parse(self.public_key) - def has_org_access(self, org): + def has_org_access(self, org: Organization) -> bool: # Internal relays always have access if self.is_internal: return True @@ -75,7 +82,7 @@ def has_org_access(self, org): return False @staticmethod - def for_keys(keys): + def for_keys(keys: Sequence[str]) -> QuerySet[Relay]: """ Returns all the relays that are configured with one of the specified keys """ diff --git a/src/sentry/models/release.py b/src/sentry/models/release.py index fdebd6bbdf80..40c96f73f4c6 100644 --- a/src/sentry/models/release.py +++ b/src/sentry/models/release.py @@ -2,14 +2,14 @@ from __future__ import annotations import logging -from collections.abc import Mapping, Sequence +from collections.abc import Mapping, MutableMapping, Sequence from datetime import datetime -from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict, cast +from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict, cast, overload import orjson from django.contrib.postgres.fields.array import ArrayField from django.db import IntegrityError, models, router -from django.db.models import Case, Exists, F, Func, OuterRef, Q, Sum, When +from django.db.models import Case, Exists, F, Func, OuterRef, Q, QuerySet, Sum, When from django.db.models.functions import Coalesce from django.utils import timezone from django.utils.functional import cached_property @@ -58,6 +58,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: + from sentry.models.distribution import Distribution from sentry.models.project import Project @@ -72,7 +73,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 +82,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 +146,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,17 +160,17 @@ 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( self, organization_id: int, operator: str, - value, + value: str | list[str], project_ids: Sequence[int] | None = None, environments: Sequence[str | int] | None = None, - ) -> models.QuerySet: + ) -> ReleaseQuerySet: return self.get_queryset().filter_by_stage( organization_id, operator, value, project_ids, environments ) @@ -186,7 +187,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( @@ -250,7 +251,7 @@ class Release(Model): projects = models.ManyToManyField( "sentry.Project", related_name="releases", through=ReleaseProject ) - status = BoundedPositiveIntegerField( + status = BoundedPositiveIntegerField[int | None, int | None]( default=ReleaseStatus.OPEN, null=True, choices=( @@ -273,11 +274,11 @@ class Release(Model): owner_id = HybridCloudForeignKey("sentry.User", on_delete="SET_NULL", null=True, blank=True) # materialized stats - commit_count = BoundedPositiveIntegerField(null=True, default=0) - last_commit_id = BoundedBigIntegerField(null=True) + commit_count = BoundedPositiveIntegerField[int | None, int | None](null=True, default=0) + last_commit_id = BoundedBigIntegerField[int | None, int | None](null=True) authors = ArrayField(models.TextField(), default=list, null=True) - total_deploys = BoundedPositiveIntegerField(null=True, default=0) - last_deploy_id = BoundedBigIntegerField(null=True) + total_deploys = BoundedPositiveIntegerField[int | None, int | None](null=True, default=0) + last_deploy_id = BoundedBigIntegerField[int | None, int | None](null=True) # Denormalized semver columns. These will be filled if `version` matches at least # part of our more permissive model of semver: @@ -375,12 +376,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 +396,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 +408,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 """ @@ -427,7 +428,9 @@ def is_semver_version(version): return False @staticmethod - def is_release_newer_or_equal(org_id, release, other_release): + def is_release_newer_or_equal( + org_id: int, release: str | None, other_release: str | None + ) -> bool: if release is None: return False @@ -463,18 +466,18 @@ 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 | None) -> 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 - def get(cls, project, version): + def get(cls, project: Project, version: str | None) -> Release | None: cache_key = cls.get_cache_key(project.organization_id, version) - release = cache.get(cache_key) + release: Release | int | None = cache.get(cache_key) if release is None: try: release = cls.objects.get( @@ -485,12 +488,56 @@ def get(cls, project, version): cache.set(cache_key, release, 300) if release == -1: - return + return None + + return cast("Release | None", release) + + @overload + @classmethod + def get_or_create( + cls, + project: Project, + version: str, + date_added: datetime | None = None, + *, + create: Literal[True] = True, + metrics_tags: MutableMapping[str, str | int | float | None] | None = None, + ) -> Release: ... + + @overload + @classmethod + def get_or_create( + cls, + project: Project, + version: str, + date_added: datetime | None = None, + *, + create: Literal[False], + metrics_tags: MutableMapping[str, str | int | float | None] | None = None, + ) -> Release | None: ... - return release + @overload + @classmethod + def get_or_create( + cls, + project: Project, + version: str, + date_added: datetime | None = None, + *, + create: bool, + metrics_tags: MutableMapping[str, str | int | float | None] | None = None, + ) -> Release | None: ... @classmethod - def get_or_create(cls, project, version, date_added=None, *, create=True, metrics_tags=None): + def get_or_create( + cls, + project: Project, + version: str, + date_added: datetime | None = None, + *, + create: bool = True, + metrics_tags: MutableMapping[str, str | int | float | None] | None = None, + ) -> Release | None: with metrics.timer("models.release.get_or_create") as timer_tags: release = cls._get_or_create_impl(project, version, date_added, timer_tags, create) if metrics_tags is not None: @@ -498,7 +545,14 @@ def get_or_create(cls, project, version, date_added=None, *, create=True, metric return release @classmethod - def _get_or_create_impl(cls, project, version, date_added, metric_tags, create=True): + def _get_or_create_impl( + cls, + project: Project, + version: str, + date_added: datetime | None, + metric_tags: MutableMapping[str, str | int | float | None], + create: bool = True, + ) -> Release | None: from sentry.models.project import Project if date_added is None: @@ -585,10 +639,11 @@ def _get_or_create_impl(cls, project, version, date_added, metric_tags, create=T metric_tags["cache_hit"] = "true" metric_tags[DATA_ACCESS_TAG] = DataAccessTagValues.CACHE_HIT.value - return release + # justification: the cache stores either a Release or the -1 sentinel, both handled above. + return cast("Release | None", release) @cached_property - def version_info(self): + def version_info(self) -> dict[str, Any] | None: # justification: decoded release JSON try: return parse_release(self.version, json_loads=orjson.loads) except RelayError: @@ -596,7 +651,7 @@ def version_info(self): return None @classmethod - def merge(cls, to_release, from_releases): + def merge(cls, to_release: Release, from_releases: Sequence[Release]) -> None: # The following models reference release: # ReleaseCommit.release # ReleaseEnvironment.release_id @@ -626,6 +681,7 @@ def merge(cls, to_release, from_releases): ) for release in from_releases: for model in model_list: + update_kwargs: dict[str, int | Release] if hasattr(model, "release"): update_kwargs = {"release": to_release} else: @@ -648,7 +704,7 @@ def merge(cls, to_release, from_releases): release.delete() - def add_dist(self, name, date_added=None): + def add_dist(self, name: str, date_added: datetime | None = None) -> Distribution: from sentry.models.distribution import Distribution if date_added is None: @@ -659,7 +715,7 @@ def add_dist(self, name, date_added=None): defaults={"date_added": date_added, "organization_id": self.organization_id}, )[0] - def add_project(self, project): + def add_project(self, project: Project) -> tuple[ReleaseProject | None, bool]: """ Add a project to this release. @@ -679,7 +735,8 @@ def add_project(self, project): return obj, created - def handle_commit_ranges(self, refs): + # justification: decoded release JSON + def handle_commit_ranges(self, refs: list[dict[str, Any]]) -> None: """ Takes commit refs of the form: [ @@ -694,7 +751,13 @@ def handle_commit_ranges(self, refs): if COMMIT_RANGE_DELIMITER in ref["commit"]: ref["previousCommit"], ref["commit"] = ref["commit"].split(COMMIT_RANGE_DELIMITER) - def set_refs(self, refs, user_id, fetch=False): + def set_refs( + # justification: decoded release JSON + self, + refs: list[dict[str, Any]], # justification: decoded release JSON + user_id: int | None, + fetch: bool = False, + ) -> None: with traces.start_span(name="set_refs", attributes={"sentry.op": "set_refs"}): from sentry.api.exceptions import InvalidRepository from sentry.models.releaseheadcommit import ReleaseHeadCommit @@ -737,7 +800,7 @@ def set_refs(self, refs, user_id, fetch=False): ) @traces.trace - def set_commits(self, commit_list): + def set_commits(self, commit_list: list[Any]) -> None: # justification: decoded release JSON """ Bind a list of commits to this release. @@ -752,7 +815,7 @@ def set_commits(self, commit_list): set_commits(self, commit_list) - def safe_delete(self): + def safe_delete(self) -> None: """Deletes a release if possible or raises a `UnsafeReleaseDeletion` exception. """ @@ -784,7 +847,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 +855,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. """ @@ -815,7 +880,7 @@ def count_artifacts_in_artifact_bundles(self, project_ids: Sequence[int]): return qs[0] - def clear_commits(self): + def clear_commits(self) -> None: """ Delete all release-specific commit data associated to this release. We will not delete the Commit model values because other releases may use these commits. """ @@ -939,7 +1004,9 @@ def get_artifact_counts(release_ids: list[int]) -> Mapping[int, int]: return dict(qs) -def follows_semver_versioning_scheme(org_id, project_id, release_version=None): +def follows_semver_versioning_scheme( + org_id: int, project_id: int, release_version: str | None = None +) -> bool: """ Checks if we should follow semantic versioning scheme for ordering based on 1. Latest ten releases of the project_id passed in all follow semver @@ -998,7 +1065,7 @@ def follows_semver_versioning_scheme(org_id, project_id, release_version=None): # Check release_version that is passed is semver compliant if release_version: follows_semver = follows_semver and Release.is_semver_version(release_version) - return follows_semver + return bool(follows_semver) def get_previous_release(release: Release) -> Release | None: @@ -1025,7 +1092,9 @@ def get_previous_release(release: Release) -> Release | None: ) -def filter_releases_by_projects(queryset: Any, project_ids: list[int]): +def filter_releases_by_projects( + queryset: QuerySet[Release], project_ids: Sequence[int] +) -> QuerySet[Release]: """Return releases belonging to a project.""" if not project_ids: return queryset @@ -1041,10 +1110,10 @@ def filter_releases_by_projects(queryset: Any, project_ids: list[int]): def filter_releases_by_environments( - queryset: Any, - project_ids: list[int], - environment_ids: list[int], -): + queryset: QuerySet[Release], + project_ids: Sequence[int], + environment_ids: Sequence[int], +) -> QuerySet[Release]: """Return a release queryset filtered by environments.""" from sentry.models.releaseprojectenvironment import ReleaseProjectEnvironment diff --git a/src/sentry/models/release_threshold/constants.py b/src/sentry/models/release_threshold/constants.py index e231687e1ed4..f9cb8402b7f9 100644 --- a/src/sentry/models/release_threshold/constants.py +++ b/src/sentry/models/release_threshold/constants.py @@ -31,7 +31,7 @@ class ReleaseThresholdType: CRASH_FREE_USER_RATE_STR: Final = "crash_free_user_rate" @classmethod - def as_choices(cls): + def as_choices(cls) -> tuple[tuple[str, int], ...]: return ( (cls.TOTAL_ERROR_COUNT_STR, cls.TOTAL_ERROR_COUNT), (cls.NEW_ISSUE_COUNT_STR, cls.NEW_ISSUE_COUNT), @@ -43,7 +43,7 @@ def as_choices(cls): ) @classmethod - def as_str_choices(cls): + def as_str_choices(cls) -> tuple[tuple[str, str], ...]: return ( (cls.TOTAL_ERROR_COUNT_STR, cls.TOTAL_ERROR_COUNT_STR), (cls.NEW_ISSUE_COUNT_STR, cls.NEW_ISSUE_COUNT_STR), @@ -63,14 +63,14 @@ class TriggerType: UNDER_STR: Final = "under" @classmethod - def as_choices(cls): # choices for model column + def as_choices(cls) -> tuple[tuple[str, int], ...]: # choices for model column return ( (cls.OVER_STR, cls.OVER), (cls.UNDER_STR, cls.UNDER), ) @classmethod - def as_str_choices(cls): # choices for serializer + def as_str_choices(cls) -> tuple[tuple[str, str], ...]: # choices for serializer return ( (cls.OVER_STR, cls.OVER_STR), (cls.UNDER_STR, cls.UNDER_STR), diff --git a/src/sentry/models/releasecommit.py b/src/sentry/models/releasecommit.py index bb68cc5067bd..c90b9ab73959 100644 --- a/src/sentry/models/releasecommit.py +++ b/src/sentry/models/releasecommit.py @@ -15,7 +15,7 @@ class ReleaseCommit(Model): organization_id = BoundedBigIntegerField(db_index=True) # DEPRECATED - project_id = BoundedBigIntegerField(null=True) + project_id = BoundedBigIntegerField[int | None, int | None](null=True) release = FlexibleForeignKey("sentry.Release") commit = FlexibleForeignKey("sentry.Commit", db_constraint=False) order = BoundedPositiveIntegerField() diff --git a/src/sentry/models/releaseenvironment.py b/src/sentry/models/releaseenvironment.py index 1c16b865f1fb..a754cf0ff852 100644 --- a/src/sentry/models/releaseenvironment.py +++ b/src/sentry/models/releaseenvironment.py @@ -1,3 +1,8 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, cast + from django.db import models from django.utils import timezone @@ -9,11 +14,17 @@ cell_silo_model, sane_repr, ) +from sentry.metrics.base import MutableTags from sentry.models.metric_tags import DATA_ACCESS_TAG, DataAccessTagValues from sentry.utils import metrics from sentry.utils.cache import cache from sentry.utils.last_seen import BumpResult, try_bump_last_seen +if TYPE_CHECKING: + from sentry.models.environment import Environment + from sentry.models.project import Project + from sentry.models.release import Release + @cell_silo_model class ReleaseEnvironment(Model): @@ -21,7 +32,7 @@ class ReleaseEnvironment(Model): organization = FlexibleForeignKey("sentry.Organization", db_index=True, db_constraint=False) # DEPRECATED - project_id = BoundedBigIntegerField(null=True) + project_id = BoundedBigIntegerField[int | None, int | None](null=True) release = FlexibleForeignKey("sentry.Release", db_index=True, db_constraint=False) environment = FlexibleForeignKey("sentry.Environment", db_index=True, db_constraint=False) first_seen = models.DateTimeField(default=timezone.now) @@ -35,19 +46,35 @@ class Meta: __repr__ = sane_repr("organization_id", "release_id", "environment_id") @classmethod - def get_cache_key(cls, organization_id, release_id, environment_id) -> str: + def get_cache_key(cls, organization_id: int, release_id: int, environment_id: int) -> str: return f"releaseenv:2:{organization_id}:{release_id}:{environment_id}" @classmethod - def get_or_create(cls, project, release, environment, datetime, metrics_tags=None, **kwargs): + def get_or_create( + cls, + project: Project, + release: Release, + environment: Environment, + datetime: datetime, + metrics_tags: MutableTags | None = None, + **kwargs: object, + ) -> ReleaseEnvironment: with metrics.timer("models.releaseenvironment.get_or_create") as timer_tags: - instance = cls._get_or_create_impl(project, release, environment, datetime, timer_tags) + tags = timer_tags + instance = cls._get_or_create_impl(project, release, environment, datetime, tags) if metrics_tags is not None: - metrics_tags.update(timer_tags) + metrics_tags.update(tags) return instance @classmethod - def _get_or_create_impl(cls, project, release, environment, datetime, metric_tags): + def _get_or_create_impl( + cls, + project: Project, + release: Release, + environment: Environment, + datetime: datetime, + metric_tags: MutableTags, + ) -> ReleaseEnvironment: cache_key = cls.get_cache_key(project.id, release.id, environment.id) instance = cache.get(cache_key) @@ -89,4 +116,5 @@ def _get_or_create_impl(cls, project, release, environment, datetime, metric_tag else: metric_tags[DATA_ACCESS_TAG] = DataAccessTagValues.DB_READ.value - return instance + # justification: the cache only stores ReleaseEnvironment instances under this key. + return cast("ReleaseEnvironment", instance) diff --git a/src/sentry/models/releasefile.py b/src/sentry/models/releasefile.py index fc0595d6eac2..0b3b1dbc888c 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, TypedDict, 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 @@ -36,6 +40,24 @@ logger = logging.getLogger(__name__) +class ArtifactIndex(TypedDict, total=False): + files: dict[str, dict[str, object]] + + +def _load_json_object(data: str | bytes) -> dict[str, object]: + raw = json.loads(data) + if not isinstance(raw, dict): + raise ValueError("expected a JSON object") + return raw + + +def _load_json_object_from_file(fp: IO[bytes]) -> dict[str, object]: + raw = json.load(fp) + if not isinstance(raw, dict): + raise ValueError("expected a JSON object") + return raw + + ARTIFACT_INDEX_FILENAME = "artifact-index.json" ARTIFACT_INDEX_TYPE = "release.artifact-index" @@ -52,7 +74,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") @@ -69,12 +91,12 @@ class ReleaseFile(Model): organization_id = BoundedBigIntegerField(db_index=True) # DEPRECATED - project_id = BoundedBigIntegerField(null=True) + project_id = BoundedBigIntegerField[int | None, int | None](null=True) release_id = BoundedBigIntegerField(db_index=True) file = FlexibleForeignKey("sentry.File") ident = models.CharField(max_length=40) name = models.TextField() - dist_id = BoundedBigIntegerField(null=True, db_index=True) + dist_id = BoundedBigIntegerField[int | None, int | None](null=True, db_index=True) date_accessed = models.DateTimeField(default=timezone.now, db_default=Now()) @@ -82,7 +104,7 @@ class ReleaseFile(Model): #: For release archives, this field is 0. #: For artifact indexes, this field is the number of artifacts contained #: in the index. - artifact_count = BoundedPositiveIntegerField(null=True, default=1) + artifact_count = BoundedPositiveIntegerField[int | None, int | None](null=True, default=1) __repr__ = sane_repr("release", "ident") @@ -95,7 +117,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 +131,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 +193,30 @@ 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()} - - def __enter__(self): + if not isinstance(files, dict): + raise ValueError("expected manifest files to be a JSON object") + self.artifact_count = len(files) + self._entries_by_url = { + cast(str, entry["url"]): (path, entry) for path, entry in files.items() + } + + 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 +226,10 @@ def info(self, filename: str) -> zipfile.ZipInfo: def read(self, filename: str) -> bytes: return self._zip_file.read(filename) - def _read_manifest(self) -> dict: - manifest_bytes = self.read("manifest.json") - return json.loads(manifest_bytes.decode("utf-8")) + def _read_manifest(self) -> dict[str, object]: + return _load_json_object(self.read("manifest.json")) - 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 +237,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 +253,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) -> ArtifactIndex: """Meant to be read-only""" - return self._data + return cast(ArtifactIndex, 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 +290,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: object) -> 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) -> ArtifactIndex | None: """Simple read, no synchronization necessary""" try: releasefile = self._releasefile_qs()[0] @@ -253,10 +307,12 @@ def readable_data(self) -> dict | None: else: fp = releasefile.file.getfile() with fp: - return json.load(fp) + return cast(ArtifactIndex, _load_json_object_from_file(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 +342,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 = _load_json_object_from_file(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 +361,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,23 +380,25 @@ 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=self._dist.id if self._dist is not None else None, name=ARTIFACT_INDEX_FILENAME, ident=self._ident, ) @traces.trace -def read_artifact_index(release: Release, dist: Distribution | None, **filter_args) -> dict | None: +def read_artifact_index( + release: Release, dist: Distribution | None, **filter_args: object +) -> ArtifactIndex | None: """Get index data""" guard = _ArtifactIndexGuard(release, dist, **filter_args) return guard.readable_data() @@ -353,8 +414,8 @@ def update_artifact_index( release: Release, dist: Distribution | None, archive_file: File, - temp_file: IO | None = None, -): + temp_file: IO[bytes] | None = None, +) -> ReleaseFile | None: """Add information from release archive to artifact index :returns: The created ReleaseFile instance @@ -372,13 +433,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 + return None 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 +449,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/models/releaseprojectenvironment.py b/src/sentry/models/releaseprojectenvironment.py index 2a73fcfe14be..bc73ef9faa80 100644 --- a/src/sentry/models/releaseprojectenvironment.py +++ b/src/sentry/models/releaseprojectenvironment.py @@ -2,7 +2,7 @@ from datetime import datetime from enum import Enum -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict, cast from django.db import models from django.utils import timezone @@ -15,11 +15,17 @@ cell_silo_model, sane_repr, ) +from sentry.metrics.base import MutableTags from sentry.models.metric_tags import DATA_ACCESS_TAG, DataAccessTagValues from sentry.utils import metrics from sentry.utils.cache import cache from sentry.utils.last_seen import BumpResult, try_bump_last_seen +if TYPE_CHECKING: + from sentry.models.environment import Environment + from sentry.models.project import Project + from sentry.models.release import Release + class ReleaseStages(str, Enum): ADOPTED = "adopted" @@ -37,7 +43,7 @@ class ReleaseProjectEnvironment(Model): new_issues_count = BoundedPositiveIntegerField(default=0) first_seen = models.DateTimeField(default=timezone.now) last_seen = models.DateTimeField(default=timezone.now, db_index=True) - last_deploy_id = BoundedPositiveIntegerField(null=True, db_index=True) + last_deploy_id = BoundedPositiveIntegerField[int | None, int | None](null=True, db_index=True) adopted = models.DateTimeField(null=True, blank=True) unadopted = models.DateTimeField(null=True, blank=True) @@ -54,11 +60,19 @@ class Meta: __repr__ = sane_repr("project", "release", "environment") @classmethod - def get_cache_key(cls, release_id, project_id, environment_id) -> str: + def get_cache_key(cls, release_id: int, project_id: int, environment_id: int) -> str: return f"releaseprojectenv:{release_id}:{project_id}:{environment_id}" @classmethod - def get_or_create(cls, release, project, environment, datetime, metrics_tags=None, **kwargs): + def get_or_create( + cls, + release: Release, + project: Project, + environment: Environment, + datetime: datetime, + metrics_tags: MutableTags | None = None, + **kwargs: object, + ) -> ReleaseProjectEnvironment: with metrics.timer("models.releaseprojectenvironment.get_or_create") as timer_tags: instance = cls._get_or_create_impl( release, project, environment, datetime, timer_tags, **kwargs @@ -68,7 +82,15 @@ def get_or_create(cls, release, project, environment, datetime, metrics_tags=Non return instance @classmethod - def _get_or_create_impl(cls, release, project, environment, datetime, metrics_tags, **kwargs): + def _get_or_create_impl( + cls, + release: Release, + project: Project, + environment: Environment, + datetime: datetime, + metrics_tags: MutableTags, + **kwargs: object, + ) -> ReleaseProjectEnvironment: cache_key = cls.get_cache_key(project.id, release.id, environment.id) instance = cache.get(cache_key) @@ -110,7 +132,8 @@ def _get_or_create_impl(cls, release, project, environment, datetime, metrics_ta else: metrics_tags[DATA_ACCESS_TAG] = DataAccessTagValues.DB_READ.value - return instance + # justification: the cache only stores ReleaseProjectEnvironment instances under this key. + return cast("ReleaseProjectEnvironment", instance) @property def adoption_stages(self) -> AdoptionStage: diff --git a/src/sentry/models/releases/release_project.py b/src/sentry/models/releases/release_project.py index aacbcfad8b77..68a1dda28b55 100644 --- a/src/sentry/models/releases/release_project.py +++ b/src/sentry/models/releases/release_project.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar from django.db import models @@ -16,12 +16,15 @@ from sentry.db.models.manager.base import BaseManager from sentry.tasks.relay import schedule_invalidate_project_config +if TYPE_CHECKING: + from sentry.models.project import Project + logger = logging.getLogger(__name__) class ReleaseProjectModelManager(BaseManager["ReleaseProject"]): @staticmethod - def _on_post(project, trigger): + def _on_post(project: Project, trigger: str) -> None: from sentry.dynamic_sampling import ProjectBoostedReleases project_boosted_releases = ProjectBoostedReleases(project) @@ -36,7 +39,7 @@ def _on_post(project, trigger): def post_save(self, *, instance: ReleaseProject, created: bool, **kwargs: object) -> None: self._on_post(project=instance.project, trigger="releaseproject.post_save") - def post_delete(self, instance, **kwargs): + def post_delete(self, instance: ReleaseProject, **kwargs: object) -> None: self._on_post(project=instance.project, trigger="releaseproject.post_delete") @@ -46,7 +49,7 @@ class ReleaseProject(Model): project = FlexibleForeignKey("sentry.Project") release = FlexibleForeignKey("sentry.Release") - new_groups = BoundedPositiveIntegerField(null=True, default=0) + new_groups = BoundedPositiveIntegerField[int | None, int | None](null=True, default=0) adopted = models.DateTimeField(null=True, blank=True) unadopted = models.DateTimeField(null=True, blank=True) diff --git a/src/sentry/models/releases/set_commits.py b/src/sentry/models/releases/set_commits.py index e1a25b109f89..eebb4e8443bd 100644 --- a/src/sentry/models/releases/set_commits.py +++ b/src/sentry/models/releases/set_commits.py @@ -3,7 +3,9 @@ import itertools import logging import re +from collections.abc import Sequence from contextlib import nullcontext +from datetime import datetime from typing import Any, TypedDict from django.db import IntegrityError, router @@ -43,16 +45,32 @@ from sentry.models.releasecommit import ReleaseCommit from sentry.models.releaseheadcommit import ReleaseHeadCommit from sentry.models.repository import Repository -from sentry.plugins.providers.integration_repository import IntegrationRepositoryProvider +from sentry.plugins.providers.integration_repository import ( + CommitPatchFile, + IntegrationRepositoryProvider, +) class _CommitDataKwargs(TypedDict, total=False): author: CommitAuthor message: str - date_added: str + date_added: datetime + + +class _CommitData(TypedDict, total=False): + id: str + repository: str + author_email: str | None + author_name: str + message: str + timestamp: datetime + patch_set: Sequence[CommitPatchFile] + _normalized_email: str + repo_model: Repository + author_model: CommitAuthor | None -def set_commits(release, commit_list): +def set_commits(release: Release, commit_list: list[_CommitData]) -> None: commit_list.sort(key=lambda commit: commit.get("timestamp", 0), reverse=True) commit_list = [ @@ -89,7 +107,7 @@ def set_commits(release, commit_list): update_group_resolutions(release, commit_author_by_commit) -def _keep_newest_commits(release, commit_list): +def _keep_newest_commits(release: Release, commit_list: list[_CommitData]) -> list[_CommitData]: """ Each commit costs several queries, all issued while holding the release lock and usually inside a web request, so the list has to be bounded. Expects @@ -113,7 +131,9 @@ def _keep_newest_commits(release, commit_list): @metrics.wraps("set_commits_on_release") -def set_commits_on_release(release, commit_list): +def set_commits_on_release( + release: Release, commit_list: list[_CommitData] +) -> tuple[dict[int, int], dict[int, CommitAuthor | None]]: # TODO(dcramer): would be good to optimize the logic to avoid these # deletes but not overly important ReleaseCommit.objects.filter(release=release).delete() @@ -145,7 +165,7 @@ def set_commits_on_release(release, commit_list): return head_commit_by_repo, commit_author_by_commit -def set_commit(idx, data, release): +def set_commit(idx: int, data: _CommitData, release: Release) -> Commit: repo = data["repo_model"] author = data["author_model"] @@ -201,7 +221,9 @@ def set_commit(idx, data, release): return commit -def fill_in_missing_release_head_commits(release, head_commit_by_repo): +def fill_in_missing_release_head_commits( + release: Release, head_commit_by_repo: dict[int, int] +) -> None: # fill any missing ReleaseHeadCommit entries for repo_id, commit_id in head_commit_by_repo.items(): try: @@ -216,7 +238,9 @@ def fill_in_missing_release_head_commits(release, head_commit_by_repo): pass -def update_group_resolutions(release, commit_author_by_commit): +def update_group_resolutions( + release: Release, commit_author_by_commit: dict[int, CommitAuthor | None] +) -> None: release_commits = list( ReleaseCommit.objects.filter(release=release) .select_related("commit") @@ -438,7 +462,7 @@ def normalize_provider_key(provider: str | None) -> str | None: return provider.removeprefix("integrations:") if provider else None -def create_commit_authors(commit_list, release): +def create_commit_authors(commit_list: list[_CommitData], release: Release) -> None: # Collect unique emails and their author data author_data_by_email = {} for data in commit_list: @@ -515,7 +539,7 @@ def create_commit_authors(commit_list, release): data["author_model"] = existing_authors.get(author_email) if author_email else None -def create_repositories(commit_list, release): +def create_repositories(commit_list: list[_CommitData], release: Release) -> None: repos = {} for data in commit_list: repo_name = data.get("repository") or f"organization-{release.organization_id}" diff --git a/src/sentry/models/releases/util.py b/src/sentry/models/releases/util.py index 69022cc989ba..490d79dbd13f 100644 --- a/src/sentry/models/releases/util.py +++ b/src/sentry/models/releases/util.py @@ -168,7 +168,7 @@ def filter_by_stage( self, organization_id: int, operator: str, - value, + value: str | list[str], project_ids: Sequence[int] | None = None, environments: Sequence[str | int] | None = None, ) -> Self: @@ -178,7 +178,7 @@ def filter_by_stage( if not environments or len(environments) != 1: raise InvalidSearchQuery("Choose a single environment to filter by release stage.") - filters = { + filters: dict[str, Q] = { ReleaseStages.ADOPTED: Q(adopted__isnull=False, unadopted__isnull=True), ReleaseStages.REPLACED: Q(adopted__isnull=False, unadopted__isnull=False), ReleaseStages.LOW_ADOPTION: Q(adopted__isnull=True, unadopted__isnull=True), @@ -247,16 +247,17 @@ def order_by_recent(self) -> Self: return self.order_by("-date_added", "-id") @staticmethod - def massage_semver_cols_into_release_object_data(kwargs): + def massage_semver_cols_into_release_object_data(kwargs: dict[str, object]) -> None: """ Helper function that takes kwargs as an argument and massages into it the release semver columns (if possible) Inputs: * kwargs: data of the release that is about to be created """ - if "version" in kwargs: + version = kwargs.get("version") + if isinstance(version, str): try: - version_info = parse_release(kwargs["version"]) + version_info = parse_release(version) package = version_info.get("package") version_parsed = version_info.get("version_parsed") @@ -284,7 +285,7 @@ def massage_semver_cols_into_release_object_data(kwargs): pass @staticmethod - def _convert_build_code_to_build_number(build_code): + def _convert_build_code_to_build_number(build_code: str | None) -> int | None: """ Helper function that takes the build_code and checks if that build code can be parsed into a 64 bit integer @@ -304,7 +305,7 @@ def _convert_build_code_to_build_number(build_code): return build_number -def parse_semver_pre_save(instance, **kwargs): +def parse_semver_pre_save(instance: Release, **kwargs: object) -> None: if instance.id: return ReleaseQuerySet.massage_semver_cols_into_release_object_data(instance.__dict__) diff --git a/src/sentry/models/repository.py b/src/sentry/models/repository.py index c09b55a0805f..82149d2abbea 100644 --- a/src/sentry/models/repository.py +++ b/src/sentry/models/repository.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, ClassVar, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast from django.contrib.postgres.fields.array import ArrayField from django.db import models, router, transaction @@ -32,6 +32,9 @@ from sentry.users.services.user import RpcUser from sentry.utils.email import MessageBuilder +if TYPE_CHECKING: + from sentry.plugins.providers import IntegrationRepositoryProvider + REPOSITORY_NAME_LENGTH = 500 REPOSITORY_URL_LENGTH = 512 @@ -165,7 +168,7 @@ class Repository(Model): default=ObjectStatus.ACTIVE, choices=ObjectStatus.as_choices(), db_index=True ) date_added = models.DateTimeField(default=timezone.now) - integration_id = BoundedPositiveIntegerField(db_index=True, null=True) + integration_id = BoundedPositiveIntegerField[int | None, int | None](db_index=True, null=True) languages = ArrayField(models.TextField(), default=list) objects: ClassVar[RepositoryManager] = RepositoryManager() @@ -178,19 +181,22 @@ class Meta: __repr__ = sane_repr("organization_id", "name", "provider") - def has_integration_provider(self): - return self.provider and self.provider.startswith("integrations:") + def has_integration_provider(self) -> bool: + return bool(self.provider and self.provider.startswith("integrations:")) - def get_provider(self): + def get_provider(self) -> IntegrationRepositoryProvider | None: from sentry.plugins.base import bindings if not self.has_integration_provider(): return None - provider_cls = bindings.get("integration-repository.provider").get(self.provider) - return provider_cls(self.provider) + provider = self.provider + assert provider is not None + provider_cls = bindings.get("integration-repository.provider").get(provider) + # justification: the integration repository binding maps provider ids to provider classes. + return cast("IntegrationRepositoryProvider", provider_cls(provider)) - def generate_delete_fail_email(self, error_message): + def generate_delete_fail_email(self, error_message: str) -> MessageBuilder: provider = self.get_provider() new_context = { "repo": self, @@ -205,7 +211,7 @@ def generate_delete_fail_email(self, error_message): html_template="sentry/emails/unable-to-delete-repo.html", ) - def send_delete_fail_email(self, error_message, actor_email): + def send_delete_fail_email(self, error_message: str, actor_email: str) -> None: from sentry.notifications.platform.service import NotificationService from sentry.notifications.platform.target import GenericNotificationTarget from sentry.notifications.platform.templates.repository import UnableToDeleteRepository @@ -306,7 +312,7 @@ def _handle_auto_enable_code_review(self) -> None: ) -def on_delete(instance, actor: RpcUser | None = None, **kwargs): +def on_delete(instance: Repository, actor: RpcUser | None = None, **kwargs: object) -> None: """ Remove webhooks for repository providers that use repository level webhooks. This is called from sentry.deletions.tasks.run_deletion() @@ -315,7 +321,7 @@ def on_delete(instance, actor: RpcUser | None = None, **kwargs): if not instance.provider: return - def handle_exception(e): + def handle_exception(e: Exception) -> None: from sentry.exceptions import InvalidIdentity, PluginError from sentry.shared_integrations.exceptions import IntegrationError @@ -328,7 +334,9 @@ def handle_exception(e): if instance.has_integration_provider(): try: - instance.get_provider().on_delete_repository(repo=instance) + provider = instance.get_provider() + assert provider is not None + provider.on_delete_repository(repo=instance) except Exception as exc: handle_exception(exc) diff --git a/src/sentry/models/rule.py b/src/sentry/models/rule.py index 3576b220c019..67d600d0a762 100644 --- a/src/sentry/models/rule.py +++ b/src/sentry/models/rule.py @@ -1,11 +1,12 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Iterable, Sequence from enum import IntEnum -from typing import Any, ClassVar, Self +from typing import Any, ClassVar, Self, cast from django.conf import settings from django.db import models +from django.db.models.base import ModelBase from django.utils import timezone from sentry.backup.scopes import RelocationScope @@ -46,10 +47,10 @@ class Rule(Model): DEFAULT_FREQUENCY = 30 # minutes project = FlexibleForeignKey("sentry.Project") - environment_id = BoundedPositiveIntegerField(null=True) + environment_id = BoundedPositiveIntegerField[int | None, int | None](null=True) # Shadow column for the in-progress widening of `environment_id` to int8; swapped # into `environment_id` once backfilled. Nothing reads or writes it yet. - new_environment_id = BoundedBigIntegerField(null=True) + new_environment_id = BoundedBigIntegerField[int | None, int | None](null=True) label = models.CharField(max_length=256) # `data` contain all the specifics of the rule - conditions, actions, frequency, etc. data = LegacyTextJSONField(default=dict) @@ -65,7 +66,9 @@ class Rule(Model): db_default=RuleSource.ISSUE, choices=RuleSource.as_choices(), ) - owner_user_id = HybridCloudForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete="SET_NULL") + owner_user_id = HybridCloudForeignKey[int | None, int | None]( + settings.AUTH_USER_MODEL, null=True, on_delete="SET_NULL" + ) owner_team = FlexibleForeignKey("sentry.Team", null=True, on_delete=models.SET_NULL) date_added = models.DateTimeField(default=timezone.now) @@ -93,13 +96,14 @@ class Meta: __repr__ = sane_repr("project_id", "label") @classmethod - def get_for_project(cls, project_id): + def get_for_project(cls, project_id: int) -> list[Rule]: cache_key = f"project:{project_id}:rules" rules_list = cache.get(cache_key) if rules_list is None: rules_list = list(cls.objects.filter(project=project_id, status=ObjectStatus.ACTIVE)) cache.set(cache_key, rules_list, 60) - return rules_list + # justification: the cache only stores rule lists under this key. + return cast("list[Rule]", rules_list) @property def owner(self) -> Actor | None: @@ -109,28 +113,41 @@ def owner(self) -> Actor | None: @owner.setter def owner(self, actor: Actor | None) -> None: """Part of ActorOwned Protocol""" - self.owner_team_id = None + setattr(self, "owner_team_id", None) self.owner_user_id = None if actor and actor.is_user: self.owner_user_id = actor.id if actor and actor.is_team: self.owner_team_id = actor.id - def delete(self, *args, **kwargs): - rv = super().delete(*args, **kwargs) + def delete( + self, using: str | None = None, keep_parents: bool = False + ) -> tuple[int, dict[str, int]]: + rv = super().delete(using=using, keep_parents=keep_parents) self._clear_project_rule_cache() return rv - def save(self, *args, **kwargs): - rv = super().save(*args, **kwargs) + def save( + self, + *, + force_insert: bool | tuple[ModelBase, ...] = False, + force_update: bool = False, + using: str | None = None, + update_fields: Iterable[str] | None = None, + ) -> None: + super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) self._clear_project_rule_cache() - return rv def _clear_project_rule_cache(self) -> None: cache_key = f"project:{self.project_id}:rules" cache.delete(cache_key) - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, object]: return { "label": self.label, "data": self.data, @@ -150,6 +167,7 @@ def get_rule_action_details_by_uuid(self, rule_action_uuid: str) -> dict[str, An continue if action_uuid == rule_action_uuid: - return action + # justification: rule actions are stored as JSON objects. + return action if isinstance(action, dict) else None return None diff --git a/src/sentry/models/sourcemapprocessingissue.py b/src/sentry/models/sourcemapprocessingissue.py index 5ec28d2b7aef..f2cd5bda7b81 100644 --- a/src/sentry/models/sourcemapprocessingissue.py +++ b/src/sentry/models/sourcemapprocessingissue.py @@ -28,16 +28,16 @@ class SourceMapProcessingIssue: } @classmethod - def get_message(cls, data): + def get_message(cls, data: str) -> str: return cls(data).message - def __init__(self, type, data=None): + def __init__(self, type: str, data: object | None = None) -> None: self.type = type self.data = data @property - def message(self): + def message(self) -> str: return self._messages.get(self.type, self._messages["unknown_error"]) - def get_api_context(self): + def get_api_context(self) -> dict[str, object]: return {"type": self.type, "message": self.message, "data": self.data} diff --git a/src/sentry/models/team.py b/src/sentry/models/team.py index 4e5e54bc4b6f..568b5699641e 100644 --- a/src/sentry/models/team.py +++ b/src/sentry/models/team.py @@ -1,11 +1,13 @@ from __future__ import annotations -from collections.abc import Callable, Sequence -from typing import TYPE_CHECKING, ClassVar +from collections.abc import Callable, Iterable, Sequence +from typing import TYPE_CHECKING, ClassVar, cast from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.db import models, router, transaction +from django.db.models import QuerySet +from django.db.models.base import ModelBase from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -26,6 +28,8 @@ if TYPE_CHECKING: from sentry.models.organization import Organization + from sentry.models.organizationmember import OrganizationMember + from sentry.models.project import Project from sentry.users.models.user import User from sentry.users.services.user import RpcUser @@ -78,14 +82,14 @@ def get_for_user( def post_save(self, *, instance: Team, created: bool, **kwargs: object) -> None: self.process_resource_change(instance, **kwargs) - def post_delete(self, instance: Team, **kwargs): + def post_delete(self, instance: Team, **kwargs: object) -> None: self.process_resource_change(instance, **kwargs) - def process_resource_change(self, instance: Team, **kwargs): + def process_resource_change(self, instance: Team, **kwargs: object) -> None: from sentry.models.project import Project from sentry.tasks.codeowners import update_code_owners_schema - def _spawn_task(): + def _spawn_task() -> None: try: update_code_owners_schema.apply_async( kwargs={ @@ -148,8 +152,20 @@ def class_name(self) -> str: def __str__(self) -> str: return f"{self.name} ({self.slug})" - def save(self, *args, **kwargs): - save_callback = lambda: super(Team, self).save(*args, **kwargs) + def save( + self, + *, + force_insert: bool | tuple[ModelBase, ...] = False, + force_update: bool = False, + using: str | None = None, + update_fields: Iterable[str] | None = None, + ) -> None: + save_callback = lambda: super(Team, self).save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) if self.slug: self._save_team(save_callback) return @@ -174,7 +190,7 @@ def _save_team(self, save_callback: Callable[[], object]) -> None: save_callback() @property - def member_set(self): + def member_set(self) -> QuerySet[OrganizationMember]: """:returns a QuerySet of all Users that belong to this Team""" return self.organization.member_set.filter( organizationmemberteam__team=self, @@ -183,7 +199,7 @@ def member_set(self): user_is_active=True, ).distinct() - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, str | int]: return { "id": self.id, "slug": self.slug, @@ -191,10 +207,13 @@ def get_audit_log_data(self): "status": self.status, } - def get_projects(self): + def get_projects(self) -> QuerySet[Project]: from sentry.models.project import Project return Project.objects.get_for_team_ids([self.id]) - def get_member_user_ids(self): - return self.member_set.values_list("user_id", flat=True) + def get_member_user_ids(self) -> QuerySet[OrganizationMember, int]: + return cast( + "QuerySet[OrganizationMember, int]", + self.member_set.values_list("user_id", flat=True), + ) diff --git a/src/sentry/models/transaction_threshold.py b/src/sentry/models/transaction_threshold.py index b3e24bad92f4..bcacebd0ae0e 100644 --- a/src/sentry/models/transaction_threshold.py +++ b/src/sentry/models/transaction_threshold.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +from collections.abc import Sequence from enum import Enum from django.db import models @@ -27,13 +30,26 @@ class TransactionMetric(Enum): PROJECT_TRANSACTION_THRESHOLD_CACHE_TTL = 3600 -def get_project_threshold_cache_key(model_name, project_ids, org_id, order_by, value_list): +def get_project_threshold_cache_key( + model_name: str, + project_ids: Sequence[int], + org_id: int, + order_by: Sequence[str], + value_list: Sequence[str], +) -> str: return "{}:{}".format( model_name, md5_text(f"{project_ids}:{org_id}:{order_by}:{value_list}").hexdigest() ) -def _filter_and_cache(cls, cache_key, project_ids, organization_id, order_by, value_list): +def _filter_and_cache( + cls: type[ProjectTransactionThreshold] | type[ProjectTransactionThresholdOverride], + cache_key: str, + project_ids: Sequence[int], + organization_id: int, + order_by: Sequence[str], + value_list: Sequence[str], +) -> list[tuple[object, ...]]: cache_result = cache.get(cache_key) if cache_result is None: @@ -48,7 +64,7 @@ def _filter_and_cache(cls, cache_key, project_ids, organization_id, order_by, va cache.set(cache_key, result, PROJECT_TRANSACTION_THRESHOLD_CACHE_TTL) return result else: - return cache_result + return cache_result if isinstance(cache_result, list) else [] @cell_silo_model @@ -69,7 +85,13 @@ class Meta: unique_together = (("project", "transaction"),) @classmethod - def filter(cls, project_ids, organization_id, order_by, value_list): + def filter( + cls, + project_ids: Sequence[int], + organization_id: int, + order_by: Sequence[str], + value_list: Sequence[str], + ) -> list[tuple[object, ...]]: cache_key = get_project_threshold_cache_key( "sentry_projecttransactionthresholdoverride", project_ids, @@ -96,7 +118,13 @@ class Meta: db_table = "sentry_projecttransactionthreshold" @classmethod - def filter(cls, project_ids, organization_id, order_by, value_list): + def filter( + cls, + project_ids: Sequence[int], + organization_id: int, + order_by: Sequence[str], + value_list: Sequence[str], + ) -> list[tuple[object, ...]]: cache_key = get_project_threshold_cache_key( "sentry_projecttransactionthreshold", project_ids, diff --git a/src/sentry/models/userreport.py b/src/sentry/models/userreport.py index 54db36b5889c..253b8f9734c2 100644 --- a/src/sentry/models/userreport.py +++ b/src/sentry/models/userreport.py @@ -10,9 +10,9 @@ class UserReport(Model): __relocation_scope__ = RelocationScope.Excluded project_id = BoundedBigIntegerField(db_index=True) - group_id = BoundedBigIntegerField(null=True, db_index=True) + group_id = BoundedBigIntegerField[int | None, int | None](null=True, db_index=True) event_id = models.CharField(max_length=32) - environment_id = BoundedBigIntegerField(null=True, db_index=True) + environment_id = BoundedBigIntegerField[int | None, int | None](null=True, db_index=True) name = models.CharField(max_length=128) email = models.EmailField(max_length=75) comments = models.TextField( diff --git a/src/sentry/monitors/clock_dispatch.py b/src/sentry/monitors/clock_dispatch.py index 647057dcad3c..05b878e36220 100644 --- a/src/sentry/monitors/clock_dispatch.py +++ b/src/sentry/monitors/clock_dispatch.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone from arroyo import Topic as ArroyoTopic -from arroyo.backends.kafka import KafkaPayload +from arroyo.backends.kafka import KafkaPayload, KafkaProducer from django.conf import settings from sentry_kafka_schemas.codecs import Codec from sentry_kafka_schemas.schema_types.ingest_monitors_v1 import ClockPulse @@ -35,7 +35,7 @@ def _int_or_none(s: str | None) -> int | None: return int(s) -def _get_producer(): +def _get_producer() -> KafkaProducer: return get_arroyo_producer( name="sentry.monitors.clock_dispatch", topic=Topic.MONITORS_CLOCK_TICK, @@ -124,7 +124,7 @@ def _should_hold_clock_tick(missing_partitions: frozenset[int] | None) -> bool: return not options.get("crons.clock_tick.disable_hold_on_missing_partitions") -def _dispatch_tick(ts: datetime): +def _dispatch_tick(ts: datetime) -> None: """ Dispatch a clock tick which will trigger monitor tasks. @@ -150,7 +150,7 @@ def _dispatch_tick(ts: datetime): _clock_tick_producer.produce(ArroyoTopic(topic), payload) -def try_monitor_clock_tick(ts: datetime, partition: int): +def try_monitor_clock_tick(ts: datetime, partition: int) -> None: """ Handles triggering the monitor tasks when we've rolled over the minute. diff --git a/src/sentry/monitors/endpoints/base.py b/src/sentry/monitors/endpoints/base.py index 376582ec0617..eda1c75e0d02 100644 --- a/src/sentry/monitors/endpoints/base.py +++ b/src/sentry/monitors/endpoints/base.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import cast from uuid import UUID from rest_framework.permissions import BasePermission @@ -32,9 +33,9 @@ def convert_args( organization_id_or_slug: int | str, monitor_id_or_slug: str, environment: str | None = None, - *args, - **kwargs, - ): + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: try: if str(organization_id_or_slug).isdigit(): organization = Organization.objects.get_from_cache(id=organization_id_or_slug) @@ -91,15 +92,15 @@ def convert_args( self, request: Request, monitor_id_or_slug: str, - *args, - **kwargs, - ): + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: args, kwargs = super().convert_args(request, *args, **kwargs) # Try lookup by slug try: kwargs["monitor"] = Monitor.objects.get( - project_id=kwargs["project"].id, slug=monitor_id_or_slug + project_id=cast("Project", kwargs["project"]).id, slug=monitor_id_or_slug ) return args, kwargs except Monitor.DoesNotExist: @@ -109,7 +110,7 @@ def convert_args( try: UUID(monitor_id_or_slug, version=4) kwargs["monitor"] = Monitor.objects.get( - project_id=kwargs["project"].id, guid=monitor_id_or_slug + project_id=cast("Project", kwargs["project"]).id, guid=monitor_id_or_slug ) return args, kwargs except (ValueError, Monitor.DoesNotExist): @@ -131,12 +132,12 @@ def convert_args( self, request: Request, monitor_id_or_slug: str, - environment: str, - *args, - **kwargs, - ): + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: args, kwargs = super().convert_args(request, monitor_id_or_slug, *args, **kwargs) - monitor = kwargs["monitor"] + environment = cast("str", kwargs.pop("environment")) + monitor = cast("Monitor", kwargs["monitor"]) try: environment_object = Environment.objects.get( organization_id=monitor.organization_id, name=environment diff --git a/src/sentry/monitors/endpoints/base_monitor_checkin_index.py b/src/sentry/monitors/endpoints/base_monitor_checkin_index.py index e5ecbffc1c3e..a6d830080640 100644 --- a/src/sentry/monitors/endpoints/base_monitor_checkin_index.py +++ b/src/sentry/monitors/endpoints/base_monitor_checkin_index.py @@ -9,7 +9,8 @@ from sentry.api.paginator import OffsetPaginator from sentry.api.serializers import serialize from sentry.api.utils import get_date_range_from_params -from sentry.monitors.models import MonitorCheckIn +from sentry.models.project import Project +from sentry.monitors.models import Monitor, MonitorCheckIn from sentry.monitors.serializers import MonitorCheckInSerializer from sentry.monitors.utils import get_monitor_environment_ids from sentry.ratelimits.config import RateLimitConfig @@ -27,7 +28,9 @@ class MonitorCheckInMixin(BaseEndpointMixin): - def get_monitor_checkins(self, request: Request, project, monitor) -> Response: + def get_monitor_checkins( + self, request: Request, project: Project, monitor: Monitor + ) -> Response: """ Retrieve a list of check-ins for a monitor """ diff --git a/src/sentry/monitors/endpoints/base_monitor_details.py b/src/sentry/monitors/endpoints/base_monitor_details.py index 2fdba90e048e..e4a3d6ce3279 100644 --- a/src/sentry/monitors/endpoints/base_monitor_details.py +++ b/src/sentry/monitors/endpoints/base_monitor_details.py @@ -207,7 +207,7 @@ def delete_monitor( target_object=rule.id, event=audit_log.get_event_id("RULE_REMOVE"), data=rule.get_audit_log_data(), - transaction_id=scheduled_rule, + transaction_id=scheduled_rule.id, ) return self.respond(status=202) diff --git a/src/sentry/monitors/endpoints/base_monitor_environment_details.py b/src/sentry/monitors/endpoints/base_monitor_environment_details.py index 74df17750308..a5a7a0db7d10 100644 --- a/src/sentry/monitors/endpoints/base_monitor_environment_details.py +++ b/src/sentry/monitors/endpoints/base_monitor_environment_details.py @@ -8,12 +8,17 @@ from sentry.api.serializers import serialize from sentry.constants import ObjectStatus from sentry.deletions.models.scheduleddeletion import CellScheduledDeletion -from sentry.monitors.models import MonitorEnvironment, MonitorStatus +from sentry.models.project import Project +from sentry.monitors.models import Monitor, MonitorEnvironment, MonitorStatus class MonitorEnvironmentDetailsMixin(BaseEndpointMixin): def update_monitor_environment( - self, request: Request, project, monitor, monitor_environment + self, + request: Request, + project: Project, + monitor: Monitor, + monitor_environment: MonitorEnvironment, ) -> Response: """ Update a monitor environment. @@ -34,7 +39,11 @@ def update_monitor_environment( return self.respond(serialize(monitor, request.user)) def delete_monitor_environment( - self, request: Request, project, monitor, monitor_environment + self, + request: Request, + project: Project, + monitor: Monitor, + monitor_environment: MonitorEnvironment, ) -> Response: """ Delete a monitor environment. diff --git a/src/sentry/monitors/endpoints/base_monitor_stats.py b/src/sentry/monitors/endpoints/base_monitor_stats.py index a10d71f9c448..8ed10d995300 100644 --- a/src/sentry/monitors/endpoints/base_monitor_stats.py +++ b/src/sentry/monitors/endpoints/base_monitor_stats.py @@ -11,11 +11,12 @@ from sentry.api.base import BaseEndpointMixin, StatsMixin from sentry.api.helpers.environments import get_environments -from sentry.monitors.models import CheckInStatus, MonitorCheckIn +from sentry.models.project import Project +from sentry.monitors.models import CheckInStatus, Monitor, MonitorCheckIn from sentry.monitors.utils import get_monitor_environment_ids -def normalize_to_epoch(timestamp: datetime, seconds: int): +def normalize_to_epoch(timestamp: datetime, seconds: int) -> int: """ Given a ``timestamp`` (datetime object) normalize to an epoch timestamp. @@ -40,7 +41,7 @@ def validate_bucket_count(start: int, end: int, rollup: int) -> None: class MonitorStatsMixin(BaseEndpointMixin, StatsMixin): - def get_monitor_stats(self, request: Request, project, monitor) -> Response: + def get_monitor_stats(self, request: Request, project: Project, monitor: Monitor) -> Response: args = self._parse_args(request) start = normalize_to_epoch(args["start"], args["rollup"]) diff --git a/src/sentry/monitors/endpoints/organization_monitor_checkin_index.py b/src/sentry/monitors/endpoints/organization_monitor_checkin_index.py index 470c03d3883c..fac2e646693b 100644 --- a/src/sentry/monitors/endpoints/organization_monitor_checkin_index.py +++ b/src/sentry/monitors/endpoints/organization_monitor_checkin_index.py @@ -10,6 +10,9 @@ from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED from sentry.apidocs.parameters import CursorQueryParam, GlobalParams, MonitorParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.monitors.models import Monitor from sentry.monitors.serializers import MonitorCheckInSerializerResponse from .base import MonitorEndpoint @@ -43,7 +46,11 @@ class OrganizationMonitorCheckInIndexEndpoint(MonitorEndpoint, MonitorCheckInMix }, ) def get( - self, request: Request, organization, project, monitor + self, + request: Request, + organization: Organization, + project: Project, + monitor: Monitor, ) -> Response[list[MonitorCheckInSerializerResponse]]: """ Retrieve a list of check-ins for a monitor diff --git a/src/sentry/monitors/endpoints/organization_monitor_details.py b/src/sentry/monitors/endpoints/organization_monitor_details.py index 288748bff036..36ec1c97fcf0 100644 --- a/src/sentry/monitors/endpoints/organization_monitor_details.py +++ b/src/sentry/monitors/endpoints/organization_monitor_details.py @@ -16,6 +16,9 @@ ) from sentry.apidocs.parameters import GlobalParams, MonitorParams from sentry.apidocs.response_types import ValidationErrorResponse +from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.monitors.models import Monitor from sentry.monitors.serializers import MonitorSerializer, MonitorSerializerResponse from sentry.monitors.validators import MonitorValidator from sentry.utils.auth import AuthenticatedHttpRequest @@ -50,7 +53,11 @@ class OrganizationMonitorDetailsEndpoint(MonitorEndpoint, MonitorDetailsMixin): }, ) def get( - self, request: Request, organization, project, monitor + self, + request: Request, + organization: Organization, + project: Project, + monitor: Monitor, ) -> Response[MonitorSerializerResponse]: """ Retrieves details for a monitor. @@ -74,7 +81,11 @@ def get( }, ) def put( - self, request: AuthenticatedHttpRequest, organization, project, monitor + self, + request: AuthenticatedHttpRequest, + organization: Organization, + project: Project, + monitor: Monitor, ) -> Response[MonitorSerializerResponse] | Response[ValidationErrorResponse]: """ Update a monitor. @@ -97,7 +108,9 @@ def put( 404: RESPONSE_NOT_FOUND, }, ) - def delete(self, request: Request, organization, project, monitor) -> Response[None]: + def delete( + self, request: Request, organization: Organization, project: Project, monitor: Monitor + ) -> Response[None]: """ Delete a monitor or monitor environments. """ diff --git a/src/sentry/monitors/endpoints/organization_monitor_environment_details.py b/src/sentry/monitors/endpoints/organization_monitor_environment_details.py index fc30ce909dbf..3f4b5f94ca08 100644 --- a/src/sentry/monitors/endpoints/organization_monitor_environment_details.py +++ b/src/sentry/monitors/endpoints/organization_monitor_environment_details.py @@ -15,6 +15,9 @@ RESPONSE_UNAUTHORIZED, ) from sentry.apidocs.parameters import GlobalParams, MonitorParams +from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.monitors.models import Monitor, MonitorEnvironment from sentry.monitors.serializers import MonitorSerializer from .base import MonitorEndpoint @@ -48,7 +51,12 @@ class OrganizationMonitorEnvironmentDetailsEndpoint( }, ) def put( - self, request: Request, organization, project, monitor, monitor_environment + self, + request: Request, + organization: Organization, + project: Project, + monitor: Monitor, + monitor_environment: MonitorEnvironment, ) -> Response: """ Update a monitor environment. @@ -70,6 +78,11 @@ def put( }, ) def delete( - self, request: Request, organization, project, monitor, monitor_environment + self, + request: Request, + organization: Organization, + project: Project, + monitor: Monitor, + monitor_environment: MonitorEnvironment, ) -> Response: return self.delete_monitor_environment(request, project, monitor, monitor_environment) diff --git a/src/sentry/monitors/endpoints/organization_monitor_index.py b/src/sentry/monitors/endpoints/organization_monitor_index.py index cd7b4c98f6ea..a754ab874fba 100644 --- a/src/sentry/monitors/endpoints/organization_monitor_index.py +++ b/src/sentry/monitors/endpoints/organization_monitor_index.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import cast + from django.db import router, transaction from django.db.models import ( Case, @@ -61,13 +65,16 @@ from sentry.utils.outcomes import Outcome -def map_value_to_constant(constant, value): +def map_value_to_constant(constant: type[object], value: str) -> int: value = value.upper() if value == "OK": value = "ACTIVE" if not hasattr(constant, value): raise ValueError(value) - return getattr(constant, value) + result = getattr(constant, value) + if not isinstance(result, int): + raise ValueError(value) + return result def flip_sort_direction(sort_field: str) -> str: @@ -265,7 +272,7 @@ def get( try: queryset = queryset.filter( monitorenvironment__status__in=map_value_to_constant( - MonitorStatus, value + MonitorStatus, cast("str", value) ) ) except ValueError: @@ -297,7 +304,7 @@ def get( }, ) def post( - self, request: AuthenticatedHttpRequest, organization + self, request: AuthenticatedHttpRequest, organization: Organization ) -> Response[MonitorSerializerResponse]: """ Create a new monitor. @@ -329,7 +336,7 @@ def post( }, ) def put( - self, request: AuthenticatedHttpRequest, organization + self, request: AuthenticatedHttpRequest, organization: Organization ) -> Response[MonitorBulkEditResponse]: """ Bulk edit the muted and disabled status of a list of monitors determined by slug diff --git a/src/sentry/monitors/endpoints/organization_monitor_index_stats.py b/src/sentry/monitors/endpoints/organization_monitor_index_stats.py index 7329d1d619ac..83d4dd38e859 100644 --- a/src/sentry/monitors/endpoints/organization_monitor_index_stats.py +++ b/src/sentry/monitors/endpoints/organization_monitor_index_stats.py @@ -189,7 +189,7 @@ def status_obj_factory() -> StatusStats: # We manually sort the response output by guid and bucket. This is fine because the set # of keys is known (they're provided as a query parameter) and there is no pagination. - def sort_history_key(row): + def sort_history_key(row: tuple[int, int, object, int]) -> tuple[str, int]: ts, meid, _status, _count = row return monitor_map[monitor_environment_to_monitor_id[meid]], ts diff --git a/src/sentry/monitors/endpoints/organization_monitor_stats.py b/src/sentry/monitors/endpoints/organization_monitor_stats.py index 1bb665051a8a..09d93e0be3d8 100644 --- a/src/sentry/monitors/endpoints/organization_monitor_stats.py +++ b/src/sentry/monitors/endpoints/organization_monitor_stats.py @@ -6,6 +6,9 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint +from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.monitors.models import Monitor from .base import MonitorEndpoint from .base_monitor_stats import MonitorStatsMixin @@ -18,5 +21,7 @@ class OrganizationMonitorStatsEndpoint(MonitorEndpoint, MonitorStatsMixin): } owner = ApiOwner.CRONS - def get(self, request: Request, organization, project, monitor) -> Response: + def get( + self, request: Request, organization: Organization, project: Project, monitor: Monitor + ) -> Response: return self.get_monitor_stats(request, project, monitor) diff --git a/src/sentry/monitors/endpoints/project_monitor_checkin_index.py b/src/sentry/monitors/endpoints/project_monitor_checkin_index.py index a9771ddd7846..08b08e1451c8 100644 --- a/src/sentry/monitors/endpoints/project_monitor_checkin_index.py +++ b/src/sentry/monitors/endpoints/project_monitor_checkin_index.py @@ -10,6 +10,8 @@ from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED from sentry.apidocs.parameters import GlobalParams, MonitorParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.project import Project +from sentry.monitors.models import Monitor from sentry.monitors.serializers import MonitorCheckInSerializerResponse from .base import ProjectMonitorEndpoint @@ -43,7 +45,7 @@ class ProjectMonitorCheckInIndexEndpoint(ProjectMonitorEndpoint, MonitorCheckInM }, ) def get( - self, request: Request, project, monitor + self, request: Request, project: Project, monitor: Monitor ) -> Response[list[MonitorCheckInSerializerResponse]]: """ Retrieve a list of check-ins for a monitor diff --git a/src/sentry/monitors/endpoints/project_monitor_details.py b/src/sentry/monitors/endpoints/project_monitor_details.py index a7235a581e2b..8595c5c71ecb 100644 --- a/src/sentry/monitors/endpoints/project_monitor_details.py +++ b/src/sentry/monitors/endpoints/project_monitor_details.py @@ -16,6 +16,8 @@ ) from sentry.apidocs.parameters import GlobalParams, MonitorParams from sentry.apidocs.response_types import ValidationErrorResponse +from sentry.models.project import Project +from sentry.monitors.models import Monitor from sentry.monitors.serializers import MonitorSerializer, MonitorSerializerResponse from sentry.monitors.validators import MonitorValidator from sentry.utils.auth import AuthenticatedHttpRequest @@ -49,7 +51,9 @@ class ProjectMonitorDetailsEndpoint(ProjectMonitorEndpoint, MonitorDetailsMixin) 404: RESPONSE_NOT_FOUND, }, ) - def get(self, request: Request, project, monitor) -> Response[MonitorSerializerResponse]: + def get( + self, request: Request, project: Project, monitor: Monitor + ) -> Response[MonitorSerializerResponse]: """ Retrieves details for a monitor. """ @@ -73,7 +77,7 @@ def get(self, request: Request, project, monitor) -> Response[MonitorSerializerR }, ) def put( - self, request: AuthenticatedHttpRequest, project, monitor + self, request: AuthenticatedHttpRequest, project: Project, monitor: Monitor ) -> Response[MonitorSerializerResponse] | Response[ValidationErrorResponse]: """ Update a monitor. @@ -97,7 +101,7 @@ def put( 404: RESPONSE_NOT_FOUND, }, ) - def delete(self, request: Request, project, monitor) -> Response[None]: + def delete(self, request: Request, project: Project, monitor: Monitor) -> Response[None]: """ Delete a monitor or monitor environments. """ diff --git a/src/sentry/monitors/endpoints/project_monitor_environment_details.py b/src/sentry/monitors/endpoints/project_monitor_environment_details.py index ef0de4813a81..558a5bad882c 100644 --- a/src/sentry/monitors/endpoints/project_monitor_environment_details.py +++ b/src/sentry/monitors/endpoints/project_monitor_environment_details.py @@ -15,6 +15,8 @@ RESPONSE_UNAUTHORIZED, ) from sentry.apidocs.parameters import GlobalParams, MonitorParams +from sentry.models.project import Project +from sentry.monitors.models import Monitor, MonitorEnvironment from sentry.monitors.serializers import MonitorSerializer from .base import ProjectMonitorEnvironmentEndpoint @@ -48,7 +50,13 @@ class ProjectMonitorEnvironmentDetailsEndpoint( 404: RESPONSE_NOT_FOUND, }, ) - def put(self, request: Request, project, monitor, monitor_environment) -> Response: + def put( + self, + request: Request, + project: Project, + monitor: Monitor, + monitor_environment: MonitorEnvironment, + ) -> Response: """ Update a monitor environment. """ @@ -69,5 +77,11 @@ def put(self, request: Request, project, monitor, monitor_environment) -> Respon 404: RESPONSE_NOT_FOUND, }, ) - def delete(self, request: Request, project, monitor, monitor_environment) -> Response: + def delete( + self, + request: Request, + project: Project, + monitor: Monitor, + monitor_environment: MonitorEnvironment, + ) -> Response: return self.delete_monitor_environment(request, project, monitor, monitor_environment) diff --git a/src/sentry/monitors/endpoints/project_monitor_processing_errors_index.py b/src/sentry/monitors/endpoints/project_monitor_processing_errors_index.py index 7d06dce19ef0..6f456dbf156b 100644 --- a/src/sentry/monitors/endpoints/project_monitor_processing_errors_index.py +++ b/src/sentry/monitors/endpoints/project_monitor_processing_errors_index.py @@ -53,7 +53,7 @@ class ProjectMonitorProcessingErrorsIndexEndpoint(ProjectMonitorEndpoint): }, ) def get( - self, request: AuthenticatedHttpRequest, project, monitor + self, request: AuthenticatedHttpRequest, project: Project, monitor: Monitor ) -> Response[list[CheckinProcessingErrorData]]: """ Retrieves checkin processing errors for a monitor diff --git a/src/sentry/monitors/endpoints/project_monitor_stats.py b/src/sentry/monitors/endpoints/project_monitor_stats.py index 7fe218ff1f80..881b928d5583 100644 --- a/src/sentry/monitors/endpoints/project_monitor_stats.py +++ b/src/sentry/monitors/endpoints/project_monitor_stats.py @@ -6,6 +6,8 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint +from sentry.models.project import Project +from sentry.monitors.models import Monitor from .base import ProjectMonitorEndpoint from .base_monitor_stats import MonitorStatsMixin @@ -18,5 +20,5 @@ class ProjectMonitorStatsEndpoint(ProjectMonitorEndpoint, MonitorStatsMixin): } owner = ApiOwner.CRONS - def get(self, request: Request, project, monitor) -> Response: + def get(self, request: Request, project: Project, monitor: Monitor) -> Response: return self.get_monitor_stats(request, project, monitor) diff --git a/src/sentry/monitors/logic/incident_occurrence.py b/src/sentry/monitors/logic/incident_occurrence.py index 71a1e7dfb9b3..fb5df9de34d8 100644 --- a/src/sentry/monitors/logic/incident_occurrence.py +++ b/src/sentry/monitors/logic/incident_occurrence.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING from arroyo import Topic as ArroyoTopic -from arroyo.backends.kafka import KafkaPayload +from arroyo.backends.kafka import KafkaPayload, KafkaProducer from django.utils.text import get_text_list from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -43,7 +43,7 @@ ) -def _get_producer(): +def _get_producer() -> KafkaProducer: return get_arroyo_producer( name="sentry.monitors.logic.incident_occurrence", topic=Topic.MONITORS_INCIDENT_OCCURRENCES, @@ -198,8 +198,9 @@ def send_incident_occurrence( else: trace_id = None - event_data = { - "contexts": {"monitor": get_monitor_environment_context(monitor_env)}, + contexts: dict[str, object] = {"monitor": get_monitor_environment_context(monitor_env)} + event_data: dict[str, object] = { + "contexts": contexts, "environment": monitor_env.get_environment().name, "event_id": occurrence.event_id, "fingerprint": [incident.grouphash], @@ -219,7 +220,7 @@ def send_incident_occurrence( } if trace_id: - event_data["contexts"]["trace"] = {"trace_id": trace_id, "span_id": None} + contexts["trace"] = {"trace_id": trace_id, "span_id": None} produce_occurrence_to_kafka( payload_type=PayloadType.OCCURRENCE, @@ -242,7 +243,9 @@ def send_incident_occurrence( } -def get_failure_reason(failed_checkins: Sequence[MonitorCheckIn]): +def get_failure_reason( + failed_checkins: Sequence[MonitorCheckIn], +) -> str | _StrPromise: """ Builds a human readable string from a list of failed check-ins. @@ -271,7 +274,9 @@ def get_failure_reason(failed_checkins: Sequence[MonitorCheckIn]): return _("%(problem_checkins)s check-ins detected") % {"problem_checkins": human_status} -def get_monitor_environment_context(monitor_environment: MonitorEnvironment): +def get_monitor_environment_context( + monitor_environment: MonitorEnvironment, +) -> dict[str, object]: config = monitor_environment.monitor.config.copy() if "schedule_type" in config: config["schedule_type"] = monitor_environment.monitor.get_schedule_type_display() diff --git a/src/sentry/monitors/logic/monitor_environment.py b/src/sentry/monitors/logic/monitor_environment.py index 1156e5050893..f0c6652a7c0f 100644 --- a/src/sentry/monitors/logic/monitor_environment.py +++ b/src/sentry/monitors/logic/monitor_environment.py @@ -5,7 +5,7 @@ def monitor_has_newer_status_affecting_checkins( monitor_env: MonitorEnvironment, timestamp: datetime -): +) -> bool: return MonitorCheckIn.objects.filter( monitor_environment=monitor_env, date_added__gt=timestamp, @@ -18,7 +18,7 @@ def update_monitor_environment( last_checkin: datetime | None, expected_ts: datetime, monitor_status: int | None = None, -): +) -> None: # Compute the next check-in time from our reference time next_checkin = monitor_env.monitor.get_next_expected_checkin(expected_ts) next_checkin_latest = monitor_env.monitor.get_next_expected_checkin_latest(expected_ts) diff --git a/src/sentry/monitors/models.py b/src/sentry/monitors/models.py index ab74d6378294..f3824f256d50 100644 --- a/src/sentry/monitors/models.py +++ b/src/sentry/monitors/models.py @@ -3,16 +3,17 @@ import logging import uuid import zoneinfo -from collections.abc import Sequence +from collections.abc import Iterable, Mapping, Sequence from datetime import datetime from functools import cache -from typing import TYPE_CHECKING, ClassVar, Self, override +from typing import TYPE_CHECKING, Any, ClassVar, Self, cast, override from uuid import uuid4 import jsonschema from django.conf import settings from django.db import models from django.db.models import Case, IntegerField, Q, Value, When +from django.db.models.base import ModelBase from django.db.models.signals import pre_save from django.dispatch import receiver from django.utils import timezone @@ -72,7 +73,11 @@ @cache def get_monitor_config_validator() -> jsonschema.protocols.Validator: - validator_cls = jsonschema.validators.validator_for(MONITOR_CONFIG) + # justification: jsonschema's validator_for returns a Validator subclass. + validator_cls = cast( + "type[jsonschema.protocols.Validator]", + jsonschema.validators.validator_for(MONITOR_CONFIG), + ) validator_cls.check_schema(MONITOR_CONFIG) return validator_cls(MONITOR_CONFIG) @@ -177,7 +182,7 @@ class CheckInStatus: """ @classmethod - def as_choices(cls): + def as_choices(cls) -> Sequence[tuple[int, str]]: return ( (cls.UNKNOWN, "unknown"), (cls.OK, "ok"), @@ -208,11 +213,11 @@ class ScheduleType: INTERVAL = 2 @classmethod - def as_choices(cls): + def as_choices(cls) -> Sequence[tuple[int, str]]: return ((cls.CRONTAB, "crontab"), (cls.INTERVAL, "interval")) @classmethod - def get_name(cls, value): + def get_name(cls, value: int) -> str: return dict(cls.as_choices())[value] @@ -259,7 +264,7 @@ class Monitor(Model): The user assigned as the owner of this model. """ - owner_team_id = BoundedBigIntegerField(null=True, db_index=True) + owner_team_id = BoundedBigIntegerField[int | None, int | None](null=True, db_index=True) """ The team assigned as the owner of this model. """ @@ -289,7 +294,13 @@ class Meta: __repr__ = sane_repr("guid", "project_id", "name") - 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 not self.slug: return save_with_unique_slug( self, @@ -299,9 +310,19 @@ def save(self, *args, **kwargs): organization_id=self.organization_id, max_length=DEFAULT_SLUG_MAX_LENGTH, ), - save_callback=lambda: super(Monitor, self).save(*args, **kwargs), + save_callback=lambda: super(Monitor, self).save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ), ) - return super().save(*args, **kwargs) + super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) @property def owner_actor(self) -> Actor | None: @@ -320,13 +341,13 @@ def schedule(self) -> CrontabSchedule | IntervalSchedule: raise NotImplementedError("unknown schedule_type") @property - def timezone(self): + def timezone(self) -> zoneinfo.ZoneInfo: return zoneinfo.ZoneInfo(self.config.get("timezone") or "UTC") - def get_schedule_type_display(self): + def get_schedule_type_display(self) -> str: return ScheduleType.get_name(self.config["schedule_type"]) - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, object]: return { "name": self.name, "status": self.status, @@ -356,7 +377,9 @@ def get_next_expected_checkin_latest(self, last_checkin: datetime) -> datetime: next_checkin = self.get_next_expected_checkin(last_checkin) return next_checkin + get_checkin_margin(self.config.get("checkin_margin")) - def update_config(self, config_payload, validated_config): + def update_config( + self, config_payload: Mapping[str, object], validated_config: Mapping[str, object] + ) -> None: keys = set(config_payload.keys()) # Always update schedule and schedule_type @@ -369,14 +392,15 @@ def update_config(self, config_payload, validated_config): if monitor_config != self.config: self.update(config=monitor_config) - def get_validated_config(self): + def get_validated_config(self) -> dict[str, Any]: try: get_monitor_config_validator().validate(self.config) except jsonschema.ValidationError: logging.warning("Monitor: %s invalid config: %s", self.id, self.config, exc_info=True) # We should always return the config here - just log an error if we detect that it doesn't # match the schema - return self.config + # justification: monitor configs are JSON objects written through validated_config. + return cast("dict[str, Any]", self.config) def normalize_before_relocation_import( self, pk_map: PrimaryKeyMap, scope: ImportScope, flags: ImportFlags @@ -395,7 +419,7 @@ def is_monitor_muted(monitor: Monitor) -> bool: A monitor is considered muted if ALL of its environments are muted. If a monitor has no environments, it is considered unmuted. """ - env_counts = MonitorEnvironment.objects.filter(monitor_id=monitor.id).aggregate( + env_counts: dict[str, int] = MonitorEnvironment.objects.filter(monitor_id=monitor.id).aggregate( total=models.Count("id"), muted=models.Count("id", filter=Q(is_muted=True)) ) @@ -422,8 +446,10 @@ def check_organization_monitor_limit(organization_id: int) -> None: @receiver(pre_save, sender=Monitor) -def check_organization_monitor_limits_on_save(sender, instance, **kwargs): - if instance.pk is None: +def check_organization_monitor_limits_on_save( + sender: type[Monitor], instance: Monitor, **kwargs: object +) -> None: + if getattr(instance, "pk", None) is None: check_organization_monitor_limit(instance.organization_id) @@ -473,7 +499,7 @@ class MonitorCheckIn(Model): The status of the check-in """ - duration = BoundedPositiveIntegerField(null=True) + duration = BoundedPositiveIntegerField[int | None, int | None](null=True) """ The total number in milliseconds that the check-in took to execute. This is generally computed from the difference between the opening and closing @@ -544,7 +570,7 @@ class MonitorCheckIn(Model): configuration at the time of the check-in. """ - trace_id = UUIDField(null=True) + trace_id = UUIDField[str | uuid.UUID | None, uuid.UUID | None](null=True) """ Trace ID associated during this check-in. Useful to find associated events that occurred during the check-in. @@ -581,16 +607,27 @@ class Meta: __repr__ = sane_repr("guid", "monitor_environment_id", "status") - 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 not self.date_added: self.date_added = timezone.now() if not self.date_updated: self.date_updated = self.date_added - return super().save(*args, **kwargs) + super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) # XXX(dcramer): BaseModel is trying to automatically set date_updated which is not # what we want to happen, so kill it here - def _update_timestamps(self): + def _update_timestamps(self) -> None: pass @@ -689,14 +726,14 @@ class Meta: def get_environment(self) -> Environment: return Environment.objects.get_from_cache(id=self.environment_id) - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, object]: return { "name": self.get_environment().name, "status": self.status, "monitor": self.monitor.name, } - def get_last_successful_checkin(self): + def get_last_successful_checkin(self) -> MonitorCheckIn | None: return ( MonitorCheckIn.objects.filter(monitor_environment=self, status=CheckInStatus.OK) .order_by("-date_added") @@ -720,9 +757,11 @@ def build_occurrence_fingerprint(self) -> str: @receiver(pre_save, sender=MonitorEnvironment) -def check_monitor_environment_limits(sender, instance, **kwargs): +def check_monitor_environment_limits( + sender: type[MonitorEnvironment], instance: MonitorEnvironment, **kwargs: object +) -> None: if ( - instance.pk is None + getattr(instance, "pk", None) is None and sender.objects.filter(monitor=instance.monitor).count() == settings.MAX_ENVIRONMENTS_PER_MONITOR ): @@ -731,7 +770,7 @@ def check_monitor_environment_limits(sender, instance, **kwargs): ) -def default_grouphash(): +def default_grouphash() -> str: """ Generate a unique 32 character grouphash for a monitor incident """ @@ -855,7 +894,7 @@ def bulk_get_query_object( @override @staticmethod - def related_model(instance) -> list[ModelRelation]: + def related_model(instance: DataSource) -> list[ModelRelation]: return [ModelRelation(Monitor, {"id": instance.source_id})] @override diff --git a/src/sentry/monitors/processing_errors/errors.py b/src/sentry/monitors/processing_errors/errors.py index 8d0edb504b67..67eadf79951b 100644 --- a/src/sentry/monitors/processing_errors/errors.py +++ b/src/sentry/monitors/processing_errors/errors.py @@ -258,10 +258,10 @@ def from_dict(cls, data: CheckinProcessingErrorData) -> CheckinProcessingError: errors=data["errors"], ) - def __hash__(self): + def __hash__(self) -> int: return hash(self.id.hex) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if isinstance(other, CheckinProcessingError): return self.id.hex == other.id.hex return False diff --git a/src/sentry/monitors/processing_errors/manager.py b/src/sentry/monitors/processing_errors/manager.py index 668c38b19399..71e5ae28f49d 100644 --- a/src/sentry/monitors/processing_errors/manager.py +++ b/src/sentry/monitors/processing_errors/manager.py @@ -126,7 +126,7 @@ def _delete_for_entity_by_type(entity_identifier: str, type: ProcessingErrorType pipeline.execute() -def store_error(error: CheckinProcessingError, monitor: Monitor | None): +def store_error(error: CheckinProcessingError, monitor: Monitor | None) -> None: entity_identifier = _get_entity_identifier_from_error(error, monitor) error_set_key = build_set_identifier(entity_identifier) error_key = build_error_identifier(error.id) @@ -149,7 +149,7 @@ def store_error(error: CheckinProcessingError, monitor: Monitor | None): pipeline.execute() -def delete_error(project: Project, uuid: uuid.UUID): +def delete_error(project: Project, uuid: uuid.UUID) -> None: error_identifier = build_error_identifier(uuid) redis = _get_cluster() raw_error = redis.get(error_identifier) @@ -164,11 +164,11 @@ def delete_error(project: Project, uuid: uuid.UUID): _delete_for_entity(entity_identifier, uuid) -def delete_errors_for_monitor_by_type(monitor: Monitor, type: ProcessingErrorType): +def delete_errors_for_monitor_by_type(monitor: Monitor, type: ProcessingErrorType) -> None: _delete_for_entity_by_type(build_monitor_identifier(monitor), type) -def delete_errors_for_project_by_type(project: Project, type: ProcessingErrorType): +def delete_errors_for_project_by_type(project: Project, type: ProcessingErrorType) -> None: _delete_for_entity_by_type(build_project_identifier(project.id), type) @@ -201,7 +201,7 @@ def _store_sample_rate(error: ProcessingErrorsException) -> float: ) -def handle_processing_errors(item: CheckinItem, error: ProcessingErrorsException): +def handle_processing_errors(item: CheckinItem, error: ProcessingErrorsException) -> None: sample_rate = _store_sample_rate(error) if not sample_rate or random.random() >= sample_rate: metrics.incr( diff --git a/src/sentry/monitors/rate_limit.py b/src/sentry/monitors/rate_limit.py index 89be71e0a517..19831ee2b507 100644 --- a/src/sentry/monitors/rate_limit.py +++ b/src/sentry/monitors/rate_limit.py @@ -52,8 +52,8 @@ def get_project_monitor_quota( project: Project, - cache_bust=False, -) -> tuple[int | None, int | None]: + cache_bust: bool = False, +) -> tuple[int, int]: """ Determines the rate-limit for monitor check-ins across a particular project. @@ -78,7 +78,7 @@ def get_project_monitor_quota( return (ALLOWED_MINIMUM + limit, QUOTA_WINDOW) -def update_monitor_quota(monitor_env: MonitorEnvironment): +def update_monitor_quota(monitor_env: MonitorEnvironment) -> None: """ When new monitor environments are created we recompute the per-project monitor check-in rate limit QuotaConfig in relay. diff --git a/src/sentry/monitors/schedule.py b/src/sentry/monitors/schedule.py index 2a08bca923df..c24753f9b52d 100644 --- a/src/sentry/monitors/schedule.py +++ b/src/sentry/monitors/schedule.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import cast from cronsim import CronSim from dateutil import rrule @@ -48,7 +49,8 @@ def get_next_schedule( dtstart=reference_ts, count=2, ) - return rule.after(reference_ts).replace(second=0, microsecond=0) + # justification: dateutil's rrule returns a datetime. + return cast("datetime", rule.after(reference_ts)).replace(second=0, microsecond=0) raise NotImplementedError("unknown schedule_type") @@ -87,6 +89,7 @@ def get_prev_schedule( dtstart=start_ts, until=reference_ts, ) - return rule.before(reference_ts).replace(second=0, microsecond=0) + # justification: dateutil's rrule returns a datetime. + return cast("datetime", rule.before(reference_ts)).replace(second=0, microsecond=0) raise NotImplementedError("unknown schedule_type") diff --git a/src/sentry/monitors/serializers.py b/src/sentry/monitors/serializers.py index 6c65f210ff6d..0d6e74395ac6 100644 --- a/src/sentry/monitors/serializers.py +++ b/src/sentry/monitors/serializers.py @@ -1,5 +1,5 @@ from collections import defaultdict -from collections.abc import MutableMapping, Sequence +from collections.abc import Mapping, MutableMapping, Sequence from datetime import datetime from itertools import groupby from operator import attrgetter @@ -32,13 +32,19 @@ class MonitorEnvBrokenDetectionSerializerResponse(TypedDict): - userNotifiedTimestamp: datetime - environmentMutedTimestamp: datetime + userNotifiedTimestamp: datetime | None + environmentMutedTimestamp: datetime | None @register(MonitorEnvBrokenDetection) class MonitorEnvBrokenDetectionSerializer(Serializer[MonitorEnvBrokenDetectionSerializerResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> MonitorEnvBrokenDetectionSerializerResponse: + def serialize( + self, + obj: MonitorEnvBrokenDetection, + attrs: Mapping[Any, Any], # justification: serializer API + user: Any, # justification: serializer API + **kwargs: Any, # justification: framework kwargs + ) -> MonitorEnvBrokenDetectionSerializerResponse: return { "userNotifiedTimestamp": obj.user_notified_timestamp, "environmentMutedTimestamp": obj.env_muted_timestamp, @@ -46,8 +52,8 @@ def serialize(self, obj, attrs, user, **kwargs) -> MonitorEnvBrokenDetectionSeri class MonitorIncidentSerializerResponse(TypedDict): - startingTimestamp: datetime - resolvingTimestamp: datetime + startingTimestamp: datetime | None + resolvingTimestamp: datetime | None brokenNotice: MonitorEnvBrokenDetectionSerializerResponse | None @@ -68,7 +74,14 @@ def get_attrs( for incident in item_list } - def serialize(self, obj, attrs, user, **kwargs) -> MonitorIncidentSerializerResponse: + def serialize( + # justification: framework kwargs + self, + obj: MonitorIncident, + attrs: Mapping[Any, Any], # justification: serializer API + user: Any, # justification: serializer API + **kwargs: Any, # justification: framework kwargs + ) -> MonitorIncidentSerializerResponse: return { "startingTimestamp": obj.starting_timestamp, "resolvingTimestamp": obj.resolving_timestamp, @@ -81,9 +94,9 @@ class MonitorEnvironmentSerializerResponse(TypedDict): status: str isMuted: bool dateCreated: datetime - lastCheckIn: datetime - nextCheckIn: datetime - nextCheckInLatest: datetime + lastCheckIn: datetime | None + nextCheckIn: datetime | None + nextCheckInLatest: datetime | None activeIncident: MonitorIncidentSerializerResponse | None @@ -137,7 +150,14 @@ def get_attrs( for monitor_env in item_list } - def serialize(self, obj, attrs, user, **kwargs) -> MonitorEnvironmentSerializerResponse: + def serialize( + # justification: framework kwargs + self, + obj: MonitorEnvironment, + attrs: Mapping[Any, Any], # justification: serializer API + user: Any, # justification: serializer API + **kwargs: Any, # justification: framework kwargs + ) -> MonitorEnvironmentSerializerResponse: environment = attrs["environment"] return { "name": environment.name if environment else "[removed]", @@ -183,10 +203,16 @@ class MonitorBulkEditResponse: @register(Monitor) class MonitorSerializer(Serializer[MonitorSerializerResponse]): - def __init__(self, environments=None): + def __init__(self, environments: Sequence[Environment] | None = None) -> None: self.environments = environments - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + # justification: framework kwargs + self, + item_list: Sequence[Monitor], + user: Any, # justification: serializer API + **kwargs: Any, # justification: framework kwargs + ) -> MutableMapping[Any, Any]: # justification: serializer API # TODO(dcramer): assert on relations projects = Project.objects.filter(id__in=[i.project_id for i in item_list]) projects_data = { @@ -267,13 +293,20 @@ def get_attrs(self, item_list, user, **kwargs): item: { "project": projects_data[item.project_id] if item.project_id else None, "environments": environment_data[item.id], - "owner": actor_data.get(item.owner_actor), + "owner": actor_data.get(item.owner_actor) if item.owner_actor else None, "is_muted": is_muted_data[item.id], } for item in item_list } - def serialize(self, obj, attrs, user, **kwargs) -> MonitorSerializerResponse: + def serialize( + # justification: framework kwargs + self, + obj: Monitor, + attrs: Mapping[Any, Any], # justification: serializer API + user: Any, # justification: serializer API + **kwargs: Any, # justification: framework kwargs + ) -> MonitorSerializerResponse: config = obj.config.copy() if "schedule_type" in config: config["schedule_type"] = obj.get_schedule_type_display() @@ -304,25 +337,38 @@ class MonitorCheckInSerializerResponse(MonitorCheckInSerializerResponseOptional) environment: str status: str duration: int | None - dateCreated: datetime + dateCreated: datetime | None dateAdded: datetime dateUpdated: datetime dateInProgress: datetime | None - dateClock: datetime - expectedTime: datetime + dateClock: datetime | None + expectedTime: datetime | None monitorConfig: MonitorConfigSerializerResponse @register(MonitorCheckIn) class MonitorCheckInSerializer(Serializer[MonitorCheckInSerializerResponse]): - def __init__(self, start=None, end=None, expand=None, organization_id=None, project_id=None): + def __init__( + self, + start: datetime | None = None, + end: datetime | None = None, + expand: Sequence[str] | None = None, + organization_id: int | None = None, + project_id: int | None = None, + ) -> None: self.start = start # timestamp of the beginning of the specified date range self.end = end # timestamp of the end of the specified date range self.expand = expand self.organization_id = organization_id self.project_id = project_id - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + # justification: framework kwargs + self, + item_list: Sequence[MonitorCheckIn], + user: Any, # justification: serializer API + **kwargs: Any, # justification: framework kwargs + ) -> MutableMapping[Any, Any]: # justification: serializer API # prefetch monitor environment data prefetch_related_objects(item_list, "monitor_environment") @@ -363,7 +409,11 @@ def get_attrs(self, item_list, user, **kwargs): if trace_ids: trace_groups = fetch_associated_groups( - trace_ids, self.organization_id, self.project_id, self.start, self.end + trace_ids, + cast("int", self.organization_id), + cast("int", self.project_id), + self.start, + self.end, ) for checkin in item_list: @@ -373,7 +423,13 @@ def get_attrs(self, item_list, user, **kwargs): return attrs - def serialize(self, obj, attrs, user, **kwargs) -> MonitorCheckInSerializerResponse: + def serialize( + self, + obj: MonitorCheckIn, + attrs: Mapping[Any, Any], # justification: serializer API + user: Any, # justification: serializer API + **kwargs: Any, # justification: framework kwargs + ) -> MonitorCheckInSerializerResponse: monitor_config = attrs["monitor_config"] config = monitor_config.copy() if monitor_config else {} if "schedule_type" in config: @@ -399,7 +455,7 @@ def serialize(self, obj, attrs, user, **kwargs) -> MonitorCheckInSerializerRespo return result - def _expand(self, key) -> bool: + def _expand(self, key: str) -> bool: if self.expand is None: return False @@ -409,6 +465,10 @@ def _expand(self, key) -> bool: @register(CheckinProcessingError) class CheckinProcessingErrorSerializer(Serializer[CheckinProcessingErrorData]): def serialize( - self, obj: CheckinProcessingError, attrs, user, **kwargs + self, + obj: CheckinProcessingError, + attrs: Mapping[Any, Any], # justification: serializer API + user: Any, # justification: serializer API + **kwargs: Any, # justification: framework kwargs ) -> CheckinProcessingErrorData: return obj.to_dict() diff --git a/src/sentry/monitors/tasks/clock_pulse.py b/src/sentry/monitors/tasks/clock_pulse.py index 0a1af7f7ef3d..32866075c20d 100644 --- a/src/sentry/monitors/tasks/clock_pulse.py +++ b/src/sentry/monitors/tasks/clock_pulse.py @@ -6,7 +6,7 @@ from arroyo import Partition from arroyo import Topic as ArroyoTopic -from arroyo.backends.kafka import KafkaPayload +from arroyo.backends.kafka import KafkaPayload, KafkaProducer from cachetools.func import ttl_cache from confluent_kafka.admin import ( # type: ignore[attr-defined] AdminClient, @@ -32,7 +32,7 @@ MONITOR_CODEC: Codec[IngestMonitorMessage] = get_topic_codec(Topic.INGEST_MONITORS) -def _get_producer(): +def _get_producer() -> KafkaProducer: return get_arroyo_producer( name="sentry.monitors.tasks.clock_pulse", topic=Topic.INGEST_MONITORS, @@ -65,7 +65,7 @@ def _get_partitions() -> Mapping[int, PartitionMetadata]: namespace=crons_tasks, silo_mode=SiloMode.CELL, ) -def clock_pulse(current_datetime=None): +def clock_pulse(current_datetime: datetime | None = None) -> None: """ This task is run once a minute when to produce 'clock pulses' into the monitor ingest topic. This is to ensure there is always a message in the diff --git a/src/sentry/monitors/tasks/detect_broken_monitor_envs.py b/src/sentry/monitors/tasks/detect_broken_monitor_envs.py index 7640ab96ab8c..9dba10240832 100644 --- a/src/sentry/monitors/tasks/detect_broken_monitor_envs.py +++ b/src/sentry/monitors/tasks/detect_broken_monitor_envs.py @@ -8,6 +8,7 @@ from urllib.parse import urlencode from django.db import router, transaction +from django.db.models import QuerySet from django.utils import timezone as django_timezone from sentry.constants import ObjectStatus @@ -49,13 +50,13 @@ MAX_ENVIRONMENTS_IN_MONITOR_LINK = 10 -def generate_monitor_overview_url(organization: Organization): +def generate_monitor_overview_url(organization: Organization) -> str: return organization.absolute_url(f"/organizations/{organization.slug}/monitors/crons/") def generate_monitor_detail_url( organization: Organization, detector_id: int, environments: list[str] -): +) -> str: return organization.absolute_url( f"/organizations/{organization.slug}/monitors/{detector_id}/", query=urlencode({"environment": environments}, doseq=True), @@ -86,16 +87,21 @@ def update_user_monitor_dictionary( user_monitor_entry["environment_names"].append(environment_name) -def get_user_ids_to_notify_from_monitor(monitor: Monitor, project: Project): +def get_user_ids_to_notify_from_monitor(monitor: Monitor, project: Project) -> Iterable[int]: try: if monitor.owner_user_id: organization_member = OrganizationMember.objects.get( user_id=monitor.owner_user_id, organization_id=monitor.organization_id ) + assert organization_member.user_id is not None return [organization_member.user_id] elif monitor.owner_team_id: team = Team.objects.get_from_cache(id=monitor.owner_team_id) - return team.member_set.values_list("user_id", flat=True) + return [ + user_id + for user_id in team.member_set.values_list("user_id", flat=True) + if user_id is not None + ] except (OrganizationMember.DoesNotExist, Team.DoesNotExist): logger.info( "monitors.broken_detection.invalid_owner", @@ -107,10 +113,14 @@ def get_user_ids_to_notify_from_monitor(monitor: Monitor, project: Project): ) project = Project.objects.get_from_cache(id=monitor.project_id) - return project.member_set.values_list("user_id", flat=True) + return [ + user_id + for user_id in project.member_set.values_list("user_id", flat=True) + if user_id is not None + ] -def get_user_emails_from_monitor(monitor: Monitor, project: Project): +def get_user_emails_from_monitor(monitor: Monitor, project: Project) -> Iterable[str]: user_ids = get_user_ids_to_notify_from_monitor(monitor, project) actors = [Actor.from_id(user_id=id) for id in user_ids] recipients = notifications_service.get_notification_recipients( @@ -126,7 +136,7 @@ def get_user_emails_from_monitor(monitor: Monitor, project: Project): def generate_monitor_email_context( monitor_entries: Iterable[dict[str, Any]], organization: Organization -): +) -> list[tuple[object, object, str, object]]: return [ ( monitor_entry["slug"], @@ -146,7 +156,7 @@ def generate_monitor_email_context( ] -def build_open_incidents_queryset(): +def build_open_incidents_queryset() -> QuerySet[MonitorIncident]: current_time = django_timezone.now() return MonitorIncident.objects.select_related("monitor").filter( resolving_checkin=None, @@ -162,7 +172,7 @@ def build_open_incidents_queryset(): namespace=crons_tasks, processing_deadline_duration=15 * 60, ) -def detect_broken_monitor_envs(): +def detect_broken_monitor_envs() -> None: open_incidents_qs = build_open_incidents_queryset() org_ids_with_open_incidents = open_incidents_qs.values_list( "monitor__organization_id", flat=True @@ -177,7 +187,7 @@ def detect_broken_monitor_envs(): namespace=crons_tasks, processing_deadline_duration=15 * 60, ) -def detect_broken_monitor_envs_for_org(org_id: int): +def detect_broken_monitor_envs_for_org(org_id: int) -> None: current_time = django_timezone.now() try: organization = Organization.objects.get_from_cache(id=org_id) diff --git a/src/sentry/monitors/testutils.py b/src/sentry/monitors/testutils.py index 46d75e548480..556b8aa1f977 100644 --- a/src/sentry/monitors/testutils.py +++ b/src/sentry/monitors/testutils.py @@ -1,5 +1,6 @@ from collections.abc import Sequence from datetime import datetime +from typing import Literal, TypedDict from sentry_kafka_schemas.schema_types.ingest_monitors_v1 import CheckIn @@ -8,14 +9,33 @@ ProcessingError, ProcessingErrorType, ) -from sentry.monitors.types import CheckinItem, CheckinPayload +from sentry.monitors.types import CheckinContexts, CheckinItem, CheckinPayload + + +class CheckInOverrides(TypedDict, total=False): + message_type: Literal["check_in"] + payload: bytes + start_time: int | float + project_id: int + sdk: str | None + retention_days: int + + +class CheckinPayloadOverrides(TypedDict, total=False): + check_in_id: str + monitor_slug: str + status: str + environment: str + duration: int + monitor_config: dict[str, object] + contexts: CheckinContexts def build_checkin_item( ts: datetime | None = None, partition: int = 0, - message_overrides=None, - payload_overrides=None, + message_overrides: CheckInOverrides | None = None, + payload_overrides: CheckinPayloadOverrides | None = None, ) -> CheckinItem: if ts is None: ts = datetime.now() @@ -42,7 +62,11 @@ def build_checkin_item( def build_checkin_processing_error( - processing_errors: Sequence[ProcessingError] | None = None, **checkin_item_params + processing_errors: Sequence[ProcessingError] | None = None, + ts: datetime | None = None, + partition: int = 0, + message_overrides: CheckInOverrides | None = None, + payload_overrides: CheckinPayloadOverrides | None = None, ) -> CheckinProcessingError: if processing_errors is None: processing_errors = [ @@ -51,4 +75,12 @@ def build_checkin_processing_error( "existingEnvironment": "some-env", }, ] - return CheckinProcessingError(processing_errors, build_checkin_item(**checkin_item_params)) + return CheckinProcessingError( + processing_errors, + build_checkin_item( + ts=ts, + partition=partition, + message_overrides=message_overrides, + payload_overrides=payload_overrides, + ), + ) diff --git a/src/sentry/monitors/types.py b/src/sentry/monitors/types.py index 3729f4c1e873..cb966f099bf0 100644 --- a/src/sentry/monitors/types.py +++ b/src/sentry/monitors/types.py @@ -27,7 +27,7 @@ class CheckinPayload(TypedDict): status: str environment: NotRequired[str] duration: NotRequired[int] - monitor_config: NotRequired[dict] + monitor_config: NotRequired[dict[str, object]] contexts: NotRequired[CheckinContexts] @@ -78,11 +78,11 @@ class CheckinItem: """ @cached_property - def valid_monitor_slug(self): + def valid_monitor_slug(self) -> str: return slugify_monitor_slug(self.payload["monitor_slug"]) @property - def processing_key(self): + def processing_key(self) -> str: """ This key is used to uniquely identify the check-in group this check-in belongs to. Check-ins grouped together will never be processed in diff --git a/src/sentry/monitors/utils.py b/src/sentry/monitors/utils.py index 7d10e8eae1f6..f30b27cc2bea 100644 --- a/src/sentry/monitors/utils.py +++ b/src/sentry/monitors/utils.py @@ -2,6 +2,7 @@ from collections import defaultdict from collections.abc import Sequence from datetime import datetime, timedelta +from typing import Any, cast from django.db import router, transaction from django.http.request import HttpRequest @@ -49,7 +50,7 @@ def get_request_attribution(request: HttpRequest | Request) -> dict[str, str | b } -def signal_first_checkin(project: Project, monitor: Monitor): +def signal_first_checkin(project: Project, monitor: Monitor) -> None: if not project.flags.has_cron_checkins: # Backfill users that already have cron monitors check_and_signal_first_monitor_created(project, None, False) @@ -64,28 +65,44 @@ def signal_first_checkin(project: Project, monitor: Monitor): ) -def check_and_signal_first_monitor_created(project: Project, user, from_upsert: bool): +def check_and_signal_first_monitor_created( + project: Project, user: object | None, from_upsert: bool +) -> None: set_project_flag_and_signal( project, "has_cron_monitors", first_cron_monitor_created, user=user, from_upsert=from_upsert ) -def signal_monitor_created(project: Project, user, from_upsert: bool, monitor: Monitor, request): +def signal_monitor_created( + project: Project, + user: object | None, + from_upsert: bool, + monitor: Monitor, + request: Request | None, +) -> None: cron_monitor_created.send_robust( project=project, user=user, from_upsert=from_upsert, sender=Project ) check_and_signal_first_monitor_created(project, user, from_upsert) - create_audit_log = create_system_audit_entry if from_upsert else create_audit_entry - kwargs = { - "organization": project.organization, - **({"request": request} if not from_upsert else {}), - "target_object": monitor.id, - "event": audit_log.get_event_id("MONITOR_ADD"), - "data": {"upsert": from_upsert, **monitor.get_audit_log_data()}, - } - - create_audit_log(**kwargs) + event = audit_log.get_event_id("MONITOR_ADD") + data = {"upsert": from_upsert, **monitor.get_audit_log_data()} + if from_upsert: + create_system_audit_entry( + organization=project.organization, + target_object=monitor.id, + event=event, + data=data, + ) + else: + assert request is not None + create_audit_entry( + organization=project.organization, + request=request, + target_object=monitor.id, + event=event, + data=data, + ) def get_monitor_environment_ids(monitor: Monitor, environments: Sequence[Environment]) -> list[int]: @@ -108,7 +125,7 @@ def get_max_runtime(max_runtime: int | None) -> timedelta: # Generates a timeout_at value for new check-ins def get_timeout_at( - monitor_config: dict | None, status: int, date_added: datetime | None + monitor_config: dict[str, Any] | None, status: int, date_added: datetime | None ) -> datetime | None: if status == CheckInStatus.IN_PROGRESS and date_added is not None: return date_added.replace(second=0, microsecond=0) + get_max_runtime( @@ -328,7 +345,9 @@ def fetch_associated_groups( ).select_related("project") for group in groups_queryset: for trace_id in group_id_data[group.id]: - trace_groups[trace_id].append({"id": group.id, "shortId": group.qualified_short_id}) + trace_groups[trace_id].append( + {"id": group.id, "shortId": cast("str", group.qualified_short_id)} + ) return trace_groups @@ -368,7 +387,7 @@ def ensure_cron_detector(monitor: Monitor) -> Detector | None: return None -def ensure_cron_detector_deletion(monitor: Monitor): +def ensure_cron_detector_deletion(monitor: Monitor) -> None: with atomic_transaction(using=router.db_for_write(DataSource)): try: data_source = DataSource.objects.get( diff --git a/src/sentry/monitors/validators.py b/src/sentry/monitors/validators.py index 67079edc76e8..c8c06f454bac 100644 --- a/src/sentry/monitors/validators.py +++ b/src/sentry/monitors/validators.py @@ -1,6 +1,6 @@ import logging import re -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal, cast import jsonschema import sentry_sdk @@ -58,6 +58,9 @@ ) from sentry.workflow_engine.models import Detector +if TYPE_CHECKING: + from sentry.models.project import Project + logger = logging.getLogger(__name__) MONITOR_STATUSES = { @@ -90,8 +93,9 @@ @extend_schema_field(OpenApiTypes.ANY) -class ObjectField(serializers.Field): - def to_internal_value(self, data): +class ObjectField(serializers.Field[Any, Any, Any, Any]): + # justification: DRF field base class erases the value type. + def to_internal_value(self, data: Any) -> Any: return data @@ -128,7 +132,8 @@ def get_value(self, dictionary: Any) -> Any: class MissedMarginField(EmptyIntegerField): - def to_internal_value(self, value): + # justification: DRF field base class erases the value type. + def to_internal_value(self, value: Any) -> Any: value = super().to_internal_value(value) # XXX(epurkhiser): As part of GH-56526 we changed the minimum value @@ -215,19 +220,20 @@ class ConfigValidator(serializers.Serializer): max_value=MAX_THRESHOLD, ) - def bind(self, *args, **kwargs): + # justification: DRF binding passes arbitrary field and parent arguments. + def bind(self, *args: Any, **kwargs: Any) -> None: super().bind(*args, **kwargs) # Inherit instance data when used as a nested serializer if self.parent.instance: self.instance = self.parent.instance.config self.partial = self.parent.partial - def validate_schedule_type(self, value): + def validate_schedule_type(self, value: str | None) -> int | str | None: if value: - value = SCHEDULE_TYPES[value] + return SCHEDULE_TYPES[value] return value - def validate(self, attrs): + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: if "schedule_type" in attrs: schedule_type = attrs["schedule_type"] elif self.instance: @@ -306,7 +312,7 @@ def validate(self, attrs): "alert_rule": "Deprecated issue alert configuration; use the dedicated Workflow APIs.", } ) -class MonitorValidator(CamelSnakeSerializer): +class MonitorValidator(CamelSnakeSerializer[Monitor]): project = ProjectField( scope="project:read", id_allowed=True, @@ -342,7 +348,7 @@ class MonitorValidator(CamelSnakeSerializer): help_text="Deprecated alert rule configuration.", ) - def validate(self, attrs): + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: # When creating a new monitor, check if we would exceed the organization limit if not self.instance: organization = self.context["organization"] @@ -369,8 +375,8 @@ def validate(self, attrs): return attrs - def validate_status(self, value): - status = MONITOR_STATUSES.get(value, value) + def validate_status(self, value: str | None) -> int | str | None: + status = MONITOR_STATUSES[value] if value in MONITOR_STATUSES else value monitor = self.context.get("monitor") # Activating a monitor may only be done if the monitor may be assigned @@ -386,7 +392,7 @@ def validate_status(self, value): return status - def validate_slug(self, value): + def validate_slug(self, value: str | None) -> str | None: if not value: return value @@ -401,25 +407,25 @@ def validate_slug(self, value): raise ValidationError(f'The slug "{value}" is already in use.') return value - def create(self, validated_data): + def create(self, validated_data: dict[str, Any]) -> Monitor: project = validated_data.get("project", self.context.get("project")) organization = self.context["organization"] owner = validated_data.get("owner") - owner_user_id = None - owner_team_id = None + owner_user_id: int | None = None + owner_team_id: int | None = None if owner and owner.is_user: - owner_user_id = owner.id + owner_user_id = cast("int", owner.id) elif owner and owner.is_team: - owner_team_id = owner.id + owner_team_id = cast("int", owner.id) monitor = Monitor.objects.create( project_id=project.id if project else self.context["project"].id, organization_id=organization.id, owner_user_id=owner_user_id, - owner_team_id=owner_team_id, + owner_team_id=cast("int", owner_team_id), name=validated_data["name"], - slug=validated_data.get("slug"), + slug=cast("str", validated_data.get("slug")), status=validated_data["status"], config=validated_data["config"], ) @@ -439,10 +445,10 @@ def create(self, validated_data): monitor.update(status=ObjectStatus.DISABLED) request = self.context["request"] - signal_monitor_created(project, request.user, False, monitor, request) + signal_monitor_created(cast("Project", project), request.user, False, monitor, request) return monitor - def update(self, instance, validated_data): + def update(self, instance: Monitor, validated_data: dict[str, Any]) -> Monitor: """ Update an existing Monitor instance. """ @@ -604,7 +610,7 @@ class MonitorBulkEditValidator(MonitorValidator): required=True, ) - def validate_ids(self, value): + def validate_ids(self, value: list[str]) -> list[str]: if Monitor.objects.filter( guid__in=value, organization_id=self.context["organization"].id ).count() != len(value): @@ -636,12 +642,12 @@ class Meta: model = Monitor fields = ["name", "slug", "status", "owner", "is_muted", "config"] - def validate_status(self, value): + def validate_status(self, value: str | int | None) -> str | int | None: if isinstance(value, str) and value.isdigit(): return MONITOR_STATUSES_REVERSE[int(value)] return value - def validate_config(self, value): + def validate_config(self, value: dict[str, Any] | None) -> dict[str, Any] | None: if value and "schedule_type" in value: schedule_type = value["schedule_type"] if isinstance(schedule_type, int): @@ -702,12 +708,16 @@ def create_source(self, validated_data: dict[str, Any]) -> Monitor: """Create the Monitor using MonitorValidator.""" monitor_validator = validated_data.pop("_monitor_validator") with in_test_hide_transaction_boundary(): - return monitor_validator.create(monitor_validator.validated_data) + # justification: the nested monitor serializer creates a Monitor. + return cast("Monitor", monitor_validator.create(monitor_validator.validated_data)) def update(self, instance: Monitor, validated_data: dict[str, Any]) -> Monitor: monitor_validator = validated_data.pop("_monitor_validator") with in_test_hide_transaction_boundary(): - return monitor_validator.update(instance, monitor_validator.validated_data) + # justification: the nested monitor serializer updates and returns a Monitor. + return cast( + "Monitor", monitor_validator.update(instance, monitor_validator.validated_data) + ) class MonitorDataSourceListField(serializers.ListField): @@ -718,7 +728,7 @@ class MonitorDataSourceListField(serializers.ListField): knows about the existing Monitor so slug validation works correctly. """ - def to_internal_value(self, data): + def to_internal_value(self, data: list[Any]) -> list[Any]: # If we're updating (parent has instance), bind the Monitor instance to child validator if hasattr(self.parent, "instance") and self.parent.instance: detector = self.parent.instance @@ -755,7 +765,7 @@ def validate_enabled(self, value: bool) -> bool: raise serializers.ValidationError(result.reason) return value - def create(self, validated_data): + def create(self, validated_data: dict[str, Any]) -> Detector: detector = super().create(validated_data) with in_test_hide_transaction_boundary(): diff --git a/src/sentry/net/http.py b/src/sentry/net/http.py index 074c12cd2cb0..40b7a87bf419 100644 --- a/src/sentry/net/http.py +++ b/src/sentry/net/http.py @@ -5,6 +5,7 @@ from functools import partial from typing import Any, Optional, cast +from requests import Response from requests import Session as _Session from requests.adapters import DEFAULT_POOLBLOCK, DEFAULT_RETRIES, HTTPAdapter, Retry from urllib3.connection import HTTPConnection, HTTPSConnection @@ -33,7 +34,13 @@ class SafeConnectionMixin: timeout: float | None port: int - def __init__(self, *args, is_ipaddress_permitted: IsIpAddressPermitted = None, **kwargs): + def __init__( + # justification: framework kwargs + self, + *args: Any, # justification: requests-style args + is_ipaddress_permitted: IsIpAddressPermitted = None, + **kwargs: Any, # justification: requests-style args + ) -> None: self.is_ipaddress_permitted = is_ipaddress_permitted super().__init__(*args, **kwargs) @@ -58,7 +65,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 @@ -113,7 +120,13 @@ class SafeHTTPSConnection(SafeConnectionMixin, HTTPSConnection): class SafeHTTPConnectionPool(HTTPConnectionPool): ConnectionCls = SafeHTTPConnection - def __init__(self, *args, is_ipaddress_permitted: IsIpAddressPermitted = None, **kwargs): + def __init__( + # justification: framework kwargs + self, + *args: Any, # justification: requests-style args + is_ipaddress_permitted: IsIpAddressPermitted = None, + **kwargs: Any, # justification: requests-style args + ) -> None: super().__init__(*args, **kwargs) self.ConnectionCls = cast( type[SafeHTTPConnection], @@ -124,7 +137,13 @@ def __init__(self, *args, is_ipaddress_permitted: IsIpAddressPermitted = None, * class SafeHTTPSConnectionPool(HTTPSConnectionPool): ConnectionCls = SafeHTTPSConnection - def __init__(self, *args, is_ipaddress_permitted: IsIpAddressPermitted = None, **kwargs): + def __init__( + # justification: framework kwargs + self, + *args: Any, # justification: requests-style args + is_ipaddress_permitted: IsIpAddressPermitted = None, + **kwargs: Any, # justification: requests-style args + ) -> None: super().__init__(*args, **kwargs) self.ConnectionCls = cast( type[SafeHTTPSConnection], @@ -139,7 +158,13 @@ class SafePoolManager(PoolManager): ConnectionPool classes to create. """ - def __init__(self, *args, is_ipaddress_permitted: IsIpAddressPermitted = None, **kwargs): + def __init__( + # justification: framework kwargs + self, + *args: Any, # justification: requests-style args + is_ipaddress_permitted: IsIpAddressPermitted = None, + **kwargs: Any, # justification: requests-style args + ) -> None: PoolManager.__init__(self, *args, **kwargs) self.pool_classes_by_scheme = { "http": partial(SafeHTTPConnectionPool, is_ipaddress_permitted=is_ipaddress_permitted), # type: ignore[dict-item] # https://github.com/urllib3/urllib3/issues/3554 @@ -169,7 +194,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,14 +210,26 @@ 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): + def send(self, *args: Any, **kwargs: Any) -> Response: # justification: framework kwargs if kwargs.get("timeout") is None: kwargs["timeout"] = self.default_timeout return HTTPAdapter.send(self, *args, **kwargs) @@ -202,7 +239,7 @@ def send(self, *args, **kwargs): class Session(_Session): - def request(self, *args, **kwargs): + def request(self, *args: Any, **kwargs: Any) -> Response: # justification: framework kwargs kwargs.setdefault("timeout", 30) response = _Session.request(self, *args, **kwargs) # requests' attempts to use chardet internally when no encoding is found @@ -230,7 +267,7 @@ def __init__( class UnixHTTPConnection(HTTPConnection): default_socket_options = [] - def __init__(self, host: str, **kwargs): + def __init__(self, host: str, **kwargs: Any) -> None: # justification: framework kwargs # We're using the `host` as the socket path, but # urllib3 uses this host as the Host header by default. # If we send along the socket path as a Host header, this is @@ -240,13 +277,13 @@ 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. _set_socket_options(sock, self.socket_options) - if self.timeout is not _DEFAULT_TIMEOUT: + if cast("object", self.timeout) is not _DEFAULT_TIMEOUT: sock.settimeout(self.timeout) sock.connect(self.socket_path) return sock @@ -259,7 +296,8 @@ def __str__(self) -> str: return f"{type(self).__name__}(host={self.host!r})" -def connection_from_url(endpoint: str, **kw: Any): +# justification: requests-style args +def connection_from_url(endpoint: str, **kw: Any) -> HTTPConnectionPool: if endpoint[:1] == "/": return UnixHTTPConnectionPool(endpoint, **kw) return _connection_from_url(endpoint, **kw) diff --git a/src/sentry/new_migrations/migrations.py b/src/sentry/new_migrations/migrations.py index 3c165c821b57..ab9c446e3f5f 100644 --- a/src/sentry/new_migrations/migrations.py +++ b/src/sentry/new_migrations/migrations.py @@ -1,4 +1,6 @@ +from django.db.backends.base.schema import BaseDatabaseSchemaEditor from django.db.migrations import Migration, RunSQL, SeparateDatabaseAndState +from django.db.migrations.operations.base import Operation from django.db.migrations.state import ProjectState from django_zero_downtime_migrations.backends.postgres.schema import UnsafeOperationException @@ -20,9 +22,14 @@ class CheckedMigration(Migration): # the `owners-migrations` team. checked = True - def apply(self, project_state, schema_editor, collect_sql=False): + def apply( + self, + project_state: ProjectState, + schema_editor: BaseDatabaseSchemaEditor, + collect_sql: bool = False, + ) -> ProjectState: if self.checked: - schema_editor.safe = True + setattr(schema_editor, "safe", True) for op in self.operations: validate_operation(op) @@ -41,7 +48,7 @@ def mutate_state(self, project_state: ProjectState, preserve: bool = True) -> Pr return new_state -def validate_operation(op): +def validate_operation(op: Operation) -> None: if isinstance(op, RunSQL) and not isinstance(op, SafeRunSQL): raise UnsafeOperationException( "Using `RunSQL` is unsafe because our migrations safety framework can't detect problems with the " diff --git a/src/sentry/new_migrations/monkey/executor.py b/src/sentry/new_migrations/monkey/executor.py index 8bca1c7f2cec..489db669cd0f 100644 --- a/src/sentry/new_migrations/monkey/executor.py +++ b/src/sentry/new_migrations/monkey/executor.py @@ -1,6 +1,8 @@ import difflib import logging import os +from collections.abc import Mapping, Sequence +from typing import Protocol, cast from django.apps import apps from django.contrib.contenttypes.management import RenameContentType @@ -8,6 +10,7 @@ from django.db.migrations.executor import MigrationExecutor from django.db.migrations.migration import Migration from django.db.migrations.operations import AlterField, SeparateDatabaseAndState +from django.db.migrations.operations.base import Operation from django.db.migrations.operations.fields import FieldOperation from django.db.migrations.operations.models import IndexOperation, ModelOperation from django.db.migrations.state import ProjectState @@ -17,6 +20,10 @@ logger = logging.getLogger(__name__) +class OperationWithHints(Protocol): + hints: Mapping[str, object] + + class MissingDatabaseRoutingInfo(Exception): """ Raised when migration operation is missing information needed for selecting @@ -77,7 +84,7 @@ def _check_db_routing(migration: Migration) -> None: else: return - def _check_operations(operations): + def _check_operations(operations: Sequence[Operation]) -> list[Operation]: failed_ops = [] for operation in operations: if isinstance( @@ -96,7 +103,7 @@ def _check_operations(operations): continue else: # Check all the other operation types (SafeRunSQL, RunPython, unknown) - if operation.hints.get("tables"): + if cast(OperationWithHints, operation).hints.get("tables"): continue else: failed_ops.append(operation) diff --git a/src/sentry/new_migrations/monkey/fields.py b/src/sentry/new_migrations/monkey/fields.py index b11e363dfb68..71b7660a25a1 100644 --- a/src/sentry/new_migrations/monkey/fields.py +++ b/src/sentry/new_migrations/monkey/fields.py @@ -1,3 +1,6 @@ +from collections.abc import Sequence +from typing import TypeVar + from django.db.migrations import RemoveField from django.db.models import Field, ManyToManyField from django.db.models.fields import NOT_PROVIDED @@ -9,8 +12,13 @@ IGNORED_ATTRS = ["verbose_name", "help_text", "choices"] original_deconstruct = Field.deconstruct +_ST = TypeVar("_ST") +_GT = TypeVar("_GT") + -def deconstruct(self): +def deconstruct( + self: Field[_ST, _GT], +) -> tuple[str, str, Sequence[object], dict[str, object]]: """ Overrides the default field deconstruct method. This is used to pop unwanted keyword arguments from the field during deconstruction, so that they will be ignored @@ -23,8 +31,8 @@ def deconstruct(self): class SafeRemoveField(RemoveField): - def __init__(self, *args, deletion_action: DeletionAction, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, model_name: str, name: str, deletion_action: DeletionAction) -> None: + super().__init__(model_name, name) self.deletion_action = deletion_action def state_forwards(self, app_label: str, state: SentryProjectState) -> None: # type: ignore[override] diff --git a/src/sentry/new_migrations/monkey/models.py b/src/sentry/new_migrations/monkey/models.py index 84208bfeb0cd..ed7c6d202bd7 100644 --- a/src/sentry/new_migrations/monkey/models.py +++ b/src/sentry/new_migrations/monkey/models.py @@ -8,8 +8,8 @@ class SafeDeleteModel(DeleteModel): - def __init__(self, *args, deletion_action: DeletionAction, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, name: str, deletion_action: DeletionAction) -> None: + super().__init__(name) self.deletion_action = deletion_action def state_forwards(self, app_label: str, state: SentryProjectState) -> None: # type: ignore[override] diff --git a/src/sentry/new_migrations/monkey/special.py b/src/sentry/new_migrations/monkey/special.py index 4317bcba0d2e..e6bbb634dc7e 100644 --- a/src/sentry/new_migrations/monkey/special.py +++ b/src/sentry/new_migrations/monkey/special.py @@ -1,13 +1,34 @@ +from collections.abc import Mapping, Sequence +from typing import TypeAlias + from django.conf import settings +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.operations.base import Operation from django.db.migrations.operations.special import RunSQL +SqlArgument: TypeAlias = dict[str, object] | list[object] | tuple[object, ...] | None +SqlSpec: TypeAlias = str | tuple[str, SqlArgument] +SqlOperations: TypeAlias = str | Sequence[SqlSpec] + class SafeRunSQL(RunSQL): - def __init__(self, *args, use_statement_timeout=True, **kwargs): - super().__init__(*args, **kwargs) + def __init__( + self, + sql: SqlOperations, + reverse_sql: SqlOperations | None = None, + state_operations: Sequence[Operation] | None = None, + hints: Mapping[str, object] | None = None, + elidable: bool = False, + use_statement_timeout: bool = True, + ) -> None: + if isinstance(sql, Sequence) and not isinstance(sql, str): + sql = list(sql) + if isinstance(reverse_sql, Sequence) and not isinstance(reverse_sql, str): + reverse_sql = list(reverse_sql) + super().__init__(sql, reverse_sql, state_operations, hints, elidable) self.use_statement_timeout = use_statement_timeout - def _run_sql(self, schema_editor, sqls): + def _run_sql(self, schema_editor: BaseDatabaseSchemaEditor, sqls: SqlOperations) -> None: use_statement_timeout = ( settings.ZERO_DOWNTIME_MIGRATIONS_STATEMENT_TIMEOUT is not None and self.use_statement_timeout diff --git a/src/sentry/new_migrations/monkey/state.py b/src/sentry/new_migrations/monkey/state.py index aa78cf1b3569..c4a9f42c12a0 100644 --- a/src/sentry/new_migrations/monkey/state.py +++ b/src/sentry/new_migrations/monkey/state.py @@ -3,7 +3,7 @@ from copy import deepcopy from enum import Enum -from django.db.migrations.state import ProjectState +from django.db.migrations.state import ModelState, ProjectState from django.db.models import Field, Model from django_zero_downtime_migrations.backends.postgres.schema import UnsafeOperationException @@ -14,10 +14,14 @@ class DeletionAction(Enum): class SentryProjectState(ProjectState): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__( + self, + models: dict[tuple[str, str], ModelState] | None = None, + real_apps: set[str] | None = None, + ) -> None: + super().__init__(models=models, real_apps=real_apps) self.pending_deletion_models: dict[tuple[str, str], type[Model]] = {} - self.pending_deletion_fields: dict[tuple[str, str, str], type[Field]] = {} + self.pending_deletion_fields: dict[tuple[str, str, str], Field[object, object]] = {} def get_pending_deletion_model(self, app_label: str, model_name: str) -> type[Model]: model_key = (app_label.lower(), model_name.lower()) @@ -30,7 +34,7 @@ def get_pending_deletion_model(self, app_label: str, model_name: str) -> type[Mo def get_pending_deletion_field( self, app_label: str, model_name: str, field_name: str - ) -> type[Field]: + ) -> Field[object, object]: field_key = (app_label.lower(), model_name.lower(), field_name.lower()) if field_key not in self.pending_deletion_fields: raise UnsafeOperationException( @@ -66,7 +70,7 @@ def remove_field( model_name: str, name: str, deletion_action: DeletionAction | None = None, - ): + ) -> None: field_key = app_label.lower(), model_name.lower(), name.lower() if deletion_action == DeletionAction.DELETE: if field_key not in self.pending_deletion_fields: diff --git a/src/sentry/newsletter/base.py b/src/sentry/newsletter/base.py index b2fe04bd6520..5b2c311b821d 100644 --- a/src/sentry/newsletter/base.py +++ b/src/sentry/newsletter/base.py @@ -1,12 +1,26 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import Any +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Protocol from sentry.users.models.user import User from sentry.utils.services import Service +class SubscriptionRecord(Protocol): + @property + def list_id(self) -> int: ... + + @property + def subscribed(self) -> bool: ... + + @property + def verified(self) -> bool | None: ... + + def get(self, key: str, default: object = None) -> object: ... + + class Newsletter(Service): __all__ = ( "is_enabled", @@ -28,7 +42,7 @@ class Newsletter(Service): def is_enabled(self) -> bool: return self.enabled - def optout_email(self, email, **kwargs): + def optout_email(self, email: str, **kwargs: object) -> None: raise NotImplementedError """ @@ -38,7 +52,7 @@ def optout_email(self, email, **kwargs): def get_default_list_ids(self) -> tuple[int, ...]: return self.DEFAULT_LISTS - def get_subscriptions(self, user): + def get_subscriptions(self, user: User) -> Mapping[str, Sequence[SubscriptionRecord]] | None: return None def update_subscriptions( @@ -48,11 +62,11 @@ def update_subscriptions( list_ids: Sequence[int] | None = None, subscribed: bool = True, create: bool | None = None, - verified=None, - subscribed_date=None, - unsubscribed_date=None, - **kwargs, - ): + verified: bool | None = None, + subscribed_date: datetime | None = None, + unsubscribed_date: datetime | None = None, + **kwargs: object, + ) -> Mapping[int, object] | None: return None def create_or_update_subscriptions( @@ -61,11 +75,11 @@ def create_or_update_subscriptions( *, list_ids: Sequence[int] | None = None, subscribed: bool = True, - verified=None, - subscribed_date=None, - unsubscribed_date=None, - **kwargs: Any, - ): + verified: bool | None = None, + subscribed_date: datetime | None = None, + unsubscribed_date: datetime | None = None, + **kwargs: object, + ) -> Mapping[int, object] | None: return self.update_subscriptions( user=user, list_ids=list_ids, @@ -92,11 +106,11 @@ def update_subscription( list_id: int | None = None, subscribed: bool = True, create: bool | None = None, - verified=None, - subscribed_date=None, - unsubscribed_date=None, - **kwargs: Any, - ): + verified: bool | None = None, + subscribed_date: datetime | None = None, + unsubscribed_date: datetime | None = None, + **kwargs: object, + ) -> Mapping[int, object] | None: return None def create_or_update_subscription( @@ -105,11 +119,11 @@ def create_or_update_subscription( *, list_id: int | None = None, subscribed: bool = True, - verified=None, - subscribed_date=None, - unsubscribed_date=None, - **kwargs: Any, - ): + verified: bool | None = None, + subscribed_date: datetime | None = None, + unsubscribed_date: datetime | None = None, + **kwargs: object, + ) -> Mapping[int, object] | None: return self.update_subscription( user=user, list_id=list_id, diff --git a/src/sentry/newsletter/dummy.py b/src/sentry/newsletter/dummy.py index 54394d3648c9..28b6ef50d1b7 100644 --- a/src/sentry/newsletter/dummy.py +++ b/src/sentry/newsletter/dummy.py @@ -2,30 +2,31 @@ import contextlib from collections import defaultdict -from collections.abc import Generator, Sequence +from collections.abc import Generator, Mapping, Sequence +from datetime import datetime from typing import Any from django.utils import timezone from sentry.users.models.user import User -from .base import Newsletter +from .base import Newsletter, SubscriptionRecord class NewsletterSubscription: def __init__( self, - user, - list_id, - list_name=None, - list_description=None, - email=None, - verified=None, - subscribed=False, - subscribed_date=None, - unsubscribed_date=None, - **kwargs, - ): + user: User, + list_id: int, + list_name: str | None = None, + list_description: str | None = None, + email: str | None = None, + verified: bool | None = None, + subscribed: bool = False, + subscribed_date: datetime | None = None, + unsubscribed_date: datetime | None = None, + **kwargs: object, + ) -> None: from sentry.users.models.useremail import UserEmail self.email = user.email or email @@ -38,20 +39,27 @@ def __init__( ) # are they subscribed to ``list_id`` self.subscribed = subscribed + self.subscribed_date: datetime | None = None + self.unsubscribed_date: datetime | None = None if subscribed: self.subscribed_date = subscribed_date or timezone.now() elif subscribed is False: self.unsubscribed_date = unsubscribed_date or timezone.now() - def __getitem__(self, key): + def __getitem__(self, key: str) -> object: return getattr(self, key) - def get(self, key, default=None): + def get(self, key: str, default: object = None) -> object: return getattr(self, key, default) def update( - self, verified=None, subscribed=None, subscribed_date=None, unsubscribed_date=None, **kwargs - ): + self, + verified: bool | None = None, + subscribed: bool | None = None, + subscribed_date: datetime | None = None, + unsubscribed_date: datetime | None = None, + **kwargs: object, + ) -> None: if verified is not None: self.verified = verified if subscribed is not None: @@ -84,13 +92,13 @@ def enable(self) -> Generator[None]: finally: self._enabled = False - def clear(self): + def clear(self) -> None: self._subscriptions = defaultdict(dict) - def is_enabled(self): + def is_enabled(self) -> bool: return self._enabled - def get_subscriptions(self, user: User): + def get_subscriptions(self, user: User) -> Mapping[str, Sequence[SubscriptionRecord]]: return {"subscriptions": list((self._subscriptions.get(user) or {}).values())} def update_subscription( @@ -117,7 +125,7 @@ def update_subscriptions( list_ids: Sequence[int] | None = None, create: bool | None = False, **kwargs: Any, - ): + ) -> dict[int, NewsletterSubscription]: if not list_ids: list_ids = self.get_default_list_ids() diff --git a/src/sentry/notebooks/contracts.py b/src/sentry/notebooks/contracts.py index 17318f5d0678..165f65ab6e7d 100644 --- a/src/sentry/notebooks/contracts.py +++ b/src/sentry/notebooks/contracts.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast import orjson from django.utils.dateparse import parse_datetime @@ -32,7 +32,8 @@ def to_internal_value(self, data: Any) -> dict[str, Any]: unknown = sorted(set(data) - set(self.fields)) if unknown: raise serializers.ValidationError({field: "Unknown field." for field in unknown}) - return super().to_internal_value(data) + # justification: DRF returns the validated field mapping. + return cast("dict[str, Any]", super().to_internal_value(data)) class QueryTimeRangeSerializer(StrictContractSerializer): diff --git a/src/sentry/notebooks/endpoints/schema.py b/src/sentry/notebooks/endpoints/schema.py index 497aa03d090d..0463fd0a3cdd 100644 --- a/src/sentry/notebooks/endpoints/schema.py +++ b/src/sentry/notebooks/endpoints/schema.py @@ -1,4 +1,5 @@ -from typing import Any +from collections.abc import Callable +from typing import Any, cast from django.urls import resolve @@ -19,10 +20,21 @@ def _get_request_body(self, direction: str = "request") -> dict[str, Any] | None # otherwise drops DELETE bodies even when request= is explicitly annotated. serializer = self.get_request_serializer() if self.method != "DELETE" or serializer is None: - return super()._get_request_body(direction) - schema, required = self._get_request_for_media_type(serializer, direction) - media = {"schema": schema} - examples = self._get_examples(serializer, direction, "application/json", None) + # justification: drf-spectacular does not annotate _get_request_body. + return cast("Callable[..., dict[str, Any] | None]", super()._get_request_body)( + direction + ) + # justification: drf-spectacular does not annotate _get_request_for_media_type. + get_request_for_media_type = cast( + "Callable[[object, str], tuple[dict[str, Any], bool]]", + self._get_request_for_media_type, + ) + schema, required = get_request_for_media_type(serializer, direction) + # justification: schema and examples are OpenAPI JSON fragments. + media: dict[str, Any] = {"schema": schema} + # justification: drf-spectacular does not annotate _get_examples. + get_examples = cast("Callable[..., list[dict[str, Any]] | None]", self._get_examples) + examples = get_examples(serializer, direction, "application/json", None) if examples: media["examples"] = examples return {"content": {"application/json": media}, "required": required} diff --git a/src/sentry/notebooks/endpoints/validators/base.py b/src/sentry/notebooks/endpoints/validators/base.py index b86f48b2e87f..28f43e760640 100644 --- a/src/sentry/notebooks/endpoints/validators/base.py +++ b/src/sentry/notebooks/endpoints/validators/base.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, TypeVar +from typing import Any, TypeVar, cast from rest_framework import serializers from rest_framework.fields import empty @@ -56,4 +56,5 @@ def to_internal_value(self, data: Any) -> dict[str, Any]: unknown = sorted(set(data) - set(self.fields)) if unknown: raise serializers.ValidationError({field: "Unknown field." for field in unknown}) - return super().to_internal_value(data) + # justification: DRF returns the validated field mapping. + return cast("dict[str, Any]", super().to_internal_value(data)) diff --git a/src/sentry/notebooks/migrations/0002_copy_from_investigations.py b/src/sentry/notebooks/migrations/0002_copy_from_investigations.py index da04c26e3643..2c7a4ec9da48 100644 --- a/src/sentry/notebooks/migrations/0002_copy_from_investigations.py +++ b/src/sentry/notebooks/migrations/0002_copy_from_investigations.py @@ -87,6 +87,8 @@ def _sequence_last_value(schema_editor: BaseDatabaseSchemaEditor, table: str) -> (sequence,) = cursor.fetchone() cursor.execute(f"SELECT last_value FROM {sequence}") (last_value,) = cursor.fetchone() + if not isinstance(last_value, int): + raise TypeError("Sequence last value is not an integer") return last_value diff --git a/src/sentry/notebooks/models/block.py b/src/sentry/notebooks/models/block.py index 08f6a835e7f9..8b1c5263f945 100644 --- a/src/sentry/notebooks/models/block.py +++ b/src/sentry/notebooks/models/block.py @@ -78,9 +78,9 @@ class NotebookBlock(DefaultFieldsModel): # A producer's key for the block. Producers that replace their output in # revisions also set producer_revision; blocks people add leave both empty. producer_key = models.CharField(max_length=128, null=True) - producer_revision = BoundedPositiveIntegerField(null=True) + producer_revision = BoundedPositiveIntegerField[int | None, int | None](null=True) # Not a foreign key: Seer can start runs for a block that Sentry never sees. - producing_seer_run_id = BoundedBigIntegerField(null=True) + producing_seer_run_id = BoundedBigIntegerField[int | None, int | None](null=True) # Soft delete keeps execution history and comment targets. deleted_at = models.DateTimeField(null=True) diff --git a/src/sentry/notebooks/models/notebook.py b/src/sentry/notebooks/models/notebook.py index 3cacc5e3e4aa..558c2eeb5420 100644 --- a/src/sentry/notebooks/models/notebook.py +++ b/src/sentry/notebooks/models/notebook.py @@ -94,7 +94,7 @@ class Notebook(DefaultFieldsModel): # The producer's identity for what the notebook covers. A later run for the same # source reuses the active notebook, or starts a new revision once it's archived. source_key = models.CharField(max_length=256, null=True) - source_revision = BoundedPositiveIntegerField(null=True) + source_revision = BoundedPositiveIntegerField[int | None, int | None](null=True) # Saved defaults, like a dashboard's filters. Access is still checked against the # viewer when a block runs or an output is returned. diff --git a/src/sentry/notebooks/services/executions.py b/src/sentry/notebooks/services/executions.py index 82121ebc8bbc..d269cedcc50e 100644 --- a/src/sentry/notebooks/services/executions.py +++ b/src/sentry/notebooks/services/executions.py @@ -463,7 +463,7 @@ def block_execution_needs_dispatch(execution: NotebookBlockExecution) -> bool: return True return ( execution.status == NotebookBlockExecutionStatus.RUNNING - and execution.seer_run_id is None + and not execution.seer_run_id and ( execution.started_at is None or execution.started_at <= timezone.now() - DISPATCH_CLAIM_TIMEOUT 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..eada4f734c20 100644 --- a/src/sentry/notifications/api/endpoints/user_notification_details.py +++ b/src/sentry/notifications/api/endpoints/user_notification_details.py @@ -1,5 +1,8 @@ from collections import defaultdict +from collections.abc import Mapping, Sequence +from typing import cast +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 +13,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: { @@ -24,25 +29,40 @@ } -class UserNotificationsSerializer(Serializer): - def get_attrs(self, item_list, user, *args, **kwargs): +class UserNotificationsSerializer(Serializer[dict[str, bool]]): + 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 - def serialize(self, obj, attrs, user, *args, **kwargs): - raw_data = {option.key: option.value for option in attrs} + def serialize( + self, + obj: User, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[str, bool]: + # justification: the serializer pipeline passes this object's get_attrs list. + options = cast(Sequence[UserOption], attrs) + raw_data = {option.key: option.value for option in options} data = {} for key, uo in USER_OPTION_SETTINGS.items(): val = raw_data.get(uo["key"], uo["default"]) - data[key.value] = bool(int(val)) # '1' is true, '0' is false + # justification: stored option values are numeric strings. + data[key.value] = bool(int(cast(str, val))) # '1' is true, '0' is false return data @@ -61,11 +81,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..9410e4d5ddc4 100644 --- a/src/sentry/notifications/api/endpoints/user_notification_settings_options_detail.py +++ b/src/sentry/notifications/api/endpoints/user_notification_settings_options_detail.py @@ -1,3 +1,5 @@ +from typing import cast + from rest_framework import status from rest_framework.exceptions import NotFound from rest_framework.request import Request @@ -23,12 +25,13 @@ def convert_args( self, request: Request, user_id: int | str | None = None, - *args, + *args: object, notification_option_id: int, - **kwargs, - ): + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: args, kwargs = super().convert_args(request, user_id, *args, **kwargs) - user = kwargs["user"] + # justification: convert_args places the resolved user in kwargs. + user = cast(User, kwargs["user"]) try: option = NotificationSettingOption.objects.get( id=to_valid_int_id( diff --git a/src/sentry/notifications/api/serializers/notification_action_request.py b/src/sentry/notifications/api/serializers/notification_action_request.py index a8c9502e67e4..55a9cc368f20 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) @@ -45,7 +45,7 @@ class NotificationActionInputData(TypedDict, total=False): target_type: int -class NotificationActionSerializer(CamelSnakeModelSerializer): +class NotificationActionSerializer(CamelSnakeModelSerializer[NotificationAction]): """ Django Rest Framework serializer for incoming NotificationAction API payloads """ @@ -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..f011d34529e7 100644 --- a/src/sentry/notifications/api/serializers/notification_action_response.py +++ b/src/sentry/notifications/api/serializers/notification_action_response.py @@ -1,5 +1,5 @@ -from collections.abc import Sequence -from typing import TypedDict +from collections.abc import Mapping, Sequence +from typing import Any, TypedDict from django.contrib.auth.models import AnonymousUser @@ -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: Mapping[str, Any], # justification: serializer attrs are dynamically keyed. + 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/inboxitemevent.py b/src/sentry/notifications/models/inboxitemevent.py index b7f542db3de8..ae6c10aabd60 100644 --- a/src/sentry/notifications/models/inboxitemevent.py +++ b/src/sentry/notifications/models/inboxitemevent.py @@ -46,7 +46,7 @@ class InboxItemEvent(DefaultFieldsModel): max_length=32, choices=[(actor_type.value, actor_type.name) for actor_type in InboxActorType], ) - actor_id = BoundedBigIntegerField(null=True) + actor_id = BoundedBigIntegerField[int | None, int | None](null=True) # Metadata specific to the event type, such as the comment to link to. data = models.JSONField(default=dict) diff --git a/src/sentry/notifications/models/notificationaction.py b/src/sentry/notifications/models/notificationaction.py index c54b87658274..49ec58e9c128 100644 --- a/src/sentry/notifications/models/notificationaction.py +++ b/src/sentry/notifications/models/notificationaction.py @@ -2,7 +2,7 @@ import logging from abc import ABCMeta, abstractmethod -from collections.abc import Mapping, MutableMapping +from collections.abc import Callable, Mapping, MutableMapping from enum import IntEnum from typing import TYPE_CHECKING, Any, TypeVar @@ -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 @@ -227,7 +227,9 @@ class Meta: db_table = "sentry_notificationaction" @classmethod - def register_action(cls, trigger_type: int, service_type: int, target_type: int): + def register_action( + cls, trigger_type: int, service_type: int, target_type: int + ) -> Callable[[type[ActionRegistrationT]], type[ActionRegistrationT]]: """ Register a new trigger/service/target combination for NotificationActions. For example, allowing audit-logs (trigger) to fire actions to slack (service) channels (target) @@ -265,7 +267,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 +296,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/models/notificationmessage.py b/src/sentry/notifications/models/notificationmessage.py index ef6ebee67fd2..189c6a353829 100644 --- a/src/sentry/notifications/models/notificationmessage.py +++ b/src/sentry/notifications/models/notificationmessage.py @@ -36,7 +36,7 @@ class NotificationMessage(Model): error_code = IntegerField(null=True, db_index=True) # Resulting identifier from the vendor that can be leveraged for future interaction with the notification. - message_identifier = CharField(null=True) + message_identifier = CharField[str | None, str | None](null=True) # Reference to another notification if we choose to modify the original message or reply to it (like start a thread) parent_notification_message = FlexibleForeignKey("self", null=True) diff --git a/src/sentry/notifications/models/notificationsettingbase.py b/src/sentry/notifications/models/notificationsettingbase.py index d0d6a57b5d83..6b6e22bd3aca 100644 --- a/src/sentry/notifications/models/notificationsettingbase.py +++ b/src/sentry/notifications/models/notificationsettingbase.py @@ -1,6 +1,9 @@ +from collections.abc import Iterable + import sentry_sdk from django.conf import settings from django.db import models +from django.db.models.base import ModelBase from sentry.backup.scopes import RelocationScope from sentry.db.models import BoundedBigIntegerField, DefaultFieldsModelExisting, FlexibleForeignKey @@ -22,11 +25,23 @@ class NotificationSettingBase(DefaultFieldsModelExisting): class Meta: abstract = True - def save(self, *args, **kwargs): + def save( + self, + *, + force_insert: bool | tuple[ModelBase, ...] = False, + force_update: bool = False, + using: str | None = None, + update_fields: Iterable[str] | None = None, + ) -> None: try: - assert not (self.user_id is None and self.team_id is None), ( + assert not (not self.user_id and not self.team_id), ( "Notification setting missing user & team" ) except AssertionError as err: sentry_sdk.capture_exception(err) - super().save(*args, **kwargs) + super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) diff --git a/src/sentry/notifications/models/notificationthread.py b/src/sentry/notifications/models/notificationthread.py index 928a0c0401fc..2142fa704126 100644 --- a/src/sentry/notifications/models/notificationthread.py +++ b/src/sentry/notifications/models/notificationthread.py @@ -60,7 +60,7 @@ class NotificationThread(DefaultFieldsModel): (object_type.value, object_type.name) for object_type in NotificationThreadObjectType ], ) - object_id = BoundedBigIntegerField(null=True) + object_id = BoundedBigIntegerField[int | None, int | None](null=True) provider_data = models.JSONField(default=dict) diff --git a/src/sentry/notifications/notification_action/activity_registry/base.py b/src/sentry/notifications/notification_action/activity_registry/base.py index 3d1dc17f23e4..d2c4bbd4c464 100644 --- a/src/sentry/notifications/notification_action/activity_registry/base.py +++ b/src/sentry/notifications/notification_action/activity_registry/base.py @@ -46,7 +46,7 @@ def send_activity_notification( def require_config(action: Action, key: str) -> str: value = action.config.get(key) - if not value: + if not isinstance(value, str) or not value: raise ValueError(f"No {key} for action {action.id}") return value diff --git a/src/sentry/notifications/notification_action/group_type_notification_registry/handlers/metric_alert_registry_handler.py b/src/sentry/notifications/notification_action/group_type_notification_registry/handlers/metric_alert_registry_handler.py index f5c79971708c..886d5d3c638e 100644 --- a/src/sentry/notifications/notification_action/group_type_notification_registry/handlers/metric_alert_registry_handler.py +++ b/src/sentry/notifications/notification_action/group_type_notification_registry/handlers/metric_alert_registry_handler.py @@ -98,6 +98,8 @@ def get_targets( } for action_id, team_id in team_id_by_action_id.items(): organization_id = org_by_action_id.get(action_id) - result[action_id] = teams.get((team_id, organization_id)) + result[action_id] = ( + teams.get((team_id, organization_id)) if organization_id is not None else None + ) return result diff --git a/src/sentry/notifications/notification_action/grouptype.py b/src/sentry/notifications/notification_action/grouptype.py index 6112ec20b274..df119311577b 100644 --- a/src/sentry/notifications/notification_action/grouptype.py +++ b/src/sentry/notifications/notification_action/grouptype.py @@ -10,6 +10,7 @@ from sentry.issues.issue_occurrence import IssueOccurrence from sentry.issues.occurrence_consumer import process_event_and_issue_occurrence from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.ratelimits.sliding_windows import Quota from sentry.services.eventstore.models import GroupEvent from sentry.utils.samples import load_data @@ -38,7 +39,7 @@ def allow_ingest(cls, organization: Organization) -> bool: return True -def get_test_notification_event_data(project) -> GroupEvent | None: +def get_test_notification_event_data(project: Project) -> GroupEvent | None: occurrence = IssueOccurrence( id=uuid4().hex, project_id=project.id, @@ -57,7 +58,7 @@ def get_test_notification_event_data(project) -> GroupEvent | None: # Load mock data event_data = load_data( - platform=project.platform, + platform=project.platform or "", default="javascript", event_id=occurrence.event_id, ) diff --git a/src/sentry/notifications/notification_action/types.py b/src/sentry/notifications/notification_action/types.py index 09389b0acea4..0391d537670b 100644 --- a/src/sentry/notifications/notification_action/types.py +++ b/src/sentry/notifications/notification_action/types.py @@ -257,6 +257,7 @@ def create_notification_origin( rule_id__isnull=False, ).first() if alert_rule_workflow: + assert alert_rule_workflow.rule_id is not None if Rule.objects.filter( id=alert_rule_workflow.rule_id, project__organization_id=detector.linked_project.organization_id, diff --git a/src/sentry/notifications/notificationcontroller.py b/src/sentry/notifications/notificationcontroller.py index f11ed1aaba38..65774ab3f9d0 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: object) -> list[NotificationSettingOption]: return [ setting for setting in self.get_all_setting_options if all(getattr(setting, arg) == kwargs[arg] for arg in kwargs) ] - def _filter_providers( - self, - **kwargs, - ) -> list[NotificationSettingProvider]: + def _filter_providers(self, **kwargs: object) -> list[NotificationSettingProvider]: return [ setting for setting in self.get_all_setting_providers @@ -177,7 +171,7 @@ def _filter_providers( def _get_layered_setting_options( self, project_id: int | None = None, - **kwargs, + **kwargs: object, ) -> MutableMapping[ Recipient, MutableMapping[NotificationSettingEnum, NotificationSettingsOptionEnum] ]: @@ -234,7 +228,7 @@ def _get_layered_setting_options( def _get_layered_setting_providers( self, project_id: int | None = None, - **kwargs, + **kwargs: object, ) -> MutableMapping[ Recipient, MutableMapping[ diff --git a/src/sentry/notifications/notifications/base.py b/src/sentry/notifications/notifications/base.py index 969859907b6f..74a584ed03ea 100644 --- a/src/sentry/notifications/notifications/base.py +++ b/src/sentry/notifications/notifications/base.py @@ -24,7 +24,9 @@ from sentry.models.project import Project -def alert_page_needs_org_id(fine_tuning_key: NotificationSettingEnum | FineTuningAPIKey) -> bool: +def alert_page_needs_org_id( + fine_tuning_key: NotificationSettingEnum | FineTuningAPIKey | str, +) -> bool: """Determines if the alert setting page needs the organization id in the query params.""" return fine_tuning_key in ( FineTuningAPIKey.ALERTS, 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/rollout.py b/src/sentry/notifications/platform/rollout.py index 7b8c2da50b9b..3bb06b95229e 100644 --- a/src/sentry/notifications/platform/rollout.py +++ b/src/sentry/notifications/platform/rollout.py @@ -85,4 +85,4 @@ def get_rollout_rate(self, option_key: str, source: NotificationSource) -> float ) return 0.0 - return source_rollout_rate + return float(source_rollout_rate) 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/platform/shadow/compare.py b/src/sentry/notifications/platform/shadow/compare.py index 89b30774e292..8c642b125653 100644 --- a/src/sentry/notifications/platform/shadow/compare.py +++ b/src/sentry/notifications/platform/shadow/compare.py @@ -5,7 +5,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field from enum import StrEnum -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import orjson import sentry_sdk @@ -65,7 +65,8 @@ def _as_list(value: Any) -> list[Any]: if not value: return [] if isinstance(value, (str, bytes)): - return orjson.loads(value) + parsed = orjson.loads(value) + return parsed if isinstance(parsed, list) else [] return [item.to_dict() if isinstance(item, Block) else item for item in value] @@ -90,7 +91,8 @@ def _normalize(provider: NotificationProviderKey, payload: Mapping[str, Any]) -> "text": payload.get("text") or "", } if provider == NotificationProviderKey.MSTEAMS: - return _without_integration_id(payload) + # justification: the normalization helper preserves the mapping shape. + return cast("Mapping[str, Any]", _without_integration_id(payload)) return payload diff --git a/src/sentry/notifications/platform/templates/seer.py b/src/sentry/notifications/platform/templates/seer.py index a7d13e60b82f..3b56a2d5eaa8 100644 --- a/src/sentry/notifications/platform/templates/seer.py +++ b/src/sentry/notifications/platform/templates/seer.py @@ -96,7 +96,7 @@ def has_next_trigger(self) -> bool: ) if has_coding_enabled is None: has_coding_enabled = ENABLE_SEER_CODING_DEFAULT - return has_coding_enabled + return bool(has_coding_enabled) case AutofixStoppingPoint.OPEN_PR: return False diff --git a/src/sentry/notifications/utils/__init__.py b/src/sentry/notifications/utils/__init__.py index 983abde5a229..167e20d2a475 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 @@ -316,11 +315,15 @@ def get_performance_issue_alert_subtitle(event: GroupEvent) -> str: repeating_spans_compact = event.occurrence.evidence_data.get("repeating_spans_compact", "") if isinstance(repeating_spans_compact, list): repeating_spans_compact = repeating_spans_compact[0] - return repeating_spans_compact.replace("`", '"') + return ( + repeating_spans_compact.replace("`", '"') + if isinstance(repeating_spans_compact, str) + else "" + ) 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 @@ -356,7 +359,7 @@ def get_replay_id(event: Event | GroupEvent) -> str | None: evidence_contexts = event.occurrence.evidence_data.get("contexts") or {} evidence_replay_id = (evidence_contexts.get("replay") or {}).get("replay_id") - if evidence_replay_id: + if isinstance(evidence_replay_id, str) and evidence_replay_id: return evidence_replay_id return replay_id diff --git a/src/sentry/notifications/utils/digest.py b/src/sentry/notifications/utils/digest.py index 3996265afc7c..19512f77e0fa 100644 --- a/src/sentry/notifications/utils/digest.py +++ b/src/sentry/notifications/utils/digest.py @@ -47,7 +47,7 @@ def should_send_as_alert_notification(context: Mapping[str, Any]) -> bool: def get_timestamp(record_param: Any) -> float: - return record_param.timestamp + return float(record_param.timestamp) def send_as_alert_notification( diff --git a/src/sentry/notifications/utils/participants.py b/src/sentry/notifications/utils/participants.py index 1b7c354fb7be..e0ea960fac37 100644 --- a/src/sentry/notifications/utils/participants.py +++ b/src/sentry/notifications/utils/participants.py @@ -215,7 +215,11 @@ def get_owners( else: outcome = "everyone" users = user_service.get_many_by_id( - ids=list(project.member_set.values_list("user_id", flat=True)) + ids=[ + user_id + for user_id in project.member_set.values_list("user_id", flat=True) + if user_id is not None + ] ) recipients = Actor.many_from_object(users) return (recipients, outcome) @@ -384,12 +388,20 @@ def get_fallthrough_recipients( elif fallthrough_choice == FallthroughChoiceType.ALL_MEMBERS: return user_service.get_many_by_id( - ids=list(project.member_set.values_list("user_id", flat=True)) + ids=[ + user_id + for user_id in project.member_set.values_list("user_id", flat=True) + if user_id is not None + ] ) elif fallthrough_choice == FallthroughChoiceType.ACTIVE_MEMBERS: member_users = user_service.get_many_by_id( - ids=list(project.member_set.values_list("user_id", flat=True)), + ids=[ + user_id + for user_id in project.member_set.values_list("user_id", flat=True) + if user_id is not None + ], ) member_users.sort( key=lambda u: u.last_active.isoformat() if u.last_active else "", reverse=True diff --git a/src/sentry/notifications/validators.py b/src/sentry/notifications/validators.py index 3c1b292a66fa..5d5f9eee0e5b 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,29 @@ from sentry.notifications.types import NOTIFICATION_SETTING_CHOICES, NotificationScopeEnum -class UserNotificationSettingsOptionsDetailsSerializer(CamelSnakeSerializer): +class UserNotificationSettingData(TypedDict, total=False): + type: str + scope_type: str + scope_identifier: str + value: str + providers: list[str] + + +class UserNotificationSettingsOptionsDetailsSerializer( + CamelSnakeSerializer[UserNotificationSettingData] +): 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 +48,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 +67,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/objectstore/endpoints/organization.py b/src/sentry/objectstore/endpoints/organization.py index cf9d7c5b7a87..ed20c64b795a 100644 --- a/src/sentry/objectstore/endpoints/organization.py +++ b/src/sentry/objectstore/endpoints/organization.py @@ -154,7 +154,10 @@ def is_valid_object_path(path: str) -> bool: def get_target_url(path: str) -> str: - base = settings.SENTRY_OBJECTSTORE_CONFIG["base_url"].rstrip("/") + base = settings.SENTRY_OBJECTSTORE_CONFIG["base_url"] + if not isinstance(base, str): + raise TypeError("SENTRY_OBJECTSTORE_CONFIG base_url is not a string") + base = base.rstrip("/") # `path` should be a relative path, only grab that part path = urlparse(path).path # Simply concatenate base and path, without resolving URLs diff --git a/src/sentry/onboarding_tasks/backends/organization_onboarding_task.py b/src/sentry/onboarding_tasks/backends/organization_onboarding_task.py index 0d043a9275e6..629300d9dec9 100644 --- a/src/sentry/onboarding_tasks/backends/organization_onboarding_task.py +++ b/src/sentry/onboarding_tasks/backends/organization_onboarding_task.py @@ -1,8 +1,9 @@ +from collections.abc import Mapping from datetime import datetime import sentry_sdk from django.db import IntegrityError, router, transaction -from django.db.models import Q +from django.db.models import Q, QuerySet from django.forms import model_to_dict from django.utils import timezone @@ -17,6 +18,7 @@ ) from sentry.models.project import Project from sentry.onboarding_tasks.base import OnboardingTaskBackend +from sentry.users.models.user import User from sentry.utils import json from sentry.utils.platform_categories import SOURCE_MAPS @@ -24,14 +26,18 @@ class OrganizationOnboardingTaskBackend(OnboardingTaskBackend[OrganizationOnboardingTask]): Model = OrganizationOnboardingTask - def fetch_onboarding_tasks(self, organization, user): + def fetch_onboarding_tasks( + self, organization: Organization, user: User + ) -> QuerySet[OrganizationOnboardingTask]: return self.Model.objects.filter( organization=organization, task__in=OnboardingTask.values(), # we exclude any tasks that might no longer be in the onboarding flow but still linger around in the database status__in=OnboardingTaskStatus.values(), # same here but for status ) - def create_or_update_onboarding_task(self, organization, user, task, values): + def create_or_update_onboarding_task( + self, organization: Organization, user: User, task: int, values: Mapping[str, object] + ) -> tuple[OrganizationOnboardingTask, bool]: return self.Model.objects.update_or_create( organization=organization, task=task, @@ -44,7 +50,7 @@ def complete_onboarding_task( organization: Organization, task: int, date_completed: datetime | None = None, - **task_kwargs, + **task_kwargs: object, ) -> bool: # Mark the task as complete created = self.Model.objects.record( @@ -65,7 +71,7 @@ def has_completed_onboarding_task(self, organization: Organization, task: int) - organization_id=organization.id, task=task ).exists() - def try_mark_onboarding_complete(self, organization_id: int): + def try_mark_onboarding_complete(self, organization_id: int) -> None: if OrganizationOption.objects.filter( organization_id=organization_id, key="onboarding:complete" ).exists(): @@ -123,7 +129,7 @@ def transfer_onboarding_tasks( from_organization_id: int, to_organization_id: int, project: Project | None = None, - ): + ) -> None: # get a list of task IDs that already exist in the new organization existing_tasks_in_new_org = OrganizationOnboardingTask.objects.filter( organization_id=to_organization_id diff --git a/src/sentry/onboarding_tasks/base.py b/src/sentry/onboarding_tasks/base.py index 7a2efc3207f9..4a14060fc358 100644 --- a/src/sentry/onboarding_tasks/base.py +++ b/src/sentry/onboarding_tasks/base.py @@ -1,11 +1,15 @@ from __future__ import annotations +from collections.abc import Mapping from datetime import datetime from typing import Generic, TypeVar +from django.db.models import QuerySet + from sentry.models.organization import Organization -from sentry.models.organizationonboardingtask import AbstractOnboardingTask +from sentry.models.organizationonboardingtask import AbstractOnboardingTask, OnboardingTaskStatus from sentry.models.project import Project +from sentry.users.models.user import User from sentry.utils.services import Service T = TypeVar("T", bound=AbstractOnboardingTask) @@ -25,19 +29,21 @@ class OnboardingTaskBackend(Service, Generic[T]): ) Model: type[T] - def get_task_lookup_by_key(self, key): + def get_task_lookup_by_key(self, key: str) -> int | None: return self.Model.TASK_LOOKUP_BY_KEY.get(key) - def get_status_lookup_by_key(self, key): + def get_status_lookup_by_key(self, key: str) -> OnboardingTaskStatus | None: return self.Model.STATUS_LOOKUP_BY_KEY.get(key) - def get_skippable_tasks(self, organization: Organization): + def get_skippable_tasks(self, organization: Organization) -> frozenset[int]: return self.Model.SKIPPABLE_TASKS - def fetch_onboarding_tasks(self, organization, user): + def fetch_onboarding_tasks(self, organization: Organization, user: User) -> QuerySet[T]: raise NotImplementedError - def create_or_update_onboarding_task(self, organization, user, task, values): + def create_or_update_onboarding_task( + self, organization: Organization, user: User, task: int, values: Mapping[str, object] + ) -> tuple[T, bool]: raise NotImplementedError def complete_onboarding_task( @@ -45,14 +51,14 @@ def complete_onboarding_task( organization: Organization, task: int, date_completed: datetime | None = None, - **task_kwargs, + **task_kwargs: object, ) -> bool: raise NotImplementedError def has_completed_onboarding_task(self, organization: Organization, task: int) -> bool: raise NotImplementedError - def try_mark_onboarding_complete(self, organization_id: int): + def try_mark_onboarding_complete(self, organization_id: int) -> None: raise NotImplementedError def transfer_onboarding_tasks( @@ -60,5 +66,5 @@ def transfer_onboarding_tasks( from_organization_id: int, to_organization_id: int, project: Project | None = None, - ): + ) -> None: raise NotImplementedError diff --git a/src/sentry/options/manager.py b/src/sentry/options/manager.py index 0fb1225ca376..b3233de7bf35 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 @@ -30,6 +30,11 @@ NoneType = type(None) + +def _is_none_type(value: object) -> bool: + return value is NoneType + + # Returned by a read hook when it has no value to serve for the key. A unique # object so it can never collide with a value a hook might legitimately return. READ_HOOK_FALLBACK = object() @@ -203,7 +208,13 @@ def set_read_hook(self, hook: ReadHook | None) -> None: """ self._read_hook = hook - def set(self, key: str, value, coerce=True, channel: UpdateChannel = UpdateChannel.UNKNOWN): + def set( + self, + key: str, + value: object, + coerce: bool = True, + channel: UpdateChannel = UpdateChannel.UNKNOWN, + ) -> bool | None: """ Set the value for an option. If the cache is unavailable the action will still succeed. @@ -239,7 +250,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: @@ -306,7 +317,7 @@ def is_set_on_disk(self, key: str) -> bool: """ return key in settings.SENTRY_OPTIONS - def get(self, key: str, silent=False): + def get(self, key: str, silent: object = False) -> TAny: """ Get the value of an option, falling back to the local configuration. @@ -352,7 +363,7 @@ def get(self, key: str, silent=False): return result if not (opt.flags & FLAG_NOSTORE): - result = self.store.get(opt, silent=silent) + result = self.store.get(opt, silent=bool(silent)) if result is not None: tags["source"] = "store" record_option(key, result) @@ -373,7 +384,7 @@ def get(self, key: str, silent=False): record_option(key, optval) return optval - def delete(self, key: str): + def delete(self, key: str) -> bool: """ Permanently remove the value of an option. @@ -395,14 +406,14 @@ def delete(self, key: str): def register( self, key: str, - default=None, - type=None, + default: object | Callable[[], object] | None = None, + type: Type[TAny] | None = None, flags: int = DEFAULT_FLAGS, ttl: int = DEFAULT_KEY_TTL, grace: int = DEFAULT_KEY_GRACE, # Optional info about how to group options together in the _admin ui. Only applies to # options marked `FLAG_ADMIN_MODIFIABLE` - grouping_info=None, + grouping_info: GroupingInfo | None = None, ) -> None: assert key not in self.registry, "Option already registered: %r" % key @@ -424,7 +435,7 @@ def register( if not callable(default): default_value = default - def default(): + def default() -> object: return default_value else: @@ -437,7 +448,7 @@ def default(): if default_value is None: default_value = "" - def default(): + def default() -> object: return default_value type = type_from_value(default_value) @@ -445,7 +456,7 @@ def default(): # We disallow None as a value for options since this is ambiguous and doesn't # really make sense as config options. There should be a sensible default # value instead that matches the type expected, rather than relying on None. - if type is NoneType: + if _is_none_type(type): raise TypeError("Options must not be None") # Make sure the type is correct at registration time @@ -474,7 +485,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 +494,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 +505,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 +526,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/rollout.py b/src/sentry/options/rollout.py index 13d2c3f5e90f..c2db23dc0633 100644 --- a/src/sentry/options/rollout.py +++ b/src/sentry/options/rollout.py @@ -11,7 +11,7 @@ def in_random_rollout(option_name: str) -> bool: Determine if the current operation is in a random % based rollout group governed by an option with `option_name`. """ - return random.random() < options.get(option_name) + return bool(random.random() < options.get(option_name)) def in_rollout_group(option_name: str, key: int | str) -> bool: @@ -23,4 +23,4 @@ def in_rollout_group(option_name: str, key: int | str) -> bool: int_key = int(hashlib.md5(key.encode("utf8")).hexdigest(), base=16) else: int_key = key - return (int_key % 100000) / 100000 < options.get(option_name) + return bool((int_key % 100000) / 100000 < options.get(option_name)) diff --git a/src/sentry/options/store.py b/src/sentry/options/store.py index 9f5eacc54d3a..5ca6cf687ecf 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, cast 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,11 @@ from sentry.options.manager import UpdateChannel from sentry.utils.types import Type +if TYPE_CHECKING: + from django.utils.connection import ConnectionProxy + + from sentry.models.options import ControlOption, Option + CACHE_FETCH_ERR = "Unable to fetch option cache for %s" CACHE_UPDATE_ERR = "Unable to update option cache for %s" @@ -58,7 +64,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) @@ -78,17 +84,19 @@ class OptionsStore: OptionsManager instead, unless you need raw access to something. """ - def __init__(self, cache=None, ttl=None): + def __init__( + self, cache: BaseCache | ConnectionProxy[BaseCache] | None = None, ttl: int | None = None + ) -> None: self.cache = cache self.ttl = ttl self.flush_local_cache() @property - def model(self): + def model(self) -> type[Option] | type[ControlOption]: return self.model_cls() @classmethod - def model_cls(cls): + def model_cls(cls) -> type[Option] | type[ControlOption]: from sentry.models.options import ControlOption, Option from sentry.silo.base import SiloMode @@ -96,7 +104,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,25 +129,27 @@ 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) if value is not None: return value - if self.cache is None: + cache: BaseCache | ConnectionProxy[BaseCache] | None = self.cache + if cache is None: return None cache_key = key.cache_key try: - value = self.cache.get(cache_key) + value = cache.get(cache_key) except Exception: if not silent: logger.warning(CACHE_FETCH_ERR, key.name, extra={"key": key.name}, exc_info=True) @@ -150,7 +160,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 +205,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 +246,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. """ @@ -247,7 +257,7 @@ def get_last_update_channel(self, key) -> UpdateChannel | None: return UpdateChannel(option.last_updated_by) - def set(self, key, value, channel: UpdateChannel): + def set(self, key: Key, value: object, channel: UpdateChannel) -> bool | None: """ Store a value in the option store. Value must get persisted to database first, then attempt caches. If it fails database, the entire operation blows up. @@ -259,7 +269,7 @@ def set(self, key, value, channel: UpdateChannel): self.set_store(key, value, channel) return self.set_cache(key, value) - def set_store(self, key, value, channel: UpdateChannel): + def set_store(self, key: Key, value: object, channel: UpdateChannel) -> None: self.model.objects.update_or_create( key=key.name, defaults={ @@ -269,7 +279,7 @@ def set_store(self, key, value, channel: UpdateChannel): }, ) - def set_cache(self, key, value): + def set_cache(self, key: Key, value: object) -> bool | None: if self.cache is None: return None @@ -285,7 +295,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 +307,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] @@ -308,13 +318,13 @@ def delete_cache(self, key): pass try: - self.cache.delete(cache_key) + cast(BaseCache, self.cache).delete(cache_key) return True except Exception: 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 +352,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 +373,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/organizations/absolute_url.py b/src/sentry/organizations/absolute_url.py index da9dc6b72034..ddd394604a3f 100644 --- a/src/sentry/organizations/absolute_url.py +++ b/src/sentry/organizations/absolute_url.py @@ -59,7 +59,10 @@ def _generate_organization_hostname(org_slug: str) -> str: def generate_organization_url(org_slug: str) -> str: org_url_template: str | None = settings.SENTRY_ORGANIZATION_URL_TEMPLATE if not org_url_template: - return options.get("system.url-prefix") + url_prefix = options.get("system.url-prefix") + if not isinstance(url_prefix, str): + raise TypeError("system.url-prefix is not configured") + return url_prefix return org_url_template.replace("{hostname}", _generate_organization_hostname(org_slug)) diff --git a/src/sentry/partnerships/base.py b/src/sentry/partnerships/base.py index 19ded0123077..0b06025998ed 100644 --- a/src/sentry/partnerships/base.py +++ b/src/sentry/partnerships/base.py @@ -12,7 +12,7 @@ class Partnership(Service): __all__ = ("get_inbound_filters",) - def __init__(self, **options): + def __init__(self, **options: object) -> None: pass def get_inbound_filters(self, organization: Organization) -> list[GenericFilter]: diff --git a/src/sentry/pipeline/views/nested.py b/src/sentry/pipeline/views/nested.py index e2e306918491..c6f6b8f6ad66 100644 --- a/src/sentry/pipeline/views/nested.py +++ b/src/sentry/pipeline/views/nested.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from django.http.request import HttpRequest from django.http.response import HttpResponseBase @@ -56,4 +56,5 @@ def dispatch(self, request: HttpRequest, pipeline: P1) -> HttpResponseBase: if not nested_pipeline.is_valid(): nested_pipeline.initialize() - return nested_pipeline.current_step() + # justification: pipeline steps render Django responses. + return cast("HttpResponseBase", nested_pipeline.current_step()) diff --git a/src/sentry/plugins/base/binding_manager.py b/src/sentry/plugins/base/binding_manager.py index c4bc36c8dba5..2723d6efe40b 100644 --- a/src/sentry/plugins/base/binding_manager.py +++ b/src/sentry/plugins/base/binding_manager.py @@ -1,5 +1,7 @@ from __future__ import annotations +import builtins +from collections.abc import ItemsView, Iterator from typing import Any from sentry.plugins.providers import IntegrationRepositoryProvider @@ -8,22 +10,22 @@ class ProviderManager: type: type[Any] | None = None - def __init__(self): - self._items = {} + def __init__(self) -> None: + self._items: dict[str, type[Any]] = {} - def __iter__(self): + def __iter__(self) -> Iterator[str]: return iter(self._items) - def add(self, item, id): + def add(self, item: builtins.type[Any], id: str) -> None: if self.type and not issubclass(item, self.type): raise ValueError(f"Invalid type for provider: {type(item)}") self._items[id] = item - def get(self, id): + def get(self, id: str) -> builtins.type[Any]: return self._items[id] - def all(self): + def all(self) -> ItemsView[str, builtins.type[Any]]: return self._items.items() @@ -32,15 +34,15 @@ class IntegrationRepositoryProviderManager(ProviderManager): class BindingManager: - BINDINGS = { + BINDINGS: dict[str, type[ProviderManager]] = { "integration-repository.provider": IntegrationRepositoryProviderManager, } - def __init__(self): - self._bindings = {k: v() for k, v in self.BINDINGS.items()} + def __init__(self) -> None: + self._bindings: dict[str, ProviderManager] = {k: v() for k, v in self.BINDINGS.items()} - def add(self, name, binding, **kwargs): + def add(self, name: str, binding: type[Any], **kwargs: Any) -> None: self._bindings[name].add(binding, **kwargs) - def get(self, name): + def get(self, name: str) -> ProviderManager: return self._bindings[name] diff --git a/src/sentry/plugins/interfaces/releasehook.py b/src/sentry/plugins/interfaces/releasehook.py index 65de6fec8795..50f0944161ea 100644 --- a/src/sentry/plugins/interfaces/releasehook.py +++ b/src/sentry/plugins/interfaces/releasehook.py @@ -1,6 +1,9 @@ __all__ = ["ReleaseHook"] import logging +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import TypedDict, Unpack from django.db import IntegrityError, router, transaction from django.http import HttpRequest @@ -9,20 +12,28 @@ from sentry.exceptions import HookValidationError from sentry.models.activity import Activity +from sentry.models.project import Project from sentry.models.release import Release from sentry.types.activity import ActivityType logger = logging.getLogger(__name__) +class _ReleaseValues(TypedDict, total=False): + date_released: datetime + ref: str | None + url: str | None + status: int + + class ReleaseHook: - def __init__(self, project): + def __init__(self, project: Project) -> None: self.project = project # TODO(dcramer): this is being used by the release details endpoint, but # it'd be ideal if most if not all of this logic lived there, and this # hook simply called out to the endpoint - def set_commits(self, version, commit_list): + def set_commits(self, version: str, commit_list: Sequence[Mapping[str, object]]) -> None: """ Commits should be ordered oldest to newest. @@ -41,12 +52,15 @@ def set_commits(self, version, commit_list): release = Release.objects.get(organization_id=project.organization_id, version=version) release.add_project(project) - release.set_commits(commit_list) + if isinstance(commit_list, list): + release.set_commits(commit_list) + else: + release.set_commits(list(commit_list)) - def set_refs(self, release, **values): + def set_refs(self, release: Release, **values: Unpack[_ReleaseValues]) -> None: pass - def finish_release(self, version, **values): + def finish_release(self, version: str, **values: Unpack[_ReleaseValues]) -> None: if not Release.is_valid_version(version): raise HookValidationError("Invalid release version: %s" % version) diff --git a/src/sentry/plugins/providers/base.py b/src/sentry/plugins/providers/base.py index 78aa5960d60a..1d211bc8eb99 100644 --- a/src/sentry/plugins/providers/base.py +++ b/src/sentry/plugins/providers/base.py @@ -1,12 +1,15 @@ from __future__ import annotations import logging +from collections.abc import Mapping +from typing import Any from django.urls import reverse from rest_framework.response import Response from sentry.exceptions import InvalidIdentity, PluginError from sentry.integrations.services.integration import integration_service +from sentry.models.organization import Organization from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.users.models.user import User from sentry.users.services.user import RpcUser @@ -18,7 +21,13 @@ class ProviderMixin: auth_provider: str | None = None logger = logging.getLogger(__name__) - def link_auth(self, user, organization, data): + def link_auth( + # justification: provider installation payload + self, + user: User | RpcUser, + organization: Organization, + data: Mapping[str, Any], # justification: provider installation payload + ) -> None: usa = usersocialauth_service.get_one_or_none( filter={ "id": data["default_auth_id"], @@ -32,7 +41,8 @@ def link_auth(self, user, organization, data): rpc_organization = serialize_rpc_organization(org=organization) usersocialauth_service.link_auth(usa=usa, organization=rpc_organization) - def get_auth(self, user: RpcUser | User, **kwargs) -> RpcUserSocialAuth | None: + # justification: framework kwargs + def get_auth(self, user: RpcUser | User, **kwargs: Any) -> RpcUserSocialAuth | None: if self.auth_provider is None: return None diff --git a/src/sentry/plugins/providers/integration_repository.py b/src/sentry/plugins/providers/integration_repository.py index 972dea83eaeb..6db697bb8b03 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) @@ -679,7 +679,8 @@ def repository_external_slug(self, repo: Any) -> str | None: The shape of this id must match the `identifier` returned by the integration's Integration.get_repositories() method """ - return repo.name + name = repo.name + return name if isinstance(name, str) else None @staticmethod def should_ignore_commit(message: str) -> bool: diff --git a/src/sentry/preprod/api/endpoints/builds.py b/src/sentry/preprod/api/endpoints/builds.py index 5f8fa8617a0b..5c0d262635a1 100644 --- a/src/sentry/preprod/api/endpoints/builds.py +++ b/src/sentry/preprod/api/endpoints/builds.py @@ -1,6 +1,8 @@ import logging +from collections.abc import Callable from typing import Any +from django.db.models import QuerySet from rest_framework.request import Request from rest_framework.response import Response @@ -40,7 +42,7 @@ def on_results(artifacts: list[PreprodArtifact]) -> list[dict[str, Any]]: ) return results - paginate = lambda queryset: self.paginate( + paginate: Callable[[QuerySet[PreprodArtifact]], Response] = lambda queryset: self.paginate( order_by="-date_added", request=request, queryset=queryset, diff --git a/src/sentry/preprod/api/endpoints/preprod_artifact_rerun_analysis.py b/src/sentry/preprod/api/endpoints/preprod_artifact_rerun_analysis.py index 9981290be5fa..8d7ae2e1c1c7 100644 --- a/src/sentry/preprod/api/endpoints/preprod_artifact_rerun_analysis.py +++ b/src/sentry/preprod/api/endpoints/preprod_artifact_rerun_analysis.py @@ -94,7 +94,7 @@ def post( return success_response( artifact_id=str(head_artifact_id), - state=head_artifact.state, + state=PreprodArtifact.ArtifactState(head_artifact.state), ) @@ -177,7 +177,7 @@ def post(self, request: Request) -> Response: return success_response( artifact_id=str(preprod_artifact_id), - state=preprod_artifact.state, + state=PreprodArtifact.ArtifactState(preprod_artifact.state), cleanup_stats=cleanup_stats, ) @@ -295,15 +295,21 @@ def cleanup_old_metrics(preprod_artifact: PreprodArtifact) -> CleanupStats: if size_metrics: size_metric_ids = [sm.id for sm in size_metrics] - file_ids_to_delete = [sm.analysis_file_id for sm in size_metrics if sm.analysis_file_id] + file_ids_to_delete = [ + sm.analysis_file_id for sm in size_metrics if sm.analysis_file_id is not None + ] comparisons = PreprodArtifactSizeComparison.objects.filter( head_size_analysis_id__in=size_metric_ids ) | PreprodArtifactSizeComparison.objects.filter(base_size_analysis_id__in=size_metric_ids) - comparison_file_ids = list( - comparisons.exclude(file_id__isnull=True).values_list("file_id", flat=True) - ) + comparison_file_ids = [ + file_id + for file_id in comparisons.exclude(file_id__isnull=True).values_list( + "file_id", flat=True + ) + if file_id is not None + ] file_ids_to_delete.extend(comparison_file_ids) with transaction.atomic(using=router.db_for_write(PreprodArtifactSizeMetrics)): diff --git a/src/sentry/preprod/api/endpoints/project_installable_preprod_artifact_download.py b/src/sentry/preprod/api/endpoints/project_installable_preprod_artifact_download.py index 5a03bed7d40b..ea6701cf808a 100644 --- a/src/sentry/preprod/api/endpoints/project_installable_preprod_artifact_download.py +++ b/src/sentry/preprod/api/endpoints/project_installable_preprod_artifact_download.py @@ -152,6 +152,6 @@ def get(self, request: Request, project: Project, url_path: str) -> HttpResponse fp, content_type="application/octet-stream", ) - response["Content-Length"] = file_obj.size + response["Content-Length"] = str(file_obj.size) response["Content-Disposition"] = f'attachment; filename="{filename}"' return response diff --git a/src/sentry/preprod/api/endpoints/project_preprod_artifact_assemble_generic.py b/src/sentry/preprod/api/endpoints/project_preprod_artifact_assemble_generic.py index dc7eca9ed79c..b790cf70b554 100644 --- a/src/sentry/preprod/api/endpoints/project_preprod_artifact_assemble_generic.py +++ b/src/sentry/preprod/api/endpoints/project_preprod_artifact_assemble_generic.py @@ -118,6 +118,7 @@ def post(self, request: Request, project: Project, head_artifact_id: int) -> Res return Response({"error": error_message}, status=400) checksum = data.get("checksum") + assert isinstance(checksum, str) chunks = data.get("chunks", []) # Check if all requested chunks have been uploaded diff --git a/src/sentry/preprod/api/endpoints/size_analysis/project_preprod_size_analysis_compare_download.py b/src/sentry/preprod/api/endpoints/size_analysis/project_preprod_size_analysis_compare_download.py index a135b04a9918..ca24415012ab 100644 --- a/src/sentry/preprod/api/endpoints/size_analysis/project_preprod_size_analysis_compare_download.py +++ b/src/sentry/preprod/api/endpoints/size_analysis/project_preprod_size_analysis_compare_download.py @@ -120,5 +120,5 @@ def get( fp, content_type="application/json", ) - response["Content-Length"] = file_obj.size + response["Content-Length"] = str(file_obj.size) return response diff --git a/src/sentry/preprod/api/models/public/installable_builds.py b/src/sentry/preprod/api/models/public/installable_builds.py index 1809de77506e..3e6f709bcd74 100644 --- a/src/sentry/preprod/api/models/public/installable_builds.py +++ b/src/sentry/preprod/api/models/public/installable_builds.py @@ -134,6 +134,7 @@ def build_build_distribution_summary( # Determine webhook-level terminal state if has_error: + assert error_code_value is not None state = "FAILED" try: error_code_name = PreprodArtifact.InstallableAppErrorCode(error_code_value).name diff --git a/src/sentry/preprod/api/models/public/size_analysis.py b/src/sentry/preprod/api/models/public/size_analysis.py index 40dc4ab67aee..67780b4ef422 100644 --- a/src/sentry/preprod/api/models/public/size_analysis.py +++ b/src/sentry/preprod/api/models/public/size_analysis.py @@ -163,6 +163,7 @@ def _build_failed_comparison( error_code: str | None, error_message: str | None, ) -> ComparisonResponseDict: + assert head_metric.metrics_artifact_type is not None return { "metricsArtifactType": PreprodArtifactSizeMetrics.MetricsArtifactType( head_metric.metrics_artifact_type @@ -182,6 +183,7 @@ def _build_comparison_result( comparison_obj: PreprodArtifactSizeComparison, ) -> ComparisonResponseDict: """Build a single comparison result.""" + assert head_metric.metrics_artifact_type is not None if comparison_obj.state == PreprodArtifactSizeComparison.State.SUCCESS: return _build_success_comparison(head_metric, comparison_obj) elif comparison_obj.state == PreprodArtifactSizeComparison.State.FAILED: @@ -212,6 +214,7 @@ def _build_success_comparison( comparison_obj: PreprodArtifactSizeComparison, ) -> ComparisonResponseDict: """Build a comparison result with inlined diff data for SUCCESS state.""" + assert head_metric.metrics_artifact_type is not None if comparison_obj.file_id is None: sentry_sdk.capture_message( diff --git a/src/sentry/preprod/models.py b/src/sentry/preprod/models.py index faa7f46e1b5e..824cfb35e6f8 100644 --- a/src/sentry/preprod/models.py +++ b/src/sentry/preprod/models.py @@ -189,7 +189,7 @@ def as_choices(cls) -> tuple[tuple[int, str], ...]: project = FlexibleForeignKey("sentry.Project") # Nullable in case the file upload fails - file_id = BoundedBigIntegerField(db_index=True, null=True) + file_id = BoundedBigIntegerField[int | None, int | None](db_index=True, null=True) # The date the artifact was built. E.g. an artifact could be built on 05/21/2025, # but the user uploaded it on 05/22/2025. @@ -204,9 +204,13 @@ def as_choices(cls) -> tuple[tuple[int, str], ...]: ) # Nullable because we only know the type after the artifact has been processed - artifact_type = BoundedPositiveIntegerField(choices=ArtifactType.as_choices(), null=True) + artifact_type = BoundedPositiveIntegerField[int | None, int | None]( + choices=ArtifactType.as_choices(), null=True + ) - error_code = BoundedPositiveIntegerField(choices=ErrorCode.as_choices(), null=True) + error_code = BoundedPositiveIntegerField[int | None, int | None]( + choices=ErrorCode.as_choices(), null=True + ) error_message = models.TextField(null=True) # Version of tooling used to upload/build the artifact, extracted from metadata files @@ -227,7 +231,9 @@ def as_choices(cls) -> tuple[tuple[int, str], ...]: ) # Installable file like IPA or APK - installable_app_file_id = BoundedBigIntegerField(db_index=True, null=True) + installable_app_file_id = BoundedBigIntegerField[int | None, int | None]( + db_index=True, null=True + ) # The identifier of the app, e.g. "com.myapp.MyApp" app_id = models.CharField(max_length=255, null=True) @@ -235,7 +241,7 @@ def as_choices(cls) -> tuple[tuple[int, str], ...]: # An identifier for the main binary main_binary_identifier = models.CharField(max_length=255, db_index=True, null=True) - installable_app_error_code = BoundedPositiveIntegerField( + installable_app_error_code = BoundedPositiveIntegerField[int | None, int | None]( choices=InstallableAppErrorCode.as_choices(), null=True ) installable_app_error_message = models.TextField(null=True) @@ -248,13 +254,14 @@ def get_mobile_app_info(self) -> PreprodArtifactMobileAppInfo | None: def platform(self) -> Platform | None: if self.artifact_type is None: return None - match self.artifact_type: + artifact_type = self.ArtifactType(self.artifact_type) + match artifact_type: case self.ArtifactType.XCARCHIVE: return Platform.APPLE case self.ArtifactType.AAB | self.ArtifactType.APK: return Platform.ANDROID case _: - assert_never(self.artifact_type) + assert_never(artifact_type) def get_sibling_artifacts_for_commit(self) -> list[PreprodArtifact]: """ @@ -607,7 +614,7 @@ def as_choices(cls) -> tuple[tuple[int, str], ...]: __relocation_scope__ = RelocationScope.Excluded preprod_artifact = FlexibleForeignKey("preprod.PreprodArtifact") - metrics_artifact_type = BoundedPositiveIntegerField( + metrics_artifact_type = BoundedPositiveIntegerField[int | None, int | None]( choices=MetricsArtifactType.as_choices(), null=True ) @@ -618,20 +625,22 @@ def as_choices(cls) -> tuple[tuple[int, str], ...]: state = BoundedPositiveIntegerField( default=SizeAnalysisState.PENDING, choices=SizeAnalysisState.as_choices() ) - error_code = BoundedPositiveIntegerField(choices=ErrorCode.as_choices(), null=True) + error_code = BoundedPositiveIntegerField[int | None, int | None]( + choices=ErrorCode.as_choices(), null=True + ) error_message = models.TextField(null=True) # Track which version of size processing determined these values processing_version = models.CharField(max_length=255, null=True) # Size fields are nullable since they won't be available until processing completes - min_install_size = BoundedPositiveBigIntegerField(null=True) - max_install_size = BoundedPositiveBigIntegerField(null=True) - min_download_size = BoundedPositiveBigIntegerField(null=True) - max_download_size = BoundedPositiveBigIntegerField(null=True) + min_install_size = BoundedPositiveBigIntegerField[int | None, int | None](null=True) + max_install_size = BoundedPositiveBigIntegerField[int | None, int | None](null=True) + min_download_size = BoundedPositiveBigIntegerField[int | None, int | None](null=True) + max_download_size = BoundedPositiveBigIntegerField[int | None, int | None](null=True) # Size analysis wont necessarily be run on every artifact (based on quotas) - analysis_file_id = BoundedBigIntegerField(db_index=True, null=True) + analysis_file_id = BoundedBigIntegerField[int | None, int | None](db_index=True, null=True) class Meta: app_label = "preprod" @@ -700,7 +709,7 @@ class PreprodArtifactSizeComparison(DefaultFieldsModel): organization_id = BoundedBigIntegerField(db_index=True) # File id of the size diff json in filestore - file_id = BoundedBigIntegerField(db_index=True, null=True) + file_id = BoundedBigIntegerField[int | None, int | None](db_index=True, null=True) class State(IntEnum): PENDING = 0 @@ -741,7 +750,9 @@ def as_choices(cls) -> tuple[tuple[int, str], ...]: ) # Set when state is FAILED - error_code = BoundedPositiveIntegerField(choices=ErrorCode.as_choices(), null=True) + error_code = BoundedPositiveIntegerField[int | None, int | None]( + choices=ErrorCode.as_choices(), null=True + ) error_message = models.TextField(null=True) class Meta: @@ -773,7 +784,7 @@ class PreprodArtifactMobileAppInfo(DefaultFieldsModel): # E.g. 1.2.300 build_version = models.CharField(max_length=255, null=True) # E.g. 9999 - build_number = BoundedBigIntegerField(null=True) + build_number = BoundedBigIntegerField[int | None, int | None](null=True) # The objectstore id of the app icon app_icon_id = models.CharField(max_length=255, null=True) # The name of the app, e.g. "My App" diff --git a/src/sentry/preprod/size_analysis/download.py b/src/sentry/preprod/size_analysis/download.py index be311accc959..f3f879cb2b26 100644 --- a/src/sentry/preprod/size_analysis/download.py +++ b/src/sentry/preprod/size_analysis/download.py @@ -146,7 +146,7 @@ def get_size_analysis_response( fp, content_type="application/json", ) - response["Content-Length"] = file_obj.size + response["Content-Length"] = str(file_obj.size) return response diff --git a/src/sentry/preprod/size_analysis/tasks.py b/src/sentry/preprod/size_analysis/tasks.py index 387d7c21cd46..9a3ed8afc7e6 100644 --- a/src/sentry/preprod/size_analysis/tasks.py +++ b/src/sentry/preprod/size_analysis/tasks.py @@ -268,9 +268,10 @@ def compare_preprod_artifact_size_analysis( ) try: - artifact_type_name = PreprodArtifact.ArtifactType( - artifact.artifact_type - ).name.lower() + artifact_type = artifact.artifact_type + if artifact_type is None: + raise ValueError("Artifact type is not set") + artifact_type_name = PreprodArtifact.ArtifactType(artifact_type).name.lower() except (ValueError, AttributeError, TypeError): artifact_type_name = "unknown" @@ -467,7 +468,10 @@ def _run_size_analysis_comparison( return try: - head_analysis_file = File.objects.get(id=head_size_metric.analysis_file_id) + head_analysis_file_id = head_size_metric.analysis_file_id + if head_analysis_file_id is None: + raise File.DoesNotExist + head_analysis_file = File.objects.get(id=head_analysis_file_id) except File.DoesNotExist: logger.exception( "preprod.size_analysis.compare.head_analysis_file_not_found", @@ -482,7 +486,10 @@ def _run_size_analysis_comparison( return try: - base_analysis_file = File.objects.get(id=base_size_metric.analysis_file_id) + base_analysis_file_id = base_size_metric.analysis_file_id + if base_analysis_file_id is None: + raise File.DoesNotExist + base_analysis_file = File.objects.get(id=base_analysis_file_id) except File.DoesNotExist: logger.exception( "preprod.size_analysis.compare.base_analysis_file_not_found", diff --git a/src/sentry/preprod/snapshots/models.py b/src/sentry/preprod/snapshots/models.py index b281e0d59e7c..bc395a43a901 100644 --- a/src/sentry/preprod/snapshots/models.py +++ b/src/sentry/preprod/snapshots/models.py @@ -81,7 +81,9 @@ def as_choices(cls) -> tuple[tuple[int, str], ...]: related_name="snapshot_comparisons_base_metrics", ) - error_code = BoundedPositiveIntegerField(choices=ErrorCode.as_choices(), null=True) + error_code = BoundedPositiveIntegerField[int | None, int | None]( + choices=ErrorCode.as_choices(), null=True + ) error_message = models.TextField(null=True) # Summary statistics @@ -95,7 +97,7 @@ def as_choices(cls) -> tuple[tuple[int, str], ...]: # Set once by the orchestrator after the plan is written and all chunk tasks dispatched. # NULL means orchestration did not finish; also the poll's done-counting denominator. - chunks_total = BoundedPositiveIntegerField(null=True) + chunks_total = BoundedPositiveIntegerField[int | None, int | None](null=True) chunks_done_indices = ArrayField(models.IntegerField(), default=list, db_default=[]) diff --git a/src/sentry/preprod/snapshots/utils.py b/src/sentry/preprod/snapshots/utils.py index bf471207a944..952c0db1b10a 100644 --- a/src/sentry/preprod/snapshots/utils.py +++ b/src/sentry/preprod/snapshots/utils.py @@ -15,7 +15,7 @@ def find_base_snapshot_artifact( base_repo_name: str, project_id: int, app_id: str | None, - artifact_type: str | None, + artifact_type: int | None, build_configuration: PreprodBuildConfiguration | None, ) -> PreprodArtifact | None: qs = PreprodArtifact.objects.filter( diff --git a/src/sentry/preprod/vcs/status_checks/size/tasks.py b/src/sentry/preprod/vcs/status_checks/size/tasks.py index dc361ac77dd3..094ba8b8c336 100644 --- a/src/sentry/preprod/vcs/status_checks/size/tasks.py +++ b/src/sentry/preprod/vcs/status_checks/size/tasks.py @@ -292,6 +292,7 @@ def create_preprod_status_check_task( # not having their repos integrated into Sentry return + assert repository.integration_id is not None provider = get_status_check_provider( client, commit_comparison.provider, diff --git a/src/sentry/preprod/vcs/status_checks/size/templates.py b/src/sentry/preprod/vcs/status_checks/size/templates.py index f790494c95e4..c319a3ade956 100644 --- a/src/sentry/preprod/vcs/status_checks/size/templates.py +++ b/src/sentry/preprod/vcs/status_checks/size/templates.py @@ -487,7 +487,7 @@ def _format_version_string(artifact: PreprodArtifact, default: str = "-") -> str def _get_size_metric_type_display_name( - metric_type: PreprodArtifactSizeMetrics.MetricsArtifactType | None, + metric_type: int | PreprodArtifactSizeMetrics.MetricsArtifactType | None, ) -> str | None: """Get display name for a metric type.""" match metric_type: diff --git a/src/sentry/preprod/vcs/status_checks/skip.py b/src/sentry/preprod/vcs/status_checks/skip.py index a9517231092e..c815eff04631 100644 --- a/src/sentry/preprod/vcs/status_checks/skip.py +++ b/src/sentry/preprod/vcs/status_checks/skip.py @@ -117,6 +117,7 @@ def create_skipped_status_check( reason="repo_not_integrated", ) + assert repository.integration_id is not None status_check_provider = get_status_check_provider( client, provider, diff --git a/src/sentry/preprod/vcs/status_checks/snapshots/tasks.py b/src/sentry/preprod/vcs/status_checks/snapshots/tasks.py index 2dab9eb0ae92..24d7eaec85d2 100644 --- a/src/sentry/preprod/vcs/status_checks/snapshots/tasks.py +++ b/src/sentry/preprod/vcs/status_checks/snapshots/tasks.py @@ -196,6 +196,7 @@ def create_preprod_snapshot_status_check_task( if not client or not repository: return + assert repository.integration_id is not None provider = get_status_check_provider( client, commit_comparison.provider, @@ -436,6 +437,7 @@ def post_snapshot_status_check_task( if not client or not repository: return + assert repository.integration_id is not None provider = get_status_check_provider( client, commit_comparison.provider, diff --git a/src/sentry/processing/backpressure/memory.py b/src/sentry/processing/backpressure/memory.py index 516595171643..803139b1d746 100644 --- a/src/sentry/processing/backpressure/memory.py +++ b/src/sentry/processing/backpressure/memory.py @@ -32,7 +32,7 @@ class NodeInfo: # Based on configuration, this could be: # - a `rediscluster` Cluster (actually `RetryingRedisCluster`) # - a `rb.Cluster` (client side routing cluster client) -Cluster = Union[RedisCluster, rb.Cluster, StrictRedis] +Cluster = Union[RedisCluster, rb.Cluster, "StrictRedis[bytes]"] def get_memory_usage(node_id: str, info: Mapping[str, Any]) -> ServiceMemory: diff --git a/src/sentry/profiles/task.py b/src/sentry/profiles/task.py index 435edd82e7af..57f8d0563056 100644 --- a/src/sentry/profiles/task.py +++ b/src/sentry/profiles/task.py @@ -1258,11 +1258,11 @@ def _deobfuscate(profile: Profile, project: Project) -> None: def get_event_id(profile: Profile) -> str: - if "chunk_id" in profile: - return profile["chunk_id"] - elif "profile_id" in profile: - return profile["profile_id"] - return profile["event_id"] + for key in ("chunk_id", "profile_id", "event_id"): + value = profile.get(key) + if isinstance(value, str): + return value + raise ValueError("Profile is missing an event id") @metrics.wraps("process_profile.track_outcome") diff --git a/src/sentry/profiles/utils.py b/src/sentry/profiles/utils.py index 8ead83cc2aee..63eb4fd13237 100644 --- a/src/sentry/profiles/utils.py +++ b/src/sentry/profiles/utils.py @@ -1,7 +1,7 @@ from collections.abc import Mapping, MutableMapping from datetime import datetime from types import TracebackType -from typing import Any, Self +from typing import Any, Self, cast from urllib.parse import urlencode, urlparse import brotli @@ -122,10 +122,13 @@ def get_from_profiling_service( metrics.distribution(metric_name, len(data), tags=metric_tags) kwargs["body"] = brotli.compress(data, quality=6, mode=brotli.MODE_TEXT) - return _profiling_pool.urlopen( - method, - path, - **kwargs, + return cast( + VroomResponse, + _profiling_pool.urlopen( + method, + path, + **kwargs, + ), ) diff --git a/src/sentry/projectoptions/__init__.py b/src/sentry/projectoptions/__init__.py index 688a81bcc957..314a00fcb956 100644 --- a/src/sentry/projectoptions/__init__.py +++ b/src/sentry/projectoptions/__init__.py @@ -1,4 +1,12 @@ -from .manager import ProjectOptionsManager +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .manager import OptionValue, ProjectOptionsManager + +if TYPE_CHECKING: + from sentry.models.project import Project + from sentry.projects.services.project.model import RpcProject __all__ = ("get", "set", "delete", "register", "isset", "LATEST_EPOCH") @@ -14,7 +22,9 @@ lookup_well_known_key = default_manager.lookup_well_known_key -def get_well_known_default(key, project=None, epoch=None): +def get_well_known_default( + key: str, project: Project | RpcProject | int | None = None, epoch: int | None = None +) -> OptionValue: """Utility function to return the default for a well known key.""" well_known_key = lookup_well_known_key(key) if well_known_key is not None: diff --git a/src/sentry/projectoptions/manager.py b/src/sentry/projectoptions/manager.py index e4bc5cc4f9ac..c6288b8d17f0 100644 --- a/src/sentry/projectoptions/manager.py +++ b/src/sentry/projectoptions/manager.py @@ -1,14 +1,33 @@ +from __future__ import annotations + import bisect +from collections.abc import Callable, Mapping, ValuesView +from typing import TYPE_CHECKING, Any, TypeAlias + +if TYPE_CHECKING: + from sentry.models.project import Project + from sentry.projects.services.project.model import RpcProject + + +# justification: option values are arbitrary settings payloads. +OptionValue: TypeAlias = Any class WellKnownProjectOption: - def __init__(self, key, default=None, epoch_defaults=None): + def __init__( + self, + key: str, + default: OptionValue = None, + epoch_defaults: Mapping[int, OptionValue] | None = None, + ) -> None: self.key = key self.default = default self.epoch_defaults = epoch_defaults self._epoch_default_list = sorted(epoch_defaults or ()) - def get_default(self, project=None, epoch=None): + def get_default( + self, project: Project | RpcProject | int | None = None, epoch: int | None = None + ) -> OptionValue: if self.epoch_defaults: if epoch is None: if project is None: @@ -41,13 +60,13 @@ class ProjectOptionsManager: time of the option lookup. """ - def __init__(self): - self.registry = {} + def __init__(self) -> None: + self.registry: dict[str, WellKnownProjectOption] = {} - def lookup_well_known_key(self, key): + def lookup_well_known_key(self, key: str) -> WellKnownProjectOption | None: return self.registry.get(key) - def freeze_option_epoch(self, project, force=False): + def freeze_option_epoch(self, project: Project, force: bool = False) -> None: # The options are frozen in a receiver hook for project saves. # See `sentry.receivers.core.freeze_option_epoch_for_project` if force or project.get_option("sentry:option-epoch") is None: @@ -55,28 +74,39 @@ def freeze_option_epoch(self, project, force=False): project.update_option("sentry:option-epoch", LATEST_EPOCH) - def set(self, project, key, value): + def set(self, project: Project | int, key: str, value: OptionValue) -> bool: from sentry.models.options.project_option import ProjectOption return ProjectOption.objects.set_value(project, key, value) - def isset(self, project, key): + def isset(self, project: Project | RpcProject, key: str) -> bool: return project.get_option(key, default=Ellipsis) is not Ellipsis - def get(self, project, key, default=None, validate=None): + def get( + self, + project: Project | RpcProject, + key: str, + default: OptionValue = None, + validate: Callable[[object], bool] | None = None, + ) -> OptionValue: return project.get_option(key, default=default, validate=validate) - def delete(self, project, key): + def delete(self, project: Project, key: str) -> None: from sentry.models.options.project_option import ProjectOption return ProjectOption.objects.unset_value(project, key) - def register(self, key, default=None, epoch_defaults=None): + def register( + self, + key: str, + default: OptionValue = None, + epoch_defaults: Mapping[int, OptionValue] | None = None, + ) -> None: self.registry[key] = WellKnownProjectOption( key=key, default=default, epoch_defaults=epoch_defaults ) - def all(self): + def all(self) -> ValuesView[WellKnownProjectOption]: """ Return an iterator for all keys in the registry. """ 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/analytics.py b/src/sentry/receivers/analytics.py index 988634680d93..5587b3f56bf9 100644 --- a/src/sentry/receivers/analytics.py +++ b/src/sentry/receivers/analytics.py @@ -5,7 +5,7 @@ from sentry.users.models.user import User -def on_user_post_save(instance: User, created: bool, **kwargs): +def on_user_post_save(instance: User, created: bool, **kwargs: object) -> None: if created: analytics.record( UserCreatedEvent( diff --git a/src/sentry/receivers/auth.py b/src/sentry/receivers/auth.py index ee0c59594f30..4ea65f3df67e 100644 --- a/src/sentry/receivers/auth.py +++ b/src/sentry/receivers/auth.py @@ -3,18 +3,20 @@ from django.contrib.auth.models import update_last_login from django.contrib.auth.signals import user_logged_in from django.db.utils import DatabaseError +from django.http.request import HttpRequest +from sentry.users.models.user import User from sentry.users.models.user_option import UserOption # Set user language if set -def set_language_on_logon(request, user, **kwargs): +def set_language_on_logon(request: HttpRequest, user: User, **kwargs: object) -> None: language = UserOption.objects.get_value(user=user, key="language") if language and hasattr(request, "session"): request.session["django_language"] = language -def safe_update_last_login(sender, user, **kwargs): +def safe_update_last_login(sender: type[User], user: User, **kwargs: object) -> None: """ Identical to Django's built-in handler except that we gracefully handle database failures. @@ -27,7 +29,7 @@ def safe_update_last_login(sender, user, **kwargs): logging.warning(str(exc), exc_info=True) -def remove_lost_password_hashes(sender, user, **kwargs): +def remove_lost_password_hashes(sender: type[User], user: User, **kwargs: object) -> None: # Remove pending password recovery hashes; user was able to login user.clear_lost_passwords() diff --git a/src/sentry/receivers/core.py b/src/sentry/receivers/core.py index f0a81f39c360..b50866a02699 100644 --- a/src/sentry/receivers/core.py +++ b/src/sentry/receivers/core.py @@ -2,6 +2,7 @@ from collections.abc import Iterator from contextlib import contextmanager from contextvars import ContextVar +from types import ModuleType from click import echo from django.conf import settings @@ -45,7 +46,7 @@ def disable_default_project_key_creation() -> Iterator[None]: _default_project_key_creation_enabled.reset(token) -def create_default_projects(**kwds): +def create_default_projects(**kwds: object) -> None: if not (in_test_environment() or is_self_hosted() or settings.DEBUG): # No op in production SaaS environments. return @@ -75,9 +76,11 @@ def create_default_projects(**kwds): @cell_silo_function -def create_default_project(id, name, slug, verbosity=2, **kwargs): +def create_default_project( + id: int, name: str, slug: str, verbosity: int = 2, **kwargs: object +) -> Project | None: if Project.objects.filter(id=id).exists(): - return + return None user = user_service.get_first_superuser() @@ -128,7 +131,9 @@ def create_default_project(id, name, slug, verbosity=2, **kwargs): return project -def create_keys_for_project(instance, created, app=None, **kwargs): +def create_keys_for_project( + instance: Project, created: bool, app: ModuleType | None = None, **kwargs: object +) -> None: if not _default_project_key_creation_enabled.get(): return @@ -146,7 +151,9 @@ def create_keys_for_project(instance, created, app=None, **kwargs): ) -def freeze_option_epoch_for_project(instance, created, app=None, **kwargs): +def freeze_option_epoch_for_project( + instance: Project, created: bool, app: ModuleType | None = None, **kwargs: object +) -> None: if app and app.__name__ != "sentry.models": return diff --git a/src/sentry/receivers/data_forwarding.py b/src/sentry/receivers/data_forwarding.py index f5c4a1573a3e..cd37177b27ee 100644 --- a/src/sentry/receivers/data_forwarding.py +++ b/src/sentry/receivers/data_forwarding.py @@ -7,7 +7,7 @@ @project_created.connect(weak=False, dispatch_uid="enroll_project_in_data_forwarding") -def enroll_project_in_data_forwarding(project: Project, **kwargs): +def enroll_project_in_data_forwarding(project: Project, **kwargs: object) -> None: data_forwarders = DataForwarder.objects.filter( organization_id=project.organization_id, is_enabled=True, enroll_new_projects=True ) diff --git a/src/sentry/receivers/experiments.py b/src/sentry/receivers/experiments.py index 28c0e115c983..d5a20bc49d96 100644 --- a/src/sentry/receivers/experiments.py +++ b/src/sentry/receivers/experiments.py @@ -4,17 +4,22 @@ from sentry.analytics.events.join_request_created import JoinRequestCreatedEvent from sentry.analytics.events.join_request_link_viewed import JoinRequestLinkViewedEvent from sentry.analytics.events.user_signup import UserSignUpEvent +from sentry.models.organization import Organization +from sentry.models.organizationmember import OrganizationMember from sentry.signals import join_request_created, join_request_link_viewed, user_signup +from sentry.users.models.user import User @join_request_created.connect(weak=False) -def record_join_request_created(member, **kwargs): +def record_join_request_created( + member: OrganizationMember, referrer: str | None = None, **kwargs: object +) -> None: try: analytics.record( JoinRequestCreatedEvent( member_id=member.id, organization_id=member.organization_id, - referrer=kwargs.get("referrer"), + referrer=referrer, ) ) except Exception as e: @@ -22,7 +27,7 @@ def record_join_request_created(member, **kwargs): @join_request_link_viewed.connect(weak=False) -def record_join_request_link_viewed(organization, **kwargs): +def record_join_request_link_viewed(organization: Organization, **kwargs: object) -> None: try: analytics.record(JoinRequestLinkViewedEvent(organization_id=organization.id)) except Exception as e: @@ -30,14 +35,20 @@ def record_join_request_link_viewed(organization, **kwargs): @user_signup.connect(weak=False) -def record_user_signup(user, source, **kwargs): +def record_user_signup( + user: User, + source: str, + provider: str | None = None, + referrer: str | None = None, + **kwargs: object, +) -> None: try: analytics.record( UserSignUpEvent( user_id=user.id, source=source, - provider=kwargs.get("provider"), - referrer=kwargs.get("referrer"), + provider=provider, + referrer=referrer, ) ) except Exception as e: diff --git a/src/sentry/receivers/features.py b/src/sentry/receivers/features.py index 90e14d24e1b7..38db44a249ff 100644 --- a/src/sentry/receivers/features.py +++ b/src/sentry/receivers/features.py @@ -1,9 +1,27 @@ from __future__ import annotations +from collections.abc import Iterable +from typing import Protocol, TypedDict, cast + import sentry_sdk from django.db.models.signals import post_save from sentry import analytics + + +class PluginLike(Protocol): + slug: str + + +class IssueActivityData(TypedDict, total=False): + ignoreDuration: int | None + ignoreCount: int | None + ignoreWindow: int | None + ignoreUserCount: int | None + ignoreUserWindow: int | None + until_escalating: bool + + from sentry.adoption import manager from sentry.analytics.events.advanced_search_feature_gated import AdvancedSearchFeatureGateEvent from sentry.analytics.events.alert_created import AlertCreatedEvent @@ -25,18 +43,25 @@ from sentry.analytics.events.search_saved import SearchSavedEvent from sentry.analytics.events.sso_enabled import SSOEnabledEvent from sentry.analytics.events.team_created import TeamCreatedEvent +from sentry.incidents.models.alert_rule import AlertRule from sentry.integrations.analytics import ( IntegrationAddedEvent, IntegrationIssueCreatedEvent, IntegrationIssueLinkedEvent, ) +from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration import integration_service +from sentry.models.deploy import Deploy from sentry.models.featureadoption import FeatureAdoption from sentry.models.group import Group 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.models.release import Release +from sentry.models.repository import Repository +from sentry.models.team import Team +from sentry.monitors.models import MonitorEnvironment +from sentry.services.eventstore.models import BaseEvent, GroupEvent from sentry.signals import ( advanced_search, advanced_search_feature_gated, @@ -117,13 +142,13 @@ # First Event @first_event_received.connect(weak=False) -def record_first_event(project, **kwargs): +def record_first_event(project: Project, **kwargs: object) -> None: FeatureAdoption.objects.record( organization_id=project.organization_id, feature_slug="first_event", complete=True ) -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 +162,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 @@ -193,21 +218,21 @@ def record_generic_event_processed( @user_feedback_received.connect(weak=False) -def record_user_feedback(project, **kwargs): +def record_user_feedback(project: Project, **kwargs: object) -> None: FeatureAdoption.objects.record( organization_id=project.organization_id, feature_slug="user_feedback", complete=True ) @project_created.connect(weak=False) -def record_project_created(project, **kwargs): +def record_project_created(project: Project, **kwargs: object) -> None: FeatureAdoption.objects.record( organization_id=project.organization_id, feature_slug="first_project", complete=True ) @member_joined.connect(weak=False) -def record_member_joined(organization_id: int, user_id: int, **kwargs): +def record_member_joined(organization_id: int, user_id: int, **kwargs: object) -> None: FeatureAdoption.objects.record( organization_id=organization_id, feature_slug="invite_team", complete=True ) @@ -218,11 +243,15 @@ def record_member_joined(organization_id: int, user_id: int, **kwargs): @issue_assigned.connect(weak=False) -def record_issue_assigned(project, group, user, **kwargs): +def record_issue_assigned( + project: Project, group: Group, user: User | RpcUser | None, **kwargs: object +) -> None: FeatureAdoption.objects.record( organization_id=project.organization_id, feature_slug="assignment", complete=True ) + user_id: int | None + default_user_id: int | str if user and user.is_authenticated: user_id = default_user_id = user.id else: @@ -243,8 +272,15 @@ def record_issue_assigned(project, group, user, **kwargs): @issue_resolved.connect(weak=False) def record_issue_resolved( - organization_id, project, group, user, resolution_type, provider=None, commit_id=None, **kwargs -): + organization_id: int, + project: Project, + group: Group, + user: User | RpcUser | None, + resolution_type: str, + provider: str | None = None, + commit_id: int | None = None, + **kwargs: object, +) -> None: """There are three main types of ways to resolve issues 1) via a release (current release, next release, or other) 2) via commit (in the UI with the commit hash (marked as "in_commit") @@ -260,6 +296,8 @@ def record_issue_resolved( organization_id=organization_id, feature_slug="resolved_with_commit", complete=True ) + user_id: int | None + default_user_id: int | str if user and user.is_authenticated: user_id = default_user_id = user.id else: @@ -286,7 +324,15 @@ def record_issue_resolved( @issue_unresolved.connect(weak=False) -def record_issue_unresolved(project, user, group, transition_type, **kwargs): +def record_issue_unresolved( + project: Project, + user: User | RpcUser | None, + group: Group, + transition_type: str, + **kwargs: object, +) -> None: + user_id: int | None + default_user_id: int | str if user and user.is_authenticated: user_id = default_user_id = user.id else: @@ -308,14 +354,16 @@ def record_issue_unresolved(project, user, group, transition_type, **kwargs): @advanced_search.connect(weak=False) -def record_advanced_search(project, **kwargs): +def record_advanced_search(project: Project, **kwargs: object) -> None: FeatureAdoption.objects.record( organization_id=project.organization_id, feature_slug="advanced_search", complete=True ) @advanced_search_feature_gated.connect(weak=False) -def record_advanced_search_feature_gated(user, organization, **kwargs): +def record_advanced_search_feature_gated( + user: User | RpcUser | None, organization: Organization, **kwargs: object +) -> None: if user and user.is_authenticated: user_id = default_user_id = user.id else: @@ -338,7 +386,9 @@ def record_advanced_search_feature_gated(user, organization, **kwargs): # those no longer exist and this is no longer connected to anything. We # probably want to connect this up to organization level saved searches. @save_search_created.connect(weak=False) -def record_save_search_created(project, user, **kwargs): +def record_save_search_created( + project: Project, user: User | RpcUser | None, **kwargs: object +) -> None: FeatureAdoption.objects.record( organization_id=project.organization_id, feature_slug="saved_search", complete=True ) @@ -363,7 +413,7 @@ def record_save_search_created(project, user, **kwargs): @inbound_filter_toggled.connect(weak=False) -def record_inbound_filter_toggled(project, **kwargs): +def record_inbound_filter_toggled(project: Project, **kwargs: object) -> None: FeatureAdoption.objects.record( organization_id=project.organization_id, feature_slug="inbound_filters", complete=True ) @@ -371,19 +421,19 @@ def record_inbound_filter_toggled(project, **kwargs): @alert_rule_created.connect(weak=False) def record_alert_rule_created( - user, + user: User | RpcUser | None, project: Project, rule_id: int, rule_type: str, is_api_token: bool, - referrer=None, - session_id=None, - alert_rule_ui_component=None, - duplicate_rule=None, - wizard_v3=None, - query_type=None, - **kwargs, -): + referrer: str | None = None, + session_id: str | None = None, + alert_rule_ui_component: str | None = None, + duplicate_rule: str | None = None, + wizard_v3: str | None = None, + query_type: str | None = None, + **kwargs: object, +) -> None: # NOTE: This intentionally does not fire for the default issue alert rule # that gets created on new project creation. FeatureAdoption.objects.record( @@ -418,13 +468,13 @@ def record_alert_rule_created( @alert_rule_edited.connect(weak=False) def record_alert_rule_edited( - user, - project, - rule, - rule_type, - is_api_token, - **kwargs, -): + user: User | RpcUser | None, + project: Project, + rule: AlertRule, + rule_type: str, + is_api_token: bool, + **kwargs: object, +) -> None: if user and user.is_authenticated: user_id = default_user_id = user.id else: @@ -447,7 +497,9 @@ def record_alert_rule_edited( @plugin_enabled.connect(weak=False) -def record_plugin_enabled(plugin, project, user: User | None, **kwargs): +def record_plugin_enabled( + plugin: PluginLike, project: Project, user: User | None, **kwargs: object +) -> None: try: analytics.record( PluginEnabledEvent( @@ -462,7 +514,7 @@ def record_plugin_enabled(plugin, project, user: User | None, **kwargs): @sso_enabled.connect(weak=False) -def record_sso_enabled(organization_id, user_id, provider, **kwargs): +def record_sso_enabled(organization_id: int, user_id: int, provider: str, **kwargs: object) -> None: FeatureAdoption.objects.record( organization_id=organization_id, feature_slug="sso", complete=True ) @@ -476,13 +528,13 @@ def record_sso_enabled(organization_id, user_id, provider, **kwargs): @data_scrubber_enabled.connect(weak=False) -def record_data_scrubber_enabled(organization, **kwargs): +def record_data_scrubber_enabled(organization: Organization, **kwargs: object) -> None: FeatureAdoption.objects.record( organization_id=organization.id, feature_slug="data_scrubbers", complete=True ) -def deleted_and_discarded_issue(instance, created, **kwargs): +def deleted_and_discarded_issue(instance: GroupTombstone, created: bool, **kwargs: object) -> None: if created: FeatureAdoption.objects.record( organization_id=instance.project.organization_id, feature_slug="delete_and_discard" @@ -490,7 +542,7 @@ def deleted_and_discarded_issue(instance, created, **kwargs): @repo_linked.connect(weak=False) -def record_repo_linked(repo, user, **kwargs): +def record_repo_linked(repo: Repository, user: User | RpcUser | None, **kwargs: object) -> None: FeatureAdoption.objects.record( organization_id=repo.organization_id, feature_slug="repo_linked", complete=True ) @@ -508,7 +560,7 @@ def record_repo_linked(repo, user, **kwargs): default_user_id=default_user_id, organization_id=repo.organization_id, repository_id=repo.id, - provider=repo.provider, + provider=cast("str", repo.provider), ) ) except Exception as e: @@ -516,21 +568,21 @@ def record_repo_linked(repo, user, **kwargs): @release_created.connect(weak=False) -def record_release_created(release, **kwargs): +def record_release_created(release: Release, **kwargs: object) -> None: FeatureAdoption.objects.record( organization_id=release.organization_id, feature_slug="release_created", complete=True ) @deploy_created.connect(weak=False) -def record_deploy_created(deploy, **kwargs): +def record_deploy_created(deploy: Deploy, **kwargs: object) -> None: FeatureAdoption.objects.record( organization_id=deploy.organization_id, feature_slug="deploy_created", complete=True ) @ownership_rule_created.connect(weak=False) -def record_ownership_rule_created(project, **kwargs): +def record_ownership_rule_created(project: Project, **kwargs: object) -> None: FeatureAdoption.objects.record( organization_id=project.organization_id, feature_slug="ownership_rule_created", @@ -539,7 +591,13 @@ def record_ownership_rule_created(project, **kwargs): @issue_ignored.connect(weak=False) -def record_issue_ignored(project, user, group_list, activity_data, **kwargs): +def record_issue_ignored( + project: Project, + user: User | RpcUser | None, + group_list: list[Group], + activity_data: IssueActivityData, + **kwargs: object, +) -> None: FeatureAdoption.objects.record( organization_id=project.organization_id, feature_slug="issue_ignored", complete=True ) @@ -570,7 +628,13 @@ def record_issue_ignored(project, user, group_list, activity_data, **kwargs): @issue_archived.connect(weak=False) -def record_issue_archived(project, user, group_list, activity_data, **kwargs): +def record_issue_archived( + project: Project, + user: User | RpcUser | None, + group_list: list[Group], + activity_data: IssueActivityData, + **kwargs: object, +) -> None: if user and user.is_authenticated: user_id = default_user_id = user.id else: @@ -598,8 +662,8 @@ def record_issue_escalating( group: Group, event: GroupEvent | None, was_until_escalating: bool, - **kwargs, -): + **kwargs: object, +) -> None: try: analytics.record( IssueEscalatingEvent( @@ -622,8 +686,8 @@ def record_update_priority( previous_priority: str | None, user_id: int | None, reason: str | None, - **kwargs, -): + **kwargs: object, +) -> None: try: analytics.record( IssuePriorityUpdatedEvent( @@ -643,7 +707,13 @@ def record_update_priority( @issue_unignored.connect(weak=False) -def record_issue_unignored(project, user_id, group, transition_type, **kwargs): +def record_issue_unignored( + project: Project, + user_id: int | None, + group: Group, + transition_type: str, + **kwargs: object, +) -> None: if user_id is not None: default_user_id = user_id else: @@ -664,7 +734,9 @@ def record_issue_unignored(project, user_id, group, transition_type, **kwargs): @issue_mark_reviewed.connect(weak=False) -def record_issue_reviewed(project: Project, user: RpcUser | User | None, group: Group, **kwargs): +def record_issue_reviewed( + project: Project, user: RpcUser | User | None, group: Group, **kwargs: object +) -> None: if user and user.is_authenticated: user_id = default_user_id = user.id else: @@ -686,18 +758,20 @@ def record_issue_reviewed(project: Project, user: RpcUser | User | None, group: @team_created.connect(weak=False) def record_team_created( - organization=None, - user=None, - team=None, - organization_id=None, - user_id=None, - team_id=None, - **kwargs, -): + organization: Organization | None = None, + user: User | RpcUser | None = None, + team: Team | None = None, + organization_id: int | None = None, + user_id: int | None = None, + team_id: int | None = None, + **kwargs: object, +) -> None: if organization is None: + assert organization_id is not None organization = Organization.objects.get(id=organization_id) if team_id is None: + assert team is not None team_id = team.id if user_id is None and user and user.is_authenticated: @@ -722,8 +796,8 @@ def record_team_created( @integration_added.connect(weak=False) def record_integration_added( - integration_id: int, organization_id: int, user_id: int | None, **kwargs -): + integration_id: int, organization_id: int, user_id: int | None, **kwargs: object +) -> None: organization = Organization.objects.get(id=organization_id) integration = integration_service.get_integration(integration_id=integration_id) assert integration, f"integration_added called for missing integration: {integration_id}" @@ -750,7 +824,12 @@ def record_integration_added( @integration_issue_created.connect(weak=False) -def record_integration_issue_created(integration, organization, user, **kwargs): +def record_integration_issue_created( + integration: Integration, + organization: Organization, + user: User | RpcUser | None, + **kwargs: object, +) -> None: if user and user.is_authenticated: user_id = default_user_id = user.id else: @@ -768,7 +847,12 @@ def record_integration_issue_created(integration, organization, user, **kwargs): @integration_issue_linked.connect(weak=False) -def record_integration_issue_linked(integration, organization, user, **kwargs): +def record_integration_issue_linked( + integration: Integration, + organization: Organization, + user: User | RpcUser | None, + **kwargs: object, +) -> None: if user and user.is_authenticated: user_id = default_user_id = user.id else: @@ -786,7 +870,9 @@ def record_integration_issue_linked(integration, organization, user, **kwargs): @issue_deleted.connect(weak=False) -def record_issue_deleted(group, user, delete_type, **kwargs): +def record_issue_deleted( + group: Group, user: User | RpcUser | None, delete_type: str, **kwargs: object +) -> None: if user and user.is_authenticated: user_id = default_user_id = user.id else: @@ -808,7 +894,7 @@ def record_issue_deleted(group, user, delete_type, **kwargs): @monitor_environment_failed.connect(weak=False) -def record_monitor_failure(monitor_environment, **kwargs): +def record_monitor_failure(monitor_environment: MonitorEnvironment, **kwargs: object) -> None: try: analytics.record( MonitorEnvironmentMarkFailed( diff --git a/src/sentry/receivers/onboarding.py b/src/sentry/receivers/onboarding.py index 994bc4f5ef94..c48f535cce00 100644 --- a/src/sentry/receivers/onboarding.py +++ b/src/sentry/receivers/onboarding.py @@ -2,6 +2,7 @@ import logging from datetime import datetime, timezone +from typing import TYPE_CHECKING, cast import sentry_sdk from django.db.models import F @@ -67,10 +68,14 @@ project_transferred, transaction_processed, ) -from sentry.users.services.user import RpcUser from sentry.utils.event import has_event_minified_stack_trace from sentry.utils.javascript import has_sourcemap -from sentry.utils.safe import get_path +from sentry.utils.safe import PathSearchable, get_path + +if TYPE_CHECKING: + from sentry.auth.principal import RequestPrincipal + from sentry.models.organizationmember import OrganizationMember + from sentry.services.eventstore.models import Event logger = logging.getLogger("sentry") @@ -84,8 +89,8 @@ START_DATE_TRACKING_FIRST_SOURCEMAP_PER_PROJ = datetime(2023, 11, 16, tzinfo=timezone.utc) -def get_owner_id(project: Project, user: RpcUser | None = None) -> int | None: - if user and has_user(user): +def get_owner_id(project: Project, user: RequestPrincipal | None = None) -> int | None: + if user is not None and has_user(user): return user.id # this is either the organizations owners user id or None @@ -93,30 +98,39 @@ def get_owner_id(project: Project, user: RpcUser | None = None) -> int | None: @project_created.connect(weak=False, dispatch_uid="record_new_project") -def record_new_project(project, user=None, user_id=None, origin=None, **kwargs): +def record_new_project( + project: Project, + user: RequestPrincipal | None = None, + user_id: int | None = None, + origin: str | None = None, + **kwargs: object, +) -> None: scope = sentry_sdk.get_current_scope() scope.set_extra("project_id", project.id) scope.set_attribute("project_id", project.id) scope.set_extra("source", "record_new_project") scope.set_attribute("source", "record_new_project") + default_user_id: int | str | None + if user_id is not None: default_user_id = user_id - elif has_user(user): + elif user is not None and has_user(user): user_id = default_user_id = acting_user_id(user) else: user_id = default_user_id = get_owner_id(project, user) - if user_id is None: - logger.warning( - "Cannot initiate onboarding for organization (%s) due to missing owners", - project.organization_id, - ) - sentry_sdk.capture_message( - f"Cannot initiate onboarding for organization ({project.organization_id}) due to missing owners", - level="warning", - ) - # XXX(dcramer): we cannot setup onboarding tasks without a user - return + + if user_id is None: + logger.warning( + "Cannot initiate onboarding for organization (%s) due to missing owners", + project.organization_id, + ) + sentry_sdk.capture_message( + f"Cannot initiate onboarding for organization ({project.organization_id}) due to missing owners", + level="warning", + ) + # XXX(dcramer): we cannot setup onboarding tasks without a user + return try: analytics.record( @@ -149,7 +163,7 @@ def record_new_project(project, user=None, user_id=None, origin=None, **kwargs): ) analytics.record( SecondPlatformAddedEvent( - user_id=default_user_id, + user_id=user_id, organization_id=project.organization_id, project_id=project.id, ) @@ -157,7 +171,7 @@ def record_new_project(project, user=None, user_id=None, origin=None, **kwargs): @first_event_received.connect(weak=False, dispatch_uid="onboarding.record_first_event") -def record_first_event(project, event, **kwargs): +def record_first_event(project: Project, event: Event, **kwargs: object) -> None: if (owner_id := get_owner_id(project)) is None: logger.warning( "Cannot record first event for organization (%s) due to missing owners", @@ -174,7 +188,7 @@ def record_first_event(project, event, **kwargs): project_platform=project.platform, url=dict(event.tags).get("url", None), has_minified_stack_trace=has_event_minified_stack_trace(event), - sdk_name=get_path(event, "sdk", "name"), + sdk_name=get_path(cast("PathSearchable", event), "sdk", "name"), ) ) @@ -201,7 +215,7 @@ def record_first_event(project, event, **kwargs): @first_transaction_received.connect(weak=False, dispatch_uid="onboarding.record_first_transaction") -def record_first_transaction(project, event, **kwargs): +def record_first_transaction(project: Project, event: Event, **kwargs: object) -> None: complete_onboarding_task( organization=project.organization, task=OnboardingTask.FIRST_TRANSACTION, @@ -218,7 +232,7 @@ def record_first_transaction(project, event, **kwargs): @first_profile_received.connect(weak=False, dispatch_uid="onboarding.record_first_profile") -def record_first_profile(project, **kwargs): +def record_first_profile(project: Project, **kwargs: object) -> None: analytics.record( FirstProfileSentEvent( user_id=get_owner_id(project), @@ -230,7 +244,7 @@ def record_first_profile(project, **kwargs): @first_replay_received.connect(weak=False, dispatch_uid="onboarding.record_first_replay") -def record_first_replay(project, **kwargs): +def record_first_replay(project: Project, **kwargs: object) -> None: logger.info("record_first_replay_start") completed = complete_onboarding_task( organization=project.organization, @@ -252,7 +266,7 @@ def record_first_replay(project, **kwargs): @first_flag_received.connect(weak=False, dispatch_uid="onboarding.record_first_flag") -def record_first_flag(project, **kwargs): +def record_first_flag(project: Project, **kwargs: object) -> None: analytics.record( FirstFlagSentEvent( organization_id=project.organization_id, @@ -263,7 +277,7 @@ def record_first_flag(project, **kwargs): @first_feedback_received.connect(weak=False, dispatch_uid="onboarding.record_first_feedback") -def record_first_feedback(project, **kwargs): +def record_first_feedback(project: Project, **kwargs: object) -> None: analytics.record( FirstFeedbackSentEvent( user_id=get_owner_id(project), @@ -277,7 +291,7 @@ def record_first_feedback(project, **kwargs): @first_new_feedback_received.connect( weak=False, dispatch_uid="onboarding.record_first_new_feedback" ) -def record_first_new_feedback(project, **kwargs): +def record_first_new_feedback(project: Project, **kwargs: object) -> None: try: analytics.record( FirstNewFeedbackSentEvent( @@ -292,7 +306,9 @@ def record_first_new_feedback(project, **kwargs): @first_cron_monitor_created.connect(weak=False, dispatch_uid="onboarding.record_first_cron_monitor") -def record_first_cron_monitor(project, user, from_upsert, **kwargs): +def record_first_cron_monitor( + project: Project, user: RequestPrincipal | None, from_upsert: bool, **kwargs: object +) -> None: analytics.record( FirstCronMonitorCreated( user_id=get_owner_id(project, user), @@ -304,7 +320,9 @@ def record_first_cron_monitor(project, user, from_upsert, **kwargs): @cron_monitor_created.connect(weak=False, dispatch_uid="onboarding.record_cron_monitor_created") -def record_cron_monitor_created(project, user, from_upsert, **kwargs): +def record_cron_monitor_created( + project: Project, user: RequestPrincipal | None, from_upsert: bool, **kwargs: object +) -> None: analytics.record( CronMonitorCreated( user_id=get_owner_id(project, user), @@ -318,7 +336,7 @@ def record_cron_monitor_created(project, user, from_upsert, **kwargs): @first_cron_checkin_received.connect( weak=False, dispatch_uid="onboarding.record_first_cron_checkin" ) -def record_first_cron_checkin(project, monitor_id, **kwargs): +def record_first_cron_checkin(project: Project, monitor_id: str, **kwargs: object) -> None: analytics.record( FirstCronCheckinSent( user_id=get_owner_id(project), @@ -332,7 +350,7 @@ def record_first_cron_checkin(project, monitor_id, **kwargs): @first_insight_span_received.connect( weak=False, dispatch_uid="onboarding.record_first_insight_span" ) -def record_first_insight_span(project, module: InsightModules, **kwargs): +def record_first_insight_span(project: Project, module: InsightModules, **kwargs: object) -> None: analytics.record( FirstInsightSpanSentEvent( user_id=get_owner_id(project), @@ -345,7 +363,7 @@ def record_first_insight_span(project, module: InsightModules, **kwargs): @first_log_received.connect(weak=False, dispatch_uid="onboarding.record_first_log") -def record_first_log(project, **kwargs): +def record_first_log(project: Project, **kwargs: object) -> None: analytics.record( FirstLogSentEvent( user_id=get_owner_id(project), @@ -359,7 +377,7 @@ def record_first_log(project, **kwargs): @first_trace_metric_received.connect( weak=False, dispatch_uid="onboarding.record_first_trace_metric" ) -def record_first_trace_metric(project, **kwargs): +def record_first_trace_metric(project: Project, **kwargs: object) -> None: analytics.record( FirstTraceMetricSentEvent( user_id=get_owner_id(project), @@ -372,7 +390,12 @@ def record_first_trace_metric(project, **kwargs): # TODO (mifu67): update this to use the new org member invite model @member_invited.connect(weak=False, dispatch_uid="onboarding.record_member_invited") -def record_member_invited(member, user, **kwargs): +def record_member_invited( + member: OrganizationMember, + user: RequestPrincipal | None, + referrer: str | None = None, + **kwargs: object, +) -> None: complete_onboarding_task( organization=member.organization, task=OnboardingTask.INVITE_MEMBER, @@ -381,18 +404,18 @@ def record_member_invited(member, user, **kwargs): analytics.record( MemberInvitedEvent( invited_member_id=member.id, - inviter_user_id=user.id if user else None, + inviter_user_id=acting_user_id(user) if user is not None else None, organization_id=member.organization_id, - referrer=kwargs.get("referrer"), + referrer=referrer, ) ) -def _record_release_received(project, event, **kwargs): - return record_release_received(project, event.data.get("release"), **kwargs) +def _record_release_received(project: Project, event: Event, **kwargs: object) -> None: + return record_release_received(project, event.data.get("release")) -def record_release_received(project, release, **kwargs): +def record_release_received(project: Project, release: str | None, **kwargs: object) -> None: if not release: return @@ -426,7 +449,9 @@ def record_release_received(project, release, **kwargs): @first_event_with_minified_stack_trace_received.connect( weak=False, dispatch_uid="onboarding.record_event_with_first_minified_stack_trace_for_project" ) -def record_event_with_first_minified_stack_trace_for_project(project, event, **kwargs): +def record_event_with_first_minified_stack_trace_for_project( + project: Project, event: Event, **kwargs: object +) -> None: if (owner_id := get_owner_id(project)) is None: logger.warning( "Cannot record first event for organization (%s) due to missing owners", @@ -448,7 +473,7 @@ def record_event_with_first_minified_stack_trace_for_project(project, event, **k @event_processed.connect(weak=False, dispatch_uid="onboarding.record_sourcemaps_received") -def record_sourcemaps_received(project, event, **kwargs): +def record_sourcemaps_received(project: Project, event: Event, **kwargs: object) -> None: if not has_sourcemap(event): return @@ -482,7 +507,9 @@ def record_sourcemaps_received(project, event, **kwargs): @event_processed.connect( weak=False, dispatch_uid="onboarding.record_sourcemaps_received_for_project" ) -def record_sourcemaps_received_for_project(project, event, **kwargs): +def record_sourcemaps_received_for_project( + project: Project, event: Event, **kwargs: object +) -> None: if not has_sourcemap(event): return @@ -516,7 +543,9 @@ def record_sourcemaps_received_for_project(project, event, **kwargs): @alert_rule_created.connect(weak=False, dispatch_uid="onboarding.record_alert_rule_created") -def record_alert_rule_created(user, project: Project, rule_type: str, **kwargs): +def record_alert_rule_created( + user: RequestPrincipal | None, project: Project, rule_type: str, **kwargs: object +) -> None: # The quick start now only has a task for issue alert rules. # Please see https://github.com/getsentry/sentry/blob/c06a3aa5fb104406f2a44994d32983e99bc2a479/static/app/components/onboardingWizard/taskConfig.tsx#L351-L352 if rule_type == "metric": @@ -525,15 +554,15 @@ def record_alert_rule_created(user, project: Project, rule_type: str, **kwargs): complete_onboarding_task( organization=project.organization, task=OnboardingTask.ALERT_RULE, - user_id=user.id if user else None, + user_id=acting_user_id(user) if user is not None else None, project_id=project.id, ) @integration_added.connect(weak=False, dispatch_uid="onboarding.record_integration_added") def record_integration_added( - integration_id: int, organization_id: int, user_id: int | None, **kwargs -): + integration_id: int, organization_id: int, user_id: int | None, **kwargs: object +) -> None: integration: RpcIntegration | None = integration_service.get_integration( integration_id=integration_id ) @@ -556,7 +585,7 @@ def record_integration_added( @project_transferred.connect(weak=False, dispatch_uid="onboarding.record_project_transferred") -def record_project_transferred(old_org_id: int, project: Project, **kwargs): +def record_project_transferred(old_org_id: int, project: Project, **kwargs: object) -> None: analytics.record( ProjectTransferredEvent( old_organization_id=old_org_id, diff --git a/src/sentry/receivers/outbox/cell.py b/src/sentry/receivers/outbox/cell.py index e2fa16c23ca1..196e0f116d35 100644 --- a/src/sentry/receivers/outbox/cell.py +++ b/src/sentry/receivers/outbox/cell.py @@ -70,7 +70,7 @@ def update_sentry_app_action_data( shard_identifier: int, object_identifier: int, **kwds: Any, -): +) -> None: try: action = Action.objects.get( id=shard_identifier, @@ -185,19 +185,19 @@ def backfill_scm_integration_config(shard_identifier: int, payload: Any, **kwds: @receiver(process_cell_outbox, sender=OutboxCategory.AUDIT_LOG_EVENT) -def process_audit_log_event(payload: Any, **kwds: Any): +def process_audit_log_event(payload: Any, **kwds: Any) -> None: if payload is not None: log_rpc_service.record_audit_log(event=AuditLogEvent(**payload)) @receiver(process_cell_outbox, sender=OutboxCategory.ORGAUTHTOKEN_UPDATE_USED) -def process_orgauthtoken_update(payload: Any, **kwds: Any): +def process_orgauthtoken_update(payload: Any, **kwds: Any) -> None: if payload is not None: orgauthtoken_rpc_service.update_orgauthtoken(**payload) @receiver(process_cell_outbox, sender=OutboxCategory.USER_IP_EVENT) -def process_user_ip_event(payload: Any, **kwds: Any): +def process_user_ip_event(payload: Any, **kwds: Any) -> None: if payload is not None: log_rpc_service.record_user_ip(event=UserIpEvent(**payload)) @@ -205,7 +205,7 @@ def process_user_ip_event(payload: Any, **kwds: Any): @receiver(process_cell_outbox, sender=OutboxCategory.PROJECT_UPDATE) def process_project_updates( object_identifier: int, shard_identifier: int, payload: Any, **kwds: Any -): +) -> None: if (proj := maybe_process_tombstone(Project, object_identifier)) is None: return if not payload or "transferred_at" not in payload: @@ -223,7 +223,7 @@ def process_project_updates( @receiver(process_cell_outbox, sender=OutboxCategory.ORGANIZATION_MAPPING_CUSTOMER_ID_UPDATE) def process_organization_mapping_customer_id_update( object_identifier: int, payload: Any, **kwds: Any -): +) -> None: if (org := maybe_process_tombstone(Organization, object_identifier)) is None: return @@ -235,7 +235,9 @@ def process_organization_mapping_customer_id_update( @receiver(process_cell_outbox, sender=OutboxCategory.DISABLE_AUTH_PROVIDER) -def process_disable_auth_provider(object_identifier: int, shard_identifier: int, **kwds: Any): +def process_disable_auth_provider( + object_identifier: int, shard_identifier: int, **kwds: Any +) -> None: # Deprecated auth_service.disable_provider(provider_id=object_identifier) AuthProviderReplica.objects.filter(auth_provider_id=object_identifier).delete() diff --git a/src/sentry/receivers/outbox/control.py b/src/sentry/receivers/outbox/control.py index de3c63ed7398..1b2e216a8a52 100644 --- a/src/sentry/receivers/outbox/control.py +++ b/src/sentry/receivers/outbox/control.py @@ -33,7 +33,7 @@ @receiver(process_control_outbox, sender=OutboxCategory.INTEGRATION_UPDATE) -def process_integration_updates(object_identifier: int, cell_name: str, **kwds: Any): +def process_integration_updates(object_identifier: int, cell_name: str, **kwds: Any) -> None: if ( integration := maybe_process_tombstone(Integration, object_identifier, cell_name=cell_name) ) is None: @@ -42,12 +42,12 @@ def process_integration_updates(object_identifier: int, cell_name: str, **kwds: @receiver(process_control_outbox, sender=OutboxCategory.IDENTITY_UPDATE) -def process_identity_updates(object_identifier: int, cell_name: str, **kwds: Any): +def process_identity_updates(object_identifier: int, cell_name: str, **kwds: Any) -> None: maybe_process_tombstone(Identity, object_identifier, cell_name=cell_name) @receiver(process_control_outbox, sender=OutboxCategory.SENTRY_APP_UPDATE) -def process_sentry_app_updates(object_identifier: int, cell_name: str, **kwds: Any): +def process_sentry_app_updates(object_identifier: int, cell_name: str, **kwds: Any) -> None: if ( sentry_app := maybe_process_tombstone( model=SentryApp, object_identifier=object_identifier, cell_name=cell_name @@ -67,7 +67,7 @@ def process_sentry_app_deletes( cell_name: str, payload: Mapping[str, Any], **kwds: Any, -): +) -> None: action_service.update_action_status_for_sentry_app_via_sentry_app_id( cell_name=cell_name, status=ObjectStatus.DISABLED, @@ -88,7 +88,7 @@ def process_sentry_app_installation_deletes( cell_name: str, payload: Mapping[str, Any], **kwds: Any, -): +) -> None: action_service.update_action_status_for_sentry_app_installation( cell_name=cell_name, status=ObjectStatus.DISABLED, @@ -98,7 +98,7 @@ def process_sentry_app_installation_deletes( @receiver(process_control_outbox, sender=OutboxCategory.API_APPLICATION_UPDATE) -def process_api_application_updates(object_identifier: int, cell_name: str, **kwds: Any): +def process_api_application_updates(object_identifier: int, cell_name: str, **kwds: Any) -> None: if ( api_application := maybe_process_tombstone( ApiApplication, object_identifier, cell_name=cell_name @@ -109,7 +109,7 @@ def process_api_application_updates(object_identifier: int, cell_name: str, **kw @receiver(process_control_outbox, sender=OutboxCategory.SERVICE_HOOK_UPDATE) -def process_service_hook_updates(object_identifier: int, cell_name: str, **kwds: Any): +def process_service_hook_updates(object_identifier: int, cell_name: str, **kwds: Any) -> None: try: installation = SentryAppInstallation.objects.select_related("sentry_app").get( id=object_identifier @@ -141,7 +141,7 @@ def process_service_hook_updates(object_identifier: int, cell_name: str, **kwds: @receiver(process_control_outbox, sender=OutboxCategory.SEND_SIGNAL) -def process_send_signal(payload: Mapping[str, Any], shard_identifier: int, **kwds: Any): +def process_send_signal(payload: Mapping[str, Any], shard_identifier: int, **kwds: Any) -> None: organization_service.send_signal( organization_id=shard_identifier, args=payload["args"], @@ -150,12 +150,12 @@ def process_send_signal(payload: Mapping[str, Any], shard_identifier: int, **kwd @receiver(process_control_outbox, sender=OutboxCategory.RESET_IDP_FLAGS) -def process_reset_idp_flags(shard_identifier: int, **kwds: Any): +def process_reset_idp_flags(shard_identifier: int, **kwds: Any) -> None: organization_service.reset_idp_flags(organization_id=shard_identifier) @receiver(process_control_outbox, sender=OutboxCategory.MARK_INVALID_SSO) -def process_mark_invalid_sso(object_identifier: int, shard_identifier: int, **kwds: Any): +def process_mark_invalid_sso(object_identifier: int, shard_identifier: int, **kwds: Any) -> None: # since we've identified an identity which is no longer valid # lets preemptively mark it as such other_member = organization_service.check_membership_by_id( @@ -171,7 +171,7 @@ def process_mark_invalid_sso(object_identifier: int, shard_identifier: int, **kw @receiver(process_control_outbox, sender=OutboxCategory.ISSUE_COMMENT_UPDATE) -def process_issue_email_reply(shard_identifier: int, payload: Any, **kwds): +def process_issue_email_reply(shard_identifier: int, payload: Any, **kwds: Any) -> None: issue_service.upsert_issue_email_reply( organization_id=shard_identifier, group_id=payload["group_id"], diff --git a/src/sentry/receivers/owners.py b/src/sentry/receivers/owners.py index a38a9b38d436..d3decfd73fa9 100644 --- a/src/sentry/receivers/owners.py +++ b/src/sentry/receivers/owners.py @@ -1,17 +1,18 @@ from django.db.models.signals import pre_save from rest_framework.serializers import ValidationError +from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember -def _assert_org_has_owner_not_from_team(organization, top_role): +def _assert_org_has_owner_not_from_team(organization: Organization, top_role: str) -> None: if not organization.member_set.filter(role=top_role).exists(): raise ValidationError(detail="An organization must have at least one owner") -def prevent_demoting_last_owner(instance: OrganizationMember, **kwargs): +def prevent_demoting_last_owner(instance: OrganizationMember, **kwargs: object) -> None: # if a member is being created - if instance.id is None: + if not instance.id: return try: diff --git a/src/sentry/receivers/releases.py b/src/sentry/receivers/releases.py index d2b6531a5ca2..148ade9abde9 100644 --- a/src/sentry/receivers/releases.py +++ b/src/sentry/receivers/releases.py @@ -92,7 +92,7 @@ def _capture_pull_request_link( outcome.succeeded = True -def validate_release_empty_version(instance: Release, **kwargs): +def validate_release_empty_version(instance: Release, **kwargs: object) -> None: if not Release.is_valid_version(instance.version): raise ValidationError( f"release_id({instance.id}) failed to save because of invalid version" @@ -100,7 +100,10 @@ def validate_release_empty_version(instance: Release, **kwargs): def invalidate_release_cache( - instance: Release, created: bool, update_fields: Iterable[str] | None = None, **kwargs + instance: Release, + created: bool, + update_fields: Iterable[str] | None = None, + **kwargs: object, ) -> None: if created: return @@ -131,7 +134,10 @@ def on_commit() -> None: def resolve_group_resolutions( - instance: Release, created: bool, update_fields: Iterable[str] | None = None, **kwargs + instance: Release, + created: bool, + update_fields: Iterable[str] | None = None, + **kwargs: object, ) -> None: # save() without update_fields may change either timestamp. Reevaluation # is idempotent, so conservatively handle those saves too. @@ -153,7 +159,7 @@ def on_commit() -> None: transaction.on_commit(on_commit, router.db_for_write(Release)) -def remove_resolved_link(link): +def remove_resolved_link(link: GroupLink) -> None: with transaction.atomic(router.db_for_write(GroupLink)): link.delete() @@ -197,7 +203,7 @@ def _find_pull_request_author_user( ) -def resolved_in_commit(instance: Commit, created, **kwargs): +def resolved_in_commit(instance: Commit, created: bool, **kwargs: object) -> None: """ Creates GroupLinks and referenced activity for commits that reference issues. @@ -326,7 +332,7 @@ def resolved_in_commit(instance: Commit, created, **kwargs): ) -def resolved_in_pull_request(instance: PullRequest, created, **kwargs): +def resolved_in_pull_request(instance: PullRequest, created: bool, **kwargs: object) -> None: groups = instance.find_referenced_groups() # Delete GroupLinks where message may have changed @@ -527,7 +533,7 @@ def _get_pull_request_activity_type_from_state( def pull_request_state_changing(instance: PullRequest, **kwargs: object) -> None: """Emit group activities when a linked PR moves between open and non-open states.""" try: - if instance.pk is None: + if not instance.pk: return old_state = ( @@ -607,7 +613,7 @@ def pull_request_state_changing(instance: PullRequest, **kwargs: object) -> None @buffer_incr_complete.connect( sender=ReleaseProject, dispatch_uid="project_has_releases_receiver", weak=False ) -def project_has_releases_receiver(filters, **_): +def project_has_releases_receiver(filters: dict[str, object], **_: object) -> None: try: project = ReleaseProject.objects.select_related("project").get(**filters).project except ReleaseProject.DoesNotExist: diff --git a/src/sentry/receivers/sentry_apps.py b/src/sentry/receivers/sentry_apps.py index 1220ee46114a..27d57075a2a3 100644 --- a/src/sentry/receivers/sentry_apps.py +++ b/src/sentry/receivers/sentry_apps.py @@ -27,7 +27,9 @@ @issue_assigned.connect(weak=False) -def send_issue_assigned_webhook(project, group, user, **kwargs): +def send_issue_assigned_webhook( + project: Project, group: Group, user: User | RpcUser | None, **kwargs: object +) -> None: assignee = GroupAssignee.objects.get(group_id=group.id).assigned_actor() actor: RpcUser | Team = assignee.resolve() @@ -45,7 +47,14 @@ def send_issue_assigned_webhook(project, group, user, **kwargs): @issue_resolved.connect(weak=False) -def send_issue_resolved_webhook(organization_id, project, group, user, resolution_type, **kwargs): +def send_issue_resolved_webhook( + organization_id: int, + project: Project, + group: Group, + user: User | RpcUser | None, + resolution_type: str, + **kwargs: object, +) -> None: send_workflow_webhooks( project.organization, group, @@ -56,7 +65,9 @@ def send_issue_resolved_webhook(organization_id, project, group, user, resolutio @issue_ignored.connect(weak=False) -def send_issue_ignored_webhook(project, user, group_list, **kwargs): +def send_issue_ignored_webhook( + project: Project, user: User | RpcUser | None, group_list: list[Group], **kwargs: object +) -> None: for issue in group_list: send_workflow_webhooks(project.organization, issue, user, "issue.ignored") @@ -66,19 +77,19 @@ def send_issue_unresolved_webhook( group: Group, project: Project, user: User | RpcUser | None = None, - **kwargs, + **kwargs: object, ) -> None: - send_issue_unresolved_webhook_helper(group=group, project=project, user=user, **kwargs) + send_issue_unresolved_webhook_helper(group=group, project=project, user=user) @issue_escalating.connect(weak=False) def send_issue_escalating_webhook( group: Group, project: Project, - **kwargs, + **kwargs: object, ) -> None: # Escalating is a form of unresolved so we send the same webhook - send_issue_unresolved_webhook_helper(group=group, project=project, **kwargs) + send_issue_unresolved_webhook_helper(group=group, project=project) def send_issue_unresolved_webhook_helper( @@ -86,7 +97,6 @@ def send_issue_unresolved_webhook_helper( project: Project, user: User | RpcUser | None = None, data: Mapping[str, Any] | None = None, - **kwargs, ) -> None: organization = project.organization send_workflow_webhooks( @@ -99,21 +109,45 @@ def send_issue_unresolved_webhook_helper( @comment_created.connect(weak=False) -def send_comment_created_webhook(project, user, group, data, **kwargs): +def send_comment_created_webhook( + project: Project, + user: User | RpcUser | None, + group: Group, + data: dict[str, Any], + **kwargs: object, +) -> None: send_comment_webhooks(project.organization, group, user, "comment.created", data=data) @comment_updated.connect(weak=False) -def send_comment_updated_webhook(project, user, group, data, **kwargs): +def send_comment_updated_webhook( + project: Project, + user: User | RpcUser | None, + group: Group, + data: dict[str, Any], + **kwargs: object, +) -> None: send_comment_webhooks(project.organization, group, user, "comment.updated", data=data) @comment_deleted.connect(weak=False) -def send_comment_deleted_webhook(project, user, group, data, **kwargs): +def send_comment_deleted_webhook( + project: Project, + user: User | RpcUser | None, + group: Group, + data: dict[str, Any], + **kwargs: object, +) -> None: send_comment_webhooks(project.organization, group, user, "comment.deleted", data=data) -def send_comment_webhooks(organization, issue, user, event, data=None): +def send_comment_webhooks( + organization: Organization, + issue: Group, + user: User | RpcUser | None, + event: str, + data: dict[str, Any] | None = None, +) -> None: data = data or {} if "timestamp" in data: data["timestamp"] = data["timestamp"].isoformat() diff --git a/src/sentry/receivers/staff.py b/src/sentry/receivers/staff.py index 42e4532f8e5a..049f739aeed9 100644 --- a/src/sentry/receivers/staff.py +++ b/src/sentry/receivers/staff.py @@ -1,7 +1,8 @@ from django.contrib.auth.signals import user_logged_out +from django.http.request import HttpRequest -def disable_staff(request, user, **kwargs): +def disable_staff(request: HttpRequest, user: object, **kwargs: object) -> None: staff = getattr(request, "staff", None) if staff: staff.set_logged_out() diff --git a/src/sentry/receivers/stats.py b/src/sentry/receivers/stats.py index 541e4990dc2f..00194039412c 100644 --- a/src/sentry/receivers/stats.py +++ b/src/sentry/receivers/stats.py @@ -1,9 +1,10 @@ +from django.db.models import Model from django.db.models.signals import post_save from sentry.utils import metrics -def record_instance_creation(instance, created, **kwargs): +def record_instance_creation(instance: Model, created: bool, **kwargs: object) -> None: if not created: return diff --git a/src/sentry/receivers/superuser.py b/src/sentry/receivers/superuser.py index c63281e2984c..8b2a398b271f 100644 --- a/src/sentry/receivers/superuser.py +++ b/src/sentry/receivers/superuser.py @@ -1,12 +1,14 @@ from django.conf import settings from django.contrib.auth.signals import user_logged_in, user_logged_out +from django.http.request import HttpRequest +from sentry.users.models.user import User from sentry.utils.settings import is_self_hosted # Upon login, we automatically want to enable superuser # status -def enable_superuser(request, user, **kwargs): +def enable_superuser(request: HttpRequest, user: User, **kwargs: object) -> None: ENABLE_SU_UPON_LOGIN_FOR_LOCAL_DEV = getattr( settings, "ENABLE_SU_UPON_LOGIN_FOR_LOCAL_DEV", False ) @@ -22,7 +24,7 @@ def enable_superuser(request, user, **kwargs): su._set_logged_out() -def disable_superuser(request, user, **kwargs): +def disable_superuser(request: HttpRequest, user: User, **kwargs: object) -> None: su = getattr(request, "superuser", None) if su: su.set_logged_out() diff --git a/src/sentry/receivers/tokens.py b/src/sentry/receivers/tokens.py index d654d2b34f4e..e162de7dc91a 100644 --- a/src/sentry/receivers/tokens.py +++ b/src/sentry/receivers/tokens.py @@ -10,7 +10,7 @@ @receiver(pre_save, sender=ApiKey, dispatch_uid="enforce_scope_hierarchy_api_key") @receiver(pre_save, sender=ApiToken, dispatch_uid="enforce_scope_hierarchy_api_token") -def enforce_scope_hierarchy(instance, **kwargs) -> None: +def enforce_scope_hierarchy(instance: ApiKey | ApiToken, **kwargs: object) -> None: """ This pre_save signal enforces scope hierarchy in the ApiToken and ApiKey models. It's impossible to know if the scopes have been modified without diff --git a/src/sentry/receivers/useremail.py b/src/sentry/receivers/useremail.py index 0ca3e030bcd4..117c0bbcf02d 100644 --- a/src/sentry/receivers/useremail.py +++ b/src/sentry/receivers/useremail.py @@ -5,7 +5,7 @@ from sentry.users.models.useremail import UserEmail -def create_user_email(instance, created, **kwargs): +def create_user_email(instance: User, created: bool, **kwargs: object) -> None: if created: try: UserEmail.objects.create(email=instance.email, user=instance) diff --git a/src/sentry/receivers/users.py b/src/sentry/receivers/users.py index e6cdebb00e11..b285ae05478c 100644 --- a/src/sentry/receivers/users.py +++ b/src/sentry/receivers/users.py @@ -6,7 +6,7 @@ from sentry.utils.settings import is_self_hosted -def create_first_user(**kwargs): +def create_first_user(**kwargs: object) -> None: if User.objects.filter(is_superuser=True).exists(): return diff --git a/src/sentry/relay/config/__init__.py b/src/sentry/relay/config/__init__.py index 66135f6057ad..eb93cc109294 100644 --- a/src/sentry/relay/config/__init__.py +++ b/src/sentry/relay/config/__init__.py @@ -4,7 +4,7 @@ import uuid from collections.abc import Iterable, Mapping, MutableMapping, Sequence from datetime import datetime, timezone -from typing import Any, Literal, TypedDict +from typing import Any, Literal, TypedDict, cast import sentry_sdk from sentry_sdk import traces @@ -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) @@ -296,7 +298,7 @@ def _should_extract_abnormal_mechanism(project: Project) -> bool: ) -def _browser_name_one_of(*args): +def _browser_name_one_of(*args: str) -> dict[str, object]: """Returns a condition that matches if an event or span's browser name is one of the options in `args`. @@ -815,7 +817,7 @@ def _get_project_config( with traces.start_span(name="get_public_config", attributes={"sentry.op": "get_public_config"}): now = datetime.now(timezone.utc) - cfg = { + cfg: dict[str, Any] = { # justification: relay config is a dynamic JSON document. "disabled": False, "slug": project.slug, "lastFetch": now, @@ -1027,7 +1029,8 @@ def get_at_path(self, *args: str) -> Any: return None # property not set or path goes beyond the Config defined valid path def __get_data(self) -> Mapping[str, Any]: - return object.__getattribute__(self, "data") + # justification: the relay config stores a decoded mapping under "data". + return cast("Mapping[str, Any]", object.__getattribute__(self, "data")) def __str__(self) -> str: try: diff --git a/src/sentry/relay/config/ai_model_costs.py b/src/sentry/relay/config/ai_model_costs.py index 849c0e442793..96bf92db7c35 100644 --- a/src/sentry/relay/config/ai_model_costs.py +++ b/src/sentry/relay/config/ai_model_costs.py @@ -1,5 +1,5 @@ import logging -from typing import Required, TypedDict +from typing import Required, TypedDict, cast from django.conf import settings @@ -48,8 +48,9 @@ def ai_model_metadata_config() -> AIModelMetadataConfig | None: return None cached_metadata = cache.get(AI_MODEL_METADATA_CACHE_KEY) - if cached_metadata is not None: - return cached_metadata + if isinstance(cached_metadata, dict): + # justification: the cache stores the AIModelMetadataConfig written by the metadata cron job. + return cast("AIModelMetadataConfig", cached_metadata) if not settings.IS_DEV: # in dev environment, we don't want to log this diff --git a/src/sentry/relay/config/experimental.py b/src/sentry/relay/config/experimental.py index ee7cf165e85d..f73d6013d94e 100644 --- a/src/sentry/relay/config/experimental.py +++ b/src/sentry/relay/config/experimental.py @@ -39,7 +39,8 @@ def check(self) -> None: class ExperimentalConfigBuilder(Protocol): - def __call__(self, timeout: TimeChecker, *args, **kwargs) -> Any: + # justification: Relay config builders have arbitrary signatures at this boundary. + def __call__(self, timeout: TimeChecker, /, *args: Any, **kwargs: Any) -> Any: pass diff --git a/src/sentry/relay/config/metric_extraction.py b/src/sentry/relay/config/metric_extraction.py index a788ead29a71..91db14b71241 100644 --- a/src/sentry/relay/config/metric_extraction.py +++ b/src/sentry/relay/config/metric_extraction.py @@ -32,12 +32,17 @@ def get_max_widget_specs(organization: Organization) -> int: - if organization.id in options.get("on_demand.extended_widget_spec_orgs") and options.get( - "on_demand.extended_max_widget_specs" + extended_max_specs = options.get("on_demand.extended_max_widget_specs") + if ( + organization.id in options.get("on_demand.extended_widget_spec_orgs") + and isinstance(extended_max_specs, int) + and extended_max_specs ): - return options.get("on_demand.extended_max_widget_specs") + return extended_max_specs max_widget_specs = options.get("on_demand.max_widget_specs") + if not isinstance(max_widget_specs, int): + raise TypeError("on_demand.max_widget_specs must be an integer") return max_widget_specs diff --git a/src/sentry/relay/datascrubbing.py b/src/sentry/relay/datascrubbing.py index 52da933f885e..b8f300fdfc0c 100644 --- a/src/sentry/relay/datascrubbing.py +++ b/src/sentry/relay/datascrubbing.py @@ -1,8 +1,8 @@ from __future__ import annotations import copy -from collections.abc import MutableMapping -from typing import TYPE_CHECKING, Any +from collections.abc import Iterator, Mapping, MutableMapping +from typing import TYPE_CHECKING, Any, TypedDict, cast import orjson from rest_framework import serializers @@ -18,13 +18,20 @@ from sentry.utils.safe import safe_execute if TYPE_CHECKING: + from sentry.models.organization import Organization from sentry.models.project import Project -def get_pii_config(project): - def _decode(value): +class _PIIConfig(TypedDict, total=False): + rules: dict[str, object] + applications: dict[str, list[str]] + + +def get_pii_config(project: Project) -> _PIIConfig: + def _decode(value: str | None) -> _PIIConfig | None: if value: return safe_execute(orjson.loads, value) + return None # Order of merging is important here. We want to apply organization rules # before project rules. For example: @@ -46,9 +53,9 @@ def _decode(value): ) -def get_datascrubbing_settings(project): +def get_datascrubbing_settings(project: Project) -> dict[str, object]: org = project.organization - rv = {} + rv: dict[str, object] = {} exclude_fields_key = "sentry:safe_fields" rv["excludeFields"] = org.get_option(exclude_fields_key, []) + project.get_option( @@ -77,7 +84,7 @@ def get_datascrubbing_settings(project): return rv -def get_all_pii_configs(project): +def get_all_pii_configs(project: Project) -> Iterator[_PIIConfig]: # Note: This logic is duplicated in Relay store. pii_config = get_pii_config(project) if pii_config: @@ -85,7 +92,11 @@ def get_all_pii_configs(project): settings = get_datascrubbing_settings(project) - yield convert_datascrubbing_config(settings, json_dumps=orjson.dumps, json_loads=orjson.loads) + # justification: relay returns a PII config mapping with the _PIIConfig keys. + yield cast( + "_PIIConfig", + convert_datascrubbing_config(settings, json_dumps=orjson.dumps, json_loads=orjson.loads), + ) @traces.trace @@ -109,14 +120,16 @@ def scrub_data(project: Project, event: MutableMapping[str, Any]) -> MutableMapp return event -def _merge_pii_configs(prefixes_and_configs: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]: +def _merge_pii_configs( + prefixes_and_configs: list[tuple[str, _PIIConfig | None]], +) -> _PIIConfig: """ Merge two PII configs into one, prefixing all custom rules with a prefix in the name. This is used to apply organization and project configs at once, and still get unique references to rule names. """ - merged_config: dict[str, Any] = {} + merged_config: _PIIConfig = {} for prefix, partial_config in prefixes_and_configs: if not partial_config: @@ -144,7 +157,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 +169,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) @@ -173,7 +186,9 @@ def validate_pii_selectors(selectors): return selectors -def _prefix_rule_references_in_rule(custom_rules, rule_def, prefix): +def _prefix_rule_references_in_rule( + custom_rules: Mapping[str, object], rule_def: object, prefix: str +) -> object: if not isinstance(rule_def, dict): return rule_def diff --git a/src/sentry/relay/projectconfig_cache/base.py b/src/sentry/relay/projectconfig_cache/base.py index d26c21ac4694..017e599fc538 100644 --- a/src/sentry/relay/projectconfig_cache/base.py +++ b/src/sentry/relay/projectconfig_cache/base.py @@ -1,14 +1,19 @@ +from collections.abc import Mapping +from typing import Any + from sentry.utils.services import Service class ProjectConfigCache(Service): __all__ = ("set_many", "get") - def __init__(self, **options): + def __init__(self, **options: object) -> None: pass - def set_many(self, configs): + def set_many(self, configs: Mapping[str, Mapping[str, object] | None]) -> None: pass - def get(self, public_key): + # justification: decoded JSON boundary; relay project configs are serialized as + # untyped JSON blobs and decoded back into arbitrary structures. + def get(self, public_key: str) -> Any: # justification: decoded relay project config JSON. raise NotImplementedError() diff --git a/src/sentry/relay/projectconfig_cache/redis.py b/src/sentry/relay/projectconfig_cache/redis.py index a333d8e156f7..52c7cb33fce1 100644 --- a/src/sentry/relay/projectconfig_cache/redis.py +++ b/src/sentry/relay/projectconfig_cache/redis.py @@ -1,6 +1,6 @@ import logging from collections.abc import Mapping -from typing import Any +from typing import Any, cast import zstandard @@ -15,25 +15,25 @@ class RedisProjectConfigCache(ProjectConfigCache): - def __init__(self, **options): - cluster_key = options.get("cluster", "default") + def __init__(self, **options: object) -> None: + cluster_key = cast(str, options.get("cluster", "default")) self.cluster = redis.redis_clusters.get_binary(cluster_key) - read_cluster_key = options.get("read_cluster", cluster_key) + read_cluster_key = cast(str, options.get("read_cluster", cluster_key)) self.cluster_read = redis.redis_clusters.get_binary(read_cluster_key) super().__init__(**options) - def validate(self): + def validate(self) -> None: validate_dynamic_cluster(True, self.cluster) - def __get_redis_key(self, public_key) -> str: + def __get_redis_key(self, public_key: str) -> str: return f"relayconfig:{public_key}" - def __get_redis_rev_key(self, public_key) -> str: + def __get_redis_rev_key(self, public_key: str) -> str: return f"{self.__get_redis_key(public_key)}.rev" - def set_many(self, configs: dict[str, Mapping[str, Any]]): + def set_many(self, configs: Mapping[str, Mapping[str, object] | None]) -> None: metrics.incr("relay.projectconfig_cache.write", amount=len(configs), tags={"action": "set"}) # Note: Those are multiple pipelines, one per cluster node. @@ -55,14 +55,20 @@ def set_many(self, configs: dict[str, Mapping[str, Any]]): # the actual revision on the project config for consistency, the revision key can and # should only be used as an optimization. This is also why the used pipeline is not # made transactional. - if rev := config.get("rev"): - p.setex(self.__get_redis_rev_key(public_key), REDIS_CACHE_TIMEOUT, rev) + if config is not None and (rev := config.get("rev")): + p.setex( + self.__get_redis_rev_key(public_key), + REDIS_CACHE_TIMEOUT, + cast(bytes | float | int | str, rev), + ) else: p.delete(self.__get_redis_rev_key(public_key)) p.execute() - def get(self, public_key): + # justification: decoded JSON boundary; the cached relay project config is JSON + # decoded back into arbitrary structures. + def get(self, public_key: str) -> Any: rv_b = self.cluster_read.get(self.__get_redis_key(public_key)) if rv_b is not None: try: @@ -73,7 +79,7 @@ def get(self, public_key): return json.loads(rv) return None - def get_rev(self, public_key) -> str | None: + def get_rev(self, public_key: str) -> str | None: if value := self.cluster_read.get(self.__get_redis_rev_key(public_key)): return value.decode() return None diff --git a/src/sentry/relay/projectconfig_debounce_cache/base.py b/src/sentry/relay/projectconfig_debounce_cache/base.py index d2b143d6ef09..5aefaee73926 100644 --- a/src/sentry/relay/projectconfig_debounce_cache/base.py +++ b/src/sentry/relay/projectconfig_debounce_cache/base.py @@ -19,10 +19,16 @@ class ProjectConfigDebounceCache(Service): __all__ = ("is_debounced", "debounce", "mark_task_done") - def __init__(self, **options): + def __init__(self, **options: object) -> None: pass - def is_debounced(self, *, public_key, project_id, organization_id): + def is_debounced( + self, + *, + public_key: str | None, + project_id: int | None, + organization_id: int | None, + ) -> bool: """Checks if the given project/organization should be debounced. If this is called this with multiple arguments each scope is checked, so that even @@ -31,13 +37,25 @@ def is_debounced(self, *, public_key, project_id, organization_id): """ return False - def debounce(self, *, public_key, project_id, organization_id): + def debounce( + self, + *, + public_key: str | None, + project_id: int | None, + organization_id: int | None, + ) -> None: """Debounces the given project/organization, without performing any checks. The highest-scoped argument passed in will be debounced. """ - def mark_task_done(self, *, public_key, project_id, organization_id): + def mark_task_done( + self, + *, + public_key: str | None, + project_id: int | None, + organization_id: int | None, + ) -> int: """ Mark a task done such that `is_debounced` starts emitting False for the given parameters. diff --git a/src/sentry/relay/projectconfig_debounce_cache/redis.py b/src/sentry/relay/projectconfig_debounce_cache/redis.py index 2114a4b8b6b2..24d895afbd2d 100644 --- a/src/sentry/relay/projectconfig_debounce_cache/redis.py +++ b/src/sentry/relay/projectconfig_debounce_cache/redis.py @@ -1,3 +1,5 @@ +from typing import cast + import rb from sentry_redis_tools.clients import RedisCluster @@ -14,16 +16,21 @@ class RedisProjectConfigDebounceCache(ProjectConfigDebounceCache): - def __init__(self, **options): - self._key_prefix = options.pop("key_prefix", "relayconfig-debounce") - self._debounce_ttl = options.pop("debounce_ttl", REDIS_CACHE_TIMEOUT) + def __init__(self, **options: object) -> None: + self._key_prefix = cast(str, options.pop("key_prefix", "relayconfig-debounce")) + self._debounce_ttl = cast(int, options.pop("debounce_ttl", REDIS_CACHE_TIMEOUT)) self.is_redis_cluster, self.cluster, options = get_dynamic_cluster_from_options( "SENTRY_RELAY_PROJECTCONFIG_DEBOUNCE_CACHE_OPTIONS", options ) super().__init__(**options) - def _get_redis_key(self, public_key, project_id, organization_id): + def _get_redis_key( + self, + public_key: str | None, + project_id: int | None, + organization_id: int | None, + ) -> str: if organization_id: return f"{self._key_prefix}:o:{organization_id}" elif project_id: @@ -33,7 +40,7 @@ def _get_redis_key(self, public_key, project_id, organization_id): else: raise ValueError() - def validate(self): + def validate(self) -> None: validate_dynamic_cluster(self.is_redis_cluster, self.cluster) def _get_redis_client(self, routing_key: str) -> rb.Cluster | RedisCluster: @@ -44,7 +51,13 @@ def _get_redis_client(self, routing_key: str) -> rb.Cluster | RedisCluster: else: raise AssertionError("unreachable") - def is_debounced(self, *, public_key, project_id, organization_id): + def is_debounced( + self, + *, + public_key: str | None, + project_id: int | None, + organization_id: int | None, + ) -> bool: if organization_id: key = self._get_redis_key( public_key=None, project_id=None, organization_id=organization_id @@ -64,15 +77,29 @@ def is_debounced(self, *, public_key, project_id, organization_id): return True return False - def debounce(self, *, public_key, project_id, organization_id): + def debounce( + self, + *, + public_key: str | None, + project_id: int | None, + organization_id: int | None, + ) -> None: key = self._get_redis_key(public_key, project_id, organization_id) client = self._get_redis_client(key) client.setex(key, self._debounce_ttl, 1) metrics.incr("relay.projectconfig_debounce_cache.debounce") - def mark_task_done(self, *, public_key, project_id, organization_id): + def mark_task_done( + self, + *, + public_key: str | None, + project_id: int | None, + organization_id: int | None, + ) -> int: key = self._get_redis_key(public_key, project_id, organization_id) client = self._get_redis_client(key) ret = client.delete(key) metrics.incr("relay.projectconfig_debounce_cache.task_done") + if not isinstance(ret, int): + raise TypeError("Redis DELETE returned no integer") return ret diff --git a/src/sentry/relay/utils.py b/src/sentry/relay/utils.py index 801e26c45a2f..7e6d32d253ac 100644 --- a/src/sentry/relay/utils.py +++ b/src/sentry/relay/utils.py @@ -1,26 +1,42 @@ import uuid +from typing import TypeVar, overload +from django.http import HttpRequest -def get_header_relay_id(request): + +def get_header_relay_id(request: HttpRequest) -> str | None: try: return str(uuid.UUID(request.META["HTTP_X_SENTRY_RELAY_ID"])) except (LookupError, ValueError, TypeError): pass + return None -def get_header_relay_signature(request): +def get_header_relay_signature(request: HttpRequest) -> str | None: try: return str(request.META["HTTP_X_SENTRY_RELAY_SIGNATURE"]) except (LookupError, ValueError, TypeError): pass + return None -def type_to_class_name(snake_str): +def type_to_class_name(snake_str: str) -> str: components = snake_str.split("_") return "".join(x.title() for x in components[0:]) -def to_camel_case_name(name): +T = TypeVar("T") + + +@overload +def to_camel_case_name(name: str) -> str: ... + + +@overload +def to_camel_case_name(name: T) -> T: ... + + +def to_camel_case_name(name: object) -> object: """ Converts a string from snake_case to camelCase @@ -45,10 +61,10 @@ def to_camel_case_name(name): 'oneTwoThreeFour' """ - def first_lower(s): + def first_lower(s: str) -> str: return s[:1].lower() + s[1:] - def first_upper(s): + def first_upper(s: str) -> str: return s[:1].upper() + s[1:] if not isinstance(name, str): diff --git a/src/sentry/release_health/eap_sessions_rollout.py b/src/sentry/release_health/eap_sessions_rollout.py index 34b3888ccfba..9e1df3de9f96 100644 --- a/src/sentry/release_health/eap_sessions_rollout.py +++ b/src/sentry/release_health/eap_sessions_rollout.py @@ -10,7 +10,7 @@ import math from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from google.protobuf.timestamp_pb2 import Timestamp from sentry_protos.snuba.v1.attribute_conditional_aggregation_pb2 import ( @@ -40,7 +40,7 @@ from sentry.utils.snuba_rpc import timeseries_rpc if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Mapping, Sequence from sentry.snuba.metrics.query import DeprecatingMetricsQuery @@ -139,7 +139,7 @@ def _agg_expr( ) -def _translate_where(where: Sequence) -> list[TraceItemFilter]: +def _translate_where(where: Sequence[object]) -> list[TraceItemFilter]: """Translate snuba_sdk conditions to TraceItemFilters.""" filters: list[TraceItemFilter] = [] try: @@ -168,7 +168,7 @@ def _build_eap_timeseries_request( start: datetime, end: datetime, rollup: int, - where: Sequence, + where: Sequence[object], ) -> TimeSeriesRequest: """Build a TimeSeriesRequest protobuf from explicit parameters.""" start_timestamp = Timestamp() @@ -401,7 +401,7 @@ def _transform_eap_response( rollup: int, include_totals: bool, include_series: bool, -) -> dict: +) -> dict[str, object]: """Convert a TimeSeriesResponse into the final get_series output format.""" start_ts = int(start.timestamp()) end_ts = int(end.timestamp()) @@ -428,7 +428,7 @@ def _transform_eap_response( values.append(0.0) grouped[group_key][label] = values[:num_buckets] - result_groups: list[dict] = [] + result_groups: list[dict[str, object]] = [] other_groupbys = [gb for gb in raw_groupby if gb != "session.status"] if not grouped and has_status_groupby and not other_groupbys: @@ -453,7 +453,7 @@ def _transform_eap_response( series_data[spec] = s totals_data[spec] = t - group: dict = {} + group: dict[str, object] = {} if include_series: group["series"] = { field_alias_map[spec]: [float(v) if v is not None else None for v in vals] @@ -484,7 +484,7 @@ def is_session_metrics_query(metrics_query: DeprecatingMetricsQuery) -> bool: def get_series_eap( metrics_query: DeprecatingMetricsQuery, org_id: int, -) -> dict | None: +) -> dict[str, object] | None: """Query EAP for session data matching a metrics/data endpoint query. Returns the result in the same format as get_series() with field names @@ -567,16 +567,17 @@ def _safe_bucket_cutoff(intervals: list[str | datetime]) -> int: def _groups_match( - control_groups: list[dict], - exp_groups: list[dict], + control_groups: list[dict[str, object]], + exp_groups: list[dict[str, object]], cutoff: int, ) -> bool: """Compare groups, only looking at the first ``cutoff`` series values.""" if len(control_groups) != len(exp_groups): return False - def _sort_key(g: dict) -> list[tuple[str, str]]: - return sorted((k, str(v)) for k, v in g.get("by", {}).items()) + def _sort_key(g: dict[str, object]) -> list[tuple[str, str]]: + by = cast("Mapping[str, object]", g.get("by", {})) + return sorted((k, str(v)) for k, v in by.items()) control_sorted = sorted(control_groups, key=_sort_key) exp_sorted = sorted(exp_groups, key=_sort_key) @@ -584,8 +585,8 @@ def _sort_key(g: dict) -> list[tuple[str, str]]: for cg, eg in zip(control_sorted, exp_sorted): if cg.get("by") != eg.get("by"): return False - c_series = cg.get("series", {}) - e_series = eg.get("series", {}) + c_series = cast("Mapping[str, list[object]]", cg.get("series", {})) + e_series = cast("Mapping[str, list[object]]", eg.get("series", {})) if c_series.keys() != e_series.keys(): return False for field in c_series: @@ -594,11 +595,11 @@ def _sort_key(g: dict) -> list[tuple[str, str]]: return True -def compare_get_series_results(control: dict, experimental: dict) -> None: +def compare_get_series_results(control: dict[str, object], experimental: dict[str, object]) -> None: """Shadow-compare get_series results from legacy metrics and EAP.""" - control_groups = control.get("groups", []) - exp_groups = experimental.get("groups", []) - intervals = control.get("intervals", []) + control_groups = cast("list[dict[str, object]]", control.get("groups", [])) + exp_groups = cast("list[dict[str, object]]", experimental.get("groups", [])) + intervals = cast("list[str | datetime]", control.get("intervals", [])) is_null = len(exp_groups) == 0 cutoff = _safe_bucket_cutoff(intervals) diff --git a/src/sentry/release_health/metrics.py b/src/sentry/release_health/metrics.py index bb58a65ddaaa..76bff88ba759 100644 --- a/src/sentry/release_health/metrics.py +++ b/src/sentry/release_health/metrics.py @@ -1582,7 +1582,8 @@ def get_project_sessions_count( groups = raw_result["groups"] if len(groups) > 0: - return get_path(groups[0], "totals", "value", default=0) + value = get_path(groups[0], "totals", "value", default=0) + return int(value) if isinstance(value, (int, float)) else 0 else: return 0 diff --git a/src/sentry/release_health/metrics_sessions_v2.py b/src/sentry/release_health/metrics_sessions_v2.py index 871ac37e8307..232a40f90e63 100644 --- a/src/sentry/release_health/metrics_sessions_v2.py +++ b/src/sentry/release_health/metrics_sessions_v2.py @@ -120,7 +120,7 @@ class Group(TypedDict): totals: dict[SessionsQueryFunction, SessionsQueryValue] -def default_for(field: SessionsQueryFunction) -> SessionsQueryValue: +def default_for(field: str) -> SessionsQueryValue: return 0 if field in ("sum(session)", "count_unique(user)") else None @@ -372,7 +372,7 @@ def _get_metric_fields( return [self._metric_field] -FIELD_MAP: Mapping[SessionsQueryFunction, type[Field]] = { +FIELD_MAP: Mapping[str, type[Field]] = { "sum(session)": SumSessionField, "count_unique(user)": CountUniqueUser, "avg(session.duration)": DurationField, @@ -483,13 +483,17 @@ def run_sessions_query( GroupKey.from_input_dict(group["by"]): group for group in metrics_results["groups"] } - default_group_gen_func: Callable[[], Group] = lambda: { - "totals": {field: default_for(field) for field in query.raw_fields}, - "series": { - field: len(metrics_results["intervals"]) * [default_for(field)] - for field in query.raw_fields + # justification: raw fields are strings validated against the sessions field map. + default_group_gen_func: Callable[[], Group] = lambda: cast( + "Group", + { + "totals": {field: default_for(field) for field in query.raw_fields}, + "series": { + field: len(metrics_results["intervals"]) * [default_for(field)] + for field in query.raw_fields + }, }, - } + ) output_groups: MutableMapping[GroupKey, Group] = defaultdict(default_group_gen_func) @@ -513,13 +517,17 @@ def run_sessions_query( for group_key, group in output_groups.items() ] - return { - "groups": result_groups, - "start": isoformat_z(metrics_results["start"]), - "end": isoformat_z(metrics_results["end"]), - "intervals": [isoformat_z(ts) for ts in metrics_results["intervals"]], - "query": query.query, - } + # justification: this backend serializes timestamps to ISO strings; SessionsQueryResult types them as datetime. + return cast( + "SessionsQueryResult", + { + "groups": result_groups, + "start": isoformat_z(metrics_results["start"]), + "end": isoformat_z(metrics_results["end"]), + "intervals": [isoformat_z(ts) for ts in metrics_results["intervals"]], + "query": query.query, + }, + ) def _empty_result(query: QueryDefinition) -> SessionsQueryResult: @@ -597,7 +605,7 @@ def _parse_session_status(status: Any) -> frozenset[SessionStatus]: def _parse_orderby( - query: QueryDefinition, fields: Mapping[SessionsQueryFunction, Field] + query: QueryDefinition, fields: Mapping[str, Field] ) -> MetricOrderByField | None: orderbys = query.raw_orderby if orderbys == []: diff --git a/src/sentry/release_health/tasks.py b/src/sentry/release_health/tasks.py index 469a39848919..c05f1ebac7f5 100644 --- a/src/sentry/release_health/tasks.py +++ b/src/sentry/release_health/tasks.py @@ -42,7 +42,7 @@ retry=Retry(times=5, on=(Exception,)), processing_deadline_duration=400, ) -def monitor_release_adoption(**kwargs) -> None: +def monitor_release_adoption(**kwargs: object) -> None: metrics.incr("sentry.tasks.monitor_release_adoption.start", sample_rate=1.0) with metrics.timer( "sentry.tasks.monitor_release_adoption.process_projects_with_sessions", sample_rate=1.0 @@ -55,7 +55,12 @@ def monitor_release_adoption(**kwargs) -> None: def get_process_projects_with_sessions_countdown(org_id: int) -> int: - jitter_seconds = max(0, options.get(PROCESS_PROJECTS_WITH_SESSIONS_JITTER_SECONDS_OPTION)) + jitter_option = options.get(PROCESS_PROJECTS_WITH_SESSIONS_JITTER_SECONDS_OPTION) + if not isinstance(jitter_option, int): + raise TypeError( + f"{PROCESS_PROJECTS_WITH_SESSIONS_JITTER_SECONDS_OPTION} must be an integer" + ) + jitter_seconds = max(0, jitter_option) if jitter_seconds == 0: return 0 @@ -68,7 +73,7 @@ def get_process_projects_with_sessions_countdown(org_id: int) -> int: retry=Retry(times=5, on=(Exception,), delay=5), processing_deadline_duration=160, ) -def process_projects_with_sessions(org_id, project_ids) -> None: +def process_projects_with_sessions(org_id: int, project_ids: Sequence[int]) -> None: # Takes a single org id and a list of project ids with metrics.timer("sentry.tasks.monitor_release_adoption.process_projects_with_sessions.core"): @@ -143,8 +148,13 @@ def adopt_releases(org_id: int, totals: Totals) -> Sequence[int]: def _update_last_seen( - org_id, current_time, cutoff_time, project_id, environment_name, release_version -): + org_id: int, + current_time: datetime, + cutoff_time: datetime, + project_id: int, + environment_name: str, + release_version: str, +) -> None: # Skip last_seen updates when the disable option is set (default True) if options.get("release-health.disable-release-last-seen-update", True): return diff --git a/src/sentry/releases/endpoints/organization_release_assemble.py b/src/sentry/releases/endpoints/organization_release_assemble.py index 690a96370350..56f9582da628 100644 --- a/src/sentry/releases/endpoints/organization_release_assemble.py +++ b/src/sentry/releases/endpoints/organization_release_assemble.py @@ -20,6 +20,7 @@ ) from sentry.apidocs.parameters import GlobalParams, ReleaseParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.organization import Organization from sentry.models.release import Release from sentry.tasks.assemble import ( AssembleTask, @@ -76,7 +77,7 @@ class OrganizationReleaseAssembleEndpoint(OrganizationReleasesBaseEndpoint): }, ) def post( - self, request: Request, organization, version + self, request: Request, organization: Organization, version: str ) -> Response[ReleaseAssembleResponse] | Response[_AssembleErrorResponse]: """ Assemble an artifact bundle from previously uploaded chunks and merge it into the diff --git a/src/sentry/releases/endpoints/organization_release_commits.py b/src/sentry/releases/endpoints/organization_release_commits.py index b75858e37ccb..285b5a444b6d 100644 --- a/src/sentry/releases/endpoints/organization_release_commits.py +++ b/src/sentry/releases/endpoints/organization_release_commits.py @@ -13,6 +13,7 @@ from sentry.apidocs.examples.release_examples import ReleaseExamples from sentry.apidocs.parameters import CursorQueryParam, GlobalParams, ReleaseParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.organization import Organization from sentry.models.release import Release from sentry.models.releasecommit import ReleaseCommit @@ -44,7 +45,7 @@ class OrganizationReleaseCommitsEndpoint(OrganizationReleasesBaseEndpoint): examples=ReleaseExamples.LIST_RELEASE_COMMITS, ) def get( - self, request: Request, organization, version + self, request: Request, organization: Organization, version: str ) -> Response[list[CommitSerializerResponseWithReleases]]: """ Retrieve a list of commits for a given release. diff --git a/src/sentry/releases/endpoints/organization_release_details.py b/src/sentry/releases/endpoints/organization_release_details.py index 7bfc1255b140..6f879414db5a 100644 --- a/src/sentry/releases/endpoints/organization_release_details.py +++ b/src/sentry/releases/endpoints/organization_release_details.py @@ -1,5 +1,8 @@ +from collections.abc import Mapping +from typing import Any, 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 +13,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,7 +78,14 @@ class OrganizationReleaseSerializer(ReleaseSerializer): ) -def add_status_filter_to_queryset(queryset, status_filter): +class _ReleaseDateQueryAndOrder(TypedDict): + date_query_q: Q + order_by: list[str] + + +def add_status_filter_to_queryset( + queryset: QuerySet[Release], status_filter: str +) -> QuerySet[Release]: """ Function that adds status filter on a queryset """ @@ -91,7 +101,9 @@ def add_status_filter_to_queryset(queryset, status_filter): return queryset -def add_query_filter_to_queryset(queryset, query): +def add_query_filter_to_queryset( + queryset: QuerySet[Release], query: str | None +) -> QuerySet[Release]: """ Function that adds a query filtering to a queryset """ @@ -108,7 +120,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 +135,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 +151,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 +172,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 +194,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 +218,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 @@ -247,7 +266,9 @@ def get_adjacent_releases_to_current_release( } @staticmethod - def __get_top_of_queryset_release_version_based_on_order_by(org, proj_and_env_dict, order_by): + def __get_top_of_queryset_release_version_based_on_order_by( + org: Organization, proj_and_env_dict: Mapping[str, Any], order_by: list[str] + ) -> str | None: """ Helper function that executes a query on Release table orders that query based on `order_by` input provided @@ -269,9 +290,16 @@ def __get_top_of_queryset_release_version_based_on_order_by(org, proj_and_env_di if not release: return None - return release.version + version = release.version + return version if isinstance(version, str) else None - 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 +315,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 +384,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 +518,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 +651,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/releases/endpoints/organization_release_file_details.py b/src/sentry/releases/endpoints/organization_release_file_details.py index 6d694dfeb2c5..0e4618fbfd9c 100644 --- a/src/sentry/releases/endpoints/organization_release_file_details.py +++ b/src/sentry/releases/endpoints/organization_release_file_details.py @@ -75,7 +75,7 @@ class OrganizationReleaseFileDetailsEndpoint( }, ) def get( - self, request: Request, organization, version, file_id + self, request: Request, organization: Organization, version: str, file_id: str ) -> Response[ReleaseFileSerializerResponse] | FileResponse | Response[None]: """ Return metadata for an individual file within a release. Does not return the file @@ -112,7 +112,7 @@ def get( }, ) def put( - self, request: Request, organization: Organization, version, file_id + self, request: Request, organization: Organization, version: str, file_id: str ) -> Response[ReleaseFileSerializerResponse] | Response[ValidationErrorResponse]: """ Update metadata of an existing release file. Currently only the name of the file @@ -141,7 +141,9 @@ def put( 404: RESPONSE_NOT_FOUND, }, ) - def delete(self, request: Request, organization, version, file_id) -> Response[None]: + def delete( + self, request: Request, organization: Organization, version: str, file_id: str + ) -> Response[None]: """ Permanently remove a file from a release. Also removes the physical file from storage. diff --git a/src/sentry/releases/endpoints/organization_release_files.py b/src/sentry/releases/endpoints/organization_release_files.py index 24d20eb33062..0a2b8ee9f23b 100644 --- a/src/sentry/releases/endpoints/organization_release_files.py +++ b/src/sentry/releases/endpoints/organization_release_files.py @@ -19,6 +19,7 @@ ) from sentry.apidocs.parameters import CursorQueryParam, GlobalParams, ReleaseParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.organization import Organization from sentry.models.release import Release from sentry.ratelimits.config import RateLimitConfig from sentry.releases.endpoints.project_release_files import ( @@ -89,7 +90,7 @@ class OrganizationReleaseFilesEndpoint(OrganizationReleasesBaseEndpoint, Release }, ) def get( - self, request: Request, organization, version + self, request: Request, organization: Organization, version: str ) -> Response[list[ReleaseFileSerializerResponse]]: """ Retrieve a list of files for a given release. @@ -121,7 +122,7 @@ def get( }, ) def post( - self, request: Request, organization, version + self, request: Request, organization: Organization, version: str ) -> Response[ReleaseFileSerializerResponse]: """ Upload a new file for the given release. diff --git a/src/sentry/releases/endpoints/organization_release_health_data.py b/src/sentry/releases/endpoints/organization_release_health_data.py index e90281c4783d..242e00580d61 100644 --- a/src/sentry/releases/endpoints/organization_release_health_data.py +++ b/src/sentry/releases/endpoints/organization_release_health_data.py @@ -1,4 +1,6 @@ import logging +from collections.abc import Sequence +from typing import Any, cast from rest_framework.exceptions import ParseError from rest_framework.request import Request @@ -59,7 +61,7 @@ class OrganizationReleaseHealthDataEndpoint(OrganizationEndpoint): # Number of groups returned for each page (applies to old endpoint). default_per_page = 50 - def _validate_fields(self, request: Request): + def _validate_fields(self, request: Request) -> None: """ Validates fields request parameter. Checks if metric field is public (InvalidParams exception is raised if it is not), @@ -86,7 +88,7 @@ def get(self, request: Request, organization: Organization) -> Response: projects = self.get_projects(request, organization) self._validate_fields(request) - def data_fn(offset: int, limit: int): + def data_fn(offset: int, limit: int) -> dict[str, Any]: try: query = QueryDefinition( projects, @@ -146,10 +148,12 @@ def data_fn(offset: int, limit: int): class MetricsDataSeriesPaginator(GenericOffsetPaginator): - def get_result(self, limit, cursor=None): + def get_result(self, limit: int, cursor: Cursor | None = None) -> CursorResult[Any]: assert limit > 0 offset = cursor.offset if cursor is not None else 0 data = self.data_fn(offset=offset, limit=limit + 1) + if not isinstance(data, dict): + raise NotImplementedError if isinstance(data.get("groups"), list): has_more = len(data["groups"]) == limit + 1 @@ -159,7 +163,8 @@ def get_result(self, limit, cursor=None): raise NotImplementedError return CursorResult( - data, + # justification: the data_fn returns a dict payload that CursorResult only stores. + cast("Sequence[Any]", data), prev=Cursor(0, max(0, offset - limit), True, offset > 0), next=Cursor(0, max(0, offset + limit), False, has_more), ) diff --git a/src/sentry/releases/endpoints/organization_release_meta.py b/src/sentry/releases/endpoints/organization_release_meta.py index eddc8f7dbdca..9cd87f8bf0a1 100644 --- a/src/sentry/releases/endpoints/organization_release_meta.py +++ b/src/sentry/releases/endpoints/organization_release_meta.py @@ -13,6 +13,7 @@ from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.serializers.models.release import expose_version_info from sentry.models.commitfilechange import CommitFileChange +from sentry.models.organization import Organization from sentry.models.projectplatform import ProjectPlatform from sentry.models.release import Release from sentry.models.releasecommit import ReleaseCommit @@ -37,7 +38,7 @@ class OrganizationReleaseMetaEndpoint(OrganizationReleasesBaseEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, organization, version) -> Response: + def get(self, request: Request, organization: Organization, version: str) -> Response: """ Retrieve an Organization's Release's Associated Meta Data ````````````````````````````````````````````````````````` diff --git a/src/sentry/releases/endpoints/project_release_commits.py b/src/sentry/releases/endpoints/project_release_commits.py index 35bec70ed8c1..def10dfa06ae 100644 --- a/src/sentry/releases/endpoints/project_release_commits.py +++ b/src/sentry/releases/endpoints/project_release_commits.py @@ -14,6 +14,7 @@ from sentry.apidocs.parameters import CursorQueryParam, GlobalParams, ReleaseParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import ObjectStatus +from sentry.models.project import Project from sentry.models.release import Release from sentry.models.releasecommit import ReleaseCommit from sentry.models.repository import Repository @@ -48,7 +49,7 @@ class ProjectReleaseCommitsEndpoint(ProjectEndpoint): examples=ReleaseExamples.LIST_RELEASE_COMMITS, ) def get( - self, request: Request, project, version + self, request: Request, project: Project, version: str ) -> Response[list[CommitSerializerResponseWithReleases]]: """ Retrieve a list of commits for a given release. diff --git a/src/sentry/releases/endpoints/project_release_details.py b/src/sentry/releases/endpoints/project_release_details.py index 68151b561e35..d112fb440c07 100644 --- a/src/sentry/releases/endpoints/project_release_details.py +++ b/src/sentry/releases/endpoints/project_release_details.py @@ -27,6 +27,7 @@ from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.activity import Activity +from sentry.models.project import Project from sentry.models.release import Release from sentry.models.releases.exceptions import UnsafeReleaseDeletion from sentry.plugins.interfaces.releasehook import ReleaseHook @@ -65,7 +66,9 @@ class ProjectReleaseDetailsEndpoint(ProjectEndpoint, ReleaseAnalyticsMixin): }, examples=ReleaseExamples.RETRIEVE_RELEASE, ) - def get(self, request: Request, project, version) -> Response[ReleaseSerializerResponse]: + def get( + self, request: Request, project: Project, version: str + ) -> Response[ReleaseSerializerResponse]: """ Return details on an individual release. """ @@ -116,7 +119,7 @@ def get(self, request: Request, project, version) -> Response[ReleaseSerializerR }, ) def put( - self, request: Request, project, version + self, request: Request, project: Project, version: str ) -> Response[ReleaseSerializerResponse] | Response[ValidationErrorResponse]: """ Update a release. This can change metadata associated with the release @@ -198,7 +201,7 @@ def put( 404: RESPONSE_NOT_FOUND, }, ) - def delete(self, request: Request, project, version) -> Response: + def delete(self, request: Request, project: Project, version: str) -> Response: """ Permanently remove a release and all of its files. """ diff --git a/src/sentry/releases/endpoints/project_release_file_details.py b/src/sentry/releases/endpoints/project_release_file_details.py index 8884c5a2ce3b..0467078bbf6e 100644 --- a/src/sentry/releases/endpoints/project_release_file_details.py +++ b/src/sentry/releases/endpoints/project_release_file_details.py @@ -1,5 +1,6 @@ import posixpath -from collections.abc import Callable +from collections.abc import Callable, Iterator, Mapping +from typing import Any, cast from zipfile import ZipFile from django.http.response import FileResponse @@ -33,6 +34,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.debug_files.release_files import maybe_renew_releasefiles from sentry.models.distribution import Distribution +from sentry.models.project import Project from sentry.models.release import Release from sentry.models.releasefile import ReleaseFile, delete_from_artifact_index, read_artifact_index from sentry.releases.endpoints.project_release_files import pseudo_releasefile @@ -47,7 +49,7 @@ class ReleaseFileSerializer(serializers.Serializer): ) -def _entry_from_index(release: Release, dist: Distribution | None, url: str) -> ReleaseFile: +def _entry_from_index(release: Release, dist: Distribution | None, url: str) -> dict[str, object]: index = read_artifact_index(release, dist) if index is None: raise ResourceDoesNotExist @@ -63,22 +65,23 @@ def _get_from_index(release: Release, dist: Distribution | None, url: str) -> Re class ClosesDependentFiles: - def __init__(self, f, *closables) -> None: + def __init__(self, f: Any, *closables: Any) -> None: self._f = f self._closables = closables - def close(self): + def close(self) -> None: self._f.close() for closable in self._closables: closable.close() - def __iter__(self): - return self._f + def __iter__(self) -> Iterator[Any]: + # justification: the wrapped file object is its own iterator. + return cast("Iterator[Any]", self._f) - def __getattr__(self, attr): + def __getattr__(self, attr: str) -> Any: return getattr(self._f, attr) - def __dir__(self): + def __dir__(self) -> list[str]: ret = list(super().__dir__()) ret.extend(dir(self._f)) ret.sort() @@ -92,21 +95,21 @@ class ReleaseFileDetailsMixin: """ @staticmethod - def download(releasefile): + def download(releasefile: ReleaseFile) -> FileResponse: file = releasefile.file fp = file.getfile() response = FileResponse( fp, content_type=file.headers.get("content-type", "application/octet-stream"), ) - response["Content-Length"] = file.size + response["Content-Length"] = str(file.size) response["Content-Disposition"] = 'attachment; filename="%s"' % posixpath.basename( " ".join(releasefile.name.split()) ) return response @staticmethod - def download_from_archive(release, entry): + def download_from_archive(release: Release, entry: Mapping[str, Any]) -> FileResponse: archive_ident = entry["archive_ident"] archive_file = ReleaseFile.objects.get(release_id=release.id, ident=archive_ident) @@ -126,7 +129,11 @@ def download_from_archive(release, entry): return response @staticmethod - def _get_releasefile(release: Release, file_id: str, index_op=_get_from_index): + def _get_releasefile( + release: Release, + file_id: str, + index_op: Callable[..., Any] = _get_from_index, + ) -> Any: """Fetch ReleaseFile either from db or from artifact_index""" try: id = decode_release_file_id(file_id) @@ -140,13 +147,14 @@ def _get_releasefile(release: Release, file_id: str, index_op=_get_from_index): except ReleaseFile.DoesNotExist: raise ResourceDoesNotExist else: - dist, url = id - if dist is not None: + dist_name, url = id + dist: Distribution | None = None + if dist_name is not None: # NOTE: Could do one less query if `read_artifact_index` accepted # `dist_name` try: dist = Distribution.objects.get( - organization_id=release.organization_id, name=dist, release=release + organization_id=release.organization_id, name=dist_name, release=release ) except Distribution.DoesNotExist: raise ResourceDoesNotExist @@ -203,7 +211,7 @@ def update_releasefile( return Response(body) @classmethod - def delete_releasefile(cls, release, file_id): + def delete_releasefile(cls, release: Release, file_id: str) -> Response: result = cls._get_releasefile(release, file_id, delete_from_artifact_index) if result is True: # was successfully deleted from index @@ -267,7 +275,7 @@ class ProjectReleaseFileDetailsEndpoint(ProjectEndpoint, ReleaseFileDetailsMixin }, ) def get( - self, request: Request, project, version, file_id + self, request: Request, project: Project, version: str, file_id: str ) -> Response[ReleaseFileSerializerResponse] | FileResponse | Response[None]: """ Return metadata for an individual file within a release. Does not return the file @@ -308,7 +316,7 @@ def get( }, ) def put( - self, request: Request, project, version, file_id + self, request: Request, project: Project, version: str, file_id: str ) -> Response[ReleaseFileSerializerResponse] | Response[ValidationErrorResponse]: """ Update metadata of an existing release file. Currently only the name of the file @@ -340,7 +348,9 @@ def put( 404: RESPONSE_NOT_FOUND, }, ) - def delete(self, request: Request, project, version, file_id) -> Response[None]: + def delete( + self, request: Request, project: Project, version: str, file_id: str + ) -> Response[None]: """ Permanently remove a file from a release. Also removes the physical file from storage, unless it is stored as part of an artifact bundle. diff --git a/src/sentry/releases/endpoints/project_release_files.py b/src/sentry/releases/endpoints/project_release_files.py index 008e5648a610..818072f556d5 100644 --- a/src/sentry/releases/endpoints/project_release_files.py +++ b/src/sentry/releases/endpoints/project_release_files.py @@ -2,10 +2,13 @@ import logging import re +from collections.abc import Mapping +from typing import Any, cast from django.db import IntegrityError, router from django.db.models import Q from django.db.models.query import QuerySet +from django.http import QueryDict from django.utils.functional import cached_property from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import serializers @@ -34,6 +37,7 @@ from sentry.debug_files.release_files import maybe_renew_releasefiles from sentry.models.distribution import Distribution from sentry.models.files.file import File +from sentry.models.project import Project from sentry.models.release import Release from sentry.models.releasefile import ReleaseFile, read_artifact_index from sentry.ratelimits.config import SENTRY_RATELIMITER_GROUP_DEFAULTS, RateLimitConfig @@ -78,7 +82,7 @@ class ReleaseFileUploadSerializer(serializers.Serializer): ) -def load_dist(results): +def load_dist(results: list[ReleaseFile]) -> list[ReleaseFile]: # Dists are pretty uncommon. In case they do appear load them now # as trying to join this on the DB does terrible things with large # offsets (it would otherwise generate a left outer join). @@ -94,13 +98,15 @@ def load_dist(results): if result.dist_id is not None: result_dist = dist_map.get(result.dist_id) if result_dist is not None: - result.dist = result_dist + setattr(result, "dist", result_dist) return results class ReleaseFilesMixin(BaseEndpointMixin): - def get_releasefiles(self, request: Request, release, organization_id): + def get_releasefiles( + self, request: Request, release: Release, organization_id: int + ) -> Response: query = request.GET.getlist("query") checksums = request.GET.getlist("checksum") @@ -139,7 +145,7 @@ def get_releasefiles(self, request: Request, release, organization_id): source = ArtifactSource(dist, files, query, checksums) data_sources.append(source) - def on_results(release_files: list[ReleaseFile]): + def on_results(release_files: list[ReleaseFile]) -> Any: # this should filter out all the "pseudo-ReleaseFile"s maybe_renew_releasefiles([rf for rf in release_files if rf.id]) @@ -156,7 +162,7 @@ def on_results(release_files: list[ReleaseFile]): ) @staticmethod - def post_releasefile(request, release, logger): + def post_releasefile(request: Request, release: Release, logger: logging.Logger) -> Response: if "file" not in request.data: return Response({"detail": "Missing uploaded file"}, status=400) @@ -174,7 +180,7 @@ def post_releasefile(request, release, logger): ) headers = {"Content-Type": fileobj.content_type} - for headerval in request.data.getlist("header") or (): + for headerval in cast("QueryDict", request.data).getlist("header") or (): try: k, v = headerval.split(":", 1) except ValueError: @@ -199,7 +205,7 @@ def post_releasefile(request, release, logger): organization_id=release.organization_id, release_id=release.id, name=full_name, - dist_id=dist.id if dist else dist, + dist_id=dist.id if dist is not None else None, ).exists(): return Response({"detail": ERR_FILE_EXISTS}, status=409) @@ -215,7 +221,7 @@ def post_releasefile(request, release, logger): release_id=release.id, file=file, name=full_name, - dist_id=dist.id if dist else dist, + dist_id=dist.id if dist is not None else None, ) except IntegrityError: file.delete() @@ -228,15 +234,19 @@ class ArtifactSource: """Provides artifact data to ChainPaginator on-demand""" def __init__( - self, dist: Distribution | None, files: dict, query: list[str], checksums: list[str] - ): + self, + dist: Distribution | None, + files: dict[str, Any], + query: list[str], + checksums: list[str], + ) -> None: self._dist = dist self._files = files self._query = query self._checksums = checksums @cached_property - def sorted_and_filtered_files(self) -> list[tuple[str, dict]]: + def sorted_and_filtered_files(self) -> list[tuple[str, dict[str, Any]]]: query = self._query checksums = self._checksums files = [ @@ -253,14 +263,14 @@ def sorted_and_filtered_files(self) -> list[tuple[str, dict]]: def __len__(self) -> int: return len(self.sorted_and_filtered_files) - def __getitem__(self, range): + def __getitem__(self, range: slice) -> list[ReleaseFile]: return [ pseudo_releasefile(url, info, self._dist) for url, info in self.sorted_and_filtered_files[range] ] -def pseudo_releasefile(url, info, dist): +def pseudo_releasefile(url: str, info: Mapping[str, Any], dist: Distribution | None) -> ReleaseFile: """Create a pseudo-ReleaseFile from an ArtifactIndex entry""" return ReleaseFile( name=url, @@ -270,7 +280,7 @@ def pseudo_releasefile(url, info, dist): timestamp=info["date_created"], checksum=info["sha1"], ), - dist_id=dist.id if dist else dist, + dist_id=dist.id if dist is not None else None, ) @@ -322,7 +332,7 @@ class ProjectReleaseFilesEndpoint(ProjectEndpoint, ReleaseFilesMixin): }, ) def get( - self, request: Request, project, version + self, request: Request, project: Project, version: str ) -> Response[list[ReleaseFileSerializerResponse]]: """ Retrieve a list of files for a given release. @@ -356,7 +366,9 @@ def get( 409: RESPONSE_CONFLICT, }, ) - def post(self, request: Request, project, version) -> Response[ReleaseFileSerializerResponse]: + def post( + self, request: Request, project: Project, version: str + ) -> Response[ReleaseFileSerializerResponse]: """ Upload a new file for the given release. diff --git a/src/sentry/releases/endpoints/project_release_repositories.py b/src/sentry/releases/endpoints/project_release_repositories.py index 4e046aef3ba7..605f3b7e1fa4 100644 --- a/src/sentry/releases/endpoints/project_release_repositories.py +++ b/src/sentry/releases/endpoints/project_release_repositories.py @@ -12,6 +12,7 @@ from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED from sentry.apidocs.parameters import GlobalParams, ReleaseParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.project import Project from sentry.models.release import Release from sentry.models.releasecommit import ReleaseCommit from sentry.models.repository import Repository @@ -44,7 +45,7 @@ class ProjectReleaseRepositories(ProjectEndpoint): }, ) def get( - self, request: Request, project, version + self, request: Request, project: Project, version: str ) -> Response[list[RepositorySerializerResponse]]: """ Return the repositories that have commits associated with a given release. diff --git a/src/sentry/releases/endpoints/project_release_setup.py b/src/sentry/releases/endpoints/project_release_setup.py index d605f5c811ca..af6158225f86 100644 --- a/src/sentry/releases/endpoints/project_release_setup.py +++ b/src/sentry/releases/endpoints/project_release_setup.py @@ -14,6 +14,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.deploy import Deploy from sentry.models.group import Group +from sentry.models.project import Project from sentry.models.releasecommit import ReleaseCommit from sentry.models.releases.release_project import ReleaseProject from sentry.models.repository import Repository @@ -56,7 +57,7 @@ class ProjectReleaseSetupCompletionEndpoint(ProjectEndpoint): 404: RESPONSE_NOT_FOUND, }, ) - def get(self, request: Request, project) -> Response[list[ReleaseSetupStepResponse]]: + def get(self, request: Request, project: Project) -> Response[list[ReleaseSetupStepResponse]]: """ Return release-setup progress based on issues with a first release, organization repositories, commits associated with project releases, and deploys of project releases. diff --git a/src/sentry/releases/endpoints/project_release_stats.py b/src/sentry/releases/endpoints/project_release_stats.py index efbaa834e013..1a5b2059e563 100644 --- a/src/sentry/releases/endpoints/project_release_stats.py +++ b/src/sentry/releases/endpoints/project_release_stats.py @@ -21,6 +21,7 @@ from sentry.apidocs.parameters import GlobalParams, ReleaseParams from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.project import Project from sentry.models.release import Release from sentry.release_health.base import is_overview_stat from sentry.releases.auto_creation import should_auto_create_releases @@ -44,7 +45,7 @@ class ProjectReleaseStatsResponse(TypedDict): usersBreakdown: list[ReleaseStatsUserBreakdown] -def upsert_missing_release(project, version) -> datetime | None: +def upsert_missing_release(project: Project, version: str) -> datetime | None: """This adds a release to postgres if it should exist but does not do yet.""" try: return Release.objects.values_list("date_added", flat=True).get( @@ -105,7 +106,7 @@ class ProjectReleaseStatsEndpoint(ProjectEndpoint): }, ) def get( - self, request: Request, project, version + self, request: Request, project: Project, version: str ) -> Response[ProjectReleaseStatsResponse] | Response[DetailResponse]: """ Return crash-free session/user health stats and a per-day breakdown for a release diff --git a/src/sentry/releases/endpoints/project_releases.py b/src/sentry/releases/endpoints/project_releases.py index 8a26cd56dcb3..8a8ee4903099 100644 --- a/src/sentry/releases/endpoints/project_releases.py +++ b/src/sentry/releases/endpoints/project_releases.py @@ -39,6 +39,7 @@ from sentry.models.activity import Activity from sentry.models.environment import Environment from sentry.models.orgauthtoken import is_org_auth_token_auth, update_org_auth_token_last_used +from sentry.models.project import Project from sentry.models.release import Release, ReleaseStatus from sentry.plugins.interfaces.releasehook import ReleaseHook from sentry.ratelimits.config import SENTRY_RATELIMITER_GROUP_DEFAULTS, RateLimitConfig @@ -81,7 +82,7 @@ class ProjectReleasesEndpoint(ProjectEndpoint): }, examples=ReleaseExamples.LIST_PROJECT_RELEASES, ) - def get(self, request: Request, project) -> Response[list[ReleaseSerializerResponse]]: + def get(self, request: Request, project: Project) -> Response[list[ReleaseSerializerResponse]]: """ Retrieve a list of releases for a given project. """ @@ -134,7 +135,7 @@ def get(self, request: Request, project) -> Response[list[ReleaseSerializerRespo examples=ReleaseExamples.CREATE_RELEASE, ) def post( - self, request: Request, project + self, request: Request, project: Project ) -> Response[ReleaseSerializerResponse] | Response[ValidationErrorResponse]: """ Create a new release and/or associate a project with a release. Releases are used by diff --git a/src/sentry/releases/endpoints/project_releases_token.py b/src/sentry/releases/endpoints/project_releases_token.py index 07ba478b1c17..28a91c56bd24 100644 --- a/src/sentry/releases/endpoints/project_releases_token.py +++ b/src/sentry/releases/endpoints/project_releases_token.py @@ -12,10 +12,11 @@ from sentry.api.bases.project import ProjectEndpoint, StrictProjectPermission from sentry.api.permissions import DisallowImpersonatedTokenCreation from sentry.models.options.project_option import ProjectOption +from sentry.models.project import Project from sentry.types.cell import get_local_locality -def _get_webhook_url(project, plugin_id, token): +def _get_webhook_url(project: Project, plugin_id: str, token: str) -> str: return get_local_locality().to_url( reverse( "sentry-release-hook", @@ -28,7 +29,7 @@ def _get_webhook_url(project, plugin_id, token): ) -def _get_signature(project_id, plugin_id, token): +def _get_signature(project_id: int, plugin_id: str, token: str) -> str: return hmac.new( key=token.encode("utf-8"), msg=(f"{plugin_id}-{project_id}").encode(), @@ -45,12 +46,12 @@ class ProjectReleasesTokenEndpoint(ProjectEndpoint): } permission_classes = (StrictProjectPermission, DisallowImpersonatedTokenCreation) - def _regenerate_token(self, project): + def _regenerate_token(self, project: Project) -> str: token = uuid1().hex ProjectOption.objects.set_value(project, "sentry:release-token", token) return token - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project: Project) -> Response: token = ProjectOption.objects.get_value(project, "sentry:release-token") if token is None: @@ -61,7 +62,7 @@ def get(self, request: Request, project) -> Response: return Response({"token": token, "webhookUrl": _get_webhook_url(project, "builtin", token)}) - def post(self, request: Request, project) -> Response: + def post(self, request: Request, project: Project) -> Response: token = self._regenerate_token(project) return Response({"token": token, "webhookUrl": _get_webhook_url(project, "builtin", token)}) diff --git a/src/sentry/releases/endpoints/release_deploys.py b/src/sentry/releases/endpoints/release_deploys.py index 645ebbb7f88c..243e96c599e6 100644 --- a/src/sentry/releases/endpoints/release_deploys.py +++ b/src/sentry/releases/endpoints/release_deploys.py @@ -81,7 +81,7 @@ class DeploySerializer(serializers.Serializer): help_text="The optional list of project slugs to create a deploy within. If not provided, deploys are created for all of the release's projects.", ) - def validate_environment(self, value): + def validate_environment(self, value: str) -> str: if not Environment.is_valid_name(value): raise serializers.ValidationError("Invalid value for environment") return value @@ -154,7 +154,7 @@ class ReleaseDeploysEndpoint(OrganizationReleasesBaseEndpoint): responses={200: DeployResponseSerializer(many=True)}, ) def get( - self, request: Request, organization, version + self, request: Request, organization: Organization, version: str ) -> Response[list[DeploySerializerResponse]]: """ Returns a list of deploys based on the organization, version, and project. @@ -175,7 +175,7 @@ def get( projects = self.get_projects(request, organization) project_id = [p.id for p in projects] - if project_id and project_id != "-1": + if project_id: release_project_envs = release_project_envs.filter(project_id__in=project_id) deploy_ids = release_project_envs.values_list("last_deploy_id", flat=True) @@ -197,7 +197,7 @@ def get( responses={201: DeployResponseSerializer, 400: RESPONSE_BAD_REQUEST}, ) def post( - self, request: Request, organization, version + self, request: Request, organization: Organization, version: str ) -> Response[DeploySerializerResponse] | Response[ValidationErrorResponse]: """ Create a deploy for a given release. diff --git a/src/sentry/releases/serializers/release.py b/src/sentry/releases/serializers/release.py index 92ba314b5bda..2f363b411d2f 100644 --- a/src/sentry/releases/serializers/release.py +++ b/src/sentry/releases/serializers/release.py @@ -70,12 +70,12 @@ def serialize( """ return { "authors": authors, - "commitCount": release.commit_count, + "commitCount": release.commit_count or 0, "currentProjectMeta": current_project_meta, "data": release.data, "dateCreated": release.date_added, "dateReleased": release.date_released, - "deployCount": release.total_deploys, + "deployCount": release.total_deploys or 0, "firstEvent": first_event, "id": release.id, "lastCommit": last_commit, diff --git a/src/sentry/releases/use_cases/release.py b/src/sentry/releases/use_cases/release.py index f5d2899b1c74..5cbad860f3c6 100644 --- a/src/sentry/releases/use_cases/release.py +++ b/src/sentry/releases/use_cases/release.py @@ -1,7 +1,7 @@ from collections import defaultdict -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping, Sequence from datetime import datetime, timezone -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from django.contrib.auth.models import AnonymousUser from django.db.models import Sum @@ -32,6 +32,9 @@ from sentry.users.services.user.serial import serialize_generic_user from sentry.users.services.user.service import user_service +if TYPE_CHECKING: + from sentry.tagstore.types import TagValue + @traces.trace def serialize( @@ -171,7 +174,9 @@ def get_release_project_first_last_seen( organization_id: int, project_ids: list[int], no_snuba_for_release_creation: bool, - fetch_first_last_seen: Callable[[int, list[int], int | None, list[str]], list[Any]], + fetch_first_last_seen: Callable[ + [int, Sequence[int], int | None, Sequence[str]], Iterable["TagValue"] + ], ) -> tuple[dict[int, datetime | None], dict[int, datetime | None]]: """ Returns a tuple of first_seen and last_seen dictionaries where the key is the release version @@ -186,6 +191,7 @@ def get_release_project_first_last_seen( rid: None for rid in release_version_and_id_map.values() } + tag_values: Iterable[TagValue] if no_snuba_for_release_creation: tag_values = [] else: @@ -197,14 +203,12 @@ def get_release_project_first_last_seen( ) for tv in tag_values: - first_val = first_seen.get(tv.value) - last_val = last_seen.get(tv.value) - first_seen[release_version_and_id_map[tv.value]] = ( - min(tv.first_seen, first_val) if first_val else tv.first_seen - ) - last_seen[release_version_and_id_map[tv.value]] = ( - max(tv.last_seen, last_val) if last_val else tv.last_seen - ) + assert tv.value is not None + assert tv.first_seen is not None + assert tv.last_seen is not None + release_id = release_version_and_id_map[tv.value] + first_seen[release_id] = tv.first_seen + last_seen[release_id] = tv.last_seen return first_seen, last_seen diff --git a/src/sentry/relocation/api/endpoints/index.py b/src/sentry/relocation/api/endpoints/index.py index 78ea645786be..1dbd2c44959c 100644 --- a/src/sentry/relocation/api/endpoints/index.py +++ b/src/sentry/relocation/api/endpoints/index.py @@ -53,7 +53,8 @@ RELOCATION_FILE_SIZE_MEDIUM = 100 * 1024**2 -def get_relocation_size_category(size: int) -> str: +def get_relocation_size_category(size: int | None) -> str: + assert size is not None if size < RELOCATION_FILE_SIZE_SMALL: return "small" elif size < RELOCATION_FILE_SIZE_MEDIUM: diff --git a/src/sentry/relocation/services/relocation_export/impl.py b/src/sentry/relocation/services/relocation_export/impl.py index 0c9e2ed7366c..6d0d2f2a39d4 100644 --- a/src/sentry/relocation/services/relocation_export/impl.py +++ b/src/sentry/relocation/services/relocation_export/impl.py @@ -6,6 +6,7 @@ import logging from datetime import UTC, datetime from io import BytesIO +from uuid import UUID from django.db import router from django.db.utils import IntegrityError @@ -153,7 +154,7 @@ def request_new_export( } logger.info("SaaS -> SaaS request received on proxy", extra=logger_data) transfer = ControlRelocationTransfer.objects.create( - relocation_uuid=relocation_uuid, + relocation_uuid=UUID(relocation_uuid), org_slug=org_slug, requesting_cell=requesting_region_name, exporting_cell=replying_region_name, @@ -199,7 +200,7 @@ def reply_with_export( # Save transfer record so we can push state to the requesting cell transfer = ControlRelocationTransfer.objects.create( - relocation_uuid=relocation_uuid, + relocation_uuid=UUID(relocation_uuid), org_slug=org_slug, requesting_cell=requesting_region_name, exporting_cell=replying_region_name, diff --git a/src/sentry/relocation/tasks/process.py b/src/sentry/relocation/tasks/process.py index 72db7295c9cd..2edd17819bb1 100644 --- a/src/sentry/relocation/tasks/process.py +++ b/src/sentry/relocation/tasks/process.py @@ -407,7 +407,7 @@ def fulfill_cross_region_export_request( # Save a transfer record to move the export to control silo transfer = RegionRelocationTransfer.objects.create( - relocation_uuid=uuid_str, + relocation_uuid=UUID(uuid_str), requesting_cell=requesting_cell_name, exporting_cell=replying_cell_name, org_slug=org_slug, diff --git a/src/sentry/remote_subscriptions/consumers/result_consumer.py b/src/sentry/remote_subscriptions/consumers/result_consumer.py index a1211011caac..572d89edb4c4 100644 --- a/src/sentry/remote_subscriptions/consumers/result_consumer.py +++ b/src/sentry/remote_subscriptions/consumers/result_consumer.py @@ -7,7 +7,7 @@ from collections.abc import Mapping from concurrent.futures import wait from functools import partial -from typing import Generic, Literal, TypeVar +from typing import Generic, Literal, TypeVar, cast from arroyo.backends.kafka.consumer import KafkaPayload from arroyo.processing.strategies import BatchStep @@ -43,7 +43,7 @@ def __init__(self, use_subscription_lock: bool = False): def subscription_model(self) -> type[U]: pass - def __call__(self, identifier: str, result: T): + def __call__(self, identifier: str, result: T) -> None: with metrics.timer( "remote_subscriptions.result_consumer.call_timing", tags={"identifier": identifier}, @@ -92,7 +92,7 @@ def get_subscription_id(self, result: T) -> str: pass @abc.abstractmethod - def handle_result(self, subscription: U | None, result: T): + def handle_result(self, subscription: U | None, result: T) -> None: pass @@ -275,7 +275,7 @@ def partition_message_batch(self, message: Message[ValuesBatch[KafkaPayload]]) - return list(batch_mapping.values()) - def process_batch(self, message: Message[ValuesBatch[KafkaPayload]]): + def process_batch(self, message: Message[ValuesBatch[KafkaPayload]]) -> None: """ Receives batches of messages. This function will take the batch and group them together using `build_payload_grouping_key`, which ensures order is preserved. Each group is then @@ -300,7 +300,7 @@ def process_batch(self, message: Message[ValuesBatch[KafkaPayload]]): ] wait(futures) - def process_group(self, items: list[T]): + def process_group(self, items: list[T]) -> None: """ Process a group of related messages serially. """ @@ -317,7 +317,8 @@ def decode_payload( try: codec = get_topic_codec(topic_for_codec) - return codec.decode(payload.value) + # justification: the codec is registered for this topic and decodes to the subscribed model. + return cast("T | None", codec.decode(payload.value)) except Exception: logger.exception( "Failed to decode message payload", diff --git a/src/sentry/replays/_case_studies/INC_1184_consumer_backlog_from_increased_threads/report.py b/src/sentry/replays/_case_studies/INC_1184_consumer_backlog_from_increased_threads/report.py index 4c5f90315984..89d6f1fc2851 100644 --- a/src/sentry/replays/_case_studies/INC_1184_consumer_backlog_from_increased_threads/report.py +++ b/src/sentry/replays/_case_studies/INC_1184_consumer_backlog_from_increased_threads/report.py @@ -15,6 +15,7 @@ import time from threading import Semaphore +from typing import cast from arroyo.processing.strategies import RunTask, RunTaskInThreads from arroyo.processing.strategies.abstract import MessageRejected, ProcessingStrategy @@ -22,40 +23,40 @@ class Producer: - def __init__(self, step): + def __init__(self, step: ProcessingStrategy[FilteredPayload | None]) -> None: self.step = step self.produced_count = 0 - def submit(self): + def submit(self) -> None: self.step.submit(Message(Value(None, {}, None))) self.produced_count += 1 - def poll(self): + def poll(self) -> None: self.step.poll() class Consumer(ProcessingStrategy[FilteredPayload | None]): - def __init__(self): + def __init__(self) -> None: self.consumed_count = 0 - def submit(self, message): + def submit(self, message: Message[FilteredPayload | None]) -> None: self.consumed_count += 1 - def poll(self): ... + def poll(self) -> None: ... - def close(self): ... + def close(self) -> None: ... - def join(self, timeout=None): ... + def join(self, timeout: float | None = None) -> None: ... - def terminate(self): ... + def terminate(self) -> None: ... class SharedResource: - def __init__(self, max_workers, wait): + def __init__(self, max_workers: int, wait: float) -> None: self.lock = Semaphore(max_workers) self.wait = wait - def __call__(self): + def __call__(self) -> None: with self.lock: time.sleep(self.wait) @@ -63,26 +64,34 @@ def __call__(self): # Fixture -def case(step_workers, step_queue_depth, shared_resource_workers, shared_resource_work_time): +def case( + step_workers: int, + step_queue_depth: int, + shared_resource_workers: int, + shared_resource_work_time: float, +) -> tuple[Producer, Consumer, ProcessingStrategy[FilteredPayload | None]]: shared = SharedResource(shared_resource_workers, wait=shared_resource_work_time) consumer = Consumer() - def processor(m): + def processor(m: Message[FilteredPayload | None]) -> None: shared() # lock and sleep - def run_task_work(m): + def run_task_work(m: Message[FilteredPayload | None]) -> Message[FilteredPayload | None]: # useless work in a loop to emulate processing. for i in range(10_000): i / 0.1 return m - step = RunTask( + step = RunTask[FilteredPayload | None, Message[FilteredPayload | None]]( function=run_task_work, - next_step=RunTaskInThreads( - processing_function=processor, - concurrency=step_workers, - max_pending_futures=step_queue_depth, - next_step=consumer, + next_step=cast( + ProcessingStrategy[FilteredPayload | Message[FilteredPayload | None]], + RunTaskInThreads( + processing_function=processor, + concurrency=step_workers, + max_pending_futures=step_queue_depth, + next_step=consumer, + ), ), ) @@ -94,7 +103,7 @@ def run_task_work(m): # Test Cases -def test1_unbalanced_worker_counts(): +def test1_unbalanced_worker_counts() -> None: """ Background: "10 connections are available. 8 threads: which means we never see backpressure. We go to 16 @@ -131,7 +140,7 @@ def test1_unbalanced_worker_counts(): assert consumer.consumed_count == 10 -def test2_insufficient_queue_depth(): +def test2_insufficient_queue_depth() -> None: """ Background: "two steps. processor and commit. processor pushes to a queue. threads pick up message off the @@ -169,7 +178,7 @@ def test2_insufficient_queue_depth(): ... -def test3_over_production_hogs_cpu_time_leading_to_starvation(): +def test3_over_production_hogs_cpu_time_leading_to_starvation() -> None: """ Background: "Because every time a MessageRejected is raised by the commit step, it happens when process @@ -230,7 +239,7 @@ def test3_over_production_hogs_cpu_time_leading_to_starvation(): # 65 ± 10 milliseconds. -def test4_high_production_to_queue_does_not_starve(): +def test4_high_production_to_queue_does_not_starve() -> None: """ Background: "If they enqueued work indefinitively we would not see a backlog probably" @@ -275,7 +284,7 @@ def test4_high_production_to_queue_does_not_starve(): # 40 ± 5 milliseconds. -def test5_modulated_message_rejected_intensity_from_production_patterns(): +def test5_modulated_message_rejected_intensity_from_production_patterns() -> None: """ Background: During the incident the production environment was experiencing perodic load spikes. Production @@ -354,7 +363,7 @@ def test5_modulated_message_rejected_intensity_from_production_patterns(): # 740 ± 40 milliseconds. -def test6_production_patterns_with_unbalanced_threads(): +def test6_production_patterns_with_unbalanced_threads() -> None: """ Background: During the production incident resolution was driven by lowering the number of threads the @@ -436,7 +445,7 @@ def test6_production_patterns_with_unbalanced_threads(): # 1000 ± 50 milliseconds. -def test7_production_patterns_with_unbalanced_threads_with_sufficient_queue_depth(): +def test7_production_patterns_with_unbalanced_threads_with_sufficient_queue_depth() -> None: """ Background: During the production incident resolution was driven by lowering the number of threads the @@ -518,7 +527,7 @@ def test7_production_patterns_with_unbalanced_threads_with_sufficient_queue_dept # 400 ± 5 milliseconds. -def test8_introducing_backoff_reduces_starvation(): +def test8_introducing_backoff_reduces_starvation() -> None: """ Background: This test is idential to test3 except we sleep after each message rejected. diff --git a/src/sentry/replays/data_export.py b/src/sentry/replays/data_export.py index d73b461dfd10..ced623486f49 100644 --- a/src/sentry/replays/data_export.py +++ b/src/sentry/replays/data_export.py @@ -470,7 +470,7 @@ def export_replay_row_set_async( limit: int = EXPORT_QUERY_ROWS_PER_PAGE, offset: int = 0, num_pages: int = EXPORT_QUERY_PAGES_PER_TASK, -): +) -> None: """ Export all replay rows which belong to the project and exist within the range. @@ -543,7 +543,7 @@ def export_replay_project_async( limit: int, destination_bucket: str, num_pages: int = EXPORT_QUERY_PAGES_PER_TASK, -): +) -> None: """ Export every replay for a given Sentry Project ID. @@ -613,7 +613,7 @@ def export_replay_data( database_pages_per_task: int = EXPORT_QUERY_PAGES_PER_TASK, source_bucket: str = EXPORT_JOB_SOURCE_BUCKET, pubsub_topic_name: str | None = None, -): +) -> None: logger.info( "Starting replay export...", extra={ diff --git a/src/sentry/replays/endpoints/organization_replay_count.py b/src/sentry/replays/endpoints/organization_replay_count.py index 9974337884b5..a502a54779f7 100644 --- a/src/sentry/replays/endpoints/organization_replay_count.py +++ b/src/sentry/replays/endpoints/organization_replay_count.py @@ -148,7 +148,7 @@ def get( return self.respond(replay_counts) -def project_in_org_has_sent_replay(organization): +def project_in_org_has_sent_replay(organization: Organization) -> bool: return ( Project.objects.filter(organization=organization) .filter(flags=F("flags").bitor(Project.flags.has_replays)) diff --git a/src/sentry/replays/endpoints/organization_replay_details.py b/src/sentry/replays/endpoints/organization_replay_details.py index 823bcb7e753d..ab0d0d9801a9 100644 --- a/src/sentry/replays/endpoints/organization_replay_details.py +++ b/src/sentry/replays/endpoints/organization_replay_details.py @@ -1,6 +1,6 @@ import uuid from datetime import datetime, timezone -from typing import TypedDict +from typing import Any, TypedDict, cast from drf_spectacular.utils import extend_schema from rest_framework.request import Request @@ -31,7 +31,7 @@ from sentry.models.organization import Organization from sentry.replays.endpoints.organization_replay_endpoint import OrganizationReplayEndpoint from sentry.replays.lib.eap import read as eap_read -from sentry.replays.lib.eap.snuba_transpiler import RequestMeta, Settings +from sentry.replays.lib.eap.snuba_transpiler import QueryResult, RequestMeta, Settings from sentry.replays.post_process import ReplayDetailsResponse, process_raw_response from sentry.replays.query import query_replay_instance from sentry.replays.validators import ReplayValidator, validate_fields @@ -103,7 +103,9 @@ def _query_replay_urls_eap( return urls -def _normalize_eap_response(data: list[dict]) -> list[dict]: +def _normalize_eap_response( + data: list[dict[str, bool | float | int | str | None]], +) -> list[dict[str, bool | float | int | str | None]]: """Normalize EAP response data for frontend compatibility. - Convert float timestamps to ISO strings @@ -133,7 +135,7 @@ def query_replay_instance_eap( organization_id: int, request_user_id: int | None, referrer: str = "replays.query.details_query", -): +) -> QueryResult: # EAP stores replay_id in hex without dashes replay_ids_no_dashes = [replay_id.replace("-", "") for replay_id in replay_ids] @@ -278,14 +280,17 @@ def get( if features.has( "organizations:replay-details-eap-query", organization, actor=request.user ): - snuba_response = query_replay_instance_eap( - project_ids=project_ids, - replay_ids=[replay_id], - start=filter_params["start"], - end=filter_params["end"], - organization_id=organization.id, - request_user_id=acting_user_id(request.user), - )["data"] + snuba_response = cast( + list[dict[str, Any]], # justification: replay query rows are decoded JSON. + query_replay_instance_eap( + project_ids=project_ids, + replay_ids=[replay_id], + start=filter_params["start"], + end=filter_params["end"], + organization_id=organization.id, + request_user_id=acting_user_id(request.user), + )["data"], + ) if snuba_response: urls = _query_replay_urls_eap( @@ -297,13 +302,16 @@ def get( ) snuba_response[0]["urls_sorted"] = urls else: - snuba_response = query_replay_instance( - project_id=project_ids, - replay_id=replay_id, - start=filter_params["start"], - end=filter_params["end"], - organization=organization, - request_user_id=acting_user_id(request.user), + snuba_response = cast( + list[dict[str, Any]], # justification: replay query rows are decoded JSON. + query_replay_instance( + project_id=project_ids, + replay_id=replay_id, + start=filter_params["start"], + end=filter_params["end"], + organization=organization, + request_user_id=acting_user_id(request.user), + ), ) replay_data = process_raw_response( diff --git a/src/sentry/replays/endpoints/organization_replay_events_meta.py b/src/sentry/replays/endpoints/organization_replay_events_meta.py index 695007210a07..04c06434af12 100644 --- a/src/sentry/replays/endpoints/organization_replay_events_meta.py +++ b/src/sentry/replays/endpoints/organization_replay_events_meta.py @@ -1,5 +1,5 @@ from collections.abc import Sequence -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.request import Request @@ -144,7 +144,9 @@ def get(self, request: Request, organization: Organization) -> Response: dataset = self.get_dataset(request, organization) - def data_fn(offset, limit): + def data_fn( + offset: int, limit: int + ) -> dict[str, Any]: # justification: replay query rows are decoded JSON. query_details = { "selected_columns": self.get_field_list(organization, request), "query": request.GET.get("query"), @@ -161,7 +163,8 @@ def data_fn(offset, limit): "transform_alias_to_input_format": True, } - return dataset.query(**query_details) + # justification: the dataset module is selected at runtime and returns a JSON payload. + return cast("dict[str, Any]", dataset.query(**query_details)) with handle_query_errors(): return self.paginate( diff --git a/src/sentry/replays/endpoints/organization_replay_index.py b/src/sentry/replays/endpoints/organization_replay_index.py index 6a04f1f75a6b..5b7969feabce 100644 --- a/src/sentry/replays/endpoints/organization_replay_index.py +++ b/src/sentry/replays/endpoints/organization_replay_index.py @@ -16,6 +16,7 @@ from sentry.apidocs.examples.replay_examples import ReplayExamples from sentry.apidocs.parameters import GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.auth.principal import has_user from sentry.exceptions import InvalidSearchQuery from sentry.models.organization import Organization from sentry.replays.endpoints.organization_replay_endpoint import OrganizationReplayEndpoint @@ -23,6 +24,7 @@ from sentry.replays.query import query_replays_collection_paginated, replay_url_parser_config from sentry.replays.usecases.query import PREFERRED_SOURCE, QueryResponse from sentry.replays.validators import ReplayValidator +from sentry.types.actor import Actor from sentry.utils.cursors import Cursor, CursorResult @@ -82,7 +84,7 @@ def get(self, request: Request, organization: Organization) -> Response[_ListRep headers = {} - def data_fn(offset: int, limit: int): + def data_fn(offset: int, limit: int) -> QueryResponse: try: search_filters = parse_search_query( request.query_params.get("query", ""), config=replay_url_parser_config @@ -99,6 +101,7 @@ def data_fn(offset: int, limit: int): if not isinstance(sort, str): sort = None + actor = Actor.from_object(request.user) if has_user(request.user) else None response = query_replays_collection_paginated( project_ids=filter_params["project_id"], start=filter_params["start"], @@ -111,7 +114,7 @@ def data_fn(offset: int, limit: int): search_filters=search_filters, preferred_source=preferred_source, organization=organization, - actor=request.user, + actor=actor, ) # We set the data-source header so we can figure out which query is giving @@ -144,7 +147,7 @@ class ReplayPaginator: def __init__(self, data_fn: Callable[[int, int], QueryResponse]) -> None: self.data_fn = data_fn - def get_result(self, limit: int, cursor=None): + def get_result(self, limit: int, cursor: Cursor | None = None) -> CursorResult[object]: assert limit > 0 offset = int(cursor.offset) if cursor is not None else 0 response = self.data_fn(offset, limit + 1) diff --git a/src/sentry/replays/endpoints/organization_replay_selector_index.py b/src/sentry/replays/endpoints/organization_replay_selector_index.py index 909151fd7544..8d08646cbe32 100644 --- a/src/sentry/replays/endpoints/organization_replay_selector_index.py +++ b/src/sentry/replays/endpoints/organization_replay_selector_index.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from datetime import datetime from typing import Any, TypedDict @@ -85,10 +85,11 @@ class OrganizationReplaySelectorIndexEndpoint(OrganizationReplayEndpoint): "GET": ApiPublishStatus.PUBLIC, } - def get_replay_filter_params(self, request, organization): - filter_params = self.get_filter_params(request, organization) - - return filter_params + # justification: filter params merge validated query params of mixed types. + def get_replay_filter_params( + self, request: Request, organization: Organization + ) -> dict[str, Any]: # justification: replay query rows are decoded JSON. + return dict(self.get_filter_params(request, organization)) @extend_schema( operation_id="listOrganizationReplaySelectors", @@ -127,7 +128,9 @@ def get(self, request: Request, organization: Organization) -> Response[ReplaySe if key not in filter_params: filter_params[key] = value - def data_fn(offset, limit): + def data_fn( + offset: int, limit: int + ) -> list[dict[str, Any]]: # justification: replay query rows are decoded JSON. try: search_filters = parse_search_query(request.query_params.get("query", "")) except InvalidSearchQuery as e: @@ -158,12 +161,12 @@ def query_selector_collection( start: datetime, end: datetime, sort: str | None, - limit: str | None, - offset: str | None, - environment: list[str], + limit: int, + offset: int, + environment: list[str] | None, search_filters: Sequence[QueryToken], organization: Organization, -) -> dict: +) -> list[dict[str, Any]]: # justification: replay query rows are decoded JSON. """Query aggregated replay collection.""" if organization: tenant_ids = {"organization_id": organization.id} @@ -182,7 +185,10 @@ def query_selector_collection( sort=sort, tenant_ids=tenant_ids, ) - return response["data"] + data = response["data"] + if not isinstance(data, list): + raise TypeError("Replay selector query returned no data array") + return data def query_selector_dataset( @@ -190,11 +196,11 @@ def query_selector_dataset( start: datetime, end: datetime, search_filters: Sequence[QueryToken], - environment: list[str], + environment: list[str] | None, pagination: Paginators | None, sort: str | None, tenant_ids: dict[str, Any] | None = None, -): +) -> Mapping[str, Any]: # justification: replay query rows are decoded JSON. query_options = {} if pagination: @@ -319,8 +325,12 @@ def query_selector_dataset( def process_raw_response(response: list[dict[str, Any]]) -> list[dict[str, Any]]: """Process the response further into the expected output.""" - def make_selector_name(row) -> str: + def make_selector_name( + row: dict[str, Any], # justification: replay query rows are decoded JSON. + ) -> str: # justification: replay query rows are decoded JSON. selector = row["click_tag"] + if not isinstance(selector, str): + raise TypeError("Replay click tag is not a string") if row["click_id"]: selector = selector + f"#{row['click_id']}" diff --git a/src/sentry/replays/endpoints/project_replay_clicks_index.py b/src/sentry/replays/endpoints/project_replay_clicks_index.py index 0ed3b1484c01..cacd55f9dd76 100644 --- a/src/sentry/replays/endpoints/project_replay_clicks_index.py +++ b/src/sentry/replays/endpoints/project_replay_clicks_index.py @@ -2,8 +2,8 @@ import datetime import uuid -from collections.abc import Sequence -from typing import TypedDict +from collections.abc import Mapping, Sequence +from typing import Any, TypedDict from drf_spectacular.utils import extend_schema from rest_framework.exceptions import ParseError @@ -92,7 +92,9 @@ def get( replay_id = str(uuid.UUID(replay_id)) - def data_fn(offset, limit): + def data_fn( + offset: int, limit: int + ) -> Mapping[str, Any]: # justification: replay query rows are decoded JSON. try: search_filters = parse_search_query(request.query_params.get("query", "")) except InvalidSearchQuery as e: @@ -126,7 +128,7 @@ def query_replay_clicks( offset: int, search_filters: Sequence[QueryToken], organization_id: int, -): +) -> Mapping[str, Any]: # justification: replay query rows are decoded JSON. """Query replay clicks. This query is atypical in that it does not aggregate by replay_id and it is not exposed as a diff --git a/src/sentry/replays/endpoints/project_replay_details.py b/src/sentry/replays/endpoints/project_replay_details.py index 2967b73c1fbe..ff914b03f594 100644 --- a/src/sentry/replays/endpoints/project_replay_details.py +++ b/src/sentry/replays/endpoints/project_replay_details.py @@ -1,4 +1,5 @@ import uuid +from typing import Any, cast from drf_spectacular.utils import extend_schema from rest_framework.request import Request @@ -94,7 +95,8 @@ def get( ) replay_data = process_raw_response( - snuba_response, + # justification: Snuba replay rows are decoded JSON objects. + cast("list[dict[str, Any]]", snuba_response), fields=fields, ) diff --git a/src/sentry/replays/endpoints/project_replay_jobs_delete.py b/src/sentry/replays/endpoints/project_replay_jobs_delete.py index e4d7ac4352dc..07a445558f98 100644 --- a/src/sentry/replays/endpoints/project_replay_jobs_delete.py +++ b/src/sentry/replays/endpoints/project_replay_jobs_delete.py @@ -1,8 +1,10 @@ from __future__ import annotations -from datetime import timedelta -from typing import TypedDict +from collections.abc import Mapping +from datetime import datetime, timedelta +from typing import Any, TypedDict, cast +from django.contrib.auth.models import AnonymousUser from drf_spectacular.utils import extend_schema from rest_framework import serializers from rest_framework.request import Request @@ -21,10 +23,20 @@ from sentry.apidocs.parameters import GlobalParams, ReplayParams from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.project import Project from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint from sentry.replays.models import DeletionJobStatus, ReplayDeletionJobModel from sentry.replays.permissions import has_replay_permission from sentry.replays.tasks import run_bulk_replay_delete_job +from sentry.users.models.user import User +from sentry.users.services.user import RpcUser + + +class _ReplayDeletionJobCreateData(TypedDict): + rangeStart: datetime + rangeEnd: datetime + environments: list[str] + query: str | None class ReplayDeletionJobResponseData(TypedDict): @@ -55,18 +67,27 @@ class ReplayDeletionJobPermission(ProjectPermission): class ReplayDeletionJobSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): - return { - "id": obj.id, - "dateCreated": obj.date_added, - "dateUpdated": obj.date_updated, - "rangeStart": obj.range_start, - "rangeEnd": obj.range_end, - "environments": obj.environments, - "status": obj.status, - "query": obj.query, - "countDeleted": obj.offset, - } + def serialize( + self, + obj: ReplayDeletionJobModel, + attrs: Mapping[Any, Any], # justification: replay query rows are decoded JSON. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: replay query rows are decoded JSON. + ) -> ReplayDeletionJobResponseData: + return cast( + ReplayDeletionJobResponseData, + { + "id": obj.id, + "dateCreated": obj.date_added, + "dateUpdated": obj.date_updated, + "rangeStart": obj.range_start, + "rangeEnd": obj.range_end, + "environments": obj.environments, + "status": obj.status, + "query": obj.query, + "countDeleted": obj.offset, + }, + ) class ReplayDeletionJobCreateDataSerializer(serializers.Serializer): @@ -77,7 +98,7 @@ class ReplayDeletionJobCreateDataSerializer(serializers.Serializer): ) query = serializers.CharField(required=True, allow_blank=True, allow_null=True) - def validate(self, data): + def validate(self, data: _ReplayDeletionJobCreateData) -> _ReplayDeletionJobCreateData: if data["rangeStart"] >= data["rangeEnd"]: raise serializers.ValidationError("rangeStart must be before rangeEnd") if data["rangeEnd"] - data["rangeStart"] > timedelta(days=30): @@ -116,7 +137,7 @@ class ProjectReplayDeletionJobsIndexEndpoint(ProjectEndpoint): }, examples=ReplayExamples.GET_REPLAY_DELETION_JOBS, ) - def get(self, request: Request, project) -> Response[ReplayDeletionJobListResponse]: + def get(self, request: Request, project: Project) -> Response[ReplayDeletionJobListResponse]: """ Retrieve a collection of replay delete jobs. """ @@ -155,7 +176,7 @@ def get(self, request: Request, project) -> Response[ReplayDeletionJobListRespon examples=ReplayExamples.CREATE_REPLAY_DELETION_JOB, ) def post( - self, request: Request, project + self, request: Request, project: Project ) -> Response[ReplayDeletionJobDetailResponse] | Response[ValidationErrorResponse]: """ Create a new replay deletion job. @@ -225,7 +246,7 @@ class ProjectReplayDeletionJobDetailEndpoint(ProjectReplayEndpoint): examples=ReplayExamples.GET_REPLAY_DELETION_JOB, ) def get( - self, request: Request, project, job_id: int + self, request: Request, project: Project, job_id: int ) -> Response[ReplayDeletionJobDetailResponse]: """ Fetch a replay delete job instance. diff --git a/src/sentry/replays/endpoints/project_replay_recording_segment_details.py b/src/sentry/replays/endpoints/project_replay_recording_segment_details.py index a5dd0c86004c..e071cf7d5c35 100644 --- a/src/sentry/replays/endpoints/project_replay_recording_segment_details.py +++ b/src/sentry/replays/endpoints/project_replay_recording_segment_details.py @@ -15,6 +15,7 @@ from sentry.apidocs.parameters import GlobalParams, ReplayParams from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.project import Project from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint from sentry.replays.lib.storage import RecordingSegmentStorageMeta, make_recording_filename from sentry.replays.usecases.reader import download_segment, fetch_segment_metadata @@ -58,7 +59,7 @@ class ProjectReplayRecordingSegmentDetailsEndpoint(ProjectReplayEndpoint): examples=ReplayExamples.GET_REPLAY_SEGMENT, ) def get( - self, request: Request, project, replay_id, segment_id + self, request: Request, project: Project, replay_id: str, segment_id: str ) -> Response[_SegmentDetailsResponse] | Response[DetailResponse] | StreamingHttpResponse: """Return a replay recording segment.""" self.check_replay_access(request, project) diff --git a/src/sentry/replays/endpoints/project_replay_recording_segment_index.py b/src/sentry/replays/endpoints/project_replay_recording_segment_index.py index 3312307f79af..407bf9c540b0 100644 --- a/src/sentry/replays/endpoints/project_replay_recording_segment_index.py +++ b/src/sentry/replays/endpoints/project_replay_recording_segment_index.py @@ -1,5 +1,5 @@ import functools -from typing import Any +from typing import Any, cast from django.http import StreamingHttpResponse from drf_spectacular.utils import extend_schema @@ -19,6 +19,7 @@ from sentry.apidocs.examples.replay_examples import ReplayExamples from sentry.apidocs.parameters import CursorQueryParam, GlobalParams, ReplayParams, VisibilityParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.project import Project from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint from sentry.replays.lib.storage import storage from sentry.replays.usecases.reader import download_segments, fetch_segments_metadata @@ -31,7 +32,7 @@ class ProjectReplayRecordingSegmentIndexEndpoint(ProjectReplayEndpoint): "GET": ApiPublishStatus.PUBLIC, } - def __init__(self, **options) -> None: + def __init__(self, **options: object) -> None: storage.initialize_client() super().__init__(**options) @@ -57,7 +58,7 @@ def __init__(self, **options) -> None: examples=ReplayExamples.GET_REPLAY_SEGMENTS, ) def get( - self, request: Request, project, replay_id: str + self, request: Request, project: Project, replay_id: str ) -> Response[list[list[dict[str, Any]]]]: """Return a collection of replay recording segments.""" self.check_replay_access(request, project) @@ -65,7 +66,7 @@ def get( with handle_query_errors(): return self.paginate( request=request, - response_cls=StreamingHttpResponse, + response_cls=cast("type[Response[Any]]", StreamingHttpResponse), response_kwargs={"content_type": "application/json"}, paginator_cls=GenericOffsetPaginator, data_fn=functools.partial(fetch_segments_metadata, project.id, replay_id), diff --git a/src/sentry/replays/endpoints/project_replay_summary.py b/src/sentry/replays/endpoints/project_replay_summary.py index d8f29a429a51..071c95c031f5 100644 --- a/src/sentry/replays/endpoints/project_replay_summary.py +++ b/src/sentry/replays/endpoints/project_replay_summary.py @@ -1,5 +1,6 @@ import logging from datetime import datetime +from typing import Any, cast import orjson import sentry_sdk @@ -56,7 +57,7 @@ class ProjectReplaySummaryEndpoint(ProjectReplayEndpoint): } permission_classes = (ReplaySummaryPermission,) - def __init__(self, **kw) -> None: + def __init__(self, **kw: object) -> None: storage.initialize_client() # Trace sample rates for each method. Uses the default when not set (=0). self.sample_rate_post = options.get( @@ -283,7 +284,11 @@ def post(self, request: Request, project: Project, replay_id: str) -> Response: ) # Extract start and end times from the replay (pass None if missing or invalid). - replay = process_raw_response(snuba_response, fields=[])[0] + replay = process_raw_response( + # justification: Snuba replay rows are decoded JSON objects. + cast("list[dict[str, Any]]", snuba_response), + fields=[], + )[0] def validate_iso_timestamp(timestamp: str | None) -> str | None: """Validate that timestamp is a valid ISO format string, return None if invalid.""" diff --git a/src/sentry/replays/endpoints/project_replay_video_details.py b/src/sentry/replays/endpoints/project_replay_video_details.py index 67fd32fbec95..1a02db99af81 100644 --- a/src/sentry/replays/endpoints/project_replay_video_details.py +++ b/src/sentry/replays/endpoints/project_replay_video_details.py @@ -17,6 +17,7 @@ from sentry.apidocs.parameters import GlobalParams, ReplayParams from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.project import Project from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint from sentry.replays.lib.http import ( MalformedRangeHeader, @@ -55,7 +56,7 @@ class ProjectReplayVideoDetailsEndpoint(ProjectReplayEndpoint): examples=ReplayExamples.GET_REPLAY_VIDEO, ) def get( - self, request: Request, project, replay_id, segment_id + self, request: Request, project: Project, replay_id: str, segment_id: str ) -> Response[DetailResponse] | HttpResponseBase: """Return a replay video.""" self.check_replay_access(request, project) diff --git a/src/sentry/replays/lib/cache.py b/src/sentry/replays/lib/cache.py index 6f824c80bc71..4abf798ec42e 100644 --- a/src/sentry/replays/lib/cache.py +++ b/src/sentry/replays/lib/cache.py @@ -86,7 +86,7 @@ def __contains__(self, key: T) -> bool: else: return True - def __len__(self): + def __len__(self) -> int: return len(self.cache) def __delitem__(self, key: T) -> None: @@ -119,7 +119,7 @@ def __init__(self, fn: Callable[[T], U], cache: CacheProtocol[T, U]) -> None: def __contains__(self, key: T) -> bool: return key in self.cache - def __len__(self): + def __len__(self) -> int: return len(self.cache) def __delitem__(self, key: T) -> None: diff --git a/src/sentry/replays/lib/eap/snuba_transpiler.py b/src/sentry/replays/lib/eap/snuba_transpiler.py index 600f1caf0a8e..e52fe1072d2d 100644 --- a/src/sentry/replays/lib/eap/snuba_transpiler.py +++ b/src/sentry/replays/lib/eap/snuba_transpiler.py @@ -383,7 +383,7 @@ class Settings(TypedDict, total=False): """ -def execute_query(request: TraceItemTableRequest, referrer: str): +def execute_query(request: TraceItemTableRequest, referrer: str) -> TraceItemTableResponse: request_method = "POST" request_body = request.SerializeToString() request_url = "/rpc/EndpointTraceItemTable/v1" @@ -777,10 +777,12 @@ def aggregate_operator(op: Op) -> AggregationComparisonFilter.Op.ValueType: def label(expr: Column | CurriedFunction | Function | ScalarType) -> str: if isinstance(expr, Column): - return expr.name + # justification: snuba_sdk is exempt from typing. + return cast("str", expr.name) elif isinstance(expr, (CurriedFunction, Function)): if expr.alias: - return expr.alias + # justification: snuba_sdk is exempt from typing. + return cast("str", expr.alias) else: return f"{expr.function}({', '.join(label(p) for p in expr.parameters)})" elif isinstance(expr, (date, datetime)): diff --git a/src/sentry/replays/lib/kafka.py b/src/sentry/replays/lib/kafka.py index bf750cb6010d..79f28bb6f673 100644 --- a/src/sentry/replays/lib/kafka.py +++ b/src/sentry/replays/lib/kafka.py @@ -1,6 +1,6 @@ from functools import partial -from arroyo.backends.kafka import FutureTrackingProducer, KafkaPayload +from arroyo.backends.kafka import FutureTrackingProducer, KafkaPayload, KafkaProducer from arroyo.types import Topic as ArroyoTopic from sentry_kafka_schemas.codecs import Codec from sentry_protos.snuba.v1.trace_item_pb2 import TraceItem @@ -39,7 +39,7 @@ def _in_process_replay_recording_task() -> bool: return task is not None and task.taskname == PROCESS_REPLAY_RECORDING_TASK_NAME -def _get_eap_items_producer(name: str = "sentry.replays.lib.kafka.eap_items"): +def _get_eap_items_producer(name: str = "sentry.replays.lib.kafka.eap_items") -> KafkaProducer: """Get a Kafka producer for EAP TraceItems.""" return get_arroyo_producer( name=name, @@ -74,7 +74,9 @@ def write_trace_items(trace_items: list[TraceItem]) -> None: # -def _get_ingest_replay_events_producer(name: str = "sentry.replays.lib.kafka.ingest_replay_events"): +def _get_ingest_replay_events_producer( + name: str = "sentry.replays.lib.kafka.ingest_replay_events", +) -> KafkaProducer: return get_arroyo_producer( name=name, topic=Topic.INGEST_REPLAY_EVENTS, diff --git a/src/sentry/replays/lib/query.py b/src/sentry/replays/lib/query.py index ae57c15a3f79..0510ed108a18 100644 --- a/src/sentry/replays/lib/query.py +++ b/src/sentry/replays/lib/query.py @@ -11,7 +11,7 @@ def attempt_compressed_condition( result: list[Expression], condition: Condition, condition_type: And | Or, -): +) -> None: """Unnecessary query optimization. Improves legibility for query debugging. Clickhouse would flatten these nested OR statements @@ -58,7 +58,7 @@ def _bitmask_on_tag_key(key: str, tag_key_column: Column) -> Function: ) -def _wildcard_search_function(value, identifier): +def _wildcard_search_function(value: str, identifier: Expression) -> Function: # XXX: We don't want the '^$' values at the beginning and end of # the regex since we want to find the pattern anywhere in the # message. Strip off here diff --git a/src/sentry/replays/lib/storage.py b/src/sentry/replays/lib/storage.py index 9193524290af..50b92c1783f1 100644 --- a/src/sentry/replays/lib/storage.py +++ b/src/sentry/replays/lib/storage.py @@ -108,7 +108,10 @@ def delete(self, segment: RecordingSegmentStorageMeta) -> None: @metrics.wraps("replays.lib.storage.FilestoreBlob.get") def get(self, segment: RecordingSegmentStorageMeta) -> bytes: - return self._segment_file(segment).getfile().read() + content = self._segment_file(segment).getfile().read() + if not isinstance(content, bytes): + raise TypeError("Replay segment read returned no bytes") + return content @metrics.wraps("replays.lib.storage.FilestoreBlob.set") def set(self, segment: RecordingSegmentStorageMeta, value: bytes) -> None: @@ -172,8 +175,8 @@ def get(self, segment: RecordingSegmentStorageMeta) -> bytes | None: def set(self, segment: RecordingSegmentStorageMeta, value: bytes) -> None: return storage_kv.set(self.make_key(segment), value) - def initialize_client(self): - return storage_kv.initialize_client() + def initialize_client(self) -> None: + storage_kv.initialize_client() def make_key(self, segment: RecordingSegmentStorageMeta) -> str: return _make_recording_filename( @@ -199,7 +202,7 @@ def get(self, key: str) -> bytes | None: logger.warning("Storage GET error: %s", repr(e)) return None else: - return result + return result if isinstance(result, bytes) else None @metrics.wraps("replays.lib.storage.SimpleStorageBlob.set") def set(self, key: str, value: bytes) -> None: @@ -217,7 +220,7 @@ def delete(self, key: str) -> None: with measure_storage_operation("delete", "replays"): storage.delete(key) - def initialize_client(self): + def initialize_client(self) -> None: storage = get_storage(self._make_storage_options()) # acccess the storage client so it is initialized below. # this will prevent race condition parallel credential getting during segment download @@ -226,7 +229,7 @@ def initialize_client(self): if hasattr(storage, "client"): storage.client - def _make_storage_options(self) -> dict | None: + def _make_storage_options(self) -> dict[str, object] | None: backend = options.get("replay.storage.backend") if backend: return {"backend": backend, "options": options.get("replay.storage.options")} diff --git a/src/sentry/replays/models.py b/src/sentry/replays/models.py index eb3c8be947a5..c0a7bcf39d0c 100644 --- a/src/sentry/replays/models.py +++ b/src/sentry/replays/models.py @@ -51,7 +51,7 @@ class ReplayRecordingSegment(Model): file_id = BoundedBigIntegerField(db_index=True) segment_id = BoundedIntegerField(db_column="sequence_id") date_added = models.DateTimeField(default=timezone.now, db_index=True) - size = BoundedPositiveIntegerField(null=True) + size = BoundedPositiveIntegerField[int | None, int | None](null=True) class Meta: app_label = "replays" @@ -63,7 +63,9 @@ class Meta: __repr__ = sane_repr("replay_id", "segment_id", "file_id") - def delete(self, *args, **kwargs): + def delete( + self, using: object | None = None, keep_parents: bool = False + ) -> tuple[int, dict[str, int]]: from sentry.models.files.file import File try: @@ -77,7 +79,7 @@ def delete(self, *args, **kwargs): else: file.delete() - rv = super().delete(*args, **kwargs) + rv = super().delete(using=using, keep_parents=keep_parents) return rv diff --git a/src/sentry/replays/post_process.py b/src/sentry/replays/post_process.py index 8b2b49672edf..71ed2da8561f 100644 --- a/src/sentry/replays/post_process.py +++ b/src/sentry/replays/post_process.py @@ -66,7 +66,7 @@ class ReplayDetailsResponse(TypedDict, total=False): trace_ids: list[str] error_ids: list[str] environment: str | None - tags: dict[str, list[str]] | list + tags: dict[str, list[str]] | list[str] user: UserResponseType sdk: SDKResponseType os: OSResponseType diff --git a/src/sentry/replays/query.py b/src/sentry/replays/query.py index 03f84ce47e26..8045933e43f0 100644 --- a/src/sentry/replays/query.py +++ b/src/sentry/replays/query.py @@ -1,8 +1,8 @@ from __future__ import annotations -from collections.abc import Generator, Sequence +from collections.abc import Generator, Mapping, Sequence from datetime import UTC, datetime, timedelta -from typing import Any, Literal +from typing import Any, Literal, cast from snuba_sdk import ( Column, @@ -30,6 +30,7 @@ PREFERRED_SOURCE, VIEWED_BY_DENYLIST_MSG, Paginators, + QueryResponse, execute_query, make_full_aggregation_query, query_using_optimized_search, @@ -37,6 +38,7 @@ from sentry.search.events.types import SnubaParams from sentry.snuba.referrer import Referrer from sentry.snuba.utils import get_dataset +from sentry.types.actor import Actor from sentry.utils.snuba import raw_snql_query MAX_PAGE_SIZE = 100 @@ -47,8 +49,36 @@ # Compatibility function for getsentry code. -def query_replays_collection(*args, **kwargs): - return query_replays_collection_paginated(*args, **kwargs).response +def query_replays_collection( + project_ids: list[int], + start: datetime, + end: datetime, + environment: list[str], + fields: list[str], + sort: str | None, + limit: int, + offset: int, + search_filters: Sequence[QueryToken], + preferred_source: PREFERRED_SOURCE, + organization: Organization | None = None, + actor: Actor | None = None, + referrer: str | None = None, +) -> list[Any]: + return query_replays_collection_paginated( + project_ids=project_ids, + start=start, + end=end, + environment=environment, + fields=fields, + sort=sort, + limit=limit, + offset=offset, + search_filters=search_filters, + preferred_source=preferred_source, + organization=organization, + actor=actor, + referrer=referrer, + ).response def query_replays_collection_paginated( @@ -63,9 +93,9 @@ def query_replays_collection_paginated( search_filters: Sequence[QueryToken], preferred_source: PREFERRED_SOURCE, organization: Organization | None = None, - actor: Any | None = None, + actor: Actor | None = None, referrer: str | None = None, -): +) -> QueryResponse: """Query aggregated replay collection.""" paginators = Paginators(limit, offset) @@ -92,25 +122,29 @@ def query_replay_instance( end: datetime, organization: Organization | None = None, request_user_id: int | None = None, -): +) -> list[object]: """Query aggregated replay instance.""" if isinstance(project_id, list): project_ids = project_id else: project_ids = [project_id] - return execute_query( - query=make_full_aggregation_query( - fields=[], - replay_ids=[replay_id], - project_ids=project_ids, - period_start=start, - period_end=end, - request_user_id=request_user_id, - ), - tenant_id={"organization_id": organization.id} if organization else {}, - referrer="replays.query.details_query", - )["data"] + # justification: the replay snuba client is untyped. + return cast( + "list[object]", + execute_query( + query=make_full_aggregation_query( + fields=[], + replay_ids=[replay_id], + project_ids=project_ids, + period_start=start, + period_end=end, + request_user_id=request_user_id, + ), + tenant_id={"organization_id": organization.id} if organization else {}, + referrer="replays.query.details_query", + )["data"], + ) def query_replay_id_by_prefix( @@ -201,18 +235,22 @@ def query_replay_viewed_by_ids( if project_id in options.get("replay.viewed-by.project-denylist"): raise BadRequest(message=VIEWED_BY_DENYLIST_MSG) - return execute_query( - query=make_full_aggregation_query( - fields=["viewed_by_ids"], - replay_ids=[replay_id], - project_ids=project_ids, - period_start=start, - period_end=end, - request_user_id=request_user_id, - ), - tenant_id={"organization_id": organization.id} if organization else {}, - referrer="replays.query.viewed_by_query", - )["data"] + # justification: the replay snuba client is untyped. + return cast( + "list[dict[str, Any]]", + execute_query( + query=make_full_aggregation_query( + fields=["viewed_by_ids"], + replay_ids=[replay_id], + project_ids=project_ids, + period_start=start, + period_end=end, + request_user_id=request_user_id, + ), + tenant_id={"organization_id": organization.id} if organization else {}, + referrer="replays.query.viewed_by_query", + )["data"], + ) def query_replays_count( @@ -221,7 +259,7 @@ def query_replays_count( end: datetime, replay_ids: list[str], tenant_ids: dict[str, Any], -): +) -> Mapping[str, Any]: snuba_request = Request( dataset="replays", app_id="replay-backend-web", @@ -276,7 +314,7 @@ def query_replays_dataset_tagkey_values( tag_key: str, tag_substr_query: str | None, tenant_ids: dict[str, Any] | None, -): +) -> Mapping[str, Any]: """ Query replay tagkey values. Like our other tag functionality, aggregates do not work here. This function is used by the tagstore backend, which expects a `tag_value` key in each result object. @@ -383,7 +421,7 @@ def anyIf( ) -def _sorted_aggregated_urls(agg_urls_column, alias): +def _sorted_aggregated_urls(agg_urls_column: Expression, alias: str | None) -> Expression: mapped_urls = Function( "arrayMap", parameters=[ @@ -472,7 +510,7 @@ def _strip_uuid_dashes( input_value: Expression, alias: str | None = None, aliased: bool = True, -): +) -> Expression: return Function( "replaceAll", parameters=[Function("toString", parameters=[input_value]), "-", ""], @@ -480,7 +518,7 @@ def _strip_uuid_dashes( ) -def _activity_score(): +def _activity_score() -> Expression: # taken from frontend calculation: # score = (count_errors * 25 + pagesVisited * 5 ) / 10; # score = Math.floor(Math.min(10, Math.max(1, score))); @@ -525,7 +563,7 @@ def _activity_score(): ) -def _collect_event_ids(alias, ids_type_list): +def _collect_event_ids(alias: str, ids_type_list: Sequence[str]) -> Expression: id_types_to_aggregate = [] for id_type in ids_type_list: id_types_to_aggregate.append(_filter_empty_uuids(id_type)) @@ -547,8 +585,8 @@ def _collect_event_ids(alias, ids_type_list): ) -def _collect_new_errors(): - def _collect_non_empty_error_and_fatals(): +def _collect_new_errors() -> Expression: + def _collect_non_empty_error_and_fatals() -> list[Expression]: return [ _filter_empty_uuids("error_id"), _filter_empty_uuids("fatal_id"), @@ -584,8 +622,8 @@ def _collect_non_empty_error_and_fatals(): ) -def _filter_empty_uuids(column_name): - def _empty_uuids_lambda(): +def _filter_empty_uuids(column_name: str) -> Expression: + def _empty_uuids_lambda() -> Lambda: return Lambda( ["id"], Function( @@ -1043,7 +1081,8 @@ def query_trace_connected_events( if dataset is None: raise ValueError(f"Unknown dataset: {dataset_label}") - return dataset.query(**query_details) + # justification: the dataset implementation is selected at runtime and returns a JSON payload. + return cast("dict[str, Any]", dataset.query(**query_details)) def get_replay_range( diff --git a/src/sentry/replays/testutils.py b/src/sentry/replays/testutils.py index 3517827f7e31..35eddcf2df20 100644 --- a/src/sentry/replays/testutils.py +++ b/src/sentry/replays/testutils.py @@ -3,7 +3,7 @@ import datetime import uuid from enum import Enum -from typing import Any +from typing import Any, cast # This __must__ match the EventType enum in RRWeb, for the version of rrweb that we are using. @@ -33,7 +33,7 @@ def ms(timestamp: datetime.datetime) -> int: def assert_viewed_by_expected_ids_and_unique( viewed_by: list[dict[str, Any]], expected_ids: set[int] -): +) -> None: seen = set() for user_dict in viewed_by: id = int(user_dict["id"]) @@ -150,9 +150,9 @@ def mock_replay( replay_id: str, **kwargs: Any, ) -> dict[str, Any]: - tags = kwargs.pop("tags", {}) + tags = cast("dict[str, object]", kwargs.pop("tags", {})) tags.update({"transaction": kwargs.pop("title", "Title")}) - tags = [[key, value] for key, value in tags.items()] + tags_list = [[key, value] for key, value in tags.items()] return { "type": "replay_event", @@ -165,7 +165,7 @@ def mock_replay( "replay_id": replay_id, "replay_type": kwargs.pop("replay_type", "session"), "segment_id": kwargs.pop("segment_id", 0), - "tags": tags, + "tags": tags_list, "urls": kwargs.pop("urls", []), "segment_names": kwargs.pop("segment_names", []), "is_archived": kwargs.pop("is_archived", None), @@ -514,20 +514,22 @@ def mock_rrweb_div_helloworld() -> RRWebNode: ) -def mock_replay_event(replay_id="b58a67446c914f44a4e329763420047b", **kwargs): +def mock_replay_event( + replay_id: str = "b58a67446c914f44a4e329763420047b", **kwargs: object +) -> dict[str, object]: """ mock a replay event for useage in our recording consumer tests """ timestamp = datetime.datetime.now() - datetime.timedelta(minutes=10) - tags = kwargs.pop("tags", {}) + tags = cast("dict[str, object]", kwargs.pop("tags", {})) tags.update({"transaction": kwargs.pop("title", "Title")}) - tags = [[key, value] for key, value in tags.items()] + tags_list = [[key, value] for key, value in tags.items()] return { "type": "replay_event", "replay_id": replay_id, "replay_type": kwargs.pop("replay_type", "session"), "segment_id": kwargs.pop("segment_id", 0), - "tags": tags, + "tags": tags_list, "urls": kwargs.pop("urls", []), "segment_names": kwargs.pop("segment_names", []), "is_archived": kwargs.pop("is_archived", None), diff --git a/src/sentry/replays/usecases/ingest/__init__.py b/src/sentry/replays/usecases/ingest/__init__.py index 170e27acc601..01bcfe973f97 100644 --- a/src/sentry/replays/usecases/ingest/__init__.py +++ b/src/sentry/replays/usecases/ingest/__init__.py @@ -94,7 +94,7 @@ class CustomEvent(msgspec.Struct, gc=False, tag_field="type", tag=5): ) -def parse_recording_data(payload: bytes) -> list[dict]: +def parse_recording_data(payload: bytes) -> list[dict[str, Any]]: # justification: decoded JSON. try: # We're parsing with msgspec (if we can) and then transforming to the type that # JSON.loads returns. @@ -108,7 +108,10 @@ def parse_recording_data(payload: bytes) -> list[dict]: # prod. We don't want a huge volume of logs slowing throughput. If there's a # significant volume of this metric we'll test against a broader cohort of data. metrics.incr("replays.recording_consumer.msgspec_decode_error") - return json.loads(payload) + decoded = json.loads(payload) + if not isinstance(decoded, list): + raise TypeError("Replay recording payload is not a JSON array") + return decoded class DropEvent(Exception): @@ -152,6 +155,7 @@ def process_recording_event( message: Event, use_new_recording_parser: bool = False ) -> ProcessedEvent: parsed_output = parse_replay_events(message, use_new_recording_parser) + trace_items: list[TraceItem] if parsed_output: replay_events, trace_items = parsed_output else: @@ -185,7 +189,9 @@ def process_recording_event( ) -def parse_replay_events(message: Event, use_new_recording_parser: bool): +def parse_replay_events( + message: Event, use_new_recording_parser: bool +) -> tuple[ParsedEventMeta, list[TraceItem]] | None: try: if use_new_recording_parser: events = parse_recording_data(message["payload"]) @@ -282,7 +288,7 @@ def extract_user_info(replay_event: dict[str, Any] | None) -> dict[str, str | No @traces.trace -def pack_replay_video(recording: bytes, video: bytes): +def pack_replay_video(recording: bytes, video: bytes) -> bytes: return zlib.compress(pack(rrweb=recording, video=video)) @@ -382,7 +388,7 @@ def emit_replay_events( def _track_initial_segment_event_old( org_id: int, project_id: int, - replay_id, + replay_id: str, key_id: int | None, received: int, ) -> None: @@ -419,7 +425,7 @@ def _track_initial_segment_event_old( def _track_initial_segment_event_new( org_id: int, project_id: int, - replay_id, + replay_id: str, key_id: int | None, received: int, has_seen_replays: AutoCache[int, bool], diff --git a/src/sentry/replays/usecases/ingest/cache.py b/src/sentry/replays/usecases/ingest/cache.py index 5c32e220322b..c25fef3c45da 100644 --- a/src/sentry/replays/usecases/ingest/cache.py +++ b/src/sentry/replays/usecases/ingest/cache.py @@ -41,9 +41,9 @@ def _option_lookup(project_id: int) -> tuple[bool, bool]: ) -def make_has_sent_replays_cache(): +def make_has_sent_replays_cache() -> AutoCache[int, bool]: return AutoCache(_has_replays_lookup, BoundedLRUCache(maxlen=10_000)) -def make_options_cache(): +def make_options_cache() -> AutoCache[int, tuple[bool, bool]]: return AutoCache(_option_lookup, BoundedLRUCache(maxlen=10_000)) diff --git a/src/sentry/replays/usecases/ingest/event_logger.py b/src/sentry/replays/usecases/ingest/event_logger.py index 42c21c87b755..91eed2ebed27 100644 --- a/src/sentry/replays/usecases/ingest/event_logger.py +++ b/src/sentry/replays/usecases/ingest/event_logger.py @@ -450,10 +450,12 @@ def _should_report_hydration_error_issue(project_id: int, context: ProcessorCont if context["options_cache"]: return context["options_cache"][project_id][0] else: - return ProjectOption.objects.get_value( - project_id, - "sentry:replay_hydration_error_issues", - default=True, + return bool( + ProjectOption.objects.get_value( + project_id, + "sentry:replay_hydration_error_issues", + default=True, + ) ) @@ -465,10 +467,12 @@ def _should_report_rage_click_issue(project_id: int, context: ProcessorContext) if context["options_cache"]: return context["options_cache"][project_id][1] else: - return ProjectOption.objects.get_value( - project_id, - "sentry:replay_rage_click_issues", - default=True, + return bool( + ProjectOption.objects.get_value( + project_id, + "sentry:replay_rage_click_issues", + default=True, + ) ) diff --git a/src/sentry/replays/usecases/ingest/issue_creation.py b/src/sentry/replays/usecases/ingest/issue_creation.py index 2081971bbc57..ffea0fb0cf35 100644 --- a/src/sentry/replays/usecases/ingest/issue_creation.py +++ b/src/sentry/replays/usecases/ingest/issue_creation.py @@ -25,7 +25,7 @@ def report_hydration_error_issue_with_replay_event( timestamp: float, url: str | None, replay_event: dict[str, Any], -): +) -> None: metrics.incr("replay.hydration_error_issue_creation_with_replay_event") # Seconds since epoch is UTC. @@ -66,7 +66,7 @@ def report_rage_click_issue_with_replay_event( node: dict[str, Any], component_name: str | None, replay_event: dict[str, Any], -): +) -> None: metrics.incr("replay.rage_click_issue_creation_with_replay_event") # Seconds since epoch is UTC. @@ -128,7 +128,10 @@ def report_rage_click_issue_with_replay_event( ) -def _make_contexts(replay_id, replay_event): +def _make_contexts( + replay_id: str, + replay_event: dict[str, Any], # justification: decoded replay payload. +) -> dict[str, Any]: contexts = {"replay": {"replay_id": replay_id}} if replay_event.get("trace_ids"): @@ -146,7 +149,11 @@ def _make_contexts(replay_id, replay_event): return contexts -def _make_tags(replay_id, url, replay_event): +def _make_tags( + replay_id: str, + url: str | None, + replay_event: dict[str, Any], # justification: decoded replay payload. +) -> dict[str, Any]: tags = {"replayId": replay_id, "url": url} if replay_event.get("tags"): tags.update(replay_event["tags"]) @@ -154,7 +161,7 @@ def _make_tags(replay_id, url, replay_event): return tags -def _make_clicked_element(node): +def _make_clicked_element(node: dict[str, Any]) -> str: # justification: decoded replay payload. element = node.get("tagName", "") if "attributes" in node: for key, value in node["attributes"].items(): @@ -176,4 +183,4 @@ def _make_clicked_element(node): elif key == "data-sentry-component": element += f'[data-sentry-component="{value}"]' - return element + return element if isinstance(element, str) else "" diff --git a/src/sentry/replays/usecases/pack.py b/src/sentry/replays/usecases/pack.py index c23a59c58fd2..395455fa045d 100644 --- a/src/sentry/replays/usecases/pack.py +++ b/src/sentry/replays/usecases/pack.py @@ -28,7 +28,7 @@ class Encoding(Enum): def pack(rrweb: bytes, video: bytes | None) -> bytes: - def _to_uint_bytes(length): + def _to_uint_bytes(length: int) -> bytes: return bytes([(length >> (i * 8)) & 0xFF for i in range(USIZE - 1, -1, -1)]) if video is None: @@ -37,7 +37,7 @@ def _to_uint_bytes(length): return b"\x01" + _to_uint_bytes(len(video)) + video + rrweb -def unpack(obj: bytes): +def unpack(obj: bytes) -> tuple[memoryview | None, memoryview]: mv = memoryview(obj) if mv[0] == 91: # Not packed. return (None, mv) diff --git a/src/sentry/replays/usecases/query/__init__.py b/src/sentry/replays/usecases/query/__init__.py index ed04159b9f5c..4381a36e68a4 100644 --- a/src/sentry/replays/usecases/query/__init__.py +++ b/src/sentry/replays/usecases/query/__init__.py @@ -181,7 +181,7 @@ def attempt_compressed_condition( result: list[Expression], condition: Condition, condition_type: And | Or, -): +) -> None: """Unnecessary query optimization. Improves legibility for query debugging. Clickhouse would flatten these nested OR statements @@ -258,7 +258,7 @@ def query_using_optimized_search( request_user_id: int | None = None, preferred_source: PREFERRED_SOURCE = "scalar", referrer: str | None = None, -): +) -> QueryResponse: tenant_id = _make_tenant_id(organization) # Environments is provided to us outside of the ?query= url parameter. It's stil filtered like @@ -349,7 +349,7 @@ def _query_using_scalar_strategy( project_ids: list[int], period_start: datetime, period_stop: datetime, -): +) -> tuple[Query, str, str]: can_scalar_search = can_scalar_search_subquery(search_filters, period_start) can_scalar_sort = sort_is_scalar_compatible(sort or DEFAULT_SORT_FIELD) if not can_scalar_search or not can_scalar_sort: @@ -407,7 +407,7 @@ def _query_using_aggregated_strategy( project_ids: list[int], period_start: datetime, period_stop: datetime, -): +) -> tuple[Query, str, str]: orderby = handle_ordering( agg_sort_config, sort or "-" + DEFAULT_SORT_FIELD, diff --git a/src/sentry/replays/usecases/query/conditions/event_ids.py b/src/sentry/replays/usecases/query/conditions/event_ids.py index e4adba1c8359..331eba7313a0 100644 --- a/src/sentry/replays/usecases/query/conditions/event_ids.py +++ b/src/sentry/replays/usecases/query/conditions/event_ids.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Sequence from uuid import UUID from snuba_sdk import Column, Condition, Function, Op @@ -172,7 +173,9 @@ def visit_not_in(value: list[UUID]) -> Condition: return does_not_contain(InfoIdScalar.visit_in(value)) -def _make_conditions_from_column_names(event_id_columns, operator, value): +def _make_conditions_from_column_names( + event_id_columns: Sequence[str], operator: Op, value: UUID | list[str] +) -> list[Condition]: return [ translate_condition_to_function(Condition(Column(column_name), operator, value)) for column_name in event_id_columns diff --git a/src/sentry/replays/usecases/query/configs/aggregate_sort.py b/src/sentry/replays/usecases/query/configs/aggregate_sort.py index 842bc8563405..a17e8b520bc1 100644 --- a/src/sentry/replays/usecases/query/configs/aggregate_sort.py +++ b/src/sentry/replays/usecases/query/configs/aggregate_sort.py @@ -13,7 +13,7 @@ from sentry.replays.usecases.query.conditions.activity import aggregate_activity -def any_if(column_name): +def any_if(column_name: str) -> Function: return Function( "anyIf", parameters=[Column(column_name), Function("notEmpty", [Column(column_name)])] ) diff --git a/src/sentry/replays/usecases/reader.py b/src/sentry/replays/usecases/reader.py index 94690583a4e9..38926d6dd4af 100644 --- a/src/sentry/replays/usecases/reader.py +++ b/src/sentry/replays/usecases/reader.py @@ -4,7 +4,7 @@ import zlib from collections.abc import Generator, Iterator from datetime import datetime, timedelta -from typing import Any +from typing import Any, TypedDict from django.conf import settings from django.db.models import Prefetch @@ -34,6 +34,12 @@ # METADATA QUERY BEHAVIOR. +class _SegmentRow(TypedDict): + segment_id: int + retention_days: int + timestamp: str + + @traces.trace def fetch_segments_metadata( project_id: int, @@ -234,7 +240,7 @@ def _fetch_segments_from_snuba( def segment_row_to_storage_meta( project_id: int, replay_id: str, - row, + row: _SegmentRow, ) -> RecordingSegmentStorageMeta: return RecordingSegmentStorageMeta( project_id=project_id, diff --git a/src/sentry/replays/usecases/summarize.py b/src/sentry/replays/usecases/summarize.py index 94516a29becb..a96571d8216f 100644 --- a/src/sentry/replays/usecases/summarize.py +++ b/src/sentry/replays/usecases/summarize.py @@ -1,7 +1,7 @@ import logging from collections.abc import Generator, Iterator from datetime import datetime, timedelta -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from urllib.parse import urlparse import sentry_sdk @@ -199,7 +199,7 @@ def fetch_trace_connected_errors( @traces.trace -def fetch_feedback_details(feedback_id: str | None, project_id) -> EventDict | None: +def fetch_feedback_details(feedback_id: str | None, project_id: int) -> EventDict | None: """ Fetch user feedback associated with a specific feedback event ID. """ @@ -270,7 +270,7 @@ def _format_when(timestamp: float | None) -> str: def generate_summary_logs( segment_data: Iterator[tuple[int, memoryview]], error_events: list[EventDict], - project_id, + project_id: int, is_mobile_replay: bool = False, replay_start: str | None = None, ) -> Generator[str]: @@ -581,7 +581,8 @@ def rpc_get_replay_summary_logs( request_user_id=None, # This is for the viewed_by_me field which is unused for summaries. ) processed_response = process_raw_response( - snuba_response, + # justification: Snuba replay rows are decoded JSON objects. + cast("list[dict[str, Any]]", snuba_response), fields=[], # Defaults to all fields. ) diff --git a/src/sentry/reprocessing2.py b/src/sentry/reprocessing2.py index b6f4b8ba63dc..2e461d5b2f46 100644 --- a/src/sentry/reprocessing2.py +++ b/src/sentry/reprocessing2.py @@ -290,11 +290,15 @@ def reprocess_event(project_id: int, event_id: str, start_time: float) -> None: def get_original_group_id(event: Event) -> int: - return event.data["contexts"]["reprocessing"]["original_issue_id"] + group_id = get_path(event.data, "contexts", "reprocessing", "original_issue_id") + if not isinstance(group_id, int): + raise ValueError("Event is missing its original issue id") + return group_id def get_original_primary_hash(event: Event) -> str | None: - return get_path(event.data, "contexts", "reprocessing", "original_primary_hash") + primary_hash = get_path(event.data, "contexts", "reprocessing", "original_primary_hash") + return primary_hash if isinstance(primary_hash, str) else None def _send_delete_old_primary_hash_messages( @@ -511,7 +515,8 @@ def is_reprocessed_event(data: Mapping[str, Any]) -> bool: def _get_original_issue_id(data: Mapping[str, Any]) -> int | None: - return get_path(data, "contexts", "reprocessing", "original_issue_id") + issue_id = get_path(data, "contexts", "reprocessing", "original_issue_id") + return issue_id if isinstance(issue_id, int) else None def buffered_handle_remaining_events( @@ -645,7 +650,8 @@ def start_group_reprocessing( # Create a duplicate row that has the same attributes by nulling out # the primary key and saving - group.pk = group.id = None # XXX: intentional resetting pk + setattr(group, "pk", None) # XXX: intentional resetting pk + setattr(group, "id", None) new_group = group # rename variable just to avoid confusion del group diff --git a/src/sentry/rules/actions/integrations/create_ticket/utils.py b/src/sentry/rules/actions/integrations/create_ticket/utils.py index 781ce79c87eb..b66663dbab97 100644 --- a/src/sentry/rules/actions/integrations/create_ticket/utils.py +++ b/src/sentry/rules/actions/integrations/create_ticket/utils.py @@ -1,11 +1,9 @@ from __future__ import annotations import logging -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from typing import Any -from rest_framework.response import Response - from sentry.constants import ObjectStatus from sentry.exceptions import InvalidIdentity from sentry.integrations.base import IntegrationInstallation @@ -42,7 +40,7 @@ def create_link( integration: RpcIntegration, installation: IntegrationInstallation, event: GroupEvent, - response: Response, + response: Mapping[str, Any], # justification: issue payload is decoded JSON. ) -> None: """ After creating the event on a third-party service, create a link to the diff --git a/src/sentry/rules/actions/notify_event_service.py b/src/sentry/rules/actions/notify_event_service.py index 4cd4a3f59cf8..4570c2028955 100644 --- a/src/sentry/rules/actions/notify_event_service.py +++ b/src/sentry/rules/actions/notify_event_service.py @@ -32,7 +32,7 @@ def build_incident_attachment( incident_serialized_response: IncidentSerializerResponse, organization: Organization, notification_uuid: str | None = None, -) -> dict[str, str]: +) -> dict[str, object]: from sentry.api.serializers.rest_framework.base import ( camel_to_snake_case, convert_dict_key_case, diff --git a/src/sentry/rules/base.py b/src/sentry/rules/base.py index 97eacc216f80..94d972ea3edb 100644 --- a/src/sentry/rules/base.py +++ b/src/sentry/rules/base.py @@ -2,8 +2,8 @@ import abc import logging -from collections.abc import Callable, MutableMapping, Sequence -from typing import Any, ClassVar, NamedTuple +from collections.abc import MutableMapping, Sequence +from typing import Any, ClassVar, NamedTuple, Protocol from sentry.models.project import Project from sentry.notifications.types import NotificationActionContext, RuleFuture @@ -43,8 +43,12 @@ # Encapsulates a reference to the callback, including arguments. The `key` # attribute may be specifically used to key the callbacks when they are # collated during rule processing. +class RuleCallback(Protocol): + def __call__(self, event: GroupEvent, futures: Sequence[RuleFuture]) -> None: ... + + class CallbackFuture(NamedTuple): - callback: Callable[[GroupEvent, Sequence[RuleFuture]], None] + callback: RuleCallback kwargs: dict[str, Any] key: str | None @@ -78,7 +82,7 @@ def render_label(self) -> str: def future( self, - callback: Callable[[GroupEvent, Sequence[RuleFuture]], None], + callback: RuleCallback, key: str | None = None, **kwargs: Any, ) -> CallbackFuture: diff --git a/src/sentry/rules/filters/issue_type.py b/src/sentry/rules/filters/issue_type.py index e0d9c7df68f4..7c31372bec96 100644 --- a/src/sentry/rules/filters/issue_type.py +++ b/src/sentry/rules/filters/issue_type.py @@ -24,7 +24,7 @@ class IssueTypeFilter(EventFilter): prompt = "The issue's type is ..." @property - def form_fields(self) -> dict: + def form_fields(self) -> dict[str, object]: return { "include": { "type": "choice", diff --git a/src/sentry/rules/processing/processor.py b/src/sentry/rules/processing/processor.py index 2356156174c9..59f427da0128 100644 --- a/src/sentry/rules/processing/processor.py +++ b/src/sentry/rules/processing/processor.py @@ -23,11 +23,15 @@ def get_rule_type(condition: Mapping[str, Any]) -> str | None: return rule_type +# justification: rule conditions are decoded from rule configuration JSON. +RuleCondition = Mapping[str, Any] + + def split_conditions_and_filters( - rule_condition_list, -) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - condition_list = [] - filter_list = [] + rule_condition_list: Sequence[RuleCondition], +) -> tuple[list[RuleCondition], list[RuleCondition]]: + condition_list: list[RuleCondition] = [] + filter_list: list[RuleCondition] = [] for rule_cond in rule_condition_list: if get_rule_type(rule_cond) == "condition/event": condition_list.append(rule_cond) diff --git a/src/sentry/runner/commands/backup.py b/src/sentry/runner/commands/backup.py index 8461ae16b9b6..c99cac1d5466 100644 --- a/src/sentry/runner/commands/backup.py +++ b/src/sentry/runner/commands/backup.py @@ -395,7 +395,7 @@ def compare( # the way. def load_data( side: Side, src: IO[bytes], decrypt_with: IO[bytes], decrypt_with_gcp_kms: IO[bytes] - ) -> dict[str, Any]: + ) -> list[Any]: decryptor = get_decryptor_from_flags(decrypt_with, decrypt_with_gcp_kms) # Decrypt the tarball, if the user has indicated that this is one by using either of the @@ -416,6 +416,8 @@ def load_data( click.echo(f"Invalid {side.name} JSON", err=True) raise + if not isinstance(data, list): + raise click.ClickException(f"Invalid {side.name} JSON") return data try: diff --git a/src/sentry/runner/commands/configoptions.py b/src/sentry/runner/commands/configoptions.py index e15e38a0fd66..6e8639ca36c9 100644 --- a/src/sentry/runner/commands/configoptions.py +++ b/src/sentry/runner/commands/configoptions.py @@ -90,7 +90,12 @@ def _load_options(file: str | None) -> dict[str, Any]: import yaml loaded = yaml.safe_load(content) - return loaded["options"] + if not isinstance(loaded, dict): + raise click.UsageError("Config file is not an object") + options = loaded.get("options") + if not isinstance(options, dict): + raise click.UsageError("Config file has no options object") + return options def _validate_options( @@ -240,7 +245,8 @@ def _single_file(ctx: click.Context, command: str) -> str | None: f"{command} operates on a single file; pass one -f, " f"or use 'validate' to check multiple files at once." ) - return files[0] + single_file = files[0] + return single_file if isinstance(single_file, str) else None @configoptions.command() diff --git a/src/sentry/runner/commands/createuser.py b/src/sentry/runner/commands/createuser.py index b852bb475736..8406e9c8b72e 100644 --- a/src/sentry/runner/commands/createuser.py +++ b/src/sentry/runner/commands/createuser.py @@ -40,7 +40,10 @@ def _get_password() -> str: rv = click.prompt("Password", hide_input=True, confirmation_prompt=True) field = _get_field("password") try: - return field.clean(rv, None) + password = field.clean(rv, None) + if not isinstance(password, str): + raise click.ClickException("Invalid password") + return password except ValidationError as e: raise click.ClickException("; ".join(e.messages)) 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/runner/decorators.py b/src/sentry/runner/decorators.py index 953e695fb764..cdd9fb6bb221 100644 --- a/src/sentry/runner/decorators.py +++ b/src/sentry/runner/decorators.py @@ -1,6 +1,6 @@ import os from collections.abc import Callable -from typing import Concatenate, ParamSpec, TypeVar +from typing import Concatenate, ParamSpec, TypeVar, cast import click @@ -23,7 +23,7 @@ def configuration(f: Callable[P, R]) -> Callable[P, R]: import click @click.pass_context - def inner(ctx: click.Context, *args: P.args, **kwargs: P.kwargs) -> R: + def inner(ctx: click.Context, /, *args: P.args, **kwargs: P.kwargs) -> R: # HACK: We can't call `configure()` from within tests # since we don't load config files from disk, so we # need a way to bypass this initialization step @@ -70,6 +70,7 @@ def decorator(f: Callable[P, R]) -> Callable[Concatenate[str | None, str | None, ) def inner( ctx: click.Context, + /, loglevel: str | None = None, logformat: str | None = None, *args: P.args, @@ -81,6 +82,7 @@ def inner( os.environ["SENTRY_LOG_FORMAT"] = logformat.lower() return ctx.invoke(f, *args, **kwargs) - return update_wrapper(inner, f) + # justification: update_wrapper preserves the decorated function's signature. + return cast("Callable[Concatenate[str | None, str | None, P], R]", update_wrapper(inner, f)) return decorator diff --git a/src/sentry/runner/settings.py b/src/sentry/runner/settings.py index 89b47841c92d..d86cfb8672bc 100644 --- a/src/sentry/runner/settings.py +++ b/src/sentry/runner/settings.py @@ -17,7 +17,10 @@ def get_sentry_conf() -> str: """ try: ctx = click.get_current_context() - return ctx.obj["config"] + config = ctx.obj["config"] + if not isinstance(config, str): + raise TypeError + return config except (RuntimeError, KeyError, TypeError): try: return os.environ["SENTRY_CONF"] diff --git a/src/sentry/scm/endpoints/scm_rpc.py b/src/sentry/scm/endpoints/scm_rpc.py index 53d714971a0e..affdd47ec1d7 100644 --- a/src/sentry/scm/endpoints/scm_rpc.py +++ b/src/sentry/scm/endpoints/scm_rpc.py @@ -11,7 +11,7 @@ from sentry.scm.private.helpers import make_fetchers, record_count_metric, report_error_to_sentry -def make_server(): +def make_server() -> RpcServer: # SCM-RPC callers own retries, so each integration proxy request gets a single attempt. fetchers = make_fetchers(disable_proxy_retries=True) return RpcServer( diff --git a/src/sentry/scm/private/event_stream.py b/src/sentry/scm/private/event_stream.py index da705e2c7105..9c378f13f020 100644 --- a/src/sentry/scm/private/event_stream.py +++ b/src/sentry/scm/private/event_stream.py @@ -17,7 +17,7 @@ class SourceCodeManagerEventStream: - def __init__(self): + def __init__(self) -> None: self.check_run_listeners: dict[str, CheckRunEventListener] = {} self.check_suite_listeners: dict[str, CheckSuiteEventListener] = {} self.comment_listeners: dict[str, CommentEventListener] = {} @@ -71,7 +71,15 @@ def listen_for( self, event_type: Literal["pull_request_review"] ) -> Callable[[PullRequestReviewEventListener], PullRequestReviewEventListener]: ... - def listen_for(self, event_type: str): + def listen_for( + self, event_type: str + ) -> ( + Callable[[CheckRunEventListener], CheckRunEventListener] + | Callable[[CheckSuiteEventListener], CheckSuiteEventListener] + | Callable[[CommentEventListener], CommentEventListener] + | Callable[[PullRequestEventListener], PullRequestEventListener] + | Callable[[PullRequestReviewEventListener], PullRequestReviewEventListener] + ): """ Decorator to register a callback for a specific event type. diff --git a/src/sentry/scm/private/rate_limit.py b/src/sentry/scm/private/rate_limit.py index 06be5c929cd1..8a1a3b41f8b2 100644 --- a/src/sentry/scm/private/rate_limit.py +++ b/src/sentry/scm/private/rate_limit.py @@ -165,7 +165,7 @@ def set_total_capacity(self, capacity: int, resource: str) -> None: class RedisRateLimitProvider: - def __init__(self): + def __init__(self) -> None: self.cluster = redis.redis_clusters.get(settings.SENTRY_SCM_REDIS_CLUSTER) def get_and_set_rate_limit( diff --git a/src/sentry/scm/stream.py b/src/sentry/scm/stream.py index 8c45103d8c9d..86cd453b2530 100644 --- a/src/sentry/scm/stream.py +++ b/src/sentry/scm/stream.py @@ -28,27 +28,27 @@ @scm_event_stream.listen_for(event_type="check_run") -def listen_for_check_run(e): +def listen_for_check_run(e: CheckRunEvent) -> None: return None @scm_event_stream.listen_for(event_type="comment") -def listen_for_comment(e): +def listen_for_comment(e: CommentEvent) -> None: return None @scm_event_stream.listen_for(event_type="pull_request") -def listen_for_pull_request(e): +def listen_for_pull_request(e: PullRequestEvent) -> None: return None @scm_event_stream.listen_for(event_type="check_suite") -def listen_for_check_suite(e): +def listen_for_check_suite(e: CheckSuiteEvent) -> None: return None @scm_event_stream.listen_for(event_type="pull_request_review") -def listen_for_pull_request_review(e): +def listen_for_pull_request_review(e: PullRequestReviewEvent) -> None: return None diff --git a/src/sentry/sdk_updates.py b/src/sentry/sdk_updates.py index bdd9fcd7dde0..05f7288af337 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, cast 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,33 +53,40 @@ 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): - return self.to_json() == other.to_json() + def __eq__(self, other: object) -> bool: + return self.to_json() == cast(Suggestion, other).to_json() class EnableIntegrationSuggestion(Suggestion): - 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] = [ @@ -356,7 +381,7 @@ class SupportedModule(TypedDict): ] -def get_sdk_index(): +def get_sdk_index() -> dict[str, dict[str, object]]: """ Get the SDK index from cache, if available. @@ -368,27 +393,32 @@ 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()) + rv.update((key, cast(str, info["version"])) for (key, info) in get_sdk_index().items()) return rv except Exception: logger.exception("sentry-release-registry.sdk-versions") 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()) + rv.update( + (key, cast(str, info.get("main_docs_url", ""))) + for (key, info) in get_sdk_index().items() + ) return rv except Exception: logger.exception("sentry-release-registry.sdk-urls") 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 +465,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/eap/columns.py b/src/sentry/search/eap/columns.py index ea58908fbb7e..09249b69e712 100644 --- a/src/sentry/search/eap/columns.py +++ b/src/sentry/search/eap/columns.py @@ -110,9 +110,13 @@ def normalize( """ if self.normalizer is None: return value + # justification: normalizers preserve the column value types. if isinstance(value, list): - return [self.normalizer(item) for item in value] - return self.normalizer(value) + return cast( + "Sequence[float] | Sequence[str]", + [self.normalizer(item) for item in value], + ) + return cast("str | float | datetime", self.normalizer(value)) def validate(self, value: Any) -> None: if callable(self.validator): diff --git a/src/sentry/search/eap/spans/formulas.py b/src/sentry/search/eap/spans/formulas.py index 14a2c7f2438c..7a689005387c 100644 --- a/src/sentry/search/eap/spans/formulas.py +++ b/src/sentry/search/eap/spans/formulas.py @@ -320,7 +320,8 @@ def get_count_of_vital(vital: str, settings: ResolverSettings) -> float: settings["query_result_cache"][cache_key] = response if len(response["data"]) > 0 and response["data"][0][vital_column] is not None: - return response["data"][0][vital_column] + vital_value = response["data"][0][vital_column] + return float(vital_value) if isinstance(vital_value, (int, float)) else 0 return 0 diff --git a/src/sentry/search/eap/utils.py b/src/sentry/search/eap/utils.py index 7bafafb29661..6cfbea71ce0f 100644 --- a/src/sentry/search/eap/utils.py +++ b/src/sentry/search/eap/utils.py @@ -612,7 +612,7 @@ def parse_formula( resolver: SearchResolver, ) -> str: # Create a dict of param name -> the arg the user passed - variables = {} + variables: dict[str, str | float] = {} saved_columns = [] for saved_arg, arg in zip(saved_args, arguments): if saved_arg.param_type == ParamItemTypes.NUMBER: @@ -639,14 +639,14 @@ def parse_formula( variables[saved_arg.name] = arg # Resolve all the calculations - calculations = {} + calculations: dict[str, float] = {} for calculation in saved_calculations: value = calculation.value for param_name, user_arg in variables.items(): # Don't sub columns since they aren't possible, ie. can't multiply a column if its used in a condition if param_name in saved_columns: continue - value = value.replace(f"{{{param_name}}}", user_arg) + value = value.replace(f"{{{param_name}}}", str(user_arg)) check_unmatched(calculation.name, value, saved_columns=saved_columns) try: parsed, _, _ = parse_arithmetic(value, definitions=resolver.definitions) diff --git a/src/sentry/search/events/builder/base.py b/src/sentry/search/events/builder/base.py index d7815009c990..8ae754733a05 100644 --- a/src/sentry/search/events/builder/base.py +++ b/src/sentry/search/events/builder/base.py @@ -93,13 +93,13 @@ class BaseQueryBuilder: uuid_fields: set[str] = set() span_id_fields: set[str] = set() - def get_middle(self): + def get_middle(self) -> datetime: """Get the middle for comparison functions""" if self.start is None or self.end is None: raise InvalidSearchQuery("Need both start & end to use percent_change") return self.start + (self.end - self.start) / 2 - def first_half_condition(self): + def first_half_condition(self) -> Function: """Create the first half condition for percent_change functions""" return Function( "less", @@ -109,7 +109,7 @@ def first_half_condition(self): ], ) - def second_half_condition(self): + def second_half_condition(self) -> Function: """Create the second half condition for percent_change functions""" return Function( "greaterOrEquals", @@ -164,7 +164,7 @@ def _dataclass_params( environments = [] # type: ignore[unreachable] user_id = params.get("user_id") - user = user_service.get_user(user_id=user_id) if user_id is not None else None # type: ignore[arg-type] + user = user_service.get_user(user_id=user_id) if user_id is not None else None teams = ( Team.objects.filter(id__in=params["team_id"]) if "team_id" in params and isinstance(params["team_id"], list) @@ -1218,7 +1218,7 @@ def convert_search_filter_to_condition( converter = self.search_filter_converter.get(name, self.default_filter_converter) return converter(search_filter) - def validate_uuid_like_filters(self, search_filter: event_search.SearchFilter): + def validate_uuid_like_filters(self, search_filter: event_search.SearchFilter) -> None: name = search_filter.key.name value = search_filter.value @@ -1228,7 +1228,7 @@ def validate_uuid_like_filters(self, search_filter: event_search.SearchFilter): if not value.is_event_id(): raise InvalidSearchQuery(INVALID_ID_DETAILS.format(name)) - def validate_span_id_like_filters(self, search_filter: event_search.SearchFilter): + def validate_span_id_like_filters(self, search_filter: event_search.SearchFilter) -> None: name = search_filter.key.name value = search_filter.value diff --git a/src/sentry/search/events/builder/errors.py b/src/sentry/search/events/builder/errors.py index b48c88ea857a..253b35b66929 100644 --- a/src/sentry/search/events/builder/errors.py +++ b/src/sentry/search/events/builder/errors.py @@ -1,6 +1,7 @@ from __future__ import annotations import dataclasses +from typing import Any from snuba_sdk import ( AliasedExpression, @@ -25,16 +26,20 @@ TopEventsQueryBuilder, ) from sentry.search.events.filter import ParsedTerms -from sentry.search.events.types import SelectType +from sentry.search.events.types import SelectType, WhereType from sentry.snuba.entity_subscription import ENTITY_TIME_COLUMNS, get_entity_key_from_query_builder value_converters = {"status": convert_status_value} class ErrorsQueryBuilderMixin(BaseQueryBuilder): - def __init__(self, *args, **kwargs): - self.match = None - self.entities = set() + def __init__( + self, + *args: Any, # justification: forwards the concrete builder signature. + **kwargs: Any, # justification: forwards the concrete builder signature. + ) -> None: + self.match: Join | Entity | None = None + self.entities: set[Entity] = set() super().__init__(*args, **kwargs) def parse_query(self, query: str | None) -> ParsedTerms: @@ -49,7 +54,7 @@ def parse_query(self, query: str | None) -> ParsedTerms: ) return parsed_terms - def resolve_match(self): + def resolve_match(self) -> None: error_entity = Entity(self.dataset.value, alias=self.dataset.value, sample=self.sample_rate) if len(self.entities) == 1: self.match = error_entity @@ -59,7 +64,7 @@ def resolve_match(self): else: raise Exception("Unexpected number of entities") - def resolve_params(self): + def resolve_params(self) -> list[WhereType]: conditions = super().resolve_params() if len(self.entities) == 2: conditions.append( @@ -154,7 +159,11 @@ def add_conditions(self, conditions: list[Condition]) -> None: class ErrorsTimeseriesQueryBuilder(ErrorsQueryBuilderMixin, TimeseriesQueryBuilder): - def __init__(self, *args, **kwargs): + def __init__( + self, + *args: Any, # justification: forwards the concrete builder signature. + **kwargs: Any, # justification: forwards the concrete builder signature. + ) -> None: super().__init__(*args, **kwargs) @property @@ -180,7 +189,11 @@ def get_snql_query(self) -> Request: class ErrorsTopEventsQueryBuilder(ErrorsQueryBuilderMixin, TopEventsQueryBuilder): - def __init__(self, *args, **kwargs): + def __init__( + self, + *args: Any, # justification: forwards the concrete builder signature. + **kwargs: Any, # justification: forwards the concrete builder signature. + ) -> None: super().__init__(*args, **kwargs) @property diff --git a/src/sentry/search/events/builder/metrics.py b/src/sentry/search/events/builder/metrics.py index fdfdc051dc47..6513e442e25a 100644 --- a/src/sentry/search/events/builder/metrics.py +++ b/src/sentry/search/events/builder/metrics.py @@ -3,7 +3,7 @@ from collections import defaultdict from collections.abc import Mapping, Sequence from datetime import datetime -from typing import Any +from typing import Any, cast import sentry_sdk from django.contrib.auth.models import AnonymousUser @@ -1871,7 +1871,8 @@ def resolve_top_event_conditions( if isinstance(value, list): value = {project_map.get(val) for val in value} else: - value = project_map.get(value) + # justification: Snuba returns the project slug as a string here. + value = project_map.get(cast(str, value)) # TODO: Handle potential None case elif value is not None: value = self.resolve_tag_value(str(value)) diff --git a/src/sentry/search/events/builder/profile_functions.py b/src/sentry/search/events/builder/profile_functions.py index 08ee6e71c852..b3aa20d59656 100644 --- a/src/sentry/search/events/builder/profile_functions.py +++ b/src/sentry/search/events/builder/profile_functions.py @@ -1,5 +1,5 @@ from datetime import UTC, datetime -from typing import Any, Protocol +from typing import Any, Protocol, cast from snuba_sdk import AliasedExpression, And, Column, Condition, CurriedFunction, Op, Or from snuba_sdk.function import Function @@ -43,7 +43,7 @@ def get_field_type(self: ProfileFunctionsQueryBuilderProtocol, field: str) -> st resolved: str | None = self.config.resolve_column_type(field) return resolved - def process_profiling_function_columns(self, row: SnubaRow): + def process_profiling_function_columns(self, row: SnubaRow) -> None: # We need to check both the aliased and non aliased names # as not all use cases enable `transform_alias_to_input_format` # and the events-stats endpoint does not actually apply it. @@ -96,7 +96,9 @@ class ProfileFunctionsTimeseriesQueryBuilder( function_alias_prefix = "sentry_" config_class = ProfileFunctionsDatasetConfig - def strip_alias_prefix(self, result): + def strip_alias_prefix( # justification: raw snuba response payload + self, result: dict[str, Any] + ) -> dict[str, Any]: # justification: raw snuba response payload alias_mappings = { column: get_function_alias(function_details.field) for column, function_details in self.function_alias_map.items() @@ -127,7 +129,8 @@ def process_results(self, results: Any) -> EventsResponse: results["meta"] = super().process_results(results)["meta"] - return results + # justification: the builder constructs an EventsResponse payload. + return cast("EventsResponse", results) class ProfileTopFunctionsTimeseriesQueryBuilder(ProfileFunctionsTimeseriesQueryBuilder): diff --git a/src/sentry/search/events/builder/utils.py b/src/sentry/search/events/builder/utils.py index 69753eb3c3d1..141e295948d2 100644 --- a/src/sentry/search/events/builder/utils.py +++ b/src/sentry/search/events/builder/utils.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta -def remove_minutes(timestamp, floor=True): +def remove_minutes(timestamp: datetime, floor: bool = True) -> datetime: if floor: return datetime(timestamp.year, timestamp.month, timestamp.day, timestamp.hour) else: @@ -11,14 +11,14 @@ def remove_minutes(timestamp, floor=True): ) -def remove_hours(timestamp, floor=True): +def remove_hours(timestamp: datetime, floor: bool = True) -> datetime: if floor: return datetime(timestamp.year, timestamp.month, timestamp.day) else: return datetime(timestamp.year, timestamp.month, timestamp.day) + timedelta(days=1) -def adjust_datetime_to_granularity(timestamp: datetime, granularity_seconds: int): +def adjust_datetime_to_granularity(timestamp: datetime, granularity_seconds: int) -> datetime: """ Adjusts a datetime (down) to the boundary of a specified granularity. diff --git a/src/sentry/search/events/datasets/base.py b/src/sentry/search/events/datasets/base.py index da67bbb9937c..d14c9aaea775 100644 --- a/src/sentry/search/events/datasets/base.py +++ b/src/sentry/search/events/datasets/base.py @@ -2,7 +2,7 @@ import abc from collections.abc import Callable, Mapping -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, cast if TYPE_CHECKING: from sentry.search.events.builder.base import BaseQueryBuilder @@ -63,7 +63,8 @@ def result_type_fn( argument = function_arguments[index] value = parameter_values[argument.name] if (field_type := self.builder.get_field_type(value)) is not None: # type: ignore[attr-defined] - return field_type + # justification: field types are metric type names. + return cast("str", field_type) else: return argument.get_type(value) diff --git a/src/sentry/search/events/datasets/function_aliases.py b/src/sentry/search/events/datasets/function_aliases.py index 2d9ad75874fc..41506514e995 100644 --- a/src/sentry/search/events/datasets/function_aliases.py +++ b/src/sentry/search/events/datasets/function_aliases.py @@ -1,5 +1,5 @@ from collections.abc import Callable, Mapping, Sequence -from typing import Any +from typing import Any, cast from snuba_sdk import Column, Entity, Function @@ -59,7 +59,9 @@ def resolve_project_threshold_config( project_threshold_config_keys = [] project_threshold_config_values = [] for project_id, metric in project_threshold_configs: - metric = TRANSACTION_METRICS[metric] + # justification: the threshold query selects these columns in this order. + project_id = cast(int, project_id) + metric = TRANSACTION_METRICS[cast(int, metric)] if metric == constants.DEFAULT_PROJECT_THRESHOLD_METRIC: # small optimization, if the configuration is equal to the default, # we can skip it in the final query @@ -72,7 +74,10 @@ def resolve_project_threshold_config( project_threshold_override_config_keys = [] project_threshold_override_config_values = [] for transaction, project_id, metric in transaction_threshold_configs: - metric = TRANSACTION_METRICS[metric] + # justification: the threshold query selects these columns in this order. + transaction = cast(str, transaction) + project_id = cast(int, project_id) + metric = TRANSACTION_METRICS[cast(int, metric)] if project_id in project_thresholds and metric == project_thresholds[project_id][0]: # small optimization, if the configuration is equal to the project # configs, we can skip it in the final query diff --git a/src/sentry/search/events/datasets/spans_metrics.py b/src/sentry/search/events/datasets/spans_metrics.py index fecc19e6574a..795061bc16f0 100644 --- a/src/sentry/search/events/datasets/spans_metrics.py +++ b/src/sentry/search/events/datasets/spans_metrics.py @@ -1319,7 +1319,7 @@ def _is_messaging_op(self, op: str, operation_name: str, operation_type: str) -> ], ) - def _resolve_count_publish(self, args, alias): + def _resolve_count_publish(self, args: Mapping[str, object], alias: str | None) -> SelectType: op = "queue.publish" operation_name = "publish" operation_type = "create" @@ -1335,7 +1335,7 @@ def _resolve_count_publish(self, args, alias): alias, ) - def _resolve_count_process(self, args, alias): + def _resolve_count_process(self, args: Mapping[str, object], alias: str | None) -> SelectType: op = "queue.process" operation_name = "process" operation_type = "process" @@ -1351,7 +1351,9 @@ def _resolve_count_process(self, args, alias): alias, ) - def _resolve_avg_if_publish(self, args, alias): + def _resolve_avg_if_publish( + self, args: Mapping[str, str | Column | SelectType], alias: str | None + ) -> SelectType: op = "queue.publish" operation_name = "publish" operation_type = "create" @@ -1376,7 +1378,9 @@ def _resolve_avg_if_publish(self, args, alias): alias, ) - def _resolve_avg_if_process(self, args, alias): + def _resolve_avg_if_process( + self, args: Mapping[str, str | Column | SelectType], alias: str | None + ) -> SelectType: op = "queue.process" operation_name = "process" operation_type = "process" @@ -1401,7 +1405,7 @@ def _resolve_avg_if_process(self, args, alias): alias, ) - def _resolve_sum(self, metric_name: str, alias: str | None = None): + def _resolve_sum(self, metric_name: str, alias: str | None = None) -> SelectType: return Function( "sumIf", [ @@ -1417,7 +1421,9 @@ def _resolve_sum(self, metric_name: str, alias: str | None = None): alias, ) - def _resolve_avg(self, args, alias): + def _resolve_avg( + self, args: Mapping[str, str | Column | SelectType], alias: str | None + ) -> SelectType: return Function( "avgIf", [ @@ -1427,7 +1433,9 @@ def _resolve_avg(self, args, alias): alias, ) - def _resolve_avg_if(self, args, alias): + def _resolve_avg_if( + self, args: Mapping[str, str | Column | SelectType], alias: str | None + ) -> SelectType: return Function( "avgIf", [ @@ -1505,7 +1513,9 @@ def _resolve_division( alias, ) - def _resolve_avg_compare(self, args, alias): + def _resolve_avg_compare( + self, args: Mapping[str, str | Column | SelectType], alias: str | None + ) -> SelectType: return function_aliases.resolve_avg_compare(self.builder.column, args, alias) def _resolve_trace_status_count( diff --git a/src/sentry/search/events/fields.py b/src/sentry/search/events/fields.py index 5ca1f8897ae6..b6971487940b 100644 --- a/src/sentry/search/events/fields.py +++ b/src/sentry/search/events/fields.py @@ -2,11 +2,11 @@ import re from collections import namedtuple -from collections.abc import Collection, Mapping, Sequence +from collections.abc import Callable, Collection, Mapping, Sequence from copy import copy, deepcopy from datetime import datetime, timezone from re import Match -from typing import Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple, Self, cast import sentry_sdk from sentry_relay.consts import SPAN_STATUS_NAME_TO_CODE @@ -59,6 +59,10 @@ MAX_QUERYABLE_TEAM_KEY_TRANSACTIONS = 500 MAX_QUERYABLE_TRANSACTION_THRESHOLDS = 500 +if TYPE_CHECKING: + from sentry.search.events.builder.base import BaseQueryBuilder + + ConditionalFunction = namedtuple("ConditionalFunction", "condition match fallback") ResolvedFunction = namedtuple("ResolvedFunction", "details column aggregate") @@ -68,7 +72,14 @@ class InvalidFunctionArgument(Exception): class PseudoField: - def __init__(self, name, alias, expression=None, expression_fn=None, result_type=None): + def __init__( + self, + name: str, + alias: str, + expression: list[Any] | None = None, + expression_fn: Callable[[ParamsType], list[Any]] | None = None, + result_type: str | None = None, + ) -> None: self.name = name self.alias = alias self.expression = expression @@ -77,15 +88,15 @@ def __init__(self, name, alias, expression=None, expression_fn=None, result_type self.validate() - def get_expression(self, params) -> list[Any] | None: + def get_expression(self, params: ParamsType | None) -> list[Any] | None: if self.expression is not None: return deepcopy(self.expression) elif self.expression_fn is not None: - return self.expression_fn(params) + return self.expression_fn(cast("ParamsType", params)) else: return None - def get_field(self, params=None): + def get_field(self, params: ParamsType | None = None) -> str | list[Any]: expression = self.get_expression(params) if expression is not None: expression.append(self.alias) @@ -100,7 +111,7 @@ def validate(self) -> None: def project_threshold_config_expression( - organization_id: int | None, project_ids: list[int] | None + organization_id: int | None, project_ids: Sequence[int] | None ) -> list[object]: """ This function returns a column with the threshold and threshold metric @@ -272,7 +283,9 @@ def project_threshold_config_expression( return project_config_query -def team_key_transaction_expression(organization_id, team_ids, project_ids): +def team_key_transaction_expression( + organization_id: int | None, team_ids: Sequence[int] | None, project_ids: Sequence[int] | None +) -> list[Any]: if organization_id is None or team_ids is None or project_ids is None: raise TypeError("Team key transactions parameters cannot be None") @@ -472,7 +485,7 @@ def normalize_count_if_value(args: Mapping[str, str]) -> float | str | int: } -def format_column_arguments(column_args, arguments) -> None: +def format_column_arguments(column_args: list[Any], arguments: Mapping[str, Any]) -> None: for i in range(len(column_args)): if isinstance(column_args[i], (list, tuple)): if isinstance(column_args[i][0], ArgValue): @@ -484,7 +497,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 @@ -561,7 +574,9 @@ def parse_arguments(_function: str, columns: str) -> list[str]: return [arg for arg in args if arg] -def resolve_field(field, params=None, functions_acl=None): +def resolve_field( + field: str, params: ParamsType | None = None, functions_acl: list[str] | None = None +) -> ResolvedFunction: if not isinstance(field, str): raise InvalidSearchQuery("Field names must be strings") @@ -582,20 +597,25 @@ def resolve_field(field, params=None, functions_acl=None): raise InvalidSearchQuery(f"Invalid characters in field {field}") -def resolve_function(field, match=None, params=None, functions_acl=False): +def resolve_function( + field: str, + match: Match[str] | None = None, + params: ParamsType | None = None, + functions_acl: list[str] | None = None, +) -> ResolvedFunction: if params is not None and field in params.get("aliases", {}): - alias = params["aliases"][field] + params_alias = params["aliases"][field] return ResolvedFunction( FunctionDetails(field, FUNCTIONS["percentage"], {}), None, - alias.aggregate, + params_alias.aggregate, ) function_name, columns, alias = parse_function(field, match) function = FUNCTIONS[function_name] if not function.is_accessible(functions_acl): raise InvalidSearchQuery(f"{function.name}: no access to private function") - arguments = function.format_as_arguments(field, columns, params) + arguments = function.format_as_arguments(field, columns, cast("ParamsType", params)) details = FunctionDetails(field, function, arguments) if function.transform is not None: @@ -608,12 +628,12 @@ def resolve_function(field, match=None, params=None, functions_acl=False): [snuba_string, None, alias], ) elif function.conditional_transform is not None: - condition, match, fallback = function.conditional_transform + condition, match_string, fallback = function.conditional_transform if alias is None: alias = get_function_alias_with_columns(function.name, columns) if arguments[condition.arg] is not None: - snuba_string = match.format(**arguments) + snuba_string = match_string.format(**arguments) else: snuba_string = fallback.format(**arguments) return ResolvedFunction( @@ -627,7 +647,7 @@ def resolve_function(field, match=None, params=None, functions_acl=False): aggregate[0] = aggregate[0].format(**arguments) if isinstance(aggregate[1], (list, tuple)): - format_column_arguments(aggregate[1], arguments) + format_column_arguments(cast("list[Any]", aggregate[1]), arguments) elif isinstance(aggregate[1], ArgValue): arg = aggregate[1].arg # The aggregate function has only a single argument @@ -650,7 +670,7 @@ def resolve_function(field, match=None, params=None, functions_acl=False): addition = deepcopy(function.column) addition[0] = addition[0].format(**arguments) if isinstance(addition[1], (list, tuple)): - format_column_arguments(addition[1], arguments) + format_column_arguments(cast("list[Any]", addition[1]), arguments) if len(addition) < 3: if alias is not None: addition.append(alias) @@ -665,6 +685,8 @@ def resolve_function(field, match=None, params=None, functions_acl=False): addition[2] = addition[2].format(**arguments) return ResolvedFunction(details, addition, None) + raise InvalidSearchQuery(f"{field}: unable to resolve function") + def parse_combinator(function: str) -> tuple[str, str | None]: for combinator in COMBINATORS: @@ -675,7 +697,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 +740,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]", "_", @@ -732,7 +758,9 @@ def get_function_alias_with_columns(function_name, columns, prefix=None) -> str: return alias -def get_json_meta_type(field_alias, snuba_type, builder=None): +def get_json_meta_type( + field_alias: str, snuba_type: str, builder: BaseQueryBuilder | None = None +) -> str: if builder: function = builder.function_alias_map.get(field_alias) else: @@ -760,17 +788,21 @@ def get_json_meta_type(field_alias, snuba_type, builder=None): if field_alias == "transaction.status": return "string" # The builder will have Custom Measurement info etc. - field_type = builder.get_field_type(field_alias) + field_type = cast("BaseQueryBuilder", builder).get_field_type(field_alias) if field_type is not None: return field_type return snuba_json -def reflective_result_type(index=0): - def result_type_fn(function_arguments, parameter_values): +def reflective_result_type( + index: int = 0, +) -> Callable[[list[FunctionArg], Mapping[str, NormalizedArg]], str]: + def result_type_fn( + function_arguments: list[FunctionArg], parameter_values: Mapping[str, NormalizedArg] + ) -> str: argument = function_arguments[index] value = parameter_values[argument.name] - return argument.get_type(value) + return argument.get_type(cast("str", value)) return result_type_fn @@ -830,7 +862,7 @@ def __init__(self, name: str): self.name = name self.has_default = False - def get_default(self, _) -> object: + def get_default(self, _: ParamsType) -> NormalizedArg: raise InvalidFunctionArgument(f"{self.name} has no defaults") def normalize( @@ -838,7 +870,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 +964,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 +1016,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( @@ -1092,15 +1124,15 @@ def normalize( class CountColumn(ColumnArg): - def __init__(self, name: str, **kwargs): + def __init__(self, name: str, **kwargs: Any) -> None: super().__init__(name, **kwargs) 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 + self, value: str | None, params: ParamsType, combinator: Combinator | None ) -> str | list[Any]: if value is None: raise InvalidFunctionArgument("a column is required") @@ -1115,9 +1147,7 @@ def normalize( expression = field.get_expression(params) if expression is not None: return expression - elif field.alias is not None: - return field.alias - return value + return field.alias or value class FieldColumn(CountColumn): @@ -1151,7 +1181,7 @@ def __init__( name: str, allow_array_value: bool | None = False, spans: bool | None = False, - **kwargs, + **kwargs: Any, ): self.spans = spans super().__init__(name, **kwargs) @@ -1227,7 +1257,7 @@ def get_type(self, value: str | list[Any]) -> str: field = FIELD_ALIASES[name] expression = field.get_expression(None) if expression == value: - return field.result_type + return cast("str", field.result_type) else: raise AssertionError(f"unreachable: {value}") @@ -1287,9 +1317,9 @@ def normalize(self, value: str, params: ParamsType, combinator: Combinator | Non raise InvalidFunctionArgument(f"{value} is not a valid sessions dataset column") -def with_default(default, argument): +def with_default(default: NormalizedArg, argument: FunctionArg) -> FunctionArg: argument.has_default = True - argument.get_default = lambda *_: default + setattr(argument, "get_default", lambda *_: default) return argument @@ -1312,7 +1342,9 @@ def normalize(self, value: str, params: ParamsType, combinator: Combinator | Non class SnQLFieldColumn(FieldColumn): - def normalize(self, value: str, params: ParamsType, combinator: Combinator | None) -> str: + def normalize( + self, value: str | None, params: ParamsType, combinator: Combinator | None + ) -> str: if value is None: raise InvalidFunctionArgument("a column is required") @@ -1322,20 +1354,21 @@ def normalize(self, value: str, params: ParamsType, combinator: Combinator | Non class DiscoverFunction: def __init__( self, - name, - required_args=None, - optional_args=None, - calculated_args=None, - column=None, - aggregate=None, - transform=None, - conditional_transform=None, - result_type_fn=None, - default_result_type=None, - redundant_grouping=False, - combinators=None, - private=False, - ): + name: str, + required_args: list[FunctionArg] | None = None, + optional_args: list[FunctionArg] | None = None, + calculated_args: Sequence[Mapping[str, Any]] | None = None, + column: list[Any] | None = None, + aggregate: list[Any] | None = None, + transform: str | None = None, + conditional_transform: ConditionalFunction | None = None, + result_type_fn: Callable[[list[FunctionArg], Mapping[str, NormalizedArg]], str | None] + | None = None, + default_result_type: str | None = None, + redundant_grouping: bool = False, + combinators: Sequence[Combinator] | None = None, + private: bool = False, + ) -> None: """ Specifies a function interface that must be followed when defining new functions @@ -1384,22 +1417,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): + def args(self) -> list[FunctionArg]: 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 @@ -1422,7 +1455,7 @@ def add_default_arguments( raise InvalidSearchQuery(f"{field}: invalid arguments: {e}") # Hacky, but we expect column arguments to be strings so easiest to convert it back - columns.append(str(default) if default else default) + columns.append(cast("str", str(default) if default else default)) return columns @@ -1432,16 +1465,20 @@ def format_as_arguments( columns: list[str], params: ParamsType, combinator: Combinator | None = None, - ) -> dict[str, NormalizedArg]: + ) -> dict[str, Any]: # justification: normalized function arguments are heterogeneous. columns = self.add_default_arguments(field, columns, params) - arguments = {} + arguments: dict[str, Any] = {} # justification: normalized arguments are heterogeneous. # normalize the arguments before putting them in a dict for argument, column in zip(self.args, columns): try: normalized_value = argument.normalize(column, params, combinator) - if not isinstance(self, SnQLFunction) and isinstance(argument, NumericColumn): + if ( + not isinstance(self, SnQLFunction) + and isinstance(argument, NumericColumn) + and isinstance(normalized_value, str) + ): if normalized_value in argument.measurement_aliases: field_obj = FIELD_ALIASES[normalized_value] normalized_value = field_obj.get_expression(params) @@ -1457,7 +1494,9 @@ def format_as_arguments( return arguments - def get_result_type(self, field=None, arguments=None) -> str | None: + def get_result_type( + self, field: str | None = None, arguments: Mapping[str, NormalizedArg] | None = None + ) -> str | None: if field is None or arguments is None or self.result_type_fn is None: return self.default_result_type @@ -1534,7 +1573,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)}" ) @@ -2174,7 +2213,7 @@ def normalize_percentile_alias(args: Mapping[str, str]) -> str: return f"{match.group(1)}({aggregate_arg})" -def custom_time_processor(interval, time_column): +def custom_time_processor(interval: int, time_column: str | Function) -> Function: """For when snuba doesn't have a time processor for a dataset""" return Function( "toDateTime", @@ -2198,7 +2237,11 @@ def custom_time_processor(interval, time_column): class SnQLFunction(DiscoverFunction): - def __init__(self, *args, **kwargs) -> None: + def __init__( + self, + *args: Any, + **kwargs: Any, # justification: forwards variadic function definitions. + ) -> None: self.snql_aggregate = kwargs.pop("snql_aggregate", None) self.snql_column = kwargs.pop("snql_column", None) self.requires_other_aggregates = kwargs.pop("requires_other_aggregates", False) @@ -2276,7 +2319,11 @@ def get_type(self, value: str) -> str: class MetricsFunction(SnQLFunction): """Metrics needs to differentiate between aggregate types so we can send queries to the right table""" - def __init__(self, *args, **kwargs) -> None: + def __init__( + self, + *args: Any, + **kwargs: Any, # justification: forwards variadic function definitions. + ) -> None: self.snql_distribution = kwargs.pop("snql_distribution", None) self.snql_set = kwargs.pop("snql_set", None) self.snql_counter = kwargs.pop("snql_counter", None) diff --git a/src/sentry/search/events/filter.py b/src/sentry/search/events/filter.py index a81287430dd8..d69038f98d03 100644 --- a/src/sentry/search/events/filter.py +++ b/src/sentry/search/events/filter.py @@ -2,7 +2,7 @@ from collections.abc import Callable, Sequence from datetime import datetime -from typing import Any, TypedDict, Union +from typing import Any, TypedDict, Union, cast import orjson from sentry_relay.consts import SPAN_STATUS_NAME_TO_CODE @@ -49,6 +49,7 @@ USER_DISPLAY_ALIAS, ) from sentry.search.events.fields import FIELD_ALIASES +from sentry.search.events.types import ParamsType from sentry.search.utils import parse_release from sentry.utils.strings import oxfordize_list from sentry.utils.validators import INVALID_ID_DETAILS, INVALID_SPAN_ID, WILDCARD_NOT_ALLOWED @@ -195,7 +196,10 @@ def _user_display_filter_converter( params: FilterConvertParams | None, ) -> list[Any]: value = search_filter.value.value - user_display_expr = FIELD_ALIASES[USER_DISPLAY_ALIAS].get_expression(params) + user_display_expr = FIELD_ALIASES[USER_DISPLAY_ALIAS].get_expression( + # justification: field expressions read a subset of the search params mapping. + cast("ParamsType", params) + ) # Handle 'has' condition if search_filter.value.raw_value == "": @@ -292,7 +296,10 @@ def _team_key_transaction_filter_converter( params: FilterConvertParams | None, ) -> list[Any]: value = search_filter.value.value - key_transaction_expr = FIELD_ALIASES[TEAM_KEY_TRANSACTION_ALIAS].get_field(params) + key_transaction_expr = FIELD_ALIASES[TEAM_KEY_TRANSACTION_ALIAS].get_field( + # justification: field expressions read a subset of the search params mapping. + cast("ParamsType", params) + ) if search_filter.value.raw_value == "": operator = "!=" if search_filter.operator == "!=" else "=" @@ -695,7 +702,10 @@ def convert_search_filter_to_snuba_query( # name_expr may become a snuba expression (list) for aliases/tags name_expr: Any = name if name in FIELD_ALIASES: - name_expr = FIELD_ALIASES[name].get_field(params) + name_expr = FIELD_ALIASES[name].get_field( + # justification: field expressions read a subset of the search params mapping. + cast("ParamsType", params) + ) # Tags are never null, but promoted tags are columns and so can be null. # To handle both cases, use `ifNull` to convert to an empty string and @@ -808,7 +818,7 @@ def format_search_filter( value = to_list(value) # `unknown` is a special value for when there is no issue associated with the event group_short_ids = [v for v in value if v and v != "unknown"] - filter_values = ["" for v in value if not v or v == "unknown"] + filter_values: list[str | int] = ["" for v in value if not v or v == "unknown"] if group_short_ids and params and "organization_id" in params: try: @@ -826,7 +836,13 @@ def format_search_filter( term = SearchFilter( SearchKey("issue.id"), operator, - SearchValue(filter_values if term.is_in_filter else filter_values[0]), + # justification: issue ids are integers; `SearchValue` accepts them at runtime. + SearchValue( + cast( + "Sequence[str] | str", + filter_values if term.is_in_filter else filter_values[0], + ) + ), ) converted_filter = convert_search_filter_to_snuba_query(term) if converted_filter: diff --git a/src/sentry/search/events/types.py b/src/sentry/search/events/types.py index 00c5914e3c14..96c9cfad6fb5 100644 --- a/src/sentry/search/events/types.py +++ b/src/sentry/search/events/types.py @@ -38,8 +38,10 @@ class ParamsType(TypedDict, total=False): organization_id: NotRequired[int | None] use_case_id: NotRequired[str] team_id: NotRequired[list[int]] + aliases: NotRequired[Mapping[str, Alias]] environment_objects: NotRequired[list[Environment]] statsPeriod: NotRequired[str] + user_id: NotRequired[int | None] SelectType = Union[AliasedExpression, Column, Function, CurriedFunction] 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/grammar/sql.py b/src/sentry/search/grammar/sql.py index 6ab4fe52ce28..a938fc55149b 100644 --- a/src/sentry/search/grammar/sql.py +++ b/src/sentry/search/grammar/sql.py @@ -1,4 +1,4 @@ -from typing import Any, Literal, Sequence, TypedDict +from typing import Any, Literal, Sequence, TypedDict, cast from parsimonious.exceptions import IncompleteParseError from parsimonious.grammar import Grammar @@ -115,19 +115,19 @@ def visit_query_exp(self, node: Node, visited_children: Sequence[Any]) -> Parsed def visit_select_clause(self, node: Node, visited_children: Sequence[Any]) -> list[str]: _, _, _, selected_columns = visited_children - return selected_columns + return cast("list[str]", selected_columns) def visit_dataset_clause(self, node: Node, visited_children: Sequence[Any]) -> str: _, _, _, dataset_literal = visited_children - return dataset_literal + return cast("str", dataset_literal) def visit_where_clause(self, node: Node, visited_children: Sequence[Any]) -> list[Condition]: _, _, _, or_condition = visited_children - return or_condition + return cast("list[Condition]", or_condition) def visit_order_by_clause(self, node: Node, visited_children: Sequence[Any]) -> list[OrderBy]: _, _, _, order_list = visited_children - return order_list + return cast("list[OrderBy]", order_list) def visit_order_list(self, node: Node, visited_children: Sequence[Any]) -> list[OrderBy]: order_list, orderby, _, direction = visited_children @@ -157,9 +157,9 @@ def visit_select_list(self, node: Node, visited_children: Sequence[Any]) -> list columns.append(right_column) return columns - def visit_select_columns(self, node: Node, visited_children: Sequence[Any]) -> list[str]: + def visit_select_columns(self, node: Node, visited_children: Sequence[Any]) -> str: selected_expression, _, _ = visited_children - return selected_expression + return cast("str", selected_expression) def generic_visit(self, node: Node, visited_children: Sequence[Any]) -> Sequence[Any]: return visited_children @@ -168,11 +168,11 @@ def generic_visit(self, node: Node, visited_children: Sequence[Any]) -> Sequence def visit_and_expression(self, node: Node, visited_children: Sequence[Any]) -> Condition: _, left_condition, and_condition = visited_children - return left_condition + return cast("Condition", left_condition) def visit_or_expression(self, node: Node, visited_children: Sequence[Any]) -> Condition: _, left_condition, or_condition = visited_children - return left_condition + return cast("Condition", left_condition) def visit_main_condition(self, node: Node, visited_children: Sequence[Any]) -> Condition: exp, _, op, _, literal = visited_children diff --git a/src/sentry/search/snuba/executors.py b/src/sentry/search/snuba/executors.py index 7ff63f00d9ce..0c6ac383bbb4 100644 --- a/src/sentry/search/snuba/executors.py +++ b/src/sentry/search/snuba/executors.py @@ -120,7 +120,7 @@ class PostgresSortStrategy: # Optional cap-free path: return (queryset, order_by_key) for a native Postgres ORDER BY # so the sort can page past the in-memory candidate cap. None for Snuba-blended strategies # (e.g. recommended_v2) whose ordering can't be expressed entirely in Postgres. - native_order_by: Callable[[BaseQuerySet], tuple[BaseQuerySet, str]] | None = None + native_order_by: Callable[[BaseQuerySet[Group]], tuple[BaseQuerySet[Group], str]] | None = None # we cannot use snuba for these fields because they require a join with tables that don't exist there @@ -216,7 +216,7 @@ class AbstractQueryExecutor(metaclass=ABCMeta): @property @abstractmethod - def aggregation_defs(self) -> Mapping[str, Sequence[str] | Callable]: + def aggregation_defs(self) -> Mapping[str, Sequence[str] | Callable[..., Sequence[str]]]: """This method should return a dict of key:value where key is a field name for your aggregation and value is the aggregation function""" @@ -256,7 +256,7 @@ def query( self, projects: Sequence[Project], retention_window_start: datetime | None, - group_queryset: BaseQuerySet, + group_queryset: BaseQuerySet[Group], environments: Sequence[Environment] | None, sort_by: str, limit: int, @@ -1053,7 +1053,7 @@ def score_fn(data: dict[str, Any]) -> float: # overflowing the float (1.0 / 2.0**x blows up once x exceeds ~1024). newness = 2.0 ** -(hours / newness_halflife_hours) regressed = 1.0 if data.get("substatus") == GroupSubStatus.REGRESSED else 0.0 - return ( + return float( (data.get("recommended") or 0.0) + assignment_weight * relevance + fixability_weight * (data.get("fixability") or 0.0) @@ -1155,7 +1155,9 @@ def _resolve_last_progressed_at( return {group_id: ts.timestamp() * 1000 for group_id, ts in rows if ts is not None} -def _progress_native_order_by(queryset: BaseQuerySet) -> tuple[BaseQuerySet, str]: +def _progress_native_order_by( + queryset: BaseQuerySet[Group], +) -> tuple[BaseQuerySet[Group], str]: """SQL reproduction of progress_strategy().score_fn so the sort can ORDER BY it natively (no in-memory candidate cap). ``.extra`` (not ``.annotate``) lets the cursor Paginator reuse the alias SQL in its WHERE clause; the ``-id`` tiebreak gives equal scores a total @@ -1185,12 +1187,12 @@ def _progress_native_order_by(queryset: BaseQuerySet) -> tuple[BaseQuerySet, str " END)" ) # F() on the nullable reverse OneToOne forces the LEFT OUTER JOIN the raw SQL relies on. - queryset = ( + ordered = ( queryset.annotate(_gdd_join=F("groupderiveddata__progress")) .extra(select={"progress_sort_score": sql}, select_params=params) .order_by("-progress_sort_score", "-id") ) - return queryset, "-progress_sort_score" + return cast("BaseQuerySet[Group]", ordered), "-progress_sort_score" def progress_strategy() -> PostgresSortStrategy: @@ -1274,16 +1276,16 @@ def postgres_sort_strategies(self) -> dict[str, PostgresSortStrategy]: def _apply_type_visibility_filter( self, - group_queryset: BaseQuerySet, + group_queryset: BaseQuerySet[Group], search_filters: Sequence[SearchFilter] | None, - ) -> BaseQuerySet: + ) -> BaseQuerySet[Group]: return group_queryset.filter(type__in=group_types_from(search_filters)) def _execute_postgres_sort( self, strategy: PostgresSortStrategy, sort_by: str, - group_queryset: BaseQuerySet, + group_queryset: BaseQuerySet[Group], projects: Sequence[Project], environments: Sequence[Environment] | None, search_filters: Sequence[SearchFilter] | None, @@ -1340,9 +1342,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,14 +1501,19 @@ 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, *, strategy: PostgresSortStrategy, - group_queryset: BaseQuerySet, + group_queryset: BaseQuerySet[Group], projects: Sequence[Project], environments: Sequence[Environment] | None, search_filters: Sequence[SearchFilter] | None, @@ -1707,14 +1717,19 @@ 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, projects: Sequence[Project], retention_window_start: datetime | None, - group_queryset: BaseQuerySet, + group_queryset: BaseQuerySet[Group], environments: Sequence[Environment] | None, sort_by: str, limit: int, @@ -1855,7 +1870,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 +1957,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 +2073,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/search/utils.py b/src/sentry/search/utils.py index 8784a9133216..a18e8471cdde 100644 --- a/src/sentry/search/utils.py +++ b/src/sentry/search/utils.py @@ -316,7 +316,10 @@ def parse_team_value(projects: Sequence[Project], value: Sequence[str]) -> Team: ).first() or Team(id=0) -def get_teams_for_users(projects: Sequence[Project], users: Sequence[User]) -> list[Team]: +def get_teams_for_users( + projects: Sequence[Project], + users: Sequence[User | RpcUser | AnonymousUser | None], +) -> list[Team]: if not projects: return [] user_ids = [u.id for u in users if u is not None] @@ -332,7 +335,7 @@ def get_teams_for_users(projects: Sequence[Project], users: Sequence[User]) -> l def parse_actor_value( - projects: Sequence[Project], value: str, user: User | RpcUser | AnonymousUser + projects: Sequence[Project], value: str, user: User | RpcUser | AnonymousUser | None ) -> RpcUser | Team: if value.startswith("#"): return parse_team_value(projects, value) @@ -340,7 +343,7 @@ def parse_actor_value( def parse_actor_or_none_value( - projects: Sequence[Project], value: str, user: User | RpcUser | AnonymousUser + projects: Sequence[Project], value: str, user: User | RpcUser | AnonymousUser | None ) -> RpcUser | Team | None: if value == "none": return None @@ -352,7 +355,7 @@ def parse_actor_or_none_value( _HACKY_INVALID_USER = RpcUser(id=0) -def parse_user_value(value: str, user: User | RpcUser | AnonymousUser) -> RpcUser: +def parse_user_value(value: str, user: User | RpcUser | AnonymousUser | None) -> RpcUser: if value == "me": if isinstance(user, User): return serialize_rpc_user(user) @@ -376,7 +379,7 @@ def get_latest_release( projects: Sequence[Project | int], environments: Sequence[Environment] | None, organization_id: int | None = None, - adopted=False, + adopted: bool = False, ) -> list[str]: if organization_id is None: project = projects[0] diff --git a/src/sentry/seer/agent/client.py b/src/sentry/seer/agent/client.py index 563d35c710d9..603432117866 100644 --- a/src/sentry/seer/agent/client.py +++ b/src/sentry/seer/agent/client.py @@ -10,6 +10,7 @@ from django.contrib.auth.models import AnonymousUser from django.db.models import Q, QuerySet +from django.http import HttpRequest from django.utils import timezone as django_timezone from django.utils.timezone import now from pydantic import BaseModel @@ -45,7 +46,10 @@ make_feature_run_request, poll_until_done, ) -from sentry.seer.agent.coding_agent_handoff import launch_coding_agents +from sentry.seer.agent.coding_agent_handoff import ( + CodingAgentHandoffResult, + launch_coding_agents, +) from sentry.seer.agent.custom_tool_utils import AgentTool, extract_tool_schema from sentry.seer.agent.embed_widgets import get_embed_widgets from sentry.seer.agent.monitoring_providers import get_monitoring_provider_connections @@ -406,7 +410,7 @@ def start_run( artifact_key: str | None = None, artifact_schema: type[BaseModel] | None = None, metadata: dict[str, Any] | None = None, - request: Request | None = None, + request: HttpRequest | Request | None = None, override_ce_enable: bool = True, force_ce: bool | None = None, ui_tools: str | None = None, @@ -982,7 +986,10 @@ def fetch_latest_run_state( run = self.latest_run(group_id=group_id, only_current_user=only_current_user) if run is None: return None - return self.get_run(run.run.seer_run_state_id) + run_state_id = run.run.seer_run_state_id + if run_state_id is None: + return None + return self.get_run(run_state_id) def get_repos(self, run_id: int) -> BaseHTTPResponse: body = AgentReposRequest( @@ -1095,7 +1102,7 @@ def launch_coding_agents( user_id: int | None = None, issue_short_id: str | None = None, issue_url: str | None = None, - ) -> dict[str, list]: + ) -> CodingAgentHandoffResult: """ Launch coding agents for an agent run. diff --git a/src/sentry/seer/agent/client_utils.py b/src/sentry/seer/agent/client_utils.py index f453c1065901..ec762957e86a 100644 --- a/src/sentry/seer/agent/client_utils.py +++ b/src/sentry/seer/agent/client_utils.py @@ -17,6 +17,7 @@ from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.db import router, transaction +from django.http import HttpRequest from django.utils.timezone import now from rest_framework.request import Request from urllib3 import BaseHTTPResponse, HTTPConnectionPool @@ -423,7 +424,7 @@ def has_seer_agent_access_with_detail( def collect_user_org_context( user: SentryUser | RpcUser | AnonymousUser | None, organization: Organization, - request: Request | None = None, + request: HttpRequest | Request | None = None, ) -> UserOrgContext: """Collect user and organization context for a new agent run.""" all_projects = Project.objects.filter( diff --git a/src/sentry/seer/agent/coding_agent_handoff.py b/src/sentry/seer/agent/coding_agent_handoff.py index 085dfe0eef12..f121b5cad342 100644 --- a/src/sentry/seer/agent/coding_agent_handoff.py +++ b/src/sentry/seer/agent/coding_agent_handoff.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +from typing import NotRequired, TypedDict import sentry_sdk from requests import HTTPError @@ -35,6 +36,24 @@ logger = logging.getLogger(__name__) +class CodingAgentHandoffFailure(TypedDict): + repo_name: str + error_message: str + failure_type: str + github_installation_id: NotRequired[str] + github_installation_url: NotRequired[str] + + +class CodingAgentHandoffSuccess(TypedDict): + repo_name: str + coding_agent_state: dict[str, object] + + +class CodingAgentHandoffResult(TypedDict): + successes: list[CodingAgentHandoffSuccess] + failures: list[CodingAgentHandoffFailure] + + def _github_installation_for_repo( organization: Organization, repo: SeerRepoDefinition ) -> tuple[str | None, str | None]: @@ -103,7 +122,7 @@ def launch_coding_agents( user_id: int | None = None, issue_short_id: str | None = None, issue_url: str | None = None, -) -> dict[str, list]: +) -> CodingAgentHandoffResult: """ Launch coding agents for an agent run. @@ -131,8 +150,8 @@ def launch_coding_agents( client, installation = _resolve_client(organization, integration_id, provider, user_id) is_github_copilot = provider == "github_copilot" - successes = [] - failures = [] + successes: list[CodingAgentHandoffSuccess] = [] + failures: list[CodingAgentHandoffFailure] = [] states_to_store: list[CodingAgentState] = [] for repo in repos: @@ -196,7 +215,7 @@ def launch_coding_agents( failure_type = "cursor_github_access" error_message = "Cursor does not have GitHub access to this repository. Please install the Cursor GitHub App to grant access." - failure: dict = { + failure: CodingAgentHandoffFailure = { "repo_name": repo_name, "error_message": error_message, "failure_type": failure_type, diff --git a/src/sentry/seer/agent/service_map_utils.py b/src/sentry/seer/agent/service_map_utils.py index 449b5d21c5cf..8f7dc4565d11 100644 --- a/src/sentry/seer/agent/service_map_utils.py +++ b/src/sentry/seer/agent/service_map_utils.py @@ -12,7 +12,8 @@ import logging import math from collections import defaultdict -from typing import Any, cast +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, TypeAlias, cast import orjson from sentry_sdk import traces @@ -29,13 +30,20 @@ from sentry.snuba.referrer import Referrer from sentry.snuba.spans_rpc import Spans +if TYPE_CHECKING: + from sentry.models.project import Project + from sentry.projects.services.project.model import RpcProject + logger = logging.getLogger(__name__) +# justification: Snuba rows and the Seer payload are decoded JSON objects. +JsonObject: TypeAlias = dict[str, Any] + # Maximum rows Snuba returns per query _SNUBA_MAX_ROWS = 10000 -def _query_service_dependencies(snuba_params: SnubaParams) -> list[dict]: +def _query_service_dependencies(snuba_params: SnubaParams) -> list[JsonObject]: """ Query segment spans and their parent spans to find cross-project dependencies. @@ -57,9 +65,9 @@ def _query_service_dependencies(snuba_params: SnubaParams) -> list[dict]: seen: set[tuple[int, str | None]] = set() # (child_project_id, parent_span_id) covered_project_ids: set[int] = set() - segments_by_parent: dict[str, list[dict]] = defaultdict(list) + segments_by_parent: dict[str, list[JsonObject]] = defaultdict(list) - def _process_rows(rows: list[dict]) -> None: + def _process_rows(rows: list[JsonObject]) -> None: for row in rows: child_project_id = row.get("project.id") child_project_slug = row.get("project.slug") @@ -209,7 +217,9 @@ def _process_rows(rows: list[dict]) -> None: return edges -def _build_nodes(edges: list[dict], all_projects: list[Any]) -> list[dict]: +def _build_nodes( + edges: list[JsonObject], all_projects: Sequence[Project | RpcProject] +) -> list[JsonObject]: """ Build a node list from edges for the Seer payload in a single pass. @@ -292,7 +302,7 @@ def _build_nodes(edges: list[dict], all_projects: list[Any]) -> list[dict]: return nodes -def _send_to_seer(org_id: int, nodes: list[dict], edges: list[dict]) -> None: +def _send_to_seer(org_id: int, nodes: list[JsonObject], edges: list[JsonObject]) -> None: """ Send service map data to Seer. diff --git a/src/sentry/seer/agent/tools.py b/src/sentry/seer/agent/tools.py index aac72696e306..1b31eeaf8463 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 @@ -176,7 +176,7 @@ def _get_full_trace_id( data = subquery_result.get("data") full_trace_id = data[0].get("trace") if data else None - if full_trace_id: + if isinstance(full_trace_id, str) and full_trace_id: return full_trace_id return None @@ -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 = { + commits_by_id: dict[int, Commit] = { 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", []) + for commit, score in entry.get("commits", list[tuple[Commit, int]]()) ], } for entry in author_commits @@ -2260,7 +2260,8 @@ def get_replay_metadata( request_user_id=None, ) response = process_raw_response( - snuba_response, + # justification: Snuba replay rows are decoded JSON objects. + cast("list[dict[str, Any]]", snuba_response), fields=[], ) if not response: diff --git a/src/sentry/seer/agent/utils.py b/src/sentry/seer/agent/utils.py index 9227dac04c7a..2d6be12478c4 100644 --- a/src/sentry/seer/agent/utils.py +++ b/src/sentry/seer/agent/utils.py @@ -1,7 +1,7 @@ import logging import re from datetime import UTC, datetime, timedelta -from typing import Any +from typing import Any, TypeAlias, cast import orjson from django.core.exceptions import BadRequest @@ -17,6 +17,9 @@ logger = logging.getLogger(__name__) +# justification: profile payloads are decoded JSON objects. +JsonObject: TypeAlias = dict[str, Any] + def normalize_description(description: str) -> str: """ @@ -51,8 +54,8 @@ def normalize_description(description: str) -> str: def _convert_profile_to_execution_tree( - profile_data: dict, max_depth: int = 200 -) -> tuple[list[dict], str | None]: + profile_data: JsonObject, max_depth: int = 200 +) -> tuple[list[JsonObject], str | None]: """ Converts profile data into a hierarchical representation of code execution. Selects the thread with the most in_app frames. Returns empty list if no @@ -107,15 +110,16 @@ def _convert_profile_to_execution_tree( # No in_app frames found, return empty tree instead of falling back to system frames return [], None - def _get_elapsed_since_start_ns( - sample: dict[str, Any], all_samples: list[dict[str, Any]] - ) -> int: + def _get_elapsed_since_start_ns(sample: JsonObject, all_samples: list[JsonObject]) -> int: """ Get the elapsed time since start for a sample. Handles both transaction and continuous profiles. """ if "elapsed_since_start_ns" in sample: - return sample["elapsed_since_start_ns"] + elapsed = sample["elapsed_since_start_ns"] + if not isinstance(elapsed, int): + raise TypeError("Profile sample elapsed time is not an integer") + return elapsed elif "timestamp" in sample: min_timestamp = min(s["timestamp"] for s in all_samples) return int((sample["timestamp"] - min_timestamp) * 1e9) @@ -136,7 +140,7 @@ def _get_elapsed_since_start_ns( else: sample_interval_ns = 10000000 # default 10ms - def create_frame_node(frame_index: int) -> dict[str, Any]: + def create_frame_node(frame_index: int) -> JsonObject: """Create a node representation for a single frame""" frame = frames[frame_index] return { @@ -153,11 +157,11 @@ def create_frame_node(frame_index: int) -> dict[str, Any]: "duration_ns": None, } - def get_node_path(node: dict[str, Any], parent_path: str = "") -> str: + def get_node_path(node: JsonObject, parent_path: str = "") -> str: """Generate a unique path identifier for a node""" return f"{parent_path}/{node['function']}:{node['filename']}:{node['lineno']}" - def find_or_create_child(parent: dict[str, Any], frame_data: dict[str, Any]) -> dict[str, Any]: + def find_or_create_child(parent: JsonObject, frame_data: JsonObject) -> JsonObject: """Find existing child node or create new one""" for child in parent["children"]: if ( @@ -166,12 +170,13 @@ def find_or_create_child(parent: dict[str, Any], frame_data: dict[str, Any]) -> and child["filename"] == frame_data["filename"] and child["lineno"] == frame_data["lineno"] ): - return child + # justification: children are profile frame JSON objects. + return cast("JsonObject", child) parent["children"].append(frame_data) return frame_data - def is_valid_frame(frame: dict[str, Any], include_all_frames: bool) -> bool: + def is_valid_frame(frame: JsonObject, include_all_frames: bool) -> bool: """Check if a frame should be included in the execution tree.""" filename = frame.get("filename", "") is_generated = filename.startswith("<") and filename.endswith(">") @@ -179,7 +184,7 @@ def is_valid_frame(frame: dict[str, Any], include_all_frames: bool) -> bool: return False return include_all_frames or frame.get("in_app", False) - def process_stack(stack_index: int, include_all_frames: bool = False) -> list[dict[str, Any]]: + def process_stack(stack_index: int, include_all_frames: bool = False) -> list[JsonObject]: """Extract frames from a stack trace. Args: @@ -189,7 +194,7 @@ def process_stack(stack_index: int, include_all_frames: bool = False) -> list[di """ frame_indices = stacks[stack_index] if not frame_indices: - empty_stack: list[dict[str, Any]] = [] + empty_stack: list[JsonObject] = [] return empty_stack # Create nodes for frames, maintaining order (bottom to top) @@ -207,9 +212,9 @@ def process_stack(stack_index: int, include_all_frames: bool = False) -> list[di return empty_tree_3, None # Build the execution tree and track call stacks - execution_tree: list[dict[str, Any]] = [] + execution_tree: list[JsonObject] = [] call_stack_history: list[tuple[list[str], int]] = [] # [(node_ids, timestamp), ...] - node_registry: dict[str, dict[str, Any]] = {} # {node_id: node_reference} + node_registry: dict[str, JsonObject] = {} # {node_id: node_reference} for sample in sorted_samples: if str(sample["thread_id"]) != selected_thread_id: @@ -306,7 +311,7 @@ def process_stack(stack_index: int, include_all_frames: bool = False) -> list[di function_periods[node_id][-1][1] = last_timestamp + sample_interval_ns # Calculate durations - def apply_durations(node): + def apply_durations(node: JsonObject) -> None: """Calculate and set duration for a node and its children""" node_id = node["node_id"] @@ -353,7 +358,9 @@ def apply_durations(node): return execution_tree, selected_thread_id -def convert_profile_to_execution_tree(profile_data: dict) -> list[ExecutionTreeNode]: +def convert_profile_to_execution_tree( + profile_data: JsonObject, +) -> list[ExecutionTreeNode]: """ Converts profile data into a hierarchical representation of code execution. Selects the thread with the most in_app frames. Returns empty list if no @@ -362,7 +369,7 @@ def convert_profile_to_execution_tree(profile_data: dict) -> list[ExecutionTreeN """ dict_tree, _ = _convert_profile_to_execution_tree(profile_data) - def dict_to_execution_tree_node(node_dict: dict) -> ExecutionTreeNode: + def dict_to_execution_tree_node(node_dict: JsonObject) -> ExecutionTreeNode: """Convert a dict node to an ExecutionTreeNode Pydantic object.""" children = [dict_to_execution_tree_node(child) for child in node_dict.get("children", [])] @@ -390,7 +397,7 @@ def fetch_profile_data( start_ts: float | None = None, end_ts: float | None = None, is_continuous: bool = False, -) -> dict[str, Any] | None: +) -> JsonObject | None: """ Fetch raw profile data from the profiling service. @@ -465,7 +472,10 @@ def fetch_profile_data( }, ) if response.status == 200: - return orjson.loads(response.data) + data = orjson.loads(response.data) + if not isinstance(data, dict): + raise TypeError("Profiling service returned a non-object response") + return data return None diff --git a/src/sentry/seer/agent_token.py b/src/sentry/seer/agent_token.py index 1d79f088c5c1..d40638227c93 100644 --- a/src/sentry/seer/agent_token.py +++ b/src/sentry/seer/agent_token.py @@ -215,7 +215,7 @@ def is_agent_token_string(token_str: str) -> bool: audience, expiry, algorithm, and protected ``typ`` before the credential is trusted. """ try: - return jwt.peek_header(token_str).get("typ") == AGENT_TOKEN_TYPE + return bool(jwt.peek_header(token_str).get("typ") == AGENT_TOKEN_TYPE) except PyJWTError: return False diff --git a/src/sentry/seer/anomaly_detection/delete_rule.py b/src/sentry/seer/anomaly_detection/delete_rule.py index 611546e192c9..d3efc10feac8 100644 --- a/src/sentry/seer/anomaly_detection/delete_rule.py +++ b/src/sentry/seer/anomaly_detection/delete_rule.py @@ -39,7 +39,7 @@ def make_delete_alert_data_request( from sentry.workflow_engine.models.detector import Detector -def delete_data_in_seer_for_detector(detector: Detector): +def delete_data_in_seer_for_detector(detector: Detector) -> None: from sentry.incidents.models.alert_rule import AlertRuleDetectionType from sentry.workflow_engine.models import DataSourceDetector diff --git a/src/sentry/seer/anomaly_detection/get_anomaly_data.py b/src/sentry/seer/anomaly_detection/get_anomaly_data.py index b32e6998ec1c..da5e029cca15 100644 --- a/src/sentry/seer/anomaly_detection/get_anomaly_data.py +++ b/src/sentry/seer/anomaly_detection/get_anomaly_data.py @@ -177,7 +177,7 @@ def get_anomaly_data_from_seer( ) return None - extra_data = { + extra_data: dict[str, object] = { "subscription_id": subscription.id, "organization_id": subscription.project.organization_id, "project_id": subscription.project_id, diff --git a/src/sentry/seer/anomaly_detection/get_historical_anomalies.py b/src/sentry/seer/anomaly_detection/get_historical_anomalies.py index 4e23fb7bb300..1224df10d065 100644 --- a/src/sentry/seer/anomaly_detection/get_historical_anomalies.py +++ b/src/sentry/seer/anomaly_detection/get_historical_anomalies.py @@ -48,8 +48,15 @@ def make_detect_historical_anomalies_request( ) -def handle_seer_error_responses(response, config, context, log_params): - def log_statement(log_level, text, extra_data=None): +def handle_seer_error_responses( + response: BaseHTTPResponse, + config: AnomalyDetectionConfig, + context: DetectHistoricalAnomaliesContext, + log_params: dict[str, object], +) -> bool: + def log_statement( + log_level: str, text: str, extra_data: dict[str, object] | None = None + ) -> None: log_data = {**log_params} if extra_data: log_data.update(**extra_data) @@ -103,7 +110,7 @@ def get_historical_anomaly_data_from_seer_preview( organization_id: int, project_id: int, config: AnomalyDetectionConfig, -) -> list | None: +) -> list[TimeSeriesPoint] | None: """ Send current and historical timeseries data to Seer and return anomaly detection response on the current timeseries. Used for rendering the preview charts of anomaly detection alert rules. diff --git a/src/sentry/seer/anomaly_detection/utils.py b/src/sentry/seer/anomaly_detection/utils.py index 7620c4f36fb3..5fbc5f20adc6 100644 --- a/src/sentry/seer/anomaly_detection/utils.py +++ b/src/sentry/seer/anomaly_detection/utils.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta -from typing import Any +from typing import Any, cast +from django.http import QueryDict from django.utils import timezone from django.utils.datastructures import MultiValueDict from rest_framework.exceptions import ParseError @@ -99,7 +100,7 @@ def get_snuba_query_string( def get_crash_free_historical_data( start: datetime, end: datetime, project: Project, organization: Organization, granularity: int -): +) -> SnubaTSResult: """ Fetch the historical metrics data from Snuba for crash free user rate and crash free session rate metrics """ @@ -120,7 +121,8 @@ def get_crash_free_historical_data( } ) query = QueryDefinition( - query=query_params, + # justification: QueryDefinition only reads get/getlist, which MultiValueDict provides. + query=cast("QueryDict", query_params), params=params, offset=None, limit=None, @@ -174,7 +176,7 @@ def format_snuba_ts_data( extra_columns=None, ) - for data in serialized_result.get("data"): + for data in serialized_result.get("data") or []: if len(data) > 1: count_data = data[1] count = 0 @@ -205,7 +207,7 @@ def format_historical_data( def get_dataset_from_label_and_event_types( dataset_label: str, event_types: list[SnubaQueryEventType.EventType] | None = None -): +) -> Any: # justification: registry mixes snuba modules and RPC dataset classes. dataset_label = get_dataset_name_from_label_and_event_types(dataset_label, event_types) dataset = get_dataset(dataset_label) if dataset is None: @@ -345,4 +347,5 @@ def fetch_historical_data( allow_metric_aggregates=True, on_demand_metrics_type=MetricSpecType.SIMPLE_QUERY, ) - return historical_data + # justification: the dataset module is selected at runtime and returns SnubaTSResult. + return cast("SnubaTSResult | None", historical_data) diff --git a/src/sentry/seer/assisted_query/discover_tools.py b/src/sentry/seer/assisted_query/discover_tools.py index e50334b43341..8f9dd3072e49 100644 --- a/src/sentry/seer/assisted_query/discover_tools.py +++ b/src/sentry/seer/assisted_query/discover_tools.py @@ -250,7 +250,7 @@ def get_event_filter_keys( def get_event_filter_key_values( *, - org_id, + org_id: int, filter_key: str, substring: str | None = None, project_ids: list[int] | None = None, diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index d677a1e5187d..cdfc9d070da7 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -3,7 +3,7 @@ import concurrent.futures import logging from datetime import datetime, timedelta -from typing import Any +from typing import Any, TypedDict from sentry_sdk import traces @@ -21,6 +21,20 @@ logger = logging.getLogger(__name__) +class TagValueOverview(TypedDict): + value: str | None + count: int + percentage: str + + +class TagOverview(TypedDict): + key: str + name: str + total_values: int | None + unique_values: int | None + top_values: list[TagValueOverview] + + def get_trace_tree_for_event( event: Event | GroupEvent, project: Project, timeout: int = 15 ) -> dict[str, Any] | None: @@ -32,7 +46,7 @@ def get_trace_tree_for_event( if not trace_id: return None - def _fetch_trace(): + def _fetch_trace() -> dict[str, Any] | None: # justification: Seer payloads are decoded JSON. projects_qs = Project.objects.filter( organization=project.organization, status=ObjectStatus.ACTIVE ) @@ -129,7 +143,7 @@ def get_all_tags_overview( end=end, ) - all_tags: list[dict] = [] + all_tags: list[TagOverview] = [] KEYS_TO_EXCLUDE = { "release", @@ -147,7 +161,7 @@ def get_all_tags_overview( continue # Calculate percentages for each tag value - tag_data = { + tag_data: TagOverview = { "key": tag.key, "name": tagstore.backend.get_tag_key_label(tag.key), "total_values": tag.count, @@ -156,17 +170,25 @@ def get_all_tags_overview( } if hasattr(tag, "top_values") and tag.top_values: + assert tag.count is not None # Calculate total from top values - top_values_total = sum(tag_value.times_seen for tag_value in tag.top_values) + top_values_total = 0 + for tag_value in tag.top_values: + assert tag_value.times_seen is not None + top_values_total += tag_value.times_seen for tag_value in tag.top_values: - percentage = round((tag_value.times_seen / tag.count) * 100) if tag.count > 0 else 0 + assert tag_value.times_seen is not None + percentage_num = ( + round((tag_value.times_seen / tag.count) * 100) if tag.count > 0 else 0 + ) + percentage: int | str = percentage_num # Ensure no single value shows 100% when there are multiple values has_multiple_values = len(tag.top_values) > 1 or top_values_total < tag.count - if has_multiple_values and percentage >= 100: + if has_multiple_values and percentage_num >= 100: percentage = ">99" - elif percentage < 1: + elif percentage_num < 1: percentage = "<1" tag_data["top_values"].append( diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 0f37e01babc9..3a109711f30f 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -831,7 +831,7 @@ def trigger_push_changes( author: SeerCommitAuthor | None = None, user: User | RpcUser | AnonymousUser | None = None, allow_force_push: bool = True, -): +) -> None: if not group.organization.get_option( "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT ): diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index dc621a85d68a..233c12b852e2 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -21,6 +21,8 @@ class IntegrationNotFound(NotFound): from sentry.integrations.github_copilot.client import GithubCopilotAgentClient from sentry.integrations.services.github_copilot_identity import github_copilot_identity_service from sentry.integrations.services.integration import integration_service +from sentry.integrations.services.integration.model import RpcIntegration +from sentry.models.organization import Organization from sentry.models.pullrequest import PullRequestAttributionSignalType, parse_pull_request_url from sentry.pr_metrics.attribution import attribute_delegated_agent_pull_request from sentry.seer.autofix.coding_agent_handoffs import sync_coding_agent_status @@ -107,7 +109,9 @@ def store_coding_agent_states_to_seer( ) -def validate_and_get_integration(organization, integration_id: int): +def validate_and_get_integration( + organization: Organization, integration_id: int +) -> tuple[RpcIntegration, CodingAgentIntegration]: """Validate request and get the coding agent integration.""" integration_id_int = integration_id @@ -352,10 +356,11 @@ def poll_claude_code_agents( poll_claude_agent(clients, agent_id, org_id, agent_state, run_id=run_id, group_id=group_id) +# justification: clients are provider-specific HTTP clients keyed by integration id. def poll_claude_agent( - clients, - agent_id, - org_id, + clients: dict[int, Any], # justification: Seer payloads are decoded JSON. + agent_id: str, + org_id: int, agent_state: CodingAgentState, run_id: int | None = None, group_id: int | None = None, @@ -439,7 +444,12 @@ def poll_claude_agent( ) -def get_claude_code_client(clients, agent_id, org_id, integration_id: int | None) -> Any | None: +def get_claude_code_client( + clients: dict[int, Any], # justification: Seer payloads are decoded JSON. + agent_id: str, + org_id: int, + integration_id: int | None, # justification: Seer payloads are decoded JSON. +) -> Any | None: # justification: Seer payloads are decoded JSON. if integration_id is None: logger.warning( "coding_agent.claude_code.missing_integration_id", diff --git a/src/sentry/seer/autofix/coding_agent_handoffs.py b/src/sentry/seer/autofix/coding_agent_handoffs.py index a33c7eee50f3..101e030e9dd6 100644 --- a/src/sentry/seer/autofix/coding_agent_handoffs.py +++ b/src/sentry/seer/autofix/coding_agent_handoffs.py @@ -6,7 +6,7 @@ from __future__ import annotations import logging -from typing import NamedTuple +from typing import NamedTuple, cast from sentry.models.organization import Organization from sentry.models.pullrequest import parse_pull_request_url @@ -121,10 +121,14 @@ def sync_coding_agent_status( handoff.status = status.value update_fields = ["status", "date_updated"] if agent_url is not None: - extras: SeerRunCodingAgentHandoffExtras = { - **handoff.extras, - "agent_url": agent_url, - } + # justification: mypy cannot prove a spread plus one optional key satisfies the TypedDict. + extras = cast( + "SeerRunCodingAgentHandoffExtras", + { + **handoff.extras, + "agent_url": agent_url, + }, + ) handoff.extras = extras update_fields.append("extras") handoff.save(update_fields=update_fields) diff --git a/src/sentry/seer/autofix/issue_summary.py b/src/sentry/seer/autofix/issue_summary.py index c9feb49eaf49..a4086f19bebc 100644 --- a/src/sentry/seer/autofix/issue_summary.py +++ b/src/sentry/seer/autofix/issue_summary.py @@ -143,7 +143,7 @@ def _trigger_autofix_task( auto_run_source: str, referrer: AutofixReferrer | str = AutofixReferrer.UNKNOWN, stopping_point: AutofixStoppingPoint | str | None = None, -): +) -> None: """ Asynchronous task to trigger Autofix. Task queue serializes enum parameters to strings, so we need to convert them back. @@ -208,9 +208,9 @@ def _trigger_autofix_task( pass if run and SeerAutofixOperator.has_access(organization=group.project.organization): - SeerOperatorAutofixCache.migrate( - from_group_id=group_id, to_run_id=run.seer_run_state_id - ) + run_state_id = run.seer_run_state_id + if run_state_id is not None: + SeerOperatorAutofixCache.migrate(from_group_id=group_id, to_run_id=run_state_id) def _get_event( @@ -250,7 +250,7 @@ def _call_seer( serialized_event: dict[str, Any], trace_tree: dict[str, Any] | None, experiment_variant: str | None = None, -): +) -> SummarizeIssueResponse: body = SummarizeIssueRequest( group_id=group.id, issue={ diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index fe804e633a54..551be37d1e4c 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -332,9 +332,10 @@ def _send_step_webhook( seer_run_state_id=run_id, ).first() - webhook_payload = { + sentry_run_id = str(seer_run.uuid) if seer_run is not None else None + webhook_payload: dict[str, object] = { "run_id": run_id, - "sentry_run_id": str(seer_run.uuid) if seer_run is not None else None, + "sentry_run_id": sentry_run_id, "group_id": group.id, } @@ -448,7 +449,7 @@ def _send_step_webhook( emit_pr_ready_for_review( organization=organization, group=group, - sentry_run_id=webhook_payload["sentry_run_id"], + sentry_run_id=sentry_run_id, state=state, ) @@ -477,7 +478,9 @@ def _send_step_webhook( ) @classmethod - def _format_code_changes_payload(cls, state: SeerRunState) -> dict: + def _format_code_changes_payload( + cls, state: SeerRunState + ) -> dict[str, Any]: # justification: Seer payloads are decoded JSON. diffs_by_repo = state.get_diffs_by_repo() return { repo: [ diff --git a/src/sentry/seer/autofix/pr_iteration/completion.py b/src/sentry/seer/autofix/pr_iteration/completion.py index 2a7f23e2389e..fb38b3ff32b8 100644 --- a/src/sentry/seer/autofix/pr_iteration/completion.py +++ b/src/sentry/seer/autofix/pr_iteration/completion.py @@ -197,7 +197,9 @@ def iteration_completed_webhook_fields( organization: Organization, group: Group, state: SeerRunState, - format_code_changes: Callable[[SeerRunState], dict], + format_code_changes: Callable[ + [SeerRunState], dict[str, Any] # justification: Seer payloads are decoded JSON. + ], # justification: Seer payloads are decoded JSON. *, retrying: bool, ) -> dict[str, Any] | None: diff --git a/src/sentry/seer/autofix/pr_iteration/completion_reactions.py b/src/sentry/seer/autofix/pr_iteration/completion_reactions.py index e919e30d77e4..d4edd4eb746d 100644 --- a/src/sentry/seer/autofix/pr_iteration/completion_reactions.py +++ b/src/sentry/seer/autofix/pr_iteration/completion_reactions.py @@ -54,7 +54,7 @@ def _repo_name_for_feedback( organization_id: int, ) -> str | None: repo_name = getattr(source, "repo_name", None) - if repo_name is not None: + if isinstance(repo_name, str) and repo_name is not None: return repo_name if len(state.repo_pr_states) == 1: logger.info( diff --git a/src/sentry/seer/autofix/pr_iteration/feedback_sources/check_suite.py b/src/sentry/seer/autofix/pr_iteration/feedback_sources/check_suite.py index 4adbaee1d625..751a98fa88b9 100644 --- a/src/sentry/seer/autofix/pr_iteration/feedback_sources/check_suite.py +++ b/src/sentry/seer/autofix/pr_iteration/feedback_sources/check_suite.py @@ -2,7 +2,7 @@ import logging from datetime import timedelta -from typing import Any, Literal +from typing import Any, Literal, cast from pydantic import Field, PrivateAttr, root_validator from sentry_sdk import traces @@ -84,7 +84,8 @@ def autofix_run(self) -> CheckSuiteAutofixRun: ``MissingCheckSuiteAutofixRun`` when no run is found. """ if self._autofix_run is not None: - return self._autofix_run + # justification: the private attr caches a resolved CheckSuiteAutofixRun. + return cast("CheckSuiteAutofixRun", self._autofix_run) autofix_run = resolve_check_suite_autofix_run(self.event) if autofix_run is None: diff --git a/src/sentry/seer/autofix/pr_ready_for_review.py b/src/sentry/seer/autofix/pr_ready_for_review.py index 7ab1aa2e97e8..bf30af574f36 100644 --- a/src/sentry/seer/autofix/pr_ready_for_review.py +++ b/src/sentry/seer/autofix/pr_ready_for_review.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from typing import Any from sentry.models.group import Group from sentry.models.organization import Organization @@ -18,7 +19,9 @@ def _public_pr_id(pr_id: str | None) -> int | str | None: return pr_id -def format_pull_requests_payload(state: SeerRunState) -> list[dict]: +def format_pull_requests_payload( + state: SeerRunState, +) -> list[dict[str, Any]]: # justification: Seer payloads are decoded JSON. return [ { "provider": pull_request.provider or "unknown", diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index bcf7a31a77cd..082edd1f3767 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -177,7 +177,7 @@ class AutofixState(BaseModel): status: AutofixStatus actor_ids: list[str] | None = None codebases: dict[str, CodebaseState] = {} - steps: list[dict] = [] + steps: list[dict[str, Any]] = [] # justification: Seer payloads are decoded JSON. coding_agents: dict[str, CodingAgentState] = {} class Config: @@ -465,7 +465,7 @@ def get_repo_url_path(repo: Repository) -> str: """ if repo.provider == "integrations:gitlab": path = repo.config.get("path") - if not path: + if not isinstance(path, str) or not path: raise ValueError( f"GitLab repository {repo.id} is missing config['path'] (path_with_namespace)" ) @@ -899,7 +899,7 @@ def has_project_connected_repos(organization: Organization, project: Project) -> def get_autofix_repos_from_project_code_mappings( project: Project, code_mappings: list[RepositoryProjectPathConfig] | None = None, -) -> list[dict]: +) -> list[dict[str, Any]]: # justification: Seer payloads are decoded JSON. if settings.SEER_AUTOFIX_FORCE_USE_REPOS: # This is for testing purposes only, for example in s4s we want to force the use of specific repo(s) return settings.SEER_AUTOFIX_FORCE_USE_REPOS @@ -907,7 +907,10 @@ def get_autofix_repos_from_project_code_mappings( if code_mappings is None: code_mappings = get_sorted_code_mapping_configs(project) - repos: dict[tuple, dict] = {} + repos: dict[ + tuple[object, object, object], + dict[str, Any], # justification: Seer payloads are decoded JSON. + ] = {} # justification: Seer payloads are decoded JSON. for code_mapping in code_mappings: repo: Repository = code_mapping.project_repository.repository repo_name_sections = get_repo_url_path(repo).split("/") @@ -991,7 +994,7 @@ def is_seer_seat_based_tier_enabled(organization: Organization) -> bool: """ cache_key = get_seer_seat_based_tier_cache_key(organization.id) cached_value = cache.get(cache_key) - if cached_value is not None: + if isinstance(cached_value, bool): return cached_value try: diff --git a/src/sentry/seer/breakpoints.py b/src/sentry/seer/breakpoints.py index edace6b9ae4c..e0809d841d77 100644 --- a/src/sentry/seer/breakpoints.py +++ b/src/sentry/seer/breakpoints.py @@ -1,6 +1,6 @@ import logging from collections.abc import Mapping -from typing import NotRequired, TypedDict +from typing import NotRequired, TypedDict, cast import sentry_sdk from django.conf import settings @@ -92,7 +92,11 @@ def detect_breakpoints( if response.status >= 200 and response.status < 300: try: - return json.loads(response.data) + payload = json.loads(response.data) + if not isinstance(payload, dict): + raise ValueError("breakpoint detection response is not an object") + # justification: Seer returns the BreakpointResponse payload. + return cast("BreakpointResponse", payload) except ValueError as e: # seer failed to return valid json, report the error # and assume no breakpoints were found diff --git a/src/sentry/seer/endpoints/compare.py b/src/sentry/seer/endpoints/compare.py index 170b9ebb30ec..60c35ca668bb 100644 --- a/src/sentry/seer/endpoints/compare.py +++ b/src/sentry/seer/endpoints/compare.py @@ -15,8 +15,8 @@ def compare_distributions( - baseline: list, - outliers: list, + baseline: list[dict[str, Any]], + outliers: list[dict[str, Any]], total_baseline: int, total_outliers: int, config: dict[str, Any], diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index 44fd0ce0a5ac..3af7ce75c1a5 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -202,7 +202,7 @@ def _check_autofix_setup(organization: Organization, project: Project) -> str | return None -class ExplorerAutofixRequestSerializer(CamelSnakeSerializer): +class ExplorerAutofixRequestSerializer(CamelSnakeSerializer[dict[str, Any]]): """Serializer for the agent-based autofix requests.""" step = serializers.ChoiceField( @@ -386,7 +386,7 @@ def post( referrer = _parse_autofix_referrer(data.get("referrer"), request) - run_id: int + run_id: int | None sentry_run_id: str | None match step: diff --git a/src/sentry/seer/endpoints/group_autofix_repos.py b/src/sentry/seer/endpoints/group_autofix_repos.py index 048aa283fc18..05c7d73e6e72 100644 --- a/src/sentry/seer/endpoints/group_autofix_repos.py +++ b/src/sentry/seer/endpoints/group_autofix_repos.py @@ -48,12 +48,17 @@ def get(self, request: Request, group: Group) -> Response: status=status.HTTP_403_FORBIDDEN, ) + assert group.id is not None run = client.latest_run(group_id=group.id) if run is None: return Response({"repos": []}, status=status.HTTP_200_OK) + run_state_id = run.run.seer_run_state_id + if run_state_id is None: + return Response({"repos": []}, status=status.HTTP_200_OK) + try: - response = client.get_repos(run.run.seer_run_state_id) + response = client.get_repos(run_state_id) except Exception: return Response( {"detail": "Failed to reach Seer"}, diff --git a/src/sentry/seer/endpoints/organization_events_anomalies.py b/src/sentry/seer/endpoints/organization_events_anomalies.py index 78da0665de44..53d081d2d261 100644 --- a/src/sentry/seer/endpoints/organization_events_anomalies.py +++ b/src/sentry/seer/endpoints/organization_events_anomalies.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence + from drf_spectacular.utils import extend_schema from rest_framework.request import Request from rest_framework.response import Response @@ -49,7 +53,9 @@ class OrganizationEventsAnomaliesEndpoint(OrganizationEventsEndpointBase): }, examples=OrganizationExamples.GET_HISTORICAL_ANOMALIES, ) - def _format_historical_data(self, data) -> list[TimeSeriesPoint] | None: + def _format_historical_data( + self, data: Sequence[tuple[int, Mapping[str, int | None]]] | None + ) -> list[TimeSeriesPoint] | None: """ Format EventsStatsData into the format that the Seer API expects. EventsStatsData is a list of lists with this format: diff --git a/src/sentry/seer/endpoints/organization_seer_agent_chat.py b/src/sentry/seer/endpoints/organization_seer_agent_chat.py index a078aacecab7..ce956275cc80 100644 --- a/src/sentry/seer/endpoints/organization_seer_agent_chat.py +++ b/src/sentry/seer/endpoints/organization_seer_agent_chat.py @@ -47,7 +47,7 @@ class SeerAgentChatStateResponse(BaseModel): sentry_run_id: str | None -class CodeModeField(serializers.Field): +class CodeModeField(serializers.Field[str | None, str | None, str | None, object]): """Accepts 'off'|'only', plus the retired 'on' and booleans from stored frontend settings. True and 'on' read as 'only'. DRF's CharField rejects raw booleans before the field-level diff --git a/src/sentry/seer/endpoints/organization_seer_autofix_overview.py b/src/sentry/seer/endpoints/organization_seer_autofix_overview.py index 56e4163d2e5a..ba76a713d592 100644 --- a/src/sentry/seer/endpoints/organization_seer_autofix_overview.py +++ b/src/sentry/seer/endpoints/organization_seer_autofix_overview.py @@ -265,8 +265,11 @@ def _coding_agent_repo_eligibility( return eligibility +# justification: serialized group output is a dynamic API payload. def _serialize_issue( - group: Group, serialized_group: dict, repo_eligibility: dict[int, _RepoEligibility] | None + group: Group, + serialized_group: dict[str, Any], + repo_eligibility: dict[int, _RepoEligibility] | None, ) -> IssuePayload: project: IssueProjectPayload = { "id": str(group.project_id), @@ -315,7 +318,7 @@ def _serialize_code_changes(artifact: CodeChangesArtifactExtras) -> list[CodeCha def _serialize_run( group: Group, run: _RunMilestones, - serialized_group: dict, + serialized_group: dict[str, Any], pull_requests: list[PullRequestPayload], status: str | None, repo_eligibility: dict[int, _RepoEligibility] | None, @@ -494,13 +497,25 @@ def get(self, request: Request, organization: Organization) -> Response: group = groups.get(group_id) if group is None: continue + seer_run_id = run.seer_run.id + seer_run_state_id = run.seer_run.seer_run_state_id + pull_requests = ( + pull_requests_by_seer_run_id.get(seer_run_id, []) + if seer_run_id is not None + else [] + ) + status = ( + status_by_state_id.get(seer_run_state_id) + if seer_run_state_id is not None + else None + ) runs_by_milestone[milestone].append( _serialize_run( group, run, serialized_by_id[str(group_id)], - pull_requests_by_seer_run_id.get(run.seer_run.id, []), - status_by_state_id.get(run.seer_run.seer_run_state_id), + pull_requests, + status, issue_repo_eligibility, ) ) diff --git a/src/sentry/seer/endpoints/organization_seer_autofix_overview_types.py b/src/sentry/seer/endpoints/organization_seer_autofix_overview_types.py index dec0d2ba6df8..83e4b8329626 100644 --- a/src/sentry/seer/endpoints/organization_seer_autofix_overview_types.py +++ b/src/sentry/seer/endpoints/organization_seer_autofix_overview_types.py @@ -48,8 +48,8 @@ class IssuePayload(TypedDict): priorityLockedAt: str | None issueType: str | None issueCategory: str | None - assignedTo: dict | None - owners: list + assignedTo: dict[str, object] | None + owners: list[object] project: IssueProjectPayload @@ -77,7 +77,7 @@ class ProposedFixPayload(TypedDict): class RunPayload(TypedDict): groupId: str - shortId: str + shortId: str | None title: str rootCause: RootCausePayload | None proposedFix: ProposedFixPayload | None diff --git a/src/sentry/seer/endpoints/organization_seer_runs.py b/src/sentry/seer/endpoints/organization_seer_runs.py index 5f41da75b1f3..387d63b47592 100644 --- a/src/sentry/seer/endpoints/organization_seer_runs.py +++ b/src/sentry/seer/endpoints/organization_seer_runs.py @@ -62,7 +62,7 @@ def _fetch_run_outputs( return {} answers_by_state_id = get_run_questions( - [run.seer_run_state_id for run in qualifying], + [run.seer_run_state_id for run in qualifying if run.seer_run_state_id is not None], organization, questions=questions, user_id=user_id, @@ -75,10 +75,12 @@ def to_output(q: RunQuestion) -> RunQuestionOutput: output["question"] = q["question"] return output - return { - run.id: [to_output(q) for q in answers_by_state_id.get(run.seer_run_state_id, [])] - for run in qualifying - } + output: dict[int, list[RunQuestionOutput]] = {} + for run in qualifying: + state_id = run.seer_run_state_id + assert state_id is not None + output[run.id] = [to_output(q) for q in answers_by_state_id.get(state_id, [])] + return output class OrganizationSeerRunsPermission(OrganizationPermission): diff --git a/src/sentry/seer/endpoints/project_seer_preferences.py b/src/sentry/seer/endpoints/project_seer_preferences.py index f09b3a580ff5..f3e881f3c180 100644 --- a/src/sentry/seer/endpoints/project_seer_preferences.py +++ b/src/sentry/seer/endpoints/project_seer_preferences.py @@ -1,5 +1,8 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence +from typing import Any + import sentry_sdk from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers @@ -25,14 +28,14 @@ from sentry.types.ratelimit import RateLimit, RateLimitCategory -class BranchOverrideSerializer(CamelSnakeSerializer): +class BranchOverrideSerializer(CamelSnakeSerializer[dict[str, Any]]): tag_name = serializers.CharField(required=True) tag_value = serializers.CharField(required=True) branch_name = serializers.CharField(required=True) @extend_schema_serializer(deprecate_fields=["owner", "name"]) -class RepositorySerializer(CamelSnakeSerializer): +class RepositorySerializer(CamelSnakeSerializer[dict[str, Any]]): repository_id = serializers.IntegerField(required=False) provider = serializers.CharField(required=False) owner = serializers.CharField(required=False) @@ -48,7 +51,7 @@ class RepositorySerializer(CamelSnakeSerializer): base_commit_sha = serializers.CharField(required=False, allow_null=True) provider_raw = serializers.CharField(required=False, allow_null=True) - def validate(self, attrs): + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: if attrs.get("repository_id") is not None: return attrs @@ -79,7 +82,9 @@ def validate(self, attrs): ) return attrs - def validate_branch_overrides(self, value): + def validate_branch_overrides( + self, value: Sequence[Mapping[str, str]] + ) -> Sequence[Mapping[str, str]]: if not value: return value seen = set() @@ -93,7 +98,7 @@ def validate_branch_overrides(self, value): return value -class SeerAutomationHandoffConfigurationSerializer(CamelSnakeSerializer): +class SeerAutomationHandoffConfigurationSerializer(CamelSnakeSerializer[dict[str, Any]]): handoff_point = serializers.ChoiceField( choices=["root_cause"], required=True, @@ -106,7 +111,7 @@ class SeerAutomationHandoffConfigurationSerializer(CamelSnakeSerializer): auto_create_pr = serializers.BooleanField(required=False, default=False) -class ProjectSeerPreferencesSerializer(CamelSnakeSerializer): +class ProjectSeerPreferencesSerializer(CamelSnakeSerializer[dict[str, Any]]): repositories = RepositorySerializer(many=True, required=True) automated_run_stopping_point = serializers.CharField(required=False, allow_null=True) automation_handoff = SeerAutomationHandoffConfigurationSerializer( diff --git a/src/sentry/seer/endpoints/project_seer_repos.py b/src/sentry/seer/endpoints/project_seer_repos.py index f96babde190c..689fd79276f4 100644 --- a/src/sentry/seer/endpoints/project_seer_repos.py +++ b/src/sentry/seer/endpoints/project_seer_repos.py @@ -1,8 +1,8 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from functools import partial -from typing import TypedDict +from typing import Any, TypedDict from django.db import router, transaction from django.db.models import QuerySet, Value @@ -176,7 +176,8 @@ def _validate_repo_ids(project: Project, repo_ids: list[int]) -> None: raise ValueError(sorted(invalid_ids)) -def _apply_search_filters(queryset, filters: Sequence[QueryToken]): +# justification: querysets carry runtime annotate()/values() shapes the Django plugin cannot express. +def _apply_search_filters(queryset: Any, filters: Sequence[QueryToken]) -> Any: for f in filters: if not isinstance(f, SearchFilter): continue @@ -196,7 +197,10 @@ def _apply_search_filters(queryset, filters: Sequence[QueryToken]): elif key == "provider": if op not in ("=", "!=", "IN", "NOT IN"): raise InvalidSearchQuery(f"provider does not support the {op} operator.") - normalize = lambda v: v.removeprefix("integrations:") + + def normalize(provider: str) -> str: + return provider.removeprefix("integrations:") + if op == "=": queryset = queryset.filter(provider_normalized=normalize(value)) elif op == "!=": @@ -209,7 +213,9 @@ def _apply_search_filters(queryset, filters: Sequence[QueryToken]): return queryset -def _validate_branch_overrides(value): +def _validate_branch_overrides( + value: Sequence[Mapping[str, str]], +) -> Sequence[Mapping[str, str]]: if not value: return value seen: set[tuple[str, str]] = set() @@ -223,7 +229,7 @@ def _validate_branch_overrides(value): return value -class BranchOverrideSerializer(CamelSnakeSerializer): +class BranchOverrideSerializer(CamelSnakeSerializer[dict[str, Any]]): tag_name = serializers.CharField(required=True, help_text="Event tag key to match.") tag_value = serializers.CharField(required=True, help_text="Event tag value to match.") branch_name = serializers.CharField( @@ -235,7 +241,7 @@ class BranchOverrideSerializer(CamelSnakeSerializer): @sentry_schema_serializer( deprecate={"branch_overrides": "Tag-based branch overrides are deprecated."} ) -class SeerProjectRepoUpdateSerializer(CamelSnakeSerializer): +class SeerProjectRepoUpdateSerializer(CamelSnakeSerializer[dict[str, Any]]): branch_name = serializers.CharField( required=False, allow_null=True, @@ -255,10 +261,12 @@ class SeerProjectRepoUpdateSerializer(CamelSnakeSerializer): help_text="Deprecated. Branch overrides selected by matching tags.", ) - def validate_branch_overrides(self, value): + def validate_branch_overrides( + self, value: Sequence[Mapping[str, str]] + ) -> Sequence[Mapping[str, str]]: return _validate_branch_overrides(value) - def validate(self, data): + def validate(self, data: dict[str, Any]) -> dict[str, Any]: if not data: raise serializers.ValidationError("At least one field must be provided.") return data @@ -267,7 +275,7 @@ def validate(self, data): @sentry_schema_serializer( deprecate={"branch_overrides": "Tag-based branch overrides are deprecated."} ) -class SeerProjectRepoCreateSerializer(CamelSnakeSerializer): +class SeerProjectRepoCreateSerializer(CamelSnakeSerializer[dict[str, Any]]): repository_id = serializers.IntegerField( required=True, help_text="The ID of an active, supported repository in the organization." ) @@ -290,11 +298,13 @@ class SeerProjectRepoCreateSerializer(CamelSnakeSerializer): help_text="Deprecated. Branch overrides selected by matching tags.", ) - def validate_branch_overrides(self, value): + def validate_branch_overrides( + self, value: Sequence[Mapping[str, str]] + ) -> Sequence[Mapping[str, str]]: return _validate_branch_overrides(value) -class SeerProjectReposBulkSerializer(CamelSnakeSerializer): +class SeerProjectReposBulkSerializer(CamelSnakeSerializer[dict[str, Any]]): repos = SeerProjectRepoCreateSerializer( many=True, required=True, @@ -302,7 +312,7 @@ class SeerProjectReposBulkSerializer(CamelSnakeSerializer): help_text="Repositories to link and configure for Seer in this project.", ) - def validate_repos(self, value): + def validate_repos(self, value: list[dict[str, Any]]) -> list[dict[str, Any]]: repo_ids = [r["repository_id"] for r in value] if len(repo_ids) != len(set(repo_ids)): raise serializers.ValidationError("Duplicate repository IDs are not allowed.") diff --git a/src/sentry/seer/endpoints/project_seer_settings.py b/src/sentry/seer/endpoints/project_seer_settings.py index 1ab710d4dd05..a69090e88299 100644 --- a/src/sentry/seer/endpoints/project_seer_settings.py +++ b/src/sentry/seer/endpoints/project_seer_settings.py @@ -148,8 +148,8 @@ def _bulk_get_project_settings( settings_by_project_id: dict[int, SeerProjectSettings] = {} for project in projects: - - def _get_option(key: str): + # justification: project option values are heterogeneous runtime settings. + def _get_option(key: str) -> Any: value = project_options[key].get(project.id) if value is None: value = projectoptions.get_well_known_default(key, project=project) @@ -214,7 +214,8 @@ def serialize_projects( return [_serialize(p, settings_by_project_id[p.id]) for p in projects] -def _annotate_queryset(queryset, organization: Organization): +# justification: querysets carry runtime annotate()/values() shapes the Django plugin cannot express. +def _annotate_queryset(queryset: Any, organization: Organization) -> Any: # ProjectOption.value is a LegacyTextJSONField — a text column storing JSON. # Use LegacyTextJSONField as output_field for project options. Coalesce fallback # values must also be JSON-encoded. @@ -281,7 +282,8 @@ def _project_option_subquery(key: str) -> Subquery: ) -def _apply_search_filters(queryset, filters: Sequence[QueryToken]): +# justification: querysets carry runtime annotate()/values() shapes the Django plugin cannot express. +def _apply_search_filters(queryset: Any, filters: Sequence[QueryToken]) -> Any: for f in filters: if not isinstance(f, SearchFilter): continue @@ -354,7 +356,7 @@ def _apply_search_filters(queryset, filters: Sequence[QueryToken]): return queryset -class _BaseProjectSettingsUpdateSerializer(CamelSnakeSerializer): +class _BaseProjectSettingsUpdateSerializer(CamelSnakeSerializer[dict[str, Any]]): agent = serializers.ChoiceField(choices=[*AutomationCodingAgent], required=False) integration_id = serializers.IntegerField(required=False) stopping_point = serializers.ChoiceField(choices=[*AutofixStoppingPoint], required=False) @@ -387,7 +389,7 @@ def validate_stopping_point(self, value: str) -> str: raise serializers.ValidationError(f'"{value}" is not a valid choice.') return value - def validate(self, data): + def validate(self, data: dict[str, Any]) -> dict[str, Any]: if "agent" in data and data["agent"] != "seer" and "integration_id" not in data: raise serializers.ValidationError( {"integration_id": "Required when agent is an external coding agent."} @@ -417,7 +419,7 @@ class ProjectSettingsUpdateSerializer(_BaseProjectSettingsUpdateSerializer): required=False, ) - def validate(self, data): + def validate(self, data: dict[str, Any]) -> dict[str, Any]: data = super().validate(data) # Keep stopping point in sync with handoff auto_create_pr. diff --git a/src/sentry/seer/endpoints/search_agent_state.py b/src/sentry/seer/endpoints/search_agent_state.py index ca134a2537f7..0667343153ca 100644 --- a/src/sentry/seer/endpoints/search_agent_state.py +++ b/src/sentry/seer/endpoints/search_agent_state.py @@ -37,7 +37,10 @@ def fetch_search_agent_state( response = make_search_agent_state_request(body, timeout=10, viewer_context=viewer_context) if response.status >= 400: raise SeerApiError("Seer request failed", response.status) - return response.json() + data = response.json() + if not isinstance(data, dict): + raise SeerApiError("Seer returned a non-object response", response.status) + return data @cell_silo_endpoint diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 941ff44fe8e6..01e8c45abd92 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -634,7 +634,7 @@ def get_github_enterprise_integration_config( def send_seer_webhook( - *, event_name: str, organization_id: int, payload: dict + *, event_name: str, organization_id: int, payload: dict[str, Any] ) -> SendSeerWebhookSuccessResponse | SendSeerWebhookErrorResponse: """ Handles receipt (in Sentry, from Seer) of a seer webhook event for an organization. diff --git a/src/sentry/seer/entrypoints/cache.py b/src/sentry/seer/entrypoints/cache.py index e91263cfff7f..15429b39a04f 100644 --- a/src/sentry/seer/entrypoints/cache.py +++ b/src/sentry/seer/entrypoints/cache.py @@ -1,5 +1,6 @@ import logging from enum import StrEnum +from typing import cast from sentry.seer.entrypoints.metrics import ( SeerOperatorEventLifecycleMetric, @@ -45,7 +46,7 @@ def _get_post_autofix_cache_key(cls, *, entrypoint_key: str, run_id: int) -> str @classmethod def _get_pre_autofix_cache( cls, *, entrypoint_key: str, group_id: int - ) -> SeerOperatorCacheResult | None: + ) -> SeerOperatorCacheResult[CachePayloadT] | None: cache_key = cls._get_pre_autofix_cache_key(entrypoint_key=entrypoint_key, group_id=group_id) cache_payload = cache.get(cache_key) if not cache_payload: @@ -59,7 +60,7 @@ def _get_pre_autofix_cache( @classmethod def _get_post_autofix_cache( cls, *, entrypoint_key: str, run_id: int - ) -> SeerOperatorCacheResult | None: + ) -> SeerOperatorCacheResult[CachePayloadT] | None: cache_key = cls._get_post_autofix_cache_key(entrypoint_key=entrypoint_key, run_id=run_id) cache_payload = cache.get(cache_key) if not cache_payload: @@ -73,7 +74,7 @@ def _get_post_autofix_cache( @classmethod def populate_pre_autofix_cache( cls, *, entrypoint_key: str, group_id: int, cache_payload: CachePayloadT - ) -> SeerOperatorCacheResult: + ) -> SeerOperatorCacheResult[CachePayloadT]: with SeerOperatorEventLifecycleMetric( interaction_type=SeerOperatorInteractionType.OPERATOR_CACHE_POPULATE_PRE_AUTOFIX, entrypoint_key=entrypoint_key, @@ -92,7 +93,7 @@ def populate_pre_autofix_cache( @classmethod def populate_post_autofix_cache( cls, *, entrypoint_key: str, run_id: int, cache_payload: CachePayloadT - ) -> SeerOperatorCacheResult: + ) -> SeerOperatorCacheResult[CachePayloadT]: with SeerOperatorEventLifecycleMetric( interaction_type=SeerOperatorInteractionType.OPERATOR_CACHE_POPULATE_POST_AUTOFIX, entrypoint_key=entrypoint_key, @@ -111,7 +112,7 @@ def populate_post_autofix_cache( @classmethod def get( cls, *, entrypoint_key: str, group_id: int | None = None, run_id: int | None = None - ) -> SeerOperatorCacheResult | None: + ) -> SeerOperatorCacheResult[CachePayloadT] | None: """ Gets the most specific cache payload for a given entrypoint, group_id and/or run_id. If run_id cache hits, it's returned, and the group_id cache is deleted (if a group_id was provided). @@ -234,7 +235,8 @@ def get(cls, *, entrypoint_key: str, run_id: int) -> CachePayloadT | None: if not cache_payload: lifecycle.record_halt(halt_reason=CacheHaltReason.CACHE_MISS) return None - return cache_payload + # justification: the cache stores the payload type this cache is parameterized with. + return cast("CachePayloadT", cache_payload) class SeerOperatorPendingMentionCache[CachePayloadT]: @@ -301,7 +303,8 @@ def pop( lifecycle.record_halt(halt_reason=CacheHaltReason.CACHE_MISS) return None cache.delete(cache_key) - return cache_payload + # justification: the cache stores the payload type this cache is parameterized with. + return cast("CachePayloadT", cache_payload) class SeerOperatorInvestigationCache[CachePayloadT]: @@ -337,4 +340,5 @@ def get(cls, *, entrypoint_key: str, investigation_id: int) -> CachePayloadT | N if not cache_payload: lifecycle.record_halt(halt_reason=CacheHaltReason.CACHE_MISS) return None - return cache_payload + # justification: the cache stores the payload type this cache is parameterized with. + return cast("CachePayloadT", cache_payload) diff --git a/src/sentry/seer/entrypoints/operator.py b/src/sentry/seer/entrypoints/operator.py index d5dc6b53a8d4..74136cffa8be 100644 --- a/src/sentry/seer/entrypoints/operator.py +++ b/src/sentry/seer/entrypoints/operator.py @@ -47,6 +47,7 @@ SeerAutofixEntrypoint, SeerEntrypointKey, SeerInvestigationEntrypoint, + SeerOperatorCacheResult, ) from sentry.seer.models import SeerPermissionError from sentry.seer.seer_setup import has_seer_access @@ -320,6 +321,7 @@ def trigger_autofix_agent( lifecycle.record_failure(failure_reason=e) return + assert run_id is not None lifecycle.add_extra("run_id", str(run_id)) # Let the entrypoint signal to the external service that the run started @@ -411,10 +413,12 @@ def trigger_handoff( lock = locks.get(lock_key, duration=30, name="autofix_trigger_handoff") try: with lock.acquire(): - non_failed = [a for a in coding_agents if a.status != CodingAgentStatus.FAILED] + non_failed = [ + a for a in coding_agents if a.status != CodingAgentStatus.FAILED.value + ] if non_failed: has_complete_stage = any( - a.status == CodingAgentStatus.COMPLETED for a in non_failed + a.status == CodingAgentStatus.COMPLETED.value for a in non_failed ) lifecycle.add_extra("active_agents", str(len(non_failed))) with SeerOperatorEventLifecycleMetric( @@ -586,7 +590,7 @@ def trigger_agent( # Discovery uses the local run mirror; continue/start stays remote. existing_run = client.latest_run(only_current_user=False) - if existing_run is not None: + if existing_run is not None and existing_run.run.seer_run_state_id is not None: run_id = client.continue_run( run_id=existing_run.run.seer_run_state_id, prompt=prompt, @@ -608,6 +612,7 @@ def trigger_agent( lifecycle.record_failure(failure_reason=e) return None + assert run_id is not None lifecycle.add_extra("run_id", str(run_id)) with SeerOperatorEventLifecycleMetric( @@ -910,7 +915,7 @@ def process_autofix_updates( logger.warning("seer.operator.no_access_entrypoint_key", extra=logging_ctx) continue - cache_result = SeerOperatorAutofixCache.get( + cache_result: SeerOperatorCacheResult[object] | None = SeerOperatorAutofixCache.get( entrypoint_key=entrypoint_key, group_id=group_id, run_id=run_id ) if not cache_result: diff --git a/src/sentry/seer/entrypoints/slack/cache.py b/src/sentry/seer/entrypoints/slack/cache.py index e1e2837cb9a7..56f6daf86f92 100644 --- a/src/sentry/seer/entrypoints/slack/cache.py +++ b/src/sentry/seer/entrypoints/slack/cache.py @@ -1,4 +1,4 @@ -from typing import NotRequired, TypedDict +from typing import NotRequired, TypedDict, cast from sentry.utils.cache import cache @@ -44,11 +44,15 @@ def set( def get( cls, *, integration_id: int, channel_id: str, message_ts: str ) -> SlackSeerAgentMessageCachePayload | None: - return cache.get( - cls._get_cache_key( - integration_id=integration_id, - channel_id=channel_id, - message_ts=message_ts, + # justification: this cache only stores SlackSeerAgentMessageCachePayload values. + return cast( + "SlackSeerAgentMessageCachePayload | None", + cache.get( + cls._get_cache_key( + integration_id=integration_id, + channel_id=channel_id, + message_ts=message_ts, + ) ), ) diff --git a/src/sentry/seer/entrypoints/slack/investigation_status.py b/src/sentry/seer/entrypoints/slack/investigation_status.py index d7a9bfc2db94..4d5aaae70c77 100644 --- a/src/sentry/seer/entrypoints/slack/investigation_status.py +++ b/src/sentry/seer/entrypoints/slack/investigation_status.py @@ -86,7 +86,10 @@ def _format_window(start: datetime, end: datetime) -> str: def get_status_title(source: Mapping[str, Any]) -> str: - return source["snapshot"]["monitor"]["name"] + name = source["snapshot"]["monitor"]["name"] + if not isinstance(name, str): + raise TypeError("Investigation status monitor name is not a string") + return name def _get_endpoint(query: str) -> str | None: diff --git a/src/sentry/seer/entrypoints/slack/mention.py b/src/sentry/seer/entrypoints/slack/mention.py index d2045a2af77b..41a2eaab3139 100644 --- a/src/sentry/seer/entrypoints/slack/mention.py +++ b/src/sentry/seer/entrypoints/slack/mention.py @@ -172,7 +172,8 @@ def _extract_rich_text_element_text(element: Mapping[str, Any]) -> str: """Extract text from a single rich_text sub-element (text, link, user, channel, etc.).""" elem_type = element.get("type") if elem_type == "text": - return element.get("text", "") + text = element.get("text", "") + return text if isinstance(text, str) else "" if elem_type == "link": url = element.get("url", "") label = element.get("text", "") @@ -223,7 +224,8 @@ def _extract_block_text(block: Mapping[str, Any]) -> str: if block_type in ("header", "markdown"): text_obj = block.get("text") if isinstance(text_obj, dict): - return text_obj.get("text", "") + text = text_obj.get("text", "") + return text if isinstance(text, str) else "" if isinstance(text_obj, str): return text_obj @@ -287,7 +289,8 @@ def _extract_attachment_text(attachment: Mapping[str, Any]) -> str: if parts: return "\n".join(parts) - return attachment.get("fallback", "") + fallback = attachment.get("fallback", "") + return fallback if isinstance(fallback, str) else "" def _extract_text_from_attachments(attachments: Sequence[Mapping[str, Any]]) -> str: diff --git a/src/sentry/seer/entrypoints/slack/tasks.py b/src/sentry/seer/entrypoints/slack/tasks.py index 83adb84d2b09..01b9cd5c32a6 100644 --- a/src/sentry/seer/entrypoints/slack/tasks.py +++ b/src/sentry/seer/entrypoints/slack/tasks.py @@ -169,7 +169,7 @@ def process_mention_for_slack( ) # Only fetch thread context when actually in a thread. - messages: list[dict] = [] + messages: list[dict[str, Any]] = [] # justification: Slack payload is dynamic JSON. thread_context: str | None = None if thread_ts: messages = entrypoint.install.get_thread_history( diff --git a/src/sentry/seer/fetch_issues/by_function_name.py b/src/sentry/seer/fetch_issues/by_function_name.py index e66c2acebc2c..a61f7790a960 100644 --- a/src/sentry/seer/fetch_issues/by_function_name.py +++ b/src/sentry/seer/fetch_issues/by_function_name.py @@ -171,7 +171,10 @@ def _get_issues_for_file( query=query, ) try: - return raw_snql_query(request, referrer=Referrer.SEER_RPC.value)["data"] + rows = raw_snql_query(request, referrer=Referrer.SEER_RPC.value)["data"] + if not isinstance(rows, list): + raise ValueError("Snuba returned no data array") + return rows except Exception: logger.exception( "Seer fetch issues given patches Snuba query error", diff --git a/src/sentry/seer/issue_detection.py b/src/sentry/seer/issue_detection.py index 13aa8e920acb..af80809a6157 100644 --- a/src/sentry/seer/issue_detection.py +++ b/src/sentry/seer/issue_detection.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Mapping from sentry.models.project import Project from sentry.seer.sentry_data_models import CreateIssueOccurrenceResponse @@ -14,7 +15,7 @@ def create_issue_occurrence( *, organization_id: int, project_id: int, - detected_issue: dict, + detected_issue: Mapping[str, object], ) -> CreateIssueOccurrenceResponse: """ Create an issue occurrence from Seer detection data. diff --git a/src/sentry/seer/models/run.py b/src/sentry/seer/models/run.py index 4253f3526252..68015159a6b5 100644 --- a/src/sentry/seer/models/run.py +++ b/src/sentry/seer/models/run.py @@ -58,7 +58,7 @@ class SeerRun(DefaultFieldsModel): # FK value from Seer's DbRunState.id. # Nullable to support outbox writing - seer_run_state_id = BoundedBigIntegerField(null=True, unique=True) + seer_run_state_id = BoundedBigIntegerField[int | None, int | None](null=True, unique=True) type = models.CharField(max_length=256, choices=SeerRunType.choices) mirror_status = models.CharField( diff --git a/src/sentry/seer/models/seer_api_models.py b/src/sentry/seer/models/seer_api_models.py index 9b303a06021a..e41405fb30d3 100644 --- a/src/sentry/seer/models/seer_api_models.py +++ b/src/sentry/seer/models/seer_api_models.py @@ -118,7 +118,7 @@ def __init__(self, message: str, status: int): self.message = message self.status = status - def __str__(self): + def __str__(self) -> str: return f"Seer API error: {self.message} (status: {self.status})" @@ -130,7 +130,7 @@ class SeerApiResponseValidationError(Exception): def __init__(self, message: str): self.message = message - def __str__(self): + def __str__(self) -> str: return f"Seer API response validation error: {self.message}" @@ -138,7 +138,7 @@ class SeerPermissionError(Exception): def __init__(self, message: str): self.message = message - def __str__(self): + def __str__(self) -> str: return f"Seer permission error: {self.message}" diff --git a/src/sentry/seer/run_questions.py b/src/sentry/seer/run_questions.py index d1f79abd5680..e39e9553e84a 100644 --- a/src/sentry/seer/run_questions.py +++ b/src/sentry/seer/run_questions.py @@ -115,7 +115,7 @@ def _get_answer( """ cache_key = _answer_cache_key(organization, run_id, question) cached = default_cache.get(cache_key) - if cached is not None: + if isinstance(cached, str): return cached try: @@ -133,7 +133,7 @@ def _get_answer( answer = result.get("answer") # A missing answer means the one-shot failed, so retry rather than cache it. # An empty string is a real answer (e.g. no follow-ups) and is cached. - if answer is None: + if not isinstance(answer, str): return "" default_cache.set(cache_key, answer, timeout=_ANSWER_CACHE_TTL) diff --git a/src/sentry/seer/sentry_data_models.py b/src/sentry/seer/sentry_data_models.py index a652e0a5ba81..abd5a0d57bef 100644 --- a/src/sentry/seer/sentry_data_models.py +++ b/src/sentry/seer/sentry_data_models.py @@ -286,7 +286,7 @@ class EventFilterKeyEntry(BaseModel): class EventFilterKeysResponse(BaseModel): __root__: dict[str, EventFilterKeyEntry] - def dict(self, **kwargs: Any) -> Any: + def dict(self, **kwargs: Any) -> dict[str, dict[str, Any]]: # Unwrap to the bare `{key: {...}}` map the seer caller expects. return {k: v.dict(**kwargs) for k, v in self.__root__.items()} @@ -328,7 +328,8 @@ def __getitem__(self, idx: int) -> Any: def __eq__(self, other: object) -> bool: if isinstance(other, list): - return self.dict() == other + values = self.dict() + return isinstance(values, list) and values == other return super().__eq__(other) def __hash__(self) -> int: @@ -368,7 +369,8 @@ def __getitem__(self, idx: int) -> Any: def __eq__(self, other: object) -> bool: if isinstance(other, list): - return self.dict() == other + values = self.dict() + return isinstance(values, list) and values == other return super().__eq__(other) def __hash__(self) -> int: @@ -394,14 +396,15 @@ class AttributeValuesResponse(BaseModel): __root__: dict[str, list[str]] - def dict(self, **kwargs: Any) -> Any: + def dict(self, **kwargs: Any) -> dict[str, list[str]]: # Pre-typed code returned `{field: sorted_list, ...}` directly — unwrap so # the dispatcher and downstream consumers see the bare map. return dict(self.__root__) def __eq__(self, other: object) -> bool: if isinstance(other, dict): - return self.dict() == other + values = self.dict() + return isinstance(values, dict) and values == other return super().__eq__(other) def __hash__(self) -> int: @@ -856,7 +859,7 @@ class ExecuteTimeseriesQueryErrorResponse(BaseModel): class Config: allow_population_by_field_name = True - def dict(self, **kwargs: Any) -> Any: + def dict(self, **kwargs: Any) -> dict[str, Any]: kwargs.setdefault("by_alias", True) return super().dict(**kwargs) @@ -962,7 +965,8 @@ def __contains__(self, key: object) -> bool: def __eq__(self, other: object) -> bool: if isinstance(other, dict): - return self.dict() == other + values = self.dict() + return isinstance(values, dict) and values == other return super().__eq__(other) def __hash__(self) -> int: diff --git a/src/sentry/seer/similarity/utils.py b/src/sentry/seer/similarity/utils.py index 417832b3fb26..6125472b025b 100644 --- a/src/sentry/seer/similarity/utils.py +++ b/src/sentry/seer/similarity/utils.py @@ -290,7 +290,7 @@ def extract_filename(frame_dict: Mapping[str, Any]) -> str: filename = "" if filename == "" and frame_dict["module"] != "": filename = frame_dict["module"] - return filename + return filename if isinstance(filename, str) else "" def is_base64_encoded_frame(frame_dict: Mapping[str, Any]) -> bool: @@ -311,7 +311,7 @@ def event_content_has_stacktrace(event: GroupEvent | Event) -> bool: exception_stacktrace = get_path(event.data, "exception", "values", -1, "stacktrace", "frames") threads_stacktrace = get_path(event.data, "threads", "values", -1, "stacktrace", "frames") only_stacktrace = get_path(event.data, "stacktrace", "frames") - return exception_stacktrace or threads_stacktrace or only_stacktrace + return bool(exception_stacktrace or threads_stacktrace or only_stacktrace) def record_did_call_seer_metric( diff --git a/src/sentry/seer/supergroups/by_group.py b/src/sentry/seer/supergroups/by_group.py index 2a41de9293f8..2781453d2ec8 100644 --- a/src/sentry/seer/supergroups/by_group.py +++ b/src/sentry/seer/supergroups/by_group.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Sequence +from typing import cast import orjson @@ -29,4 +30,8 @@ def get_supergroups_by_group_ids( ) if response.status >= 400: raise SeerApiError("Seer request failed", response.status) - return orjson.loads(response.data) + data = orjson.loads(response.data) + if not isinstance(data, dict): + raise SeerApiError("Seer returned a non-object response", response.status) + # justification: Seer returns the SupergroupsByGroupIdsResponse payload. + return cast("SupergroupsByGroupIdsResponse", data) diff --git a/src/sentry/seer/workflows/breached_metrics.py b/src/sentry/seer/workflows/breached_metrics.py index 00b6cda6d543..0100ee1b7d56 100644 --- a/src/sentry/seer/workflows/breached_metrics.py +++ b/src/sentry/seer/workflows/breached_metrics.py @@ -289,7 +289,7 @@ def _get_projects(self, config: SeerWorkflowConfig) -> QuerySet[Project]: projects = Project.objects.filter( organization_id=config.organization_id, status=ObjectStatus.ACTIVE ) - if config.backing_workflow_id is None: + if not config.backing_workflow_id: return projects.none() return projects.filter( id__in=DetectorWorkflow.objects.filter( diff --git a/src/sentry/sentry_apps/alert_rule_action_creator.py b/src/sentry/sentry_apps/alert_rule_action_creator.py index d0ade2a4c5c4..a83680e3a20c 100644 --- a/src/sentry/sentry_apps/alert_rule_action_creator.py +++ b/src/sentry/sentry_apps/alert_rule_action_creator.py @@ -9,6 +9,7 @@ SentryAppAlertRuleActionRequester, SentryAppAlertRuleActionResult, ) +from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation from sentry.sentry_apps.utils.errors import SentryAppErrorType @@ -25,14 +26,20 @@ def run(self) -> SentryAppAlertRuleActionResult: response = self._make_external_request(uri) return response - def _fetch_sentry_app_uri(self): + def _fetch_sentry_app_uri(self) -> str | None: component = SentryAppComponent.objects.get( type="alert-rule-action", sentry_app=self.sentry_app ) - settings = component.schema.get("settings", {}) - return settings.get("uri") - - def _make_external_request(self, uri=None): + schema = component.schema + if not isinstance(schema, Mapping): + return None + settings = schema.get("settings", {}) + if not isinstance(settings, Mapping): + return None + uri = settings.get("uri") + return uri if isinstance(uri, str) else None + + def _make_external_request(self, uri: str | None = None) -> SentryAppAlertRuleActionResult: if uri is None: return SentryAppAlertRuleActionResult( message="Request url for alert-rule-action not found, please check your integration schema", @@ -49,5 +56,5 @@ def _make_external_request(self, uri=None): return response @cached_property - def sentry_app(self): + def sentry_app(self) -> SentryApp: return self.install.sentry_app diff --git a/src/sentry/sentry_apps/api/bases/sentryapps.py b/src/sentry/sentry_apps/api/bases/sentryapps.py index 9e53ac69c49a..2f936f9be21e 100644 --- a/src/sentry/sentry_apps/api/bases/sentryapps.py +++ b/src/sentry/sentry_apps/api/bases/sentryapps.py @@ -1,13 +1,15 @@ from __future__ import annotations import logging -from collections.abc import Collection, Mapping, Sequence -from typing import Any +from collections.abc import Mapping, Sequence +from typing import Any, cast 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_sdk import Scope from sentry import options from sentry.api.authentication import ClientIdSecretAuthentication, JWTClientSecretAuthentication @@ -19,11 +21,14 @@ from sentry.integrations.api.bases.integration import PARANOID_GET from sentry.models.organization import OrganizationStatus from sentry.organizations.services.organization import ( + RpcOrganization, + RpcOrganizationSummary, RpcUserOrganizationContext, organization_service, ) from sentry.sentry_apps.models.sentry_app import SentryApp -from sentry.sentry_apps.services.app import RpcSentryApp, app_service +from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation +from sentry.sentry_apps.services.app import RpcSentryApp, RpcSentryAppInstallation, app_service from sentry.sentry_apps.services.cell.model import RpcSentryAppError from sentry.sentry_apps.utils.errors import ( SentryAppError, @@ -70,7 +75,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 @@ -131,12 +138,18 @@ def respond_rpc_sentry_app_error( response.exception = True return response - def handle_exception_with_details(self, request, exc, handler_context=None, scope=None): + def handle_exception_with_details( + self, + request: Request, + exc: Exception, + handler_context: Mapping[str, object] | None = None, + scope: Scope | None = None, + ) -> Response: return self._handle_sentry_app_exception( exception=exc ) or super().handle_exception_with_details(request, exc, handler_context, scope) - def _handle_sentry_app_exception(self, exception: Exception): + def _handle_sentry_app_exception(self, exception: Exception) -> Response | None: if isinstance(exception, (SentryAppError, SentryAppIntegratorError)): response_body = exception.to_public_dict() @@ -156,7 +169,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." @@ -196,7 +209,14 @@ def _get_org_context(self, request: Request) -> RpcUserOrganizationContext: else: return self._get_organization_for_user(request.user, organization_slug) - def convert_args(self, request: Request, *args, **kwargs): + def convert_args( + self, + request: Request, + *args: Any, # justification: Django view argument passing is variadic. + **kwargs: Any, # justification: Django view argument passing is variadic. + ) -> tuple[ + tuple[Any, ...], dict[str, Any] # justification: Django view argument passing is variadic. + ]: # justification: Django view argument passing is variadic. """ This baseclass is the SentryApp collection endpoints: @@ -241,7 +261,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 +300,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: @@ -300,9 +326,11 @@ class SentryAppAndStaffPermission(StaffPermissionMixin, SentryAppPermission): class SentryAppBaseEndpoint(IntegrationPlatformEndpoint): permission_classes: tuple[type[BasePermission], ...] = (SentryAppPermission,) - def convert_args( + def convert_args( # justification: Django view argument passing is variadic. self, request: Request, sentry_app_id_or_slug: int | str, *args: Any, **kwargs: Any - ): + ) -> tuple[ + tuple[Any, ...], dict[str, Any] # justification: Django view argument passing is variadic. + ]: # justification: Django view argument passing is variadic. try: sentry_app = SentryApp.objects.get(slug__id_or_slug=sentry_app_id_or_slug) except SentryApp.DoesNotExist: @@ -324,11 +352,15 @@ class SentryAppInstallationsPermission(SentryPermission): "POST": ("org:integrations", "org:write", "org:admin"), } - def has_object_permission(self, request: Request, view, organization): + def has_object_permission( + self, request: Request, view: APIView, organization: RpcOrganizationSummary + ) -> bool: if not hasattr(request, "user") or not request.user: return False - self.determine_access(request, organization) + # justification: determine_access accepts a summarized organization at runtime + # via extract_id_from, but is not typed to accept one. + self.determine_access(request, cast(RpcOrganization, organization)) if superuser_has_permission(request): return True @@ -351,8 +383,16 @@ def has_object_permission(self, request: Request, view, organization): class SentryAppInstallationsBaseEndpoint(IntegrationPlatformEndpoint): permission_classes = (SentryAppInstallationsPermission,) - def convert_args(self, request: Request, organization_id_or_slug, *args, **kwargs): - extra_args = {} + def convert_args( + self, + request: Request, + organization_id_or_slug: int | str, + *args: Any, # justification: Django view argument passing is variadic. + **kwargs: Any, # justification: Django view argument passing is variadic. + ) -> tuple[ + tuple[Any, ...], dict[str, Any] # justification: Django view argument passing is variadic. + ]: # justification: Django view argument passing is variadic. + extra_args: dict[str, int | None] = {} # We need to pass user_id if the user is not a superuser if not is_active_superuser(request): extra_args["user_id"] = request.user.id @@ -388,17 +428,26 @@ class SentryAppInstallationPermission(SentryPermission): "POST": ("org:integrations", "event:write", "event:admin"), } - def has_permission(self, request: Request, *args, **kwargs) -> bool: + def has_permission( # justification: Django view argument passing is variadic. + self, request: Request, *args: Any, **kwargs: Any + ) -> bool: # To let the app mark the installation as installed, we don't care about permissions if request.user.is_authenticated and request.user.is_sentry_app and request.method == "PUT": return True return super().has_permission(request, *args, **kwargs) - def has_object_permission(self, request: Request, view, installation): + def has_object_permission( + self, + request: Request, + view: APIView, + installation: RpcSentryAppInstallation | SentryAppInstallation, + ) -> bool: if not hasattr(request, "user") or not request.user or not request.user.is_authenticated: return False - self.determine_access(request, installation.organization_id) + # justification: determine_access accepts an organization id at runtime via + # extract_id_from, but is not typed to accept one. + self.determine_access(request, cast(RpcOrganization, installation.organization_id)) if superuser_has_permission(request): return True @@ -427,8 +476,16 @@ def has_object_permission(self, request: Request, view, installation): class SentryAppInstallationBaseEndpoint(IntegrationPlatformEndpoint): permission_classes: tuple[type[BasePermission], ...] = (SentryAppInstallationPermission,) - def convert_args(self, request: Request, uuid, *args, **kwargs): - installations = app_service.get_many(filter=dict(uuids=[uuid])) + def convert_args( + self, + request: Request, + uuid: str, + *args: Any, # justification: Django view argument passing is variadic. + **kwargs: Any, # justification: Django view argument passing is variadic. + ) -> tuple[ + tuple[Any, ...], dict[str, Any] # justification: Django view argument passing is variadic. + ]: # justification: Django view argument passing is variadic. + installations = app_service.get_many(filter={"uuids": [uuid]}) installation = installations[0] if installations else None if installation is None: raise SentryAppError( @@ -458,7 +515,12 @@ class SentryAppInstallationExternalIssueBaseEndpoint(SentryAppInstallationBaseEn class SentryAppAuthorizationsPermission(SentryPermission): - def has_object_permission(self, request: Request, view, installation): + def has_object_permission( + self, + request: Request, + view: APIView, + installation: RpcSentryAppInstallation | SentryAppInstallation, + ) -> bool: if not hasattr(request, "user") or not request.user: return False @@ -488,7 +550,9 @@ class SentryInternalAppTokenPermission(SentryPermission): "DELETE": ("org:write", "org:admin"), } - def has_object_permission(self, request: Request, view, sentry_app): + 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 @@ -511,7 +575,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 +608,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/group_external_issue_details.py b/src/sentry/sentry_apps/api/endpoints/group_external_issue_details.py index 1033e7202999..8104310b0123 100644 --- a/src/sentry/sentry_apps/api/endpoints/group_external_issue_details.py +++ b/src/sentry/sentry_apps/api/endpoints/group_external_issue_details.py @@ -27,6 +27,7 @@ from sentry.issues.action_log.types import UnlinkPlatformExternalIssueAction from sentry.issues.endpoints.bases.group import GroupEndpoint from sentry.locks import locks +from sentry.models.group import Group from sentry.sentry_apps.models.platformexternalissue import PlatformExternalIssue from sentry.utils.locking import UnableToAcquireLock @@ -68,7 +69,10 @@ class GroupExternalIssueDetailsEndpoint(GroupEndpoint): url_names=["sentry-api-0-group-external-issues-details"], ) def delete( - self, request: Request, external_issue_id, group + self, + request: Request, + external_issue_id: str, + group: Group, ) -> Response[None] | Response[DetailResponse]: """Remove a custom integration's association with a Sentry issue. The external issue is not deleted.""" try: @@ -93,6 +97,7 @@ def delete( except PlatformExternalIssue.DoesNotExist: return Response(status=204) + actor_id = acting_user_id(request.user) publish_action( UnlinkPlatformExternalIssueAction( service_type=external_issue.service_type, @@ -102,11 +107,7 @@ def delete( source=resolve_action_source(request), group_id=group.id, project=group.project, - actor=( - GroupActionActor.user(user_id) - if (user_id := acting_user_id(request.user)) is not None - else SYSTEM_ACTOR - ), + actor=(GroupActionActor.user(actor_id) if actor_id is not None else SYSTEM_ACTOR), ) deletions.exec_sync(external_issue) diff --git a/src/sentry/sentry_apps/api/endpoints/group_external_issues.py b/src/sentry/sentry_apps/api/endpoints/group_external_issues.py index 961f1808e026..1a49e47bc08a 100644 --- a/src/sentry/sentry_apps/api/endpoints/group_external_issues.py +++ b/src/sentry/sentry_apps/api/endpoints/group_external_issues.py @@ -12,6 +12,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.issues.endpoints.bases.group import GroupEndpoint +from sentry.models.group import Group from sentry.sentry_apps.api.serializers.platform_external_issue import ( PlatformExternalIssueSerializerResponse, ) @@ -48,7 +49,7 @@ class GroupExternalIssuesEndpoint(GroupEndpoint): url_names=["sentry-api-0-group-external-issues"], ) def get( - self, request: Request, group + self, request: Request, group: Group ) -> Response[list[PlatformExternalIssueSerializerResponse]]: """ Retrieve custom integration issue links for the given Sentry issue diff --git a/src/sentry/sentry_apps/api/endpoints/installation_details.py b/src/sentry/sentry_apps/api/endpoints/installation_details.py index 76a9ea7a6c13..9e197efa1114 100644 --- a/src/sentry/sentry_apps/api/endpoints/installation_details.py +++ b/src/sentry/sentry_apps/api/endpoints/installation_details.py @@ -34,6 +34,7 @@ SentryAppInstallationUpdater, ) from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation +from sentry.sentry_apps.services.app.model import RpcSentryAppInstallation from sentry.utils.audit import create_audit_entry from sentry.utils.sentry_apps.webhooks import WebhookTimeoutError @@ -61,7 +62,9 @@ class SentryAppInstallationDetailsEndpoint(SentryAppInstallationBaseEndpoint): 404: RESPONSE_NOT_FOUND, }, ) - def get(self, request: Request, installation) -> Response[SentryAppInstallationResult]: + def get( + self, request: Request, installation: RpcSentryAppInstallation + ) -> Response[SentryAppInstallationResult]: """ Return details about a single custom integration (Sentry App) installation. """ @@ -83,7 +86,7 @@ def get(self, request: Request, installation) -> Response[SentryAppInstallationR 404: RESPONSE_NOT_FOUND, }, ) - def delete(self, request: Request, installation) -> Response: + def delete(self, request: Request, installation: RpcSentryAppInstallation) -> Response: """ Uninstall a custom integration (Sentry App) from an organization. """ @@ -142,7 +145,7 @@ def notify_on_commit() -> None: }, ) def put( - self, request: Request, installation + self, request: Request, installation: RpcSentryAppInstallation ) -> Response[SentryAppInstallationResult] | Response[ValidationErrorResponse]: """ Update a custom integration (Sentry App) installation, for example to mark a diff --git a/src/sentry/sentry_apps/api/endpoints/installation_external_issue_actions.py b/src/sentry/sentry_apps/api/endpoints/installation_external_issue_actions.py index 736ca0a4d01d..048215c55b63 100644 --- a/src/sentry/sentry_apps/api/endpoints/installation_external_issue_actions.py +++ b/src/sentry/sentry_apps/api/endpoints/installation_external_issue_actions.py @@ -27,6 +27,7 @@ PlatformExternalIssueSerializerResponse, ) from sentry.sentry_apps.external_requests.utils import validate_sentry_app_uri +from sentry.sentry_apps.services.app.model import RpcSentryAppInstallation from sentry.sentry_apps.services.cell import sentry_app_cell_service from sentry.sentry_apps.utils.errors import SentryAppPublicErrorBody from sentry.users.services.user.serial import serialize_generic_user @@ -89,7 +90,7 @@ class SentryAppInstallationExternalIssueActionsEndpoint( }, ) def post( - self, request: Request, installation + self, request: Request, installation: RpcSentryAppInstallation ) -> ( Response[PlatformExternalIssueSerializerResponse] | Response[DetailResponse] diff --git a/src/sentry/sentry_apps/api/endpoints/installation_external_issue_details.py b/src/sentry/sentry_apps/api/endpoints/installation_external_issue_details.py index f25dd955b607..26d7beff2507 100644 --- a/src/sentry/sentry_apps/api/endpoints/installation_external_issue_details.py +++ b/src/sentry/sentry_apps/api/endpoints/installation_external_issue_details.py @@ -16,6 +16,7 @@ from sentry.sentry_apps.api.bases.sentryapps import ( SentryAppInstallationExternalIssueBaseEndpoint as ExternalIssueBaseEndpoint, ) +from sentry.sentry_apps.services.app.model import RpcSentryAppInstallation from sentry.sentry_apps.services.cell import sentry_app_cell_service from sentry.sentry_apps.utils.errors import SentryAppError from sentry.users.services.user.serial import serialize_generic_user @@ -49,13 +50,18 @@ class SentryAppInstallationExternalIssueDetailsEndpoint(ExternalIssueBaseEndpoin 409: RESPONSE_CONFLICT, }, ) - def delete(self, request: Request, installation, external_issue_id) -> Response: + def delete( + self, + request: Request, + installation: RpcSentryAppInstallation, + external_issue_id: str, + ) -> Response: """ Remove the link between a Sentry issue and an external resource created through a custom integration (Sentry App) installation. """ try: - external_issue_id = int(external_issue_id) + issue_id = int(external_issue_id) except (ValueError, TypeError): raise SentryAppError( message="Invalid external_issue_id format. Expected numeric value.", @@ -69,7 +75,7 @@ def delete(self, request: Request, installation, external_issue_id) -> Response: result = sentry_app_cell_service.delete_external_issue( organization_id=installation.organization_id, installation=installation, - external_issue_id=external_issue_id, + external_issue_id=issue_id, user=rpc_user, ) diff --git a/src/sentry/sentry_apps/api/endpoints/installation_external_issues.py b/src/sentry/sentry_apps/api/endpoints/installation_external_issues.py index d328089d28b6..d8e11d7554bb 100644 --- a/src/sentry/sentry_apps/api/endpoints/installation_external_issues.py +++ b/src/sentry/sentry_apps/api/endpoints/installation_external_issues.py @@ -30,6 +30,7 @@ from sentry.sentry_apps.api.serializers.platform_external_issue import ( PlatformExternalIssueSerializerResponse, ) +from sentry.sentry_apps.services.app.model import RpcSentryAppInstallation from sentry.sentry_apps.services.cell import sentry_app_cell_service from sentry.sentry_apps.utils.errors import SentryAppPublicErrorBody from sentry.users.services.user.serial import serialize_generic_user @@ -68,7 +69,7 @@ class SentryAppInstallationExternalIssuesEndpoint(ExternalIssueBaseEndpoint): }, ) def post( - self, request: Request, installation + self, request: Request, installation: RpcSentryAppInstallation ) -> ( Response[PlatformExternalIssueSerializerResponse] | Response[DetailResponse] diff --git a/src/sentry/sentry_apps/api/endpoints/installation_service_hook_projects.py b/src/sentry/sentry_apps/api/endpoints/installation_service_hook_projects.py index a4cf1191c126..0716c40fac21 100644 --- a/src/sentry/sentry_apps/api/endpoints/installation_service_hook_projects.py +++ b/src/sentry/sentry_apps/api/endpoints/installation_service_hook_projects.py @@ -17,7 +17,7 @@ class ServiceHookProjectsInputSerializer(serializers.Serializer): projects = serializers.ListField(required=True) - def validate_projects(self, value): + def validate_projects(self, value: object) -> list[int]: if not isinstance(value, list): raise serializers.ValidationError( "Projects must be a list, not %s" % type(value).__name__ diff --git a/src/sentry/sentry_apps/api/endpoints/organization_legacy_webhooks.py b/src/sentry/sentry_apps/api/endpoints/organization_legacy_webhooks.py index fb17fb4ba2d7..2058ca72a193 100644 --- a/src/sentry/sentry_apps/api/endpoints/organization_legacy_webhooks.py +++ b/src/sentry/sentry_apps/api/endpoints/organization_legacy_webhooks.py @@ -37,5 +37,5 @@ def get(self, request: Request, organization: Organization) -> Response: } ) - result.sort(key=lambda x: x["projectSlug"]) + result.sort(key=lambda x: str(x["projectSlug"])) return Response({"projects": result}) 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..7bc17902ada0 100644 --- a/src/sentry/sentry_apps/api/endpoints/sentry_app_avatar.py +++ b/src/sentry/sentry_apps/api/endpoints/sentry_app_avatar.py @@ -8,11 +8,12 @@ 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 +from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.sentry_apps.models.sentry_app_avatar import SentryAppAvatar @control_silo_endpoint -class SentryAppAvatarEndpoint(AvatarMixin[SentryAppAvatar], SentryAppBaseEndpoint): +class SentryAppAvatarEndpoint(AvatarMixin[SentryAppAvatar, SentryApp], SentryAppBaseEndpoint): owner = ApiOwner.INTEGRATION_PLATFORM publish_status = { "GET": ApiPublishStatus.PRIVATE, @@ -22,15 +23,15 @@ class SentryAppAvatarEndpoint(AvatarMixin[SentryAppAvatar], SentryAppBaseEndpoin model = SentryAppAvatar serializer_cls = SentryAppAvatarParser - def get(self, request: Request, **kwargs) -> Response: + def get(self, request: Request, **kwargs: object) -> Response: return super().get( request, access=request.access, serializer=SentryAppSerializer(), **kwargs ) - def put(self, request: Request, **kwargs) -> Response: + def put(self, request: Request, **kwargs: object) -> Response: return super().put( request, access=request.access, serializer=SentryAppSerializer(), **kwargs ) - def get_avatar_filename(self, obj) -> str: + def get_avatar_filename(self, obj: SentryApp) -> str: return f"{obj.slug}.png" diff --git a/src/sentry/sentry_apps/api/endpoints/sentry_app_components.py b/src/sentry/sentry_apps/api/endpoints/sentry_app_components.py index 2b15683335c1..d4efe3460afe 100644 --- a/src/sentry/sentry_apps/api/endpoints/sentry_app_components.py +++ b/src/sentry/sentry_apps/api/endpoints/sentry_app_components.py @@ -25,6 +25,7 @@ SentryAppComponentSerializerResponse, ) from sentry.sentry_apps.components import SentryAppComponentPreparer +from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation from sentry.sentry_apps.utils.errors import SentryAppError, SentryAppIntegratorError @@ -42,7 +43,7 @@ class SentryAppComponentsEndpoint(SentryAppBaseEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, sentry_app) -> Response: + def get(self, request: Request, sentry_app: SentryApp) -> Response: return self.paginate( request=request, queryset=sentry_app.components.all(), diff --git a/src/sentry/sentry_apps/api/endpoints/sentry_app_details.py b/src/sentry/sentry_apps/api/endpoints/sentry_app_details.py index 73747beca187..8f105f1052f6 100644 --- a/src/sentry/sentry_apps/api/endpoints/sentry_app_details.py +++ b/src/sentry/sentry_apps/api/endpoints/sentry_app_details.py @@ -238,7 +238,7 @@ def put( responses={204: RESPONSE_NO_CONTENT, 403: RESPONSE_FORBIDDEN}, ) def delete( - self, request: Request, sentry_app + self, request: Request, sentry_app: SentryApp ) -> Response[None] | Response[DetailResponse] | Response[_PublishedAppErrorResponse]: """ Delete a custom integration. diff --git a/src/sentry/sentry_apps/api/endpoints/sentry_app_features.py b/src/sentry/sentry_apps/api/endpoints/sentry_app_features.py index 19b1a5965f59..ecaa82603db8 100644 --- a/src/sentry/sentry_apps/api/endpoints/sentry_app_features.py +++ b/src/sentry/sentry_apps/api/endpoints/sentry_app_features.py @@ -16,6 +16,7 @@ ) from sentry.integrations.models.integration_feature import IntegrationFeature, IntegrationTypes from sentry.sentry_apps.api.bases.sentryapps import SentryAppBaseEndpoint +from sentry.sentry_apps.models.sentry_app import SentryApp @extend_schema(tags=["Integration"]) @@ -38,7 +39,9 @@ class SentryAppFeaturesEndpoint(SentryAppBaseEndpoint): 404: RESPONSE_NOT_FOUND, }, ) - def get(self, request: Request, sentry_app) -> Response[list[IntegrationFeatureResponse]]: + def get( + self, request: Request, sentry_app: SentryApp + ) -> Response[list[IntegrationFeatureResponse]]: """ Return the list of features that a custom integration (Sentry App) declares. """ diff --git a/src/sentry/sentry_apps/api/endpoints/sentry_app_installations.py b/src/sentry/sentry_apps/api/endpoints/sentry_app_installations.py index 450b67f0d53b..7d758b4909f2 100644 --- a/src/sentry/sentry_apps/api/endpoints/sentry_app_installations.py +++ b/src/sentry/sentry_apps/api/endpoints/sentry_app_installations.py @@ -73,7 +73,7 @@ def get( ), ) - def post(self, request: Request, organization) -> Response: + def post(self, request: Request, organization: Organization) -> Response: serializer = SentryAppInstallationsSerializer(data=request.data) if not serializer.is_valid(): diff --git a/src/sentry/sentry_apps/api/endpoints/sentry_app_publish_request.py b/src/sentry/sentry_apps/api/endpoints/sentry_app_publish_request.py index 6a144ad68fb0..7facc61980f3 100644 --- a/src/sentry/sentry_apps/api/endpoints/sentry_app_publish_request.py +++ b/src/sentry/sentry_apps/api/endpoints/sentry_app_publish_request.py @@ -13,6 +13,7 @@ from sentry.models.organizationmapping import OrganizationMapping from sentry.organizations.services.organization.service import organization_service from sentry.sentry_apps.api.bases.sentryapps import COMPONENT_TYPES, SentryAppBaseEndpoint +from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.sentry_apps.models.sentry_app_avatar import SentryAppAvatar, SentryAppAvatarTypes from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser @@ -37,12 +38,12 @@ class SentryAppPublishRequestEndpoint(SentryAppBaseEndpoint): "POST": ApiPublishStatus.PRIVATE, } - def has_ui_component(self, sentry_app): + def has_ui_component(self, sentry_app: SentryApp) -> bool: """Determine if the sentry app supports issue linking or stack trace linking.""" elements = (sentry_app.schema or {}).get("elements", []) return any(element.get("type") in COMPONENT_TYPES for element in elements) - def post(self, request: Request, sentry_app) -> Response: + def post(self, request: Request, sentry_app: SentryApp) -> Response: # check status of app to make sure it is unpublished if sentry_app.is_published: return Response({"detail": "Cannot publish already published integration."}, status=400) diff --git a/src/sentry/sentry_apps/api/endpoints/sentry_app_stats_details.py b/src/sentry/sentry_apps/api/endpoints/sentry_app_stats_details.py index f1badf3e4568..8282a5fa89d4 100644 --- a/src/sentry/sentry_apps/api/endpoints/sentry_app_stats_details.py +++ b/src/sentry/sentry_apps/api/endpoints/sentry_app_stats_details.py @@ -12,6 +12,7 @@ from sentry.apidocs.parameters import SentryAppParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.sentry_apps.api.bases.sentryapps import SentryAppBaseEndpoint, SentryAppStatsPermission +from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation @@ -65,7 +66,7 @@ class SentryAppStatsEndpoint(SentryAppBaseEndpoint, StatsMixin): 404: RESPONSE_NOT_FOUND, }, ) - def get(self, request: Request, sentry_app) -> Response[SentryAppStatsResponse]: + def get(self, request: Request, sentry_app: SentryApp) -> Response[SentryAppStatsResponse]: """ Return the number of installations and uninstallations of a custom integration (Sentry App) over a time series range. diff --git a/src/sentry/sentry_apps/api/endpoints/sentry_app_webhook_requests.py b/src/sentry/sentry_apps/api/endpoints/sentry_app_webhook_requests.py index aeec2c7a11d8..0c7937e98100 100644 --- a/src/sentry/sentry_apps/api/endpoints/sentry_app_webhook_requests.py +++ b/src/sentry/sentry_apps/api/endpoints/sentry_app_webhook_requests.py @@ -46,12 +46,13 @@ class IncomingRequestSerializer(serializers.Serializer): format=date_format, default=None, default_timezone=timezone.utc, required=False ) - def validate(self, data): - if "start" in data and "end" in data and data["start"] > data["end"]: + def validate(self, data: dict[str, object]) -> dict[str, object]: + start, end = data.get("start"), data.get("end") + if isinstance(start, datetime) and isinstance(end, datetime) and start > end: raise serializers.ValidationError("Invalid timestamp (start must be before end).") return data - def validate_end(self, end): + def validate_end(self, end: datetime | None) -> datetime: if end is None: end = datetime.now(tz=timezone.utc) return end diff --git a/src/sentry/sentry_apps/api/endpoints/sentry_apps.py b/src/sentry/sentry_apps/api/endpoints/sentry_apps.py index 2876bd1f76af..42aec17e3a60 100644 --- a/src/sentry/sentry_apps/api/endpoints/sentry_apps.py +++ b/src/sentry/sentry_apps/api/endpoints/sentry_apps.py @@ -19,6 +19,7 @@ from sentry.constants import SentryAppStatus from sentry.db.models.manager.base_query_set import BaseQuerySet from sentry.organizations.services.organization import organization_service +from sentry.organizations.services.organization.model import RpcOrganization from sentry.sentry_apps.api.bases.sentryapps import SentryAppsBaseEndpoint from sentry.sentry_apps.api.parsers.sentry_app import SentryAppParser from sentry.sentry_apps.api.serializers.sentry_app import ( @@ -76,7 +77,7 @@ def get(self, request: Request) -> Response: ), ) - def post(self, request: Request, organization) -> Response: + def post(self, request: Request, organization: RpcOrganization) -> Response: data = { "name": request.data.get("name"), "user": request.user, @@ -175,7 +176,9 @@ def post(self, request: Request, organization) -> Response: ) return Response(serializer.errors, status=400) - def _filter_queryset_for_user(self, queryset: BaseQuerySet[SentryApp, SentryApp], user_id: int): + def _filter_queryset_for_user( + self, queryset: BaseQuerySet[SentryApp, SentryApp], user_id: int + ) -> BaseQuerySet[SentryApp, SentryApp]: owner_ids = [] for o in user_service.get_organizations(user_id=user_id, only_visible=True): org_context = organization_service.get_organization_by_id(id=o.id, user_id=user_id) diff --git a/src/sentry/sentry_apps/api/endpoints/sentry_internal_app_token_details.py b/src/sentry/sentry_apps/api/endpoints/sentry_internal_app_token_details.py index 1c2d5bd2263a..4d0e1365ae0c 100644 --- a/src/sentry/sentry_apps/api/endpoints/sentry_internal_app_token_details.py +++ b/src/sentry/sentry_apps/api/endpoints/sentry_internal_app_token_details.py @@ -19,6 +19,7 @@ SentryInternalAppTokenPermission, ) from sentry.sentry_apps.api.endpoints.sentry_app_details import PARTNERSHIP_RESTRICTED_ERROR_MESSAGE +from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken from sentry.utils.audit import create_audit_entry @@ -33,7 +34,14 @@ class SentryInternalAppTokenDetailsEndpoint(SentryAppBaseEndpoint): permission_classes = (SentryInternalAppTokenPermission, DisallowImpersonatedTokenCreation) allow_disabled_sentry_app_for_methods = {"DELETE"} - def convert_args(self, request: Request, sentry_app_id_or_slug, api_token_id, *args, **kwargs): + def convert_args( + self, + request: Request, + sentry_app_id_or_slug: int | str, + api_token_id: int | str, + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: # get the sentry_app from the SentryAppBaseEndpoint class (args, kwargs) = super().convert_args(request, sentry_app_id_or_slug, *args, **kwargs) @@ -44,7 +52,7 @@ def convert_args(self, request: Request, sentry_app_id_or_slug, api_token_id, *a return (args, kwargs) - def delete(self, request: Request, sentry_app, api_token) -> Response: + def delete(self, request: Request, sentry_app: SentryApp, api_token: ApiToken) -> Response: # Validate the token is associated with the application if api_token.application_id != sentry_app.application_id: raise Http404 diff --git a/src/sentry/sentry_apps/api/endpoints/sentry_internal_app_tokens.py b/src/sentry/sentry_apps/api/endpoints/sentry_internal_app_tokens.py index 56f81d56d864..19b80126ed7c 100644 --- a/src/sentry/sentry_apps/api/endpoints/sentry_internal_app_tokens.py +++ b/src/sentry/sentry_apps/api/endpoints/sentry_internal_app_tokens.py @@ -17,7 +17,7 @@ ) from sentry.sentry_apps.api.endpoints.sentry_app_details import PARTNERSHIP_RESTRICTED_ERROR_MESSAGE from sentry.sentry_apps.installations import SentryAppInstallationTokenCreator -from sentry.sentry_apps.models.sentry_app import MASKED_VALUE +from sentry.sentry_apps.models.sentry_app import MASKED_VALUE, SentryApp from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser @@ -34,7 +34,7 @@ class SentryInternalAppTokensEndpoint(SentryAppBaseEndpoint): authentication_classes = (SessionNoAuthTokenAuthentication,) permission_classes = (SentryInternalAppTokenPermission, DisallowImpersonatedTokenCreation) - def get(self, request: Request, sentry_app) -> Response: + def get(self, request: Request, sentry_app: SentryApp) -> Response: if not sentry_app.is_internal: return Response([]) @@ -53,7 +53,7 @@ def get(self, request: Request, sentry_app) -> Response: return Response(token_list) - def post(self, request: Request, sentry_app) -> Response: + def post(self, request: Request, sentry_app: SentryApp) -> Response: if not sentry_app.is_internal: return Response( "This route is limited to internal integrations only", diff --git a/src/sentry/sentry_apps/api/parsers/schema.py b/src/sentry/sentry_apps/api/parsers/schema.py index f93764bd057f..871bfa8870c4 100644 --- a/src/sentry/sentry_apps/api/parsers/schema.py +++ b/src/sentry/sentry_apps/api/parsers/schema.py @@ -1,4 +1,6 @@ import logging +from collections.abc import Mapping +from typing import Any import orjson from jsonschema import Draft7Validator @@ -200,8 +202,12 @@ DEFAULT_TEXT_TYPES = ["issue.title", "issue.description"] TEXT_COMPONENTS = ["text", "textarea"] +# justification: UI element schemas are arbitrary JSON submitted by Sentry apps and +# only constrained at runtime by SCHEMA, so they have no static shape. +UiElementSchema = Mapping[str, Any] # justification: decoded UI schema JSON. -def validate_component(schema): + +def validate_component(schema: UiElementSchema) -> None: """ In order to test individual components, that aren't normally allowed at the top-level of a schema, we just plop all `definitions` into `properties`. @@ -213,12 +219,12 @@ def validate_component(schema): validate(instance={schema["type"]: schema}, schema=component_schema) -def check_elements_is_array(instance): +def check_elements_is_array(instance: UiElementSchema) -> None: if "elements" in instance and not isinstance(instance["elements"], list): raise SchemaValidationError("'elements' should be an array of objects") -def check_each_element_for_error(instance): +def check_each_element_for_error(instance: UiElementSchema) -> None: if "elements" not in instance: return @@ -238,7 +244,7 @@ def check_each_element_for_error(instance): raise SchemaValidationError(f"{e.message} for element of type '{found_type}'") -def validate_text_component_defaults(element, found_type): +def validate_text_component_defaults(element: UiElementSchema, found_type: str) -> None: data = element["settings"] if found_type == "alert-rule-action" else element optional_fields = data.get("optional_fields", []) required_fields = data.get("required_fields", []) @@ -252,7 +258,7 @@ def validate_text_component_defaults(element, found_type): ) -def check_only_one_of_each_element(instance): +def check_only_one_of_each_element(instance: UiElementSchema) -> None: if "elements" not in instance: return found = {} @@ -264,7 +270,7 @@ def check_only_one_of_each_element(instance): raise SchemaValidationError(f"Multiple elements of type: {element['type']}") -def validate_ui_element_schema(instance): +def validate_ui_element_schema(instance: UiElementSchema) -> None: try: # schema validator will catch elements missing check_elements_is_array(instance) @@ -284,7 +290,7 @@ def validate_ui_element_schema(instance): validate(instance, SCHEMA) -def validate(instance, schema): +def validate(instance: object, schema: Mapping[str, object]) -> None: v = Draft7Validator(schema) if not v.is_valid(instance): raise best_match(v.iter_errors(instance)) diff --git a/src/sentry/sentry_apps/api/parsers/sentry_app.py b/src/sentry/sentry_apps/api/parsers/sentry_app.py index cd3788c74e9e..05860aa3983e 100644 --- a/src/sentry/sentry_apps/api/parsers/sentry_app.py +++ b/src/sentry/sentry_apps/api/parsers/sentry_app.py @@ -1,5 +1,7 @@ import logging import re +from collections.abc import Callable, Mapping, Sequence +from typing import Any, cast from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field @@ -10,9 +12,10 @@ from sentry.api.serializers.rest_framework.base import camel_to_snake_case from sentry.apidocs.omissions import sentry_schema_serializer from sentry.apidocs.parameters import build_typed_list +from sentry.auth.access import Access from sentry.integrations.models.integration_feature import Feature from sentry.models.apiscopes import ApiScopes -from sentry.sentry_apps.api.parsers.schema import validate_ui_element_schema +from sentry.sentry_apps.api.parsers.schema import UiElementSchema, validate_ui_element_schema from sentry.sentry_apps.models.sentry_app import ( UUID_CHARS_IN_SLUG, VALID_EVENTS, @@ -50,12 +53,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 +69,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,16 +85,16 @@ 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 {} try: - validate_ui_element_schema(data) + validate_ui_element_schema(cast(UiElementSchema, data)) except SchemaValidationError as e: raise ValidationError(e.message) @@ -95,7 +102,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. @@ -198,26 +205,30 @@ class SentryAppParser(Serializer): allow_null=True, ) - def __init__(self, *args, **kwargs): - self.active_staff = kwargs.pop("active_staff", False) - self.access = kwargs.pop("access", None) + # justification: DRF serializer init options are arbitrary decoded request values. + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.active_staff: bool = kwargs.pop("active_staff", False) + self.access: Access | None = kwargs.pop("access", None) 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 mapped_field_name = camel_to_snake_case(field_name) if hasattr(self.instance, mapped_field_name): - return getattr(self.instance, mapped_field_name) + # justification: parsed attributes hold arbitrary API values. + return cast("object", getattr(self.instance, mapped_field_name)) else: return None 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 +250,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 +305,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 @@ -327,7 +338,8 @@ def validate_scopes(self, value): return value - def validate(self, attrs): + # justification: DRF validated data is decoded request JSON with no static shape. + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: # validates events against scopes if attrs.get("scopes"): for subscription in attrs.get("events", []): diff --git a/src/sentry/sentry_apps/api/parsers/sentry_app_avatar.py b/src/sentry/sentry_apps/api/parsers/sentry_app_avatar.py index efc7ee8cd65a..1e90f77dccf6 100644 --- a/src/sentry/sentry_apps/api/parsers/sentry_app_avatar.py +++ b/src/sentry/sentry_apps/api/parsers/sentry_app_avatar.py @@ -1,3 +1,5 @@ +from typing import IO, Any, cast + from rest_framework import serializers from rest_framework.serializers import Serializer @@ -10,7 +12,8 @@ class SentryAppAvatarParser(Serializer): avatar_type = serializers.ChoiceField(choices=(("default", "default"), ("upload", "upload"))) color = serializers.BooleanField(required=True) - def validate(self, attrs): + # justification: DRF validated data is decoded request JSON with no static shape. + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: attrs = super().validate(attrs) if attrs.get("avatar_type") == "upload" and not attrs.get("avatar_photo"): @@ -19,7 +22,7 @@ def validate(self, attrs): if ( not attrs.get("color") and attrs.get("avatar_type") == "upload" - and not is_black_alpha_only(attrs.get("avatar_photo")) + and not is_black_alpha_only(cast("IO[bytes]", attrs.get("avatar_photo"))) ): raise serializers.ValidationError( { diff --git a/src/sentry/sentry_apps/api/parsers/sentry_app_installation.py b/src/sentry/sentry_apps/api/parsers/sentry_app_installation.py index 431024d9fe96..47ae8325c4c9 100644 --- a/src/sentry/sentry_apps/api/parsers/sentry_app_installation.py +++ b/src/sentry/sentry_apps/api/parsers/sentry_app_installation.py @@ -13,7 +13,7 @@ class SentryAppInstallationParser(Serializer): ) ) - def validate_status(self, new_status): + def validate_status(self, new_status: str) -> str: # can only set status to installed if new_status != SentryAppInstallationStatus.INSTALLED_STR: raise ValidationError( diff --git a/src/sentry/sentry_apps/api/parsers/servicehook.py b/src/sentry/sentry_apps/api/parsers/servicehook.py index 673b91d6a23a..d1582938566d 100644 --- a/src/sentry/sentry_apps/api/parsers/servicehook.py +++ b/src/sentry/sentry_apps/api/parsers/servicehook.py @@ -20,7 +20,7 @@ class ServiceHookValidator(serializers.Serializer): required=False, default=True, help_text="Whether the service hook is active." ) - def validate_events(self, value): + def validate_events(self, value: list[str]) -> list[str]: if value: for event in value: if event not in SERVICE_HOOK_EVENTS: diff --git a/src/sentry/sentry_apps/api/serializers/sentry_app.py b/src/sentry/sentry_apps/api/serializers/sentry_app.py index 29d56ba3c28b..717370f106dd 100644 --- a/src/sentry/sentry_apps/api/serializers/sentry_app.py +++ b/src/sentry/sentry_apps/api/serializers/sentry_app.py @@ -1,4 +1,4 @@ -from collections.abc import Mapping, Sequence +from collections.abc import Mapping, MutableMapping, Sequence from datetime import datetime, timedelta from typing import Any, NotRequired, TypedDict @@ -26,7 +26,7 @@ from sentry.users.services.user.service import user_service -def mask_webhook_header_values(headers: Sequence[str]) -> list[str]: +def mask_webhook_header_values(headers: Sequence[str | None]) -> list[str]: """Replace each header's value with MASKED_VALUE, preserving the header name. Webhook header values may carry secrets (e.g. bearer tokens), so they are masked @@ -35,6 +35,8 @@ def mask_webhook_header_values(headers: Sequence[str]) -> list[str]: """ masked = [] for header in headers: + if header is None: + continue name, separator, _value = header.partition(":") masked.append(f"{name}: {MASKED_VALUE}" if separator else MASKED_VALUE) return masked @@ -84,7 +86,7 @@ class SentryAppSerializerResponse(TypedDict): class SentryAppSerializer(Serializer): def get_attrs( self, item_list: Sequence[SentryApp], user: User | RpcUser | AnonymousUser, **kwargs: Any - ): + ) -> MutableMapping[Any, Any]: # justification: serializer attrs and kwargs are dynamic. # Get associated IntegrationFeatures app_feature_attrs = IntegrationFeature.objects.get_by_targets_as_dict( targets=item_list, target_type=IntegrationTypes.SENTRY_APP diff --git a/src/sentry/sentry_apps/api/serializers/sentry_app_avatar.py b/src/sentry/sentry_apps/api/serializers/sentry_app_avatar.py index d9c3d01a8098..0ce824458d55 100644 --- a/src/sentry/sentry_apps/api/serializers/sentry_app_avatar.py +++ b/src/sentry/sentry_apps/api/serializers/sentry_app_avatar.py @@ -1,8 +1,13 @@ -from typing import TypedDict +from collections.abc import Mapping +from typing import Any, TypedDict + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register from sentry.sentry_apps.models.sentry_app_avatar import SentryAppAvatar from sentry.sentry_apps.services.app.model import RpcSentryAppAvatar +from sentry.users.models.user import User +from sentry.users.services.user import RpcUser class SentryAppAvatarSerializerResponse(TypedDict): @@ -16,7 +21,11 @@ class SentryAppAvatarSerializerResponse(TypedDict): @register(SentryAppAvatar) class SentryAppAvatarSerializer(Serializer): def serialize( - self, obj: SentryAppAvatar | RpcSentryAppAvatar, attrs, user, **kwargs + self, + obj: SentryAppAvatar | RpcSentryAppAvatar, + attrs: Mapping[Any, Any], # justification: serializer attrs and kwargs are dynamic. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer attrs and kwargs are dynamic. ) -> SentryAppAvatarSerializerResponse: return { "avatarType": obj.get_avatar_type_display(), diff --git a/src/sentry/sentry_apps/api/serializers/sentry_app_component.py b/src/sentry/sentry_apps/api/serializers/sentry_app_component.py index 975383d25b22..d15c00aeeef1 100644 --- a/src/sentry/sentry_apps/api/serializers/sentry_app_component.py +++ b/src/sentry/sentry_apps/api/serializers/sentry_app_component.py @@ -1,13 +1,18 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any, NotRequired, TypedDict +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.api.serializers.base import serialize from sentry.sentry_apps.api.serializers.sentry_app_avatar import SentryAppAvatarSerializerResponse from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent from sentry.sentry_apps.services.app import SentryAppEventDataInterface from sentry.sentry_apps.utils.errors import SentryAppPublicErrorBody +from sentry.users.models.user import User +from sentry.users.services.user import RpcUser class SentryAppComponentAppResponse(TypedDict): @@ -28,7 +33,13 @@ class SentryAppComponentSerializerResponse(TypedDict): @register(SentryAppComponent) class SentryAppComponentSerializer(Serializer[SentryAppComponentSerializerResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> SentryAppComponentSerializerResponse: + def serialize( + self, + obj: SentryAppComponent, + attrs: Mapping[Any, Any], # justification: serializer attrs and kwargs are dynamic. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer attrs and kwargs are dynamic. + ) -> SentryAppComponentSerializerResponse: errors = kwargs["errors"] installation_uuids: dict[int, str] | None = kwargs.get("installation_uuids") data: SentryAppComponentSerializerResponse = { @@ -49,7 +60,13 @@ def serialize(self, obj, attrs, user, **kwargs) -> SentryAppComponentSerializerR class SentryAppAlertRuleActionSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: SentryAppComponent, + attrs: Mapping[Any, Any], # justification: serializer attrs and kwargs are dynamic. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer attrs and kwargs are dynamic. + ) -> dict[str, object]: event_action: SentryAppEventDataInterface | None = kwargs.get("event_action") if not event_action: raise AssertionError("Requires event_action keyword argument of type EventAction") diff --git a/src/sentry/sentry_apps/api/serializers/sentry_app_installation.py b/src/sentry/sentry_apps/api/serializers/sentry_app_installation.py index 367f1822699e..d7ea773e36df 100644 --- a/src/sentry/sentry_apps/api/serializers/sentry_app_installation.py +++ b/src/sentry/sentry_apps/api/serializers/sentry_app_installation.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import MutableMapping, Sequence +from collections.abc import Mapping, MutableMapping, Sequence from typing import Any, NotRequired, TypedDict from django.contrib.auth.models import AnonymousUser @@ -40,7 +40,7 @@ def get_attrs( item_list: Sequence[SentryAppInstallation], user: User | RpcUser | AnonymousUser, **kwargs: Any, - ): + ) -> MutableMapping[Any, Any]: # justification: serializer attrs and kwargs are dynamic. result: MutableMapping[Any, Any] = super().get_attrs(item_list, user, **kwargs) organizations = { @@ -61,7 +61,11 @@ def get_attrs( return result def serialize( - self, obj, attrs, user: User | RpcUser | AnonymousUser, **kwargs + self, + obj: SentryAppInstallation, + attrs: Mapping[Any, Any], # justification: serializer attrs and kwargs are dynamic. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer attrs and kwargs are dynamic. ) -> SentryAppInstallationResult: access = kwargs.get("access") data: SentryAppInstallationResult = { diff --git a/src/sentry/sentry_apps/api/serializers/servicehook.py b/src/sentry/sentry_apps/api/serializers/servicehook.py index dc438deed4a8..aa881db99db1 100644 --- a/src/sentry/sentry_apps/api/serializers/servicehook.py +++ b/src/sentry/sentry_apps/api/serializers/servicehook.py @@ -1,12 +1,17 @@ +from collections.abc import Mapping from datetime import datetime -from typing import TypedDict +from typing import Any, TypedDict + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register from sentry.sentry_apps.models.servicehook import ServiceHook +from sentry.users.models.user import User +from sentry.users.services.user import RpcUser class ServiceHookSerializerResponse(TypedDict): - id: str + id: str | None url: str secret: str status: str @@ -16,7 +21,13 @@ class ServiceHookSerializerResponse(TypedDict): @register(ServiceHook) class ServiceHookSerializer(Serializer[ServiceHookSerializerResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> ServiceHookSerializerResponse: + def serialize( + self, + obj: ServiceHook, + attrs: Mapping[Any, Any], # justification: serializer attrs and kwargs are dynamic. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer attrs and kwargs are dynamic. + ) -> ServiceHookSerializerResponse: return { "id": obj.guid, "url": obj.url, diff --git a/src/sentry/sentry_apps/api/serializers/servicehookproject.py b/src/sentry/sentry_apps/api/serializers/servicehookproject.py index c8cff88f8e4a..ace052fb914e 100644 --- a/src/sentry/sentry_apps/api/serializers/servicehookproject.py +++ b/src/sentry/sentry_apps/api/serializers/servicehookproject.py @@ -1,11 +1,24 @@ +from collections.abc import Mapping +from typing import Any + +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.sentry_apps.models.servicehook import ServiceHookProject from sentry.sentry_apps.services.cell.model import RpcServiceHookProject +from sentry.users.models.user import User +from sentry.users.services.user import RpcUser @register(ServiceHookProject) class ServiceHookProjectSerializer(Serializer): - def serialize(self, obj: ServiceHookProject | RpcServiceHookProject, attrs, user, **kwargs): + def serialize( + self, + obj: ServiceHookProject | RpcServiceHookProject, + attrs: Mapping[Any, Any], # justification: serializer attrs and kwargs are dynamic. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer attrs and kwargs are dynamic. + ) -> dict[str, str]: return { "id": str(obj.id), "project_id": str(obj.project_id), diff --git a/src/sentry/sentry_apps/external_requests/utils.py b/src/sentry/sentry_apps/external_requests/utils.py index 1bb883e99ac1..e800e38706d5 100644 --- a/src/sentry/sentry_apps/external_requests/utils.py +++ b/src/sentry/sentry_apps/external_requests/utils.py @@ -151,6 +151,7 @@ def send_and_save_sentry_app_request( # Re-raise the exception because some of these tasks might retry on the exception raise + project_id_header = resp.headers.get("Sentry-Hook-Project") track_response_code(resp.status_code, slug, event) buffer.add_request( response_code=resp.status_code, @@ -158,7 +159,7 @@ def send_and_save_sentry_app_request( event=event, url=url, error_id=resp.headers.get("Sentry-Hook-Error"), - project_id=resp.headers.get("Sentry-Hook-Project"), + project_id=int(project_id_header) if project_id_header is not None else None, response=resp, headers=loggable_headers, ) diff --git a/src/sentry/sentry_apps/installations.py b/src/sentry/sentry_apps/installations.py index 10c41441bbb1..b52ebad24311 100644 --- a/src/sentry/sentry_apps/installations.py +++ b/src/sentry/sentry_apps/installations.py @@ -3,7 +3,7 @@ import dataclasses import datetime from functools import cached_property -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict from django.db import router, transaction from django.http.request import HttpRequest @@ -38,6 +38,9 @@ from sentry.sentry_apps.tasks.sentry_apps import installation_webhook from sentry.sentry_apps.utils.errors import SentryAppSentryError from sentry.sentry_apps.utils.webhooks import InstallationActionType, SentryAppResourceType + +if TYPE_CHECKING: + from sentry.sentry_apps.services.app.model import RpcSentryAppInstallation from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser from sentry.utils import metrics @@ -231,15 +234,17 @@ def sentry_app(self) -> SentryApp: @cached_property def api_grant(self) -> ApiGrant | None: - return self.sentry_app_installation.api_grant_id and self.sentry_app_installation.api_grant + if not self.sentry_app_installation.api_grant_id: + return None + return self.sentry_app_installation.api_grant @dataclasses.dataclass class SentryAppInstallationUpdater: - sentry_app_installation: SentryAppInstallation + sentry_app_installation: SentryAppInstallation | RpcSentryAppInstallation status: str | None = None - def run(self) -> SentryAppInstallation: + def run(self) -> SentryAppInstallation | RpcSentryAppInstallation: with transaction.atomic(router.db_for_write(SentryAppInstallation)): self._update_status() self.record_analytics() diff --git a/src/sentry/sentry_apps/logic.py b/src/sentry/sentry_apps/logic.py index 3e4767343d1b..ffbc64b7a2c8 100644 --- a/src/sentry/sentry_apps/logic.py +++ b/src/sentry/sentry_apps/logic.py @@ -91,7 +91,7 @@ def expand_events(rolled_up_events: list[str]) -> list[str]: # TODO(schew2381): Delete this method after staff is GA'd and the options are removed -def _is_elevated_user(user) -> bool: +def _is_elevated_user(user: User | RpcUser) -> bool: """ This is a temporary helper method that checks if the user can become staff if staff mode is enabled. Otherwise, it defaults to checking that the user @@ -306,6 +306,8 @@ def _update_webhook_headers(self) -> None: # they should re-enter the value when renaming. existing_by_name = {} for header in self.sentry_app.webhook_headers: + if header is None: + continue name, separator, _value = header.partition(":") if separator: existing_by_name[name.strip().lower()] = header @@ -382,7 +384,7 @@ class SentryAppCreator: allowed_origins: list[str] = dataclasses.field(default_factory=list) webhook_headers: list[str] = dataclasses.field(default_factory=list) popularity: int | None = None - metadata: dict | None = field(default_factory=dict) + metadata: dict[str, object] | None = field(default_factory=dict) def __post_init__(self) -> None: if self.is_internal: diff --git a/src/sentry/sentry_apps/models/platformexternalissue.py b/src/sentry/sentry_apps/models/platformexternalissue.py index 6ab2fb0280ce..2402546fbcc3 100644 --- a/src/sentry/sentry_apps/models/platformexternalissue.py +++ b/src/sentry/sentry_apps/models/platformexternalissue.py @@ -31,11 +31,13 @@ class Meta: __repr__ = sane_repr("group_id", "service_type", "display_name", "web_url") @classmethod - def get_annotations_for_group_list(cls, group_list: Sequence[Group]): + def get_annotations_for_group_list( + cls, group_list: Sequence[Group] + ) -> dict[int, list[dict[str, str]]]: external_issues = cls.objects.filter(group_id__in=[group.id for group in group_list]) # group annotations by group id - annotations_by_group_id = defaultdict(list) + annotations_by_group_id: dict[int, list[dict[str, str]]] = defaultdict(list) for ei in external_issues: annotation = {"url": ei.web_url, "displayName": ei.display_name} annotations_by_group_id[ei.group_id].append(annotation) diff --git a/src/sentry/sentry_apps/models/sentry_app.py b/src/sentry/sentry_apps/models/sentry_app.py index 196ca95ac760..2c68dfa5265d 100644 --- a/src/sentry/sentry_apps/models/sentry_app.py +++ b/src/sentry/sentry_apps/models/sentry_app.py @@ -1,8 +1,10 @@ +from __future__ import annotations + import hmac import itertools import uuid from hashlib import sha256 -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, Protocol from django.contrib.postgres.fields.array import ArrayField from django.db import models, router, transaction @@ -36,6 +38,19 @@ from sentry.types.cell import find_all_cell_names, find_cells_for_sentry_app from sentry.utils import metrics +if TYPE_CHECKING: + from collections.abc import Iterable + + from django.db.models.base import ModelBase + + from sentry.models.organization import Organization + + +class HasScopes(Protocol): + @property + def scopes(self) -> Iterable[str]: ... + + REQUIRED_EVENT_PERMISSIONS = { "issue": "event:read", "error": "event:read", @@ -65,11 +80,11 @@ def required_scope_for_subscription(subscription: str) -> str: UUID_CHARS_IN_SLUG = 6 -def default_uuid(): +def default_uuid() -> str: return str(uuid.uuid4()) -def track_response_code(status, integration_slug, webhook_event): +def track_response_code(status: int | str, integration_slug: str, webhook_event: str) -> None: metrics.incr( "integration-platform.http_response", sample_rate=1.0, @@ -78,7 +93,7 @@ def track_response_code(status, integration_slug, webhook_event): class SentryAppManager(ParanoidManager["SentryApp"]): - def get_alertable_sentry_apps(self, organization_id: int) -> QuerySet["SentryApp"]: + def get_alertable_sentry_apps(self, organization_id: int) -> QuerySet[SentryApp]: return self.filter( installations__organization_id=organization_id, is_alertable=True, @@ -86,7 +101,7 @@ def get_alertable_sentry_apps(self, organization_id: int) -> QuerySet["SentryApp installations__date_deleted=None, ).distinct() - def visible_for_user(self, request: Request) -> QuerySet["SentryApp"]: + def visible_for_user(self, request: Request) -> QuerySet[SentryApp]: from sentry.auth.superuser import is_active_superuser if is_active_superuser(request): @@ -185,22 +200,33 @@ def slug_for_metrics(self) -> str: return "unpublished" return self.slug - 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: self.date_updated = timezone.now() with outbox_context(transaction.atomic(using=router.db_for_write(SentryApp)), flush=False): - result = super().save(*args, **kwargs) + result = super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) for outbox in self.outboxes_for_update(): outbox.save() return result - def update(self, *args, **kwargs): + def update(self, using: str | None = None, **kwargs: object) -> int: with outbox_context(transaction.atomic(using=router.db_for_write(SentryApp)), flush=False): - result = super().update(*args, **kwargs) + result = super().update(using=using, **kwargs) for outbox in self.outboxes_for_update(): outbox.save() return result - def is_installed_on(self, organization) -> bool: + def is_installed_on(self, organization: Organization) -> bool: from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation return SentryAppInstallation.objects.filter( @@ -208,7 +234,7 @@ def is_installed_on(self, organization) -> bool: sentry_app=self, ).exists() - def build_signature(self, body) -> str: + def build_signature(self, body: str) -> str: assert self.application is not None secret = self.application.client_secret # SentryApps always have a client_secret (they are confidential clients) @@ -217,7 +243,7 @@ def build_signature(self, body) -> str: key=secret.encode("utf-8"), msg=body.encode("utf-8"), digestmod=sha256 ).hexdigest() - def show_auth_info(self, access) -> bool: + def show_auth_info(self, access: HasScopes) -> bool: from sentry.conf.server import SENTRY_TOKEN_ONLY_SCOPES encoded_scopes = set({"%s" % scope for scope in list(access.scopes)}) @@ -253,7 +279,9 @@ def outboxes_for_delete(self) -> list[ControlOutbox]: def cells_with_installations(self) -> set[str]: return find_cells_for_sentry_app(self) - def delete(self, *args, **kwargs): + def delete( + self, using: str | None = None, keep_parents: bool = False + ) -> tuple[int, dict[str, int]]: from sentry.sentry_apps.models.sentry_app_avatar import SentryAppAvatar with outbox_context(transaction.atomic(using=router.db_for_write(SentryApp))): @@ -267,9 +295,9 @@ def delete(self, *args, **kwargs): if options.get("sentry-apps.hard-delete"): # actually delete the object. we need to delete all soft-deleted objects before removing ParanoidModel - return super(Model, self).delete(*args, **kwargs) + return super(Model, self).delete(using=using, keep_parents=keep_parents) - return super().delete(*args, **kwargs) + return super().delete(using=using, keep_parents=keep_parents) @classmethod def sanitize_relocation_json( diff --git a/src/sentry/sentry_apps/models/sentry_app_avatar.py b/src/sentry/sentry_apps/models/sentry_app_avatar.py index 3f1f0d6e2eba..005597bf2f10 100644 --- a/src/sentry/sentry_apps/models/sentry_app_avatar.py +++ b/src/sentry/sentry_apps/models/sentry_app_avatar.py @@ -25,7 +25,7 @@ class SentryAppAvatarTypes(Enum): UPLOAD = 1 @classmethod - def get_choices(cls): + def get_choices(cls) -> tuple[tuple[int, str], ...]: return tuple((_.value, _.name.lower()) for _ in SentryAppAvatarTypes) @@ -67,7 +67,7 @@ class Meta: url_path = "sentry-app-avatar" - def get_cache_key(self, size) -> str: + def get_cache_key(self, size: int) -> str: color_identifier = "color" if self.color else "simple" return f"sentry_app_avatar:{self.sentry_app_id}:{color_identifier}:{size}" diff --git a/src/sentry/sentry_apps/models/sentry_app_component.py b/src/sentry/sentry_apps/models/sentry_app_component.py index 2838589e241e..ed19e20e8da8 100644 --- a/src/sentry/sentry_apps/models/sentry_app_component.py +++ b/src/sentry/sentry_apps/models/sentry_app_component.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import MutableMapping -from typing import Any +from typing import Any, cast from django.db import models @@ -27,7 +27,8 @@ class Meta: @property def app_schema(self) -> MutableMapping[str, Any]: """Provides consistent interface with RpcSentryAppComponent""" - return self.schema + # justification: component schemas are stored as JSON objects. + return cast("MutableMapping[str, Any]", self.schema) @classmethod def sanitize_relocation_json( diff --git a/src/sentry/sentry_apps/models/sentry_app_installation.py b/src/sentry/sentry_apps/models/sentry_app_installation.py index 02ebec625214..da1b7b14cfdb 100644 --- a/src/sentry/sentry_apps/models/sentry_app_installation.py +++ b/src/sentry/sentry_apps/models/sentry_app_installation.py @@ -30,6 +30,10 @@ from sentry.types.cell import find_all_cell_names, find_cells_for_orgs if TYPE_CHECKING: + from collections.abc import Iterable + + from django.db.models.base import ModelBase + from sentry.models.project import Project from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent @@ -39,7 +43,7 @@ def default_uuid() -> str: class SentryAppInstallationForProviderManager(ParanoidManager["SentryAppInstallation"]): - def get_organization_filter_kwargs(self, organization_ids: list[int]): + def get_organization_filter_kwargs(self, organization_ids: list[int]) -> dict[str, object]: return { "organization_id__in": organization_ids, "status": SentryAppInstallationStatus.INSTALLED, @@ -122,11 +126,24 @@ class Meta: # grant code should be included in the serialization. is_new = False - 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: self.date_updated = timezone.now() - return super().save(*args, **kwargs) + return super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) - def delete(self, *args, **kwargs): + def delete( + self, using: str | None = None, keep_parents: bool = False + ) -> tuple[int, dict[str, int]]: with outbox_context(transaction.atomic(using=router.db_for_write(SentryAppInstallation))): for outbox in self.outboxes_for_delete(): outbox.save() @@ -136,9 +153,9 @@ def delete(self, *args, **kwargs): for update_outbox in self.outboxes_for_update(): update_outbox.save() # mimics ControlOutboxProducingModel - return models.Model.delete(self, *args, **kwargs) + return models.Model.delete(self, using=using, keep_parents=keep_parents) - return super().delete(*args, **kwargs) + return super().delete(using=using, keep_parents=keep_parents) @property def api_application_id(self) -> int | None: diff --git a/src/sentry/sentry_apps/models/sentry_app_installation_token.py b/src/sentry/sentry_apps/models/sentry_app_installation_token.py index 46a4d0cd27c8..bed85db155eb 100644 --- a/src/sentry/sentry_apps/models/sentry_app_installation_token.py +++ b/src/sentry/sentry_apps/models/sentry_app_installation_token.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: from sentry.auth.services.auth import AuthenticatedToken + from sentry.models.project import Project class SentryAppInstallationTokenManager(BaseManager["SentryAppInstallationToken"]): @@ -41,7 +42,7 @@ def _get_token(self, token: ApiToken | AuthenticatedToken) -> SentryAppInstallat pass return None - def get_projects(self, token: ApiToken) -> QuerySet: + def get_projects(self, token: ApiToken) -> QuerySet[Project]: from sentry.models.project import Project install_token = self._get_token(token) diff --git a/src/sentry/sentry_apps/models/servicehook.py b/src/sentry/sentry_apps/models/servicehook.py index 81046021612b..8637a2817900 100644 --- a/src/sentry/sentry_apps/models/servicehook.py +++ b/src/sentry/sentry_apps/models/servicehook.py @@ -1,8 +1,10 @@ +from __future__ import annotations + import hmac import secrets from functools import cached_property from hashlib import sha256 -from typing import Any, ClassVar, Self +from typing import TYPE_CHECKING, Any, ClassVar, Self from uuid import uuid4 from django.contrib.postgres.fields.array import ArrayField @@ -26,6 +28,9 @@ from sentry.sentry_apps.services.app import app_service from sentry.sentry_apps.services.app.model import RpcSentryApp +if TYPE_CHECKING: + from sentry.models.project import Project + SERVICE_HOOK_EVENTS = [ "event.alert", "event.created", @@ -64,8 +69,8 @@ class ServiceHook(Model): installation_id = HybridCloudForeignKey( "sentry.SentryAppInstallation", null=True, on_delete="CASCADE" ) - project_id = BoundedBigIntegerField(db_index=True, null=True) - organization_id = BoundedBigIntegerField(db_index=True, null=True) + project_id = BoundedBigIntegerField[int | None, int | None](db_index=True, null=True) + organization_id = BoundedBigIntegerField[int | None, int | None](db_index=True, null=True) url = models.URLField(max_length=512) secret = models.TextField(default=generate_secret) events = ArrayField(models.TextField(), default=list) @@ -84,7 +89,7 @@ class Meta: __repr__ = sane_repr("guid", "project_id") @property - def created_by_sentry_app(self): + def created_by_sentry_app(self) -> int | bool | None: return self.application_id and bool(self.sentry_app) @cached_property @@ -94,7 +99,7 @@ def sentry_app(self) -> RpcSentryApp | None: else: return app_service.get_by_application_id(application_id=self.application_id) - def __init__(self, *args, **kwargs): + def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) if self.guid is None: self.guid = uuid4().hex @@ -102,15 +107,15 @@ def __init__(self, *args, **kwargs): def __str__(self) -> str: return str(self.guid) - def build_signature(self, body): + def build_signature(self, body: str) -> str: return hmac.new( key=self.secret.encode("utf-8"), msg=body.encode("utf-8"), digestmod=sha256 ).hexdigest() - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, str]: return {"url": self.url} - def add_project(self, project_or_project_id): + def add_project(self, project_or_project_id: Project | int) -> None: """ Add a project to the service hook. """ diff --git a/src/sentry/sentry_apps/services/app/impl.py b/src/sentry/sentry_apps/services/app/impl.py index b690a5d2628b..503cfb7ab729 100644 --- a/src/sentry/sentry_apps/services/app/impl.py +++ b/src/sentry/sentry_apps/services/app/impl.py @@ -170,7 +170,7 @@ def find_alertable_services(self, *, organization_id: int) -> list[RpcSentryAppS def get_custom_alert_rule_actions( self, *, event_data: RpcSentryAppEventData, organization_id: int, project_slug: str | None ) -> list[Mapping[str, Any]]: - action_list = [] + action_list: list[Mapping[str, Any]] = [] # justification: action payloads are dynamic. for install in SentryAppInstallation.objects.get_installed_for_organization( organization_id ): diff --git a/src/sentry/sentry_apps/services/app/model.py b/src/sentry/sentry_apps/services/app/model.py index c98b72ec66f2..c8ebeacd8154 100644 --- a/src/sentry/sentry_apps/services/app/model.py +++ b/src/sentry/sentry_apps/services/app/model.py @@ -59,6 +59,8 @@ def absolute_url(self) -> str: Modified from AvatarBase.absolute_url for SentryAppAvatar """ url_base = options.get("system.url-prefix") + if not isinstance(url_base, str): + raise TypeError("system.url-prefix is not configured") return urljoin(url_base, f"/{self.url_path}/{self.ident}/") diff --git a/src/sentry/sentry_apps/services/legacy_webhook/client.py b/src/sentry/sentry_apps/services/legacy_webhook/client.py index b38c8cd7647a..8e2680e0d638 100644 --- a/src/sentry/sentry_apps/services/legacy_webhook/client.py +++ b/src/sentry/sentry_apps/services/legacy_webhook/client.py @@ -1,7 +1,6 @@ -from requests import Response - from sentry.sentry_apps.services.legacy_webhook.service import LegacyWebhookPayload from sentry.shared_integrations.client.base import BaseApiClient +from sentry.shared_integrations.response.base import BaseApiResponse class LegacyWebhookClient(BaseApiClient): @@ -14,7 +13,7 @@ def __init__(self, data: LegacyWebhookPayload) -> None: self.data = data super().__init__(verify_ssl=False) - def request(self, url: str) -> Response: + def request(self, url: str, *args: object, **kwargs: object) -> BaseApiResponse: return self._request( path=url, method="post", diff --git a/src/sentry/sentry_apps/tasks/sentry_apps.py b/src/sentry/sentry_apps/tasks/sentry_apps.py index 57c2eb1ab3c4..936da4c04bf9 100644 --- a/src/sentry/sentry_apps/tasks/sentry_apps.py +++ b/src/sentry/sentry_apps/tasks/sentry_apps.py @@ -914,7 +914,7 @@ def send_webhooks(installation: RpcSentryAppInstallation, event: str, **kwargs: silo_mode=SiloMode.CONTROL, ) def create_or_update_service_hooks_for_sentry_app( - sentry_app_id: int, webhook_url: str, events: list[str], **kwargs: dict + sentry_app_id: int, webhook_url: str, events: list[str], **kwargs: object ) -> None: with SentryAppInteractionEvent( operation_type=SentryAppInteractionType.MANAGEMENT, @@ -1001,7 +1001,8 @@ def _record_metric_alert_sent_analytics( def _record_metric_alert_ui_component_analytics( organization_id: int, sentry_app_id: int, - app_platform_event: AppPlatformEvent, + # justification: app event bodies are arbitrary JSON mappings. + app_platform_event: AppPlatformEvent[Mapping[str, Any]], ) -> None: if not find_alert_rule_action_ui_component(app_platform_event): return diff --git a/src/sentry/sentry_apps/tasks/service_hooks.py b/src/sentry/sentry_apps/tasks/service_hooks.py index 8fe29b0f9d12..4c6bb9bc23e6 100644 --- a/src/sentry/sentry_apps/tasks/service_hooks.py +++ b/src/sentry/sentry_apps/tasks/service_hooks.py @@ -1,4 +1,5 @@ from time import time +from typing import Any, cast from taskbroker_client.retry import Retry @@ -15,8 +16,12 @@ from sentry.utils.cache import cache -def get_payload_v0(event): +def get_payload_v0( + event: Event | GroupEvent, +) -> dict[str, Any]: # justification: webhook payload is JSON. group = event.group + if group is None: + raise ValueError("Cannot build a webhook payload for an ungrouped event") project = group.project group_context = serialize(group) @@ -42,7 +47,8 @@ def _get_service_hooks(project_id: int) -> list[tuple[int, list[str]]]: hooks = ServiceHook.objects.filter(servicehookproject__project_id=project_id) result = [(h.id, h.events) for h in hooks] cache.set(cache_key, result, 60) - return result + # justification: the cache only stores service hook id/event lists. + return cast("list[tuple[int, list[str]]]", result) def kick_off_service_hooks(event: GroupEvent, has_alert: bool) -> None: @@ -89,10 +95,12 @@ def process_service_hook( else: raise NotImplementedError + guid = servicehook.guid + assert guid is not None headers = { "Content-Type": "application/json", "X-ServiceHook-Timestamp": str(int(time())), - "X-ServiceHook-GUID": servicehook.guid, + "X-ServiceHook-GUID": guid, "X-ServiceHook-Signature": servicehook.build_signature(payload), } diff --git a/src/sentry/sentry_apps/utils/headers.py b/src/sentry/sentry_apps/utils/headers.py index 6ec0464363bd..9ac5c2d6d3e4 100644 --- a/src/sentry/sentry_apps/utils/headers.py +++ b/src/sentry/sentry_apps/utils/headers.py @@ -3,9 +3,11 @@ from sentry.sentry_apps.models.sentry_app import MASKED_VALUE -def parse_custom_headers(webhook_headers: Sequence[str]) -> dict[str, str]: +def parse_custom_headers(webhook_headers: Sequence[str | None]) -> dict[str, str]: headers: dict[str, str] = {} for header in webhook_headers: + if header is None: + continue name, separator, value = header.partition(":") if separator: headers[name.strip()] = value.strip() diff --git a/src/sentry/sentry_apps/utils/webhooks.py b/src/sentry/sentry_apps/utils/webhooks.py index 7bc88ce1d5b6..18bc0a999f30 100644 --- a/src/sentry/sentry_apps/utils/webhooks.py +++ b/src/sentry/sentry_apps/utils/webhooks.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Collection +from collections.abc import Collection, Mapping from enum import StrEnum from typing import TYPE_CHECKING, Any, Final @@ -162,7 +162,7 @@ def is_subscribed(stored_events: Collection[str], event: str) -> bool: def find_alert_rule_action_ui_component( - app_platform_event: AppPlatformEvent[dict[str, Any]], + app_platform_event: AppPlatformEvent[Mapping[str, Any]], ) -> bool: """ Returns True if the metric alert event contains a sentry app action with UI component settings. diff --git a/src/sentry/sentry_metrics/indexer/cache.py b/src/sentry/sentry_metrics/indexer/cache.py index 40791d962362..a94363abce63 100644 --- a/src/sentry/sentry_metrics/indexer/cache.py +++ b/src/sentry/sentry_metrics/indexer/cache.py @@ -4,6 +4,7 @@ import random from collections.abc import Collection, Iterable, Mapping, MutableMapping, Sequence from datetime import UTC, datetime, timedelta +from typing import cast from django.conf import settings from django.core.cache import caches @@ -137,7 +138,8 @@ def get(self, namespace: str, key: str) -> int | None: self._make_namespaced_cache_key(namespace, key), version=self.version ) return self._validate_result(result) - return self.cache.get(self._make_cache_key(key), version=self.version) + # justification: the legacy namespace stores raw integer values under this key. + return cast("int | None", self.cache.get(self._make_cache_key(key), version=self.version)) def set(self, namespace: str, key: str, value: int) -> None: self.cache.set( diff --git a/src/sentry/sentry_metrics/querying/data/execution.py b/src/sentry/sentry_metrics/querying/data/execution.py index 1699e4789aed..346b4a13bce1 100644 --- a/src/sentry/sentry_metrics/querying/data/execution.py +++ b/src/sentry/sentry_metrics/querying/data/execution.py @@ -352,17 +352,26 @@ def merge(self, other: "QueryResult") -> "QueryResult": @property def modified_start(self) -> datetime: - return self.result["modified_start"] + value = self.result["modified_start"] + if not isinstance(value, datetime): + raise MetricsQueryExecutionError("modified_start is not a datetime") + return value @property def modified_end(self) -> datetime: - return self.result["modified_end"] + value = self.result["modified_end"] + if not isinstance(value, datetime): + raise MetricsQueryExecutionError("modified_end is not a datetime") + return value @property def series(self) -> Sequence[Mapping[str, Any]]: if "series" not in self.result: return [] - return self.result["series"]["data"] + data = self.result["series"]["data"] + if not isinstance(data, list): + raise MetricsQueryExecutionError("series data is not a list") + return data @series.setter def series(self, value: Sequence[Mapping[str, Any]]) -> None: @@ -372,7 +381,10 @@ def series(self, value: Sequence[Mapping[str, Any]]) -> None: def totals(self) -> Sequence[Mapping[str, Any]]: if "totals" not in self.result: return [] - return self.result["totals"]["data"] + data = self.result["totals"]["data"] + if not isinstance(data, list): + raise MetricsQueryExecutionError("totals data is not a list") + return data @totals.setter def totals(self, value: Sequence[Mapping[str, Any]]) -> None: @@ -383,7 +395,10 @@ def meta(self) -> Sequence[Mapping[str, str]]: # By default, we extract the metadata from the totals query, if that is not there we extract from the series # query. meta_source = "totals" if "totals" in self.result else "series" - return self.result[meta_source]["meta"] + data = self.result[meta_source]["meta"] + if not isinstance(data, list): + raise MetricsQueryExecutionError("meta is not a list") + return data @property def group_bys(self) -> list[str]: @@ -400,7 +415,8 @@ def group_bys(self) -> list[str]: @property def interval(self) -> int | None: if self.series_query: - return self.series_query.metrics_query.rollup.interval + interval = self.series_query.metrics_query.rollup.interval + return interval if isinstance(interval, int) else None return None @@ -417,7 +433,8 @@ def limit(self) -> int | None: # TODO: we might want to return the limit that is actually returned to users. In that, we would need to check # if the queries run have a dynamic interval, since in that case we might need to return limit - 1. if self.totals_query: - return self.totals_query.metrics_query.limit.limit + limit = self.totals_query.metrics_query.limit.limit + return limit if isinstance(limit, int) else None return None diff --git a/src/sentry/sentry_metrics/querying/data/mapping/base.py b/src/sentry/sentry_metrics/querying/data/mapping/base.py index c3f816aa4695..5e55d3b73fd2 100644 --- a/src/sentry/sentry_metrics/querying/data/mapping/base.py +++ b/src/sentry/sentry_metrics/querying/data/mapping/base.py @@ -10,11 +10,11 @@ class Mapper(abc.ABC): to_key: str = "" applied_on_groupby: bool = False - def __init__(self): + def __init__(self) -> None: # This exists to satisfy mypy, which complains otherwise self.map: dict[Any, Any] = {} - def __hash__(self): + def __hash__(self) -> int: return hash((self.from_key, self.to_key)) @abc.abstractmethod @@ -27,7 +27,7 @@ def backward(self, projects: Sequence[Project], value: Any) -> Any: class MapperConfig: - def __init__(self): + def __init__(self) -> None: self.mappers: set[type[Mapper]] = set() def add(self, mapper: type[Mapper]) -> "MapperConfig": diff --git a/src/sentry/sentry_metrics/querying/data/mapping/project_mapper.py b/src/sentry/sentry_metrics/querying/data/mapping/project_mapper.py index f8506e963a23..8b0fbdf2b6b8 100644 --- a/src/sentry/sentry_metrics/querying/data/mapping/project_mapper.py +++ b/src/sentry/sentry_metrics/querying/data/mapping/project_mapper.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from typing import cast from sentry.models.project import Project from sentry.sentry_metrics.querying.data.mapping.base import Mapper @@ -19,7 +20,8 @@ def forward(self, projects: Sequence[Project], value: str) -> int: for project in projects: if project.slug == value: self.map[value] = project.id - return self.map[value] + # justification: the forward map stores project ids. + return cast("int", self.map[value]) def backward(self, projects: Sequence[Project], value: int) -> str: if value not in self.map: @@ -27,4 +29,5 @@ def backward(self, projects: Sequence[Project], value: int) -> str: if project.id == value: self.map[value] = project.slug - return self.map[value] + # justification: the backward map stores project slugs. + return cast("str", self.map[value]) diff --git a/src/sentry/sentry_metrics/querying/data/postprocessing/base.py b/src/sentry/sentry_metrics/querying/data/postprocessing/base.py index 5357a8ecab05..61b7570d51bb 100644 --- a/src/sentry/sentry_metrics/querying/data/postprocessing/base.py +++ b/src/sentry/sentry_metrics/querying/data/postprocessing/base.py @@ -22,7 +22,9 @@ def run(self, query_results: list[QueryResult]) -> list[QueryResult]: raise NotImplementedError -def run_post_processing_steps(query_results: list[QueryResult], *steps) -> list[QueryResult]: +def run_post_processing_steps( + query_results: list[QueryResult], *steps: PostProcessingStep +) -> list[QueryResult]: """ Takes a series of query results and steps and runs the post-processing steps one after each other in order they are supplied in. diff --git a/src/sentry/sentry_metrics/querying/data/preparation/base.py b/src/sentry/sentry_metrics/querying/data/preparation/base.py index eb25bf75bab5..e40acffe04dc 100644 --- a/src/sentry/sentry_metrics/querying/data/preparation/base.py +++ b/src/sentry/sentry_metrics/querying/data/preparation/base.py @@ -51,7 +51,7 @@ def run(self, intermediate_queries: list[IntermediateQuery]) -> list[Intermediat def run_preparation_steps( - intermediate_queries: list[IntermediateQuery], *steps + intermediate_queries: list[IntermediateQuery], *steps: PreparationStep ) -> list[IntermediateQuery]: """ Takes a series of intermediate queries and steps and runs those intermediate queries on the supplied steps in an diff --git a/src/sentry/sentry_metrics/querying/data/query.py b/src/sentry/sentry_metrics/querying/data/query.py index 81bce8efc159..404571b3035c 100644 --- a/src/sentry/sentry_metrics/querying/data/query.py +++ b/src/sentry/sentry_metrics/querying/data/query.py @@ -26,8 +26,12 @@ class MQLQuery: """ def __init__( - self, mql: str, order: QueryOrder | None = None, limit: int | None = None, **sub_queries - ): + self, + mql: str, + order: QueryOrder | None = None, + limit: int | None = None, + **sub_queries: object, + ) -> None: self.mql = mql self.order = order self.limit = limit diff --git a/src/sentry/sentry_metrics/querying/data/transformation/metrics_api.py b/src/sentry/sentry_metrics/querying/data/transformation/metrics_api.py index f9cc82340488..31101de2d75b 100644 --- a/src/sentry/sentry_metrics/querying/data/transformation/metrics_api.py +++ b/src/sentry/sentry_metrics/querying/data/transformation/metrics_api.py @@ -30,10 +30,10 @@ class GroupValue: def empty(cls) -> "GroupValue": return GroupValue(series=[], totals=None) - def add_series_entry(self, time: str, aggregate_value: ResultValue): + def add_series_entry(self, time: str, aggregate_value: ResultValue) -> None: self.series.append((time, self._transform_aggregate_value(aggregate_value))) - def add_totals(self, aggregate_value: ResultValue): + def add_totals(self, aggregate_value: ResultValue) -> None: self.totals = self._transform_aggregate_value(aggregate_value) def _transform_aggregate_value(self, aggregate_value: ResultValue) -> ResultValue: @@ -66,11 +66,11 @@ class QueryMeta: meta: dict[str, Any] - def __init__(self, **kwargs): + def __init__(self, **kwargs: object) -> None: self.meta = kwargs self._transform_meta() - def _transform_meta(self): + def _transform_meta(self) -> None: """ Transforms the medata of the query after initialization. diff --git a/src/sentry/sentry_metrics/querying/visitors/base.py b/src/sentry/sentry_metrics/querying/visitors/base.py index 1d82a2489f7b..a36bd969be1c 100644 --- a/src/sentry/sentry_metrics/querying/visitors/base.py +++ b/src/sentry/sentry_metrics/querying/visitors/base.py @@ -1,5 +1,5 @@ from abc import ABC -from typing import Generic, TypeVar +from typing import Generic, TypeVar, cast from snuba_sdk import BooleanCondition, Condition, Formula, Timeseries from snuba_sdk.conditions import ConditionGroup @@ -36,10 +36,12 @@ def _visit_formula(self, formula: Formula) -> TVisited: for parameter in formula.parameters: parameters.append(self.visit(parameter)) - return formula.set_parameters(parameters) + # justification: snuba_sdk is exempt from typing, so set_parameters returns Any. + return cast("TVisited", formula.set_parameters(parameters)) def _visit_timeseries(self, timeseries: Timeseries) -> TVisited: - return timeseries + # justification: the default visitor returns the timeseries unchanged. + return cast("TVisited", timeseries) def _visit_int(self, int_number: float) -> TVisited: return int_number # type: ignore[return-value] @@ -82,7 +84,8 @@ def _visit_boolean_condition(self, boolean_condition: BooleanCondition) -> TVisi for condition in boolean_condition.conditions: conditions.append(self.visit(condition)) - return BooleanCondition(op=boolean_condition.op, conditions=conditions) + # justification: snuba_sdk is exempt from typing, so BooleanCondition is Any. + return cast("TVisited", BooleanCondition(op=boolean_condition.op, conditions=conditions)) def _visit_condition(self, condition: Condition) -> TVisited: raise condition diff --git a/src/sentry/sentry_metrics/querying/visitors/query_condition.py b/src/sentry/sentry_metrics/querying/visitors/query_condition.py index 5c0e672e4281..f6899daf53f8 100644 --- a/src/sentry/sentry_metrics/querying/visitors/query_condition.py +++ b/src/sentry/sentry_metrics/querying/visitors/query_condition.py @@ -103,7 +103,7 @@ def _visit_condition(self, condition: Condition) -> QueryCondition: ) -class MapperConditionVisitor(QueryConditionVisitor): +class MapperConditionVisitor(QueryConditionVisitor[QueryCondition]): def __init__(self, projects: Sequence[Project], mapper_config: MapperConfig): self.projects = projects self.mapper_config = mapper_config diff --git a/src/sentry/sentry_metrics/querying/visitors/query_expression.py b/src/sentry/sentry_metrics/querying/visitors/query_expression.py index 6d23694850cf..8cb71ab47a55 100644 --- a/src/sentry/sentry_metrics/querying/visitors/query_expression.py +++ b/src/sentry/sentry_metrics/querying/visitors/query_expression.py @@ -1,5 +1,5 @@ from collections.abc import Sequence -from typing import Any +from typing import Any, cast import sentry_sdk from snuba_sdk import AliasedExpression, Column, Condition, Formula, Op, Timeseries @@ -14,7 +14,7 @@ get_or_create_mapper, ) from sentry.sentry_metrics.querying.errors import InvalidMetricsQueryError -from sentry.sentry_metrics.querying.types import QueryExpression +from sentry.sentry_metrics.querying.types import QueryCondition, QueryExpression from sentry.sentry_metrics.querying.units import ( Unit, UnitMetadata, @@ -94,10 +94,10 @@ class QueryValidationV2Visitor(QueryExpressionVisitor[QueryExpression]): Visitor that recursively validates the `QueryExpression` of the new endpoint. """ - def __init__(self): - self._query_namespace = None - self._query_entity = None - self._query_group_bys = None + def __init__(self) -> None: + self._query_namespace: str | None = None + self._query_entity: str | None = None + self._query_group_bys: list[str] | None = None self._query_group_bys_stack: list[list[str]] = [] def _visit_formula(self, formula: Formula) -> QueryExpression: @@ -148,7 +148,7 @@ def _visit_timeseries(self, timeseries: Timeseries) -> QueryExpression: return timeseries - def _validate_accumulated_group_bys(self, timeseries: Timeseries): + def _validate_accumulated_group_bys(self, timeseries: Timeseries) -> None: """ Validates that the group bys on a given `Timeseries` are equal to the ones previously encountered (if any). @@ -181,9 +181,11 @@ def _flatten_group_bys(self, group_bys: list[Column | AliasedExpression] | None) def _column_name(group_by: Column | AliasedExpression) -> str: if isinstance(group_by, Column): - return group_by.name + # justification: snuba_sdk is exempt from typing. + return cast("str", group_by.name) elif isinstance(group_by, AliasedExpression): - return group_by.exp.name + # justification: snuba_sdk is exempt from typing. + return cast("str", group_by.exp.name) return "" @@ -198,7 +200,7 @@ class QueryConditionsCompositeVisitor(QueryExpressionVisitor[QueryExpression]): Visitor that runs a series of `QueryConditionVisitor`(s) on each filters of elements of a `QueryExpression`. """ - def __init__(self, *visitors: QueryConditionVisitor): + def __init__(self, *visitors: QueryConditionVisitor[QueryCondition]) -> None: self._visitors = list(visitors) def _visit_formula(self, formula: Formula) -> QueryExpression: @@ -245,7 +247,7 @@ def _visit_timeseries(self, timeseries: Timeseries) -> set[str]: return {timeseries.metric.mri} - def _visit_int(self, int_number: float): + def _visit_int(self, int_number: float) -> set[str]: return set() def _visit_float(self, float_number: float) -> set[str]: @@ -274,7 +276,7 @@ def _visit_formula(self, formula: Formula) -> set[str]: def _visit_timeseries(self, timeseries: Timeseries) -> set[str]: return self._group_bys_as_string(timeseries.groupby) - def _visit_int(self, int_number: float): + def _visit_int(self, int_number: float) -> set[str]: return set() def _visit_float(self, float_number: float) -> set[str]: @@ -319,8 +321,8 @@ class UnitsNormalizationVisitor(QueryExpressionVisitor[tuple[UnitMetadata, Query UNITLESS_AGGREGATES = {"count", "count_unique"} - def __init__(self): - self._unit_family = None + def __init__(self) -> None: + self._unit_family: str | None = None def _visit_formula(self, formula: Formula) -> tuple[UnitMetadata, QueryExpression]: last_metadata: WithUnit | None = None @@ -475,7 +477,7 @@ def _is_numeric_scalar(self, value: QueryExpression) -> bool: return isinstance(value, int) or isinstance(value, float) -class MapperVisitor(QueryExpressionVisitor): +class MapperVisitor(QueryExpressionVisitor[QueryExpression]): """ Visitor that recursively transforms the QueryExpression components to modulate certain attributes to be queried by API that need to be translated for Snuba to be able to query the data. diff --git a/src/sentry/services/eventstore/models.py b/src/sentry/services/eventstore/models.py index 597c54a160a5..c3f655e797f3 100644 --- a/src/sentry/services/eventstore/models.py +++ b/src/sentry/services/eventstore/models.py @@ -95,7 +95,8 @@ def trace_id(self) -> str | None: # Reuse the Snuba column to avoid loading the full event body from nodestore. if "trace_id" in self._snuba_data: return cast(str | None, self._snuba_data["trace_id"]) - return get_path(self.data, "contexts", "trace", "trace_id") + trace_id = get_path(self.data, "contexts", "trace", "trace_id") + return trace_id if isinstance(trace_id, str) else None @property def platform(self) -> str | None: @@ -216,9 +217,11 @@ def get_minimal_user(self) -> User: username = self._snuba_data[user_username_column] ip_address = self._snuba_data[user_ip_address_column] - return User.to_python( + user = User.to_python( {"id": user_id, "email": email, "username": username, "ip_address": ip_address} ) + assert user is not None + return user return self.get_interface("user") @@ -259,7 +262,7 @@ def title(self) -> str: event_type = self.get_event_type() # TODO: It may be that we don't have to restrict this to just default and error types - if title and event_type in ["default", "error"]: + if isinstance(title, str) and title and event_type in ["default", "error"]: return title event_type_instance = eventtypes.get(event_type)() @@ -383,7 +386,7 @@ def get_hashes(self, force_config: StrategyConfiguration | None = None) -> list[ # this if we do not force a different config. if force_config is None: hashes = self.data.get("hashes") - if hashes is not None: + if isinstance(hashes, list): return hashes # Create fresh hashes @@ -837,16 +840,16 @@ def __getitem__(self, name: str) -> str: raise KeyError return str(value) elif name == "project": - return cast(str, self.event.project.get_full_name()) + return self.event.project.get_full_name() elif name == "projectID": - return cast(str, self.event.project.slug) + return self.event.project.slug elif name == "shortID" and self.event.group_id and self.event.group: return cast(str, self.event.group.qualified_short_id) elif name == "orgID": return self.event.organization.slug elif name == "title": occurrence = getattr(self.event, "occurrence", None) - if occurrence: + if occurrence and isinstance(occurrence.issue_title, str): return occurrence.issue_title else: return self.event.title diff --git a/src/sentry/services/eventstore/query_preprocessing.py b/src/sentry/services/eventstore/query_preprocessing.py index 110c17a6be0c..d189bb7b9ea6 100644 --- a/src/sentry/services/eventstore/query_preprocessing.py +++ b/src/sentry/services/eventstore/query_preprocessing.py @@ -61,7 +61,7 @@ def _try_get_from_cache( def get_all_merged_group_ids( - group_ids: Iterable[str | int], threshold=SIZE_THRESHOLD_FOR_CLICKHOUSE + group_ids: Iterable[str | int], threshold: int = SIZE_THRESHOLD_FOR_CLICKHOUSE ) -> set[str | int]: with traces.start_span( name="get_all_merged_group_ids", attributes={"sentry.op": "get_all_merged_group_ids"} diff --git a/src/sentry/services/eventstore/reprocessing/redis.py b/src/sentry/services/eventstore/reprocessing/redis.py index 95149f906c08..a1c5d55f2cec 100644 --- a/src/sentry/services/eventstore/reprocessing/redis.py +++ b/src/sentry/services/eventstore/reprocessing/redis.py @@ -1,6 +1,6 @@ import uuid from datetime import datetime -from typing import Any +from typing import Any, cast import orjson import redis @@ -156,6 +156,8 @@ def mark_event_reprocessed(self, group_id: int, num_events: int) -> bool: pipe.expire(name=sync_counter_key, time=settings.SENTRY_REPROCESSING_SYNC_TTL) pipe.decrby(name=sync_counter_key, amount=num_events) new_decremented_value = pipe.execute()[2] + if not isinstance(new_decremented_value, int): + raise TypeError("Reprocessing sync counter is not an integer") # It can be that we overshoot and end up with a negative number here as such use `<=` rather than `==`. return new_decremented_value <= 0 @@ -180,13 +182,21 @@ def get_pending(self, group_id: int) -> tuple[str | None, int]: pending_key = _get_sync_counter_key(group_id) pending = self.redis.get(pending_key) ttl = self.redis.ttl(pending_key) + if pending is not None and not isinstance(pending, str): + raise TypeError("Reprocessing sync counter is not a string") + if not isinstance(ttl, int): + raise TypeError("Reprocessing sync counter TTL is not an integer") return pending, ttl def get_progress(self, group_id: int) -> ReprocessingInfo | None: info = self.redis.get(_get_info_reprocessed_key(group_id)) if info is None: return None - return orjson.loads(info) + data = orjson.loads(info) + if not isinstance(data, dict): + raise TypeError("Reprocessing info is not an object") + # justification: the info key stores the ReprocessingInfo written by start_reprocessing. + return cast("ReprocessingInfo", data) def try_claim_page( self, diff --git a/src/sentry/services/eventstore/snuba/backend.py b/src/sentry/services/eventstore/snuba/backend.py index 3dbcaff588a3..6ce8a4d9259d 100644 --- a/src/sentry/services/eventstore/snuba/backend.py +++ b/src/sentry/services/eventstore/snuba/backend.py @@ -5,7 +5,7 @@ from collections.abc import Mapping, Sequence from copy import copy, deepcopy from datetime import UTC, datetime, timedelta -from typing import Any, Literal, overload +from typing import Any, Literal, cast, overload import sentry_sdk from django.utils import timezone @@ -479,12 +479,12 @@ def get_event_by_id( event into a `GroupEvent`. Used as part of `post_process_group`. """ - event_id = normalize_event_id(event_id) + normalized_event_id = normalize_event_id(event_id) - if not event_id: + if not normalized_event_id: return None - event = Event(project_id=project_id, event_id=event_id) + event = Event(project_id=project_id, event_id=normalized_event_id) # Return None if there was no data in nodestore if len(event.data) == 0: @@ -512,12 +512,12 @@ def get_event_by_id( elif event.get_event_type() != "transaction" or group_id: # Load group_id from Snuba if not a transaction - raw_query_kwargs = {} + conditions: list[tuple[str, str, object] | list[object]] | None = None if event.datetime > timezone.now() - timedelta(hours=1): # XXX: This is a hack to bust the snuba cache. We want to avoid the case where # we cache an empty result, since this can result in us failing to fetch new events # in some cases. - raw_query_kwargs["conditions"] = [ + conditions = [ [ "timestamp", ">", @@ -531,7 +531,11 @@ def get_event_by_id( ) try: tenant_ids = tenant_ids or {"organization_id": event.project.organization_id} - filter_keys = {"project_id": [project_id], "event_id": [event_id]} + filter_keys: dict[str, Sequence[int]] = { + "project_id": [project_id], + # justification: snuba accepts hex event id strings for `event_id`. + "event_id": cast("Sequence[int]", [event_id]), + } if group_id: filter_keys["group_id"] = [group_id] result = snuba.raw_query( @@ -540,10 +544,10 @@ def get_event_by_id( start=event.datetime, end=event.datetime + timedelta(seconds=1), filter_keys=filter_keys, + conditions=conditions, limit=1, referrer=Referrer.EVENTSTORE_GET_EVENT_BY_ID_NODESTORE.value, tenant_ids=tenant_ids, - **raw_query_kwargs, ) except snuba.QueryOutsideRetentionError: # this can happen due to races. We silently want to hide @@ -975,7 +979,7 @@ def __get_event_ids_from_filters( filter_keys=filter.filter_keys, start=filter.start, end=filter.end, - orderby=filter.orderby, + orderby=(list(filter.orderby) if filter.orderby is not None else None), limit=1, referrer="eventstore.get_next_or_prev_event_id", dataset=dataset, diff --git a/src/sentry/services/filestore/gcs.py b/src/sentry/services/filestore/gcs.py index 672ea2b0d881..c6d77dcb5c95 100644 --- a/src/sentry/services/filestore/gcs.py +++ b/src/sentry/services/filestore/gcs.py @@ -6,7 +6,10 @@ import random import time from collections.abc import Callable +from datetime import datetime from tempfile import SpooledTemporaryFile +from typing import IO, TYPE_CHECKING, TypeVar, cast +from typing import cast as typing_cast from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -20,8 +23,12 @@ InternalServerError, ServiceUnavailable, ) +from google.auth.credentials import Credentials from google.auth.exceptions import RefreshError, TransportError from google.cloud.exceptions import NotFound + +# justification: google.cloud does not annotate the NotFound constructor. +_NotFound = typing_cast("Callable[[str], NotFound]", NotFound) from google.cloud.storage.blob import Blob from google.cloud.storage.bucket import Bucket from google.cloud.storage.client import Client @@ -32,6 +39,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 +66,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[str | None, object | None, Client | None] = (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 +109,7 @@ def try_repeated(func): idx += 1 -def get_client(project_id, credentials): +def get_client(project_id: str | None, credentials: object | None) -> Client: global _client if _client[2] is None or (project_id, credentials) != (_client[0], _client[1]): client = Client(project=project_id, credentials=credentials) @@ -104,10 +118,13 @@ def get_client(project_id, credentials): session.mount("http://", adapter) session.mount("https://", adapter) _client = (project_id, credentials, client) - return _client[2] + cached_client = _client[2] + if cached_client is None: + raise RuntimeError("GCS client was not cached") + return cached_client -def clean_name(name): +def clean_name(name: str) -> str: """ Cleans the name so that Windows style paths work """ @@ -127,7 +144,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 +157,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: @@ -161,11 +181,18 @@ def safe_join(base, *paths): class FancyBlob(Blob): - def __init__(self, download_url, *args, **kwargs): + def __init__(self, download_url: str, name: str, bucket: Bucket) -> None: self.download_url = download_url - super().__init__(*args, **kwargs) + super().__init__(name, bucket) - def _get_download_url(self, *args, **kwargs): + def _get_download_url( + self, + client: Client, + if_generation_match: int | None = None, + if_generation_not_match: int | None = None, + if_metageneration_match: int | None = None, + if_metageneration_not_match: int | None = None, + ) -> str: # media_link is for public objects; we completely ignore it. download_url = f"{self.download_url}/download/storage/v1{self.path}?alt=media" if self.generation is not None: @@ -173,8 +200,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,18 +211,18 @@ 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 - def size(self): + def size(self) -> int: if self.blob.size is None: self.blob.reload() - return self.blob.size + return cast("int", 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 +238,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,18 +269,25 @@ 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, - credentials=None, - bucket_name=None, - file_name_charset="utf-8", - file_overwrite=True, - download_url="https://www.googleapis.com", + project_id: str | None = None, + credentials: Credentials | None = None, + bucket_name: str | None = None, + file_name_charset: str = "utf-8", + file_overwrite: bool = True, + download_url: str = "https://www.googleapis.com", # The max amount of memory a returned file can take up before being # rolled over into a temporary file on disk. Default is 0: Do not roll over. - max_memory_size=0, - ): + max_memory_size: int = 0, + ) -> None: self.project_id = project_id self.credentials = credentials self.bucket_name = bucket_name @@ -262,22 +296,22 @@ def __init__( self.download_url = download_url self.max_memory_size = max_memory_size - self._bucket = None - self._client = None + self._bucket: Bucket | None = None + self._client: Client | None = None @property - def client(self): + def client(self) -> Client: if self._client is None: self._client = get_client(self.project_id, self.credentials) return self._client @property - def bucket(self): + def bucket(self) -> Bucket: if self._bucket is None: 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 +319,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 +341,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 +351,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 @@ -328,7 +362,7 @@ def exists(self, name): name = self._normalize_name(clean_name(name)) return bool(self.bucket.get_blob(self._encode_name(name))) - def listdir(self, name): + def listdir(self, name: str) -> tuple[list[str], list[str]]: name = self._normalize_name(clean_name(name)) # for the bucket.list and logic below name needs to end in / # But for the root path "" we leave it as an empty string @@ -351,38 +385,39 @@ def listdir(self, name): dirs.add(parts[0]) return list(dirs), files - def _get_blob(self, name): + def _get_blob(self, name: str) -> Blob: # Wrap google.cloud.storage's blob to raise if the file doesn't exist blob = self.bucket.get_blob(name) if blob is None: - raise NotFound(f"File does not exist: {name}") + raise _NotFound(f"File does not exist: {name}") return blob - def size(self, name): + def size(self, name: str) -> int: name = self._normalize_name(clean_name(name)) blob = self._get_blob(self._encode_name(name)) - return blob.size + return cast("int", 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 return updated if settings.USE_TZ else timezone.make_naive(updated) - def url(self, name): + def url(self, name: str | None) -> str: # Preserve the trailing slash after normalizing the path. + name = cast("str", name) name = self._normalize_name(clean_name(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 +456,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..15b580f3e8df 100644 --- a/src/sentry/services/filestore/s3.py +++ b/src/sentry/services/filestore/s3.py @@ -40,8 +40,10 @@ import os import posixpath import threading +from datetime import datetime from gzip import GzipFile from io import BytesIO +from typing import IO, TYPE_CHECKING, Any, cast from urllib import parse as urlparse from boto3.session import Session @@ -59,7 +61,13 @@ _thread_local_connection = threading.local() -def _get_thread_local_session(): +if TYPE_CHECKING: + _S3Boto3StorageFileBase = File[bytes] +else: + _S3Boto3StorageFileBase = File + + +def _get_thread_local_session() -> Session: try: return _thread_local_connection.session except AttributeError: @@ -67,11 +75,11 @@ def _get_thread_local_session(): return rv -def resource(*args, **kwargs): +def resource(*args: object, **kwargs: object) -> object: 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 +112,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. @@ -127,7 +135,9 @@ class S3Boto3StorageFile(File): # BufferedIO streams in the Python 2.6 io module. buffer_size = 5242880 - def __init__(self, name, mode, storage, buffer_size=None): + def __init__( + self, name: str, mode: str, storage: S3Boto3Storage, buffer_size: int | None = None + ) -> None: self._storage = storage self.name = name[len(self._storage.location) :].lstrip("/") self._mode = mode @@ -142,8 +152,9 @@ 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._multipart = None + self._file: BytesIO | GzipFile | None = None + # justification: boto3 multipart upload handles are untyped. + self._multipart: Any = 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 # up to roughly 50 GB. Increase the part size to accommodate @@ -153,11 +164,14 @@ def __init__(self, name, mode, storage, buffer_size=None): self._write_counter = 0 @property - def size(self): - return self.obj.content_length + def size(self) -> int: + content_length = self.obj.content_length + if not isinstance(content_length, int): + raise TypeError("S3 object has no content length") + return content_length @property - def file(self): + def file(self) -> IO[bytes] | None: if self._file is None: with metrics.timer("filestore.read", instance="s3"): self._file = BytesIO() @@ -167,18 +181,22 @@ def file(self): self._file.seek(0) if self._storage.gzip and self.obj.content_encoding == "gzip": self._file = GzipFile(mode=self._mode, fileobj=self._file, mtime=0.0) - return self._file + assert self._file is not None + return cast("IO[bytes]", self._file) @file.setter - def file(self, value): - self._file = value + def file(self, value: IO[bytes] | None) -> None: + self._file = cast("BytesIO | GzipFile | None", value) - def read(self, *args, **kwargs): + def read(self, num_bytes: int | None = None) -> bytes: if "r" not in self._mode: raise AttributeError("File was not opened in read mode.") - return super().read(*args, **kwargs) + data = super().read(num_bytes) + if not isinstance(data, bytes): + raise TypeError("S3 file returned a non-bytes read") + return data - 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 @@ -195,28 +213,35 @@ def write(self, content): self._multipart = self.obj.initiate_multipart_upload(**parameters) if self.buffer_size <= self._buffer_file_size: self._flush_write_buffer() - return super().write(force_bytes(content)) + written = super().write(force_bytes(content)) + if not isinstance(written, int): + raise TypeError("S3 file returned a non-integer write count") + return written @property - def _buffer_file_size(self): - pos = self.file.tell() - self.file.seek(0, os.SEEK_END) - length = self.file.tell() - self.file.seek(pos) + def _buffer_file_size(self) -> int: + file = self.file + assert file is not None + pos = file.tell() + file.seek(0, os.SEEK_END) + length = file.tell() + file.seek(pos) return length - def _flush_write_buffer(self): + def _flush_write_buffer(self) -> None: """ Flushes the write buffer. """ if self._buffer_file_size: self._write_counter += 1 - self.file.seek(0) + file = self.file + assert file is not None + file.seek(0) assert self._multipart is not None part = self._multipart.Part(self._write_counter) - part.upload(Body=self.file.read()) + part.upload(Body=file.read()) - def close(self): + def close(self) -> None: if self._is_dirty: self._flush_write_buffer() assert self._multipart is not None @@ -256,6 +281,10 @@ class S3Boto3Storage(Storage): file_class = S3Boto3StorageFile # If config provided in init, signature_version and addressing_style settings/args are ignored. config: Config | None = None + # justification: boto3 has no type stubs, so the connection and bucket handles are dynamically typed. + _connection: Any = None + _bucket: Any = None + _entries: dict[str, Any] = {} # used for looking up the access and secret key from env vars access_key_names = ["AWS_S3_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID"] @@ -293,7 +322,9 @@ class S3Boto3Storage(Storage): region_name: str | None = None use_ssl = True - def __init__(self, acl=None, bucket=None, **settings): + def __init__( + self, acl: str | None = None, bucket: str | None = None, **settings: object + ) -> None: # check if some of the settings we've provided as class attributes # need to be overwritten with values passed in here for name, value in settings.items(): @@ -327,7 +358,8 @@ def __init__(self, acl=None, bucket=None, **settings): ) @property - def connection(self): + # justification: boto3 connection handles are untyped. + def connection(self) -> Any: # TODO: Support host, port like in s3boto # Note that proxies are handled by environment variables that the underlying # urllib/requests libraries read. See https://github.com/boto/boto3/issues/338 @@ -352,7 +384,8 @@ def connection(self): return self._connection @property - def bucket(self): + # justification: boto3 bucket handles are untyped. + def bucket(self) -> Any: """ Get the current bucket. If there is no current bucket object create it. @@ -362,7 +395,7 @@ def bucket(self): return self._bucket @property - def entries(self): + def entries(self) -> dict[str, Any]: """ Get the locally cached files for the bucket. """ @@ -373,24 +406,26 @@ 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) return access_key, secret_key - def _get_or_create_bucket(self, name): + # justification: boto3 bucket handles are untyped. + def _get_or_create_bucket(self, name: str | None) -> Any: """ Retrieves a bucket if it exists, otherwise creates it. """ @@ -417,7 +452,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 +467,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 +478,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) -> BytesIO: """Gzip a given string content.""" zbuf = BytesIO() zfile = GzipFile(mode="wb", compresslevel=6, fileobj=zbuf) @@ -463,7 +498,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) @@ -473,7 +508,7 @@ def _open(self, name, mode="rb"): raise # Let it bubble up if it was some other error return f - def _save(self, name, content): + def _save(self, name: str, content: File[bytes] | BytesIO) -> str: with metrics.timer("filestore.save", instance="s3"): cleaned_name = self._clean_name(name) name = self._normalize_name(cleaned_name) @@ -499,7 +534,10 @@ def _save(self, name, content): # the next time it is accessed; no need to specifically reload it. return cleaned_name - def _save_content(self, obj, content, parameters): + # justification: boto3 object handles are untyped. + def _save_content( + self, obj: Any, content: File[bytes] | BytesIO, parameters: dict[str, str] | None + ) -> None: # only pass backwards incompatible arguments if they vary from the default put_parameters = parameters.copy() if parameters else {} if self.encryption: @@ -511,11 +549,11 @@ def _save_content(self, obj, content, parameters): content.seek(0, os.SEEK_SET) obj.upload_fileobj(content, ExtraArgs=put_parameters) - def delete(self, name): + def delete(self, name: str) -> None: name = self._normalize_name(self._clean_name(name)) self.bucket.Object(self._encode_name(name)).delete() - def exists(self, name): + def exists(self, name: str) -> bool: if not name: try: self.bucket @@ -532,7 +570,7 @@ def exists(self, name): except self.connection_response_error: return False - def listdir(self, name): + def listdir(self, name: str) -> tuple[list[str], list[str]]: name = self._normalize_name(self._clean_name(name)) # for the bucket.objects.filter and logic below name needs to end in / # But for the root path "" we leave it as an empty string @@ -553,16 +591,22 @@ def listdir(self, name): dirs.add(parts[0]) return list(dirs), files - def size(self, name): + def size(self, name: str) -> int: name = self._normalize_name(self._clean_name(name)) if self.entries: entry = self.entries.get(name) if entry: - return entry.content_length + content_length = entry.content_length + if not isinstance(content_length, int): + raise TypeError("S3 object has no content length") + return content_length return 0 - return self.bucket.Object(self._encode_name(name)).content_length + content_length = self.bucket.Object(self._encode_name(name)).content_length + if not isinstance(content_length, int): + raise TypeError("S3 object has no content length") + return content_length - def get_modified_time(self, name): + def get_modified_time(self, name: str) -> datetime: """ Returns an (aware) datetime object containing the last modified time if USE_TZ is True, otherwise returns a naive datetime in the local timezone. @@ -573,17 +617,20 @@ def get_modified_time(self, name): # in the preloaded metadata. if entry is None: entry = self.bucket.Object(self._encode_name(name)) + last_modified = entry.last_modified + if not isinstance(last_modified, datetime): + raise TypeError("S3 object has no last modified time") if settings.USE_TZ: # boto3 returns TZ aware timestamps - return entry.last_modified + return last_modified else: - return localtime(entry.last_modified).replace(tzinfo=None) + return localtime(last_modified).replace(tzinfo=None) - def modified_time(self, name): + def modified_time(self, name: str) -> datetime: """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 @@ -611,7 +658,11 @@ def _strip_signing_parameters(self, url): split_url = split_url._replace(query="&".join(joined_qs)) return split_url.geturl() - def url(self, name, parameters=None, expire=None): + def url( + self, name: str | None, parameters: dict[str, str] | None = None, expire: int | None = None + ) -> str: + name = cast("str", name) + # Preserve the trailing slash after normalizing the path. # TODO: Handle force_http=not self.secure_urls like in s3boto name = self._normalize_name(self._clean_name(name)) @@ -626,11 +677,13 @@ def url(self, name, parameters=None, expire=None): url = self.bucket.meta.client.generate_presigned_url( "get_object", Params=params, ExpiresIn=expire ) + if not isinstance(url, str): + raise TypeError("S3 presigned URL is not a string") if self.querystring_auth: 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/services/http.py b/src/sentry/services/http.py index 80738c96352f..76ce5ccc29ea 100644 --- a/src/sentry/services/http.py +++ b/src/sentry/services/http.py @@ -9,7 +9,7 @@ from sentry.services.base import Service -def _run_server(options: dict[str, Any]): +def _run_server(options: dict[str, Any]) -> None: server = Granian( target=options["module"], address=options["host"], @@ -110,7 +110,7 @@ def prepare_environment(self, env: MutableMapping[str, str] | None = None) -> No # This has already been validated inside __init__ env["SENTRY_SKIP_BACKEND_VALIDATION"] = "1" - def run(self): + def run(self) -> None: self.prepare_environment() if self.debug or os.environ.get("SENTRY_RUNNING_GRANIAN") == "0": from wsgiref.simple_server import make_server diff --git a/src/sentry/services/organization/provisioning.py b/src/sentry/services/organization/provisioning.py index 3f105eaa92e5..cf5f195daff2 100644 --- a/src/sentry/services/organization/provisioning.py +++ b/src/sentry/services/organization/provisioning.py @@ -34,7 +34,7 @@ class OrganizationProvisioningException(Exception): class OrganizationProvisioningService: - def _validate_or_default_cell(self, cell_name: str | None): + def _validate_or_default_cell(self, cell_name: str | None) -> str: silo_mode = SiloMode.get_current_mode() if cell_name is None and silo_mode == SiloMode.CONTROL: raise OrganizationProvisioningException( @@ -49,6 +49,7 @@ def _validate_or_default_cell(self, cell_name: str | None): cell_name = local_cell.name + assert cell_name is not None return cell_name def provision_organization_in_cell( @@ -152,7 +153,7 @@ def bulk_create_organization_slugs(self, slug_mapping: dict[int, str]) -> None: @receiver(process_control_outbox, sender=OutboxCategory.PROVISION_ORGANIZATION) def process_provision_organization_outbox( object_identifier: int, cell_name: str, payload: Any, **kwds: Any -): +) -> None: """ Outbox handler for PROVISION_ORGANIZATION @@ -201,7 +202,7 @@ def process_provision_organization_outbox( @receiver(process_cell_outbox, sender=OutboxCategory.ORGANIZATION_PROVISION_ANALYTICS) def process_organization_provision_analytics_outbox( object_identifier: int, payload: Any, **kwds: Any -): +) -> None: """ Create analytics events and audit logs for an organization Separate from POST_ORGANIZATION_PROVISION (used by getsentry) so that a failure in one @@ -275,7 +276,7 @@ def process_organization_provision_analytics_outbox( ) -def handle_possible_organization_slug_swap(*, cell_name: str, org_slug_reservation_id: int): +def handle_possible_organization_slug_swap(*, cell_name: str, org_slug_reservation_id: int) -> None: """ CAUTION: THIS IS ONLY INTENDED TO BE USED BY THE `organization_provisioning` RPC SERVICE. DO NOT USE THIS FOR LOCAL SLUG SWAPS. diff --git a/src/sentry/shared_integrations/client/base.py b/src/sentry/shared_integrations/client/base.py index 9711e79485c2..c8bbf067c827 100644 --- a/src/sentry/shared_integrations/client/base.py +++ b/src/sentry/shared_integrations/client/base.py @@ -3,7 +3,7 @@ import logging from collections.abc import Callable, Mapping, MutableMapping, Sequence from types import TracebackType -from typing import Any, Literal, NotRequired, Self, TypedDict, TypeVar, overload +from typing import Any, Literal, NotRequired, Self, TypedDict, TypeVar, cast, overload import sentry_sdk from django.core.cache import cache @@ -26,6 +26,8 @@ ApiTimeoutError, ) from ..response.base import BaseApiResponse +from ..response.mapping import MappingApiResponse +from ..response.sequence import SequenceApiResponse class SessionSettings(TypedDict): @@ -41,6 +43,26 @@ class SessionSettings(TypedDict): _TPaginatedResult = TypeVar("_TPaginatedResult") +def _mapping_response(response: object) -> MappingApiResponse: + from ..response.mapping import MappingApiResponse + + if isinstance(response, MappingApiResponse): + return response + if isinstance(response, Mapping): + return MappingApiResponse(response) + raise ApiError("Expected a JSON object response") + + +def _sequence_response(response: object) -> SequenceApiResponse: + from ..response.sequence import SequenceApiResponse + + if isinstance(response, SequenceApiResponse): + return response + if isinstance(response, (list, tuple)): + return SequenceApiResponse(response) + raise ApiError("Expected a JSON array response") + + class BaseApiClient: base_url: str = "" @@ -66,7 +88,10 @@ class BaseApiClient: @property def name(self) -> str: - return getattr(self, f"{self.integration_type}_name") + name = getattr(self, f"{self.integration_type}_name") + if not isinstance(name, str): + raise AttributeError(f"{self.integration_type}_name is not a string") + return name def __init__( self, @@ -252,7 +277,8 @@ def _request( ignore_webhook_errors: bool = False, prepared_request: PreparedRequest | None = None, stream: bool | None = None, - raw_response: Literal[True] = ..., + *, + raw_response: Literal[True], api_request_type: str | None = None, ) -> Response: ... @@ -272,9 +298,10 @@ def _request( ignore_webhook_errors: bool = False, prepared_request: PreparedRequest | None = None, stream: bool | None = None, - raw_response: bool = ..., + *, + raw_response: Literal[False] = ..., api_request_type: str | None = None, - ) -> Any: ... + ) -> BaseApiResponse: ... def _request( self, @@ -291,9 +318,10 @@ def _request( ignore_webhook_errors: bool = False, prepared_request: PreparedRequest | None = None, stream: bool | None = None, + *, raw_response: bool = False, api_request_type: str | None = None, - ) -> Any | Response: + ) -> BaseApiResponse | Response: if allow_redirects is None: allow_redirects = self.allow_redirects @@ -414,7 +442,7 @@ def _request( self.track_response_data(resp.status_code, None, resp, extra=extra) if resp.status_code == 204: - return {} + return MappingApiResponse({}, resp.headers, resp.status_code) # BaseApiResponse.from_response returns MappingApiResponse (subclass of dict) # or SequenceApiResponse (subclass of list), or TextApiResponse (has str .text) @@ -428,10 +456,11 @@ def _do_send( return session.send(request, **session_settings) # subclasses should override ``request`` - def request(self, *args: Any, **kwargs: Any) -> Any: - return self._request(*args, **kwargs) + def request(self, *args: Any, **kwargs: Any) -> BaseApiResponse: + # justification: forwarding *args defeats the _request overloads. + return cast("Callable[..., BaseApiResponse]", self._request)(*args, **kwargs) - def delete(self, path: str, *args: Any, **kwargs: Any) -> Any: + def delete(self, path: str, *args: object, **kwargs: object) -> BaseApiResponse: return self.request("DELETE", path, *args, **kwargs) def get_cache_key(self, path: str, method: str, query: str = "", data: str | None = "") -> str: @@ -479,26 +508,70 @@ def _get_cached(self, path: str, method: str, *args: Any, **kwargs: Any) -> Any: self.set_cache(key, result, cache_time) return result - def get_cached(self, path: str, *args: Any, **kwargs: Any) -> Any: - return self._get_cached(path, "GET", *args, **kwargs) + @overload + def get_cached( + self, path: str, *args: Any, raw_response: Literal[True], **kwargs: Any + ) -> Response: ... + @overload + def get_cached( + self, path: str, *args: Any, raw_response: Literal[False] = ..., **kwargs: Any + ) -> BaseApiResponse: ... + def get_cached(self, path: str, *args: Any, **kwargs: Any) -> BaseApiResponse | Response: + # justification: forwarding *args defeats the _request overloads. + return cast("BaseApiResponse | Response", self._get_cached(path, "GET", *args, **kwargs)) - def get(self, path: str, *args: Any, **kwargs: Any) -> Any: + @overload + def get( + self, path: str, *args: Any, raw_response: Literal[True], **kwargs: Any + ) -> Response: ... + @overload + def get( + self, path: str, *args: Any, raw_response: Literal[False] = ..., **kwargs: Any + ) -> BaseApiResponse: ... + def get(self, path: str, *args: object, **kwargs: object) -> BaseApiResponse | Response: return self.request("GET", path, *args, **kwargs) - def patch(self, path: str, *args: Any, **kwargs: Any) -> Any: + def get_mapping(self, path: str, *args: Any, **kwargs: Any) -> MappingApiResponse: + return _mapping_response(self.get(path, *args, **kwargs)) + + def post_mapping(self, path: str, *args: object, **kwargs: object) -> MappingApiResponse: + return _mapping_response(self.post(path, *args, **kwargs)) + + def put_mapping(self, path: str, *args: object, **kwargs: object) -> MappingApiResponse: + return _mapping_response(self.put(path, *args, **kwargs)) + + def patch_mapping(self, path: str, *args: object, **kwargs: object) -> MappingApiResponse: + return _mapping_response(self.patch(path, *args, **kwargs)) + + def delete_mapping(self, path: str, *args: object, **kwargs: object) -> MappingApiResponse: + return _mapping_response(self.delete(path, *args, **kwargs)) + + def get_sequence(self, path: str, *args: Any, **kwargs: Any) -> SequenceApiResponse: + return _sequence_response(self.get(path, *args, **kwargs)) + + def get_cached_mapping(self, path: str, *args: Any, **kwargs: Any) -> MappingApiResponse: + return _mapping_response(self.get_cached(path, *args, **kwargs)) + + def get_cached_sequence(self, path: str, *args: Any, **kwargs: Any) -> SequenceApiResponse: + return _sequence_response(self.get_cached(path, *args, **kwargs)) + + def patch(self, path: str, *args: object, **kwargs: object) -> BaseApiResponse: return self.request("PATCH", path, *args, **kwargs) - def post(self, path: str, *args: Any, **kwargs: Any) -> Any: + def post(self, path: str, *args: object, **kwargs: object) -> BaseApiResponse: return self.request("POST", path, *args, **kwargs) - def put(self, path: str, *args: Any, **kwargs: Any) -> Any: + def put(self, path: str, *args: object, **kwargs: object) -> BaseApiResponse: return self.request("PUT", path, *args, **kwargs) def head(self, path: str, *args: Any, **kwargs: Any) -> Any: return self.request("HEAD", path, *args, **kwargs) - def head_cached(self, path: str, *args: Any, **kwargs: Any) -> Any: - return self._get_cached(path, "HEAD", *args, **kwargs) + def head_cached(self, path: str, *args: Any, **kwargs: Any) -> BaseApiResponse: + response = self._get_cached(path, "HEAD", *args, **kwargs) + if isinstance(response, BaseApiResponse): + return response + raise ApiError("Expected a HEAD response") def get_with_pagination( self, diff --git a/src/sentry/shared_integrations/response/mapping.py b/src/sentry/shared_integrations/response/mapping.py index 3e194643e01f..149de7d058b9 100644 --- a/src/sentry/shared_integrations/response/mapping.py +++ b/src/sentry/shared_integrations/response/mapping.py @@ -6,7 +6,8 @@ from sentry.shared_integrations.response.base import BaseApiResponse -class MappingApiResponse(dict, BaseApiResponse): +# justification: API response payloads are decoded JSON of mixed types. +class MappingApiResponse(dict[str, Any], BaseApiResponse): def __init__(self, data: Mapping[str, Any], *args: Any, **kwargs: Any) -> None: dict.__init__(self, data) BaseApiResponse.__init__(self, *args, **kwargs) diff --git a/src/sentry/shared_integrations/response/sequence.py b/src/sentry/shared_integrations/response/sequence.py index fff6f4db7b9d..576370988e43 100644 --- a/src/sentry/shared_integrations/response/sequence.py +++ b/src/sentry/shared_integrations/response/sequence.py @@ -6,7 +6,8 @@ from sentry.shared_integrations.response.base import BaseApiResponse -class SequenceApiResponse(list, BaseApiResponse): +# justification: API response payloads are decoded JSON of mixed types. +class SequenceApiResponse(list[Any], BaseApiResponse): def __init__(self, data: Sequence[Any], *args: Any, **kwargs: Any) -> None: list.__init__(self, data) BaseApiResponse.__init__(self, *args, **kwargs) diff --git a/src/sentry/shared_integrations/response/validation.py b/src/sentry/shared_integrations/response/validation.py new file mode 100644 index 000000000000..5c01ac19d42e --- /dev/null +++ b/src/sentry/shared_integrations/response/validation.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from sentry.shared_integrations.exceptions import ApiError + + +def dict_items(value: object, *, context: str) -> list[dict[str, object]]: + if not isinstance(value, list): + raise ApiError(f"Expected a {context} list") + items: list[dict[str, object]] = [] + for item in value: + if not isinstance(item, dict): + raise ApiError(f"Expected a {context} object") + items.append(item) + return items diff --git a/src/sentry/signals.py b/src/sentry/signals.py index a70422ee152d..6099c07c7b12 100644 --- a/src/sentry/signals.py +++ b/src/sentry/signals.py @@ -3,14 +3,16 @@ import enum import functools import logging -from collections.abc import Callable -from typing import Any +from collections.abc import Callable, Hashable +from typing import ParamSpec, TypeVar, cast, overload from django.dispatch.dispatcher import Signal from sentry.utils.env import in_test_environment -Receiver = Callable[[], Any] +P = ParamSpec("P") +R = TypeVar("R") +Receiver = Callable[..., object] _AllReceivers = enum.Enum("_AllReceivers", "ALL") @@ -26,7 +28,7 @@ class receivers_raise_on_send: This behavior only works in tests. """ - receivers: Any + receivers: _AllReceivers | Receiver | list[Receiver] def __init__(self, receivers: _AllReceivers | Receiver | list[Receiver] = _AllReceivers.ALL): self.receivers = receivers @@ -38,24 +40,51 @@ def __enter__(self) -> None: if self.receivers is _AllReceivers.ALL: _receivers_that_raise = self.receivers else: - _receivers_that_raise += self.receivers + receivers = self.receivers + if not isinstance(receivers, list): + receivers = [receivers] + _receivers_that_raise = cast(list[Receiver], _receivers_that_raise) + receivers - def __call__(self, f: Callable[..., Any]) -> Callable[..., Any]: + def __call__(self, f: Callable[P, R]) -> Callable[P, R]: @functools.wraps(f) - def wrapped(*args: Any, **kwds: Any) -> Any: + def wrapped(*args: P.args, **kwds: P.kwargs) -> R: with receivers_raise_on_send(self.receivers): return f(*args, **kwds) return wrapped - def __exit__(self, *args) -> bool | None: + def __exit__(self, *args: object) -> bool | None: global _receivers_that_raise _receivers_that_raise = self.old return None class BetterSignal(Signal): - def connect(self, receiver=None, *args, **kwargs): + @overload + def connect( + self, + receiver: None = None, + sender: object | None = None, + weak: bool = True, + dispatch_uid: Hashable | None = None, + ) -> Callable[[Callable[..., object]], None]: ... + + @overload + def connect( + self, + receiver: Callable[..., object], + sender: object | None = None, + weak: bool = True, + dispatch_uid: Hashable | None = None, + ) -> None: ... + + def connect( + self, + receiver: Callable[..., object] | None = None, + sender: object | None = None, + weak: bool = True, + dispatch_uid: Hashable | None = None, + ) -> Callable[[Callable[..., object]], None] | None: """ Support decorator syntax: @@ -65,8 +94,10 @@ def connect(self, receiver=None, *args, **kwargs): """ - def wrapped(func): - return super(BetterSignal, self).connect(func, *args, **kwargs) + def wrapped(func: Callable[..., object]) -> None: + super(BetterSignal, self).connect( + func, sender=sender, weak=weak, dispatch_uid=dispatch_uid + ) if receiver is None: return wrapped @@ -77,7 +108,8 @@ def wrapped(func): wrapped.__module__ = receiver.__module__ if hasattr(receiver, "__doc__"): wrapped.__doc__ = receiver.__doc__ - return wrapped(receiver) + wrapped(receiver) + return None def _log_robust_failure(self, receiver: object, err: Exception) -> None: if in_test_environment(): diff --git a/src/sentry/silo/client.py b/src/sentry/silo/client.py index d3c72e1fc289..e59eaa9ad47c 100644 --- a/src/sentry/silo/client.py +++ b/src/sentry/silo/client.py @@ -3,9 +3,9 @@ import ipaddress import logging import socket -from collections.abc import Mapping +from collections.abc import Callable, Mapping from hashlib import sha256 -from typing import Any +from typing import Any, cast import sentry_sdk import urllib3 @@ -182,7 +182,9 @@ def request( if prefix_hash is not None: hash = sha256(f"{prefix_hash}{self.cell.name}{method}{path}".encode()).hexdigest() self.check_request_attempts(hash=hash, method=method, path=path) - return self._request( + # justification: raw_response is a runtime bool, defeating the _request overloads. + request = cast("Callable[..., Any]", self._request) + return request( method=method, path=path, headers=clean_proxy_headers(headers), diff --git a/src/sentry/silo/patches/silo_aware_transaction_patch.py b/src/sentry/silo/patches/silo_aware_transaction_patch.py index 0e9d33d32340..589139f4f743 100644 --- a/src/sentry/silo/patches/silo_aware_transaction_patch.py +++ b/src/sentry/silo/patches/silo_aware_transaction_patch.py @@ -2,7 +2,7 @@ import sys from collections.abc import Callable from types import CodeType, FrameType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from django.db import router, transaction from django.db.backends.base.base import BaseDatabaseWrapper @@ -44,7 +44,8 @@ def siloed_atomic( def siloed_get_connection(using: str | None = None) -> BaseDatabaseWrapper: validate_transaction_using_for_silo_mode(using) - return _default_get_connection(using=using) + # justification: Django's connection handler returns the connection wrapper. + return cast("BaseDatabaseWrapper", _default_get_connection(using=using)) def siloed_on_commit(func: Callable[..., Any], using: str | None = None) -> None: diff --git a/src/sentry/similarity/__init__.py b/src/sentry/similarity/__init__.py index 403cdc51e7b9..94bff244a89b 100644 --- a/src/sentry/similarity/__init__.py +++ b/src/sentry/similarity/__init__.py @@ -1,12 +1,17 @@ +from __future__ import annotations + import logging +from collections.abc import Callable, Mapping +from typing import TYPE_CHECKING, cast from django.conf import settings from sentry import features as feature_flags from sentry.interfaces.stacktrace import Frame +from sentry.similarity.backends.abstract import AbstractIndexBackend from sentry.similarity.backends.dummy import DummyIndexBackend from sentry.similarity.backends.metrics import MetricsWrapper -from sentry.similarity.backends.redis import RedisScriptMinHashIndexBackend +from sentry.similarity.backends.redis import Cluster, RedisScriptMinHashIndexBackend from sentry.similarity.encoder import Encoder from sentry.similarity.features import ( ExceptionFeature, @@ -20,10 +25,13 @@ from sentry.utils.datastructures import BidirectionalMapping from sentry.utils.iterators import shingle +if TYPE_CHECKING: + from sentry.models.project import Project + logger = logging.getLogger(__name__) -def text_shingle(n, value): +def text_shingle(n: int, value: str) -> list[str]: return ["".join(part) for part in shingle(n, value)] @@ -31,8 +39,8 @@ class FrameEncodingError(ValueError): pass -def get_frame_attributes(frame): - attributes = {} +def get_frame_attributes(frame: Frame) -> dict[str, object]: + attributes: dict[str, object] = {} if frame.function in {"", None}: if frame.context_line is None: @@ -57,7 +65,7 @@ def get_frame_attributes(frame): return attributes -def _make_index_backend(cluster, namespace="sim:1"): +def _make_index_backend(cluster: object, namespace: str = "sim:1") -> AbstractIndexBackend: if isinstance(cluster, str): cluster_id = cluster @@ -70,7 +78,13 @@ def _make_index_backend(cluster, namespace="sim:1"): return MetricsWrapper( RedisScriptMinHashIndexBackend( - cluster, namespace, MinHashSignatureBuilder(16, 0xFFFF), 8, 60 * 60 * 24 * 30, 3, 5000 + cast("Cluster", cluster), + namespace, + MinHashSignatureBuilder(16, 0xFFFF), + 8, + 60 * 60 * 24 * 30, + 3, + 5000, ), scope_tag_name=None, ) @@ -82,13 +96,17 @@ def _make_index_backend(cluster, namespace="sim:1"): namespace="sim:1", ), Encoder({Frame: get_frame_attributes}), - BidirectionalMapping( - { - "exception:message:character-shingles": "a", - "exception:stacktrace:application-chunks": "b", - "exception:stacktrace:pairs": "c", - "message:message:character-shingles": "d", - } + cast( + "Mapping[str, str]", + # justification: BidirectionalMapping erases its value type to object. + BidirectionalMapping( + { + "exception:message:character-shingles": "a", + "exception:stacktrace:application-chunks": "b", + "exception:stacktrace:pairs": "c", + "message:message:character-shingles": "d", + } + ), ), { "exception:message:character-shingles": ExceptionFeature( @@ -109,10 +127,10 @@ def _make_index_backend(cluster, namespace="sim:1"): ) -def _build_dispatcher(methodname): +def _build_dispatcher(methodname: str) -> Callable[..., None]: v1_method = getattr(features, methodname) - def inner(project, *args, **kwargs): + def inner(project: Project | None, *args: object, **kwargs: object) -> None: if project is None or feature_flags.has("projects:similarity-indexing", project): v1_method(*args, **kwargs) diff --git a/src/sentry/similarity/backends/abstract.py b/src/sentry/similarity/backends/abstract.py index bdc054e77fb7..a699763b94b8 100644 --- a/src/sentry/similarity/backends/abstract.py +++ b/src/sentry/similarity/backends/abstract.py @@ -1,39 +1,91 @@ from abc import ABCMeta, abstractmethod +from collections.abc import Iterable, Iterator, Sequence + +FeatureData = Sequence[bytes] +ClassifyItem = tuple[str, float, FeatureData] +CompareItem = tuple[str, float] +RecordItem = tuple[str, FeatureData] +IndexItem = tuple[str, str] +ImportItem = tuple[str, str, object] +SearchResult = tuple[str, list[float | int | None]] class AbstractIndexBackend(metaclass=ABCMeta): @abstractmethod - def classify(self, scope, items, limit=None, timestamp=None): + def classify( + self, + scope: str, + items: Sequence[ClassifyItem], + limit: int | None = None, + timestamp: int | None = None, + ) -> list[SearchResult]: pass @abstractmethod - def compare(self, scope, key, items, limit=None, timestamp=None): + def compare( + self, + scope: str, + key: str, + items: Sequence[CompareItem], + limit: int | None = None, + timestamp: int | None = None, + ) -> list[SearchResult]: pass @abstractmethod - def record(self, scope, key, items, timestamp=None): + def record( + self, + scope: str, + key: str, + items: Sequence[RecordItem], + timestamp: int | None = None, + ) -> object: pass @abstractmethod - def merge(self, scope, destination, items, timestamp=None): + def merge( + self, + scope: str, + destination: str, + items: Sequence[IndexItem], + timestamp: int | None = None, + ) -> object: pass @abstractmethod - def delete(self, scope, items, timestamp=None): + def delete( + self, scope: str, items: Sequence[IndexItem], timestamp: int | None = None + ) -> object: pass @abstractmethod - def scan(self, scope, indices, batch=1000, timestamp=None): + def scan( + self, + scope: str, + indices: Sequence[str], + batch: int = 1000, + timestamp: int | None = None, + ) -> Iterator[tuple[str, list[bytes]]]: pass @abstractmethod - def flush(self, scope, indices, batch=1000, timestamp=None): + def flush( + self, + scope: str, + indices: Sequence[str], + batch: int = 1000, + timestamp: int | None = None, + ) -> None: pass @abstractmethod - def export(self, scope, items, timestamp=None): + def export( + self, scope: str, items: Sequence[IndexItem], timestamp: int | None = None + ) -> Iterable[object]: pass @abstractmethod - def import_(self, scope, items, timestamp=None): + def import_( + self, scope: str, items: Sequence[ImportItem], timestamp: int | None = None + ) -> object: pass diff --git a/src/sentry/similarity/backends/dummy.py b/src/sentry/similarity/backends/dummy.py index 2cf4e54f3465..e48dac64a0eb 100644 --- a/src/sentry/similarity/backends/dummy.py +++ b/src/sentry/similarity/backends/dummy.py @@ -1,32 +1,84 @@ -from sentry.similarity.backends.abstract import AbstractIndexBackend +from collections.abc import Iterable, Iterator, Sequence + +from sentry.similarity.backends.abstract import ( + AbstractIndexBackend, + ClassifyItem, + CompareItem, + ImportItem, + IndexItem, + RecordItem, + SearchResult, +) class DummyIndexBackend(AbstractIndexBackend): - def classify(self, scope, items, limit=None, timestamp=None): + def classify( + self, + scope: str, + items: Sequence[ClassifyItem], + limit: int | None = None, + timestamp: int | None = None, + ) -> list[SearchResult]: return [] - def compare(self, scope, key, items, limit=None, timestamp=None): + def compare( + self, + scope: str, + key: str, + items: Sequence[CompareItem], + limit: int | None = None, + timestamp: int | None = None, + ) -> list[SearchResult]: return [] - def record(self, scope, key, items, timestamp=None): + def record( + self, + scope: str, + key: str, + items: Sequence[RecordItem], + timestamp: int | None = None, + ) -> object: return {} - def merge(self, scope, destination, items, timestamp=None) -> bool: + def merge( + self, + scope: str, + destination: str, + items: Sequence[IndexItem], + timestamp: int | None = None, + ) -> object: return False - def delete(self, scope, items, timestamp=None) -> bool: + def delete( + self, scope: str, items: Sequence[IndexItem], timestamp: int | None = None + ) -> object: return False - def scan(self, scope, indices, batch=1000, timestamp=None): + def scan( + self, + scope: str, + indices: Sequence[str], + batch: int = 1000, + timestamp: int | None = None, + ) -> Iterator[tuple[str, list[bytes]]]: # empty generator - return - yield + yield from () - def flush(self, scope, indices, batch=1000, timestamp=None): + def flush( + self, + scope: str, + indices: Sequence[str], + batch: int = 1000, + timestamp: int | None = None, + ) -> None: pass - def export(self, scope, items, timestamp=None): + def export( + self, scope: str, items: Sequence[IndexItem], timestamp: int | None = None + ) -> Iterable[object]: return {} - def import_(self, scope, items, timestamp=None): + def import_( + self, scope: str, items: Sequence[ImportItem], timestamp: int | None = None + ) -> object: return {} diff --git a/src/sentry/similarity/backends/metrics.py b/src/sentry/similarity/backends/metrics.py index af88af160c10..f641eaa96991 100644 --- a/src/sentry/similarity/backends/metrics.py +++ b/src/sentry/similarity/backends/metrics.py @@ -1,17 +1,35 @@ -from sentry.similarity.backends.abstract import AbstractIndexBackend +from collections.abc import Iterable, Iterator, Sequence +from typing import cast + +from sentry.similarity.backends.abstract import ( + AbstractIndexBackend, + ClassifyItem, + CompareItem, + ImportItem, + IndexItem, + RecordItem, + SearchResult, +) from sentry.utils.metrics import timer class MetricsWrapper(AbstractIndexBackend): - def __init__(self, backend, template="similarity.{}", scope_tag_name="scope"): + def __init__( + self, + backend: AbstractIndexBackend, + template: str = "similarity.{}", + scope_tag_name: str | None = "scope", + ) -> None: self.backend = backend self.template = template self.scope_tag_name = scope_tag_name - def __getattr__(self, name): + def __getattr__(self, name: str) -> object: return getattr(self.backend, name) - def __instrumented_method_call(self, method, scope, *args, **kwargs): + def __instrumented_method_call( + self, method: str, scope: str, *args: object, **kwargs: object + ) -> object: tags = {} if self.scope_tag_name is not None: tags[self.scope_tag_name] = scope @@ -19,29 +37,92 @@ def __instrumented_method_call(self, method, scope, *args, **kwargs): with timer(self.template.format(method), tags=tags): return getattr(self.backend, method)(scope, *args, **kwargs) - def record(self, *args, **kwargs): - return self.__instrumented_method_call("record", *args, **kwargs) + def classify( + self, + scope: str, + items: Sequence[ClassifyItem], + limit: int | None = None, + timestamp: int | None = None, + ) -> list[SearchResult]: + return cast( + "list[SearchResult]", + self.__instrumented_method_call( + "classify", scope, items, limit=limit, timestamp=timestamp + ), + ) - def classify(self, *args, **kwargs): - return self.__instrumented_method_call("classify", *args, **kwargs) + def compare( + self, + scope: str, + key: str, + items: Sequence[CompareItem], + limit: int | None = None, + timestamp: int | None = None, + ) -> list[SearchResult]: + return cast( + "list[SearchResult]", + self.__instrumented_method_call( + "compare", scope, key, items, limit=limit, timestamp=timestamp + ), + ) - def compare(self, *args, **kwargs): - return self.__instrumented_method_call("compare", *args, **kwargs) + def record( + self, + scope: str, + key: str, + items: Sequence[RecordItem], + timestamp: int | None = None, + ) -> object: + return self.__instrumented_method_call("record", scope, key, items, timestamp=timestamp) - def merge(self, *args, **kwargs): - return self.__instrumented_method_call("merge", *args, **kwargs) + def merge( + self, + scope: str, + destination: str, + items: Sequence[IndexItem], + timestamp: int | None = None, + ) -> object: + return self.__instrumented_method_call( + "merge", scope, destination, items, timestamp=timestamp + ) - def delete(self, *args, **kwargs): - return self.__instrumented_method_call("delete", *args, **kwargs) + def delete( + self, scope: str, items: Sequence[IndexItem], timestamp: int | None = None + ) -> object: + return self.__instrumented_method_call("delete", scope, items, timestamp=timestamp) - def scan(self, *args, **kwargs): - return self.__instrumented_method_call("scan", *args, **kwargs) + def scan( + self, + scope: str, + indices: Sequence[str], + batch: int = 1000, + timestamp: int | None = None, + ) -> Iterator[tuple[str, list[bytes]]]: + return cast( + "Iterator[tuple[str, list[bytes]]]", + self.__instrumented_method_call( + "scan", scope, indices, batch=batch, timestamp=timestamp + ), + ) - def flush(self, *args, **kwargs): - return self.__instrumented_method_call("flush", *args, **kwargs) + def flush( + self, + scope: str, + indices: Sequence[str], + batch: int = 1000, + timestamp: int | None = None, + ) -> None: + self.__instrumented_method_call("flush", scope, indices, batch=batch, timestamp=timestamp) - def export(self, *args, **kwargs): - return self.__instrumented_method_call("export", *args, **kwargs) + def export( + self, scope: str, items: Sequence[IndexItem], timestamp: int | None = None + ) -> Iterable[object]: + return cast( + "Iterable[object]", + self.__instrumented_method_call("export", scope, items, timestamp=timestamp), + ) - def import_(self, *args, **kwargs): - return self.__instrumented_method_call("import_", *args, **kwargs) + def import_( + self, scope: str, items: Sequence[ImportItem], timestamp: int | None = None + ) -> object: + return self.__instrumented_method_call("import_", scope, items, timestamp=timestamp) diff --git a/src/sentry/similarity/backends/redis.py b/src/sentry/similarity/backends/redis.py index 26a2b816732f..30c946c03809 100644 --- a/src/sentry/similarity/backends/redis.py +++ b/src/sentry/similarity/backends/redis.py @@ -1,28 +1,49 @@ import itertools import time +from collections.abc import Callable, Iterable, Iterator, Sequence +from typing import Protocol, cast from django.utils.encoding import force_str -from sentry.similarity.backends.abstract import AbstractIndexBackend +from sentry.similarity.backends.abstract import ( + AbstractIndexBackend, + ClassifyItem, + CompareItem, + ImportItem, + IndexItem, + RecordItem, + SearchResult, +) from sentry.utils.iterators import chunked from sentry.utils.redis import load_redis_script index = load_redis_script("similarity/index.lua") -def band(n, value): +class Cluster(Protocol): + def delete(self, *keys: bytes) -> object: ... + + +def band(n: int, value: Sequence[int]) -> list[list[int]]: assert len(value) % n == 0 - return list(chunked(value, len(value) / n)) + return list(chunked(value, len(value) // n)) -def flatten(value): +def flatten(value: Iterable[Iterable[object]]) -> list[object]: return list(itertools.chain.from_iterable(value)) class RedisScriptMinHashIndexBackend(AbstractIndexBackend): def __init__( - self, cluster, namespace, signature_builder, bands, interval, retention, candidate_set_limit - ): + self, + cluster: Cluster, + namespace: str, + signature_builder: Callable[[Sequence[bytes]], Sequence[int]], + bands: int, + interval: int, + retention: int, + candidate_set_limit: int, + ) -> None: self.cluster = cluster self.namespace = namespace self.signature_builder = signature_builder @@ -31,53 +52,62 @@ def __init__( self.retention = retention self.candidate_set_limit = candidate_set_limit - def _build_signature_arguments(self, features): + def _build_signature_arguments(self, features: Sequence[bytes]) -> list[object]: if not features: return [0] * self.bands - arguments = [] + arguments: list[object] = [] for bucket in band(self.bands, self.signature_builder(features)): arguments.extend([1, ",".join(str(b) for b in bucket), 1]) return arguments - def __index(self, scope, args): + def __index(self, scope: str, args: Sequence[object]) -> list[object]: # scope must be passed into the script call as a key to allow the # cluster client to determine what cluster the script should be # executed on. The script itself will use the scope as the hashtag for # all redis operations. - return index([scope], args, self.cluster) + # justification: the embedded redis script returns a list of index results. + return cast("list[object]", index([scope], args, self.cluster)) - def _as_search_result(self, results): + def _as_search_result( + self, results: Iterable[tuple[bytes | str, Sequence[float]]] + ) -> list[SearchResult]: score_replacements = { -1.0: None, # both items don't have the feature (no comparison) -2.0: 0, # one item doesn't have the feature (totally dissimilar) } - def decode_search_result(result): + def decode_search_result(result: tuple[bytes | str, Sequence[float]]) -> SearchResult: key, scores = result return ( force_str(key), [score_replacements.get(float(score), float(score)) for score in scores], ) - def get_comparison_key(result): + def get_comparison_key(result: SearchResult) -> tuple[float, int, str]: key, scores = result - scores = [score for score in scores if score is not None] + averaged_scores = [score for score in scores if score is not None] return ( - sum(scores) / len(scores) * -1, # average score, descending - len(scores) * -1, # number of indexes with scores, descending + sum(averaged_scores) / len(averaged_scores) * -1, # average score, descending + len(averaged_scores) * -1, # number of indexes with scores, descending key, # lexicographical sort on key, ascending ) return sorted((decode_search_result(result) for result in results), key=get_comparison_key) - def classify(self, scope, items, limit=None, timestamp=None): + def classify( + self, + scope: str, + items: Sequence[ClassifyItem], + limit: int | None = None, + timestamp: int | None = None, + ) -> list[SearchResult]: if timestamp is None: timestamp = int(time.time()) - arguments = [ + arguments: list[object] = [ "CLASSIFY", timestamp, self.namespace, @@ -93,13 +123,22 @@ def classify(self, scope, items, limit=None, timestamp=None): arguments.extend([idx, threshold]) arguments.extend(self._build_signature_arguments(features)) - return self._as_search_result(self.__index(scope, arguments)) - - def compare(self, scope, key, items, limit=None, timestamp=None): + return self._as_search_result( + cast("list[tuple[bytes | str, Sequence[float]]]", self.__index(scope, arguments)) + ) + + def compare( + self, + scope: str, + key: str, + items: Sequence[CompareItem], + limit: int | None = None, + timestamp: int | None = None, + ) -> list[SearchResult]: if timestamp is None: timestamp = int(time.time()) - arguments = [ + arguments: list[object] = [ "COMPARE", timestamp, self.namespace, @@ -115,16 +154,24 @@ def compare(self, scope, key, items, limit=None, timestamp=None): for idx, threshold in items: arguments.extend([idx, threshold]) - return self._as_search_result(self.__index(scope, arguments)) - - def record(self, scope, key, items, timestamp=None): + return self._as_search_result( + cast("list[tuple[bytes | str, Sequence[float]]]", self.__index(scope, arguments)) + ) + + def record( + self, + scope: str, + key: str, + items: Sequence[RecordItem], + timestamp: int | None = None, + ) -> object: if not items: - return # nothing to do + return None # nothing to do if timestamp is None: timestamp = int(time.time()) - arguments = [ + arguments: list[object] = [ "RECORD", timestamp, self.namespace, @@ -142,11 +189,17 @@ def record(self, scope, key, items, timestamp=None): return self.__index(scope, arguments) - def merge(self, scope, destination, items, timestamp=None): + def merge( + self, + scope: str, + destination: str, + items: Sequence[IndexItem], + timestamp: int | None = None, + ) -> list[object]: if timestamp is None: timestamp = int(time.time()) - arguments = [ + arguments: list[object] = [ "MERGE", timestamp, self.namespace, @@ -163,11 +216,13 @@ def merge(self, scope, destination, items, timestamp=None): return self.__index(scope, arguments) - def delete(self, scope, items, timestamp=None): + def delete( + self, scope: str, items: Sequence[IndexItem], timestamp: int | None = None + ) -> list[object]: if timestamp is None: timestamp = int(time.time()) - arguments = [ + arguments: list[object] = [ "DELETE", timestamp, self.namespace, @@ -183,11 +238,17 @@ def delete(self, scope, items, timestamp=None): return self.__index(scope, arguments) - def scan(self, scope, indices, batch=1000, timestamp=None): + def scan( + self, + scope: str, + indices: Sequence[str], + batch: int = 1000, + timestamp: int | None = None, + ) -> Iterator[tuple[str, list[bytes]]]: if timestamp is None: timestamp = int(time.time()) - arguments = [ + arguments: list[object] = [ "SCAN", timestamp, self.namespace, @@ -200,31 +261,43 @@ def scan(self, scope, indices, batch=1000, timestamp=None): cursors = {idx: 0 for idx in indices} while cursors: - requests = [] - for idx, cursor in cursors.items(): - requests.append([idx, cursor, batch]) + requests: list[list[object]] = [] + for index_key, cursor in cursors.items(): + requests.append([index_key, cursor, batch]) - responses = self.__index(scope, arguments + flatten(requests)) + responses = cast( + "list[tuple[int, list[bytes]]]", + self.__index(scope, arguments + flatten(requests)), + ) for (idx, _, _), (cursor, chunk) in zip(requests, responses): + index_key = cast(str, idx) cursor = int(cursor) if cursor == 0: - del cursors[idx] + del cursors[index_key] else: - cursors[idx] = cursor + cursors[index_key] = cursor - yield idx, chunk + yield index_key, chunk - def flush(self, scope, indices, batch=1000, timestamp=None): + def flush( + self, + scope: str, + indices: Sequence[str], + batch: int = 1000, + timestamp: int | None = None, + ) -> None: for index, chunk in self.scan(scope, indices, batch, timestamp): if chunk: self.cluster.delete(*chunk) - def export(self, scope, items, timestamp=None): + def export( + self, scope: str, items: Sequence[IndexItem], timestamp: int | None = None + ) -> list[object]: if timestamp is None: timestamp = int(time.time()) - arguments = [ + arguments: list[object] = [ "EXPORT", timestamp, self.namespace, @@ -240,11 +313,13 @@ def export(self, scope, items, timestamp=None): return self.__index(scope, arguments) - def import_(self, scope, items, timestamp=None): + def import_( + self, scope: str, items: Sequence[ImportItem], timestamp: int | None = None + ) -> list[object]: if timestamp is None: timestamp = int(time.time()) - arguments = [ + arguments: list[object] = [ "IMPORT", timestamp, self.namespace, diff --git a/src/sentry/similarity/encoder.py b/src/sentry/similarity/encoder.py index 06e3f2b5e691..70ea2b124bd6 100644 --- a/src/sentry/similarity/encoder.py +++ b/src/sentry/similarity/encoder.py @@ -1,13 +1,13 @@ -from collections.abc import Mapping, Sequence, Set +from collections.abc import Callable, Mapping, Sequence, Set class Encoder: number_types = (int, float) - def __init__(self, types=None): + def __init__(self, types: Mapping[type[object], Callable[..., object]] | None = None) -> None: self.types = types if types is not None else {} - def dumps(self, value): + def dumps(self, value: object) -> bytes: for cls, function in self.types.items(): if isinstance(value, cls): value = function(value) diff --git a/src/sentry/similarity/features.py b/src/sentry/similarity/features.py index 1141038c9626..f22f686605d5 100644 --- a/src/sentry/similarity/features.py +++ b/src/sentry/similarity/features.py @@ -1,10 +1,28 @@ +from __future__ import annotations + import itertools import logging +from collections.abc import Callable, Mapping, Sequence +from typing import TYPE_CHECKING, Protocol, cast + +from sentry.interfaces.base import Interface +from sentry.interfaces.stacktrace import Frame +from sentry.similarity.backends.abstract import AbstractIndexBackend +from sentry.similarity.encoder import Encoder + +if TYPE_CHECKING: + from sentry.models.group import Group + from sentry.models.project import Project + from sentry.services.eventstore.models import Event logger = logging.getLogger("sentry.similarity") -def get_application_chunks(exception): +class FeatureStrategy(Protocol): + def extract(self, event: Event) -> Sequence[object]: ... + + +def get_application_chunks(exception: Interface) -> list[list[Frame]]: """\ Filters out system and framework frames from a stacktrace in order to better align similar logical application paths. This returns a sequence of @@ -24,10 +42,10 @@ class InterfaceDoesNotExist(KeyError): class ExceptionFeature: - def __init__(self, function): + def __init__(self, function: Callable[[Interface], Sequence[object]]) -> None: self.function = function - def extract(self, event): + def extract(self, event: Event) -> Sequence[object]: try: interface = event.interfaces["exception"] except KeyError: @@ -36,10 +54,10 @@ def extract(self, event): class MessageFeature: - def __init__(self, function): + def __init__(self, function: Callable[[Interface], Sequence[object]]) -> None: self.function = function - def extract(self, event): + def extract(self, event: Event) -> Sequence[object]: try: interface = event.interfaces["logentry"] except KeyError: @@ -50,13 +68,13 @@ def extract(self, event): class FeatureSet: def __init__( self, - index, - encoder, - aliases, - features, - expected_extraction_errors, - expected_encoding_errors, - ): + index: AbstractIndexBackend, + encoder: Encoder, + aliases: Mapping[str, str], + features: Mapping[str, FeatureStrategy], + expected_extraction_errors: tuple[type[BaseException], ...], + expected_encoding_errors: tuple[type[BaseException], ...], + ) -> None: self.index = index self.encoder = encoder self.aliases = aliases @@ -65,14 +83,14 @@ def __init__( self.expected_encoding_errors = expected_encoding_errors assert set(self.aliases) == set(self.features) - def __get_scope(self, project) -> str: + def __get_scope(self, project: Project) -> str: return f"{project.id}" - def __get_key(self, group) -> str: + def __get_key(self, group: Group) -> str: return f"{group.id}" - def extract(self, event): - results = {} + def extract(self, event: Event) -> dict[str, Sequence[object]]: + results: dict[str, Sequence[object]] = {} for label, strategy in self.features.items(): try: results[label] = strategy.extract(event) @@ -91,14 +109,14 @@ def extract(self, event): ) return results - def record(self, events): + def record(self, events: Sequence[Event]) -> object: if not events: return [] scope: str | None = None key: str | None = None - items = [] + items: list[tuple[str, list[bytes]]] = [] for event in events: if not event.group_id: continue @@ -111,14 +129,14 @@ def record(self, events): ) if key is None: - key = self.__get_key(event.group) + key = self.__get_key(cast("Group", event.group)) else: - assert self.__get_key(event.group) == key, ( + assert self.__get_key(cast("Group", event.group)) == key, ( "all events must be associated with the same group" ) try: - features = [self.encoder.dumps(feature) for feature in features] + encoded_features = [self.encoder.dumps(feature) for feature in features] except Exception as error: log = ( logger.debug @@ -133,12 +151,19 @@ def record(self, events): exc_info=True, ) else: - if features: - items.append((self.aliases[label], features)) + if encoded_features: + items.append((self.aliases[label], encoded_features)) - return self.index.record(scope, key, items, timestamp=int(event.datetime.timestamp())) + return self.index.record( + cast(str, scope), cast(str, key), items, timestamp=int(event.datetime.timestamp()) + ) - def classify(self, events, limit=None, thresholds=None): + def classify( + self, + events: Sequence[Event], + limit: int | None = None, + thresholds: Mapping[str, float] | None = None, + ) -> list[tuple[int, dict[str, float | int | None]]]: if not events: return [] @@ -147,8 +172,8 @@ def classify(self, events, limit=None, thresholds=None): scope: str | None = None - labels = [] - items = [] + labels: list[str] = [] + items: list[tuple[str, float, list[bytes]]] = [] for event in events: for label, features in self.extract(event).items(): if scope is None: @@ -159,7 +184,7 @@ def classify(self, events, limit=None, thresholds=None): ) try: - features = [self.encoder.dumps(feature) for feature in features] + encoded_features = [self.encoder.dumps(feature) for feature in features] except Exception as error: log = ( logger.debug @@ -174,21 +199,28 @@ def classify(self, events, limit=None, thresholds=None): exc_info=True, ) else: - if features: - items.append((self.aliases[label], thresholds.get(label, 0), features)) + if encoded_features: + items.append( + (self.aliases[label], thresholds.get(label, 0), encoded_features) + ) labels.append(label) return [ (int(key), dict(zip(labels, scores))) for key, scores in self.index.classify( - scope, + cast(str, scope), items, limit=limit, timestamp=int(event.datetime.timestamp()), ) ] - def compare(self, group, limit=None, thresholds=None): + def compare( + self, + group: Group, + limit: int | None = None, + thresholds: Mapping[str, float] | None = None, + ) -> list[tuple[int, dict[str, float | int | None]]]: if thresholds is None: thresholds = {} @@ -203,15 +235,17 @@ def compare(self, group, limit=None, thresholds=None): ) ] - def merge(self, destination, sources, allow_unsafe=False): - def add_index_aliases_to_key(key): + def merge( + self, destination: Group, sources: Sequence[Group], allow_unsafe: bool = False + ) -> None: + def add_index_aliases_to_key(key: str) -> list[tuple[str, str]]: return [(self.aliases[label], key) for label in self.features.keys()] # Collect all of the sources by the scope that they are contained # within so that we can make the most efficient queries possible and # reject queries that cross scopes if we haven't explicitly allowed # unsafe actions. - scopes: dict[str, set[str]] = {} + scopes: dict[str, set[Group]] = {} for source in sources: scopes.setdefault(self.__get_scope(source.project), set()).add(source) @@ -224,9 +258,9 @@ def add_index_aliases_to_key(key): destination_scope = self.__get_scope(destination.project) destination_key = self.__get_key(destination) - for source_scope, sources in scopes.items(): - items = [] - for source in sources: + for source_scope, scoped_sources in scopes.items(): + items: list[tuple[str, str]] = [] + for source in scoped_sources: items.extend(add_index_aliases_to_key(self.__get_key(source))) if source_scope != destination_scope: @@ -239,12 +273,12 @@ def add_index_aliases_to_key(key): else: self.index.merge(destination_scope, destination_key, items) - def delete(self, group): + def delete(self, group: Group) -> object: key = self.__get_key(group) return self.index.delete( self.__get_scope(group.project), [(self.aliases[label], key) for label in self.features.keys()], ) - def flush(self, project): + def flush(self, project: Project) -> None: return self.index.flush(self.__get_scope(project), list(self.aliases.values())) diff --git a/src/sentry/similarity/signatures.py b/src/sentry/similarity/signatures.py index 127d9222afee..1f57b3153744 100644 --- a/src/sentry/similarity/signatures.py +++ b/src/sentry/similarity/signatures.py @@ -10,7 +10,7 @@ def __init__(self, columns: int, rows: int) -> None: self.columns = columns self.rows = rows - def __call__(self, features: Iterable[str]) -> list[int]: + def __call__(self, features: Iterable[str | bytes]) -> list[int]: return [ min(mmh3.hash(feature, column) % self.rows for feature in features) for column in range(self.columns) 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/functions.py b/src/sentry/snuba/functions.py index 1448872e169c..c50221867f9d 100644 --- a/src/sentry/snuba/functions.py +++ b/src/sentry/snuba/functions.py @@ -1,10 +1,13 @@ import logging +from collections.abc import Mapping, Sequence from datetime import timedelta -from typing import Any +from typing import Any, cast from sentry_sdk import traces +from snuba_sdk import Condition from sentry.exceptions import InvalidSearchQuery +from sentry.models.organization import Organization from sentry.search.events.builder.profile_functions import ( ProfileFunctionsQueryBuilder, ProfileFunctionsTimeseriesQueryBuilder, @@ -32,14 +35,14 @@ def query( auto_fields: bool = False, auto_aggregations: bool = False, use_aggregate_conditions: bool = False, - conditions=None, + conditions: list[Condition] | None = None, allow_metric_aggregates: bool = False, transform_alias_to_input_format: bool = False, has_metrics: bool = False, functions_acl: list[str] | None = None, on_demand_metrics_enabled: bool = False, on_demand_metrics_type: MetricSpecType | None = None, - fallback_to_transactions=False, + fallback_to_transactions: bool = False, query_source: QuerySource | None = None, *, referrer: str, @@ -132,21 +135,21 @@ def timeseries_query( def top_events_timeseries( - timeseries_columns, - selected_columns, - user_query, - snuba_params, - orderby, - rollup, - limit, - organization, - equations=None, - top_events=None, - allow_empty=True, - zerofill_results=True, - include_other=False, - functions_acl=None, - result_key_order=None, + timeseries_columns: list[str], + selected_columns: list[str], + user_query: str, + snuba_params: SnubaParams, + orderby: list[str] | None, + rollup: int, + limit: int, + organization: Organization | None, + equations: list[str] | None = None, + top_events: dict[str, list[dict[str, object]]] | None = None, + allow_empty: bool = True, + zerofill_results: bool = True, + include_other: bool = False, + functions_acl: list[str] | None = None, + result_key_order: list[str] | None = None, on_demand_metrics_enabled: bool = False, on_demand_metrics_type: MetricSpecType | None = None, query_source: QuerySource | None = None, @@ -154,7 +157,7 @@ def top_events_timeseries( transform_alias_to_input_format: bool = False, *, referrer: str, -): +) -> dict[str, SnubaTSResult] | SnubaTSResult: assert not include_other, "Other is not supported" # TODO: support other if top_events is None: @@ -210,19 +213,21 @@ def top_events_timeseries( def format_top_events_timeseries_results( - result, - query_builder, - snuba_params, - rollup, - top_events=None, - allow_empty=True, - zerofill_results=True, - result_key_order=None, -): + result: Mapping[str, object], + query_builder: ProfileTopFunctionsTimeseriesQueryBuilder, + snuba_params: SnubaParams, + rollup: int, + top_events: dict[str, list[dict[str, object]]] | None = None, + allow_empty: bool = True, + zerofill_results: bool = True, + result_key_order: list[str] | None = None, +) -> dict[str, SnubaTSResult] | SnubaTSResult: if top_events is None: assert top_events, "Need to provide top events" # TODO: support this use case - if not allow_empty and not len(result.get("data", [])): + if not allow_empty and not len( + cast(Sequence[object], result.get("data", [])) # justification: query rows. + ): return SnubaTSResult( { "data": ( @@ -239,9 +244,13 @@ def format_top_events_timeseries_results( with traces.start_span( name="top_events.transform_results", attributes={"sentry.op": "discover.discover"} ) as span: - result = query_builder.strip_alias_prefix(result) + # justification: snuba response is a mutable row payload. + result = query_builder.strip_alias_prefix(cast(dict[str, object], result)) - span.set_attribute("result_count", len(result.get("data", []))) + span.set_attribute( + "result_count", + len(cast(Sequence[object], result.get("data", []))), # justification: query rows. + ) processed_result = query_builder.process_results(result) if result_key_order is None: @@ -288,7 +297,7 @@ def format_top_events_timeseries_results( } -def create_result_key(result_row, fields) -> str: +def create_result_key(result_row: Mapping[str, object], fields: Sequence[str]) -> str: values = [] for field in fields: value = result_row.get(field) diff --git a/src/sentry/snuba/issue_platform.py b/src/sentry/snuba/issue_platform.py index 5ca6f4f79c9c..19b9314f2fb2 100644 --- a/src/sentry/snuba/issue_platform.py +++ b/src/sentry/snuba/issue_platform.py @@ -3,6 +3,7 @@ from datetime import timedelta from sentry_sdk import traces +from snuba_sdk import Condition from sentry.discover.arithmetic import categorize_columns from sentry.exceptions import InvalidSearchQuery @@ -17,27 +18,27 @@ def query( - selected_columns, - query, - snuba_params, - equations=None, - orderby=None, - offset=None, - limit=50, - auto_fields=False, - auto_aggregations=False, - include_equation_fields=False, - allow_metric_aggregates=False, - use_aggregate_conditions=False, - conditions=None, - functions_acl=None, - transform_alias_to_input_format=False, - sample=None, - has_metrics=False, - skip_tag_resolution=False, - on_demand_metrics_enabled=False, + selected_columns: list[str], + query: str, + snuba_params: SnubaParams, + equations: list[str] | None = None, + orderby: list[str] | None = None, + offset: int | None = None, + limit: int = 50, + auto_fields: bool = False, + auto_aggregations: bool = False, + include_equation_fields: bool = False, + allow_metric_aggregates: bool = False, + use_aggregate_conditions: bool = False, + conditions: list[Condition] | None = None, + functions_acl: list[str] | None = None, + transform_alias_to_input_format: bool = False, + sample: float | None = None, + has_metrics: bool = False, + skip_tag_resolution: bool = False, + on_demand_metrics_enabled: bool = False, on_demand_metrics_type: MetricSpecType | None = None, - fallback_to_transactions=False, + fallback_to_transactions: bool = False, query_source: QuerySource | None = None, *, referrer: str, @@ -115,16 +116,16 @@ def timeseries_query( zerofill_results: bool = True, comparison_delta: timedelta | None = None, functions_acl: list[str] | None = None, - allow_metric_aggregates=False, - has_metrics=False, - on_demand_metrics_enabled=False, + allow_metric_aggregates: bool = False, + has_metrics: bool = False, + on_demand_metrics_enabled: bool = False, on_demand_metrics_type: MetricSpecType | None = None, query_source: QuerySource | None = None, fallback_to_transactions: bool = False, transform_alias_to_input_format: bool = False, *, referrer: str, -): +) -> SnubaTSResult: """ High-level API for doing arbitrary user timeseries queries against events. diff --git a/src/sentry/snuba/metrics/datasource.py b/src/sentry/snuba/metrics/datasource.py index cef16a0d6913..8092748ee5d9 100644 --- a/src/sentry/snuba/metrics/datasource.py +++ b/src/sentry/snuba/metrics/datasource.py @@ -51,6 +51,8 @@ UNALLOWED_TAGS, DerivedMetricParseException, MetricDoesNotExistInIndexer, + MetricEntity, + MetricOperationType, MetricType, NotSupportedOverCompositeEntityException, Tag, @@ -621,7 +623,7 @@ def get_series( ) results: dict[str, dict[str, Any]] = {} meta = [] - fields_in_entities = {} + fields_in_entities: dict[MetricEntity, list[tuple[MetricOperationType | None, str, str]]] = {} if not metrics_query.groupby: # When there is no groupBy columns specified, we don't want to go through running an diff --git a/src/sentry/snuba/metrics/extraction.py b/src/sentry/snuba/metrics/extraction.py index e044c8e99032..c7a2e4b2b21e 100644 --- a/src/sentry/snuba/metrics/extraction.py +++ b/src/sentry/snuba/metrics/extraction.py @@ -19,7 +19,6 @@ SearchKey, SearchValue, ) -from sentry.constants import DataCategory from sentry.discover.arithmetic import is_equation from sentry.exceptions import InvalidSearchQuery from sentry.models.organization import Organization @@ -73,16 +72,18 @@ class OnDemandMetricSpecVersioning: ] @classmethod - def get_query_spec_version(cls: Any, organization_id: int) -> SpecVersion: + def get_query_spec_version( + cls: type[OnDemandMetricSpecVersioning], organization_id: int + ) -> SpecVersion: return cls.spec_versions[0] @classmethod - def get_spec_versions(cls: Any) -> Sequence[SpecVersion]: + def get_spec_versions(cls: type[OnDemandMetricSpecVersioning]) -> Sequence[SpecVersion]: """Get all spec versions.""" return cls.spec_versions @classmethod - def get_default_spec_version(cls: Any) -> SpecVersion: + def get_default_spec_version(cls: type[OnDemandMetricSpecVersioning]) -> SpecVersion: return cls.spec_versions[0] @@ -1336,7 +1337,7 @@ def to_metric_spec(self, project: Project) -> MetricSpec: extended_tags_conditions.append(self._tag_for_field("environment")) metric_spec: MetricSpec = { - "category": DataCategory.TRANSACTION.api_name(), + "category": "transaction", "mri": self.mri, "field": self.field_to_extract, "tags": extended_tags_conditions, @@ -1469,7 +1470,8 @@ def _parse_field(value: str) -> FieldParsingResult: return FieldParsingResult(function=function, arguments=arguments, alias=alias) # TODO: why is this here? - column = query_builder.resolve_column(value) + # justification: snuba_sdk is exempt from typing, so resolve_column's result is Any. + column = cast("FieldParsingResult", query_builder.resolve_column(value)) return column except InvalidSearchQuery as e: raise OnDemandMetricSpecError( @@ -1582,7 +1584,7 @@ def _get_satisfactory_metric(project: Project) -> str: if len(result) == 0: metric = TransactionMetric.DURATION.value else: - metric = result[0][0] + metric = cast(int, result[0][0]) if metric == TransactionMetric.DURATION.value: metric_field = "transaction.duration" diff --git a/src/sentry/snuba/metrics/fields/base.py b/src/sentry/snuba/metrics/fields/base.py index c01b359b8e86..1b059d99bf0f 100644 --- a/src/sentry/snuba/metrics/fields/base.py +++ b/src/sentry/snuba/metrics/fields/base.py @@ -174,7 +174,10 @@ def run_metrics_query( end=end, ) result = raw_snql_query(request, referrer, use_cache=True) - return result["data"] + data = result["data"] + if not isinstance(data, list): + raise ValueError("Snuba returned no data array") + return data _PREFIX_TO_METRIC_ENTITY: dict[str, MetricEntity] = { diff --git a/src/sentry/snuba/metrics/fields/snql.py b/src/sentry/snuba/metrics/fields/snql.py index 8c91a7985fe2..b99ec72baa57 100644 --- a/src/sentry/snuba/metrics/fields/snql.py +++ b/src/sentry/snuba/metrics/fields/snql.py @@ -1,7 +1,7 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import Any +from collections.abc import Callable, Sequence +from typing import Any, TypeAlias from snuba_sdk import Column, Function @@ -19,8 +19,12 @@ TransactionTagsKey, ) +SnqlValue: TypeAlias = Function | Column | str | float | int -def _aggregation_on_session_status_func_factory(aggregate) -> Function: + +def _aggregation_on_session_status_func_factory( + aggregate: str, +) -> Callable[[int, str, Sequence[int], str | None], Function]: def _snql_on_session_status_factory( org_id: int, session_status: str, metric_ids: Sequence[int], alias: str | None = None ) -> Function: @@ -271,15 +275,17 @@ def http_error_count_span( ) -def subtraction(arg1_snql, arg2_snql, alias: str | None = None) -> Function: +def subtraction(arg1_snql: SnqlValue, arg2_snql: SnqlValue, alias: str | None = None) -> Function: return Function("minus", [arg1_snql, arg2_snql], alias) -def addition(arg1_snql, arg2_snql, alias: str | None = None) -> Function: +def addition(arg1_snql: SnqlValue, arg2_snql: SnqlValue, alias: str | None = None) -> Function: return Function("plus", [arg1_snql, arg2_snql], alias) -def division_float(arg1_snql, arg2_snql, alias: str | None = None) -> Function: +def division_float( + arg1_snql: SnqlValue, arg2_snql: SnqlValue, alias: str | None = None +) -> Function: return Function( "divide", # Clickhouse can manage divisions by 0, see: @@ -289,12 +295,12 @@ def division_float(arg1_snql, arg2_snql, alias: str | None = None) -> Function: ) -def complement(arg1_snql, alias: str | None = None) -> Function: +def complement(arg1_snql: SnqlValue, alias: str | None = None) -> Function: """(x) -> (1 - x)""" return Function("minus", [1.0, arg1_snql], alias=alias) -def session_duration_filters(org_id) -> Function: +def session_duration_filters(org_id: int) -> list[Function]: return [ Function( "equals", @@ -347,7 +353,7 @@ def rate_snql_factory( def count_web_vitals_snql_factory( - aggregate_filter: Function, org_id: int, measurement_rating, alias: str | None = None + aggregate_filter: Function, org_id: int, measurement_rating: str, alias: str | None = None ) -> Function: return Function( "countIf", @@ -376,13 +382,15 @@ def count_web_vitals_snql_factory( def count_transaction_name_snql_factory( - aggregate_filter: Function, org_id: int, transaction_name, alias: str | None = None + aggregate_filter: Function, org_id: int, transaction_name: str, alias: str | None = None ) -> Function: is_unparameterized = "is_unparameterized" is_null = "is_null" has_value = "has_value" - def generate_transaction_name_filter(operation, transaction_name_identifier) -> Function: + def generate_transaction_name_filter( + operation: str, transaction_name_identifier: str + ) -> Function: if transaction_name_identifier == is_unparameterized: inner_tag_value = resolve_tag_value( UseCaseID.TRANSACTIONS, org_id, "<< unparameterized >>" @@ -436,7 +444,7 @@ def generate_transaction_name_filter(operation, transaction_name_identifier) -> def team_key_transaction_snql( - org_id: int, team_key_condition_rhs, alias: str | None = None + org_id: int, team_key_condition_rhs: Sequence[tuple[int | str, str]], alias: str | None = None ) -> Function: team_key_conditions = set() for elem in team_key_condition_rhs: diff --git a/src/sentry/snuba/metrics/mqb_query_transformer.py b/src/sentry/snuba/metrics/mqb_query_transformer.py index 66cf6c92bc7c..f094f2d364fb 100644 --- a/src/sentry/snuba/metrics/mqb_query_transformer.py +++ b/src/sentry/snuba/metrics/mqb_query_transformer.py @@ -1,9 +1,21 @@ import inspect - -from snuba_sdk import AliasedExpression, BooleanCondition, Column, Function, Granularity, Op +from collections.abc import Sequence +from typing import Any, cast + +from snuba_sdk import ( + AliasedExpression, + BooleanCondition, + Column, + Condition, + Function, + Granularity, + Op, + OrderBy, +) from snuba_sdk.query import Query from sentry.exceptions import InvalidParams +from sentry.models.project import Project from sentry.sentry_metrics.use_case_id_registry import UseCaseID from sentry.snuba.metrics import FIELD_ALIAS_MAPPINGS, OPERATIONS, DerivedMetricException from sentry.snuba.metrics.fields.base import DERIVED_OPS, metric_object_factory @@ -25,7 +37,7 @@ class MQBQueryTransformationException(Exception): pass -def _get_derived_op_metric_field_from_snuba_function(function: Function): +def _get_derived_op_metric_field_from_snuba_function(function: Function) -> MetricField: if len(function.parameters) == 0 or not isinstance(function.parameters[0], Column): raise MQBQueryTransformationException( "The first parameter of a function should be a column of the metric MRI" @@ -55,7 +67,9 @@ def _get_derived_op_metric_field_from_snuba_function(function: Function): ) -def _transform_select(query_select): +def _transform_select( + query_select: Sequence[Column | AliasedExpression | Function], +) -> list[MetricField]: select = [] for select_field in query_select: if isinstance(select_field, (Column, AliasedExpression)): @@ -99,7 +113,9 @@ def _transform_select(query_select): return select -def _transform_groupby(query_groupby): +def _transform_groupby( + query_groupby: Sequence[Column | AliasedExpression | Function], +) -> tuple[list[MetricGroupByField] | None, bool, int | None]: mq_groupby = [] interval = None include_series = False @@ -172,12 +188,14 @@ def _transform_groupby(query_groupby): ) else: raise MQBQueryTransformationException(f"Unsupported groupby field {groupby_field}") - return mq_groupby if len(mq_groupby) > 0 else None, include_series, interval + return mq_groupby if len(mq_groupby) > 0 else None, include_series, cast("int | None", interval) -def _get_mq_dict_params_and_conditions_from(conditions): - mq_dict = {} - converted_conditions = [] +def _get_mq_dict_params_and_conditions_from( + conditions: Sequence[BooleanCondition | Condition], +) -> tuple[dict[str, Any], list[BooleanCondition | Condition | MetricConditionField]]: + mq_dict: dict[str, Any] = {} + converted_conditions: list[BooleanCondition | Condition | MetricConditionField] = [] for condition in conditions: if isinstance(condition, BooleanCondition): @@ -227,8 +245,8 @@ def _get_mq_dict_params_and_conditions_from(conditions): return mq_dict, converted_conditions -def _transform_orderby(query_orderby): - mq_orderby = [] +def _transform_orderby(query_orderby: Sequence[OrderBy]) -> list[MetricOrderByField] | None: + mq_orderby: list[MetricOrderByField] = [] for orderby_field in query_orderby: orderby_exp = orderby_field.exp @@ -261,7 +279,11 @@ def _transform_orderby(query_orderby): return mq_orderby if len(mq_orderby) > 0 else None -def _derive_mri_to_apply(project_ids, select, orderby): +def _derive_mri_to_apply( + project_ids: Sequence[int], + select: Sequence[MetricField], + orderby: Sequence[MetricOrderByField] | None, +) -> str: mri_dictionary = { "generic_metrics_counters": TransactionMRI.COUNT_PER_ROOT_PROJECT.value, } @@ -278,6 +300,7 @@ def _derive_mri_to_apply(project_ids, select, orderby): break if has_order_by_team_key_transaction: + assert orderby is not None entities = set() if len(orderby) == 1: @@ -289,7 +312,9 @@ def _derive_mri_to_apply(project_ids, select, orderby): for select_field in select: if select_field.op != TEAM_KEY_TRANSACTION_OP: expr = metric_object_factory(select_field.op, select_field.metric_mri) - entity = expr.get_entity(project_ids, use_case_id=UseCaseID.TRANSACTIONS) + entity = expr.get_entity( + cast("Sequence[Project]", project_ids), use_case_id=UseCaseID.TRANSACTIONS + ) if isinstance(entity, str): entities.add(entity) else: @@ -305,7 +330,10 @@ def _derive_mri_to_apply(project_ids, select, orderby): expr = metric_object_factory( orderby_field.field.op, orderby_field.field.metric_mri ) - entity = expr.get_entity(project_ids, use_case_id=UseCaseID.TRANSACTIONS) + entity = expr.get_entity( + cast("Sequence[Project]", project_ids), + use_case_id=UseCaseID.TRANSACTIONS, + ) if isinstance(entity, str): entities.add(entity) @@ -320,11 +348,13 @@ def _derive_mri_to_apply(project_ids, select, orderby): return mri_to_apply -def _transform_team_key_transaction_in_select(mri_to_apply, select): +def _transform_team_key_transaction_in_select( + mri_to_apply: str, select: Sequence[MetricField] | None +) -> list[MetricField] | None: if select is None: return select - def _select_predicate(select_field): + def _select_predicate(select_field: MetricField) -> MetricField: if select_field.op == TEAM_KEY_TRANSACTION_OP: return MetricField( op=select_field.op, @@ -338,11 +368,16 @@ def _select_predicate(select_field): return list(map(_select_predicate, select)) -def _transform_team_key_transaction_in_where(mri_to_apply, where): +def _transform_team_key_transaction_in_where( + mri_to_apply: str, + where: Sequence[BooleanCondition | Condition | MetricConditionField] | None, +) -> list[BooleanCondition | Condition | MetricConditionField] | None: if where is None: return where - def _where_predicate(where_field): + def _where_predicate( + where_field: BooleanCondition | Condition | MetricConditionField, + ) -> BooleanCondition | Condition | MetricConditionField: if ( isinstance(where_field, MetricConditionField) and where_field.lhs.op == TEAM_KEY_TRANSACTION_OP @@ -363,11 +398,13 @@ def _where_predicate(where_field): return list(map(_where_predicate, where)) -def _transform_team_key_transaction_in_groupby(mri_to_apply, groupby): +def _transform_team_key_transaction_in_groupby( + mri_to_apply: str, groupby: Sequence[MetricGroupByField] | None +) -> list[MetricGroupByField] | None: if groupby is None: return groupby - def _groupby_predicate(groupby_field): + def _groupby_predicate(groupby_field: MetricGroupByField) -> MetricGroupByField: if ( isinstance(groupby_field.field, MetricField) and groupby_field.field.op == TEAM_KEY_TRANSACTION_OP @@ -386,11 +423,13 @@ def _groupby_predicate(groupby_field): return list(map(_groupby_predicate, groupby)) -def _transform_team_key_transaction_in_orderby(mri_to_apply, orderby): +def _transform_team_key_transaction_in_orderby( + mri_to_apply: str, orderby: Sequence[MetricOrderByField] | None +) -> list[MetricOrderByField] | None: if orderby is None: return orderby - def _orderby_predicate(orderby_field): + def _orderby_predicate(orderby_field: MetricOrderByField) -> MetricOrderByField: if isinstance(orderby_field.field, MetricField): if orderby_field.field.op == TEAM_KEY_TRANSACTION_OP: return MetricOrderByField( @@ -408,7 +447,7 @@ def _orderby_predicate(orderby_field): return list(map(_orderby_predicate, orderby)) -def _transform_team_key_transaction_fake_mri(mq_dict): +def _transform_team_key_transaction_fake_mri(mq_dict: dict[str, Any]) -> dict[str, Any]: if "project_ids" not in mq_dict: raise MQBQueryTransformationException("Missing project_id in query") mri_to_apply = _derive_mri_to_apply( diff --git a/src/sentry/snuba/metrics/query_builder.py b/src/sentry/snuba/metrics/query_builder.py index 025565831bab..afc82a7f0953 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, @@ -43,6 +44,7 @@ from sentry.snuba.metrics.fields.base import ( COMPOSITE_ENTITY_CONSTITUENT_ALIAS, MetricExpressionBase, + MetricOperationParams, generate_bottom_up_dependency_tree_for_metrics, org_id_from_projects, ) @@ -71,6 +73,7 @@ DerivedMetricParseException, MetricDoesNotExistException, MetricEntity, + MetricOperationType, get_num_intervals, get_timestamp_column_name, require_rhs_condition_resolution, @@ -128,7 +131,7 @@ def parse_public_field(field: str) -> MetricField: matches = PUBLIC_EXPRESSION_REGEX.match(field) if matches is not None: - operation = matches[1] + operation = cast(MetricOperationType, matches[1]) metric_name = matches[2] else: operation = None @@ -137,7 +140,9 @@ def parse_public_field(field: str) -> MetricField: return MetricField(operation, get_mri(metric_name)) -def transform_null_transaction_to_unparameterized(use_case_id, org_id, alias=None): +def transform_null_transaction_to_unparameterized( + use_case_id: UseCaseID, org_id: int, alias: str | None = None +) -> Function: """ This function transforms any null tag.transaction to '<< unparameterized >>' so that it can be handled as such in any query using that tag value. @@ -164,10 +169,12 @@ def _refers_to_column(expression: Column | Function) -> str | None: """ if isinstance(expression, Column): - return expression.name + # justification: snuba_sdk is exempt from typing. + return cast("str", expression.name) elif isinstance(expression, Function): if expression.function == "ifNull": - return expression.parameters[0].name + # justification: snuba_sdk is exempt from typing. + return cast("str", expression.parameters[0].name) return None @@ -508,11 +515,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 +568,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 +588,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 +610,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)) @@ -748,10 +757,10 @@ def translate_meta_results( elif alias_type == AliasMetaType.GROUP_BY_METRIC_FIELD: metric_groupby_field = alias_to_metric_group_by_field[record["name"]] defined_parent_meta_type = get_metric_object_from_metric_field( - metric_groupby_field.field + cast(MetricField, metric_groupby_field.field) ).get_meta_type() - record["type"] = defined_parent_meta_type + record["type"] = cast(str, defined_parent_meta_type) elif alias_type == AliasMetaType.TAG: record["type"] = "string" elif alias_type == AliasMetaType.DATASET_COLUMN or alias_type == AliasMetaType.TIME_COLUMN: @@ -828,7 +837,10 @@ def generate_snql_for_action_by_fields( if isinstance(metric_action_by_field.field, str): # This transformation is currently supported only for group by because OrderBy doesn't support the Function type. - if is_group_by and metric_action_by_field.field == "transaction": + if ( + isinstance(metric_action_by_field, MetricGroupByField) + and metric_action_by_field.field == "transaction" + ): return transform_null_transaction_to_unparameterized( use_case_id, org_id, metric_action_by_field.alias ) @@ -855,11 +867,11 @@ def generate_snql_for_action_by_fields( exp=Column(name=column_name), alias=metric_action_by_field.alias, ) - if is_group_by and not is_column + if isinstance(metric_action_by_field, MetricGroupByField) and not is_column else Column(name=column_name) ) - if is_order_by: + if isinstance(metric_action_by_field, MetricOrderByField): # We return a list in order to use the "extend" method and reduce the number of changes across # the codebase. exp = [OrderBy(exp=exp, direction=metric_action_by_field.direction)] @@ -871,18 +883,18 @@ def generate_snql_for_action_by_fields( metric_action_by_field.field.op, metric_action_by_field.field.metric_mri ) - if is_group_by: + if isinstance(metric_action_by_field, MetricGroupByField): return metric_expression.generate_groupby_statements( use_case_id=use_case_id, alias=metric_action_by_field.field.alias, - params=metric_action_by_field.field.params, + params=cast(MetricOperationParams, metric_action_by_field.field.params), projects=projects, )[0] - elif is_order_by: + elif isinstance(metric_action_by_field, MetricOrderByField): return metric_expression.generate_orderby_clause( use_case_id=use_case_id, alias=metric_action_by_field.field.alias, - params=metric_action_by_field.field.params, + params=cast(MetricOperationParams, metric_action_by_field.field.params), projects=projects, direction=metric_action_by_field.direction, ) @@ -922,14 +934,18 @@ def _build_where(self) -> list[BooleanCondition | Condition]: Condition( lhs=metric_expression.generate_where_statements( use_case_id=self._use_case_id, - params=condition.lhs.params, + params=cast(MetricOperationParams, condition.lhs.params), projects=self._projects, alias=condition.lhs.alias, )[0], op=condition.op, rhs=( - resolve_tag_value(self._use_case_id, self._org_id, condition.rhs) - if require_rhs_condition_resolution(condition.lhs.op) + resolve_tag_value( + self._use_case_id, self._org_id, cast(str, condition.rhs) + ) + if require_rhs_condition_resolution( + cast(MetricOperationType, condition.lhs.op) + ) else condition.rhs ), ) @@ -1033,18 +1049,18 @@ def _build_having(self) -> list[BooleanCondition | Condition]: def __build_totals_and_series_queries( self, - entity, - select, - where, - having, - groupby, - orderby, - limit, - offset, - rollup, - intervals_len, - ): - rv = {} + entity: MetricEntity, + select: list[Function], + where: list[BooleanCondition | Condition], + having: list[BooleanCondition | Condition], + groupby: list[Column] | None, + orderby: list[OrderBy] | None, + limit: Limit, + offset: Offset | None, + rollup: int, + intervals_len: int, + ) -> dict[str, Query]: + rv: dict[str, Query] = {} totals_query = Query( match=Entity(entity), groupby=groupby, @@ -1067,7 +1083,7 @@ def __build_totals_and_series_queries( if self._use_case_id in [UseCaseID.TRANSACTIONS, UseCaseID.SPANS]: time_groupby_column = self.__generate_time_groupby_column_for_discover_queries( - self._metrics_query.interval + cast(int, self._metrics_query.interval) ) else: time_groupby_column = Column(TS_COL_GROUP) @@ -1097,10 +1113,12 @@ def __generate_time_groupby_column_for_discover_queries(interval: int) -> Functi def __update_query_dicts_with_component_entities( self, component_entities: dict[MetricEntity, Sequence[str]], - metric_mri_to_obj_dict: dict[tuple[str | None, str, str], MetricExpressionBase], - fields_in_entities: dict[MetricEntity, list[tuple[str | None, str, str]]], - parent_alias, - ) -> dict[tuple[str | None, str, str], MetricExpressionBase]: + metric_mri_to_obj_dict: dict[ + tuple[MetricOperationType | None, str, str], MetricExpressionBase + ], + fields_in_entities: dict[MetricEntity, list[tuple[MetricOperationType | None, str, str]]], + parent_alias: str, + ) -> dict[tuple[MetricOperationType | None, str, str], MetricExpressionBase]: # At this point in time, we are only supporting raw metrics in the metrics attribute of # any instance of DerivedMetric, and so in this case the op will always be None # ToDo(ahmed): In future PR, we might want to allow for dependency metrics to also have an @@ -1124,9 +1142,18 @@ def __update_query_dicts_with_component_entities( fields_in_entities.setdefault(entity, []).append(metric_key) return metric_mri_to_obj_dict - def get_snuba_queries(self): - metric_mri_to_obj_dict: dict[tuple[str | None, str, str], MetricExpressionBase] = {} - fields_in_entities: dict[MetricEntity, list[tuple[str | None, str, str]]] = {} + def get_snuba_queries( + self, + ) -> tuple[ + dict[MetricEntity, Query], + dict[MetricEntity, list[tuple[MetricOperationType | None, str, str]]], + ]: + metric_mri_to_obj_dict: dict[ + tuple[MetricOperationType | None, str, str], MetricExpressionBase + ] = {} + fields_in_entities: dict[ + MetricEntity, list[tuple[MetricOperationType | None, str, str]] + ] = {} for select_field in self._metrics_query.select: metric_field_obj = metric_object_factory(select_field.op, select_field.metric_mri) @@ -1150,7 +1177,9 @@ def get_snuba_queries(self): # metric_mris combination that this metric_object is composed of, or rather # the instances of SingleEntityDerivedMetric that it is composed of metric_mri_to_obj_dict = self.__update_query_dicts_with_component_entities( - component_entities=component_entities, + component_entities=cast( + dict[MetricEntity, Sequence[str]], component_entities + ), metric_mri_to_obj_dict=metric_mri_to_obj_dict, fields_in_entities=fields_in_entities, parent_alias=select_field.alias, @@ -1198,7 +1227,7 @@ def get_snuba_queries(self): projects=self._projects, use_case_id=self._use_case_id, alias=field[2], - params=params, + params=cast(MetricOperationParams, params), ) metric_ids_set |= metric_field_obj.generate_metric_ids( self._projects, self._use_case_id @@ -1222,7 +1251,7 @@ def get_snuba_queries(self): having=having, groupby=groupby, # Empty group by is set to None. orderby=orderby, # Empty order by is set to None. - limit=self._metrics_query.limit, + limit=cast(Limit, self._metrics_query.limit), offset=self._metrics_query.offset, # No offset is set to None. rollup=self._metrics_query.granularity, intervals_len=get_num_intervals( @@ -1252,9 +1281,9 @@ def __init__( self, organization_id: int, metrics_query: DeprecatingMetricsQuery, - fields_in_entities: dict[MetricEntity, list[tuple[str | None, str, str]]], + fields_in_entities: dict[MetricEntity, list[tuple[MetricOperationType | None, str, str]]], intervals: list[datetime], - results, + results: Mapping[str, Mapping[str, Mapping[str, list[dict[str, Any]]]]], use_case_id: UseCaseID, ): self._organization_id = organization_id @@ -1294,7 +1323,11 @@ def __init__( self._timestamp_index = {timestamp: index for index, timestamp in enumerate(intervals)} - def _extract_data(self, data, groups: dict[tuple[tuple[str, str], ...], _SeriesTotals]) -> None: + def _extract_data( + self, + data: dict[str, Any], # justification: Snuba result rows are decoded JSON of mixed types. + groups: dict[tuple[tuple[str, str], ...], _SeriesTotals], + ) -> None: group_key_aliases = ( {metric_groupby_obj.alias for metric_groupby_obj in self._metrics_query.groupby} if self._metrics_query.groupby @@ -1316,6 +1349,7 @@ def _extract_data(self, data, groups: dict[tuple[tuple[str, str], ...], _SeriesT # QueryBuilder necessary as it contains the constituent instances of # SingularEntityDerivedMetric for instances of CompositeEntityDerivedMetric for op, metric_mri, alias in self._set_of_constituent_queries: + cleaned_value: int | float | list[tuple[float]] | None default_null_value = metric_object_factory( op, metric_mri ).generate_default_null_values() @@ -1354,7 +1388,7 @@ def _extract_data(self, data, groups: dict[tuple[tuple[str, str], ...], _SeriesT if series[series_index] == default_null_value: series[series_index] = cleaned_value - def translate_result_groups(self): + def translate_result_groups(self) -> list[_BySeriesTotals]: groups_d: dict[tuple[tuple[str, str], ...], _SeriesTotals] = {} for _, subresults in self._results.items(): for k in "totals", "series": @@ -1433,7 +1467,7 @@ def resolve_tag_value(value: int | str | None) -> str | None: except KeyError: params = None totals[alias] = metric_obj.run_post_query_function( - totals, params=params, alias=alias + totals, params=cast(MetricOperationParams, params), alias=alias ) if series is not None: @@ -1448,7 +1482,7 @@ def resolve_tag_value(value: int | str | None) -> str | None: except KeyError: params = None series[alias][idx] = metric_obj.run_post_query_function( - series, params=params, idx=idx, alias=alias + series, params=cast(MetricOperationParams, params), idx=idx, alias=alias ) # Remove the extra fields added due to the constituent metrics that were added diff --git a/src/sentry/snuba/metrics_enhanced_performance.py b/src/sentry/snuba/metrics_enhanced_performance.py index 1f6dc61049a4..925989c44fb7 100644 --- a/src/sentry/snuba/metrics_enhanced_performance.py +++ b/src/sentry/snuba/metrics_enhanced_performance.py @@ -3,7 +3,7 @@ import types from collections.abc import Sequence from datetime import timedelta -from typing import Any +from typing import Any, Literal, cast import sentry_sdk from snuba_sdk import Column, Condition @@ -146,12 +146,12 @@ def timeseries_query( rollup: int, referrer: str, zerofill_results: bool = True, - allow_metric_aggregates=True, + allow_metric_aggregates: bool = True, comparison_delta: timedelta | None = None, functions_acl: list[str] | None = None, has_metrics: bool = True, on_demand_metrics_enabled: bool = False, - on_demand_metrics_type=None, + on_demand_metrics_type: MetricSpecType | None = None, query_source: QuerySource | None = None, fallback_to_transactions: bool = False, transform_alias_to_input_format: bool = False, @@ -199,18 +199,22 @@ def timeseries_query( else: sentry_sdk.set_tag("performance.dataset", "discover") sentry_sdk.set_attribute("performance.dataset", "discover") - return dataset.timeseries_query( - selected_columns, - query, - snuba_params, - rollup=rollup, - referrer=referrer, - zerofill_results=zerofill_results, - comparison_delta=comparison_delta, - functions_acl=functions_acl, - has_metrics=has_metrics, - query_source=query_source, - transform_alias_to_input_format=transform_alias_to_input_format, + # justification: dataset modules are chosen at runtime and return SnubaTSResult. + return cast( + "SnubaTSResult", + dataset.timeseries_query( + selected_columns, + query, + snuba_params, + rollup=rollup, + referrer=referrer, + zerofill_results=zerofill_results, + comparison_delta=comparison_delta, + functions_acl=functions_acl, + has_metrics=has_metrics, + query_source=query_source, + transform_alias_to_input_format=transform_alias_to_input_format, + ), ) return SnubaTSResult( { @@ -296,24 +300,28 @@ def top_events_timeseries( else: sentry_sdk.set_tag("performance.dataset", "discover") sentry_sdk.set_attribute("performance.dataset", "discover") - return dataset.top_events_timeseries( - timeseries_columns, - selected_columns, - user_query, - snuba_params, - orderby, - rollup, - limit, - organization, - equations, - top_events, - allow_empty, - zerofill_results, - include_other, - functions_acl, - query_source=query_source, - transform_alias_to_input_format=transform_alias_to_input_format, - referrer=referrer, + # justification: dataset modules are chosen at runtime and return SnubaTSResult. + return cast( + "SnubaTSResult | dict[str, Any]", + dataset.top_events_timeseries( + timeseries_columns, + selected_columns, + user_query, + snuba_params, + orderby, + rollup, + limit, + organization, + equations, + top_events, + allow_empty, + zerofill_results, + include_other, + functions_acl, + query_source=query_source, + transform_alias_to_input_format=transform_alias_to_input_format, + referrer=referrer, + ), ) return SnubaTSResult( { @@ -332,26 +340,26 @@ def top_events_timeseries( def histogram_query( - fields, - user_query, - snuba_params, - num_buckets, - precision=0, - min_value=None, - max_value=None, - data_filter=None, - group_by=None, - order_by=None, - limit_by=None, - histogram_rows=None, - extra_conditions=None, - normalize_results=True, - on_demand_metrics_enabled=False, - on_demand_metrics_type=None, + fields: list[str], + user_query: str, + snuba_params: SnubaParams, + num_buckets: int, + precision: int = 0, + min_value: float | None = None, + max_value: float | None = None, + data_filter: Literal["exclude_outliers"] | None = None, + group_by: list[str] | None = None, + order_by: list[str] | None = None, + limit_by: list[str] | None = None, + histogram_rows: int | None = None, + extra_conditions: list[Condition] | None = None, + normalize_results: bool = True, + on_demand_metrics_enabled: bool = False, + on_demand_metrics_type: MetricSpecType | None = None, query_source: QuerySource | None = None, *, referrer: str, -): +) -> dict[str, object] | EventsResponse: """ High-level API for doing arbitrary user timeseries queries against events. this API should match that of sentry.snuba.discover.histogram_query diff --git a/src/sentry/snuba/metrics_performance.py b/src/sentry/snuba/metrics_performance.py index 67f7e19b106a..0588d77dfb0e 100644 --- a/src/sentry/snuba/metrics_performance.py +++ b/src/sentry/snuba/metrics_performance.py @@ -3,7 +3,7 @@ import logging from collections.abc import Sequence from datetime import timedelta -from typing import Any, Literal, overload +from typing import Any, Literal, cast, overload import sentry_sdk from sentry_sdk import traces @@ -11,6 +11,7 @@ from sentry.discover.arithmetic import categorize_columns from sentry.exceptions import IncompatibleMetricsQuery +from sentry.models.organization import Organization from sentry.search.events.builder.metrics import ( HistogramMetricQueryBuilder, MetricsQueryBuilder, @@ -18,7 +19,12 @@ TopMetricsQueryBuilder, ) from sentry.search.events.fields import get_function_alias -from sentry.search.events.types import EventsResponse, QueryBuilderConfig, SnubaParams +from sentry.search.events.types import ( + EventsResponse, + HistogramParams, + QueryBuilderConfig, + SnubaParams, +) from sentry.snuba import discover from sentry.snuba.dataset import Dataset from sentry.snuba.metrics.extraction import MetricSpecType @@ -52,7 +58,7 @@ def query( on_demand_metrics_enabled: bool = False, on_demand_metrics_type: MetricSpecType | None = None, granularity: int | None = None, - fallback_to_transactions=False, + fallback_to_transactions: bool = False, query_source: QuerySource | None = None, *, referrer: str, @@ -106,7 +112,7 @@ def bulk_timeseries_query( snuba_params: SnubaParams, rollup: int, zerofill_results: bool = True, - allow_metric_aggregates=True, + allow_metric_aggregates: bool = True, comparison_delta: timedelta | None = None, functions_acl: list[str] | None = None, has_metrics: bool = True, @@ -127,7 +133,7 @@ def bulk_timeseries_query( snuba_params: SnubaParams, rollup: int, zerofill_results: bool = True, - allow_metric_aggregates=True, + allow_metric_aggregates: bool = True, comparison_delta: timedelta | None = None, functions_acl: list[str] | None = None, has_metrics: bool = True, @@ -146,7 +152,7 @@ def bulk_timeseries_query( snuba_params: SnubaParams, rollup: int, zerofill_results: bool = True, - allow_metric_aggregates=True, + allow_metric_aggregates: bool = True, comparison_delta: timedelta | None = None, functions_acl: list[str] | None = None, has_metrics: bool = True, @@ -258,7 +264,7 @@ def timeseries_query( snuba_params: SnubaParams, rollup: int, zerofill_results: bool = True, - allow_metric_aggregates=True, + allow_metric_aggregates: bool = True, comparison_delta: timedelta | None = None, functions_acl: list[str] | None = None, has_metrics: bool = True, @@ -278,7 +284,7 @@ def timeseries_query( equations, columns = categorize_columns(selected_columns) metrics_compatible = not equations - def run_metrics_query(inner_params: SnubaParams): + def run_metrics_query(inner_params: SnubaParams) -> dict[str, Any]: with traces.start_span( name="TimeseriesMetricQueryBuilder", attributes={"sentry.op": "mep"} ): @@ -404,28 +410,28 @@ def run_metrics_query(inner_params: SnubaParams): def top_events_timeseries( - timeseries_columns, - selected_columns, - user_query, - snuba_params, - orderby, - rollup, - limit, - organization, - equations=None, - top_events=None, - allow_empty=True, - zerofill_results=True, - include_other=False, - functions_acl=None, - on_demand_metrics_enabled=False, + timeseries_columns: list[str], + selected_columns: list[str], + user_query: str, + snuba_params: SnubaParams, + orderby: list[str], + rollup: int, + limit: int, + organization: Organization, + equations: list[str] | None = None, + top_events: EventsResponse | None = None, + allow_empty: bool = True, + zerofill_results: bool = True, + include_other: bool = False, + functions_acl: list[str] | None = None, + on_demand_metrics_enabled: bool = False, on_demand_metrics_type: MetricSpecType | None = None, query_source: QuerySource | None = None, fallback_to_transactions: bool = False, transform_alias_to_input_format: bool = False, *, referrer: str, -) -> SnubaTSResult | dict[str, Any]: +) -> dict[str, SnubaTSResult] | SnubaTSResult: if top_events is None: top_events = query( selected_columns, @@ -556,24 +562,24 @@ def top_events_timeseries( def histogram_query( - fields, - user_query, - snuba_params, - num_buckets, - precision=0, - min_value=None, - max_value=None, - data_filter=None, - group_by=None, - order_by=None, - limit_by=None, - histogram_rows=None, - extra_conditions=None, - normalize_results=True, + fields: list[str], + user_query: str, + snuba_params: SnubaParams, + num_buckets: int, + precision: int = 0, + min_value: float | None = None, + max_value: float | None = None, + data_filter: Literal["exclude_outliers"] | None = None, + group_by: list[str] | None = None, + order_by: list[str] | None = None, + limit_by: list[str] | None = None, + histogram_rows: int | None = None, + extra_conditions: list[Condition] | None = None, + normalize_results: bool = True, query_source: QuerySource | None = None, *, referrer: str, -): +) -> dict[str, Any] | EventsResponse: """ API for generating histograms for numeric columns. @@ -601,7 +607,7 @@ def histogram_query( """ if data_filter == "exclude_outliers": - if user_query is None: + if cast("str | None", user_query) is None: user_query = INLIER_QUERY_CLAUSE elif INLIER_QUERY_CLAUSE not in user_query: user_query += " " + INLIER_QUERY_CLAUSE @@ -638,14 +644,17 @@ def histogram_query( # TODO: format to match non-metric-result if not normalize_results: - return results + # justification: the builder returns the raw EventsResponse payload. + return cast("dict[str, Any] | EventsResponse", results) result = normalize_histogram_results(fields, histogram_params, results) result["meta"] = {"isMetricsData": True} return result -def normalize_histogram_results(fields, histogram_params, results): +def normalize_histogram_results( + fields: list[str], histogram_params: HistogramParams, results: dict[str, Any] +) -> dict[str, Any]: """ Normalizes the histogram results by renaming the columns to key and bin and make sure to zerofill any missing values. diff --git a/src/sentry/snuba/outcomes.py b/src/sentry/snuba/outcomes.py index deadeab5b5a2..3a4a3281eae3 100644 --- a/src/sentry/snuba/outcomes.py +++ b/src/sentry/snuba/outcomes.py @@ -187,7 +187,7 @@ def map_row(self, row: MutableMapping[str, Any]) -> None: pass -class ReasonDimension(Dimension): +class ReasonDimension(Dimension[str]): def resolve_filter(self, raw_filter: Sequence[str]) -> list[str]: return [ "smart_rate_limit" if reason == "spike_protection" else reason for reason in raw_filter @@ -205,7 +205,7 @@ def map_row(self, row: MutableMapping[str, Any]) -> None: "sum(times_seen)": TimesSeenField(), } -DIMENSION_MAP: Mapping[str, Dimension] = { +DIMENSION_MAP: Mapping[str, Dimension[Any]] = { "outcome": OutcomeDimension("outcome"), "category": CategoryDimension("category"), "reason": ReasonDimension("reason"), @@ -459,8 +459,10 @@ def _outcomes_dataset(rollup: int) -> tuple[Dataset, str]: ] -def _split_rows_groupby(rows, groupby): - groups: dict[frozenset[str], list[object]] = {} +def _split_rows_groupby( + rows: list[dict[str, Any]] | None, groupby: Sequence[SimpleGroupBy] +) -> dict[frozenset[tuple[str, Any]], list[dict[str, Any]]]: + groups: dict[frozenset[tuple[str, Any]], list[dict[str, Any]]] = {} if rows is None: return groups for row in rows: @@ -478,13 +480,16 @@ def _split_rows_groupby(rows, groupby): class _RawSessionsResult(TypingTypedDict): start: str end: str - query: str + query: list[Any] intervals: list[str] groups: list[_StatsGroup] def massage_sessions_result( - query, result_totals, result_timeseries, ts_col="bucketed_started" + query: QueryDefinition, + result_totals: ResultSet, + result_timeseries: ResultSet | None, + ts_col: str = "bucketed_started", ) -> _RawSessionsResult: """ Post-processes the query result. @@ -521,14 +526,17 @@ def massage_sessions_result( total_groups = _split_rows_groupby(result_totals, query.groupby) timeseries_groups = _split_rows_groupby(result_timeseries, query.groupby) - def make_timeseries(rows, group): - for row in rows: - row[ts_col] = row[ts_col][:19] + "Z" + def make_timeseries( + rows: list[dict[str, Any]], group: Mapping[str, Any] + ) -> dict[str, list[float | None]]: + for span_row in rows: + span_row[ts_col] = span_row[ts_col][:19] + "Z" rows.sort(key=lambda row: row[ts_col]) fields: list[tuple[str, Field, list[float | None]]] fields = [(name, field, []) for name, field in query.fields.items()] group_index = 0 + row: dict[str, Any] | None while group_index < len(rows): row = rows[group_index] @@ -549,7 +557,9 @@ def make_timeseries(rows, group): return {name: series for (name, field, series) in fields} - def make_totals(totals, group): + def make_totals( + totals: Sequence[dict[str, Any] | None], group: Mapping[str, Any] + ) -> dict[str, int]: return { name: field.extract_from_row(totals[0], group) for name, field in query.fields.items() } @@ -625,7 +635,9 @@ class _Period(TypingTypedDict): def massage_sessions_result_summary( - query, result_totals, outcome_query=None + query: QueryDefinition, + result_totals: ResultSet, + outcome_query: Sequence[str] | None = None, ) -> tuple[dict[int, dict[str, dict[str, _CategoryStats]]], _Period]: """ Post-processes the query result. @@ -667,13 +679,19 @@ def massage_sessions_result_summary( """ total_groups = _split_rows_groupby(result_totals, query.groupby) - def make_totals(totals, group): + def make_totals( + totals: Sequence[dict[str, Any] | None], group: Mapping[str, Any] + ) -> dict[str, int]: return { name: field.extract_from_row(totals[0], group) for name, field in query.fields.items() } def get_category_stats( - reason, totals, outcome, category, category_stats: _CategoryStats | None = None + reason: str | None, + totals: Mapping[str, int], + outcome: str, + category: str, + category_stats: _CategoryStats | None = None, ) -> _CategoryStats: if not category_stats: category_stats = { @@ -705,14 +723,14 @@ def get_category_stats( keys = set(total_groups.keys()) projects: dict[int, dict[str, dict[str, _CategoryStats]]] = {} - for key in keys: - by = dict(key) + for group_key in keys: + by = dict(group_key) project_id = by["project"] outcome = by["outcome"] category = by["category"] reason = by.get("reason") # optional - totals = make_totals(total_groups.get(key, [None]), by) + totals = make_totals(total_groups.get(group_key, [None]), by) projects.setdefault(project_id, {"categories": {}}) @@ -737,7 +755,7 @@ def get_category_stats( categories = values["categories"] project_dict: _Project = {"id": key, "slug": project_id_to_slug[key], "stats": []} - for key, stats in categories.items(): + for category_key, stats in categories.items(): project_dict["stats"].append(stats) project_dict["stats"].sort(key=lambda d: d["category"]) @@ -751,7 +769,7 @@ def get_category_stats( } -def get_timestamps(query): +def get_timestamps(query: QueryDefinition) -> list[str]: """ Generates a list of timestamps according to `query`. The timestamps are returned as ISO strings for now. diff --git a/src/sentry/snuba/rpc_dataset_common.py b/src/sentry/snuba/rpc_dataset_common.py index 996fb98436fc..96a37914e7c0 100644 --- a/src/sentry/snuba/rpc_dataset_common.py +++ b/src/sentry/snuba/rpc_dataset_common.py @@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass, field, replace from datetime import datetime, timedelta -from typing import Any +from typing import Any, cast import sentry_sdk from django.utils import timezone @@ -844,9 +844,13 @@ def apply_timeseries_transformations( if series is not None else output_timestamps ) - values = ( + values: list[float | None] = ( [ - process_value(point.data if point.data_present or not has_fill else None) + # justification: snuba timeseries data points are numeric. + cast( + "float | None", + process_value(point.data if point.data_present or not has_fill else None), + ) for point in series.data_points ] if series is not None diff --git a/src/sentry/snuba/sessions_v2.py b/src/sentry/snuba/sessions_v2.py index 1aa468a0fd19..7b70e5c7b2a0 100644 --- a/src/sentry/snuba/sessions_v2.py +++ b/src/sentry/snuba/sessions_v2.py @@ -2,9 +2,11 @@ import logging import math +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone -from typing import Protocol +from typing import Any, Protocol, cast +from django.http import QueryDict from snuba_sdk import BooleanCondition, Column, Condition, Function from sentry.api.utils import get_date_range_from_params @@ -90,12 +92,22 @@ class _Field(Protocol): - def extract_from_row(self, row, group) -> float | None: ... - def get_snuba_columns(self, raw_groupby) -> list[str]: ... + # justification: session rows are decoded Snuba results with heterogeneous value shapes. + def extract_from_row( + self, row: Mapping[str, Any] | None, group: Mapping[str, str] + ) -> float | None: ... + def get_snuba_columns(self, raw_groupby: Sequence[str]) -> list[str]: ... + + +def _number(row: Mapping[str, object], key: str) -> float: + value = row[key] + if not isinstance(value, (int, float)): + raise TypeError(f"Expected a numeric {key!r} in the sessions row") + return value class SessionsField: - def get_snuba_columns(self, raw_groupby): + def get_snuba_columns(self, raw_groupby: Sequence[str]) -> list[str]: if "session.status" in raw_groupby: return [ "sessions", @@ -106,75 +118,89 @@ def get_snuba_columns(self, raw_groupby): ] return ["sessions"] - def extract_from_row(self, row, group): + def extract_from_row( + self, row: Mapping[str, Any] | None, group: Mapping[str, str] + ) -> float | None: if row is None: return 0 status = group.get("session.status") if status is None: - return row["sessions"] + return _number(row, "sessions") if status == "healthy": - healthy_sessions = row["sessions"] - row["sessions_errored"] - row["sessions_unhandled"] + healthy_sessions = ( + _number(row, "sessions") + - _number(row, "sessions_errored") + - _number(row, "sessions_unhandled") + ) return max(healthy_sessions, 0) if status == "abnormal": - return row["sessions_abnormal"] + return _number(row, "sessions_abnormal") if status == "unhandled": - return row["sessions_unhandled"] + return _number(row, "sessions_unhandled") if status == "crashed": - return row["sessions_crashed"] + return _number(row, "sessions_crashed") if status == "errored": errored_sessions = ( - row["sessions_errored"] - - row["sessions_unhandled"] - - row["sessions_crashed"] - - row["sessions_abnormal"] + _number(row, "sessions_errored") + - _number(row, "sessions_unhandled") + - _number(row, "sessions_crashed") + - _number(row, "sessions_abnormal") ) return max(errored_sessions, 0) return 0 class UsersField: - def get_snuba_columns(self, raw_groupby): + def get_snuba_columns(self, raw_groupby: Sequence[str]) -> list[str]: if "session.status" in raw_groupby: return ["users", "users_abnormal", "users_crashed", "users_errored", "users_unhandled"] return ["users"] - def extract_from_row(self, row, group): + def extract_from_row( + self, row: Mapping[str, Any] | None, group: Mapping[str, str] + ) -> float | None: if row is None: return 0 status = group.get("session.status") if status is None: - return row["users"] + return _number(row, "users") if status == "healthy": - healthy_users = row["users"] - row["users_errored"] - row["users_unhandled"] + healthy_users = ( + _number(row, "users") + - _number(row, "users_errored") + - _number(row, "users_unhandled") + ) return max(healthy_users, 0) if status == "abnormal": - return row["users_abnormal"] + return _number(row, "users_abnormal") if status == "unhandled": - return row["users_unhandled"] + return _number(row, "users_unhandled") if status == "crashed": - return row["users_crashed"] + return _number(row, "users_crashed") if status == "errored": errored_users = ( - row["users_errored"] - - row["users_crashed"] - - row["users_abnormal"] - - row["users_unhandled"] + _number(row, "users_errored") + - _number(row, "users_crashed") + - _number(row, "users_abnormal") + - _number(row, "users_unhandled") ) return max(errored_users, 0) return 0 -def finite_or_none(val): +def finite_or_none(val: float | None) -> float | None: if isinstance(val, (int, float)) and not math.isfinite(val): return None return val class DurationAverageField: - def get_snuba_columns(self, raw_groupby): + def get_snuba_columns(self, raw_groupby: Sequence[str]) -> list[str]: return ["duration_avg"] - def extract_from_row(self, row, group): + def extract_from_row( + self, row: Mapping[str, Any] | None, group: Mapping[str, str] + ) -> float | None: if row is None: return None status = group.get("session.status") @@ -184,13 +210,15 @@ def extract_from_row(self, row, group): class DurationQuantileField: - def __init__(self, quantile_index): + def __init__(self, quantile_index: int) -> None: self.quantile_index = quantile_index - def get_snuba_columns(self, raw_groupby): + def get_snuba_columns(self, raw_groupby: Sequence[str]) -> list[str]: return ["duration_quantiles"] - def extract_from_row(self, row, group): + def extract_from_row( + self, row: Mapping[str, Any] | None, group: Mapping[str, str] + ) -> float | None: if row is None: return None status = group.get("session.status") @@ -215,7 +243,7 @@ def extract_from_row(self, row, group): class _GroupBy(Protocol): def get_snuba_columns(self) -> list[str]: ... def get_snuba_groupby(self) -> list[str]: ... - def get_keys_for_row(self, row) -> list[tuple[str, str]]: ... + def get_keys_for_row(self, row: Mapping[str, Any]) -> list[tuple[str, str]]: ... class SimpleGroupBy: @@ -229,18 +257,18 @@ def get_snuba_columns(self) -> list[str]: def get_snuba_groupby(self) -> list[str]: return [self.row_name] - def get_keys_for_row(self, row) -> list[tuple[str, str]]: - return [(self.name, row[self.row_name])] + def get_keys_for_row(self, row: Mapping[str, Any]) -> list[tuple[str, str]]: + return [(self.name, cast("str", row[self.row_name]))] class SessionStatusGroupBy: - def get_snuba_columns(self): + def get_snuba_columns(self) -> list[str]: return [] - def get_snuba_groupby(self): + def get_snuba_groupby(self) -> list[str]: return [] - def get_keys_for_row(self, row): + def get_keys_for_row(self, row: Mapping[str, Any]) -> list[tuple[str, str]]: return [ ("session.status", key) for key in ["healthy", "abnormal", "crashed", "errored", "unhandled"] @@ -274,11 +302,11 @@ class QueryDefinition: def __init__( self, - query, - params, + query: QueryDict, + params: Mapping[str, Any], limit: int | None = 0, offset: int | None = 0, - ): + ) -> None: self.query = query.get("query", "") self.raw_fields = raw_fields = query.getlist("field", []) self.raw_groupby = raw_groupby = query.getlist("groupBy", []) @@ -289,7 +317,7 @@ def __init__( if len(raw_fields) == 0: raise InvalidField('Request is missing a "field"') - self.fields = {} + self.fields: dict[str, _Field] = {} for key in raw_fields: if key not in COLUMN_MAP: from sentry.release_health.metrics_sessions_v2 import FIELD_MAP @@ -306,7 +334,7 @@ def __init__( self.fields[key] = COLUMN_MAP[key] - self.groupby = [] + self.groupby: list[_GroupBy] = [] for key in raw_groupby: if key not in GROUPBY_MAP: raise InvalidField(f'Invalid groupBy: "{key}"') @@ -339,7 +367,7 @@ def __init__( query_groupby.update(groupby.get_snuba_groupby()) self.query_groupby = list(query_groupby) - def to_query_builder_dict(self, orderby=None): + def to_query_builder_dict(self, orderby: list[str] | None = None) -> dict[str, Any]: num_intervals = len(get_timestamps(self)) if num_intervals == 0: raise ZeroIntervalsException @@ -364,7 +392,7 @@ def to_query_builder_dict(self, orderby=None): } return query_builder_dict - def get_filter_conditions(self): + def get_filter_conditions(self) -> list[Any]: """ Returns filter conditions for the query to be used for metrics queries, and hence excluding timestamp and organization id condition that are later added by the metrics layer. @@ -387,7 +415,8 @@ def get_filter_conditions(self): return filter_conditions @classmethod - def _check_supported_condition(cls, condition): + # justification: conditions come from the untyped query builder tree. + def _check_supported_condition(cls, condition: Any) -> None: if isinstance(condition, BooleanCondition): for nested_condition in condition.conditions: cls._check_supported_condition(nested_condition) @@ -419,15 +448,15 @@ def __repr__(self) -> str: SNUBA_LIMIT = 5000 -def get_now(): +def get_now() -> datetime: """Wrapper function to make it mockable in unit tests""" return datetime.now(tz=timezone.utc) def get_constrained_date_range( - params, + params: Mapping[str, Any], allowed_resolution: AllowedResolution = AllowedResolution.one_hour, - max_points=MAX_POINTS, + max_points: int = MAX_POINTS, ) -> tuple[datetime, datetime, int]: interval_td = parse_stats_period(params.get("interval", "1h")) interval = int(3600 if interval_td is None else interval_td.total_seconds()) @@ -463,11 +492,11 @@ def get_constrained_date_range( return adjusted_start, adjusted_end, interval -def isoformat_z(date): +def isoformat_z(date: datetime) -> str: return datetime.fromtimestamp(int(date.timestamp())).isoformat() + "Z" -def get_timestamps(query): +def get_timestamps(query: QueryDefinition) -> list[str]: """ Generates a list of timestamps according to `query`. The timestamps are returned as ISO strings for now. diff --git a/src/sentry/snuba/spans_metrics.py b/src/sentry/snuba/spans_metrics.py index 7ed44999561f..a43da40110ca 100644 --- a/src/sentry/snuba/spans_metrics.py +++ b/src/sentry/snuba/spans_metrics.py @@ -1,50 +1,56 @@ +from __future__ import annotations + import logging from datetime import timedelta +from typing import TYPE_CHECKING -from snuba_sdk import Column +from snuba_sdk import Column, Condition from sentry.search.events.builder.spans_metrics import ( SpansMetricsQueryBuilder, TimeseriesSpansMetricsQueryBuilder, TopSpansMetricsQueryBuilder, ) -from sentry.search.events.types import QueryBuilderConfig, SnubaParams +from sentry.search.events.types import EventsResponse, QueryBuilderConfig, SnubaParams from sentry.snuba import discover from sentry.snuba.dataset import Dataset from sentry.snuba.metrics.extraction import MetricSpecType from sentry.snuba.query_sources import QuerySource from sentry.utils.snuba import SnubaTSResult +if TYPE_CHECKING: + from sentry.models.organization import Organization + logger = logging.getLogger(__name__) def query( - selected_columns, - query, - snuba_params=None, - equations=None, - orderby=None, - offset=None, - limit=50, - auto_fields=False, - auto_aggregations=False, - include_equation_fields=False, - allow_metric_aggregates=False, - use_aggregate_conditions=False, - conditions=None, - functions_acl=None, - transform_alias_to_input_format=False, - sample=None, - has_metrics=False, - skip_tag_resolution=False, - extra_columns=None, - on_demand_metrics_enabled=False, + selected_columns: list[str], + query: str, + snuba_params: SnubaParams, + equations: list[str] | None = None, + orderby: list[str] | None = None, + offset: int | None = None, + limit: int = 50, + auto_fields: bool = False, + auto_aggregations: bool = False, + include_equation_fields: bool = False, + allow_metric_aggregates: bool = False, + use_aggregate_conditions: bool = False, + conditions: list[Condition] | None = None, + functions_acl: list[str] | None = None, + transform_alias_to_input_format: bool = False, + sample: float | None = None, + has_metrics: bool = False, + skip_tag_resolution: bool = False, + extra_columns: list[Column] | None = None, + on_demand_metrics_enabled: bool = False, on_demand_metrics_type: MetricSpecType | None = None, - fallback_to_transactions=False, + fallback_to_transactions: bool = False, query_source: QuerySource | None = None, *, referrer: str, -): +) -> EventsResponse: builder = SpansMetricsQueryBuilder( dataset=Dataset.PerformanceMetrics, params={}, @@ -82,7 +88,7 @@ def timeseries_query( snuba_params: SnubaParams, rollup: int, zerofill_results: bool = True, - allow_metric_aggregates=True, + allow_metric_aggregates: bool = True, comparison_delta: timedelta | None = None, functions_acl: list[str] | None = None, has_metrics: bool = True, @@ -144,28 +150,28 @@ def timeseries_query( def top_events_timeseries( - timeseries_columns, - selected_columns, - user_query, - snuba_params, - orderby, - rollup, - limit, - organization, - equations=None, - top_events=None, - allow_empty=True, - zerofill_results=True, - include_other=False, - functions_acl=None, - on_demand_metrics_enabled=False, + timeseries_columns: list[str], + selected_columns: list[str], + user_query: str, + snuba_params: SnubaParams, + orderby: list[str], + rollup: int, + limit: int, + organization: Organization, + equations: list[str] | None = None, + top_events: EventsResponse | None = None, + allow_empty: bool = True, + zerofill_results: bool = True, + include_other: bool = False, + functions_acl: list[str] | None = None, + on_demand_metrics_enabled: bool = False, on_demand_metrics_type: MetricSpecType | None = None, query_source: QuerySource | None = None, fallback_to_transactions: bool = False, transform_alias_to_input_format: bool = False, *, referrer: str, -): +) -> dict[str, SnubaTSResult] | SnubaTSResult: """ High-level API for doing arbitrary user timeseries queries for a limited number of top events diff --git a/src/sentry/snuba/tasks.py b/src/sentry/snuba/tasks.py index 86969cd6894a..38e09db0dc72 100644 --- a/src/sentry/snuba/tasks.py +++ b/src/sentry/snuba/tasks.py @@ -304,7 +304,10 @@ def _create_snql_in_snuba( metrics.incr("snuba.snql.subscription.http.error", tags={"dataset": snuba_query.dataset}) raise SnubaError("HTTP %s response from Snuba!" % response.status) - return orjson.loads(response.data)["subscription_id"] + subscription_id = orjson.loads(response.data)["subscription_id"] + if not isinstance(subscription_id, str): + raise SnubaError("Snuba returned no subscription id") + return subscription_id def _create_rpc_in_snuba( diff --git a/src/sentry/snuba/trace.py b/src/sentry/snuba/trace.py index f27eaaa24848..e821dbce0b20 100644 --- a/src/sentry/snuba/trace.py +++ b/src/sentry/snuba/trace.py @@ -2,7 +2,7 @@ from collections import defaultdict from collections.abc import Mapping, Sequence from datetime import datetime -from typing import Any, Literal, NotRequired, TypedDict +from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict, cast from sentry_sdk import traces @@ -49,6 +49,9 @@ from sentry.utils.snuba import bulk_snuba_queries from sentry.utils.snuba_rpc import table_rpc +if TYPE_CHECKING: + from sentry_sdk._types import AttributeValue as SpanAttributeValue + # Mostly here for testing ERROR_LIMIT = 10_000 @@ -870,8 +873,8 @@ def query_trace_data( id_to_span = {event["id"]: event for event in spans_data} id_to_error: dict[str, Any] = {} - for event in errors_data: - id_to_error.setdefault(event["trace.span"], []).append(event) + for error_event in errors_data: + id_to_error.setdefault(error_event["trace.span"], []).append(error_event) id_to_occurrence = defaultdict(list) with traces.start_span( name="process.occurrence_data", attributes={"sentry.op": "process.occurrence_data"} @@ -879,7 +882,11 @@ def query_trace_data( for event in occurrence_data: offender_span_ids = event["occurrence"].evidence_data.get("offender_span_ids", []) if len(offender_span_ids) == 0: - sdk_span.set_attribute("evidence_data.empty", event["occurrence"].evidence_data) + sdk_span.set_attribute( + "evidence_data.empty", + # justification: the SDK accepts occurrence evidence mappings as span attributes. + cast("SpanAttributeValue", event["occurrence"].evidence_data), + ) for span_id in offender_span_ids: id_to_occurrence[span_id].append(event) with traces.start_span( diff --git a/src/sentry/spans/buffer.py b/src/sentry/spans/buffer.py index 62f5002280d0..50b7d8a34a5c 100644 --- a/src/sentry/spans/buffer.py +++ b/src/sentry/spans/buffer.py @@ -176,7 +176,7 @@ def store(self) -> SpansBufferStore: return SpansBufferStore(self.client, self.assigned_shards, self.slice_id) # make it pickleable - def __reduce__(self): + def __reduce__(self) -> tuple[type[SpansBuffer], tuple[list[int], int | None]]: return (SpansBuffer, (self.assigned_shards, self.slice_id)) def _get_debug_trace_logger(self) -> DebugTraceLogger: @@ -185,7 +185,7 @@ def _get_debug_trace_logger(self) -> DebugTraceLogger: return self._debug_trace_logger @metrics.wraps("spans.buffer.process_spans") - def process_spans(self, spans: Sequence[Span], now: int): + def process_spans(self, spans: Sequence[Span], now: int) -> None: """ :param spans: List of to-be-ingested spans. :param now: The current time to be used for setting expiration/flush @@ -402,7 +402,7 @@ def _decompress_batch(self, compressed_data: bytes) -> list[bytes]: decompressed_buffer = self._zstd_decompressor.decompress(compressed_data) return decompressed_buffer.split(b"\x00") - def record_stored_segments(self): + def record_stored_segments(self) -> None: with metrics.timer("spans.buffer.get_stored_segments"): with self.client.pipeline(transaction=False) as p: for shard in self.assigned_shards: @@ -630,7 +630,7 @@ def _record_segment_loss_metrics( # worst-case. metrics.incr("spans.buffer.empty_segments") - def done_flush_segments(self, segment_keys: dict[SegmentKey, FlushedSegment]): + def done_flush_segments(self, segment_keys: dict[SegmentKey, FlushedSegment]) -> None: metrics.timing("spans.buffer.done_flush_segments.num_segments", len(segment_keys)) with metrics.timer("spans.buffer.done_flush_segments"): self.store.cleanup_flushed_segments(segment_keys) diff --git a/src/sentry/spans/consumers/process/factory.py b/src/sentry/spans/consumers/process/factory.py index 0a81d7584bee..59b8b539c2fe 100644 --- a/src/sentry/spans/consumers/process/factory.py +++ b/src/sentry/spans/consumers/process/factory.py @@ -230,6 +230,8 @@ def process_batch( metrics.timing("spans.buffer.process_batch.decode", decode_time) assert min_timestamp is not None + if not isinstance(min_timestamp, int): + raise TypeError("Batch minimum timestamp is not an integer") buffer.process_spans(spans, now=min_timestamp) return min_timestamp diff --git a/src/sentry/spans/consumers/process/flusher.py b/src/sentry/spans/consumers/process/flusher.py index ae97bebda9c4..11c28c065403 100644 --- a/src/sentry/spans/consumers/process/flusher.py +++ b/src/sentry/spans/consumers/process/flusher.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import multiprocessing import multiprocessing.context @@ -7,6 +9,7 @@ from collections.abc import Callable from concurrent.futures import Future from functools import partial +from multiprocessing.sharedctypes import Synchronized from typing import Any import orjson @@ -93,7 +96,7 @@ def __init__( for process_index in self.process_to_shards_map.keys(): self._wait_for_process_to_become_healthy(process_index) - def _wait_for_process_to_become_healthy(self, process_index: int): + def _wait_for_process_to_become_healthy(self, process_index: int) -> None: start_time = time.time() max_unhealthy_seconds = options.get("spans.buffer.flusher.max-unhealthy-seconds") * 2 @@ -117,12 +120,12 @@ def _wait_for_process_to_become_healthy(self, process_index: int): time.sleep(0.1) - def _create_processes(self): + def _create_processes(self) -> None: # Create processes based on shard mapping for process_index, shards in self.process_to_shards_map.items(): self._create_process_for_shards(process_index, shards) - def _create_process_for_shards(self, process_index: int, shards: list[int]): + def _create_process_for_shards(self, process_index: int, shards: list[int]) -> None: use_stuck_detector = options.get("spans.buffer.flusher.use-stuck-detector") self.process_healthy_since[process_index].value = 0 @@ -166,7 +169,7 @@ def _create_process_for_shards(self, process_index: int, shards: list[int]): self.processes[process_index] = process self.buffers[process_index] = shard_buffer - def _create_process_for_shard(self, shard: int): + def _create_process_for_shard(self, shard: int) -> None: # Find which process this shard belongs to and restart that process for process_index, shards in self.process_to_shards_map.items(): if shard in shards: @@ -177,10 +180,10 @@ def _create_process_for_shard(self, shard: int): def main( buffer: SpansBuffer, shards: list[int], - stopped, - current_drift, - backpressure_since, - healthy_since, + stopped: Synchronized[int], + current_drift: Synchronized[int], + backpressure_since: Synchronized[int], + healthy_since: Synchronized[int], produce_to_pipe: ProduceToPipe | None, ) -> None: logger.info("Flusher process main started for shards %s", shards) @@ -446,7 +449,7 @@ def close(self) -> None: # memory spike in redis self.next_step.close() - def join(self, timeout: float | None = None): + def join(self, timeout: float | None = None) -> None: # set stopped flag first so we can "flush" the background threads while # next_step is also shutting down. we can do two things at once! self.stopped.value = True diff --git a/src/sentry/spans/consumers/process_segments/enrichment.py b/src/sentry/spans/consumers/process_segments/enrichment.py index 8e52cbfb18a4..6632194e509c 100644 --- a/src/sentry/spans/consumers/process_segments/enrichment.py +++ b/src/sentry/spans/consumers/process_segments/enrichment.py @@ -185,9 +185,8 @@ def _find_ancestor_agent_name(self, span: SpanEvent) -> str | None: Returns the first agent name found, or None if no ancestor has one. """ for ancestor in islice(self._iter_ancestors(span), MAX_AGENT_NAME_ANCESTOR_HOPS): - if ( - agent_name := attribute_value(ancestor, ATTRIBUTE_NAMES.GEN_AI_AGENT_NAME) - ) is not None: + agent_name = attribute_value(ancestor, ATTRIBUTE_NAMES.GEN_AI_AGENT_NAME) + if isinstance(agent_name, str): return agent_name return None diff --git a/src/sentry/spans/consumers/process_segments/message.py b/src/sentry/spans/consumers/process_segments/message.py index 7c0bf9f27193..92e29a874432 100644 --- a/src/sentry/spans/consumers/process_segments/message.py +++ b/src/sentry/spans/consumers/process_segments/message.py @@ -329,7 +329,7 @@ def _create_models( with metrics.timer("spans.consumers.process_segments.create_models.record_release"): # Record the release for dynamic sampling - record_latest_release(project, release, environment) + record_latest_release(project, release, environment_name) # Record onboarding signals record_release_received(project, release.version) diff --git a/src/sentry/spans/consumers/process_segments/shim.py b/src/sentry/spans/consumers/process_segments/shim.py index 25923049f15e..3d186eb6f162 100644 --- a/src/sentry/spans/consumers/process_segments/shim.py +++ b/src/sentry/spans/consumers/process_segments/shim.py @@ -6,7 +6,7 @@ import logging import uuid -from typing import Any +from typing import Any, cast from urllib.parse import parse_qsl from sentry_conventions.attributes import ATTRIBUTE_NAMES @@ -159,12 +159,16 @@ def make_compatible(span: SpanEvent) -> CompatibleSpan: # logic. sentry_tags = _extract_attribute_values(span, SPAN_SENTRY_TAGS_FIELDS_BY_ATTRIBUTE_NAME) - ret: CompatibleSpan = { - **span, - "sentry_tags": {key: str(value) for key, value in sentry_tags.items()}, - "op": get_span_op(span), - "exclusive_time": attribute_value(span, "sentry.exclusive_time_ms"), - } + # justification: mypy cannot prove a SpanEvent spread fills CompatibleSpan's required keys. + ret = cast( + "CompatibleSpan", + { + **span, + "sentry_tags": {key: str(value) for key, value in sentry_tags.items()}, + "op": get_span_op(span), + "exclusive_time": attribute_value(span, "sentry.exclusive_time_ms"), + }, + ) return ret diff --git a/src/sentry/spans/grouping/strategy/base.py b/src/sentry/spans/grouping/strategy/base.py index b2022110c908..7892fe3c5684 100644 --- a/src/sentry/spans/grouping/strategy/base.py +++ b/src/sentry/spans/grouping/strategy/base.py @@ -19,6 +19,8 @@ class Span(TypedDict): timestamp: float same_process_as_parent: bool op: str + project_id: NotRequired[int] + organization_id: NotRequired[int] description: str | None fingerprint: Sequence[str] | None tags: Any | None diff --git a/src/sentry/spans/log_analyzer/fetchers.py b/src/sentry/spans/log_analyzer/fetchers.py index ab0ab75d3a06..a87bc5a67ac3 100644 --- a/src/sentry/spans/log_analyzer/fetchers.py +++ b/src/sentry/spans/log_analyzer/fetchers.py @@ -1,10 +1,10 @@ from __future__ import annotations import logging -from collections.abc import Iterable +from collections.abc import Callable, Iterable from dataclasses import dataclass from datetime import datetime -from typing import Any +from typing import Any, Protocol, cast from dateutil import parser as dateutil_parser @@ -48,6 +48,10 @@ class FlusherTraceData: bytes_flushed: int +class _LoggingClient(Protocol): + def list_entries(self, **kwargs: object) -> Iterable[object]: ... + + @dataclass class LogEntry: """Parsed representation of a single GCP log entry.""" @@ -164,7 +168,7 @@ def fetch_logs_from_file( def _parse_buffer_file_entries( - raw_entries: list[dict], + raw_entries: list[dict[str, Any]], # justification: decoded log JSON entries. start_time: datetime | None, end_time: datetime | None, consumer: str | None, @@ -194,7 +198,7 @@ def _parse_buffer_file_entries( def _parse_flusher_file_entries( - raw_entries: list[dict], + raw_entries: list[dict[str, Any]], # justification: decoded log JSON entries. start_time: datetime | None, end_time: datetime | None, consumer: str | None, @@ -309,7 +313,9 @@ def fetch_logs_from_gcp( event_type = BUFFER_LOG_MESSAGE if log_type == "buffer" else FLUSHER_LOG_MESSAGE - client = gcp_logging.Client(project=gcp_project) + # justification: google.cloud does not annotate its logging client constructor. + logging_client_cls = cast("Callable[..., _LoggingClient]", gcp_logging.Client) + client = logging_client_cls(project=gcp_project) filter_str = build_gcp_filter( event_type=event_type, diff --git a/src/sentry/stacktraces/functions.py b/src/sentry/stacktraces/functions.py index b9b7946270e3..9ee90c74f1de 100644 --- a/src/sentry/stacktraces/functions.py +++ b/src/sentry/stacktraces/functions.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from collections.abc import Callable +from collections.abc import Callable, Mapping from typing import Any from urllib.parse import urlparse @@ -100,7 +100,7 @@ def replace_enclosed_string( return "".join(rv) -def split_func_tokens(s): +def split_func_tokens(s: str) -> list[str]: if s and _function_token_break_re.search(s) is None: return [s] @@ -132,7 +132,7 @@ def split_func_tokens(s): return ["".join(x) for x in rv] -def trim_function_name(function, platform, normalize_lambdas=True): +def trim_function_name(function: str, platform: str | None, normalize_lambdas: bool = True) -> str: """Given a function value from the frame's function attribute this returns a trimmed version that can be stored in `function_name`. This is only used if the client did not supply a value itself already. @@ -144,7 +144,7 @@ def trim_function_name(function, platform, normalize_lambdas=True): return function -def trim_csharp_function_name(function): +def trim_csharp_function_name(function: str) -> str: """This trims off signatures from C# frames. This takes advantage of the Unity not emitting any return values and using a space before the argument list. @@ -157,7 +157,9 @@ def trim_csharp_function_name(function): return function.split(" (", 1)[0] -def trim_native_function_name(function, platform, normalize_lambdas=True): +def trim_native_function_name( + function: str, platform: str | None, normalize_lambdas: bool = True +) -> str: if function in ("", ""): return function @@ -169,7 +171,7 @@ def trim_native_function_name(function, platform, normalize_lambdas=True): return function # Remove special `[clone .foo]` annotations for cloned/split functions - def process_brackets(value, start): + def process_brackets(value: str, start: int) -> str: if value.startswith("clone ."): return "" return "[%s]" % value @@ -214,7 +216,7 @@ def process_brackets(value, start): function = _anon_namespace_re.sub("〔anonymousnamespace〕::", function) # Remove the arguments if there is one. - def process_args(value, start): + def process_args(value: str, start: int) -> str: value = value.strip() if value in ("anonymous namespace", "operator"): return "(%s)" % value @@ -228,7 +230,7 @@ def process_args(value, start): # Resolve generic types, but special case rust which uses things like # ::baz to denote traits. - def process_generics(value, start): + def process_generics(value: str, start: int) -> str: # Special case for lambdas if value == "lambda" or _lambda_re.match(value): return "<%s>" % value @@ -318,7 +320,9 @@ def process_generics(value, start): return _windecl_hash.sub("\\1", function) if "@" in function else function -def get_function_name_for_frame(frame, platform=None): +def get_function_name_for_frame( + frame: Frame | Mapping[str, Any], platform: str | None = None +) -> str | None: """Given a frame object or dictionary this returns the actual function name trimmed. """ @@ -333,6 +337,7 @@ def get_function_name_for_frame(frame, platform=None): rv = frame.get("function") if rv: return trim_function_name(rv, frame.get("platform") or platform) + return None def get_source_link_for_frame(frame: Frame) -> str | None: @@ -341,7 +346,7 @@ def get_source_link_for_frame(frame: Frame) -> str | None: stacktrace link. Otherwise, return the link as is. """ source_link = getattr(frame, "source_link", None) - if source_link is None: + if not isinstance(source_link, str): return None try: diff --git a/src/sentry/stacktraces/processing.py b/src/sentry/stacktraces/processing.py index f7c9eb73c2a6..84d783f6c9ef 100644 --- a/src/sentry/stacktraces/processing.py +++ b/src/sentry/stacktraces/processing.py @@ -2,7 +2,7 @@ import logging import re -from collections.abc import Mapping, MutableMapping, Sequence +from collections.abc import Callable, Mapping, MutableMapping, Sequence from typing import TYPE_CHECKING, Any, NamedTuple from urllib.parse import urlparse @@ -44,10 +44,10 @@ def get_frames(self) -> Sequence[dict[str, Any]]: return _safe_get_frames(self.stacktrace) -def _safe_get_frames(stacktrace) -> Sequence[dict[str, Any]]: +def _safe_get_frames(stacktrace: Mapping[str, Any] | None) -> Sequence[dict[str, Any]]: frames = [] if stacktrace and stacktrace.get("frames"): - frames = [frame for frame in stacktrace.get("frames") if frame] + frames = [frame for frame in stacktrace.get("frames") or () if frame] return frames @@ -309,7 +309,9 @@ def _trim_function_name(frame: dict[str, Any], platform: str | None) -> None: frame["function"] = trimmed_function -def get_crash_frame_from_event_data(data, frame_filter=None): +def get_crash_frame_from_event_data( + data: Mapping[str, Any], frame_filter: Callable[[dict[str, Any]], bool] | None = None +) -> dict[str, Any] | None: """ Return the highest (closest to the crash) in-app frame in the top stacktrace which doesn't fail the given filter test. @@ -335,7 +337,7 @@ def get_crash_frame_from_event_data(data, frame_filter=None): default = None for frame in reversed(frames or ()): - if frame is None: + if not isinstance(frame, dict): continue if frame_filter is not None: if not frame_filter(frame): @@ -347,3 +349,4 @@ def get_crash_frame_from_event_data(data, frame_filter=None): if default: return default + return None diff --git a/src/sentry/statistical_detectors/detector.py b/src/sentry/statistical_detectors/detector.py index e224f2d04fa1..3aad54cafe46 100644 --- a/src/sentry/statistical_detectors/detector.py +++ b/src/sentry/statistical_detectors/detector.py @@ -4,7 +4,7 @@ import logging from abc import ABC, abstractmethod from collections import defaultdict -from collections.abc import Generator, Iterable +from collections.abc import Generator, Iterable, Mapping from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import ClassVar @@ -70,7 +70,7 @@ class RegressionDetector(ABC): def min_throughput_threshold(cls) -> int: ... @classmethod - def configure_tags(cls): + def configure_tags(cls) -> None: sentry_sdk.set_tag("regression.source", cls.source) sentry_sdk.set_attribute("regression.source", cls.source) sentry_sdk.set_tag("regression.kind", cls.source) @@ -82,7 +82,9 @@ def detector_algorithm_factory(cls) -> DetectorAlgorithm: ... @classmethod @abstractmethod - def detector_store_factory(cls) -> DetectorStore: ... + def detector_store_factory( + cls, + ) -> DetectorStore[Mapping[str | bytes, bytes | float | int | str]]: ... @classmethod def all_payloads( @@ -109,7 +111,7 @@ def query_payloads( @classmethod def detect_trends( - cls, projects: list[Project], start: datetime, batch_size=100 + cls, projects: list[Project], start: datetime, batch_size: int = 100 ) -> Generator[TrendBundle]: unique_project_ids: set[int] = set() @@ -200,7 +202,11 @@ def detect_trends( @classmethod def all_timeseries( - cls, objects: list[tuple[Project, int | str]], start: datetime, function: str, chunk_size=25 + cls, + objects: list[tuple[Project, int | str]], + start: datetime, + function: str, + chunk_size: int = 25, ) -> Generator[tuple[int, int | str, SnubaTSResult]]: # Snuba allows 10,000 data points per request. 14 days * 1hr * 24hr = # 336 data points per transaction name, so we can safely get 25 transaction @@ -226,7 +232,7 @@ def detect_regressions( objects: list[tuple[Project, int | str]], start: datetime, function: str, - timeseries_per_batch=10, + timeseries_per_batch: int = 10, viewer_context: SeerViewerContext | None = None, ) -> Generator[BreakpointData]: serializer = SnubaTSResultSerializer(None, None, None) @@ -317,7 +323,7 @@ def make_status_change_message( def get_regression_groups( cls, bundles: Generator[TrendBundle], - batch_size=100, + batch_size: int = 100, ) -> Generator[TrendBundle]: for trend_chunk in chunked(bundles, batch_size): active_regression_groups = { @@ -355,7 +361,7 @@ def redirect_resolutions( cls, bundles: Generator[TrendBundle], timestamp: datetime, - batch_size=100, + batch_size: int = 100, ) -> Generator[TrendBundle]: groups_to_resolve = [] @@ -403,7 +409,7 @@ def redirect_escalations( cls, bundles: Generator[TrendBundle], timestamp: datetime, - batch_size=100, + batch_size: int = 100, ) -> Generator[TrendBundle]: escalated = 0 @@ -524,7 +530,7 @@ def _log_missing_issue_group( def _filter_escalating_groups( cls, bundles_to_escalate: list[TrendBundle], - batch_size=100, + batch_size: int = 100, ) -> Generator[tuple[TrendBundle, bool]]: now = django_timezone.now() @@ -584,7 +590,7 @@ def _filter_escalating_groups( def get_regression_versions( cls, regressions: Generator[BreakpointData], - batch_size=100, + batch_size: int = 100, ) -> Generator[tuple[int, datetime | None, BreakpointData]]: now = django_timezone.now() active_regressions = [] @@ -667,7 +673,7 @@ def get_regression_versions( def save_regressions_with_versions( cls, regressions: Generator[BreakpointData], - batch_size=100, + batch_size: int = 100, ) -> Generator[BreakpointData]: versioned_regressions = cls.get_regression_versions(regressions) diff --git a/src/sentry/statistical_detectors/issue_platform_adapter.py b/src/sentry/statistical_detectors/issue_platform_adapter.py index c42c309ba552..7f47792232d5 100644 --- a/src/sentry/statistical_detectors/issue_platform_adapter.py +++ b/src/sentry/statistical_detectors/issue_platform_adapter.py @@ -9,7 +9,7 @@ from sentry.utils import metrics -def fingerprint_regression(transaction, full=False): +def fingerprint_regression(transaction: str | int, full: bool = False) -> str: prehashed_fingerprint = f"p95_transaction_duration_regression-{transaction}" fingerprint = hashlib.sha1((prehashed_fingerprint).encode()).hexdigest() if not full: @@ -17,7 +17,7 @@ def fingerprint_regression(transaction, full=False): return fingerprint -def send_regression_to_platform(regression: BreakpointData): +def send_regression_to_platform(regression: BreakpointData) -> None: current_timestamp = datetime.now(timezone.utc) displayed_old_baseline = round(float(regression["aggregate_range_1"]), 2) diff --git a/src/sentry/statistical_detectors/redis.py b/src/sentry/statistical_detectors/redis.py index ea50e11f51f3..1ee475bfe14c 100644 --- a/src/sentry/statistical_detectors/redis.py +++ b/src/sentry/statistical_detectors/redis.py @@ -24,11 +24,11 @@ class FunctionChangePointQueue: ready_key = "sd:fncp:{queue}:ready" - def __init__(self, client: RedisCluster | StrictRedis | None = None): + def __init__(self, client: RedisCluster[str] | StrictRedis[str] | None = None): self._client = client @property - def client(self) -> RedisCluster | StrictRedis: + def client(self) -> RedisCluster[str] | StrictRedis[str]: if self._client is None: self._client = RedisDetectorStore.get_redis_client() return self._client @@ -98,22 +98,22 @@ def acknowledge(self, candidates: list[tuple[int, str, datetime]]) -> None: pipeline.execute() -class RedisDetectorStore(DetectorStore): +class RedisDetectorStore(DetectorStore[Mapping[str | bytes, bytes | float | int | str]]): def __init__( self, regression_type: RegressionType, - client: RedisCluster | StrictRedis | None = None, - ttl=STATE_TTL, - ): + client: RedisCluster[str] | StrictRedis[str] | None = None, + ttl: int = STATE_TTL, + ) -> None: self.regression_type = regression_type self.ttl = ttl - self._client: RedisCluster | StrictRedis | None = None + self._client: RedisCluster[str] | StrictRedis[str] | None = None @property def client( self, - client: RedisCluster | StrictRedis | None = None, - ) -> RedisCluster | StrictRedis: + client: RedisCluster[str] | StrictRedis[str] | None = None, + ) -> RedisCluster[str] | StrictRedis[str]: if self._client is None: self._client = self.get_redis_client() if client is None else client return self._client @@ -151,5 +151,5 @@ def make_key(self, payload: DetectorPayload) -> str: ) @staticmethod - def get_redis_client() -> RedisCluster | StrictRedis: + def get_redis_client() -> RedisCluster[str] | StrictRedis[str]: return redis.redis_clusters.get(settings.SENTRY_STATISTICAL_DETECTORS_REDIS_CLUSTER) diff --git a/src/sentry/statistical_detectors/store.py b/src/sentry/statistical_detectors/store.py index 12499bae2878..63e934ce1c54 100644 --- a/src/sentry/statistical_detectors/store.py +++ b/src/sentry/statistical_detectors/store.py @@ -11,4 +11,6 @@ class DetectorStore(ABC, Generic[T]): def bulk_read_states(self, payloads: list[DetectorPayload]) -> list[T]: ... @abstractmethod - def bulk_write_states(self, payloads: list[DetectorPayload], states: list[T]) -> None: ... + def bulk_write_states( + self, payloads: list[DetectorPayload], states: list[T | None] + ) -> None: ... diff --git a/src/sentry/synapse/paginator.py b/src/sentry/synapse/paginator.py index 069ad5a7a777..61f4d6974103 100644 --- a/src/sentry/synapse/paginator.py +++ b/src/sentry/synapse/paginator.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from typing import Any -from django.db.models import Q, QuerySet +from django.db.models import Model, Q, QuerySet from django.db.models.functions import TruncSecond from rest_framework.exceptions import ParseError @@ -58,7 +58,7 @@ class SynapsePaginator: def __init__( self, - queryset: QuerySet, + queryset: QuerySet[Model], id_field: str, timestamp_field: str, ) -> None: diff --git a/src/sentry/tagstore/base.py b/src/sentry/tagstore/base.py index dc5aca890e08..e606e626cd30 100644 --- a/src/sentry/tagstore/base.py +++ b/src/sentry/tagstore/base.py @@ -10,8 +10,9 @@ from sentry.utils.services import Service if TYPE_CHECKING: + from sentry.api.paginator import SequencePaginator from sentry.models.group import Group - from sentry.tagstore.types import GroupTagKey, GroupTagValue, TagKey + from sentry.tagstore.types import GroupTagKey, GroupTagValue, TagKey, TagValue # Valid pattern for tag key names TAG_KEY_RE = re.compile(r"^[a-zA-Z0-9_\.:-]+$") @@ -87,7 +88,7 @@ def get_standardized_key(self, key: str) -> str: def get_tag_key_label(self, key: str) -> str: return TAG_LABELS.get(key) or key.replace("_", " ").title() - def get_tag_value_label(self, key: str, value) -> str: + def get_tag_value_label(self, key: str, value: str | None) -> str | None: label = value if key == "sentry:user" and value: @@ -103,7 +104,12 @@ def get_tag_value_label(self, key: str, value) -> str: return label def get_tag_key( - self, project_id, environment_id, key: str, status=TagKeyStatus.ACTIVE, tenant_ids=None + self, + project_id: int, + environment_id: int | None, + key: str, + status: int = TagKeyStatus.ACTIVE, + tenant_ids: dict[str, int | str] | None = None, ) -> GroupTagKey | TagKey: """ >>> get_tag_key(1, 2, "key1") @@ -112,12 +118,15 @@ def get_tag_key( def get_tag_keys( self, - project_id, - environment_id, - status=TagKeyStatus.ACTIVE, - include_values_seen=False, - tenant_ids=None, - ): + project_id: int, + environment_id: int | None, + status: int = TagKeyStatus.ACTIVE, + include_values_seen: bool = False, + tenant_ids: dict[str, int | str] | None = None, + *, + start: datetime | None = None, + end: datetime | None = None, + ) -> set[TagKey | GroupTagKey]: """ >>> get_tag_keys(1, 2) """ @@ -125,21 +134,27 @@ def get_tag_keys( def get_tag_keys_for_projects( self, - projects, - environments, - start, - end, + projects: Sequence[int], + environments: Sequence[str] | None, + start: datetime, + end: datetime, dataset: Dataset = Dataset.Events, - status=TagKeyStatus.ACTIVE, + status: int = TagKeyStatus.ACTIVE, use_cache: bool = False, - tenant_ids=None, - ): + tenant_ids: dict[str, int | str] | None = None, + ) -> set[TagKey | GroupTagKey]: """ >>> get_tag_key([1], [2]) """ raise NotImplementedError - def get_tag_values(self, project_id, environment_id, key: str, tenant_ids=None): + def get_tag_values( + self, + project_id: int, + environment_id: int | None, + key: str, + tenant_ids: dict[str, int | str] | None = None, + ) -> set[TagValue]: """ >>> get_tag_values(1, 2, "key1") """ @@ -147,10 +162,10 @@ def get_tag_values(self, project_id, environment_id, key: str, tenant_ids=None): def get_group_tag_key( self, - group, - environment_id, + group: Group, + environment_id: int | None, key: str, - tenant_ids=None, + tenant_ids: dict[str, int | str] | None = None, ) -> GroupTagKey | TagKey: """ >>> get_group_tag_key(group, 3, "key1") @@ -158,37 +173,54 @@ def get_group_tag_key( raise NotImplementedError def get_group_tag_keys( - self, group, environment_ids, limit=None, keys: list[str] | None = None, tenant_ids=None - ): + self, + group: Group, + environment_ids: Sequence[int] | None, + limit: int | None = None, + keys: list[str] | None = None, + tenant_ids: dict[str, int | str] | None = None, + ) -> set[TagKey | GroupTagKey]: """ >>> get_group_tag_key(group, 2, [3]) """ raise NotImplementedError def get_group_list_tag_value( - self, project_ids, group_id_list, environment_ids, key: str, value, tenant_ids=None - ): + self, + project_ids: Sequence[int], + group_id_list: Sequence[int], + environment_ids: Sequence[int], + key: str, + value: str, + tenant_ids: dict[str, int | str] | None = None, + ) -> dict[int, GroupTagValue]: """ >>> get_group_list_tag_value([1, 2], [1, 2, 3, 4, 5], [3], "key1", "value1") """ raise NotImplementedError def get_generic_group_list_tag_value( - self, project_ids, group_id_list, environment_ids, key: str, value, tenant_ids=None - ): + self, + project_ids: Sequence[int], + group_id_list: Sequence[int], + environment_ids: Sequence[int], + key: str, + value: str, + tenant_ids: dict[str, int | str] | None = None, + ) -> dict[int, GroupTagValue]: raise NotImplementedError def get_tag_value_paginator( self, - project_id, - environment_id, + project_id: int, + environment_id: int | None, key: str, - start=None, - end=None, - query=None, - order_by="-last_seen", - tenant_ids=None, - ): + start: datetime | None = None, + end: datetime | None = None, + query: str | None = None, + order_by: str = "-last_seen", + tenant_ids: dict[str, int | str] | None = None, + ) -> SequencePaginator[TagValue]: """ >>> get_tag_value_paginator(1, 2, 'environment', query='prod') """ @@ -196,19 +228,19 @@ def get_tag_value_paginator( def get_tag_value_paginator_for_projects( self, - projects, - environments, + projects: Sequence[int], + environments: Sequence[int] | None, key: str, - start=None, - end=None, + start: datetime | None = None, + end: datetime | None = None, dataset: Dataset | None = None, - query=None, - order_by="-last_seen", + query: str | None = None, + order_by: str = "-last_seen", include_transactions: bool = False, include_sessions: bool = False, include_replays: bool = False, - tenant_ids=None, - ): + tenant_ids: dict[str, int | str] | None = None, + ) -> SequencePaginator[TagValue]: """ Includes tags and also snuba columns, with the arrayjoin when they are nested. Also supports a query parameter to do a substring match on the tag/column values. @@ -233,12 +265,12 @@ def get_group_tag_value_iter( def get_group_tag_value_paginator( self, - group, - environment_ids, + group: Group, + environment_ids: Sequence[int] | None, key: str, - order_by="-id", - tenant_ids=None, - ): + order_by: str = "-id", + tenant_ids: dict[str, int | str] | None = None, + ) -> SequencePaginator[GroupTagValue]: """ >>> get_group_tag_value_paginator(group, 3, 'environment') """ @@ -274,11 +306,11 @@ def get_generic_groups_user_counts( def get_group_tag_value_count( self, - group, - environment_id, + group: Group, + environment_id: int | None, key: str, - tenant_ids=None, - ): + tenant_ids: dict[str, int | str] | None = None, + ) -> int: """ >>> get_group_tag_value_count(group, 3, 'key1') """ @@ -286,30 +318,36 @@ def get_group_tag_value_count( def get_top_group_tag_values( self, - group, - environment_id, + group: Group, + environment_id: int | None, key: str, - limit=TOP_VALUES_DEFAULT_LIMIT, - tenant_ids=None, - ): + limit: int = TOP_VALUES_DEFAULT_LIMIT, + tenant_ids: dict[str, int | str] | None = None, + ) -> tuple[TagValue, ...] | tuple[GroupTagValue, ...] | None: """ >>> get_top_group_tag_values(group, 3, 'key1') """ raise NotImplementedError - def get_first_release(self, project_id, group_id): + def get_first_release(self, project_id: int, group_id: int) -> str | None: """ >>> get_first_release(1, 2) """ raise NotImplementedError - def get_last_release(self, project_id, group_id): + def get_last_release(self, project_id: int, group_id: int) -> str | None: """ >>> get_last_release(1, 2) """ raise NotImplementedError - def get_release_tags(self, organization_id, project_ids, environment_id, versions): + def get_release_tags( + self, + organization_id: int, + project_ids: Sequence[int], + environment_id: int | None, + versions: Sequence[str], + ) -> set[TagValue]: """ >>> get_release_tags([1, 2], 3, ["1", "2"]) """ @@ -317,13 +355,13 @@ def get_release_tags(self, organization_id, project_ids, environment_id, version def get_group_tag_keys_and_top_values( self, - group, - environment_ids, + group: Group, + environment_ids: Sequence[int] | None, keys: list[str] | None = None, - value_limit=TOP_VALUES_DEFAULT_LIMIT, - tenant_ids=None, - **kwargs, - ): + value_limit: int = TOP_VALUES_DEFAULT_LIMIT, + tenant_ids: dict[str, int | str] | None = None, + **kwargs: object, + ) -> set[TagKey | GroupTagKey]: # only the snuba backend supports multi env, and that overrides this method if environment_ids and len(environment_ids) > 1: environment_ids = environment_ids[:1] diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py index d08128fb93f1..a18f418f7de6 100644 --- a/src/sentry/tagstore/snuba/backend.py +++ b/src/sentry/tagstore/snuba/backend.py @@ -7,7 +7,7 @@ from collections import defaultdict from collections.abc import Iterable, MutableMapping, Sequence from datetime import datetime, timedelta, timezone -from typing import Any, Never, Protocol, TypedDict +from typing import Any, Never, Protocol, TypedDict, cast from dateutil.parser import parse as parse_datetime from django.core.cache import cache @@ -39,6 +39,7 @@ from sentry.models.releaseenvironment import ReleaseEnvironment from sentry.models.releaseprojectenvironment import ReleaseProjectEnvironment from sentry.models.releases.release_project import ReleaseProject +from sentry.models.releases.util import ReleaseQuerySet from sentry.replays.query import query_replays_dataset_tagkey_values from sentry.search.eap.columns import datetime_processor from sentry.search.eap.occurrences.common_queries import count_occurrences @@ -108,15 +109,16 @@ 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) -def fix_tag_value_data(data): +# justification: heterogeneous Snuba JSON +def fix_tag_value_data(data: dict[str, Any]) -> dict[str, Any]: for key, transformer in tag_value_data_transformers.items(): if key in data: data[key] = transformer(data[key]).replace(tzinfo=timezone.utc) @@ -128,7 +130,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} @@ -138,7 +140,10 @@ def _translate_filter_keys( filter_keys["group_id"] = group_ids forward, reverse = get_snuba_translators(filter_keys, is_grouprelease=False) - return forward(filter_keys) + translated = forward(filter_keys) + if not isinstance(translated, dict): + raise TypeError("Snuba translator returned no mapping") + return translated def _reasonable_user_counts_match(control: dict[int, int], experimental: dict[int, int]) -> bool: @@ -276,7 +281,12 @@ class SnubaTagStorage(TagStorage): format_string = "tags[{}]" def __eap_get_tags_for_group( - self, tag_key: str, group: Group, environment_id: int | None, limit: int | None, **kwargs + self, + tag_key: str, + group: Group, + environment_id: int | None, + limit: int | None, + **kwargs: Any, # justification: framework kwargs ) -> GroupTagKey: """ tag_key should be unformatted (i.e. "foo" rather than "tags[foo]") @@ -385,17 +395,18 @@ def __eap_get_tags_for_group( def __get_tag_key_and_top_values( self, - project_id, + project_id: int, group: Group | None, environment_id: int | None, - key, + key: str, limit: int | None = 3, - raise_on_empty=True, - tenant_ids=None, - **kwargs, + raise_on_empty: bool = True, + tenant_ids: dict[str, int | str] | None = None, + **kwargs: Any, # justification: framework kwargs ) -> GroupTagKey | TagKey: tag = self.format_string.format(key) - filters: dict[str, Sequence[Any]] = {"project_id": get_project_list(project_id)} + # justification: heterogeneous Snuba JSON + filters: MutableMapping[str, Sequence[Any]] = {"project_id": get_project_list(project_id)} if environment_id: filters["environment"] = [environment_id] conditions = kwargs.get("conditions", []) @@ -435,11 +446,13 @@ def __get_tag_key_and_top_values( raise TagKeyNotFound if group is None else GroupTagKeyNotFound output: TagKey | GroupTagKey + # justification: tag keys are strings even though Snuba result keys are str | int. + tag_results = cast("dict[str, dict[str, Any]]", result) if group is None: output = _make_result( key=key, totals=totals, - result=result, + result=tag_results, key_ctor=TagKey, value_ctor=TagValue, ) @@ -447,7 +460,7 @@ def __get_tag_key_and_top_values( snuba_output = _make_result( key=key, totals=totals, - result=result, + result=tag_results, key_ctor=functools.partial(GroupTagKey, group_id=group.id), value_ctor=functools.partial(GroupTagValue, group_id=group.id), ) @@ -526,12 +539,12 @@ def __get_tag_keys( self, project_id: int, group: Group | None, - environment_ids: list[int] | None, + environment_ids: Sequence[int] | None, limit: int | None = 1000, keys: list[str] | None = None, include_values_seen: bool = True, dataset: Dataset = Dataset.Events, - tenant_ids=None, + tenant_ids: dict[str, int | str] | None = None, **kwargs: Any, ) -> set[TagKey | GroupTagKey]: optimize_kwargs: _OptimizeKwargs = {} @@ -558,17 +571,17 @@ def __get_tag_keys( def __get_tag_keys_for_projects( self, - projects, - group, - environments: Sequence[int] | None, + projects: Sequence[int], + group: Group | None, + environments: Sequence[str | int] | None, start: datetime | None, end: datetime | None, limit: int | None = 1000, keys: list[str] | None = None, include_values_seen: bool = True, - use_cache=False, + use_cache: bool = False, dataset: Dataset = Dataset.Discover, - **kwargs, + **kwargs: Any, # justification: framework kwargs ) -> set[TagKey | GroupTagKey]: """Query snuba for tag keys based on projects @@ -592,7 +605,8 @@ def __get_tag_keys_for_projects( aggregations = [["count()", "", "count"]] - filters: dict[str, Sequence[Any]] = {"project_id": sorted(projects)} + # justification: heterogeneous Snuba JSON + filters: MutableMapping[str, Sequence[Any]] = {"project_id": sorted(projects)} if environments: filters["environment"] = sorted(environments) if group is not None: @@ -686,12 +700,12 @@ def __get_tag_keys_for_projects( def get_tag_key( self, - project_id, - environment_id, - key, - status=TagKeyStatus.ACTIVE, - tenant_ids=None, - **kwargs, + project_id: int, + environment_id: int | None, + key: str, + status: int = TagKeyStatus.ACTIVE, + tenant_ids: dict[str, int | str] | None = None, + **kwargs: Any, # justification: framework kwargs ) -> GroupTagKey | TagKey: assert status is TagKeyStatus.ACTIVE return self.__get_tag_key_and_top_values( @@ -704,8 +718,8 @@ def get_tag_keys( environment_id: int | None, status: int = TagKeyStatus.ACTIVE, include_values_seen: bool = False, - tenant_ids=None, - **kwargs, + tenant_ids: dict[str, int | str] | None = None, + **kwargs: Any, # justification: framework kwargs ) -> set[TagKey | GroupTagKey]: assert status is TagKeyStatus.ACTIVE @@ -733,13 +747,13 @@ def get_tag_keys( def get_tag_keys_for_projects( self, projects: Sequence[int], - environments: Sequence[int] | None, + environments: Sequence[str] | None, start: datetime, end: datetime, dataset: Dataset = Dataset.Events, status: int = TagKeyStatus.ACTIVE, use_cache: bool = False, - tenant_ids=None, + tenant_ids: dict[str, int | str] | None = None, ) -> set[TagKey | GroupTagKey]: max_unsampled_projects = _max_unsampled_projects # We want to disable FINAL in the snuba query to reduce load. @@ -779,8 +793,14 @@ def get_tag_keys_for_projects( **optimize_kwargs, ) - def get_tag_values(self, project_id, environment_id, key, tenant_ids=None): - key = self.__get_tag_key_and_top_values( + def get_tag_values( + self, + project_id: int, + environment_id: int | None, + key: str, + tenant_ids: dict[str, int | str] | None = None, + ) -> set[TagValue]: + tag = self.__get_tag_key_and_top_values( project_id, None, environment_id, @@ -789,15 +809,15 @@ def get_tag_values(self, project_id, environment_id, key, tenant_ids=None): raise_on_empty=False, tenant_ids=tenant_ids, ) - return set(key.top_values) + return set(cast("tuple[TagValue, ...]", tag.top_values)) def get_group_tag_key( self, - group, - environment_id, - key, - tenant_ids=None, - **kwargs, + group: Group, + environment_id: int | None, + key: str, + tenant_ids: dict[str, int | str] | None = None, + **kwargs: Any, # justification: framework kwargs ) -> GroupTagKey | TagKey: return self.__get_tag_key_and_top_values( group.project_id, @@ -811,12 +831,12 @@ def get_group_tag_key( def get_group_tag_keys( self, - group, - environment_ids: list[int], + group: Group, + environment_ids: Sequence[int] | None, limit: int | None = None, keys: list[str] | None = None, - tenant_ids=None, - **kwargs, + tenant_ids: dict[str, int | str] | None = None, + **kwargs: Any, # justification: framework kwargs ) -> set[TagKey | GroupTagKey]: """Get tag keys for a specific group""" return self.__get_tag_keys( @@ -833,17 +853,17 @@ def get_group_tag_keys( def __get_group_list_tag_value( self, - project_ids, - group_id_list, - environment_ids: list[int], + project_ids: Sequence[int], + group_id_list: Sequence[int], + environment_ids: Sequence[int], key: str, - value, - dataset, - extra_conditions, - extra_aggregations, - referrer, - tenant_ids=None, - ): + value: str, + dataset: Dataset, + extra_conditions: list[Any], # justification: heterogeneous Snuba JSON + extra_aggregations: list[Any], # justification: heterogeneous Snuba JSON + referrer: str, + tenant_ids: dict[str, int | str] | None = None, + ) -> dict[int, GroupTagValue]: filters = {"project_id": project_ids, "group_id": group_id_list} if environment_ids: filters["environment"] = environment_ids @@ -870,18 +890,18 @@ def __get_group_list_tag_value( group_id: GroupTagValue( group_id=group_id, key=key, value=value, **fix_tag_value_data(data) ) - for group_id, data in result.items() + for group_id, data in cast(dict[int, Any], result).items() # justification: query rows } def get_group_list_tag_value( self, - project_ids, - group_id_list, - environment_ids: list[int], + project_ids: Sequence[int], + group_id_list: Sequence[int], + environment_ids: Sequence[int], key: str, - value, - tenant_ids=None, - ): + value: str, + tenant_ids: dict[str, int | str] | None = None, + ) -> dict[int, GroupTagValue]: snuba_result = self.__get_group_list_tag_value( project_ids, group_id_list, @@ -926,13 +946,13 @@ def get_group_list_tag_value( def get_generic_group_list_tag_value( self, - project_ids, - group_id_list, - environment_ids: list[int], + project_ids: Sequence[int], + group_id_list: Sequence[int], + environment_ids: Sequence[int], key: str, - value, - tenant_ids=None, - ): + value: str, + tenant_ids: dict[str, int | str] | None = None, + ) -> dict[int, GroupTagValue]: translated_params = _translate_filter_keys(project_ids, group_id_list, environment_ids) organization_id = get_organization_id_from_project_ids(project_ids) start, end = _prepare_start_end( @@ -1109,7 +1129,13 @@ def _eap_get_group_list_tag_value( ) return {} - def apply_group_filters(self, group: Group | None, filters: MutableMapping[str, Sequence[Any]]): + def apply_group_filters( + # justification: heterogeneous Snuba JSON + self, + group: Group | None, + filters: MutableMapping[str, Sequence[Any]], # justification: heterogeneous Snuba JSON + # justification: heterogeneous Snuba JSON + ) -> tuple[Dataset, MutableMapping[str, Sequence[Any]]]: dataset = Dataset.Events if group: filters["group_id"] = [group.id] @@ -1124,18 +1150,24 @@ def get_group_tag_value_count( key: str, tenant_ids: dict[str, str | int] | None = None, ) -> int: - filters: dict[str, Sequence[Any]] = {"project_id": get_project_list(group.project_id)} + filters: MutableMapping[str, Sequence[Any]] = { # justification: heterogeneous Snuba JSON + "project_id": get_project_list(group.project_id) + } if environment_id: filters["environment"] = [environment_id] aggregations = [["count()", "", "count"]] dataset, filters = self.apply_group_filters(group, filters) - snuba_result = snuba.query( - dataset=dataset, - filter_keys=filters, - aggregations=aggregations, - referrer=Referrer.TAGSTORE_GET_GROUP_TAG_VALUE_COUNT.value, - tenant_ids=tenant_ids, + # justification: a single ungrouped aggregate lowers to a scalar count. + snuba_result = cast( + int, + snuba.query( + dataset=dataset, + filter_keys=filters, + aggregations=aggregations, + referrer=Referrer.TAGSTORE_GET_GROUP_TAG_VALUE_COUNT.value, + tenant_ids=tenant_ids, + ), ) result = snuba_result @@ -1180,12 +1212,12 @@ def get_group_tag_value_count( def get_top_group_tag_values( self, - group, - environment_id, + group: Group, + environment_id: int | None, key: str, limit: int = TOP_VALUES_DEFAULT_LIMIT, - tenant_ids=None, - ): + tenant_ids: dict[str, int | str] | None = None, + ) -> tuple[TagValue, ...] | tuple[GroupTagValue, ...] | None: tag = self.__get_tag_key_and_top_values( group.project_id, group, @@ -1199,12 +1231,12 @@ def get_top_group_tag_values( def get_group_tag_keys_and_top_values( self, group: Group, - environment_ids: list[int], + environment_ids: Sequence[int] | None, keys: list[str] | None = None, value_limit: int = TOP_VALUES_DEFAULT_LIMIT, - tenant_ids=None, - **kwargs, - ): + tenant_ids: dict[str, int | str] | None = None, + **kwargs: Any, # justification: framework kwargs + ) -> set[TagKey | GroupTagKey]: # Similar to __get_tag_key_and_top_values except we get the top values # for all the keys provided. value_limit in this case means the number # of top values for each key, so the total rows returned should be @@ -1252,7 +1284,7 @@ def get_group_tag_keys_and_top_values( tenant_ids=tenant_ids, ) - keys_to_check = list(values_by_key.keys()) or [k.key for k in keys_with_counts] + keys_to_check = [str(k) for k in values_by_key.keys()] or [k.key for k in keys_with_counts] empty_stats_map = self.__get_empty_value_stats_map( dataset=dataset, filters=filters, @@ -1302,7 +1334,7 @@ def __get_empty_value_stats_map( self, dataset: Dataset, filters: MutableMapping[str, Sequence[Any]], - conditions: list, + conditions: list[Any], # justification: heterogeneous Snuba JSON keys_to_check: list[str], tenant_ids: dict[str, int | str] | None, start: datetime | None, @@ -1318,16 +1350,19 @@ def __get_empty_value_stats_map( total_filters = dict(filters) total_filters.pop(self.key_column, None) - total_events: int | None = snuba.query( - dataset=dataset, - start=start, - end=end, - groupby=None, - conditions=conditions, - filter_keys=total_filters, - aggregations=[["count()", "", "count"]], - referrer=Referrer.TAGSTORE__GET_TAG_KEYS_AND_TOP_VALUES_EMPTY_COUNTS, - tenant_ids=tenant_ids, + total_events: int | None = cast( + int, + snuba.query( + dataset=dataset, + start=start, + end=end, + groupby=None, + conditions=conditions, + filter_keys=total_filters, + aggregations=[["count()", "", "count"]], + referrer=Referrer.TAGSTORE__GET_TAG_KEYS_AND_TOP_VALUES_EMPTY_COUNTS, + tenant_ids=tenant_ids, + ), ) if total_events is None: return {k: {"count": 0} for k in keys_to_check} @@ -1335,23 +1370,32 @@ def __get_empty_value_stats_map( non_empty_filters = dict(filters) non_empty_filters[self.key_column] = keys_to_check - non_empty_by_key: dict[str, int] = snuba.query( - dataset=dataset, - start=start, - end=end, - groupby=[self.key_column], - conditions=conditions + [[self.value_column, "!=", ""]], - filter_keys=non_empty_filters, - aggregations=[["count()", "", "count"]], - referrer=Referrer.TAGSTORE__GET_TAG_KEYS_AND_TOP_VALUES_EMPTY_COUNTS, - tenant_ids=tenant_ids, + non_empty_by_key: dict[str, int] = cast( + dict[str, int], + snuba.query( + dataset=dataset, + start=start, + end=end, + groupby=[self.key_column], + conditions=conditions + [[self.value_column, "!=", ""]], + filter_keys=non_empty_filters, + aggregations=[["count()", "", "count"]], + referrer=Referrer.TAGSTORE__GET_TAG_KEYS_AND_TOP_VALUES_EMPTY_COUNTS, + tenant_ids=tenant_ids, + ), ) return { k: {"count": max(total_events - non_empty_by_key.get(k, 0), 0)} for k in keys_to_check } - def get_release_tags(self, organization_id, project_ids, environment_id, versions): + def get_release_tags( + self, + organization_id: int, + project_ids: Sequence[int], + environment_id: int | None, + versions: Sequence[str], + ) -> set[TagValue]: filters = {"project_id": project_ids} if environment_id: filters["environment"] = [environment_id] @@ -1360,7 +1404,10 @@ def get_release_tags(self, organization_id, project_ids, environment_id, version # release ids which would need to be translated by the snuba util. tag = "sentry:release" col = f"tags[{tag}]" - conditions = [[col, "IN", versions], DEFAULT_TYPE_CONDITION] + conditions: list[list[Any]] = [ # justification: heterogeneous Snuba JSON + [col, "IN", versions], + DEFAULT_TYPE_CONDITION, + ] aggregations = [ ["count()", "", "times_seen"], ["min", SEEN_COLUMN, "first_seen"], @@ -1501,7 +1548,11 @@ def _eap_get_release_tags( return set() def get_min_start_date( - self, organization_id, project_ids, environment_id, versions + self, + organization_id: int, + project_ids: Sequence[int], + environment_id: int | None, + versions: Sequence[str], ) -> datetime | None: rpe_query = ReleaseProjectEnvironment.objects.filter( project_id__in=project_ids, @@ -1520,15 +1571,15 @@ def get_min_start_date( def __get_groups_user_counts( self, - project_ids, - group_ids, + project_ids: Sequence[int], + group_ids: Sequence[int], environment_ids: Sequence[int] | None, start: datetime | None = None, end: datetime | None = None, - dataset=Dataset.Events, - extra_aggregations=None, - referrer=Referrer.TAGSTORE_GET_GROUPS_USER_COUNTS.value, - tenant_ids=None, + dataset: Dataset = Dataset.Events, + extra_aggregations: list[Any] | None = None, # justification: heterogeneous Snuba JSON + referrer: str = Referrer.TAGSTORE_GET_GROUPS_USER_COUNTS.value, + tenant_ids: dict[str, int | str] | None = None, ) -> dict[int, int]: filters = {"project_id": project_ids, "group_id": group_ids} if environment_ids: @@ -1538,17 +1589,20 @@ def __get_groups_user_counts( ["uniq", "tags[sentry:user]", "count"] ] - result = snuba.query( - dataset=dataset, - start=start, - end=end, - groupby=["group_id"], - conditions=None, - filter_keys=filters, - aggregations=aggregations, - orderby="-count", - referrer=referrer, - tenant_ids=tenant_ids, + result = cast( + dict[int, int], + snuba.query( + dataset=dataset, + start=start, + end=end, + groupby=["group_id"], + conditions=None, + filter_keys=filters, + aggregations=aggregations, + orderby="-count", + referrer=referrer, + tenant_ids=tenant_ids, + ), ) return defaultdict(int, {k: v for k, v in result.items() if v}) @@ -1757,15 +1811,15 @@ def get_generic_groups_user_counts( def get_tag_value_paginator( self, - project_id, - environment_id, + project_id: int, + environment_id: int | None, key: str, - start=None, - end=None, - query=None, - order_by="-last_seen", - tenant_ids=None, - ): + start: datetime | None = None, + end: datetime | None = None, + query: str | None = None, + order_by: str = "-last_seen", + tenant_ids: dict[str, int | str] | None = None, + ) -> SequencePaginator[TagValue]: return self.get_tag_value_paginator_for_projects( get_project_list(project_id), [environment_id] if environment_id else None, @@ -1777,27 +1831,33 @@ def get_tag_value_paginator( tenant_ids=tenant_ids, ) - def _get_semver_versions_for_package(self, projects, organization_id, package): + def _get_semver_versions_for_package( + self, projects: Sequence[int], organization_id: int, package: str + ) -> ReleaseQuerySet: packages = ( Release.objects.filter(organization_id=organization_id, package__startswith=package) .values_list("package") .distinct() ) - return Release.objects.filter( - organization_id=organization_id, - package__in=packages, - id__in=ReleaseProject.objects.filter(project_id__in=projects).values_list( - "release_id", flat=True - ), - ).annotate_prerelease_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet + # justification: annotate_prerelease_column returns a ReleaseQuerySet at runtime. + return cast( + "ReleaseQuerySet", + Release.objects.filter( + organization_id=organization_id, + package__in=packages, + id__in=ReleaseProject.objects.filter(project_id__in=projects).values_list( + "release_id", flat=True + ), + ).annotate_prerelease_column(), # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet + ) def _get_tag_values_for_semver( self, - projects: list[int], + projects: Sequence[int], environments: Sequence[int] | None, query: str | None, - ): + ) -> SequencePaginator[TagValue]: from sentry.api.paginator import SequencePaginator query = query if query else "" @@ -1813,9 +1873,13 @@ def _get_tag_values_for_semver( include_package = "@" in query query = query.replace("*", "") if "@" in query: - versions = Release.objects.filter(version__startswith=query) + versions = cast( + "ReleaseQuerySet", Release.objects.filter(version__startswith=query) + ) else: - versions = Release.objects.filter(version__contains="@" + query) + versions = cast( + "ReleaseQuerySet", Release.objects.filter(version__contains="@" + query) + ) if projects: versions = versions.filter( @@ -1831,7 +1895,7 @@ def _get_tag_values_for_semver( ) order_by = map(_flip_field_sort, Release.SEMVER_COLS + ["package"]) - versions = ( + version_rows = ( versions.filter_to_semver() .annotate_prerelease_column() .order_by(*order_by) @@ -1846,7 +1910,7 @@ def _get_tag_values_for_semver( # When we don't include package, this can result in duplicate version numbers, so we # also de-dupe here. This can result in less than 1000 versions returned, but we # typically use very few values so this works ok. - for version in versions: + for version in version_rows: formatted_version = version if include_package else version.split("@", 1)[1] formatted_version = formatted_version.split("+", 1)[0] if formatted_version in seen: @@ -1863,8 +1927,8 @@ def _get_tag_values_for_semver( ) def _get_tag_values_for_semver_package( - self, projects, environments: Sequence[int] | None, package: str | None - ): + self, projects: Sequence[int], environments: Sequence[int] | None, package: str | None + ) -> SequencePaginator[TagValue]: from sentry.api.paginator import SequencePaginator package = package if package else "" @@ -1888,8 +1952,8 @@ def _get_tag_values_for_semver_package( ) def _get_tag_values_for_release_stages( - self, projects, environments: Sequence[int] | None, query: str | None - ): + self, projects: Sequence[int], environments: Sequence[int] | None, query: str | None + ) -> SequencePaginator[TagValue]: from sentry.api.paginator import SequencePaginator organization_id = Project.objects.filter(id=projects[0]).values_list( @@ -1898,7 +1962,7 @@ def _get_tag_values_for_release_stages( versions = Release.objects.filter_by_stage( organization_id, "=", - query, + cast("str | list[str]", query), project_ids=projects, environments=environments, ) @@ -1909,17 +1973,17 @@ def _get_tag_values_for_release_stages( ).values_list("release_id", flat=True) ) - versions = versions.order_by("version").values_list("version", flat=True)[:1000] + version_rows = versions.order_by("version").values_list("version", flat=True)[:1000] return SequencePaginator( [ (i, TagValue(RELEASE_STAGE_ALIAS, v, None, None, None)) - for i, v in enumerate(versions) + for i, v in enumerate(version_rows) ] ) def _get_tag_values_for_semver_build( - self, projects, environments: Sequence[int] | None, build: str | None - ): + self, projects: Sequence[int], environments: Sequence[int] | None, build: str | None + ) -> SequencePaginator[TagValue]: from sentry.api.paginator import SequencePaginator build = build if build else "" @@ -1946,8 +2010,8 @@ def _get_tag_values_for_semver_build( ) def _get_tag_values_for_releases_across_all_datasets( - self, projects, environments: Sequence[int] | None, query: str | None - ): + self, projects: Sequence[int], environments: Sequence[int] | None, query: str | None + ) -> SequencePaginator[TagValue]: from sentry.api.paginator import SequencePaginator organization_id = Project.objects.filter(id=projects[0]).values_list( @@ -1978,23 +2042,23 @@ def _get_tag_values_for_releases_across_all_datasets( [(i, TagValue(RELEASE_ALIAS, v, None, None, None)) for i, v in enumerate(versions)] ) - def get_snuba_column_name(self, key: str, dataset: Dataset): + def get_snuba_column_name(self, key: str, dataset: Dataset) -> str: return snuba.get_snuba_column_name(key, dataset=dataset) def get_tag_value_paginator_for_projects( self, - projects, + projects: Sequence[int], environments: Sequence[int] | None, key: str, - start=None, - end=None, + start: datetime | None = None, + end: datetime | None = None, dataset: Dataset | None = None, query: str | None = None, - order_by="-last_seen", + order_by: str = "-last_seen", include_transactions: bool = False, include_sessions: bool = False, include_replays: bool = False, - tenant_ids=None, + tenant_ids: dict[str, int | str] | None = None, ) -> SequencePaginator[TagValue]: if not (order_by == "-last_seen" or order_by == "-count"): raise ValueError("Unsupported order_by: %s" % order_by) @@ -2085,7 +2149,7 @@ def get_tag_value_paginator_for_projects( projects, environments, query ) - conditions = [] + conditions: list[list[Any]] = [] # justification: heterogeneous Snuba JSON project_slugs = {} # transaction status needs a special case so that the user interacts with the names and not codes transaction_status = snuba_key == "transaction_status" @@ -2107,7 +2171,7 @@ def get_tag_value_paginator_for_projects( conditions.append([snuba_key, ">=", converted_query - FUZZY_NUMERIC_DISTANCE]) conditions.append([snuba_key, "<=", converted_query + FUZZY_NUMERIC_DISTANCE]) elif include_transactions and key == PROJECT_ALIAS: - project_filters = { + project_filters: dict[str, Any] = { # justification: heterogeneous Snuba JSON "id__in": projects, } if query: @@ -2121,7 +2185,7 @@ def get_tag_value_paginator_for_projects( projects = [project["id"] for project in project_queryset] snuba_key = "project_id" else: - snuba_name = snuba_key + snuba_name: str | list[Any] = snuba_key # justification: alias resolves to either form is_user_alias = include_transactions and key == USER_DISPLAY_ALIAS if is_user_alias: @@ -2130,7 +2194,8 @@ def get_tag_value_paginator_for_projects( # and resolve it to the corresponding snuba query resolver = snuba.resolve_column(dataset) snuba_name = FIELD_ALIASES[USER_DISPLAY_ALIAS].get_field() - snuba.resolve_complex_column(snuba_name, resolver, []) + # justification: user.alias resolves to a coalesce expression list. + snuba.resolve_complex_column(cast(list[Any], snuba_name), resolver, []) elif snuba_name in BLACKLISTED_COLUMNS: snuba_name = self.format_string.format(key) @@ -2147,23 +2212,25 @@ def get_tag_value_paginator_for_projects( if dataset == Dataset.Events: conditions.append(DEFAULT_TYPE_CONDITION) + results: dict[int | str, Any] # justification: decoded tag query rows. if dataset == Dataset.Replays: - results = query_replays_dataset_tagkey_values( - project_ids=filters["project_id"], - start=start, - end=end, - environment=filters.get("environment"), + replay_results = query_replays_dataset_tagkey_values( + project_ids=list(filters["project_id"]), + start=cast("datetime", start), + end=cast("datetime", end), + # justification: replay queries accept environment ids at runtime + environment=cast("str | None", filters.get("environment")), tag_key=key, tag_substr_query=query, tenant_ids=tenant_ids, ) results = { - d["tag_value"]: { + cast(int, d["tag_value"]): { "times_seen": d["times_seen"], "first_seen": d["first_seen"], "last_seen": d["last_seen"], } - for d in results["data"] + for d in replay_results["data"] } else: @@ -2193,7 +2260,7 @@ def get_tag_value_paginator_for_projects( # With transaction_status we need to map the ids back to their names if transaction_status: results = { - SPAN_STATUS_CODE_TO_NAME[result_key]: data + SPAN_STATUS_CODE_TO_NAME[cast(int, result_key)]: data for result_key, data in results.items() } # With project names we map the ids back to the project slugs @@ -2201,7 +2268,7 @@ def get_tag_value_paginator_for_projects( results = { project_slugs[value]: data for value, data in results.items() - if value in project_slugs + if isinstance(value, int) and value in project_slugs } elif is_fuzzy_numeric_key(key): # numeric keys like measurements and breakdowns are nullable @@ -2263,7 +2330,13 @@ def get_group_tag_value_iter( ) snuba_result = [ - GroupTagValue(group_id=group.id, key=key, value=value, **fix_tag_value_data(data)) + GroupTagValue( + group_id=group.id, + key=key, + # justification: tag value groups are strings at runtime. + value=cast("str", value), + **fix_tag_value_data(data), + ) for value, data in results.items() ] result = snuba_result @@ -2405,12 +2478,12 @@ def _eap_get_group_tag_value_iter( def get_group_tag_value_paginator( self, - group, - environment_ids: list[int], + group: Group, + environment_ids: Sequence[int] | None, key: str, - order_by="-id", - tenant_ids=None, - ): + order_by: str = "-id", + tenant_ids: dict[str, int | str] | None = None, + ) -> SequencePaginator[GroupTagValue]: from sentry.api.paginator import SequencePaginator if order_by in ("-last_seen", "-first_seen", "-times_seen"): @@ -2423,7 +2496,7 @@ def get_group_tag_value_paginator( group_tag_values = self.get_group_tag_value_iter( group, - environment_ids, + environment_ids or [], key, orderby=( "-times_seen" @@ -2462,16 +2535,20 @@ class SnubaFlagStorage(SnubaTagStorage): value_column = "flags_value" format_string = "flags[{}]" - def get_release_tags(self, *args, **kwargs): + # justification: framework kwargs + def get_release_tags(self, *args: Any, **kwargs: Any) -> Never: raise NotImplementedError - def __get_groups_user_counts(self, *args, **kwargs): + # justification: framework kwargs + def __get_groups_user_counts(self, *args: Any, **kwargs: Any) -> Never: raise NotImplementedError - def get_groups_user_counts(self, *args, **kwargs): + # justification: framework kwargs + def get_groups_user_counts(self, *args: Any, **kwargs: Any) -> Never: raise NotImplementedError - def get_generic_groups_user_counts(self, *args, **kwargs): + # justification: framework kwargs + def get_generic_groups_user_counts(self, *args: Any, **kwargs: Any) -> Never: raise NotImplementedError def get_snuba_column_name(self, key: str, dataset: Dataset) -> str: diff --git a/src/sentry/tagstore/types.py b/src/sentry/tagstore/types.py index 8f95a57bd2d9..2d7576b51475 100644 --- a/src/sentry/tagstore/types.py +++ b/src/sentry/tagstore/types.py @@ -1,6 +1,7 @@ from __future__ import annotations import functools +from collections.abc import Mapping from datetime import datetime from typing import Any, ClassVar, TypedDict @@ -38,7 +39,7 @@ def __lt__(self, other: object) -> bool: return True if other_val is None: return False - return self_val < other_val + return bool(self_val < other_val) def __getstate__(self) -> dict[str, Any]: return {name: getattr(self, name) for name in self.__slots__} @@ -66,7 +67,7 @@ def __init__( self.count = count self.top_values = top_values - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, str]: return {"key": self.key} @@ -118,8 +119,8 @@ def __init__( key: str, value: str | None, times_seen: int, - first_seen, - last_seen, + first_seen: datetime | None, + last_seen: datetime | None, ): self.group_id = group_id self.key = key @@ -143,7 +144,14 @@ class TagKeySerializerResponse(TagKeySerializerResponseOptional): @register(GroupTagKey) @register(TagKey) class TagKeySerializer(Serializer[TagKeySerializerResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> TagKeySerializerResponse: + def serialize( + # justification: framework kwargs + self, + obj: TagKey | GroupTagKey, + attrs: Mapping[Any, Any], # justification: serializer API + user: Any, # justification: serializer API + **kwargs: Any, # justification: framework kwargs + ) -> TagKeySerializerResponse: from sentry import tagstore output: TagKeySerializerResponse = { @@ -165,18 +173,25 @@ class TagValueSerializerResponseOptional(TypedDict, total=False): class TagValueSerializerResponse(TagValueSerializerResponseOptional): key: str - name: str + name: str | None value: str | None # Empty values do not have counts, last seen or first seen timestamps. count: int | None - lastSeen: str | None - firstSeen: str | None + lastSeen: datetime | None + firstSeen: datetime | None @register(GroupTagValue) @register(TagValue) class TagValueSerializer(Serializer[TagValueSerializerResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> TagValueSerializerResponse: + def serialize( + # justification: framework kwargs + self, + obj: TagValue | GroupTagValue, + attrs: Mapping[Any, Any], # justification: serializer API + user: Any, # justification: serializer API + **kwargs: Any, # justification: framework kwargs + ) -> TagValueSerializerResponse: from sentry import tagstore key = tagstore.backend.get_standardized_key(obj.key) @@ -189,7 +204,7 @@ def serialize(self, obj, attrs, user, **kwargs) -> TagValueSerializerResponse: "firstSeen": obj.first_seen, } - query = convert_user_tag_to_query(key, obj.value) + query = convert_user_tag_to_query(key, obj.value) if obj.value is not None else None if query: serialized["query"] = query diff --git a/src/sentry/tasks/assemble.py b/src/sentry/tasks/assemble.py index 2be16c897f35..4e88f9fbd674 100644 --- a/src/sentry/tasks/assemble.py +++ b/src/sentry/tasks/assemble.py @@ -7,8 +7,10 @@ import tempfile import uuid from collections import Counter +from collections.abc import Sequence from datetime import datetime -from typing import IO, TYPE_CHECKING, Any, NamedTuple +from types import TracebackType +from typing import IO, TYPE_CHECKING, Any, NamedTuple, cast import orjson import psycopg2.errors @@ -82,12 +84,18 @@ class AssembleResult(NamedTuple): # Temporary in-memory object representing the file used for efficiency. bundle_temp_file: IO[bytes] - def delete_bundle(self): + def delete_bundle(self) -> None: self.bundle.delete() self.bundle_temp_file.close() -def _get_assemble_file_blob_ids(task, org_or_project, name, checksum, chunks) -> list[int] | None: +def _get_assemble_file_blob_ids( + task: str, + org_or_project: Organization | Project, + name: str, + checksum: str, + chunks: Sequence[str], +) -> list[int] | None: """Validates uploaded chunks and returns their IDs in assembly order.""" from sentry.models.files.fileblob import FileBlob @@ -143,7 +151,14 @@ def _get_assemble_file_blob_ids(task, org_or_project, name, checksum, chunks) -> @traces.trace -def assemble_file(task, org_or_project, name, checksum, chunks, file_type) -> AssembleResult | None: +def assemble_file( + task: str, + org_or_project: Organization | Project, + name: str, + checksum: str, + chunks: Sequence[str], + file_type: str, +) -> AssembleResult | None: """ Verifies and assembles a file model from chunks. @@ -177,7 +192,13 @@ def assemble_file(task, org_or_project, name, checksum, chunks, file_type) -> As @traces.trace -def assemble_file_blobs(task, org_or_project, name, checksum, chunks) -> IO[bytes] | None: +def assemble_file_blobs( + task: str, + org_or_project: Organization | Project, + name: str, + checksum: str, + chunks: Sequence[str], +) -> IO[bytes] | None: """Assembles uploaded chunks into a temporary file without creating a ``File``.""" from sentry.models.files.fileblob import FileBlob @@ -220,7 +241,7 @@ def assemble_file_blobs(task, org_or_project, name, checksum, chunks) -> IO[byte return temp_file -def _get_cache_key(task, scope, checksum): +def _get_cache_key(task: str, scope: int, checksum: str) -> str: """Computes the cache key for assemble status. ``task`` must be one of the ``AssembleTask`` values. The scope can be the @@ -249,7 +270,7 @@ def _get_redis_cluster_for_assemble() -> RedisCluster: @traces.trace -def get_assemble_status(task, scope, checksum): +def get_assemble_status(task: str, scope: int, checksum: str) -> tuple[str | None, str | None]: """ Checks the current status of an assembling task. @@ -265,11 +286,13 @@ def get_assemble_status(task, scope, checksum): return None, None # It is stored as bytes with [state, detail] on Redis. - return tuple(orjson.loads(rv)) + return cast("tuple[str | None, str | None]", tuple(orjson.loads(rv))) @traces.trace -def set_assemble_status(task, scope, checksum, state, detail=None): +def set_assemble_status( + task: str, scope: int, checksum: str, state: str | None, detail: str | None = None +) -> None: """ Updates the status of an assembling task. It is cached for 10 minutes. """ @@ -279,7 +302,7 @@ def set_assemble_status(task, scope, checksum, state, detail=None): @traces.trace -def delete_assemble_status(task, scope, checksum): +def delete_assemble_status(task: str, scope: int, checksum: str) -> None: """ Deletes the status of an assembling task. """ @@ -294,7 +317,14 @@ def delete_assemble_status(task, scope, checksum): processing_deadline_duration=60 * 5, silo_mode=SiloMode.CELL, ) -def assemble_dif(project_id, name, checksum, chunks, debug_id=None, **kwargs): +def assemble_dif( + project_id: int, + name: str, + checksum: str, + chunks: Sequence[str], + debug_id: str | None = None, + **kwargs: object, +) -> None: """ Assembles uploaded chunks into a ``ProjectDebugFile``. """ @@ -436,7 +466,7 @@ def __init__( dist: str | None, project_ids: list[int], is_release_bundle_migration: bool = False, - ): + ) -> None: self.assemble_result = assemble_result self._validate_bundle_guarded() @@ -446,7 +476,7 @@ def __init__( self.project_ids = project_ids self.is_release_bundle_migration = is_release_bundle_migration - def _validate_bundle_guarded(self): + def _validate_bundle_guarded(self) -> None: try: self._validate_bundle() except AssembleArtifactsTooLargeError: @@ -456,7 +486,7 @@ def _validate_bundle_guarded(self): self._cleanup_invalid_bundle() raise AssembleArtifactsError("the bundle is invalid") - def _validate_bundle(self): + def _validate_bundle(self) -> None: self.archive = ArtifactBundleArchive(self.assemble_result.bundle_temp_file) metrics.incr( "tasks.assemble.artifact_bundle.artifact_count", amount=self.archive.artifact_count @@ -469,16 +499,21 @@ def _validate_bundle(self): f"({info.file_size} > {MAX_SOURCE_FILE_SIZE} bytes)" ) - def _cleanup_invalid_bundle(self): + def _cleanup_invalid_bundle(self) -> None: metrics.incr("tasks.assemble.invalid_bundle") # In case the bundle is invalid, we want to delete the actual `File` object created in the database, to # avoid orphan entries. self.delete_bundle_file_object() - def __enter__(self): + def __enter__(self) -> ArtifactBundlePostAssembler: return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: # In case any exception happens in the `with` block, we will capture it, and we want to delete the actual `File` # object created in the database, to avoid orphan entries. if exc_type is not None: @@ -486,10 +521,10 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.archive.close() - def delete_bundle_file_object(self): + def delete_bundle_file_object(self) -> None: self.assemble_result.delete_bundle() - def post_assemble(self): + def post_assemble(self) -> None: if self.archive.artifact_count == 0: metrics.incr("tasks.assemble.artifact_bundle.discarded_empty_bundle") self.delete_bundle_file_object() @@ -743,6 +778,7 @@ def _create_or_update_artifact_bundle( if existing_artifact_bundle is None: file = self.assemble_result.bundle + assert file.size is not None metrics.distribution( "storage.put.size", file.size, @@ -801,7 +837,7 @@ def _create_or_update_artifact_bundle( return existing_artifact_bundle, False - def _remove_duplicate_artifact_bundles(self, ids: list[int]): + def _remove_duplicate_artifact_bundles(self, ids: list[int]) -> None: # In case there are no ids to delete, we don't want to run the query, otherwise it will result in a deletion of # all ArtifactBundle(s) with the specific bundle_id. if not ids: @@ -812,7 +848,9 @@ def _remove_duplicate_artifact_bundles(self, ids: list[int]): ArtifactBundle.objects.filter(Q(id__in=ids), organization_id=self.organization.id).delete() @traces.trace - def _index_bundle_if_needed(self, artifact_bundle: ArtifactBundle, release: str, dist: str): + def _index_bundle_if_needed( + self, artifact_bundle: ArtifactBundle, release: str, dist: str + ) -> None: # We collect how many times we tried to perform indexing. metrics.incr("tasks.assemble.artifact_bundle.try_indexing") @@ -847,15 +885,15 @@ def _index_bundle_if_needed(self, artifact_bundle: ArtifactBundle, release: str, silo_mode=SiloMode.CELL, ) def assemble_artifacts( - org_id, - version, - checksum, - chunks, + org_id: int, + version: str, + checksum: str, + chunks: Sequence[str], # These params have been added for supporting artifact bundles assembling. - project_ids=None, - dist=None, - is_release_bundle_migration=False, - **kwargs, + project_ids: list[int] | None = None, + dist: str | None = None, + is_release_bundle_migration: bool = False, + **kwargs: object, ) -> None: """ Creates a release file or artifact bundle from an uploaded bundle given the checksums of its chunks. diff --git a/src/sentry/tasks/auth/auth.py b/src/sentry/tasks/auth/auth.py index bd01f8dbd655..cc2ab42ad8bd 100644 --- a/src/sentry/tasks/auth/auth.py +++ b/src/sentry/tasks/auth/auth.py @@ -32,7 +32,9 @@ processing_deadline_duration=30, silo_mode=SiloMode.CONTROL, ) -def email_missing_links_control(org_id: int, actor_id: int, provider_key: str, **kwargs): +def email_missing_links_control( + org_id: int, actor_id: int, provider_key: str, **kwargs: object +) -> None: # This seems dumb as the region method is the same, but we need to keep # queues separate so that the transition from monolith to siloed is clean _email_missing_links(org_id=org_id, sending_user_id=actor_id, provider_key=provider_key) @@ -42,7 +44,7 @@ def email_missing_links_control(org_id: int, actor_id: int, provider_key: str, * name="sentry.tasks.send_sso_link_emails", namespace=auth_tasks, ) -def email_missing_links(org_id: int, actor_id: int, provider_key: str, **kwargs): +def email_missing_links(org_id: int, actor_id: int, provider_key: str, **kwargs: object) -> None: _email_missing_links(org_id=org_id, sending_user_id=actor_id, provider_key=provider_key) @@ -70,7 +72,7 @@ def _email_missing_links(org_id: int, sending_user_id: int, provider_key: str) - ) def email_unlink_notifications( org_id: int, sending_user_email: str, provider_key: str, actor_id: int | None = None -): +) -> None: try: org = Organization.objects.get(id=org_id) provider = manager.get(provider_key) @@ -107,16 +109,16 @@ def is_compliant(self, user: RpcUser) -> bool: raise NotImplementedError() @abc.abstractmethod - def call_to_action(self, org: Organization, user: RpcUser, member: OrganizationMember): + def call_to_action(self, org: Organization, user: RpcUser, member: OrganizationMember) -> None: """Prompt a member to comply with the new requirement.""" raise NotImplementedError() def remove_non_compliant_members( self, org_id: int, actor_id: int | None, actor_key_id: int | None, ip_address: str | None - ): + ) -> None: actor = user_service.get_user(user_id=actor_id) if actor_id else None - def remove_member(org_member: OrganizationMember, user: RpcUser): + def remove_member(org_member: OrganizationMember, user: RpcUser) -> None: logging_data = { "organization_id": org_id, "user_id": user.id, @@ -178,7 +180,7 @@ def is_compliant(self, user: RpcUser) -> bool: return user.has_2fa() return False - def call_to_action(self, org: Organization, user: RpcUser, member: OrganizationMember): + def call_to_action(self, org: Organization, user: RpcUser, member: OrganizationMember) -> None: # send invite to setup 2fa email_context = {"url": member.get_invite_link(), "organization": org} subject = "{} {} Mandatory: Enable Two-Factor Authentication".format( @@ -203,7 +205,12 @@ def call_to_action(self, org: Organization, user: RpcUser, member: OrganizationM ), silo_mode=SiloMode.CELL, ) -def remove_2fa_non_compliant_members(org_id, actor_id=None, actor_key_id=None, ip_address=None): +def remove_2fa_non_compliant_members( + org_id: int, + actor_id: int | None = None, + actor_key_id: int | None = None, + ip_address: str | None = None, +) -> None: TwoFactorComplianceTask().remove_non_compliant_members( org_id, actor_id, actor_key_id, ip_address ) diff --git a/src/sentry/tasks/auth/check_auth.py b/src/sentry/tasks/auth/check_auth.py index f2680e83f467..ec3b2f24c0e2 100644 --- a/src/sentry/tasks/auth/check_auth.py +++ b/src/sentry/tasks/auth/check_auth.py @@ -31,7 +31,7 @@ namespace=auth_control_tasks, silo_mode=SiloMode.CONTROL, ) -def check_auth(chunk_size=100, **kwargs): +def check_auth(chunk_size: int = 100, **kwargs: object) -> None: """ Checks for batches of auth identities and schedules them to refresh in a batched job. That batched job can recursively trigger check_auth to continue processing auth identities if necessary. @@ -62,7 +62,7 @@ def check_auth(chunk_size=100, **kwargs): namespace=auth_control_tasks, silo_mode=SiloMode.CONTROL, ) -def check_auth_identity(auth_identity_id: int, **kwargs): +def check_auth_identity(auth_identity_id: int, **kwargs: object) -> None: check_single_auth_identity(auth_identity_id) @@ -75,9 +75,9 @@ def check_auth_identity(auth_identity_id: int, **kwargs): def check_auth_identities( auth_identity_id: int | None = None, auth_identity_ids: list[int] | None = None, - chunk_size=100, - **kwargs, -): + chunk_size: int = 100, + **kwargs: object, +) -> None: if auth_identity_ids is None and isinstance(auth_identity_id, int): auth_identity_ids = [auth_identity_id] @@ -94,7 +94,7 @@ def check_auth_identities( check_auth.apply_async(kwargs={"chunk_size": chunk_size}) -def check_single_auth_identity(auth_identity_id: int): +def check_single_auth_identity(auth_identity_id: int) -> None: try: auth_identity = AuthIdentity.objects.get(id=auth_identity_id) except AuthIdentity.DoesNotExist: diff --git a/src/sentry/tasks/auth/cleanup_pending_users.py b/src/sentry/tasks/auth/cleanup_pending_users.py index 980608d8387d..bc6dfd2ecc49 100644 --- a/src/sentry/tasks/auth/cleanup_pending_users.py +++ b/src/sentry/tasks/auth/cleanup_pending_users.py @@ -14,5 +14,5 @@ silo_mode=SiloMode.CONTROL, processing_deadline_duration=90, ) -def cleanup_pending_users(**kwargs) -> None: +def cleanup_pending_users(**kwargs: object) -> None: PendingUser.objects.filter(expires_at__lte=timezone.now()).delete() diff --git a/src/sentry/tasks/auto_ongoing_issues.py b/src/sentry/tasks/auto_ongoing_issues.py index ccbf9518c0a3..7baf7b9a0ee1 100644 --- a/src/sentry/tasks/auto_ongoing_issues.py +++ b/src/sentry/tasks/auto_ongoing_issues.py @@ -1,6 +1,7 @@ import logging import time from datetime import datetime, timedelta, timezone +from typing import Any from django.db.models import OuterRef, Subquery from sentry_sdk import traces @@ -84,7 +85,7 @@ def schedule_auto_transition_to_ongoing() -> None: ) def schedule_auto_transition_issues_new_to_ongoing( first_seen_lte: int, - **kwargs, + **kwargs: Any, # justification: framework kwargs ) -> None: """ We will update NEW Groups to ONGOING that were created before the @@ -152,8 +153,8 @@ def schedule_auto_transition_issues_new_to_ongoing( ) def run_auto_transition_issues_new_to_ongoing( group_ids: list[int], - **kwargs, -): + **kwargs: Any, # justification: framework kwargs +) -> None: """ Child task of `auto_transition_issues_new_to_ongoing` to conduct the update of specified Groups to Ongoing. @@ -178,7 +179,7 @@ def run_auto_transition_issues_new_to_ongoing( ) def schedule_auto_transition_issues_regressed_to_ongoing( date_added_lte: int, - **kwargs, + **kwargs: Any, # justification: framework kwargs ) -> None: """ We will update REGRESSED Groups to ONGOING that were created before the @@ -251,7 +252,7 @@ def schedule_auto_transition_issues_regressed_to_ongoing( ) def run_auto_transition_issues_regressed_to_ongoing( group_ids: list[int], - **kwargs, + **kwargs: Any, # justification: framework kwargs ) -> None: """ Child task of `auto_transition_issues_regressed_to_ongoing` @@ -277,7 +278,7 @@ def run_auto_transition_issues_regressed_to_ongoing( ) def schedule_auto_transition_issues_escalating_to_ongoing( date_added_lte: int, - **kwargs, + **kwargs: Any, # justification: framework kwargs ) -> None: """ We will update ESCALATING Groups to ONGOING that were created before the @@ -350,7 +351,7 @@ def schedule_auto_transition_issues_escalating_to_ongoing( ) def run_auto_transition_issues_escalating_to_ongoing( group_ids: list[int], - **kwargs, + **kwargs: Any, # justification: framework kwargs ) -> None: """ Child task of `auto_transition_issues_escalating_to_ongoing` diff --git a/src/sentry/tasks/auto_resolve_issues.py b/src/sentry/tasks/auto_resolve_issues.py index 8824d35c45cc..46fee8101ebd 100644 --- a/src/sentry/tasks/auto_resolve_issues.py +++ b/src/sentry/tasks/auto_resolve_issues.py @@ -34,7 +34,7 @@ processing_deadline_duration=75, silo_mode=SiloMode.CELL, ) -def schedule_auto_resolution(): +def schedule_auto_resolution() -> None: options_qs = ProjectOption.objects.filter( key__in=["sentry:resolve_age", "sentry:_last_auto_resolve"] ) @@ -67,7 +67,13 @@ def schedule_auto_resolution(): processing_deadline_duration=90, silo_mode=SiloMode.CELL, ) -def auto_resolve_project_issues(project_id, cutoff=None, chunk_size=1000, **kwargs): +def auto_resolve_project_issues( + # justification: framework kwargs + project_id: int, + cutoff: int | None = None, + chunk_size: int = 1000, + **kwargs: Any, # justification: Django view args +) -> None: project = Project.objects.get_from_cache(id=project_id) age = project.get_option("sentry:resolve_age", None) if not age: @@ -76,13 +82,13 @@ def auto_resolve_project_issues(project_id, cutoff=None, chunk_size=1000, **kwar project.update_option("sentry:_last_auto_resolve", int(time())) if cutoff: - cutoff = datetime.fromtimestamp(cutoff, timezone.utc) + cutoff_dt = datetime.fromtimestamp(cutoff, timezone.utc) else: - cutoff = django_timezone.now() - timedelta(hours=int(age)) + cutoff_dt = django_timezone.now() - timedelta(hours=int(age)) - filter_conditions = { + filter_conditions: dict[str, Any] = { # justification: task kwargs "project": project, - "last_seen__lte": cutoff, + "last_seen__lte": cutoff_dt, "status": GroupStatus.UNRESOLVED, } @@ -102,7 +108,7 @@ def auto_resolve_project_issues(project_id, cutoff=None, chunk_size=1000, **kwar happened = Group.objects.filter( id=group.id, status=GroupStatus.UNRESOLVED, - last_seen__lte=cutoff, + last_seen__lte=cutoff_dt, ).update( status=GroupStatus.RESOLVED, resolved_at=resolution_time, @@ -154,7 +160,7 @@ def auto_resolve_project_issues(project_id, cutoff=None, chunk_size=1000, **kwar if might_have_more: auto_resolve_project_issues.apply_async( args=[project_id], - kwargs={"cutoff": int(cutoff.strftime("%s")), "chunk_size": chunk_size}, + kwargs={"cutoff": int(cutoff_dt.strftime("%s")), "chunk_size": chunk_size}, expires=TEN_MINUTES, headers={"sentry-propagate-traces": False}, ) diff --git a/src/sentry/tasks/base.py b/src/sentry/tasks/base.py index 988172abfc7f..51a940f4bf81 100644 --- a/src/sentry/tasks/base.py +++ b/src/sentry/tasks/base.py @@ -3,7 +3,7 @@ import datetime import logging from collections.abc import Callable -from typing import TypeVar +from typing import TypeVar, cast from django.db.models import Model from taskbroker_client.constants import CompressionType @@ -26,7 +26,8 @@ def load_model_from_db( """Utility function to allow a task to transition to passing ids rather than model instances.""" if isinstance(instance_or_id, int): if hasattr(tp.objects, "get_from_cache") and allow_cache: - return tp.objects.get_from_cache(pk=instance_or_id) + # justification: get_from_cache returns an instance of the given model type. + return cast("ModelT", tp.objects.get_from_cache(pk=instance_or_id)) return tp.objects.get(pk=instance_or_id) return instance_or_id @@ -46,7 +47,7 @@ def instrumented_task( silenced_exceptions: tuple[type[BaseException], ...] | None = None, silo_mode: SiloMode | None = None, pass_headers: bool = False, - **kwargs, + **kwargs: object, ) -> Callable[[Callable[P, R]], Task[P, R]]: """ Decorator for defining tasks. @@ -165,8 +166,8 @@ def wrapped(func: Callable[P, R]) -> Task[P, R]: return wrapped -def track_group_async_operation(function): - def wrapper(*args, **kwargs): +def track_group_async_operation(function: Callable[P, R]) -> Callable[P, R]: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: from sentry.utils import snuba try: diff --git a/src/sentry/tasks/beacon.py b/src/sentry/tasks/beacon.py index ead19ead170e..16fb6f1439d8 100644 --- a/src/sentry/tasks/beacon.py +++ b/src/sentry/tasks/beacon.py @@ -42,6 +42,8 @@ def get_install_id() -> str: logger.info("beacon.generated-install-id", extra={"install_id": install_id}) options.set("sentry:install-id", install_id) + if not isinstance(install_id, str): + raise TypeError("sentry:install-id must be a string") return install_id diff --git a/src/sentry/tasks/clear_expired_resolutions.py b/src/sentry/tasks/clear_expired_resolutions.py index fca1e4ffc620..58da4a37101a 100644 --- a/src/sentry/tasks/clear_expired_resolutions.py +++ b/src/sentry/tasks/clear_expired_resolutions.py @@ -21,7 +21,7 @@ processing_deadline_duration=15, silo_mode=SiloMode.CELL, ) -def clear_expired_resolutions(release_id): +def clear_expired_resolutions(release_id: int) -> None: """ This should be fired when ``release_id`` is created or its dates change, and will indicate to the system that any pending resolutions older than the given release can now diff --git a/src/sentry/tasks/collect_project_platforms.py b/src/sentry/tasks/collect_project_platforms.py index 2d8194738bb3..c807ac3726a5 100644 --- a/src/sentry/tasks/collect_project_platforms.py +++ b/src/sentry/tasks/collect_project_platforms.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from datetime import timedelta from django.utils import timezone @@ -11,7 +12,7 @@ from sentry.taskworker.namespaces import issues_tasks -def paginate_project_ids(paginate): +def paginate_project_ids(paginate: int) -> Iterator[list[int]]: id_cursor = -1 while True: queryset = ( @@ -30,7 +31,7 @@ def paginate_project_ids(paginate): processing_deadline_duration=30, silo_mode=SiloMode.CELL, ) -def collect_project_platforms(paginate=1000, **kwargs): +def collect_project_platforms(paginate: int = 1000, **kwargs: object) -> None: now = timezone.now() for page_of_project_ids in paginate_project_ids(paginate): diff --git a/src/sentry/tasks/commit_context.py b/src/sentry/tasks/commit_context.py index 016dde4c42d4..b324cb558bc7 100644 --- a/src/sentry/tasks/commit_context.py +++ b/src/sentry/tasks/commit_context.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from datetime import timedelta from typing import Any @@ -38,8 +38,12 @@ from sentry.utils.locking import UnableToAcquireLock from sentry.utils.sdk import set_current_event_project -DEBOUNCE_PR_COMMENT_CACHE_KEY = lambda pullrequest_id: f"pr-comment-{pullrequest_id}" -DEBOUNCE_PR_COMMENT_LOCK_KEY = lambda pullrequest_id: f"queue_comment_task:{pullrequest_id}" +DEBOUNCE_PR_COMMENT_CACHE_KEY: Callable[[int], str] = ( + lambda pullrequest_id: f"pr-comment-{pullrequest_id}" +) +DEBOUNCE_PR_COMMENT_LOCK_KEY: Callable[[int], str] = ( + lambda pullrequest_id: f"queue_comment_task:{pullrequest_id}" +) PR_COMMENT_TASK_TTL = timedelta(minutes=5).total_seconds() PR_COMMENT_WINDOW = 14 # days TASK_DURATION_S = 90 @@ -86,7 +90,7 @@ def process_commit_context( group = Group.objects.get_from_cache(id=group_id) set_tag("organization.slug", project.organization.slug) sentry_sdk.set_attribute("organization.slug", project.organization.slug) - basic_logging_details = { + basic_logging_details: dict[str, str | int] = { "event": event_id, "group": group_id, "organization": project.organization_id, @@ -106,8 +110,8 @@ def process_commit_context( IntegrationsFailedToFetchCommitContextAllFrames( organization_id=project.organization_id, project_id=project_id, - group_id=basic_logging_details["group"], - event_id=basic_logging_details["event"], + group_id=group_id, + event_id=event_id, num_frames=0, num_successfully_mapped_frames=0, reason="could_not_find_in_app_stacktrace_frame", diff --git a/src/sentry/tasks/commits.py b/src/sentry/tasks/commits.py index f05328be63e0..140b9786b1c3 100644 --- a/src/sentry/tasks/commits.py +++ b/src/sentry/tasks/commits.py @@ -107,7 +107,7 @@ def fetch_commits_for_compare_range( "cache_key": cache_key, }, ) - if cached_repo_commits is not None: + if isinstance(cached_repo_commits, list): return cached_repo_commits repo_commits = provider.fetch_commits_for_compare_range(repo, start_sha, end_sha, actor=user) @@ -118,6 +118,8 @@ def fetch_commits_for_compare_range( repo_commits, GITHUB_FETCH_COMMITS_COMPARE_CACHE_TTL_SECONDS, ) + if not isinstance(repo_commits, list): + raise TypeError("Provider returned no commits array") return repo_commits @@ -128,7 +130,10 @@ def fetch_recent_commits( end_sha: str, user: RpcUser | None, ) -> list[dict[str, Any]]: - return provider.fetch_recent_commits(repo, end_sha, actor=user) + commits = provider.fetch_recent_commits(repo, end_sha, actor=user) + if not isinstance(commits, list): + raise TypeError("Provider returned no commits array") + return commits def get_repo_for_ref( @@ -169,8 +174,11 @@ def get_provider_for_repo( if repo.has_integration_provider() else "repository.provider" ) + provider_name = repo.provider + if provider_name is None: + return None try: - provider_cls = bindings.get(binding_key).get(repo.provider) + provider_cls = bindings.get(binding_key).get(provider_name) except KeyError: return None diff --git a/src/sentry/tasks/console_platform_cleanup.py b/src/sentry/tasks/console_platform_cleanup.py index 67bbbf8b460d..f83d8544738c 100644 --- a/src/sentry/tasks/console_platform_cleanup.py +++ b/src/sentry/tasks/console_platform_cleanup.py @@ -17,7 +17,7 @@ silo_mode=SiloMode.CELL, ) def remove_inaccessible_console_platform_sources( - organization_id: int, current_platforms: list[str], **kwargs + organization_id: int, current_platforms: list[str], **kwargs: object ) -> None: """ Remove symbol sources that the organization can no longer access from all diff --git a/src/sentry/tasks/digests.py b/src/sentry/tasks/digests.py index 306424b43eaa..d8cec9c5f1e5 100644 --- a/src/sentry/tasks/digests.py +++ b/src/sentry/tasks/digests.py @@ -1,5 +1,6 @@ import logging import time +from typing import cast from taskbroker_client.retry import Retry @@ -85,7 +86,8 @@ def deliver_digest( project, digest, target_type, - target_identifier, + # justification: legacy digest keys can carry a string identifier. + cast("int | None", target_identifier), fallthrough_choice=fallthrough_choice, notification_uuid=notification_uuid, ) diff --git a/src/sentry/tasks/embeddings_grouping/utils.py b/src/sentry/tasks/embeddings_grouping/utils.py index 7b100cd44a51..04f2a006d091 100644 --- a/src/sentry/tasks/embeddings_grouping/utils.py +++ b/src/sentry/tasks/embeddings_grouping/utils.py @@ -1,8 +1,16 @@ import time +from collections.abc import Callable # Marked as internal with underscore but actually used in getsentry -def _retry_operation(operation, *args, retries, delay, exceptions, **kwargs): +def _retry_operation( + operation: Callable[..., object], + *args: object, + retries: int, + delay: float, + exceptions: type[BaseException] | tuple[type[BaseException], ...], + **kwargs: object, +) -> object | None: for attempt in range(retries): try: return operation(*args, **kwargs) @@ -11,3 +19,4 @@ def _retry_operation(operation, *args, retries, delay, exceptions, **kwargs): time.sleep(delay * (2**attempt)) else: raise + return None diff --git a/src/sentry/tasks/files.py b/src/sentry/tasks/files.py index 9f1f3433ddbb..173602dc90c9 100644 --- a/src/sentry/tasks/files.py +++ b/src/sentry/tasks/files.py @@ -1,4 +1,8 @@ +from __future__ import annotations + +from collections.abc import Sequence from datetime import timedelta +from typing import TYPE_CHECKING from django.db import DatabaseError, IntegrityError, router from django.utils import timezone @@ -9,6 +13,12 @@ from sentry.taskworker.namespaces import deletion_control_tasks, deletion_tasks from sentry.utils.db import atomic_transaction +if TYPE_CHECKING: + from sentry.models.files.control_fileblob import ControlFileBlob + from sentry.models.files.control_fileblobindex import ControlFileBlobIndex + from sentry.models.files.fileblob import FileBlob + from sentry.models.files.fileblobindex import FileBlobIndex + MAX_RETRIES = 5 @@ -22,7 +32,7 @@ ), silo_mode=SiloMode.CELL, ) -def delete_file_region(path, checksum, **kwargs): +def delete_file_region(path: str | None, checksum: str, **kwargs: object) -> None: from sentry.models.files import FileBlob delete_file(FileBlob, path, checksum, **kwargs) @@ -38,15 +48,23 @@ def delete_file_region(path, checksum, **kwargs): ), silo_mode=SiloMode.CONTROL, ) -def delete_file_control(path, checksum, **kwargs): +def delete_file_control(path: str | None, checksum: str, **kwargs: object) -> None: from sentry.models.files import ControlFileBlob delete_file(ControlFileBlob, path, checksum, **kwargs) -def delete_file(file_blob_model, path, checksum, **kwargs): +def delete_file( + file_blob_model: type[FileBlob] | type[ControlFileBlob], + path: str | None, + checksum: str, + **kwargs: object, +) -> None: from sentry.models.files.utils import get_storage + if path is None: + return + # check that the fileblob with *this* path exists, as its possible # that a concurrent re-upload added the same chunk once again, with a # different path that time @@ -64,7 +82,7 @@ def delete_file(file_blob_model, path, checksum, **kwargs): ), silo_mode=SiloMode.CELL, ) -def delete_unreferenced_blobs_region(blob_ids): +def delete_unreferenced_blobs_region(blob_ids: Sequence[int]) -> None: from sentry.models.files.fileblob import FileBlob from sentry.models.files.fileblobindex import FileBlobIndex @@ -81,14 +99,18 @@ def delete_unreferenced_blobs_region(blob_ids): ), silo_mode=SiloMode.CONTROL, ) -def delete_unreferenced_blobs_control(blob_ids): +def delete_unreferenced_blobs_control(blob_ids: Sequence[int]) -> None: from sentry.models.files.control_fileblob import ControlFileBlob from sentry.models.files.control_fileblobindex import ControlFileBlobIndex delete_unreferenced_blobs(ControlFileBlob, ControlFileBlobIndex, blob_ids) -def delete_unreferenced_blobs(blob_model, blob_index_model, blob_ids): +def delete_unreferenced_blobs( + blob_model: type[FileBlob] | type[ControlFileBlob], + blob_index_model: type[FileBlobIndex] | type[ControlFileBlobIndex], + blob_ids: Sequence[int], +) -> None: for blob_id in blob_ids: # If a blob is referenced, we do not want to delete it if blob_index_model.objects.filter(blob_id=blob_id).exists(): diff --git a/src/sentry/tasks/gpu_crash.py b/src/sentry/tasks/gpu_crash.py index 5d81d0e2af93..05ddb3378141 100644 --- a/src/sentry/tasks/gpu_crash.py +++ b/src/sentry/tasks/gpu_crash.py @@ -9,8 +9,8 @@ from __future__ import annotations import logging -from collections.abc import MutableMapping -from typing import Any +from collections.abc import MutableMapping, Sequence +from typing import Any, cast import sentry_sdk from taskbroker_client.constants import CompressionType @@ -19,7 +19,7 @@ from sentry.ingest.event_payload import load_event_payload from sentry.killswitches import killswitch_matches_context from sentry.lang.native.gpu import apply_gpu_crash_symbolication -from sentry.lang.native.teapot import TeapotUnavailable, submit_to_teapot +from sentry.lang.native.teapot import TeapotAttachment, TeapotUnavailable, submit_to_teapot from sentry.lang.native.utils import ( find_all_shader_debug_attachments, find_gpu_crash_dump_attachment, @@ -121,6 +121,9 @@ def _try_symbolicate(data: MutableMapping[str, Any], project_id: int, event_id: metrics.incr("tasks.gpu_crash.skipped", tags={"reason": "attachment_missing"}) return False shaders = find_all_shader_debug_attachments(data) + assert dump.name is not None + for shader in shaders: + assert shader.name is not None breaker = _teapot_circuit_breaker() if not breaker.should_allow_request(): @@ -141,7 +144,13 @@ def _try_symbolicate(data: MutableMapping[str, Any], project_id: int, event_id: try: with metrics.timer("tasks.gpu_crash.teapot"): - response = submit_to_teapot(project, event_id, dump, shaders) + response = submit_to_teapot( + project, + event_id, + # justification: event attachments carry names for teapot resolution. + cast(TeapotAttachment, dump), + cast(Sequence[TeapotAttachment], shaders), + ) except TeapotUnavailable: # A genuine outage — feed the breaker so repeated failures trip it. breaker.record_error() diff --git a/src/sentry/tasks/groupowner.py b/src/sentry/tasks/groupowner.py index ed56b87029dd..211868188e0b 100644 --- a/src/sentry/tasks/groupowner.py +++ b/src/sentry/tasks/groupowner.py @@ -1,5 +1,5 @@ import logging -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from datetime import timedelta from typing import Any, cast @@ -27,21 +27,21 @@ PREFERRED_GROUP_OWNERS = 2 PREFERRED_GROUP_OWNER_AGE = timedelta(days=7) MIN_COMMIT_SCORE = 2 -DEBOUNCE_CACHE_KEY = lambda group_id: f"process-suspect-commits-{group_id}" +DEBOUNCE_CACHE_KEY: Callable[[int], str] = lambda group_id: f"process-suspect-commits-{group_id}" TASK_DURATION_S = 90 logger = logging.getLogger(__name__) def _process_suspect_commits( - event_id, - event_platform, + event_id: str, + event_platform: str | None, event_frames: Sequence[Mapping[str, Any]], - group_id, - project_id, - sdk_name=None, - **kwargs, -): + group_id: int, + project_id: int, + sdk_name: str | None = None, + **kwargs: object, +) -> None: """ This is the logic behind SuspectCommitStrategy.RELEASE_BASED """ @@ -193,14 +193,14 @@ def _process_suspect_commits( silo_mode=SiloMode.CELL, ) def process_suspect_commits( - event_id, - event_platform, + event_id: str, + event_platform: str | None, event_frames: Sequence[Mapping[str, Any]], - group_id, - project_id, - sdk_name=None, - **kwargs, -): + group_id: int, + project_id: int, + sdk_name: str | None = None, + **kwargs: object, +) -> None: """ This is the task behind SuspectCommitStrategy.RELEASE_BASED """ diff --git a/src/sentry/tasks/on_demand_metrics.py b/src/sentry/tasks/on_demand_metrics.py index bbf5afbdda08..b61ab013d93b 100644 --- a/src/sentry/tasks/on_demand_metrics.py +++ b/src/sentry/tasks/on_demand_metrics.py @@ -68,7 +68,8 @@ def _set_currently_processing_batch(current_batch: int) -> None: def _get_previous_processing_batch() -> int: - return cache.get(_get_widget_processing_batch_key(), 0) + batch = cache.get(_get_widget_processing_batch_key(), 0) + return batch if isinstance(batch, int) else 0 def _get_current_processing_batch(total_batches: int) -> int: diff --git a/src/sentry/tasks/options.py b/src/sentry/tasks/options.py index dfc0b71cbb0f..2e98e1f90c4f 100644 --- a/src/sentry/tasks/options.py +++ b/src/sentry/tasks/options.py @@ -18,7 +18,7 @@ namespace=options_control_tasks, silo_mode=SiloMode.CONTROL, ) -def sync_options_control(cutoff=ONE_HOUR): +def sync_options_control(cutoff: int = ONE_HOUR) -> None: _sync_options(cutoff) @@ -26,11 +26,11 @@ def sync_options_control(cutoff=ONE_HOUR): name="sentry.tasks.options.sync_options", namespace=options_tasks, ) -def sync_options(cutoff=ONE_HOUR): +def sync_options(cutoff: int = ONE_HOUR) -> None: _sync_options(cutoff) -def _sync_options(cutoff): +def _sync_options(cutoff: int) -> None: """ Ensures all options that have been updated (within the database) since ``cutoff`` have their correct values stored in the cache. diff --git a/src/sentry/tasks/organization_contributors.py b/src/sentry/tasks/organization_contributors.py index f4ff1d33a816..e54115ded48d 100644 --- a/src/sentry/tasks/organization_contributors.py +++ b/src/sentry/tasks/organization_contributors.py @@ -41,7 +41,7 @@ def reset_num_actions_for_organization_contributors(organization_id: int) -> Non silo_mode=SiloMode.CELL, retry=Retry(times=3, delay=60), ) -def assign_seat_to_organization_contributor(contributor_id) -> None: +def assign_seat_to_organization_contributor(contributor_id: int) -> None: try: organization_contributor = OrganizationContributors.objects.get(id=contributor_id) except OrganizationContributors.DoesNotExist: diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 955ab22534ab..ba6b4a4d4864 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1559,7 +1559,7 @@ def detect_new_escalation(job: PostProcessJob) -> None: group = job["event"].group if not group: return - extra = { + extra: dict[str, object] = { "org_id": group.organization.id, "project_id": job["event"].project.id, "group_id": group.id, diff --git a/src/sentry/tasks/process_buffer.py b/src/sentry/tasks/process_buffer.py index 29f889716bec..58b3f67bd316 100644 --- a/src/sentry/tasks/process_buffer.py +++ b/src/sentry/tasks/process_buffer.py @@ -1,5 +1,7 @@ +from __future__ import annotations + import logging -from typing import Any +from typing import TYPE_CHECKING import sentry_sdk from django.apps import apps @@ -10,6 +12,9 @@ from sentry.utils.locking import UnableToAcquireLock from sentry.utils.locking.lock import Lock +if TYPE_CHECKING: + from sentry.buffer.base import BufferField + logger = logging.getLogger(__name__) @@ -45,12 +50,12 @@ def process_pending() -> None: ) def process_incr( columns: dict[str, int] | None = None, - filters: dict[str, Any] | None = None, - extra: dict[str, Any] | None = None, + filters: dict[str, object] | None = None, + extra: dict[str, object] | None = None, signal_only: bool | None = None, model_name: str | None = None, - **kwargs, -): + **kwargs: object, +) -> None: """ Processes a buffer event. """ @@ -75,10 +80,16 @@ def process_incr( ) -def buffer_incr(model: type[Model], *args, **kwargs): +def buffer_incr( + model: type[Model], + columns: dict[str, int], + filters: dict[str, BufferField], + extra: dict[str, object] | None = None, + signal_only: bool | None = None, +) -> None: from sentry import buffer sentry_sdk.set_tag("model", model._meta.model_name) sentry_sdk.set_attribute("model", model._meta.model_name) - buffer.backend.incr(model, *args, **kwargs) + buffer.backend.incr(model, columns, filters, extra=extra, signal_only=signal_only) diff --git a/src/sentry/tasks/relay.py b/src/sentry/tasks/relay.py index 6c98dd58a454..774fcfd232b4 100644 --- a/src/sentry/tasks/relay.py +++ b/src/sentry/tasks/relay.py @@ -1,5 +1,9 @@ +from __future__ import annotations + import logging import time +from collections.abc import Iterator, Mapping +from typing import TYPE_CHECKING, Any, TypedDict import sentry_sdk from django.db import connections, router, transaction @@ -17,8 +21,18 @@ from sentry.utils.exceptions import quiet_redis_noise from sentry.utils.sdk import set_current_event_project +if TYPE_CHECKING: + from sentry.models.projectkey import ProjectKey + logger = logging.getLogger(__name__) + +class _DebounceKeys(TypedDict): + public_key: str | None + project_id: int | None + organization_id: int | None + + # Project options that don't trigger a project config invalidation. NON_INVALIDATING_PROJECT_OPTIONS = [ "sentry:_last_auto_resolve", @@ -38,7 +52,7 @@ expires=30, processing_deadline_duration=30, ) -def build_project_config(public_key=None, **kwargs): +def build_project_config(public_key: str, **kwargs: object) -> None: """Build a project config and put it in the Redis cache. This task is used to compute missing project configs, it is aggressively @@ -75,7 +89,7 @@ def build_project_config(public_key=None, **kwargs): ) -def schedule_build_project_config(public_key): +def schedule_build_project_config(public_key: str) -> None: """Schedule the `build_project_config` with debouncing applied. See documentation of `build_project_config` for documentation of parameters. @@ -107,7 +121,11 @@ def schedule_build_project_config(public_key): ) -def validate_args(organization_id=None, project_id=None, public_key=None): +def validate_args( + organization_id: int | None = None, + project_id: int | None = None, + public_key: str | None = None, +) -> None: """Validates arguments for the tasks and sets sentry scope. The tasks should be invoked for only one of these arguments, however because tasks @@ -118,7 +136,11 @@ def validate_args(organization_id=None, project_id=None, public_key=None): raise TypeError("Must provide exactly one of organzation_id, project_id or public_key") -def compute_configs(organization_id=None, project_id=None, public_key=None): +def compute_configs( + organization_id: int | None = None, + project_id: int | None = None, + public_key: str | None = None, +) -> Iterator[tuple[str, Mapping[str, object]]]: """Computes all configs for the org, project or single public key. You must only provide one single argument, not all. @@ -144,6 +166,7 @@ def compute_configs(organization_id=None, project_id=None, public_key=None): project.set_cached_field_value("organization", organization) for key in ProjectKey.objects.filter(project_id=project.id): key.set_cached_field_value("project", project) + assert key.public_key is not None # If we find the config in the cache it means it was active. As such we want to # recalculate it. If the config was not there at all, we leave it and avoid the # cost of re-computation. @@ -160,6 +183,7 @@ def compute_configs(organization_id=None, project_id=None, public_key=None): for project in Project.objects.filter(id=project_id): for key in ProjectKey.objects.filter(project_id=project_id): key.set_cached_field_value("project", project) + assert key.public_key is not None # If we find the config in the cache it means it was active. As such we want to # recalculate it. If the config was not there at all, we leave it and avoid the # cost of re-computation. @@ -192,7 +216,8 @@ def compute_configs(organization_id=None, project_id=None, public_key=None): raise TypeError("One of the arguments must not be None") -def compute_projectkey_config(key): +# justification: relay project configs are decoded JSON objects. +def compute_projectkey_config(key: ProjectKey) -> dict[str, Any]: """Computes a single config for the given :class:`ProjectKey`. :returns: A dict with the project config. @@ -208,7 +233,7 @@ def compute_projectkey_config(key): OrganizationOption.objects.reload_task_local_cache(key.project.organization_id) ProjectOption.objects.reload_task_local_cache(key.project_id) - return get_project_config(key.project, project_keys=[key]).to_dict() + return dict(get_project_config(key.project, project_keys=[key]).to_dict()) @instrumented_task( @@ -219,13 +244,13 @@ def compute_projectkey_config(key): silo_mode=SiloMode.CELL, ) def invalidate_project_config( - organization_id=None, - project_id=None, - public_key=None, - trigger="invalidated", - trigger_details=None, - **kwargs, -): + organization_id: int | None = None, + project_id: int | None = None, + public_key: str | None = None, + trigger: str = "invalidated", + trigger_details: str | None = None, + **kwargs: object, +) -> None: """Task which re-computes an invalidated project config. This task can be scheduled regardless of whether the :func:`build_project_config` task @@ -277,14 +302,14 @@ def invalidate_project_config( @traces.trace def schedule_invalidate_project_config( *, - trigger, - trigger_details=None, - organization_id=None, - project_id=None, - public_key=None, - countdown=5, - transaction_db=None, -): + trigger: str, + trigger_details: str | None = None, + organization_id: int | None = None, + project_id: int | None = None, + public_key: str | None = None, + countdown: int = 5, + transaction_db: str | None = None, +) -> None: """Schedules the :func:`invalidate_project_config` task. Pass exactly one of ``organization_id``, ``project_id`` or ``public_key``. If none or @@ -340,7 +365,7 @@ def schedule_invalidate_project_config( if transaction_db is None: transaction_db = router.db_for_write(Project) - def _do_schedule(): + def _do_schedule() -> None: _schedule_invalidate_project_config( trigger=trigger, trigger_details=trigger_details, @@ -369,13 +394,13 @@ def _do_schedule(): def _schedule_invalidate_project_config( *, - trigger, - trigger_details=None, - organization_id=None, - project_id=None, - public_key=None, - countdown=5, -): + trigger: str, + trigger_details: str | None = None, + organization_id: int | None = None, + project_id: int | None = None, + public_key: str | None = None, + countdown: int = 5, +) -> None: """For param docs, see :func:`schedule_invalidate_project_config`.""" from sentry.models.project import Project from sentry.models.projectkey import ProjectKey @@ -384,7 +409,7 @@ def _schedule_invalidate_project_config( # The keys we need to check for to see if this is debounced, we want to check all # levels. - check_debounce_keys = { + check_debounce_keys: _DebounceKeys = { "public_key": public_key, "project_id": project_id, "organization_id": organization_id, diff --git a/src/sentry/tasks/release_registry.py b/src/sentry/tasks/release_registry.py index 0ecebaf88fd8..8c90f31eaddb 100644 --- a/src/sentry/tasks/release_registry.py +++ b/src/sentry/tasks/release_registry.py @@ -20,7 +20,7 @@ CACHE_TTL = 3600 -def _fetch_registry_url(relative_url): +def _fetch_registry_url(relative_url: str) -> dict[str, object]: if not settings.SENTRY_RELEASE_REGISTRY_BASEURL: return {} @@ -35,10 +35,13 @@ def _fetch_registry_url(relative_url): with Session() as session: response = session.get(full_url, timeout=REQUEST_TIMEOUT) response.raise_for_status() - return response.json() + data = response.json() + if not isinstance(data, dict): + raise TypeError("Release registry returned a non-object response") + return data -def _fetch_release_registry_data(**kwargs): +def _fetch_release_registry_data(**kwargs: object) -> None: """ Fetch information about the latest SDK version from the release registry. @@ -72,7 +75,7 @@ def _fetch_release_registry_data(**kwargs): processing_deadline_duration=65, silo_mode=SiloMode.CELL, ) -def fetch_release_registry_data(**kwargs): +def fetch_release_registry_data(**kwargs: object) -> None: _fetch_release_registry_data(**kwargs) @@ -82,5 +85,5 @@ def fetch_release_registry_data(**kwargs): processing_deadline_duration=65, silo_mode=SiloMode.CONTROL, ) -def fetch_release_registry_data_control(**kwargs): +def fetch_release_registry_data_control(**kwargs: object) -> None: _fetch_release_registry_data(**kwargs) diff --git a/src/sentry/tasks/seer/agentic_triage/cron.py b/src/sentry/tasks/seer/agentic_triage/cron.py index ef4f97647836..76532ff8f2bb 100644 --- a/src/sentry/tasks/seer/agentic_triage/cron.py +++ b/src/sentry/tasks/seer/agentic_triage/cron.py @@ -887,7 +887,7 @@ def _dispatch_pending_shards( for shard_index, planned_shard in enumerate(planned_shards): with transaction.atomic(using=using): shard = SeerWorkflowRunExecution.objects.select_for_update().get(id=planned_shard.id) - if shard.seer_run_id is not None: + if shard.seer_run_id: dispatched += 1 continue diff --git a/src/sentry/tasks/seer/agentic_triage/tweaks.py b/src/sentry/tasks/seer/agentic_triage/tweaks.py index fe29adc8dea4..58d91f80a206 100644 --- a/src/sentry/tasks/seer/agentic_triage/tweaks.py +++ b/src/sentry/tasks/seer/agentic_triage/tweaks.py @@ -21,7 +21,10 @@ def default_max_candidates() -> int: - return options.get("seer.night_shift.issues_per_org") + max_candidates = options.get("seer.night_shift.issues_per_org") + if not isinstance(max_candidates, int): + raise TypeError("seer.night_shift.issues_per_org must be an integer") + return max_candidates class AgenticTriageTweaks(pydantic.BaseModel): diff --git a/src/sentry/tasks/seer/autofix.py b/src/sentry/tasks/seer/autofix.py index 4f5560ba18f6..a0fc1e71860a 100644 --- a/src/sentry/tasks/seer/autofix.py +++ b/src/sentry/tasks/seer/autofix.py @@ -1,4 +1,5 @@ import logging +from typing import cast import sentry_sdk from django.contrib.auth.models import AnonymousUser @@ -69,8 +70,8 @@ def _get_group_or_log(group_id: int, task_name: str) -> Group | None: processing_deadline_duration=35, retry=Retry(times=1), ) -def generate_summary_and_run_automation(group_id: int, **kwargs) -> None: - trigger_path = kwargs.get("trigger_path", "unknown") +def generate_summary_and_run_automation(group_id: int, **kwargs: object) -> None: + trigger_path = cast(str, kwargs.get("trigger_path", "unknown")) sentry_sdk.set_tag("trigger_path", trigger_path) sentry_sdk.set_attribute("trigger_path", trigger_path) diff --git a/src/sentry/tasks/seer/backfill_supergroups_lightweight.py b/src/sentry/tasks/seer/backfill_supergroups_lightweight.py index 151822689f94..30d61fe7220e 100644 --- a/src/sentry/tasks/seer/backfill_supergroups_lightweight.py +++ b/src/sentry/tasks/seer/backfill_supergroups_lightweight.py @@ -45,7 +45,7 @@ def backfill_supergroups_lightweight_for_org( organization_id: int, last_project_id: int = 0, last_group_id: int = 0, - **kwargs, + **kwargs: object, ) -> None: if options.get("seer.supergroups_backfill_lightweight.killswitch"): logger.info("supergroups_backfill_lightweight.killswitch_enabled") @@ -258,7 +258,9 @@ def _backfill_org( ) -def _batch_fetch_events(groups: Sequence[Group], organization_id: int) -> list[tuple[Group, dict]]: +def _batch_fetch_events( + groups: Sequence[Group], organization_id: int +) -> list[tuple[Group, dict[str, object]]]: """ Fetch the latest event for each group using batched Snuba queries, then serialize each event for sending to Seer. diff --git a/src/sentry/tasks/seer/context_engine_index.py b/src/sentry/tasks/seer/context_engine_index.py index c4c16b7752c9..bf46b1aa6ecb 100644 --- a/src/sentry/tasks/seer/context_engine_index.py +++ b/src/sentry/tasks/seer/context_engine_index.py @@ -178,7 +178,7 @@ def index_org_project_knowledge(org_id: int) -> None: processing_deadline_duration=10 * 60, # 10 minutes retry=Retry(times=3, on=(SnubaRPCRateLimitExceeded,), delay=60), ) -def build_service_map(organization_id: int, *args, **kwargs) -> None: +def build_service_map(organization_id: int, *args: object, **kwargs: object) -> None: """ Build service map for a single organization and send to Seer. @@ -257,7 +257,7 @@ def build_service_map(organization_id: int, *args, **kwargs) -> None: processing_deadline_duration=10 * 60, # 10 minutes retry=Retry(times=3, on=(SeerApiError,), delay=60), ) -def index_repos(organization_id: int, *args, **kwargs) -> None: +def index_repos(organization_id: int, *args: object, **kwargs: object) -> None: if not options.get("explorer.context_engine_indexing.enable"): logger.info("explorer.context_engine_indexing.enable flag is disabled") return diff --git a/src/sentry/tasks/seer/explorer_index.py b/src/sentry/tasks/seer/explorer_index.py index 131aaa1c903d..f4d01167e266 100644 --- a/src/sentry/tasks/seer/explorer_index.py +++ b/src/sentry/tasks/seer/explorer_index.py @@ -190,7 +190,7 @@ def dispatch_explorer_index_projects( processing_deadline_duration=60, ) def run_explorer_index_for_projects( - projects: list[tuple[int, int]], start: str, *args, **kwargs + projects: list[tuple[int, int]], start: str, *args: object, **kwargs: object ) -> None: """ Call the seer /v1/automation/explorer/index endpoint to schedule indexing tasks diff --git a/src/sentry/tasks/seer/investigation.py b/src/sentry/tasks/seer/investigation.py index 8fa4fd591273..14233c823467 100644 --- a/src/sentry/tasks/seer/investigation.py +++ b/src/sentry/tasks/seer/investigation.py @@ -103,7 +103,7 @@ def _mark_create_dispatch_failed(run_id: int) -> None: database = router.db_for_write(InvestigationOrchestrationRun) with transaction.atomic(using=database): run = _lock_orchestration_run(run_id) - if run is None or run.seer_run_id is not None: + if run is None or run.seer_run_id: return detail = { "code": "seer_dispatch_failed", @@ -310,7 +310,7 @@ def _reconcile_command_version_conflict( reason: CommandRejectionCode, ) -> None: run.refresh_from_db() - if run.seer_run_id is None: + if not run.seer_run_id: raise SeerApiError("Investigation orchestration run is missing", 502) response = get_investigation_orchestration_run( run, @@ -379,7 +379,7 @@ def dispatch_investigation_orchestration_create(orchestration_run_id: int) -> No ) if run is None: return - if run.seer_run_id is not None: + if run.seer_run_id: dispatch_investigation_orchestration_commands.delay(run.id) return viewer_context = SeerViewerContext(organization_id=run.investigation.organization_id) @@ -424,7 +424,7 @@ def dispatch_investigation_orchestration_commands(orchestration_run_id: int) -> ) if run is None: return - if run.seer_run_id is None: + if not run.seer_run_id: dispatch_investigation_orchestration_create.delay(run.id) return diff --git a/src/sentry/tasks/seer/lightweight_rca_cluster.py b/src/sentry/tasks/seer/lightweight_rca_cluster.py index 784ec2e914b4..63d79eea70df 100644 --- a/src/sentry/tasks/seer/lightweight_rca_cluster.py +++ b/src/sentry/tasks/seer/lightweight_rca_cluster.py @@ -14,7 +14,7 @@ alias_namespace=ingest_errors_tasks, ) def trigger_lightweight_rca_cluster_task( - group_id: int, event_id: str | None = None, **kwargs + group_id: int, event_id: str | None = None, **kwargs: object ) -> None: try: group = Group.objects.get(id=group_id) diff --git a/src/sentry/tasks/seer/pr_iteration.py b/src/sentry/tasks/seer/pr_iteration.py index 7462b2d65887..8e8033f13041 100644 --- a/src/sentry/tasks/seer/pr_iteration.py +++ b/src/sentry/tasks/seer/pr_iteration.py @@ -554,7 +554,7 @@ def consume_queued_autofix_feedback( def _dropped_drain_reason(dropped: list[dict[str, Any]]) -> str: """One reason for a drain that dropped everything.""" - reasons = {item["reason"] for item in dropped} + reasons = {reason for item in dropped if isinstance(reason := item["reason"], str) and reason} if reasons <= {"stale_head", "live_head_mismatch"}: return PrIterationOutcome.STALE_HEAD.value diff --git a/src/sentry/tasks/statistical_detectors.py b/src/sentry/tasks/statistical_detectors.py index cdf21142f95a..002468cd4424 100644 --- a/src/sentry/tasks/statistical_detectors.py +++ b/src/sentry/tasks/statistical_detectors.py @@ -2,7 +2,7 @@ import contextlib import logging -from collections.abc import Generator, Iterable, Sequence +from collections.abc import Generator, Iterable, Mapping, Sequence from datetime import UTC, datetime, timedelta from typing import Any, cast @@ -74,7 +74,7 @@ TIMESERIES_PER_BATCH = 10 -def get_performance_issue_settings(projects: list[Project]): +def get_performance_issue_settings(projects: list[Project]) -> dict[Project, dict[str, object]]: project_settings = {} project_option_settings = ProjectOption.objects.get_value_bulk( @@ -168,7 +168,12 @@ class FunctionRegressionDetector(RegressionDetector): @classmethod def min_throughput_threshold(cls) -> int: - return options.get("statistical_detectors.throughput.threshold.functions") + threshold = options.get("statistical_detectors.throughput.threshold.functions") + if not isinstance(threshold, int): + raise TypeError( + "statistical_detectors.throughput.threshold.functions must be an integer" + ) + return threshold @classmethod def detector_algorithm_factory(cls) -> DetectorAlgorithm: @@ -182,7 +187,9 @@ def detector_algorithm_factory(cls) -> DetectorAlgorithm: ) @classmethod - def detector_store_factory(cls) -> DetectorStore: + def detector_store_factory( + cls, + ) -> DetectorStore[Mapping[str | bytes, bytes | float | int | str]]: return RedisDetectorStore(regression_type=RegressionType.FUNCTION) @classmethod @@ -208,7 +215,9 @@ def query_timeseries( namespace=profiling_tasks, processing_deadline_duration=30, ) -def detect_function_trends(project_ids: list[int], start: str, *args, **kwargs) -> None: +def detect_function_trends( + project_ids: list[int], start: str, *args: object, **kwargs: object +) -> None: if not options.get("statistical_detectors.enable"): return @@ -247,8 +256,8 @@ def detect_function_trends(project_ids: list[int], start: str, *args, **kwargs) def detect_function_change_points( functions_list: list[tuple[int, int]] | None = None, start: str | None = None, - *args, - **kwargs, + *args: object, + **kwargs: object, ) -> None: if not options.get("statistical_detectors.enable"): return @@ -295,7 +304,10 @@ def process_function_change_points(candidates: list[tuple[int, str, str]]) -> No def _detect_function_change_points( - functions_list: Sequence[tuple[int, int | str]], start: datetime, *args, **kwargs + functions_list: Sequence[tuple[int, int | str]], + start: datetime, + *args: object, + **kwargs: object, ) -> None: if not options.get("statistical_detectors.enable"): return @@ -486,7 +498,9 @@ def emit_function_regression_issue( return len(data["occurrence_payloads"]) -def fetch_continuous_examples(raw_examples): +def fetch_continuous_examples( + raw_examples: Mapping[tuple[int, int], Mapping[str, datetime | str | float | None]], +) -> Mapping[tuple[int, int], Mapping[str, datetime | str | float | None]]: if not raw_examples: return raw_examples @@ -646,18 +660,22 @@ def query_functions_timeseries( for project, fingerprint in functions_list ] - results = functions.top_events_timeseries( - timeseries_columns=[agg_function], - selected_columns=["project.id", "fingerprint"], - user_query="is_application:1", - snuba_params=params, - orderby=None, # unused because top events is specified - rollup=interval, - limit=len(chunk), - organization=None, # unused - referrer=Referrer.API_PROFILING_FUNCTIONS_STATISTICAL_DETECTOR_STATS.value, - top_events={"data": chunk}, - result_key_order=["project.id", "fingerprint"], + # justification: top events are supplied and allow_empty defaults to True. + results = cast( + dict[str, SnubaTSResult], + functions.top_events_timeseries( + timeseries_columns=[agg_function], + selected_columns=["project.id", "fingerprint"], + user_query="is_application:1", + snuba_params=params, + orderby=None, # unused because top events is specified + rollup=interval, + limit=len(chunk), + organization=None, # unused + referrer=Referrer.API_PROFILING_FUNCTIONS_STATISTICAL_DETECTOR_STATS.value, + top_events={"data": chunk}, + result_key_order=["project.id", "fingerprint"], + ), ) for project, fingerprint in functions_list: diff --git a/src/sentry/tasks/store.py b/src/sentry/tasks/store.py index 9b28cb4e0e67..ef546fcb548d 100644 --- a/src/sentry/tasks/store.py +++ b/src/sentry/tasks/store.py @@ -5,7 +5,7 @@ from collections.abc import Mapping, MutableMapping from dataclasses import dataclass from time import time -from typing import Any +from typing import Any, cast import orjson from sentry_relay.processing import StoreNormalizer @@ -168,6 +168,7 @@ def _do_preprocess_event( original_data = data event_id = data["event_id"] project_id = data["project"] + # justification: the SDK accepts None when no project is known; the helper is typed as int. set_current_event_project(project_id) if project is None: @@ -347,7 +348,7 @@ def is_process_disabled(project_id: int, event_id: str, platform: str) -> bool: if not rollout_rate: return False - return random.random() < rollout_rate + return bool(random.random() < rollout_rate) @traces.trace @@ -387,6 +388,7 @@ def do_process_event( ) project_id = data["project"] + # justification: the SDK accepts None when no project is known; the helper is typed as int. set_current_event_project(project_id) data_event_id = data["event_id"] @@ -566,7 +568,8 @@ def _do_save_event( Saves an event to the database. """ - set_current_event_project(project_id) + # justification: the SDK accepts None when no project is known; the helper is typed as int. + set_current_event_project(cast("int", project_id)) from sentry.event_manager import EventManager, resolve_project from sentry.exceptions import HashDiscarded diff --git a/src/sentry/tasks/summaries/utils.py b/src/sentry/tasks/summaries/utils.py index ffcf667ccaef..4d31d4ae23d5 100644 --- a/src/sentry/tasks/summaries/utils.py +++ b/src/sentry/tasks/summaries/utils.py @@ -76,7 +76,7 @@ def __init__(self, timestamp: float, duration: int, organization: Organization): def __repr__(self) -> str: return self.projects_context_map.__repr__() - def is_empty(self): + def is_empty(self) -> bool: """ Returns True if every project context is empty. """ @@ -86,6 +86,17 @@ def is_empty(self): ) +KeyPerformanceIssue = tuple[Group, int] | tuple[Group, GroupHistory | None, int] + + +def key_performance_issue_with_history( + issue: KeyPerformanceIssue, +) -> tuple[Group, GroupHistory | None, int]: + if len(issue) == 3: + return issue + return issue[0], None, issue[1] + + class ProjectContext: accepted_error_count = 0 prev_week_accepted_error_count = 0 @@ -97,22 +108,22 @@ class ProjectContext: total_substatus_count = 0 prev_week_total_substatus_count = 0 - def __init__(self, project): + def __init__(self, project: Project) -> None: self.project = project self.key_error_issues_by_id: list[tuple[int, int]] = [] self.key_error_issues: list[tuple[Group, int]] = [] # Array of (Group, count) - self.key_performance_issues = [] + self.key_performance_issues: list[KeyPerformanceIssue] = [] # Array of (Group, event_count, resolution_label, resolution_url) self.past_resolved_issues: list[tuple[Group, int, str, str | None]] = [] - self.key_replay_events = [] + self.key_replay_events: list[object] = [] # Dictionary of { timestamp: count } - self.error_count_by_day = {} + self.error_count_by_day: dict[int, int] = {} # Dictionary of { timestamp: count } - self.issue_count_by_day = {} + self.issue_count_by_day: dict[int, int] = {} def __repr__(self) -> str: return "\n".join( @@ -122,7 +133,7 @@ def __repr__(self) -> str: ] ) - def check_if_project_is_empty(self): + def check_if_project_is_empty(self) -> bool: return ( not self.key_error_issues and not self.key_performance_issues @@ -233,9 +244,11 @@ def org_key_error_issues( return results -def project_key_performance_issues(ctx: OrganizationReportContext, project: Project, referrer: str): +def project_key_performance_issues( + ctx: OrganizationReportContext, project: Project, referrer: str +) -> list[KeyPerformanceIssue] | None: if not project.first_event: - return + return None op = "weekly_reports.project_key_performance_issues" @@ -257,7 +270,7 @@ def project_key_performance_issues(ctx: OrganizationReportContext, project: Proj group_id_to_group = {group.id: group for group in groups} if len(group_id_to_group) == 0: - return + return None snuba_rows = _project_key_performance_issues_snuba( ctx=ctx, @@ -294,7 +307,7 @@ def project_key_performance_issues(ctx: OrganizationReportContext, project: Proj }, ) - key_performance_issues = [] + key_performance_issues: list[KeyPerformanceIssue] = [] for result in query_result: count = result["count()"] group_id = result["group_id"] @@ -334,7 +347,10 @@ def _project_key_performance_issues_snuba( query=query, tenant_ids={"organization_id": ctx.organization.id}, ) - return raw_snql_query(request, referrer=referrer)["data"] + rows = raw_snql_query(request, referrer=referrer)["data"] + if not isinstance(rows, list): + raise ValueError("Snuba returned no data array") + return rows def _project_key_performance_issues_eap( @@ -417,11 +433,11 @@ def fetch_key_error_issues(ctx: OrganizationReportContext) -> None: ] -def fetch_key_performance_issues(ctx: OrganizationReportContext): +def fetch_key_performance_issues(ctx: OrganizationReportContext) -> None: # Organization pass. Depends on project_key_performance_issue. all_groups = [] for project_ctx in ctx.projects_context_map.values(): - all_groups.extend([group for group, count in project_ctx.key_performance_issues]) + all_groups.extend([issue[0] for issue in project_ctx.key_performance_issues]) if len(all_groups) == 0: return @@ -440,12 +456,14 @@ def fetch_key_performance_issues(ctx: OrganizationReportContext): for project_ctx in ctx.projects_context_map.values(): project_ctx.key_performance_issues = [ - (group, group_id_to_group_history.get(group.id, None), count) - for group, count in project_ctx.key_performance_issues + (issue[0], group_id_to_group_history.get(issue[0].id, None), issue[-1]) + for issue in project_ctx.key_performance_issues ] -def project_event_counts_for_organization(start, end, ctx, referrer: str) -> list[dict[str, Any]]: +def project_event_counts_for_organization( + start: datetime, end: datetime, ctx: OrganizationReportContext, referrer: str +) -> list[dict[str, Any]]: """ Populates context.projects which is { project_id: ProjectContext } """ @@ -478,6 +496,8 @@ def project_event_counts_for_organization(start, end, ctx, referrer: str) -> lis tenant_ids={"organization_id": ctx.organization.id}, ) data = raw_snql_query(request, referrer=referrer)["data"] + if not isinstance(data, list): + raise ValueError("Snuba returned no data array") return data diff --git a/src/sentry/tasks/summaries/weekly_reports.py b/src/sentry/tasks/summaries/weekly_reports.py index 4d834a888a57..ada13bf0639a 100644 --- a/src/sentry/tasks/summaries/weekly_reports.py +++ b/src/sentry/tasks/summaries/weekly_reports.py @@ -4,7 +4,7 @@ import logging import uuid import zoneinfo -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass from datetime import datetime, timedelta from functools import partial @@ -46,6 +46,7 @@ PAST_ISSUES_LINK_BOOST, OrganizationReportContext, ProjectContext, + key_performance_issue_with_history, ) from sentry.tasks.summaries.weekly_report_cache import cache_project_metrics from sentry.taskworker.namespaces import reports_tasks @@ -193,7 +194,7 @@ def prepare_organization_report( dry_run: bool = False, target_user: int | None = None, email_override: str | None = None, -): +) -> None: traces.new_trace() propagation_context = sentry_sdk.get_current_scope().get_active_propagation_context() prev_sampling_context = propagation_context.custom_sampling_context @@ -662,7 +663,7 @@ def _top_spans_chart_url( def render_template_context( - ctx, + ctx: OrganizationReportContext, user_id: int | None, excluded_project_ids: set[int] | None = None, spans_chart_cache: dict[frozenset[str], str | None] | None = None, @@ -698,7 +699,7 @@ def _substatus_url(project_ctx: ProjectContext, query: str) -> str: def _multi_project_substatus_url(project_ctxs: list[ProjectContext], query: str) -> str: path = f"/organizations/{ctx.organization.slug}/issues/" - params = [ + params: list[tuple[str, str | int]] = [ ("referrer", "weekly_report"), ("notification_uuid", notification_uuid), ("query", query), @@ -707,9 +708,9 @@ def _multi_project_substatus_url(project_ctxs: list[ProjectContext], query: str) params.append(("project", pc.project.id)) return ctx.organization.absolute_url(path, query=urlencode(params)) - def trends(): + def trends() -> dict[str, Any]: # justification: report context is dynamic JSON. # Given an iterator of event counts, sum up their accepted errors/transaction counts. - def sum_error_counts(project_ctxs): + def sum_error_counts(project_ctxs: Sequence[ProjectContext]) -> int: return sum(project_ctx.accepted_error_count for project_ctx in project_ctxs) # Highest volume projects go first @@ -799,10 +800,11 @@ def sum_error_counts(project_ctxs): ) # Calculate series - series = [] + # justification: report context is dynamic JSON. + series: list[tuple[datetime, list[dict[str, Any]]]] = [] for i in range(0, 7): t = int(ctx.start.timestamp()) + ONE_DAY * i - project_series = [ + project_series: list[dict[str, Any]] = [ # justification: report context. { "color": project_breakdown_colors[i], "error_count": project_ctx.error_count_by_day.get(t, 0), @@ -847,8 +849,8 @@ def sum_error_counts(project_ctxs): ), } - def top_issues(): - def all_issues(): + def top_issues() -> list[dict[str, Any]]: # justification: report context is dynamic JSON. + def all_issues() -> Iterator[dict[str, Any]]: # justification: report context. for project_ctx in user_projects: for group, count in project_ctx.key_error_issues: display = get_group_display(group) @@ -870,7 +872,8 @@ def all_issues(): "group_substatus_text_color": substatus_text_color, } - for group, group_history, count in project_ctx.key_performance_issues: + for issue in project_ctx.key_performance_issues: + group, group_history, count = key_performance_issue_with_history(issue) display = get_group_display(group) ( substatus, @@ -897,8 +900,8 @@ def all_issues(): return heapq.nlargest(5, all_issues(), lambda d: d["count"]) - def past_issues(): - def all_past_issues(): + def past_issues() -> list[dict[str, Any]]: # justification: report context is dynamic JSON. + def all_past_issues() -> Iterator[dict[str, Any]]: # justification: report context. for project_ctx in user_projects: for ( group, @@ -920,7 +923,7 @@ def all_past_issues(): return heapq.nlargest(3, all_past_issues(), lambda d: d["_relevance"]) - def issue_summary(): + def issue_summary() -> dict[str, int]: new_substatus_count = 0 escalating_substatus_count = 0 ongoing_substatus_count = 0 @@ -940,7 +943,7 @@ def issue_summary(): "total_substatus_count": total_substatus_count, } - def top_spans(): + def top_spans() -> dict[str, Any]: # justification: report context is dynamic JSON. user_total_spans_count = sum( count for pid, count in ctx.spans_count_by_project.items() if pid in user_project_ids ) @@ -1039,7 +1042,7 @@ def top_spans(): def prepare_template_context( ctx: OrganizationReportContext, user_ids: Sequence[int | None] -) -> list[Mapping[str, Any]] | list: +) -> list[dict[str, Any]]: # justification: report context is dynamic JSON. exclusions_by_user: dict[int, set[int]] = {} valid_user_ids = [uid for uid in user_ids if uid is not None] if valid_user_ids: @@ -1050,7 +1053,9 @@ def prepare_template_context( exclusions_by_user.setdefault(exc_user_id, set()).add(exc_project_id) spans_chart_cache: dict[frozenset[str], str | None] = {} - user_template_context_by_user_id_list = [] + user_template_context_by_user_id_list: list[ + dict[str, Any] # justification: report context is dynamic JSON. + ] = [] # justification: report context. for user_id in user_ids: excluded = exclusions_by_user.get(user_id) if isinstance(user_id, int) else None template_ctx = render_template_context( diff --git a/src/sentry/tasks/unmerge.py b/src/sentry/tasks/unmerge.py index 86c11b5b810d..c3171acb04c9 100644 --- a/src/sentry/tasks/unmerge.py +++ b/src/sentry/tasks/unmerge.py @@ -102,7 +102,8 @@ def _initial_level(event: GroupEvent, group: Group) -> int: return logging.ERROR if level is None else level -initial_fields = { +# justification: field values are heterogeneous event attributes merged into the group row. +initial_fields: dict[str, Callable[[GroupEvent, Group], Any]] = { "culprit": lambda event, group: generate_culprit(event.data), "data": lambda event, group: { "last_received": event.data.get("received") or float(event.datetime.strftime("%s")), @@ -118,7 +119,8 @@ def _initial_level(event: GroupEvent, group: Group) -> int: } -backfill_fields = { +# justification: field values are heterogeneous event attributes merged into the group row. +backfill_fields: dict[str, Callable[[Mapping[str, Any], dict[str, Any], GroupEvent], Any]] = { "platform": lambda caches, data, event: event.platform, "logger": lambda caches, data, event: event.get_tag("logger") or DEFAULT_LOGGER_NAME, "first_seen": lambda caches, data, event: event.datetime, diff --git a/src/sentry/tasks/user_report.py b/src/sentry/tasks/user_report.py index 423845de26de..10579fcd54da 100644 --- a/src/sentry/tasks/user_report.py +++ b/src/sentry/tasks/user_report.py @@ -13,8 +13,11 @@ silo_mode=SiloMode.CELL, ) def user_report( - project_id: int, report: Mapping[str, Any] | None = None, report_id: int | None = None, **kwargs -): + project_id: int, + report: Mapping[str, Any] | None = None, + report_id: int | None = None, + **kwargs: object, +) -> None: """ Create and send a UserReport. @@ -33,5 +36,5 @@ def user_report( user_report = UserReport.objects.get(id=report_id) serialized_report = serialize(user_report, AnonymousUser(), UserReportWithGroupSerializer()) safe_execute(mail_adapter.handle_user_report, report=serialized_report, project=project) - else: + elif report is not None: safe_execute(mail_adapter.handle_user_report, report=report, project=project) diff --git a/src/sentry/taskworker/adapters.py b/src/sentry/taskworker/adapters.py index 9fd431a6da3f..6cca5c006569 100644 --- a/src/sentry/taskworker/adapters.py +++ b/src/sentry/taskworker/adapters.py @@ -10,7 +10,7 @@ import contextlib import logging import threading -from collections.abc import MutableMapping +from collections.abc import Mapping, MutableMapping from typing import Any import orjson @@ -94,8 +94,10 @@ def route_namespace(self, name: str) -> str: # Check global overrides overrides = options.get("taskworker.route.overrides") - if name in overrides: - return overrides[name] + if isinstance(overrides, Mapping) and name in overrides: + route = overrides[name] + if isinstance(route, str): + return route # Check for configured mapping if name in self._route_map: diff --git a/src/sentry/tempest/serializers.py b/src/sentry/tempest/serializers.py index 625e636be2de..6bd13dc3543e 100644 --- a/src/sentry/tempest/serializers.py +++ b/src/sentry/tempest/serializers.py @@ -1,7 +1,12 @@ +from collections.abc import Mapping, MutableMapping, Sequence + +from django.contrib.auth.models import AnonymousUser from rest_framework import serializers from sentry.api.serializers.base import Serializer, register from sentry.tempest.models import TempestCredentials +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service @@ -12,24 +17,36 @@ def _obfuscate_client_secret(self, client_secret: str) -> str: def get_attrs( self, - item_list, - user, - **kwargs, - ): + item_list: Sequence[TempestCredentials], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> MutableMapping[TempestCredentials, dict[str, RpcUser | None]]: users_mapping = {} user_ids = [item.created_by_id for item in item_list if item.created_by_id is not None] users = user_service.get_many_by_id(ids=user_ids) for rpc_user in users: users_mapping[rpc_user.id] = rpc_user - attrs = {} + attrs: MutableMapping[TempestCredentials, dict[str, RpcUser | None]] = {} for item in item_list: - attrs[item] = users_mapping.get(item.created_by_id) + attrs[item] = { + "user": ( + users_mapping.get(item.created_by_id) + if item.created_by_id is not None + else None + ) + } return attrs - def serialize(self, obj, attrs, user, **kwargs): - rpc_user = attrs + def serialize( + self, + obj: TempestCredentials, + attrs: Mapping[str, RpcUser | None], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> Mapping[str, object]: + rpc_user = attrs["user"] return { "id": obj.id, "clientId": obj.client_id, @@ -44,7 +61,7 @@ def serialize(self, obj, attrs, user, **kwargs): } -class DRFTempestCredentialsSerializer(serializers.ModelSerializer): +class DRFTempestCredentialsSerializer(serializers.ModelSerializer[TempestCredentials]): clientId = serializers.CharField(source="client_id") clientSecret = serializers.CharField(source="client_secret") message = serializers.CharField(read_only=True) diff --git a/src/sentry/tempest/tasks.py b/src/sentry/tempest/tasks.py index 5cb4d4aa4a87..eaa73d2af464 100644 --- a/src/sentry/tempest/tasks.py +++ b/src/sentry/tempest/tasks.py @@ -29,7 +29,7 @@ processing_deadline_duration=60, silo_mode=SiloMode.CELL, ) -def poll_tempest(**kwargs): +def poll_tempest(**kwargs: object) -> None: for credentials in TempestCredentials.objects.select_related("project__organization").all(): # Note: We are caching credentials in the database even if users don't have access to them. # However the latest_fetched_item_id is unique per credentials, and we do not allow PUT/PATCH @@ -61,7 +61,7 @@ def poll_tempest(**kwargs): processing_deadline_duration=60, silo_mode=SiloMode.CELL, ) -def fetch_latest_item_id(credentials_id: int, **kwargs) -> None: +def fetch_latest_item_id(credentials_id: int, **kwargs: object) -> None: # Lock duration should be slightly longer than the task deadline to prevent # overlapping tasks for the same credential. If a task is still running when # the next poll_tempest fires, the new task will skip this credential. @@ -253,7 +253,7 @@ def _fetch_latest_item_id_impl(credentials_id: int) -> None: processing_deadline_duration=60, silo_mode=SiloMode.CELL, ) -def poll_tempest_crashes(credentials_id: int, **kwargs) -> None: +def poll_tempest_crashes(credentials_id: int, **kwargs: object) -> None: # Lock duration should be slightly longer than the task deadline to prevent # overlapping tasks for the same credential. If a task is still running when # the next poll_tempest fires, the new task will skip this credential. diff --git a/src/sentry/templatetags/sentry_admin_helpers.py b/src/sentry/templatetags/sentry_admin_helpers.py index a68be0c80260..92f633f3d269 100644 --- a/src/sentry/templatetags/sentry_admin_helpers.py +++ b/src/sentry/templatetags/sentry_admin_helpers.py @@ -1,16 +1,18 @@ import datetime +from collections.abc import Iterator, Sequence from django import template from django.utils import timezone from sentry import tsdb +from sentry.models.project import Project from sentry.tsdb.base import TSDBModel register = template.Library() @register.filter -def with_event_counts(project_list): +def with_event_counts(project_list: Sequence[Project]) -> Iterator[tuple[Project, int]]: end = timezone.now() start = end - datetime.timedelta(days=1) 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..e1b2a4aee0fb 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, cast 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 +124,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_avatars.py b/src/sentry/templatetags/sentry_avatars.py index aaa7090773e3..30be7ad529d6 100644 --- a/src/sentry/templatetags/sentry_avatars.py +++ b/src/sentry/templatetags/sentry_avatars.py @@ -1,8 +1,10 @@ from urllib.parse import urlencode from django import template +from django.template import Context from django.urls import reverse +from sentry.models.organizationmember import OrganizationMember from sentry.users.models.user import User from sentry.users.services.user import RpcUser from sentry.users.services.user.service import user_service @@ -16,21 +18,23 @@ # The "mm" default is for the grey, "mystery man" icon. See: # http://en.gravatar.com/site/implement/images/ @register.simple_tag(takes_context=True) -def gravatar_url(context, email, size, default="mm"): +def gravatar_url(context: Context, email: str, size: int, default: str = "mm") -> str: return get_gravatar_url(email, size, default) @register.simple_tag(takes_context=True) -def letter_avatar_svg(context, display_name, identifier, size=None): +def letter_avatar_svg( + context: Context, display_name: str, identifier: str, size: int | None = None +) -> str: return get_letter_avatar(display_name, identifier, size=size) @register.simple_tag(takes_context=True) -def profile_photo_url(context, user_id, size=None): +def profile_photo_url(context: Context, user_id: int, size: int | None = None) -> str | None: user_avatar = user_service.get_user_avatar(user_id=user_id) if not user_avatar: - return + return None url = reverse("sentry-user-avatar-url", args=[user_avatar.ident]) if size: url += "?" + urlencode({"s": size}) @@ -40,21 +44,29 @@ def profile_photo_url(context, user_id, size=None): # Don't use this in any situations where you're rendering more # than 1-2 avatars. It will make a request for every user! @register.simple_tag(takes_context=True) -def email_avatar(context, display_name, identifier, size=None, try_gravatar=True): +def email_avatar( + context: Context, + display_name: str, + identifier: str, + size: int | None = None, + try_gravatar: bool = True, +) -> str: return get_email_avatar(display_name, identifier, size, try_gravatar) @register.inclusion_tag("sentry/partial/avatar.html") -def avatar(user, size=36): +def avatar(user: User | RpcUser | OrganizationMember, size: int = 36) -> dict[str, object]: # user can be User or OrganizationMember - if isinstance(user, User) or isinstance(user, RpcUser): + email: str | None + user_id: int | None + if isinstance(user, (User, RpcUser)): user_id = user.id email = user.email else: user_id = user.user_id email = user.email if user_id: - email = user.user.email + email = getattr(user, "user").email return { "email": email, @@ -67,16 +79,20 @@ def avatar(user, size=36): @register.inclusion_tag("sentry/partial/avatar.html") -def avatar_for_email(user, size=36): +def avatar_for_email( + user: User | RpcUser | OrganizationMember, size: int = 36 +) -> dict[str, object]: # user can be User or OrganizationMember - if isinstance(user, User) or isinstance(user, RpcUser): + email: str | None + user_id: int | None + if isinstance(user, (User, RpcUser)): user_id = user.id email = user.email else: user_id = user.user_id email = user.email if user_id: - email = user.user.email + email = getattr(user, "user").email return { "for_email": True, "email": email, 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/templatetags/sentry_helpers.py b/src/sentry/templatetags/sentry_helpers.py index 859abc51d3db..fb579b742b00 100644 --- a/src/sentry/templatetags/sentry_helpers.py +++ b/src/sentry/templatetags/sentry_helpers.py @@ -2,11 +2,15 @@ import os.path import random from collections import namedtuple +from collections.abc import Iterable, Mapping, Sequence +from datetime import date as date_type from datetime import datetime, timedelta, timezone from random import randint +from typing import cast from urllib.parse import quote, urlencode from django import template +from django.template import Context from django.template.defaultfilters import stringfilter from django.utils import timezone as django_timezone from django.utils.safestring import mark_safe @@ -27,13 +31,13 @@ @register.filter -def to_json(obj, request=None): +def to_json(obj: object, request: object = None) -> str: return json.dumps_htmlsafe(obj) @register.filter -def multiply(x, y): - def coerce(value): +def multiply(x: int | float | str, y: int | float | str) -> int | float: + def coerce(value: int | float | str) -> int | float: if isinstance(value, ((int,), float)): return value try: @@ -45,11 +49,11 @@ def coerce(value): class AbsoluteUriNode(template.Node): - def __init__(self, args, target_var): + def __init__(self, args: Sequence[str], target_var: str | None) -> None: self.args = args self.target_var = target_var - def render(self, context): + def render(self, context: Context) -> str: from sentry.utils.http import absolute_uri # Attempt to resolve all arguments into actual variables. @@ -87,7 +91,7 @@ def render(self, context): @register.tag -def absolute_uri(parser, token): +def absolute_uri(parser: template.base.Parser, token: template.base.Token) -> AbsoluteUriNode: bits = token.split_contents()[1:] # Check if the last two bits are `as {var}` if len(bits) >= 2 and bits[-2] == "as": @@ -99,13 +103,19 @@ def absolute_uri(parser, token): @register.simple_tag -def org_url(organization, path, query=None, fragment=None) -> str: +def org_url( + organization: object, + path: str, + query: str | None = None, + fragment: str | None = None, +) -> str: """ Generate an absolute url for an organization """ if not hasattr(organization, "absolute_url"): raise RuntimeError("organization parameter is not an Organization instance") - return organization.absolute_url(path, query=query, fragment=fragment) + # justification: Organization.absolute_url is present, checked above. + return cast("str", organization.absolute_url(path, query=query, fragment=fragment)) LOADING_MESSAGES = [ @@ -128,14 +138,14 @@ def org_url(organization, path, query=None, fragment=None) -> str: ] -def _get_organization_from_context(context): +def _get_organization_from_context(context: Context) -> object | None: org_context = context.get("org_context") if org_context is not None: return getattr(org_context, "organization", None) return None -def _is_themed_loader(context) -> bool: +def _is_themed_loader(context: Context) -> bool: organization = _get_organization_from_context(context) if organization is None: return False @@ -145,7 +155,7 @@ def _is_themed_loader(context) -> bool: @register.simple_tag(takes_context=True) -def loading_message(context): +def loading_message(context: Context) -> str: options = list(LOADING_MESSAGES) if _is_themed_loader(context): options.extend(PRIDE_LOADING_MESSAGES) @@ -153,7 +163,7 @@ def loading_message(context): @register.simple_tag(takes_context=True) -def loader(context): +def loader(context: Context) -> str: from django.template.loader import render_to_string template_name = ( @@ -161,28 +171,31 @@ def loader(context): if _is_themed_loader(context) else "sentry/partial/loader.html" ) - return mark_safe(render_to_string(template_name, context.flatten())) + return mark_safe( + render_to_string(template_name, cast("Mapping[str, object]", context.flatten())) + ) @register.simple_tag -def querystring(**kwargs): +def querystring(**kwargs: object) -> str: return urlencode(kwargs, doseq=False) @register.simple_tag -def system_origin(): +def system_origin() -> str: from sentry.utils.http import absolute_uri, origin_from_url return origin_from_url(absolute_uri()) @register.simple_tag -def security_contact(): - return options.get("system.security-email") or options.get("system.admin-email") +def security_contact() -> str | None: + email = options.get("system.security-email") or options.get("system.admin-email") + return email if isinstance(email, str) else None @register.filter -def is_url(value): +def is_url(value: object) -> bool: if not isinstance(value, str): return False if not value.startswith(("http://", "https://")): @@ -193,17 +206,17 @@ def is_url(value): @register.filter -def absolute_value(value): +def absolute_value(value: int | float | str) -> int | float: return abs(int(value) if isinstance(value, int) else float(value)) @register.filter -def as_sorted(value): +def as_sorted(value: Iterable[str]) -> list[str]: return sorted(value) @register.filter -def small_count(v, precision=1): +def small_count(v: int | float | str | None, precision: int = 1) -> int | str: if not v: return 0 z = [(1000000000, _("b")), (1000000, _("m")), (1000, _("k"))] @@ -218,8 +231,10 @@ def small_count(v, precision=1): @register.filter -def format_duration_ms(v): +def format_duration_ms(v: int | float | str | None) -> str: """Format a duration in milliseconds to a human-readable string.""" + if v is None: + return "0ms" try: v = float(v) except (TypeError, ValueError): @@ -248,18 +263,23 @@ def format_duration_ms(v): @register.filter -def as_tag_alias(v): - return {"sentry:release": "release", "sentry:dist": "dist", "sentry:user": "user"}.get(v, v) +def as_tag_alias(v: object) -> object: + aliases: Mapping[object, object] = { + "sentry:release": "release", + "sentry:dist": "dist", + "sentry:user": "user", + } + return aliases.get(v, v) @register.simple_tag(takes_context=True) -def serialize(context, value): +def serialize(context: Context, value: object) -> str: value = serialize_func(value, context["request"].user) return json.dumps_htmlsafe(value) @register.simple_tag(takes_context=True) -def get_sentry_version(context): +def get_sentry_version(context: Context) -> str: import sentry current = sentry.VERSION @@ -273,7 +293,7 @@ def get_sentry_version(context): @register.filter -def timesince(value, now=None): +def timesince(value: datetime | None, now: datetime | None = None) -> str | date_type: from django.utils.timesince import timesince if now is None: @@ -282,21 +302,23 @@ def timesince(value, now=None): return _("never") if value < (now - timedelta(days=5)): return value.date() - value = (" ".join(timesince(value, now).split(" ")[0:2])).strip(",") - if value == _("0 minutes"): + formatted = (" ".join(timesince(value, now).split(" ")[0:2])).strip(",") + if formatted == _("0 minutes"): return _("just now") - if value == _("1 day"): + if formatted == _("1 day"): return _("yesterday") - return _("%s ago") % value + return _("%s ago") % formatted @register.filter -def duration(value): +def duration(value: int | float | None) -> str: if not value: return "0s" # value is assumed to be in ms value = value / 1000.0 - hours, minutes, seconds = 0, 0, 0 + hours: float = 0 + minutes: float = 0 + seconds: float = 0 if value > 3600: hours = value / 3600 value = value % 3600 @@ -317,7 +339,7 @@ def duration(value): @register.filter -def date(dt, arg=None): +def date(dt: datetime | date_type | None, arg: str | None = None) -> str: from django.template.defaultfilters import date if isinstance(dt, datetime) and not django_timezone.is_aware(dt): @@ -326,7 +348,7 @@ def date(dt, arg=None): @register.filter -def day_initial(dt): +def day_initial(dt: datetime) -> str: """Returns the single-letter day-of-week initial (M, T, W, T, F, S, S).""" if isinstance(dt, datetime) and not django_timezone.is_aware(dt): dt = dt.replace(tzinfo=timezone.utc) @@ -334,7 +356,9 @@ def day_initial(dt): @register.simple_tag -def percent(value, total, format=None): +def percent( + value: int | float | str | None, total: int | float | str | None, format: str | None = None +) -> int | str: if not (value and total): result = 0.0 else: @@ -347,47 +371,47 @@ def percent(value, total, format=None): @register.filter -def titleize(value): +def titleize(value: str) -> str: return value.replace("_", " ").title() @register.filter -def split(value, delim=""): +def split(value: str, delim: str = "") -> list[str]: return value.split(delim) @register.filter -def urlquote(value, safe=""): +def urlquote(value: str, safe: str = "") -> str: return quote(value.encode("utf8"), safe) @register.filter -def basename(value): +def basename(value: str) -> str: return os.path.basename(value) @register.filter -def soft_break(value, length): +def soft_break(value: str, length: int) -> str: return _soft_break( value, length, functools.partial(soft_hyphenate, length=max(length // 10, 10)) ) @register.simple_tag -def random_int(a, b=None): +def random_int(a: int, b: int | None = None) -> int: if b is None: a, b = 0, a return randint(a, b) @register.filter -def get_item(dictionary, key): +def get_item(dictionary: Mapping[object, object], key: object) -> object: return dictionary.get(key, "") @register.filter @stringfilter -def sanitize_periods(value): +def sanitize_periods(value: str) -> str: """ Primarily used in email templates when a field may contain a domain name to prevent email clients from creating a clickable link to the domain. diff --git a/src/sentry/templatetags/sentry_platforms.py b/src/sentry/templatetags/sentry_platforms.py index 0cb90a2489a2..5047f57ff58f 100644 --- a/src/sentry/templatetags/sentry_platforms.py +++ b/src/sentry/templatetags/sentry_platforms.py @@ -1,4 +1,5 @@ from django import template +from django.template import Context from sentry.utils.avatar import get_letter_avatar, get_platform_avatar @@ -6,7 +7,13 @@ @register.simple_tag(takes_context=True) -def letter_platform_svg(context, display_name, identifier, size=None, rounded=False): +def letter_platform_svg( + context: Context, + display_name: str, + identifier: str, + size: int | None = None, + rounded: bool = False, +) -> str: return get_letter_avatar( display_name, identifier, @@ -18,5 +25,5 @@ def letter_platform_svg(context, display_name, identifier, size=None, rounded=Fa @register.simple_tag(takes_context=True) -def platform_avatar(context, display_name, size=36): +def platform_avatar(context: Context, display_name: str, size: int = 36) -> str: return get_platform_avatar(display_name, size) diff --git a/src/sentry/templatetags/sentry_react.py b/src/sentry/templatetags/sentry_react.py index a1f91609b5e2..84a8ae3d4d06 100644 --- a/src/sentry/templatetags/sentry_react.py +++ b/src/sentry/templatetags/sentry_react.py @@ -1,4 +1,5 @@ from django import template +from django.template import Context from sentry.utils import json from sentry.web.client_config import get_client_config @@ -7,7 +8,7 @@ @register.simple_tag(takes_context=True) -def get_react_config(context): - context = get_client_config(context.get("request", None), context.get("org_context")) +def get_react_config(context: Context) -> str: + client_config = get_client_config(context.get("request", None), context.get("org_context")) - return json.dumps_htmlsafe(context) + return json.dumps_htmlsafe(client_config) diff --git a/src/sentry/templatetags/sentry_status.py b/src/sentry/templatetags/sentry_status.py index 06aabe79c485..26693140f9c1 100644 --- a/src/sentry/templatetags/sentry_status.py +++ b/src/sentry/templatetags/sentry_status.py @@ -1,6 +1,7 @@ import itertools from django import template +from django.template import Context from sentry import status_checks from sentry.status_checks import sort_by_severity @@ -9,6 +10,6 @@ @register.inclusion_tag("sentry/partial/system-status.html", takes_context=True) -def show_system_status(context): +def show_system_status(context: Context) -> dict[str, object]: problems = itertools.chain.from_iterable(status_checks.check_all().values()) return {"problems": sort_by_severity(problems)} diff --git a/src/sentry/templatetags/sentry_trace.py b/src/sentry/templatetags/sentry_trace.py index dbb3feda9ee2..beea048f9fd1 100644 --- a/src/sentry/templatetags/sentry_trace.py +++ b/src/sentry/templatetags/sentry_trace.py @@ -5,10 +5,10 @@ @register.simple_tag -def get_sentry_trace(): +def get_sentry_trace() -> str | None: return sentry_sdk.get_traceparent() @register.simple_tag -def get_sentry_baggage(): +def get_sentry_baggage() -> str | None: return sentry_sdk.get_baggage() diff --git a/src/sentry/testutils/asserts.py b/src/sentry/testutils/asserts.py index aa5edde45c61..067b1fd42a49 100644 --- a/src/sentry/testutils/asserts.py +++ b/src/sentry/testutils/asserts.py @@ -1,10 +1,12 @@ -from collections.abc import Iterator +from collections.abc import Iterator, Mapping, Sequence from contextlib import contextmanager from functools import reduce +from typing import Any from unittest import mock from django.db import models from django.db.models.functions import Cast +from django.http import HttpResponse from sentry.constants import ObjectStatus from sentry.integrations.types import EventLifecycleOutcome @@ -16,7 +18,7 @@ from sentry.testutils.silo import assume_test_silo_mode -def assert_mock_called_once_with_partial(mock, *args, **kwargs): +def assert_mock_called_once_with_partial(mock: mock.Mock, *args: object, **kwargs: object) -> None: """ Similar to ``mock.assert_called_once_with()``, but we don't require all args and kwargs to be specified. @@ -29,7 +31,7 @@ def assert_mock_called_once_with_partial(mock, *args, **kwargs): assert m_kwargs[kwarg] == kwargs[kwarg], (m_kwargs[kwarg], kwargs[kwarg]) -def assert_commit_shape(commit): +def assert_commit_shape(commit: Mapping[str, Any]) -> None: assert commit["id"] assert commit["repository"] assert commit["author_email"] @@ -43,7 +45,7 @@ def assert_commit_shape(commit): assert patch["path"] -def assert_status_code(response, minimum: int, maximum: int | None = None): +def assert_status_code(response: HttpResponse, minimum: int, maximum: int | None = None) -> None: # Omit max to assert status_code == minimum. maximum = maximum or minimum + 1 assert minimum <= response.status_code < maximum, response @@ -69,7 +71,7 @@ def assert_no_organization_resource_hydration() -> Iterator[None]: def assert_existing_projects_status( org: Organization, active_project_ids: list[int], deleted_project_ids: list[int] -): +) -> None: all_projects = Project.objects.filter(organization=org).values_list("id", "status") active_ids = [] deleted_or_to_be_deleted_ids = [] @@ -85,10 +87,13 @@ def assert_existing_projects_status( @assume_test_silo_mode(SiloMode.CONTROL) -def org_audit_log_exists(**kwargs): +def org_audit_log_exists(**kwargs: object) -> bool: assert kwargs - if "organization" in kwargs: - kwargs["organization_id"] = kwargs.pop("organization").id + organization = kwargs.get("organization") + if organization is not None: + assert isinstance(organization, Organization) + kwargs["organization_id"] = organization.id + kwargs.pop("organization") if "data" in kwargs: kwargs["data__asjsonb"] = kwargs.pop("data") return ( @@ -99,7 +104,7 @@ def org_audit_log_exists(**kwargs): ) -def assert_org_audit_log_exists(**kwargs): +def assert_org_audit_log_exists(**kwargs: object) -> None: assert org_audit_log_exists(**kwargs) @@ -108,7 +113,7 @@ def assert_org_audit_log_exists(**kwargs): """ -def assert_halt_metric(mock_record, error_msg): +def assert_halt_metric(mock_record: mock.Mock, error_msg: object) -> None: (event_halts,) = ( call for call in mock_record.mock_calls if call.args[0] == EventLifecycleOutcome.HALTED ) @@ -118,7 +123,7 @@ def assert_halt_metric(mock_record, error_msg): assert event_halts.args[1] == error_msg -def assert_failure_metric(mock_record, error_msg): +def assert_failure_metric(mock_record: mock.Mock, error_msg: object) -> None: (event_failures,) = ( call for call in mock_record.mock_calls if call.args[0] == EventLifecycleOutcome.FAILURE ) @@ -128,7 +133,7 @@ def assert_failure_metric(mock_record, error_msg): assert event_failures.args[1] == error_msg -def assert_success_metric(mock_record): +def assert_success_metric(mock_record: mock.Mock) -> None: event_success = ( call for call in mock_record.mock_calls if call.args[0] == EventLifecycleOutcome.SUCCESS ) @@ -136,8 +141,9 @@ def assert_success_metric(mock_record): def assert_slo_metric( - mock_record, event_outcome: EventLifecycleOutcome = EventLifecycleOutcome.SUCCESS -): + mock_record: mock.Mock, + event_outcome: EventLifecycleOutcome = EventLifecycleOutcome.SUCCESS, +) -> None: assert len(mock_record.mock_calls) == 2 start, end = mock_record.mock_calls assert start.args[0] == EventLifecycleOutcome.STARTED @@ -145,14 +151,15 @@ def assert_slo_metric( def assert_slo_metric_calls( - mock_calls, event_outcome: EventLifecycleOutcome = EventLifecycleOutcome.SUCCESS -): + mock_calls: Sequence[mock._Call], + event_outcome: EventLifecycleOutcome = EventLifecycleOutcome.SUCCESS, +) -> None: start, end = mock_calls assert start.args[0] == EventLifecycleOutcome.STARTED assert end.args[0] == event_outcome -def assert_middleware_metrics(middleware_calls): +def assert_middleware_metrics(middleware_calls: Sequence[mock._Call]) -> None: start1, end1, start2, end2 = middleware_calls assert start1.args[0] == EventLifecycleOutcome.STARTED assert end1.args[0] == EventLifecycleOutcome.SUCCESS @@ -160,7 +167,9 @@ def assert_middleware_metrics(middleware_calls): assert end2.args[0] == EventLifecycleOutcome.SUCCESS -def assert_count_of_metric(mock_record, outcome, outcome_count): +def assert_count_of_metric( + mock_record: mock.Mock, outcome: EventLifecycleOutcome, outcome_count: int +) -> None: calls = reduce( lambda acc, calls: (acc + 1 if calls.args[0] == outcome else acc), mock_record.mock_calls, @@ -170,7 +179,7 @@ def assert_count_of_metric(mock_record, outcome, outcome_count): # Given messages_or_errors need to align 1:1 to the calls :bufo-big-eyes: -def assert_many_halt_metrics(mock_record, messages_or_errors): +def assert_many_halt_metrics(mock_record: mock.Mock, messages_or_errors: Sequence[object]) -> None: halts = ( call for call in mock_record.mock_calls if call.args[0] == EventLifecycleOutcome.HALTED ) @@ -182,7 +191,9 @@ def assert_many_halt_metrics(mock_record, messages_or_errors): # Given messages_or_errors need to align 1:1 to the calls :bufo-big-eyes: -def assert_many_failure_metrics(mock_record, messages_or_errors): +def assert_many_failure_metrics( + mock_record: mock.Mock, messages_or_errors: Sequence[object] +) -> None: failures = ( call for call in mock_record.mock_calls if call.args[0] == EventLifecycleOutcome.FAILURE ) diff --git a/src/sentry/testutils/cases.py b/src/sentry/testutils/cases.py index 9f5f342c638d..1ee2c197c521 100644 --- a/src/sentry/testutils/cases.py +++ b/src/sentry/testutils/cases.py @@ -6,21 +6,22 @@ import re import time import uuid -from collections.abc import Callable, Generator, Mapping, Sequence -from contextlib import contextmanager +from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence +from contextlib import AbstractContextManager, contextmanager from datetime import UTC, datetime, timedelta from enum import Enum from io import BytesIO -from typing import Any, Literal, TypedDict, Union +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, Union, cast from unittest import TestLoader, mock from urllib.parse import urlencode from uuid import UUID, uuid4 from zlib import compress +import click import pytest import requests import responses -from click.testing import CliRunner +from click.testing import CliRunner, Result from django.conf import settings from django.contrib.auth import login from django.contrib.auth.models import AnonymousUser @@ -28,7 +29,9 @@ from django.core.cache import cache from django.db import connections from django.db.migrations.executor import MigrationExecutor -from django.http import HttpRequest +from django.db.migrations.state import ProjectState +from django.db.models import QuerySet +from django.http import HttpRequest, HttpResponse, HttpResponseBase from django.test import RequestFactory, override_settings from django.test import TestCase as DjangoTestCase from django.test import TransactionTestCase as DjangoTransactionTestCase @@ -115,9 +118,8 @@ from sentry.models.release import Release from sentry.models.releasecommit import ReleaseCommit from sentry.models.repository import Repository -from sentry.models.rule import RuleSource +from sentry.models.rule import Rule, RuleSource from sentry.monitors.models import Monitor, MonitorEnvironment, ScheduleType -from sentry.new_migrations.monkey.state import SentryProjectState from sentry.notifications.models.notificationsettingoption import ( NotificationSettingOption, ) @@ -129,6 +131,7 @@ from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.replays.lib.event_linking import transform_event_for_linking_payload from sentry.replays.models import ReplayRecordingSegment +from sentry.rules.actions.base import EventAction from sentry.search.events.constants import ( METRIC_FRUSTRATED_TAG_VALUE, METRIC_SATISFACTION_TAG_KEY, @@ -186,6 +189,25 @@ from .silo import assume_test_silo_mode from .skips import requires_snuba +if TYPE_CHECKING: + from django.db.migrations.state import StateApps + from pytest_django.fixtures import DjangoCaptureOnCommitCallbacks + + from sentry.integrations.base import IntegrationProvider + from sentry.issue_detection.performance_problem import PerformanceProblem + from sentry.issues.grouptype import GroupType + from sentry.mail.adapter import MailAdapter + from sentry.models.commitfilechange import CommitFileChange + from sentry.models.dashboard_widget import DashboardWidgetQuery + from sentry.models.deletedproject import DeletedProject + from sentry.models.deletedteam import DeletedTeam + from sentry.models.group import Group + from sentry.models.releaseheadcommit import ReleaseHeadCommit + from sentry.models.team import Team + from sentry.replays.lib.event_linking import EventLinkKafkaMessage + from sentry.testutils.pytest.stale_database_reads import StaleDatabaseReads + from sentry.users.services.user import RpcUser + __all__ = ( "TestCase", "TransactionTestCase", @@ -225,18 +247,28 @@ SessionOrTransactionMRI = Union[SessionMRI, TransactionMRI] +class _ElevatedRequest(Protocol): + staff: Staff + superuser: Superuser + successful_authenticator: object | None + + class BaseTestCase(Fixtures): + client: Any # justification: Django and DRF test clients differ across subclasses. + @pytest.fixture(autouse=True) - def setup_dummy_auth_provider(self): + def setup_dummy_auth_provider(self) -> Generator[None]: auth.register(DummyProvider) yield auth.unregister(DummyProvider) - def tasks(self): + def tasks(self) -> AbstractContextManager[None]: return TaskRunner() @pytest.fixture(autouse=True) - def polyfill_capture_on_commit_callbacks(self, django_capture_on_commit_callbacks): + def polyfill_capture_on_commit_callbacks( + self, django_capture_on_commit_callbacks: DjangoCaptureOnCommitCallbacks + ) -> None: """ https://pytest-django.readthedocs.io/en/latest/helpers.html#django_capture_on_commit_callbacks @@ -246,18 +278,21 @@ def polyfill_capture_on_commit_callbacks(self, django_capture_on_commit_callback self.capture_on_commit_callbacks = django_capture_on_commit_callbacks @pytest.fixture(autouse=True) - def expose_stale_database_reads(self, stale_database_reads): + def expose_stale_database_reads(self, stale_database_reads: StaleDatabaseReads) -> None: self.stale_database_reads = stale_database_reads - def feature(self, names): + def feature( + self, names: str | Iterable[str] | dict[str, bool | list[str]] + ) -> AbstractContextManager[None]: """ >>> with self.feature({'feature:name': True}) >>> # ... """ return Feature(names) - def save_session(self): + def save_session(self) -> None: self.session.save() + assert self.session.session_key is not None self.save_cookie( name=settings.SESSION_COOKIE_NAME, value=self.session.session_key, @@ -268,24 +303,27 @@ def save_session(self): expires=None, ) - def save_cookie(self, name, value, **params): + def save_cookie(self, name: str, value: str, **params: Any) -> None: self.client.cookies[name] = value self.client.cookies[name].update({k.replace("_", "-"): v for k, v in params.items()}) def make_request( self, - user=None, - auth=None, - method="get", - is_superuser=False, - is_staff=False, - path="/", - secure_scheme=False, - subdomain=None, + user: User | RpcUser | AnonymousUser | None = None, + auth: Any = None, + method: str = "get", + is_superuser: bool | None = False, + is_staff: bool | None = False, + path: str = "/", + secure_scheme: bool = False, + subdomain: str | None = None, *, GET: dict[str, str] | None = None, ) -> HttpRequest: - request = getattr(RequestFactory(), method.lower())(path, query_params=GET) + # justification: RequestFactory returns an HttpRequest for every HTTP method name. + request = cast( + "HttpRequest", getattr(RequestFactory(), method.lower())(path, query_params=GET) + ) if subdomain: setattr(request, "subdomain", subdomain) request.META["REMOTE_ADDR"] = "127.0.0.1" @@ -300,56 +338,60 @@ def make_request( request.session = self.session set_request_principal(request, user or AnonymousUser(), auth) # must happen after request.user/request.session is populated - request.superuser = Superuser(request) - request.staff = Staff(request) + # justification: make_request attaches the superuser and staff principals below. + elevated_request = cast("_ElevatedRequest", request) + elevated_request.superuser = Superuser(request) + elevated_request.staff = Staff(request) if is_superuser: # XXX: this is gross, but it's a one-off and apis change only once in a great while - request.superuser.set_logged_in(user) + # justification: is_superuser is only set by callers that pass a persisted User. + elevated_request.superuser.set_logged_in(cast("User", user)) if is_staff: - request.staff.set_logged_in(user) - request.successful_authenticator = None + # justification: is_staff is only set by callers that pass a persisted User. + elevated_request.staff.set_logged_in(cast("User", user)) + elevated_request.successful_authenticator = None return request # TODO(dcramer): ideally superuser_sso would be False by default, but that would require # a lot of tests changing def login_as( self, - user, - organization_id=None, - organization_ids=None, - superuser=False, - staff=False, - staff_sso=True, - superuser_sso=True, - ): + user: User | OrganizationMember, + organization_id: int | None = None, + organization_ids: Iterable[int] | None = None, + superuser: bool = False, + staff: bool = False, + staff_sso: bool = True, + superuser_sso: bool = True, + ) -> None: if isinstance(user, OrganizationMember): with assume_test_silo_mode(SiloMode.CONTROL): + assert user.user_id is not None user = User.objects.get(id=user.user_id) user.backend = settings.AUTHENTICATION_BACKENDS[0] request = self.make_request() + elevated_request = cast(_ElevatedRequest, request) with assume_test_silo_mode(SiloMode.CONTROL): login(request, user) set_request_principal(request, user, request.auth) - if organization_ids is None: - organization_ids = set() - else: - organization_ids = set(organization_ids) + org_ids: set[int] = set(organization_ids) if organization_ids is not None else set() if superuser and superuser_sso is not False: if SUPERUSER_ORG_ID: - organization_ids.add(SUPERUSER_ORG_ID) + org_ids.add(SUPERUSER_ORG_ID) if staff and staff_sso is not False: if STAFF_ORG_ID: - organization_ids.add(SUPERUSER_ORG_ID) + # justification: this pre-existing path uses the superuser SSO org for staff. + org_ids.add(cast(int, SUPERUSER_ORG_ID)) if organization_id: - organization_ids.add(organization_id) + org_ids.add(organization_id) # TODO(dcramer): ideally this would get abstracted - if organization_ids: - for o in organization_ids: + if org_ids: + for o in org_ids: sso_session = SsoSession.create(o) self.session[sso_session.session_key] = sso_session.to_dict() @@ -358,15 +400,17 @@ def login_as( # superuser-only code if not superuser: # XXX(dcramer): we're calling the internal method to avoid logging - request.superuser._set_logged_out() + elevated_request.superuser._set_logged_out() elif request.user.is_superuser and superuser: - request.superuser.set_logged_in(request.user) + elevated_request.superuser.set_logged_in(request.user) # XXX(dcramer): awful hack to ensure future attempts to instantiate # the Superuser object are successful self.save_cookie( name=SU_COOKIE_NAME, value=signing.get_cookie_signer(salt=SU_COOKIE_NAME + SU_COOKIE_SALT).sign( - request.superuser.token + elevated_request.superuser.token + if elevated_request.superuser.token is not None + else "" ), max_age=None, path=SU_COOKIE_PATH, @@ -376,13 +420,14 @@ def login_as( ) # XXX(schew2381): Same as above, but for staff if not staff: - request.staff._set_logged_out() + elevated_request.staff._set_logged_out() elif request.user.is_staff and staff: - request.staff.set_logged_in(request.user) + elevated_request.staff.set_logged_in(request.user) + assert elevated_request.staff.token is not None self.save_cookie( name=STAFF_COOKIE_NAME, value=signing.get_cookie_signer(salt=STAFF_COOKIE_NAME + STAFF_COOKIE_SALT).sign( - request.staff.token + elevated_request.staff.token ), max_age=None, path=STAFF_COOKIE_PATH, @@ -393,29 +438,37 @@ def login_as( # Save the session values. self.save_session() - def load_fixture(self, filepath): + def load_fixture(self, filepath: str) -> bytes: with open(get_fixture_path(filepath), "rb") as fp: return fp.read() @classmethod - def _pre_setup(cls): - super()._pre_setup() + def _pre_setup(cls) -> None: + parent_pre_setup = getattr(super(), "_pre_setup", None) + if parent_pre_setup is not None: + parent_pre_setup() cache.clear() ProjectOption.objects.clear_local_cache() GroupMeta.objects.clear_local_cache() - def _post_teardown(self): - super()._post_teardown() + def _post_teardown(self) -> None: + parent_post_teardown = getattr(super(), "_post_teardown", None) + if parent_post_teardown is not None: + parent_post_teardown() - def options(self, options): + def options(self, options: Mapping[str, object]) -> AbstractContextManager[None]: """ A context manager that temporarily sets a global option and reverts back to the original value when exiting the context. """ return override_options(options) - def assert_valid_deleted_log(self, deleted_log, original_object): + def assert_valid_deleted_log( + self, + deleted_log: DeletedOrganization | DeletedProject | DeletedTeam, + original_object: Organization | Project | Team, + ) -> None: assert deleted_log is not None assert original_object.name == deleted_log.name @@ -423,14 +476,17 @@ def assert_valid_deleted_log(self, deleted_log, original_object): assert deleted_log.slug == original_object.slug if not isinstance(deleted_log, DeletedOrganization): + assert not isinstance(original_object, Organization) assert deleted_log.organization_id == original_object.organization.id assert deleted_log.organization_name == original_object.organization.name assert deleted_log.organization_slug == original_object.organization.slug + assert deleted_log.date_created is not None + assert deleted_log.date_deleted is not None assert deleted_log.date_created == original_object.date_added assert deleted_log.date_deleted >= deleted_log.date_created - def get_mock_uuid(self, hex_value="abc123"): + def get_mock_uuid(self, hex_value: str = "abc123") -> type[object]: class uuid: hex = hex_value bytes = b"\x00\x01\x02" @@ -443,7 +499,7 @@ class TestCase(BaseTestCase, DjangoTestCase): databases: set[str] | str = "__all__" @contextmanager - def auto_select_silo_mode_on_redirects(self): + def auto_select_silo_mode_on_redirects(self) -> Iterator[None]: """ Tests that utilize follow=True may follow redirects between silo modes. This isn't ideal but convenient for testing certain work flows. Using this context manager, the silo mode in the test will swap automatically @@ -488,17 +544,17 @@ class PerformanceIssueTestCase(BaseTestCase): def create_performance_issue( self, - tags=None, - contexts=None, - fingerprint=None, - transaction=None, - event_data=None, - issue_type=None, - noise_limit=0, - project_id=None, - detector_option="performance.issues.n_plus_one_db.problem-creation", - user_data=None, - ): + tags: dict[str, Any] | None = None, + contexts: dict[str, Any] | None = None, + fingerprint: str | None = None, + transaction: str | None = None, + event_data: dict[str, Any] | None = None, + issue_type: type[GroupType] | None = None, + noise_limit: int = 0, + project_id: int | None = None, + detector_option: str = "performance.issues.n_plus_one_db.problem-creation", + user_data: dict[str, Any] | None = None, + ) -> Event | GroupEvent: if issue_type is None: issue_type = PerformanceNPlusOneGroupType if event_data is None: @@ -506,6 +562,7 @@ def create_performance_issue( "transaction-n-plus-one", timestamp=before_now(minutes=10), ) + assert event_data is not None if tags is not None: event_data["tags"] = tags if contexts is not None: @@ -521,8 +578,11 @@ def create_performance_issue( perf_event_manager.normalize() def detect_performance_problems_interceptor( - data: Event, project: Project, standalone: bool = False - ): + # justification: the detector consumes the decoded event payload. + data: dict[str, Any], + project: Project, + standalone: bool = False, + ) -> list[PerformanceProblem]: perf_problems = detect_performance_problems(data, project, standalone=standalone) if fingerprint: for perf_problem in perf_problems: @@ -547,14 +607,18 @@ def detect_performance_problems_interceptor( {"performance.issues.all.problem-detection": 1.0, detector_option: 1.0} ), ): - event = perf_event_manager.save(project_id) + stored_event = perf_event_manager.save(project_id) if mock_eventstream.call_args: - event = event.for_group(mock_eventstream.call_args[0][2].group) - event.occurrence = mock_eventstream.call_args[0][1] - return event + group_event = stored_event.for_group(mock_eventstream.call_args[0][2].group) + group_event.occurrence = mock_eventstream.call_args[0][1] + return group_event + return stored_event class APITestCaseMixin: + client: Any # justification: Django and DRF test clients differ across subclasses. + method: str + """ Extend APITestCase to inherit access to `client`, an object with methods that simulate API calls to Sentry, and the helper `get_response`, which @@ -566,10 +630,10 @@ class APITestCaseMixin: """ @property - def endpoint(self): + def endpoint(self) -> str: raise NotImplementedError(f"implement for {type(self).__module__}.{type(self).__name__}") - def get_response(self, *args, **params): + def get_response(self, *args: Any, **params: Any) -> Response: """ Simulate an API call to the test case's URI and method. @@ -603,9 +667,10 @@ def get_response(self, *args, **params): data = raw_data or params method = params.pop("method", self.method).lower() - return getattr(self.client, method)(url, format=format, data=data, **headers) + response: Response = getattr(self.client, method)(url, format=format, data=data, **headers) + return response - def get_success_response(self, *args, **params): + def get_success_response(self, *args: Any, **params: Any) -> Response: """ Call `get_response` (see above) and assert the response's status code. @@ -640,7 +705,7 @@ def get_success_response(self, *args, **params): return response - def get_error_response(self, *args, **params): + def get_error_response(self, *args: Any, **params: Any) -> Response: """ Call `get_response` (see above) and assert that the response's status code is an error code. Basically it's syntactic sugar. @@ -664,16 +729,18 @@ def get_error_response(self, *args, **params): return response - def get_cursor_headers(self, response): + def get_cursor_headers(self, response: Response) -> list[str]: + link_header = response.get("link") + assert link_header is not None return [ link["cursor"] for link in requests.utils.parse_header_links( - response.get("link").rstrip(">").replace(">,<", ",<") + link_header.rstrip(">").replace(">,<", ",<") ) ] @contextmanager - def api_gateway_proxy_stubbed(self): + def api_gateway_proxy_stubbed(self) -> Iterator[None]: """Mocks a fake api gateway proxy that redirects via Client objects""" def proxy_raw_request( @@ -729,36 +796,44 @@ class APITransactionTestCase(BaseTestCase, BaseAPITransactionTestCase, APITestCa class TwoFactorAPITestCase(APITestCase): @cached_property - def path_2fa(self): + def path_2fa(self) -> str: return reverse("sentry-account-settings-security") - def enable_org_2fa(self, organization): + def enable_org_2fa(self, organization: Organization) -> None: with assume_test_silo_mode(SiloMode.CELL): organization.flags.require_2fa = True organization.save() - def api_enable_org_2fa(self, organization, user): + def api_enable_org_2fa(self, organization: Organization, user: User) -> Response: self.login_as(user) url = reverse( "sentry-api-0-organization-details", kwargs={"organization_id_or_slug": organization.slug}, ) - return self.client.put(url, data={"require2FA": True}) + response: Response = self.client.put(url, data={"require2FA": True}) + return response - def api_disable_org_2fa(self, organization, user): + def api_disable_org_2fa(self, organization: Organization, user: User) -> Response: url = reverse( "sentry-api-0-organization-details", kwargs={"organization_id_or_slug": organization.slug}, ) - return self.client.put(url, data={"require2FA": False}) + response: Response = self.client.put(url, data={"require2FA": False}) + return response - def assert_can_enable_org_2fa(self, organization, user, status_code=200): + def assert_can_enable_org_2fa( + self, organization: Organization, user: User, status_code: int = 200 + ) -> None: self.__helper_enable_organization_2fa(organization, user, status_code) - def assert_cannot_enable_org_2fa(self, organization, user, status_code, err_msg=None): + def assert_cannot_enable_org_2fa( + self, organization: Organization, user: User, status_code: int, err_msg: str | None = None + ) -> None: self.__helper_enable_organization_2fa(organization, user, status_code, err_msg) - def __helper_enable_organization_2fa(self, organization, user, status_code, err_msg=None): + def __helper_enable_organization_2fa( + self, organization: Organization, user: User, status_code: int, err_msg: str | None = None + ) -> None: response = self.api_enable_org_2fa(organization, user) assert response.status_code == status_code if err_msg: @@ -770,7 +845,9 @@ def __helper_enable_organization_2fa(self, organization, user, status_code, err_ else: assert not organization.flags.require_2fa - def add_2fa_users_to_org(self, organization, num_of_users=10, num_with_2fa=5): + def add_2fa_users_to_org( + self, organization: Organization, num_of_users: int = 10, num_with_2fa: int = 5 + ) -> list[str]: non_compliant_members = [] for num in range(0, num_of_users): user = self.create_user("foo_%s@example.com" % num) @@ -786,7 +863,7 @@ def add_2fa_users_to_org(self, organization, num_of_users=10, num_with_2fa=5): class AuthProviderTestCase(TestCase): provider: type[Provider] = DummyProvider - def setUp(self): + def setUp(self) -> None: super().setUp() # TestCase automatically sets up dummy provider if self.provider != DummyProvider: @@ -796,30 +873,32 @@ def setUp(self): class RuleTestCase(TestCase): @property - def rule_cls(self): + def rule_cls(self) -> type[EventAction]: raise NotImplementedError(f"implement for {type(self).__module__}.{type(self).__name__}") - def get_event(self): + def get_event(self) -> Event: return self.event - def get_group_event(self): - return GroupEvent.from_event(self.event, self.event.group) + def get_group_event(self) -> GroupEvent: + group = self.event.group + assert group is not None + return GroupEvent.from_event(self.event, group) - def get_rule(self, **kwargs): + def get_rule(self, **kwargs: Any) -> EventAction: kwargs.setdefault("project", self.project) kwargs.setdefault("data", {}) return self.rule_cls(**kwargs) class DRFPermissionTestCase(TestCase): - def make_request(self, *arg, **kwargs) -> Request: + def make_request(self, *arg: Any, **kwargs: Any) -> Request: """ Override the return type of make_request b/c DRF permission classes expect a DRF request (go figure) """ return super().make_request(*arg, **kwargs) # type: ignore[return-value] - def setUp(self): + def setUp(self) -> None: self.superuser = self.create_user(is_superuser=True, is_staff=False) self.staff_user = self.create_user(is_staff=True, is_superuser=False) self.superuser_request = self.make_request(user=self.superuser, is_superuser=True) @@ -827,7 +906,7 @@ def setUp(self): class PermissionTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.owner = self.create_user(is_superuser=False) self.organization = self.create_organization( @@ -836,45 +915,49 @@ def setUp(self): ) self.team = self.create_team(organization=self.organization) - def assert_can_access(self, user, path, method="GET", **kwargs): + def assert_can_access( + self, user: User, path: str, method: str = "GET", **kwargs: Any + ) -> Response: self.login_as(user, superuser=user.is_superuser) - resp = getattr(self.client, method.lower())(path, **kwargs) + resp: Response = getattr(self.client, method.lower())(path, **kwargs) assert resp.status_code >= 200 and resp.status_code < 300 return resp - def assert_cannot_access(self, user, path, method="GET", **kwargs): + def assert_cannot_access( + self, user: User, path: str, method: str = "GET", **kwargs: Any + ) -> None: self.login_as(user, superuser=user.is_superuser) resp = getattr(self.client, method.lower())(path, **kwargs) assert resp.status_code >= 300 - def assert_member_can_access(self, path, **kwargs): + def assert_member_can_access(self, path: str, **kwargs: Any) -> Response: return self.assert_role_can_access(path, "member", **kwargs) - def assert_manager_can_access(self, path, **kwargs): + def assert_manager_can_access(self, path: str, **kwargs: Any) -> Response: return self.assert_role_can_access(path, "manager", **kwargs) - def assert_member_cannot_access(self, path, **kwargs): + def assert_member_cannot_access(self, path: str, **kwargs: Any) -> None: return self.assert_role_cannot_access(path, "member", **kwargs) - def assert_team_admin_cannot_access(self, path, **kwargs): + def assert_team_admin_cannot_access(self, path: str, **kwargs: Any) -> None: return self.assert_role_cannot_access(path, "admin", **kwargs) - def assert_teamless_admin_cannot_access(self, path, **kwargs): + def assert_teamless_admin_cannot_access(self, path: str, **kwargs: Any) -> None: user = self.create_user(is_superuser=False) self.create_member(user=user, organization=self.organization, role="admin", teams=[]) self.assert_cannot_access(user, path, **kwargs) - def assert_owner_can_access(self, path, **kwargs): + def assert_owner_can_access(self, path: str, **kwargs: Any) -> Response: return self.assert_role_can_access(path, "owner", **kwargs) - def assert_role_can_access(self, path, role, **kwargs): + def assert_role_can_access(self, path: str, role: str, **kwargs: Any) -> Response: user = self.create_user(is_superuser=False) self.create_member(user=user, organization=self.organization, role=role, teams=[self.team]) return self.assert_can_access(user, path, **kwargs) - def assert_role_cannot_access(self, path, role, **kwargs): + def assert_role_cannot_access(self, path: str, role: str, **kwargs: Any) -> None: user = self.create_user(is_superuser=False) self.create_member(user=user, organization=self.organization, role=role, teams=[self.team]) @@ -884,10 +967,10 @@ def assert_role_cannot_access(self, path, role, **kwargs): @requires_snuba class PluginTestCase(TestCase): @property - def plugin(self): + def plugin(self) -> object: raise NotImplementedError(f"implement for {type(self).__module__}.{type(self).__name__}") - def setUp(self): + def setUp(self) -> None: super().setUp() @@ -897,12 +980,12 @@ def runner(self) -> CliRunner: return CliRunner() @property - def command(self): + def command(self) -> click.Command: raise NotImplementedError(f"implement for {type(self).__module__}.{type(self).__name__}") default_args: list[str] = [] - def invoke(self, *args, **kwargs): + def invoke(self, *args: Any, **kwargs: Any) -> Result: args += tuple(self.default_args) return self.runner.invoke(self.command, args, obj={}, **kwargs) @@ -915,37 +998,39 @@ class AcceptanceTestCase(TransactionTestCase): browser: Browser @pytest.fixture(autouse=True) - def _setup_today(self): + def _setup_today(self) -> Iterator[None]: with mock.patch( "django.utils.timezone.now", return_value=(datetime(2013, 5, 18, 15, 13, 58, 132928, tzinfo=UTC)), ): yield - def wait_for_loading(self): + def wait_for_loading(self) -> None: # NOTE: [data-test-id="loading-placeholder"] is not used here as # some dashboards have placeholders that never complete. self.browser.wait_until_not('[data-test-id="events-request-loading"]') self.browser.wait_until_not('[data-test-id="loading-indicator"]') self.browser.wait_until_not(".loading") - def tearDown(self): + def tearDown(self) -> None: # Avoid tests finishing before their API calls have finished. # NOTE: This is not fool-proof, it requires loading indicators to be # used when API requests are made. self.wait_for_loading() super().tearDown() - def save_cookie(self, name, value, **params): + def save_cookie(self, name: str, value: str, **params: Any) -> None: self.browser.save_cookie(name=name, value=value, **params) - def save_session(self): + def save_session(self) -> None: self.session.save() - self.save_cookie(name=settings.SESSION_COOKIE_NAME, value=self.session.session_key) + assert self.session.session_key is not None + session_key = self.session.session_key + self.save_cookie(name=settings.SESSION_COOKIE_NAME, value=session_key) # Forward session cookie to django client. - self.client.cookies[settings.SESSION_COOKIE_NAME] = self.session.session_key + self.client.cookies[settings.SESSION_COOKIE_NAME] = session_key - def dismiss_assistant(self, which=None): + def dismiss_assistant(self, which: str | Iterable[str] | None = None) -> None: if which is None: which = ("issue", "issue_stream") if isinstance(which, str): @@ -962,10 +1047,10 @@ def dismiss_assistant(self, which=None): class IntegrationTestCase(TestCase): @property - def provider(self): + def provider(self) -> type[IntegrationProvider]: raise NotImplementedError(f"implement for {type(self).__module__}.{type(self).__name__}") - def setUp(self): + def setUp(self) -> None: from sentry.integrations.pipeline import IntegrationPipeline super().setUp() @@ -991,8 +1076,9 @@ def setUp(self): self.pipeline.initialize() self.save_session() - def assertDialogSuccess(self, resp): - assert b'window.opener.postMessage({"success":true' in resp.content + def assertDialogSuccess(self, resp: HttpResponseBase) -> None: + # justification: the flow's dialog response is a rendered HttpResponse. + assert b'window.opener.postMessage({"success":true' in cast(HttpResponse, resp).content @pytest.mark.snuba @@ -1008,28 +1094,32 @@ class SnubaTestCase(BaseTestCase): databases: set[str] | str = "__all__" reset_snuba_data: bool = True - def setUp(self): - super().setUp() + def setUp(self) -> None: + parent_setup = getattr(super(), "setUp", None) + if parent_setup is not None: + parent_setup() self.init_snuba() @pytest.fixture(autouse=True) - def initialize(self, request, call_snuba): + def initialize( + self, request: pytest.FixtureRequest, call_snuba: Callable[[str], requests.Response] + ) -> None: if self.reset_snuba_data: request.getfixturevalue("reset_snuba") self.call_snuba = call_snuba - def create_project(self, **kwargs) -> Project: + def create_project(self, **kwargs: Any) -> Project: if "flags" not in kwargs: # We insert events directly into snuba in tests, so we need to set has_transactions to True so the # application knows that events have been sent kwargs["flags"] = Project.flags.has_transactions return super().create_project(**kwargs) - def init_snuba(self): + def init_snuba(self) -> None: self.snuba_eventstream = SnubaEventStream() self.snuba_tagstore = SnubaTagStorage() - def store_event(self, *args, **kwargs): + def store_event(self, *args: Any, **kwargs: Any) -> Event: """ Simulates storing an event for testing. @@ -1053,7 +1143,7 @@ def store_event(self, *args, **kwargs): self.store_group(group) return stored_event - def wait_for_event_count(self, project_id, total, attempts=2): + def wait_for_event_count(self, project_id: int, total: int, attempts: int = 2) -> None: """ Wait until the event count reaches the provided value or until attempts is reached. @@ -1080,7 +1170,7 @@ def wait_for_event_count(self, project_id, total, attempts=2): f"Could not ensure that {total} event(s) were persisted within {attempt} attempt(s). Event count is instead currently {last_events_seen}." ) - def build_session(self, **kwargs): + def build_session(self, **kwargs: Any) -> dict[str, Any]: session = { "session_id": str(uuid4()), "distinct_id": str(uuid4()), @@ -1107,7 +1197,7 @@ def build_session(self, **kwargs): session.update(kwargs) return session - def store_group(self, group): + def store_group(self, group: Group) -> None: data = [self.__wrap_group(group)] assert ( _snuba_pool.urlopen( @@ -1119,10 +1209,10 @@ def store_group(self, group): == 200 ) - def store_span(self, span): + def store_span(self, span: dict[str, Any]) -> None: self.store_spans([span]) - def store_spans(self, spans): + def store_spans(self, spans: Sequence[dict[str, Any]]) -> None: files = {} for i, span in enumerate(spans): trace_item = span_to_trace_item(span) @@ -1210,7 +1300,7 @@ def store_eap_items(self, items: Sequence[TraceItem]) -> None: for item in items: item.item_id = item.item_id[::-1] - def store_issues(self, issues): + def store_issues(self, issues: Sequence[Mapping[str, Any]]) -> None: assert ( requests.post( settings.SENTRY_SNUBA + "/tests/entities/search_issues/insert", @@ -1220,11 +1310,13 @@ def store_issues(self, issues): == 200 ) - def to_snuba_time_format(self, datetime_value): + def to_snuba_time_format(self, datetime_value: datetime | None) -> str | None: + if datetime_value is None: + return None date_format = "%Y-%m-%d %H:%M:%S%z" return datetime_value.strftime(date_format) - def __wrap_group(self, group): + def __wrap_group(self, group: Group) -> dict[str, Any]: return { "event": "change", "kind": "insert", @@ -1273,16 +1365,22 @@ def __wrap_group(self, group): ], } - def snuba_insert(self, events): + def snuba_insert( + self, + events: ( + Mapping[str, Any] + | Sequence[Mapping[str, Any]] + | tuple[int, str, Mapping[str, Any], Mapping[str, Any]] + ), + ) -> None: "Write a (wrapped) event (or events) to Snuba." - if not isinstance(events, list): - events = [events] + events_to_insert = [events] if not isinstance(events, list) else events assert ( requests.post( settings.SENTRY_SNUBA + "/tests/entities/events/insert", - data=json.dumps(events), + data=json.dumps(events_to_insert), timeout=30, ).status_code == 200 @@ -1290,7 +1388,7 @@ def snuba_insert(self, events): class BaseSpansTestCase(SnubaTestCase): - def _random_span_id(self): + def _random_span_id(self) -> str: random_number = random.randint(0, 100000000) return hex(random_number)[2:] @@ -1314,7 +1412,7 @@ def store_segment( status: str | None = None, environment: str | None = None, organization_id: int = 1, - ): + ) -> None: if span_id is None: span_id = self._random_span_id() if timestamp is None: @@ -1389,7 +1487,7 @@ def store_indexed_span( group: str = "00", category: str | None = None, organization_id: int = 1, - ): + ) -> None: if span_id is None: span_id = self._random_span_id() if timestamp is None: @@ -1452,7 +1550,7 @@ class BaseMetricsTestCase(SnubaTestCase): snuba_endpoint = "/tests/entities/{entity}/insert" - def store_session(self, session): + def store_session(self, session: dict[str, Any]) -> None: """Mimic relays behavior of always emitting a metric for a started session, and emitting an additional one if the session is fatal https://github.com/getsentry/relay/blob/e3c064e213281c36bde5d2b6f3032c6d36e22520/relay-server/src/actors/envelopes.rs#L357 @@ -1471,7 +1569,7 @@ def store_session(self, session): # This check is not yet reflected in relay, see https://getsentry.atlassian.net/browse/INGEST-464 user_is_nil = user is None or user == "00000000-0000-0000-0000-000000000000" - def push(mri: str, tags, value): + def push(mri: str, tags: Mapping[str, str], value: Any) -> None: self.store_metric( org_id, project_id, @@ -1513,7 +1611,7 @@ def push(mri: str, tags, value): session["duration"], ) - def bulk_store_sessions(self, sessions): + def bulk_store_sessions(self, sessions: Sequence[dict[str, Any]]) -> None: for session in sessions: self.store_session(session) @@ -1530,6 +1628,7 @@ def store_metric( sampling_weight: int | None = None, ) -> None: parsed = parse_mri(mri) + assert parsed is not None metric_type = parsed.entity use_case_id = UseCaseID(parsed.namespace) if use_case_id is not UseCaseID.SESSIONS: @@ -1537,7 +1636,7 @@ def store_metric( mapping_meta = {} - def metric_id(key: str): + def metric_id(key: str) -> int: assert isinstance(key, str) res = indexer.record( use_case_id=use_case_id, @@ -1548,7 +1647,7 @@ def metric_id(key: str): mapping_meta[str(res)] = key return res - def tag_key(name): + def tag_key(name: str) -> str: assert isinstance(name, str) res = indexer.record( use_case_id=use_case_id, @@ -1559,7 +1658,7 @@ def tag_key(name): mapping_meta[str(res)] = name return str(res) - def tag_value(name): + def tag_value(name: str) -> int: assert isinstance(name, str) res = indexer.record( @@ -1619,7 +1718,7 @@ def tag_value(name): cls.__send_buckets([msg], entity) @classmethod - def __send_buckets(cls, buckets, entity): + def __send_buckets(cls, buckets: Sequence[dict[str, Any]], entity: str) -> None: # DO NOT USE THIS METHOD IN YOUR TESTS, use store_metric instead. we # need to be able to make changes to the indexer's output protocol # without having to update a million tests @@ -1652,7 +1751,7 @@ class BaseMetricsLayerTestCase(BaseMetricsTestCase): ) @property - def now(self): + def now(self) -> datetime: """ Returns the current time instance that will be used throughout the tests of the metrics layer. @@ -1673,7 +1772,7 @@ def _store_metric( minutes_before_now: int = 0, seconds_before_now: int = 0, aggregation_option: AggregationOption | None = None, - ): + ) -> None: # We subtract one second in order to account for right non-inclusivity in the query. If we wouldn't do this # some data won't be returned (this applies only if we use self.now() in the "end" bound of the query). # @@ -1747,7 +1846,7 @@ def store_performance_metric( minutes_before_now: int = 0, seconds_before_now: int = 0, aggregation_option: AggregationOption | None = None, - ): + ) -> None: self._store_metric( mri=name, tags=tags, @@ -1773,7 +1872,7 @@ def store_release_health_metric( hours_before_now: int = 0, minutes_before_now: int = 0, seconds_before_now: int = 0, - ): + ) -> None: self._store_metric( mri=name, tags=tags, @@ -1799,7 +1898,7 @@ def store_custom_metric( minutes_before_now: int = 0, seconds_before_now: int = 0, aggregation_option: AggregationOption | None = None, - ): + ) -> None: self._store_metric( mri=name, tags=tags, @@ -1827,7 +1926,7 @@ def build_metrics_query( include_series: bool = True, before_now: str | None = None, granularity: str | None = None, - ): + ) -> DeprecatingMetricsQuery: # TODO: fix this method which gets the range after now instead of before now. (start, end, granularity_in_seconds) = get_date_range( {"statsPeriod": before_now, "interval": granularity} @@ -1852,6 +1951,9 @@ def build_metrics_query( class MetricsEnhancedPerformanceTestCase(BaseMetricsLayerTestCase, TestCase): + features: dict[str, bool] = {} + url: str = "" + TYPE_MAP = { "metrics_distributions": "distribution", "metrics_sets": "set", @@ -1912,7 +2014,7 @@ class MetricsEnhancedPerformanceTestCase(BaseMetricsLayerTestCase, TestCase): METRIC_STRINGS: list[str] = [] DEFAULT_METRIC_TIMESTAMP = datetime(2015, 1, 1, 10, 15, 0, tzinfo=UTC) - def setUp(self): + def setUp(self) -> None: super().setUp() self.min_ago = before_now(minutes=1) self.two_min_ago = before_now(minutes=2) @@ -1928,7 +2030,7 @@ def do_request(self, data: dict[str, Any], features: dict[str, bool] | None = No assert is_drf_response(ret) return ret - def _index_metric_strings(self): + def _index_metric_strings(self) -> None: strings = [ "transaction", "environment", @@ -1972,9 +2074,11 @@ def store_transaction_metric( if project is None: project = self.project.id - if not isinstance(value, list): - value = [value] - for subvalue in value: + values = cast( + list[int | dict[str, int]], + value if isinstance(value, list) else [value], + ) + for subvalue in values: self.store_metric( org_id, project, @@ -2010,7 +2114,7 @@ def store_on_demand_metric( metric=self.ON_DEMAND_KEY_MAP[metric_type], internal_metric=self.ON_DEMAND_MRI_MAP[metric_type], entity=self.ON_DEMAND_ENTITY_MAP[metric_type], - tags=tags, + tags=cast(dict[str, str], tags), timestamp=timestamp, ) @@ -2024,7 +2128,7 @@ def store_span_metric( timestamp: datetime | None = None, project: int | None = None, use_case_id: UseCaseID = UseCaseID.SPANS, - ): + ) -> None: internal_metric = SPAN_METRICS_MAP[metric] if internal_metric is None else internal_metric org_id = self.organization.id @@ -2039,9 +2143,11 @@ def store_span_metric( if project is None: project = self.project.id - if not isinstance(value, list): - value = [value] - for subvalue in value: + values = cast( + list[int | dict[str, int]], + value if isinstance(value, list) else [value], + ) + for subvalue in values: self.store_metric( org_id, project, @@ -2053,12 +2159,12 @@ def store_span_metric( def wait_for_metric_count( self, - project, - total, - metric="transaction.count_per_root_project", - mri=TransactionMRI.COUNT_PER_ROOT_PROJECT.value, - attempts=2, - ): + project: Project, + total: int, + metric: str = "transaction.count_per_root_project", + mri: str = TransactionMRI.COUNT_PER_ROOT_PROJECT.value, + attempts: int = 2, + ) -> None: attempt = 0 metrics_query = self.build_metrics_query( before_now="1d", @@ -2090,12 +2196,17 @@ def wait_for_metric_count( class BaseIncidentsTest(SnubaTestCase): - def create_event(self, timestamp, fingerprint=None, user=None): + def create_event( + self, + timestamp: datetime, + fingerprint: str | None = None, + user: dict[str, Any] | None = None, + ) -> Event: event_id = uuid4().hex if fingerprint is None: fingerprint = event_id - data = { + data: dict[str, Any] = { # justification: event payload is decoded JSON. "event_id": event_id, "fingerprint": [fingerprint], "timestamp": timestamp.isoformat(), @@ -2109,21 +2220,21 @@ def create_event(self, timestamp, fingerprint=None, user=None): return self.store_event(data=data, project_id=self.project.id) @cached_property - def now(self): + def now(self) -> datetime: return timezone.now().replace(minute=0, second=0, microsecond=0) @pytest.mark.snuba @requires_snuba class OutcomesSnubaTest(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() assert ( requests.post(settings.SENTRY_SNUBA + "/tests/outcomes/drop", timeout=30).status_code == 200 ) - def store_outcomes(self, outcome, num_times=1): + def store_outcomes(self, outcome: dict[str, Any], num_times: int = 1) -> None: outcomes = [] for _ in range(num_times): outcome_copy = outcome.copy() @@ -2149,14 +2260,15 @@ class ProfilesSnubaTestCase( ): def store_functions( self, - functions, - project, - transaction=None, - extras=None, - timestamp=None, - ): + functions: Sequence[dict[str, Any]], + project: Project, + transaction: dict[str, Any] | None = None, + extras: dict[str, Any] | None = None, + timestamp: datetime | None = None, + ) -> dict[str, Any]: if transaction is None: transaction = load_data("transaction", timestamp=timestamp or before_now(minutes=10)) + assert transaction is not None profile_context = transaction.setdefault("contexts", {}).setdefault("profile", {}) if profile_context.get("profile_id") is None: @@ -2165,7 +2277,7 @@ def store_functions( self.store_event(transaction, project_id=project.id) - timestamp = transaction["timestamp"] + receipt_timestamp = transaction["timestamp"] functions = [ { **function, @@ -2184,7 +2296,7 @@ def store_functions( "project_id": project.id, "received": int(timezone.now().timestamp()), "retention_days": 90, - "timestamp": int(timestamp), + "timestamp": int(receipt_timestamp), "transaction_name": transaction["transaction"], "materialization_version": 1, } @@ -2206,12 +2318,12 @@ def store_functions( def store_functions_chunk( self, - functions, - project, - profiler_id=None, - extras=None, - timestamp=None, - ): + functions: Sequence[dict[str, Any]], + project: Project, + profiler_id: str | None = None, + extras: dict[str, Any] | None = None, + timestamp: datetime | None = None, + ) -> dict[str, Any]: if profiler_id is None: profiler_id = uuid4().hex @@ -2227,14 +2339,14 @@ def store_functions_chunk( for function in functions ] - timestamp = (timestamp or timezone.now()).timestamp() + timestamp_ts = (timestamp or timezone.now()).timestamp() max_duration = max( duration for function in functions for duration in function["self_times_ns"] ) - start = timestamp - end = timestamp + max_duration / 1e9 + start = timestamp_ts + end = timestamp_ts + max_duration / 1e9 functions_payload = { "functions": functions, @@ -2242,9 +2354,9 @@ def store_functions_chunk( "transaction_name": "", "profile_id": profiler_id, "project_id": project.id, - "received": int(timestamp), + "received": int(timestamp_ts), "retention_days": 90, - "timestamp": int(timestamp), + "timestamp": int(timestamp_ts), "start_timestamp": start, "end_timestamp": end, "profiling_type": "continuous", @@ -2266,7 +2378,7 @@ def store_functions_chunk( "functions": functions, } - def function_fingerprint(self, function): + def function_fingerprint(self, function: dict[str, Any]) -> int: # this is a different hashing algorithm than is used by vroom # but it's not a big deal hasher = hashlib.md5() @@ -2278,10 +2390,10 @@ def function_fingerprint(self, function): hasher.update(function["function"].encode()) return int(hasher.hexdigest()[:8], 16) - def store_span(self, span): + def store_span(self, span: dict[str, Any]) -> None: self.store_spans([span]) - def store_spans(self, spans): + def store_spans(self, spans: Sequence[dict[str, Any]]) -> None: files = {} for i, span in enumerate(spans): trace_item = span_to_trace_item(span) @@ -2297,20 +2409,22 @@ def store_spans(self, spans): @pytest.mark.snuba @requires_snuba class ReplaysSnubaTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() assert ( requests.post(settings.SENTRY_SNUBA + "/tests/replays/drop", timeout=30).status_code == 200 ) - def store_replays(self, replay): + def store_replays(self, replay: dict[str, Any]) -> None: response = requests.post( settings.SENTRY_SNUBA + "/tests/entities/replays/insert", json=[replay], timeout=30 ) assert response.status_code == 200 - def mock_event_links(self, timestamp, project_id, level, replay_id, event_id): + def mock_event_links( + self, timestamp: datetime, project_id: int, level: str, replay_id: str, event_id: str + ) -> EventLinkKafkaMessage: event = self.store_event( data={ "timestamp": int(timestamp.timestamp()), @@ -2328,7 +2442,7 @@ def mock_event_links(self, timestamp, project_id, level, replay_id, event_id): @requires_snuba @pytest.mark.usefixtures("reset_snuba") class UptimeCheckSnubaTestCase(TestCase): - def store_uptime_check(self, uptime_check): + def store_uptime_check(self, uptime_check: dict[str, Any]) -> None: response = requests.post( settings.SENTRY_SNUBA + "/tests/entities/uptime_checks/insert", json=[uptime_check], @@ -2350,7 +2464,7 @@ def store_snuba_uptime_check( region: str = "default", environment: str = "production", trace_id: UUID | None = None, - ): + ) -> None: if scheduled_check_time is None: scheduled_check_time = datetime.now() - timedelta(minutes=5) if incident_status is None: @@ -2399,7 +2513,7 @@ def store_snuba_uptime_check( # AcceptanceTestCase and TestCase are mutually exclusive base classses class ReplaysAcceptanceTestCase(AcceptanceTestCase, SnubaTestCase): - def setUp(self): + def setUp(self) -> None: self.now = datetime.now(UTC) super().setUp() self.drop_replays() @@ -2407,13 +2521,13 @@ def setUp(self): patcher.start() self.addCleanup(patcher.stop) - def drop_replays(self): + def drop_replays(self) -> None: assert ( requests.post(settings.SENTRY_SNUBA + "/tests/replays/drop", timeout=30).status_code == 200 ) - def store_replays(self, replays): + def store_replays(self, replays: Sequence[dict[str, Any]]) -> None: assert len(replays) >= 2, ( "You need to store at least 2 replay events for the replay to be considered valid" ) @@ -2427,7 +2541,7 @@ def store_replay_segments( replay_id: str, project_id: int, segment_id: int, - segment, + segment: dict[str, Any], ) -> None: f = File.objects.create(name="rr:{segment_id}", type="replay.recording") f.putfile(BytesIO(compress(dumps_htmlsafe(segment).encode()))) @@ -2440,26 +2554,28 @@ def store_replay_segments( class IntegrationRepositoryTestCase(APITestCase): - def setUp(self): + provider_name: str = "" + + def setUp(self) -> None: super().setUp() self.login_as(self.user) @pytest.fixture(autouse=True) - def responses_context(self): + def responses_context(self) -> Iterator[None]: with responses.mock: yield - def add_create_repository_responses(self, repository_config): + def add_create_repository_responses(self, repository_config: Mapping[str, Any]) -> None: raise NotImplementedError(f"implement for {type(self).__module__}.{type(self).__name__}") @assume_test_silo_mode(SiloMode.CELL) def create_repository( self, - repository_config, - integration_id, - organization_slug=None, - add_responses=True, - ): + repository_config: Mapping[str, Any], + integration_id: int | None, + organization_slug: str | None = None, + add_responses: bool = True, + ) -> Response: if add_responses: self.add_create_repository_responses(repository_config) if not integration_id: @@ -2474,7 +2590,7 @@ def create_repository( "identifier": repository_config["id"], } - response = self.client.post( + response: Response = self.client.post( path=reverse( "sentry-api-0-organization-repositories", args=[organization_slug or self.organization.slug], @@ -2483,13 +2599,13 @@ def create_repository( ) return response - def assert_error_message(self, response, error_type, error_message): + def assert_error_message(self, response: Response, error_type: str, error_message: str) -> None: assert response.data["error_type"] == error_type assert error_message in response.data["errors"]["__all__"] class ReleaseCommitPatchTest(APITestCase): - def setUp(self): + def setUp(self) -> None: user = self.create_user(is_staff=False, is_superuser=False) self.org = self.create_organization() self.org.save() @@ -2501,24 +2617,28 @@ def setUp(self): self.login_as(user=user) @cached_property - def url(self): + def url(self) -> str: raise NotImplementedError(f"implement for {type(self).__module__}.{type(self).__name__}") - def assert_commit(self, commit, repo_id, key, author_id, message): + def assert_commit( + self, commit: Commit, repo_id: int, key: str, author_id: int, message: str + ) -> None: assert commit.organization_id == self.org.id assert commit.repository_id == repo_id assert commit.key == key assert commit.author_id == author_id assert commit.message == message - def assert_file_change(self, file_change, type, filename, commit_id): + def assert_file_change( + self, file_change: CommitFileChange, type: str, filename: str, commit_id: int + ) -> None: assert file_change.type == type assert file_change.filename == filename assert file_change.commit_id == commit_id class SetRefsTestCase(APITestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.user = self.create_user(is_staff=False, is_superuser=False) self.org = self.create_organization() @@ -2531,7 +2651,13 @@ def setUp(self): self.group = self.create_group(project=self.project) self.repo = Repository.objects.create(organization_id=self.org.id, name="test/repo") - def assert_fetch_commits(self, mock_fetch_commit, prev_release_id, release_id, refs): + def assert_fetch_commits( + self, + mock_fetch_commit: mock.Mock, + prev_release_id: int | None, + release_id: int, + refs: Sequence[dict[str, Any]], + ) -> None: assert len(mock_fetch_commit.method_calls) == 1 kwargs = mock_fetch_commit.method_calls[0][2]["kwargs"] assert kwargs == { @@ -2541,7 +2667,9 @@ def assert_fetch_commits(self, mock_fetch_commit, prev_release_id, release_id, r "user_id": self.user.id, } - def assert_head_commit(self, head_commit, commit_key, release_id=None): + def assert_head_commit( + self, head_commit: ReleaseHeadCommit, commit_key: str, release_id: int | None = None + ) -> None: assert self.org.id == head_commit.organization_id assert self.repo.id == head_commit.repository_id if release_id: @@ -2550,7 +2678,7 @@ def assert_head_commit(self, head_commit, commit_key, release_id=None): assert self.release.id == head_commit.release_id self.assert_commit(head_commit.commit, commit_key) - def assert_commit(self, commit, key): + def assert_commit(self, commit: Commit, key: str) -> None: assert self.org.id == commit.organization_id assert self.repo.id == commit.repository_id assert commit.key == key @@ -2566,7 +2694,7 @@ class _QueryDict(TypedDict): class OrganizationDashboardWidgetTestCase(APITestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(self.user) self.dashboard = Dashboard.objects.create( @@ -2599,11 +2727,14 @@ def setUp(self): "conditions": "has:geo.country_code", } - def do_request(self, method, url, data=None): + def do_request(self, method: str, url: str, data: dict[str, Any] | None = None) -> Response: func = getattr(self.client, method) - return func(url, data=data) + response: Response = func(url, data=data) + return response - def assert_serialized_widget_query(self, data, widget_data_source): + def assert_serialized_widget_query( + self, data: dict[str, Any], widget_data_source: DashboardWidgetQuery + ) -> None: if "id" in data: assert data["id"] == str(widget_data_source.id) if "name" in data: @@ -2623,10 +2754,12 @@ def assert_serialized_widget_query(self, data, widget_data_source): if "selectedAggregate" in data: assert data["selectedAggregate"] == widget_data_source.selected_aggregate - def get_widgets(self, dashboard_id): + def get_widgets(self, dashboard_id: int) -> QuerySet[DashboardWidget]: return DashboardWidget.objects.filter(dashboard_id=dashboard_id).order_by("order", "id") - def assert_serialized_widget(self, data, expected_widget): + def assert_serialized_widget( + self, data: dict[str, Any], expected_widget: DashboardWidget + ) -> None: if "id" in data: assert data["id"] == str(expected_widget.id) if "title" in data: @@ -2644,7 +2777,7 @@ def assert_serialized_widget(self, data, expected_widget): if "datasetSource" in data: assert data["datasetSource"] == DATASET_SOURCES[expected_widget.dataset_source] - def create_user_member_role(self): + def create_user_member_role(self) -> None: self.user = self.create_user(is_superuser=False) self.create_member( user=self.user, @@ -2669,7 +2802,7 @@ class TestMigrations(TransactionTestCase): # share one run. allow_multiple_tests = False - def __init_subclass__(cls, **kwargs): + def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) tests = TestLoader().getTestCaseNames(cls) if len(tests) > 1 and not cls.allow_multiple_tests: @@ -2681,24 +2814,25 @@ def __init_subclass__(cls, **kwargs): ) @property - def migrate_from(self): + def migrate_from(self) -> str: raise NotImplementedError(f"implement for {type(self).__module__}.{type(self).__name__}") @property - def migrate_to(self): + def migrate_to(self) -> str: raise NotImplementedError(f"implement for {type(self).__module__}.{type(self).__name__}") - _project_state_cache: SentryProjectState | None = None + current_migration: list[tuple[str, str]] = [] + _project_state_cache: ProjectState | None = None @classmethod - def setUpClass(cls): + def setUpClass(cls) -> None: super().setUpClass() conn = connections[cls.connection] executor = MigrationExecutor(conn) matching_migrations = [m for m in executor.loader.applied_migrations if m[0] == cls.app] cls.current_migration = [max(matching_migrations)] - def setUp(self): + def setUp(self) -> None: super().setUp() migrate_from = [(self.app, self.migrate_from)] @@ -2729,7 +2863,7 @@ def setUp(self): self.apps = self._project_state_cache.apps - def tearDown(self): + def tearDown(self) -> None: super().tearDownClass() executor = MigrationExecutor(connections[self.connection]) self._project_state_cache = executor.migrate( @@ -2737,7 +2871,7 @@ def tearDown(self): ) @classmethod - def tearDownClass(cls): + def tearDownClass(cls) -> None: super().tearDownClass() cls._project_state_cache = None @@ -2749,7 +2883,7 @@ def setup_initial_state(self) -> None: # database operations are required. pass - def setup_before_migration(self, apps) -> None: + def setup_before_migration(self, apps: StateApps) -> None: # Add code here to run after we have rolled the database back to the `migrate_from` # migration. This code must use `apps` to create any database models, and not directly # access Django models. @@ -2760,7 +2894,7 @@ def setup_before_migration(self, apps) -> None: class SCIMTestCase(APITestCase): provider = "dummy" - def setUp(self): + def setUp(self) -> None: super().setUp() with assume_test_silo_mode(SiloMode.CONTROL): self.auth_provider_inst = AuthProviderModel( @@ -2784,8 +2918,13 @@ def _use_dummy_provider_for_ad_provider(self) -> Generator[None]: class ActivityTestCase(TestCase): + org: Organization + project2: Project + @assume_test_silo_mode(SiloMode.CONTROL) - def another_user(self, email_string, team=None, alt_email_string=None): + def another_user( + self, email_string: str, team: Team | None = None, alt_email_string: str | None = None + ) -> User: user = self.create_user(email_string) if alt_email_string: UserEmail.objects.create(email=alt_email_string, user=user) @@ -2800,7 +2939,14 @@ def another_user(self, email_string, team=None, alt_email_string=None): return user - def another_commit(self, order, name, user, repository, alt_email_string=None): + def another_commit( + self, + order: int, + name: str, + user: User, + repository: Repository, + alt_email_string: str | None = None, + ) -> Commit: commit = Commit.objects.create( key=name * 40, repository_id=repository.id, @@ -2820,7 +2966,7 @@ def another_commit(self, order, name, user, repository, alt_email_string=None): return commit - def another_release(self, name): + def another_release(self, name: str) -> tuple[Release, Deploy]: release = Release.objects.create( version=name * 40, organization_id=self.project.organization_id, @@ -2845,10 +2991,10 @@ def get_notification_uuid(self, text: str) -> str: class SlackActivityNotificationTest(ActivityTestCase): @cached_property - def adapter(self): + def adapter(self) -> MailAdapter: return mail_adapter - def setUp(self): + def setUp(self) -> None: with assume_test_silo_mode(SiloMode.CONTROL): base_params = { "user_id": self.user.id, @@ -2877,12 +3023,12 @@ def setUp(self): self.short_id = self.group.qualified_short_id @pytest.fixture(autouse=True) - def responses_context(self): + def responses_context(self) -> Iterator[None]: with responses.mock: yield @pytest.fixture(autouse=True) - def mock_chat_postMessage(self): + def mock_chat_postMessage(self) -> Iterator[None]: with mock.patch( "slack_sdk.web.client.WebClient.chat_postMessage", return_value=SlackResponse( @@ -2899,14 +3045,14 @@ def mock_chat_postMessage(self): def assert_performance_issue_blocks_with_culprit_blocks( self, - blocks, + blocks: Sequence[dict[str, Any]], org: Organization, project_slug: str, - group, - referrer, + group: Group, + referrer: str, alert_type: FineTuningAPIKey = FineTuningAPIKey.WORKFLOW, - issue_link_extra_params=None, - ): + issue_link_extra_params: str | None = None, + ) -> None: notification_uuid = self.get_notification_uuid(blocks[1]["text"]["text"]) issue_link = f"http://testserver/organizations/{org.slug}/issues/{group.id}/?referrer={referrer}¬ification_uuid={notification_uuid}" @@ -2930,15 +3076,15 @@ def assert_performance_issue_blocks_with_culprit_blocks( def assert_generic_issue_blocks( self, - blocks, + blocks: Sequence[dict[str, Any]], org: Organization, project_slug: str, - group, - referrer, - alert_type="workflow", - issue_link_extra_params=None, - with_culprit=False, - ): + group: Group, + referrer: str, + alert_type: FineTuningAPIKey | str = "workflow", + issue_link_extra_params: str | None = None, + with_culprit: bool = False, + ) -> None: notification_uuid = self.get_notification_uuid(blocks[1]["text"]["text"]) issue_link = f"http://testserver/organizations/{org.slug}/issues/{group.id}/?referrer={referrer}¬ification_uuid={notification_uuid}" if issue_link_extra_params is not None: @@ -2967,7 +3113,7 @@ def assert_generic_issue_blocks( class MSTeamsActivityNotificationTest(ActivityTestCase): - def setUp(self): + def setUp(self) -> None: with assume_test_silo_mode(SiloMode.CONTROL): base_params = { "user_id": self.user.id, @@ -3019,8 +3165,8 @@ def build_and_store_session( hours_before_now: int = 0, minutes_before_now: int = 0, seconds_before_now: int = 0, - **kwargs, - ): + **kwargs: Any, + ) -> None: # We perform also here the same - 1 seconds transformation as in the _store_metric() method. kwargs["started"] = self.adjust_timestamp( self.now @@ -3036,7 +3182,7 @@ def build_and_store_session( class OrganizationMetricsIntegrationTestCase(MetricsAPIBaseTestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(user=self.user) now = int(time.time()) @@ -3084,7 +3230,7 @@ def setUp(self): class MonitorTestCase(APITestCase): - def _create_monitor(self, **kwargs): + def _create_monitor(self, **kwargs: Any) -> Monitor: if "owner_user_id" not in kwargs: kwargs["owner_user_id"] = self.user.id @@ -3100,7 +3246,9 @@ def _create_monitor(self, **kwargs): **kwargs, ) - def _create_monitor_environment(self, monitor, name="production", **kwargs): + def _create_monitor_environment( + self, monitor: Monitor, name: str = "production", **kwargs: Any + ) -> MonitorEnvironment: environment = Environment.get_or_create(project=self.project, name=name) monitorenvironment_defaults = { @@ -3114,7 +3262,7 @@ def _create_monitor_environment(self, monitor, name="production", **kwargs): **monitorenvironment_defaults, ) - def _create_issue_alert_rule(self, monitor): + def _create_issue_alert_rule(self, monitor: Monitor) -> Rule: conditions = [ { "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition", @@ -3166,10 +3314,10 @@ class MonitorIngestTestCase(MonitorTestCase): """ @property - def token_auth_headers(self): + def token_auth_headers(self) -> dict[str, str]: return {"HTTP_AUTHORIZATION": f"Bearer {self.token.token}"} - def setUp(self): + def setUp(self) -> None: super().setUp() # DSN based auth self.project_key = self.create_project_key() @@ -3186,8 +3334,12 @@ def setUp(self): class UptimeTestCaseMixin: - def setUp(self): - super().setUp() + addCleanup: Callable[..., None] + + def setUp(self) -> None: + parent_setup = getattr(super(), "setUp", None) + if parent_setup is not None: + parent_setup() patcher = mock.patch( "sentry.uptime.rdap.query.resolve_hostname", return_value="192.168.0.1" ) @@ -3219,10 +3371,7 @@ def create_uptime_result( guid = uuid.uuid4().hex if scheduled_check_time is None: scheduled_check_time = datetime.now().replace(microsecond=0) - optional_fields: _OptionalCheckResult = {} - if uptime_region is not None: - optional_fields["region"] = uptime_region - return { + result: CheckResult = { "guid": guid, "subscription_id": subscription_id, "status": status, @@ -3237,12 +3386,10 @@ def create_uptime_result( "duration_ms": 100, "request_info": {"request_type": REQUESTTYPE_HEAD, "http_status_code": 500}, "assertion_failure_data": MOCK_ASSERTION_FAILURE_DATA, - **optional_fields, } - - -class _OptionalCheckResult(TypedDict, total=False): - region: str + if uptime_region is not None: + result["region"] = uptime_region + return result class UptimeTestCase(UptimeTestCaseMixin, TestCase): @@ -3255,6 +3402,8 @@ def should_call_api_without_proxying(self) -> bool: class SpanTestCase(BaseTestCase): + ten_mins_ago: datetime + # Some base data for create_span base_span: dict[str, Any] = { "is_segment": False, @@ -3270,7 +3419,7 @@ def load_data( timestamp: datetime | None = None, duration: timedelta | None = None, **kwargs: Any, - ) -> dict[str | int, Any]: + ) -> dict[str, Any]: if timestamp is None: timestamp = self.ten_mins_ago @@ -3290,7 +3439,9 @@ def load_data( microseconds=start_timestamp.microsecond % 1000 ) - return load_data(platform, timestamp=timestamp, start_timestamp=start_timestamp, **kwargs) + data = load_data(platform, timestamp=timestamp, start_timestamp=start_timestamp, **kwargs) + assert data is not None + return data def create_span( self, @@ -3377,7 +3528,7 @@ def scalar_to_any_value(value: Any) -> AnyValue: raise Exception(f"cannot convert {value} of type {type(value)} to AnyValue") -def span_to_trace_item(span) -> TraceItem: +def span_to_trace_item(span: dict[str, Any]) -> TraceItem: client_sample_rate = 1.0 server_sample_rate = 1.0 attributes = {} @@ -3761,7 +3912,12 @@ def create_trace_attachment( class TraceTestCase(SpanTestCase): - def setUp(self): + FEATURES: list[str] = [] + gen1_project: Project + # justification: mirrors SnubaTestCase.store_spans. + store_spans: Callable[[Sequence[dict[str, Any]]], None] + + def setUp(self) -> None: self.day_ago = before_now(days=1).replace(hour=10, minute=0, second=0, microsecond=0) self.root_span_ids = [uuid4().hex[:16] for _ in range(3)] self.trace_id = uuid4().hex @@ -3797,6 +3953,7 @@ def create_event( timestamp=end, start_timestamp=start, ) + assert data is not None data["transaction"] = transaction data["contexts"]["trace"]["parent_span_id"] = parent_span_id data["contexts"]["profile"] = {"profile_id": uuid4().hex} @@ -3920,6 +4077,7 @@ def load_errors( "javascript", timestamp=start, ) + assert error_data is not None error_data["contexts"]["trace"] = { "type": "trace", "trace_id": self.trace_id, @@ -3969,16 +4127,16 @@ class ReplayEAPTestCase(BaseTestCase): def create_eap_replay_breadcrumb( self, *, - project, - replay_id, - segment_id, - breadcrumb_type, - timestamp=None, - organization=None, - trace_id=None, - retention_days=30, - **attributes, - ): + project: Project, + replay_id: str, + segment_id: int, + breadcrumb_type: ReplayBreadcrumbType, + timestamp: datetime | None = None, + organization: Organization | None = None, + trace_id: str | None = None, + retention_days: int = 30, + **attributes: Any, + ) -> TraceItem: """Create single EAP replay breadcrumb TraceItem.""" from datetime import datetime from uuid import uuid4 @@ -4058,16 +4216,16 @@ def create_eap_replay_breadcrumb( def create_replay_breadcrumb( self, *, - project, - replay_id, - segment_id, - breadcrumb_type, - timestamp=None, - organization=None, - trace_id=None, - retention_days=30, - **attributes, - ): + project: Project, + replay_id: str, + segment_id: int, + breadcrumb_type: ReplayBreadcrumbType, + timestamp: datetime | None = None, + organization: Organization | None = None, + trace_id: str | None = None, + retention_days: int = 30, + **attributes: Any, + ) -> TraceItem: """create_eap_replay_breadcrumb is wrong so making another create event that's tested against what we see in production. can also check `src/sentry/replays/usecases/ingest/event_parser.py` for these names too""" @@ -4146,36 +4304,36 @@ class UptimeResultEAPTestCase(BaseTestCase): def create_eap_uptime_result( self, *, - organization=None, - project=None, - scheduled_check_time=None, - trace_id=None, - guid=None, - subscription_id=None, - check_id=None, - check_status="success", - incident_status=None, - region="default", - http_status_code=200, - request_type="GET", - request_url="https://example.com", - original_url=None, - request_sequence=0, - check_duration_us=150000, - request_duration_us=125000, - dns_lookup_duration_us=None, - tcp_connection_duration_us=None, - tls_handshake_duration_us=None, - time_to_first_byte_duration_us=None, - send_request_duration_us=None, - receive_response_duration_us=None, - request_body_size_bytes=None, - assertion_failure_data=None, - response_body_size_bytes=None, - status_reason_type=None, - status_reason_description=None, - span_id=None, - ): + organization: Organization | None = None, + project: Project | None = None, + scheduled_check_time: datetime | None = None, + trace_id: str | None = None, + guid: str | None = None, + subscription_id: str | None = None, + check_id: str | None = None, + check_status: str = "success", + incident_status: IncidentStatus | None = None, + region: str = "default", + http_status_code: int = 200, + request_type: str = "GET", + request_url: str = "https://example.com", + original_url: str | None = None, + request_sequence: int = 0, + check_duration_us: int | None = 150000, + request_duration_us: int | None = 125000, + dns_lookup_duration_us: int | None = None, + tcp_connection_duration_us: int | None = None, + tls_handshake_duration_us: int | None = None, + time_to_first_byte_duration_us: int | None = None, + send_request_duration_us: int | None = None, + receive_response_duration_us: int | None = None, + request_body_size_bytes: int | None = None, + assertion_failure_data: dict[str, Any] | None = None, + response_body_size_bytes: int | None = None, + status_reason_type: str | None = None, + status_reason_description: str | None = None, + span_id: str | None = None, + ) -> TraceItem: if organization is None: organization = self.organization if project is None: @@ -4271,19 +4429,19 @@ class ProcessingErrorTestCase(BaseTestCase): def create_processing_error( self, *, - organization=None, - project=None, - timestamp=None, - trace_id=None, - event_id=None, - error_type="js_no_source", - symbolicator_type=None, - release=None, - environment=None, - platform="javascript", - sdk_name=None, - sdk_version=None, - ): + organization: Organization | None = None, + project: Project | None = None, + timestamp: datetime | None = None, + trace_id: str | None = None, + event_id: str | None = None, + error_type: str = "js_no_source", + symbolicator_type: str | None = None, + release: str | None = None, + environment: str | None = None, + platform: str = "javascript", + sdk_name: str | None = None, + sdk_version: str | None = None, + ) -> TraceItem: if organization is None: organization = self.organization if project is None: diff --git a/src/sentry/testutils/factories.py b/src/sentry/testutils/factories.py index 8ab52b7a316e..86f8b5459f10 100644 --- a/src/sentry/testutils/factories.py +++ b/src/sentry/testutils/factories.py @@ -9,12 +9,12 @@ import zipfile from base64 import b64encode from binascii import hexlify -from collections.abc import Mapping, MutableMapping, Sequence +from collections.abc import Collection, Mapping, MutableMapping, Sequence from datetime import UTC, datetime, timedelta from enum import Enum from hashlib import sha1 from importlib import import_module -from typing import Any +from typing import TYPE_CHECKING, Any, cast from unittest import mock from uuid import uuid4 @@ -226,17 +226,19 @@ from sentry.signals import project_created from sentry.silo.base import SiloMode from sentry.snuba.dataset import Dataset -from sentry.snuba.models import QuerySubscriptionDataSourceHandler +from sentry.snuba.models import QuerySubscriptionDataSourceHandler, SnubaQuery, SnubaQueryEventType from sentry.snuba.subscriptions import bulk_create_snuba_subscriptions, create_snuba_query from sentry.tempest.models import MessageType as TempestMessageType from sentry.tempest.models import TempestCredentials from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import assume_test_silo_mode from sentry.types.activity import ActivityType +from sentry.types.actor import Actor from sentry.types.cell import Cell, get_cell_by_name, get_local_cell from sentry.types.token import AuthTokenType from sentry.uptime.models import ( IntervalSecondsLiteral, + SupportedHTTPMethodsLiteral, UptimeSubscription, UptimeSubscriptionRegion, ) @@ -282,6 +284,15 @@ from sentry.workflow_engine.typings.grouptype import IssueStreamGroupType from social_auth.models import UserSocialAuth +if TYPE_CHECKING: + from django.contrib.sessions.backends.base import SessionBase + from django.http import HttpRequest + + from sentry.integrations.models.data_forwarder_project import DataForwarderProject + from sentry.models.distribution import Distribution + from sentry.models.projectkey import ProjectKey + from sentry.models.pullrequest import PullRequest, PullRequestComment + class EventType(Enum): ERROR = "error" @@ -295,13 +306,13 @@ def get_fixture_path(*parts: str) -> str: return os.path.join(path, "fixtures", *parts) -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)) -def make_word(words=None): +def make_word(words: int | None = None) -> str: if words is None: words = int(random.weibullvariate(8, 3)) return random.choice(loremipsum.words) @@ -420,7 +431,13 @@ def make_word(words=None): } -def _patch_artifact_manifest(path, org=None, release=None, project=None, extra_files=None): +def _patch_artifact_manifest( + path: str, + org: str | None = None, + release: str | None = None, + project: str | None = None, + extra_files: Mapping[str, str | bytes] | None = None, +) -> str: with open(path, "rb") as fp: manifest = orjson.loads(fp.read()) if org: @@ -454,7 +471,9 @@ def _set_sample_rate_from_error_sampling(normalized_data: MutableMapping[str, An class Factories: @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_notebook(organization, created_by=None, **kwargs): + def create_notebook( + organization: Organization, created_by: User | None = None, **kwargs: Any + ) -> Notebook: return Notebook.objects.create( organization=organization, created_by_id=created_by.id if created_by else None, @@ -463,22 +482,27 @@ def create_notebook(organization, created_by=None, **kwargs): @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_notebook_project(notebook, project): + def create_notebook_project(notebook: Notebook, project: Project) -> NotebookProject: return NotebookProject.objects.create(notebook=notebook, project=project) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_notebook_favorite(notebook, user): + def create_notebook_favorite(notebook: Notebook, user: User) -> NotebookFavoriteUser: return NotebookFavoriteUser.objects.create(notebook=notebook, user_id=user.id) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_notebook_seen(notebook, user, **kwargs): + def create_notebook_seen(notebook: Notebook, user: User, **kwargs: Any) -> NotebookSeen: return NotebookSeen.objects.create(notebook=notebook, user_id=user.id, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_notebook_comment(notebook, author=None, block=None, body="A comment"): + def create_notebook_comment( + notebook: Notebook, + author: User | None = None, + block: NotebookBlock | None = None, + body: str = "A comment", + ) -> NotebookComment: return NotebookComment.objects.create( notebook=notebook, block=block, @@ -489,8 +513,8 @@ def create_notebook_comment(notebook, author=None, block=None, body="A comment") @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_notebook_action_request( - notebook, producer_revision=0, key="action-request", **kwargs - ): + notebook: Notebook, producer_revision: int = 0, key: str = "action-request", **kwargs: Any + ) -> NotebookActionRequest: kwargs.setdefault("title", "An action request") kwargs.setdefault("rationale", "Because it helps.") return NotebookActionRequest.objects.create( @@ -499,43 +523,60 @@ def create_notebook_action_request( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_notebook_block(notebook, position=0, kind="text", **kwargs): + def create_notebook_block( + notebook: Notebook, position: int = 0, kind: str = "text", **kwargs: Any + ) -> NotebookBlock: return NotebookBlock.objects.create( notebook=notebook, position=position, kind=kind, **kwargs ) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_notebook_block_execution(block, **kwargs): + def create_notebook_block_execution( + block: NotebookBlock, **kwargs: Any + ) -> NotebookBlockExecution: return NotebookBlockExecution.objects.create(block=block, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_notebook_block_execution_project(execution, project): + def create_notebook_block_execution_project( + execution: NotebookBlockExecution, project: Project + ) -> NotebookBlockExecutionProject: return NotebookBlockExecutionProject.objects.create(execution=execution, project=project) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_investigation_orchestration_run(investigation, **kwargs): + def create_investigation_orchestration_run( + investigation: Notebook, **kwargs: Any + ) -> InvestigationOrchestrationRun: return InvestigationOrchestrationRun.objects.create(investigation=investigation, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_investigation_orchestration_event(orchestration_run, **kwargs): + def create_investigation_orchestration_event( + orchestration_run: InvestigationOrchestrationRun, **kwargs: Any + ) -> InvestigationOrchestrationEvent: return InvestigationOrchestrationEvent.objects.create( orchestration_run=orchestration_run, **kwargs ) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_investigation_orchestration_command(orchestration_run, **kwargs): + def create_investigation_orchestration_command( + orchestration_run: InvestigationOrchestrationRun, **kwargs: Any + ) -> InvestigationOrchestrationCommand: return InvestigationOrchestrationCommand.objects.create( orchestration_run=orchestration_run, **kwargs ) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_organization(name=None, owner=None, cell: Cell | str | None = None, **kwargs): + def create_organization( + name: str | None = None, + owner: User | None = None, + cell: Cell | str | None = None, + **kwargs: Any, + ) -> Organization: if not name: name = petname.generate(2, " ", letters=10).title() @@ -581,13 +622,13 @@ def create_organization(name=None, owner=None, cell: Cell | str | None = None, * @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_organization_avatar(*args, **kwargs) -> OrganizationAvatar: + def create_organization_avatar(*args: Any, **kwargs: Any) -> OrganizationAvatar: with outbox_runner(): return OrganizationAvatar.objects.create(*args, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_org_mapping(org=None, **kwds): + def create_org_mapping(org: Organization | None = None, **kwds: Any) -> OrganizationMapping: if org: kwds.setdefault("organization_id", org.id) kwds.setdefault("slug", org.slug) @@ -598,7 +639,11 @@ def create_org_mapping(org=None, **kwds): @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_member(teams=None, team_roles=None, **kwargs): + def create_member( + teams: Sequence[Team] | None = None, + team_roles: Sequence[tuple[Team, str]] | None = None, + **kwargs: Any, + ) -> OrganizationMember: kwargs.setdefault("role", "member") teamRole = kwargs.pop("teamRole", None) @@ -631,7 +676,7 @@ def create_member(teams=None, team_roles=None, **kwargs): def create_member_invite( organization: Organization | None = None, email: str | None = None, - **kwargs, + **kwargs: Any, ) -> OrganizationMemberInvite: if organization is None: organization = Factories.create_organization() @@ -644,7 +689,12 @@ def create_member_invite( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_team_membership(team, member=None, user=None, role=None): + def create_team_membership( + team: Team, + member: OrganizationMember | None = None, + user: User | None = None, + role: str | None = None, + ) -> OrganizationMemberTeam: if member is None: member, created = OrganizationMember.objects.get_or_create( user_id=user.id if user else None, @@ -658,34 +708,36 @@ def create_team_membership(team, member=None, user=None, role=None): @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_api_key(organization, **kwargs) -> ApiKey: + def create_api_key(organization: Organization, **kwargs: Any) -> ApiKey: return ApiKey.objects.create(organization_id=organization.id, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_api_application(owner: User, **kwargs) -> ApiApplication: + def create_api_application(owner: User, **kwargs: Any) -> ApiApplication: kwargs.setdefault("name", "Example App") kwargs.setdefault("redirect_uris", "https://example.com/callback") return ApiApplication.objects.create(owner=owner, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_api_device_code(application: ApiApplication, **kwargs) -> ApiDeviceCode: + def create_api_device_code(application: ApiApplication, **kwargs: Any) -> ApiDeviceCode: return ApiDeviceCode.objects.create(application=application, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_auth_provider(**kwargs): + def create_auth_provider(**kwargs: Any) -> AuthProvider: return AuthProvider.objects.create(**kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_auth_identity(**kwargs): + def create_auth_identity(**kwargs: Any) -> AuthIdentity: return AuthIdentity.objects.create(**kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_user_auth_token(user, scope_list: list[str] | None = None, **kwargs) -> ApiToken: + def create_user_auth_token( + user: User, scope_list: list[str] | None = None, **kwargs: Any + ) -> ApiToken: if scope_list is None: scope_list = [] return ApiToken.objects.create( @@ -697,12 +749,12 @@ def create_user_auth_token(user, scope_list: list[str] | None = None, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_org_auth_token(*args, **kwargs) -> OrgAuthToken: + def create_org_auth_token(*args: Any, **kwargs: Any) -> OrgAuthToken: return OrgAuthToken.objects.create(*args, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_team(organization, **kwargs): + def create_team(organization: Organization, **kwargs: Any) -> Team: if not kwargs.get("name"): kwargs["name"] = petname.generate(2, " ", letters=10).title() if not kwargs.get("slug"): @@ -717,7 +769,7 @@ def create_team(organization, **kwargs): @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_environment(project, **kwargs): + def create_environment(project: Project, **kwargs: Any) -> Environment: name = kwargs.get("name", petname.generate(3, " ", letters=10)[:64]) organization = kwargs.get("organization") @@ -730,11 +782,11 @@ def create_environment(project, **kwargs): @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_project( - organization=None, - teams=None, - fire_project_created=False, - create_default_detectors=True, - **kwargs, + organization: Organization | None = None, + teams: Sequence[Team] | None = None, + fire_project_created: bool = False, + create_default_detectors: bool = True, + **kwargs: Any, ) -> Project: from sentry.workflow_engine.receivers.project_detectors import ( disable_default_detector_creation, @@ -753,7 +805,11 @@ def create_project( else contextlib.nullcontext() ): with transaction.atomic(router.db_for_write(Project)): - project = Project.objects.create(organization=organization, **kwargs) + if organization is not None: + project = Project.objects.create(organization=organization, **kwargs) + else: + assert "organization_id" in kwargs + project = Project.objects.create(**kwargs) if teams: for team in teams: project.add_team(team) @@ -766,12 +822,12 @@ def create_project( @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_data_access_grant(**kwargs): + def create_data_access_grant(**kwargs: Any) -> DataAccessGrant: return DataAccessGrant.objects.create(**kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_project_bookmark(project, user): + def create_project_bookmark(project: Project, user: User) -> ProjectBookmark: return ProjectBookmark.objects.create(project_id=project.id, user_id=user.id) @staticmethod @@ -813,15 +869,15 @@ def create_ai_conversation_metadata( @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_project_rule( - project, - action_data=None, - condition_data=None, - name="Test Alert", - action_match="all", - filter_match="all", - frequency=30, - **kwargs, - ): + project: Project, + action_data: list[dict[str, Any]] | None = None, + condition_data: list[dict[str, Any]] | None = None, + name: str = "Test Alert", + action_match: str = "all", + filter_match: str = "all", + frequency: int = 30, + **kwargs: Any, + ) -> Rule: action_data = action_data or [ { "id": "sentry.rules.actions.notify_event_service.NotifyEventServiceAction", @@ -855,7 +911,12 @@ def create_project_rule( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_slack_project_rule(project, integration_id, channel_id=None, channel_name=None): + def create_slack_project_rule( + project: Project, + integration_id: int | str, + channel_id: str | None = None, + channel_name: str | None = None, + ) -> Rule: action_data = [ { "id": "sentry.rules.actions.notify_event.SlackNotifyServiceAction", @@ -869,7 +930,7 @@ def create_slack_project_rule(project, integration_id, channel_id=None, channel_ @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_project_key(project, **kwargs): + def create_project_key(project: Project, **kwargs: Any) -> ProjectKey: if kwargs: return project.key_set.create(**kwargs) return project.key_set.get_or_create()[0] @@ -906,7 +967,7 @@ def create_tempest_credentials( message: str = "", message_type: str | None = None, latest_fetched_item_id: str | None = None, - ): + ) -> TempestCredentials: if client_id is None: client_id = str(uuid4()) if client_secret is None: @@ -937,7 +998,7 @@ def create_release( adopted: datetime | None = None, unadopted: datetime | None = None, status: int | None = ReleaseStatus.OPEN, - ): + ) -> Release: if version is None: version = hexlify(os.urandom(20)).decode() @@ -1007,7 +1068,12 @@ def create_group_release(project: Project, group: Group, release: Release) -> Gr @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_release_file(release_id, file=None, name=None, dist_id=None): + def create_release_file( + release_id: int, + file: File | None = None, + name: str | None = None, + dist_id: int | None = None, + ) -> ReleaseFile: if file is None: file = Factories.create_file( name="log.txt", @@ -1032,8 +1098,12 @@ def create_release_file(release_id, file=None, name=None, dist_id=None): @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_artifact_bundle_zip( - org=None, release=None, project=None, extra_files=None, fixture_path="artifact_bundle" - ): + org: str | None = None, + release: str | None = None, + project: str | None = None, + extra_files: Mapping[str, str | bytes] | None = None, + fixture_path: str = "artifact_bundle", + ) -> bytes: bundle = io.BytesIO() bundle_dir = get_fixture_path(fixture_path) with zipfile.ZipFile(bundle, "w", zipfile.ZIP_DEFLATED) as zipf: @@ -1055,7 +1125,9 @@ def create_artifact_bundle_zip( @classmethod @assume_test_silo_mode(SiloMode.CELL) - def create_release_archive(cls, org, release: str, project=None, dist=None): + def create_release_archive( + cls, org: str, release: str, project: str | None = None, dist: Distribution | None = None + ) -> ReleaseFile | None: bundle = cls.create_artifact_bundle_zip(org, release, project) file = File.objects.create(name="release-artifacts.zip") file.putfile(ContentFile(bundle)) @@ -1066,14 +1138,14 @@ def create_release_archive(cls, org, release: str, project=None, dist=None): @assume_test_silo_mode(SiloMode.CELL) def create_artifact_bundle( cls, - org, - bundle_id=None, - artifact_count=0, - fixture_path="artifact_bundle_debug_ids", - date_uploaded=None, - date_last_modified=None, - indexing_state=None, - ): + org: Organization, + bundle_id: uuid.UUID | None = None, + artifact_count: int = 0, + fixture_path: str = "artifact_bundle_debug_ids", + date_uploaded: datetime | None = None, + date_last_modified: datetime | None = None, + indexing_state: int | None = None, + ) -> ArtifactBundle: if date_uploaded is None: date_uploaded = timezone.now() @@ -1095,13 +1167,19 @@ def create_artifact_bundle( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_code_mapping(project, repo=None, organization_integration=None, **kwargs): + def create_code_mapping( + project: Project, + repo: Repository | None = None, + organization_integration: OrganizationIntegration | None = None, + **kwargs: Any, + ) -> RepositoryProjectPathConfig: kwargs.setdefault("stack_root", "") kwargs.setdefault("source_root", "") kwargs.setdefault("default_branch", "master") if not repo: repo = Factories.create_repo(project=project) + assert organization_integration is not None if "project_repository" not in kwargs: project_repo, _ = ProjectRepository.objects.get_or_create( project=project, @@ -1119,7 +1197,9 @@ def create_code_mapping(project, repo=None, organization_integration=None, **kwa @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_project_repository( - project, repository, source=ProjectRepositorySource.MANUAL + project: Project, + repository: Repository, + source: ProjectRepositorySource = ProjectRepositorySource.MANUAL, ) -> ProjectRepository: return ProjectRepository.objects.create( project=project, repository=repository, source=source @@ -1127,7 +1207,12 @@ def create_project_repository( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_seer_project_repository(project, repository=None, repository_id=None, **kwargs): + def create_seer_project_repository( + project: Project, + repository: Repository | None = None, + repository_id: int | None = None, + **kwargs: Any, + ) -> SeerProjectRepository: if repository is None and repository_id is not None: repository = Repository.objects.get(id=repository_id) assert repository is not None, "repository or repository_id is required" @@ -1144,8 +1229,13 @@ def create_seer_project_repository(project, repository=None, repository_id=None, @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_repo( - project, name=None, provider=None, integration_id=None, url=None, external_id=None - ): + project: Project, + name: str | None = None, + provider: str | None = None, + integration_id: int | None = None, + url: str | None = None, + external_id: str | None = None, + ) -> Repository: repo, _ = Repository.objects.get_or_create( organization_id=project.organization_id, name=name @@ -1180,8 +1270,14 @@ def create_repository_settings( @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_commit( - repo, project=None, author=None, release=None, message=None, key=None, date_added=None - ): + repo: Repository, + project: Project | None = None, + author: CommitAuthor | None = None, + release: Release | None = None, + message: str | None = None, + key: str | None = None, + date_added: datetime | None = None, + ) -> Commit: commit = Commit.objects.get_or_create( organization_id=repo.organization_id, repository_id=repo.id, @@ -1212,13 +1308,21 @@ def create_commit( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_commit_author(organization_id=None, project=None, user=None, email=None): + def create_commit_author( + organization_id: int | None = None, + project: Project | None = None, + user: User | None = None, + email: str | None = None, + ) -> CommitAuthor: if email: user_email = email else: user_email = user.email if user else f"{make_word()}@example.com" + if organization_id is None: + assert project is not None + organization_id = project.organization_id return CommitAuthor.objects.get_or_create( - organization_id=organization_id or project.organization_id, + organization_id=organization_id, email=user_email, defaults={"name": user.name if user else make_word()}, )[0] @@ -1226,10 +1330,17 @@ def create_commit_author(organization_id=None, project=None, user=None, email=No @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_pull_request( - repository_id=None, organization_id=None, key=None, title=None, message=None, author=None - ): + repository_id: int | None = None, + organization_id: int | None = None, + key: str | None = None, + title: str | None = None, + message: str | None = None, + author: CommitAuthor | None = None, + ) -> PullRequest: from sentry.models.pullrequest import PullRequest + assert repository_id is not None + assert organization_id is not None return PullRequest.objects.create( repository_id=repository_id, organization_id=organization_id, @@ -1242,14 +1353,14 @@ def create_pull_request( @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_pull_request_comment( - pull_request, - external_id=None, - created_at=None, - updated_at=None, - group_ids=None, - comment_type=None, - reactions=None, - ): + pull_request: PullRequest, + external_id: int | None = None, + created_at: datetime | None = None, + updated_at: datetime | None = None, + group_ids: list[int] | None = None, + comment_type: int | None = None, + reactions: dict[str, Any] | None = None, + ) -> PullRequestComment: from django.utils import timezone from sentry.models.pullrequest import CommentType, PullRequestComment @@ -1275,7 +1386,7 @@ def create_pull_request_comment( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_pull_request_commit(pull_request, commit): + def create_pull_request_commit(pull_request: PullRequest, commit: Commit) -> PullRequestCommit: return PullRequestCommit.objects.create( pull_request=pull_request, commit=commit, @@ -1283,14 +1394,14 @@ def create_pull_request_commit(pull_request, commit): @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_commit_file_change(commit, filename): + def create_commit_file_change(commit: Commit, filename: str) -> tuple[CommitFileChange, bool]: return CommitFileChange.objects.get_or_create( organization_id=commit.organization_id, commit_id=commit.id, filename=filename, type="M" ) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_release_commit(release, commit, order=1): + def create_release_commit(release: Release, commit: Commit, order: int = 1) -> ReleaseCommit: return ReleaseCommit.objects.create( organization_id=release.organization_id, release=release, @@ -1301,7 +1412,12 @@ def create_release_commit(release, commit, order=1): @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) def create_user( - email=None, is_superuser=False, is_staff=False, is_active=True, is_test_user=True, **kwargs + email: str | None = None, + is_superuser: bool = False, + is_staff: bool = False, + is_active: bool = True, + is_test_user: bool = True, + **kwargs: Any, ) -> User: if email is None: email = uuid4().hex + "@example.com" @@ -1324,7 +1440,7 @@ def create_user( @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_useremail(user, email=None, **kwargs): + def create_useremail(user: User, email: str | None = None, **kwargs: Any) -> UserEmail: if not email: email = uuid4().hex + "@example.com" @@ -1337,12 +1453,12 @@ def create_useremail(user, email=None, **kwargs): @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_user_avatar(*args, **kwargs): + def create_user_avatar(*args: Any, **kwargs: Any) -> UserAvatar: return UserAvatar.objects.create(*args, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_user_role(*args, **kwargs): + def create_user_role(*args: Any, **kwargs: Any) -> UserRole: return UserRole.objects.create(*args, **kwargs) @staticmethod @@ -1352,7 +1468,7 @@ def create_usersocialauth( provider: str | None = None, uid: str | None = None, extra_data: dict[str, Any] | None = None, - ): + ) -> UserSocialAuth: if not provider: provider = "asana" if not uid: @@ -1364,7 +1480,7 @@ def create_usersocialauth( return usa @staticmethod - def inject_performance_problems(jobs, _): + def inject_performance_problems(jobs: list[dict[str, Any]], _: object) -> None: for job in jobs: job["performance_problems"] = [] for f in job["data"]["fingerprint"]: @@ -1393,7 +1509,7 @@ def inject_performance_problems(jobs, _): @staticmethod @assume_test_silo_mode(SiloMode.CELL) def store_event( - data, + data: MutableMapping[str, Any], project_id: int, assert_no_errors: bool = True, default_event_type: EventType | None = None, @@ -1449,7 +1565,7 @@ def store_event( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_group(project, create_open_period=True, **kwargs): + def create_group(project: Project, create_open_period: bool = True, **kwargs: Any) -> Group: from sentry.models.group import GroupStatus from sentry.models.groupopenperiod import GroupOpenPeriod, should_create_open_periods from sentry.testutils.helpers.datetime import before_now @@ -1484,23 +1600,23 @@ def create_group(project, create_open_period=True, **kwargs): @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_group_activity(group, *args, **kwargs): + def create_group_activity(group: Group, *args: Any, **kwargs: Any) -> Activity: return Activity.objects.create(group=group, project=group.project, *args, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_group_subscription(group, **kwargs): + def create_group_subscription(group: Group, **kwargs: Any) -> GroupSubscription: kwargs.setdefault("project", group.project) return GroupSubscription.objects.create(group=group, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_group_owner( - group, - type=GroupOwnerType.OWNERSHIP_RULE.value, - user_id=None, - team=None, - **kwargs, + group: Group, + type: int = GroupOwnerType.OWNERSHIP_RULE.value, + user_id: int | None = None, + team: Team | None = None, + **kwargs: Any, ) -> GroupOwner: return GroupOwner.objects.create( group=group, @@ -1514,13 +1630,20 @@ def create_group_owner( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_group_derived_data(group, **kwargs) -> GroupDerivedData: + def create_group_derived_data(group: Group, **kwargs: Any) -> GroupDerivedData: return GroupDerivedData.objects.create(group=group, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_group_action_log_entry( - group, type=None, actor_type=None, actor_id=None, source=None, data=None, *args, **kwargs + group: Group, + type: GroupActionType | None = None, + actor_type: GroupActorType | None = None, + actor_id: int | None = None, + source: str | None = None, + data: dict[str, Any] | None = None, + *args: Any, + **kwargs: Any, ) -> GroupActionLogEntry: if type is None: type = GroupActionType.VIEW @@ -1547,19 +1670,23 @@ def create_group_action_log_entry( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_file(**kwargs): + def create_file(**kwargs: Any) -> File: return File.objects.create(**kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_data_forwarder(organization, provider, config, **kwargs): + def create_data_forwarder( + organization: Organization, provider: str, config: dict[str, Any], **kwargs: Any + ) -> DataForwarder: return DataForwarder.objects.create( organization=organization, provider=provider, config=config, **kwargs ) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_data_forwarder_project(data_forwarder, project, **kwargs): + def create_data_forwarder_project( + data_forwarder: DataForwarder, project: Project, **kwargs: Any + ) -> DataForwarderProject: from sentry.integrations.models.data_forwarder_project import DataForwarderProject return DataForwarderProject.objects.create( @@ -1568,28 +1695,28 @@ def create_data_forwarder_project(data_forwarder, project, **kwargs): @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_file_from_path(path, name=None, **kwargs): + def create_file_from_path(path: str, name: str | None = None, **kwargs: Any) -> File: if name is None: name = os.path.basename(path) file = Factories.create_file(name=name, **kwargs) - with open(path) as f: + with open(path, "rb") as f: file.putfile(f) return file @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_dif_file( - project, - debug_id=None, - object_name=None, - features=None, - data=None, - file=None, - cpu_name=None, - code_id=None, - **kwargs, - ): + project: Project, + debug_id: str | None = None, + object_name: str | None = None, + features: list[str] | None = None, + data: dict[str, Any] | None = None, + file: File | None = None, + cpu_name: str | None = None, + code_id: str | None = None, + **kwargs: Any, + ) -> ProjectDebugFile: if debug_id is None: debug_id = str(uuid4()) @@ -1623,7 +1750,9 @@ def create_dif_file( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_dif_from_path(path, object_name=None, **kwargs): + def create_dif_from_path( + path: str, object_name: str | None = None, **kwargs: Any + ) -> ProjectDebugFile: if object_name is None: object_name = os.path.basename(path) @@ -1633,13 +1762,13 @@ def create_dif_from_path(path, object_name=None, **kwargs): @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def add_user_permission(user, permission): + def add_user_permission(user: User, permission: str) -> None: with outbox_runner(): UserPermission.objects.create(user=user, permission=permission) @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_sentry_app(**kwargs): + def create_sentry_app(**kwargs: Any) -> SentryApp: published = kwargs.pop("published", False) args = Factories._sentry_app_kwargs(**kwargs) user = args.pop("user", None) @@ -1652,12 +1781,12 @@ def create_sentry_app(**kwargs): @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_sentry_app_avatar(*args, **kwargs): + def create_sentry_app_avatar(*args: Any, **kwargs: Any) -> SentryAppAvatar: return SentryAppAvatar.objects.create(*args, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_internal_integration(**kwargs) -> SentryApp: + def create_internal_integration(**kwargs: Any) -> SentryApp: args = Factories._sentry_app_kwargs(**kwargs) args["verify_install"] = False user = args.pop("user", None) @@ -1669,10 +1798,10 @@ def create_internal_integration(**kwargs) -> SentryApp: @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) def create_internal_integration_token( - user, + user: User | RpcUser, internal_integration: SentryApp | None = None, install: SentryAppInstallation | None = None, - request=None, + request: HttpRequest | None = None, ) -> ApiToken: if internal_integration and install: raise ValueError("Only one of internal_integration or install arg can be provided") @@ -1694,7 +1823,7 @@ def create_internal_integration_token( ) @staticmethod - def _sentry_app_kwargs(**kwargs): + def _sentry_app_kwargs(**kwargs: Any) -> dict[str, Any]: _kwargs = { "user": kwargs.get("user", Factories.create_user()), "name": kwargs.get("name", petname.generate(2, " ", letters=10).title()), @@ -1715,12 +1844,12 @@ def _sentry_app_kwargs(**kwargs): @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_sentry_app_installation( - organization=None, - slug=None, - user=None, - status=None, - prevent_token_exchange=False, - ): + organization: Organization | None = None, + slug: str | None = None, + user: User | None = None, + status: int | None = None, + prevent_token_exchange: bool = False, + ) -> SentryAppInstallation: if not organization: organization = Factories.create_organization() @@ -1770,11 +1899,11 @@ def create_sentry_app_installation_for_provider( @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_stacktrace_link_schema(): + def create_stacktrace_link_schema() -> dict[str, Any]: return {"type": "stacktrace-link", "uri": "/redirect/"} @staticmethod - def create_issue_link_schema(): + def create_issue_link_schema() -> dict[str, Any]: return { "type": "issue-link", "link": { @@ -1812,7 +1941,7 @@ def create_issue_link_schema(): } @staticmethod - def create_alert_rule_action_schema(): + def create_alert_rule_action_schema() -> dict[str, Any]: return { "type": "alert-rule-action", "title": "Create Task with App", @@ -1843,8 +1972,14 @@ def create_alert_rule_action_schema(): @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_service_hook( - actor=None, org=None, project=None, events=None, url=None, project_ids=None, **kwargs - ): + actor: User | None = None, + org: Organization | None = None, + project: Project | None = None, + events: list[str] | None = None, + url: str | None = None, + project_ids: list[int] | None = None, + **kwargs: Any, + ) -> ServiceHook: if project: if project_ids is not None: raise ValueError("Cannot provide both project and project_ids") @@ -1891,7 +2026,11 @@ def create_service_hook_project_for_installation( @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_sentry_app_feature(feature=None, sentry_app=None, description=None): + def create_sentry_app_feature( + feature: int | None = None, + sentry_app: SentryApp | None = None, + description: str | None = None, + ) -> IntegrationFeature: if not sentry_app: sentry_app = Factories.create_sentry_app() @@ -1907,7 +2046,7 @@ def create_sentry_app_feature(feature=None, sentry_app=None, description=None): return integration_feature @staticmethod - def _doc_integration_kwargs(**kwargs): + def _doc_integration_kwargs(**kwargs: Any) -> dict[str, Any]: _kwargs = { "name": kwargs.get("name", petname.generate(2, " ", letters=10).title()), "author": kwargs.get("author", "me"), @@ -1923,7 +2062,9 @@ def _doc_integration_kwargs(**kwargs): @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_doc_integration(features=None, has_avatar: bool = False, **kwargs) -> DocIntegration: + def create_doc_integration( + features: Sequence[int] | None = None, has_avatar: bool = False, **kwargs: Any + ) -> DocIntegration: doc = DocIntegration.objects.create(**Factories._doc_integration_kwargs(**kwargs)) if features: Factories.create_doc_integration_features(features=features, doc_integration=doc) @@ -1934,7 +2075,7 @@ def create_doc_integration(features=None, has_avatar: bool = False, **kwargs) -> @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) def create_doc_integration_features( - features=None, doc_integration=None + features: Sequence[int] | None = None, doc_integration: DocIntegration | None = None ) -> list[IntegrationFeature]: if not features: features = [Feature.API] @@ -1953,7 +2094,9 @@ def create_doc_integration_features( @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_doc_integration_avatar(doc_integration=None, **kwargs) -> DocIntegrationAvatar: + def create_doc_integration_avatar( + doc_integration: DocIntegration | None = None, **kwargs: Any + ) -> DocIntegrationAvatar: if not doc_integration: doc_integration = Factories.create_doc_integration() photo = ControlFile.objects.create(name="test.png", type="avatar.file") @@ -1989,18 +2132,26 @@ def create_userreport( ) @staticmethod - def create_session(): + def create_session() -> SessionBase: engine = import_module(settings.SESSION_ENGINE) - session = engine.SessionStore() + # justification: Django's session engine returns a SessionBase implementation. + session = cast("SessionBase", engine.SessionStore()) session.save() return session @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_platform_external_issue( - group=None, service_type=None, display_name=None, web_url=None - ): + group: Group | None = None, + service_type: str | None = None, + display_name: str | None = None, + web_url: str | None = None, + ) -> PlatformExternalIssue: + assert group is not None + assert service_type is not None + assert display_name is not None + assert web_url is not None return PlatformExternalIssue.objects.create( group_id=group.id, project_id=group.project_id, @@ -2011,7 +2162,7 @@ def create_platform_external_issue( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_group_resolution(group, release, **kwargs): + def create_group_resolution(group: Group, release: Release, **kwargs: Any) -> GroupResolution: return GroupResolution.objects.create( group=group, release=release, @@ -2020,7 +2171,13 @@ def create_group_resolution(group, release, **kwargs): @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_group_link(group, linked_id, linked_type=None, relationship=None, **kwargs): + def create_group_link( + group: Group, + linked_id: int, + linked_type: int | None = None, + relationship: int | None = None, + **kwargs: Any, + ) -> GroupLink: return GroupLink.objects.create( group_id=group.id, project_id=group.project_id, @@ -2032,7 +2189,15 @@ def create_group_link(group, linked_id, linked_type=None, relationship=None, **k @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_integration_external_issue(group=None, integration=None, key=None, **kwargs): + def create_integration_external_issue( + group: Group | None = None, + integration: Integration | None = None, + key: str | None = None, + **kwargs: Any, + ) -> ExternalIssue: + assert group is not None + assert integration is not None + assert key is not None external_issue = ExternalIssue.objects.create( organization_id=group.organization.id, integration_id=integration.id, key=key, **kwargs ) @@ -2062,28 +2227,28 @@ def create_integration_external_project( @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_alert_rule( - organization, - projects, - name=None, - owner=None, - query="level:error", - aggregate="count()", - time_window=10, - threshold_period=1, - environment=None, - date_added=None, - query_type=None, - dataset=Dataset.Events, - threshold_type=AlertRuleThresholdType.ABOVE, - resolve_threshold=None, - user=None, - event_types=None, - comparison_delta=None, - description=None, - sensitivity=None, - seasonality=None, - detection_type=AlertRuleDetectionType.STATIC, - ): + organization: Organization, + projects: Sequence[Project], + name: str | None = None, + owner: Actor | None = None, + query: str = "level:error", + aggregate: str = "count()", + time_window: int = 10, + threshold_period: int = 1, + environment: Environment | None = None, + date_added: datetime | None = None, + query_type: SnubaQuery.Type | None = None, + dataset: Dataset = Dataset.Events, + threshold_type: AlertRuleThresholdType = AlertRuleThresholdType.ABOVE, + resolve_threshold: float | None = None, + user: User | None = None, + event_types: Collection[SnubaQueryEventType.EventType] | None = None, + comparison_delta: int | None = None, + description: str | None = None, + sensitivity: str | None = None, + seasonality: AlertRuleSeasonality | None = None, + detection_type: AlertRuleDetectionType = AlertRuleDetectionType.STATIC, + ) -> AlertRule: if not name: name = petname.generate(2, " ", letters=10).title() @@ -2160,7 +2325,9 @@ def create_external_team(team: Team, **kwargs: Any) -> ExternalActor: @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_codeowners(project, code_mapping, **kwargs): + def create_codeowners( + project: Project, code_mapping: RepositoryProjectPathConfig, **kwargs: Any + ) -> ProjectCodeOwners: kwargs.setdefault("raw", "") return ProjectCodeOwners.objects.create( @@ -2371,7 +2538,9 @@ def create_group_history( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_comment(issue, project, user, text="hello world"): + def create_comment( + issue: Group, project: Project, user: User, text: str = "hello world" + ) -> Activity: data = {"text": text} return Activity.objects.create( project=project, @@ -2386,8 +2555,8 @@ def create_comment(issue, project, user, text="hello world"): def create_notification_action( organization: Organization | None = None, projects: list[Project] | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> NotificationAction: if not organization: organization = Factories.create_organization() @@ -2412,12 +2581,14 @@ def create_notification_action( @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_notification_setting_option(*args, **kwargs) -> NotificationSettingOption: + def create_notification_setting_option(*args: Any, **kwargs: Any) -> NotificationSettingOption: return NotificationSettingOption.objects.create(*args, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_notification_settings_provider(*args, **kwargs) -> NotificationSettingProvider: + def create_notification_settings_provider( + *args: Any, **kwargs: Any + ) -> NotificationSettingProvider: return NotificationSettingProvider.objects.create(*args, **kwargs) @staticmethod @@ -2427,7 +2598,7 @@ def create_inbox_item( user: User | RpcUser, subject_type: str = "issue", subject_id: str = "1", - **kwargs, + **kwargs: Any, ) -> InboxItem: kwargs.setdefault("last_activity_at", timezone.now()) return InboxItem.objects.create( @@ -2444,7 +2615,7 @@ def create_inbox_item_event( inbox_item: InboxItem, type: str = "issue.assigned", actor: User | RpcUser | None = None, - **kwargs, + **kwargs: Any, ) -> InboxItemEvent: return InboxItemEvent.objects.create( inbox_item=inbox_item, @@ -2456,12 +2627,12 @@ def create_inbox_item_event( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_weekly_report_project_exclusion(**kwargs): + def create_weekly_report_project_exclusion(**kwargs: Any) -> WeeklyReportProjectExclusion: return WeeklyReportProjectExclusion.objects.create(**kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_user_option(*args, **kwargs) -> UserOption: + def create_user_option(*args: Any, **kwargs: Any) -> UserOption: return UserOption.objects.create(*args, **kwargs) @staticmethod @@ -2471,9 +2642,9 @@ def create_basic_auth_header(username: str, password: str = "") -> bytes: @staticmethod def create_request_access( sso_state: RpcMemberSsoState | None = None, - permissions: list | None = None, + permissions: list[str] | None = None, org_context: RpcUserOrganizationContext | None = None, - scopes_upper_bound: frozenset | None = frozenset(), + scopes_upper_bound: frozenset[str] | None = frozenset(), ) -> RpcBackedAccess: if not sso_state: sso_state = RpcMemberSsoState() @@ -2492,7 +2663,7 @@ def create_request_access( @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) def create_webhook_payload( - mailbox_name: str, cell_name: str | None, **kwargs + mailbox_name: str, cell_name: str | None, **kwargs: Any ) -> WebhookPayload: payload_kwargs = { "request_method": "POST", @@ -2517,13 +2688,13 @@ def create_uptime_subscription( host_provider_name: str, interval_seconds: IntervalSecondsLiteral, timeout_ms: int, - method, - headers, - body, + method: SupportedHTTPMethodsLiteral, + headers: Sequence[Sequence[str]], + body: str | None, date_updated: datetime, trace_sampling: bool = False, assertion: Any | None = None, - ): + ) -> UptimeSubscription: if url is None: url = petname.generate().title() url = f"http://{url}.com" @@ -2565,8 +2736,8 @@ def create_dashboard( organization: Organization | None = None, title: str | None = None, created_by: User | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> Dashboard: if organization is None: organization = Factories.create_organization() if created_by is None: @@ -2687,7 +2858,7 @@ def create_dashboard_favorite_user( user: User, organization: Organization | None = None, position: int | None = None, - **kwargs, + **kwargs: Any, ) -> DashboardFavoriteUser: if organization is None: organization = dashboard.organization @@ -2705,8 +2876,8 @@ def create_dashboard_widget( dashboard: Dashboard | None = None, title: str | None = None, display_type: int | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> DashboardWidget: if dashboard is None: dashboard = Factories.create_dashboard() if display_type is None: @@ -2724,8 +2895,8 @@ def create_dashboard_widget_query( order: int, widget: DashboardWidget | None = None, name: str | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> DashboardWidgetQuery: if widget is None: widget = Factories.create_dashboard_widget() if name is None: @@ -2738,7 +2909,7 @@ def create_workflow( name: str | None = None, organization: Organization | None = None, config: dict[str, Any] | None = None, - **kwargs, + **kwargs: Any, ) -> Workflow: if organization is None: organization = Factories.create_organization() @@ -2754,7 +2925,7 @@ def create_workflow( @assume_test_silo_mode(SiloMode.CELL) def create_data_condition_group( organization: Organization | None = None, - **kwargs, + **kwargs: Any, ) -> DataConditionGroup: if organization is None: organization = Factories.create_organization() @@ -2765,7 +2936,7 @@ def create_data_condition_group( def create_workflow_data_condition_group( workflow: Workflow | None = None, condition_group: DataConditionGroup | None = None, - **kwargs, + **kwargs: Any, ) -> WorkflowDataConditionGroup: if workflow is None: workflow = Factories.create_workflow() @@ -2780,7 +2951,7 @@ def create_workflow_data_condition_group( @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_data_condition( - condition_group: DataConditionGroup | None = None, **kwargs + condition_group: DataConditionGroup | None = None, **kwargs: Any ) -> DataCondition: if condition_group is None: condition_group = Factories.create_data_condition_group() @@ -2792,7 +2963,7 @@ def create_data_source( organization: Organization | None = None, source_id: str | None = None, type: str | None = None, - **kwargs, + **kwargs: Any, ) -> DataSource: if organization is None: organization = Factories.create_organization() @@ -2806,8 +2977,8 @@ def create_data_source( @assume_test_silo_mode(SiloMode.CELL) def create_detector( name: str | None = None, - config: dict | None = None, - **kwargs, + config: dict[str, Any] | None = None, + **kwargs: Any, ) -> Detector: if name is None: name = petname.generate(2, " ", letters=10).title() @@ -2830,7 +3001,7 @@ def create_detector( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_all_projects_detector(organization_id: int, **kwargs) -> Detector: + def create_all_projects_detector(organization_id: int, **kwargs: Any) -> Detector: return Detector.objects.create( name=ALL_PROJECTS_DETECTOR_NAME, config={"organization_id": organization_id}, @@ -2843,7 +3014,7 @@ def create_all_projects_detector(organization_id: int, **kwargs) -> Detector: @assume_test_silo_mode(SiloMode.CELL) def create_detector_state( detector: Detector | None = None, - **kwargs, + **kwargs: Any, ) -> DetectorState: if detector is None: detector = Factories.create_detector() @@ -2855,7 +3026,7 @@ def create_detector_state( def create_data_source_detector( data_source: DataSource | None = None, detector: Detector | None = None, - **kwargs, + **kwargs: Any, ) -> DataSourceDetector: if data_source is None: data_source = Factories.create_data_source() @@ -2869,7 +3040,7 @@ def create_action( config: dict[str, Any] | None = None, type: Action.Type | None = None, data: dict[str, Any] | None = None, - **kwargs, + **kwargs: Any, ) -> Action: if config is None and type is None and data is None: # Default to a slack action with nice defaults so someone can just do @@ -2901,7 +3072,7 @@ def create_action_invocation( detector: Detector, workflow_id: int | None = None, notification_uuid: str | None = None, - **kwargs, + **kwargs: Any, ) -> ActionInvocation: import uuid @@ -2919,7 +3090,7 @@ def create_action_invocation( def create_detector_workflow( detector: Detector | None = None, workflow: Workflow | None = None, - **kwargs, + **kwargs: Any, ) -> DetectorWorkflow: if detector is None: detector = Factories.create_detector() @@ -2932,7 +3103,7 @@ def create_detector_workflow( def create_detector_group( detector: Detector, group: Group, - **kwargs, + **kwargs: Any, ) -> DetectorGroup: return DetectorGroup.objects.create(detector=detector, group=group, **kwargs) @@ -2942,7 +3113,7 @@ def create_alert_rule_workflow( alert_rule_id: int | None = None, rule_id: int | None = None, workflow: Workflow | None = None, - **kwargs, + **kwargs: Any, ) -> AlertRuleWorkflow: if rule_id is None and alert_rule_id is None: raise ValueError("Either rule_id or alert_rule_id must be provided") @@ -2964,7 +3135,7 @@ def create_incident_group_open_period( *, incident_id: int, incident_identifier: int, - **kwargs, + **kwargs: Any, ) -> IncidentGroupOpenPeriod: return IncidentGroupOpenPeriod.objects.create( incident_id=incident_id, @@ -2979,7 +3150,7 @@ def create_alert_rule_detector( alert_rule_id: int | None = None, rule_id: int | None = None, detector: Detector | None = None, - **kwargs, + **kwargs: Any, ) -> AlertRuleDetector: if rule_id is None and alert_rule_id is None: raise ValueError("Either rule_id or alert_rule_id must be provided") @@ -2999,7 +3170,7 @@ def create_alert_rule_detector( def create_action_alert_rule_trigger_action( alert_rule_trigger_action_id: int, action: Action | None = None, - **kwargs, + **kwargs: Any, ) -> ActionAlertRuleTriggerAction: if action is None: action = Factories.create_action() @@ -3023,7 +3194,7 @@ def create_data_condition_alert_rule_trigger( def create_data_condition_group_action( action: Action | None = None, condition_group: DataConditionGroup | None = None, - **kwargs, + **kwargs: Any, ) -> DataConditionGroupAction: if action is None: action = Factories.create_action() @@ -3036,18 +3207,18 @@ def create_data_condition_group_action( @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_preprod_artifact_size_metrics( - artifact, - metrics_type=None, - state=None, - identifier=None, - min_download_size=1024 * 1024, # 1 MB - max_download_size=1024 * 1024, # 1 MB - min_install_size=2 * 1024 * 1024, # 2 MB - max_install_size=2 * 1024 * 1024, # 2 MB - error_code=None, - error_message=None, - analysis_file_id=None, - ): + artifact: PreprodArtifact, + metrics_type: int | None = None, + state: int | None = None, + identifier: str | None = None, + min_download_size: int = 1024 * 1024, # 1 MB + max_download_size: int = 1024 * 1024, # 1 MB + min_install_size: int = 2 * 1024 * 1024, # 2 MB + max_install_size: int = 2 * 1024 * 1024, # 2 MB + error_code: int | None = None, + error_message: str | None = None, + analysis_file_id: int | None = None, + ) -> PreprodArtifactSizeMetrics: if metrics_type is None: metrics_type = PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT if state is None: @@ -3129,7 +3300,7 @@ def create_preprod_comparison_approval( approval_status: int = PreprodComparisonApproval.ApprovalStatus.APPROVED, approved_at: datetime | None = None, approved_by_id: int | None = None, - extras: dict | None = None, + extras: dict[str, Any] | None = None, ) -> PreprodComparisonApproval: return PreprodComparisonApproval.objects.create( preprod_artifact=preprod_artifact, @@ -3251,9 +3422,9 @@ def create_preprod_artifact( installable_app_file_id: int | None = None, date_added: datetime | None = None, build_configuration: PreprodBuildConfiguration | None = None, - extras: dict | None = None, + extras: dict[str, Any] | None = None, create_mobile_app_info: bool = True, - **kwargs, + **kwargs: Any, ) -> PreprodArtifact: # Extract deprecated fields that were moved to PreprodArtifactMobileAppInfo # Remove them from kwargs before passing to PreprodArtifact.objects.create() @@ -3296,7 +3467,7 @@ def create_preprod_artifact_mobile_app_info( build_number: int | None = None, app_name: str | None = None, app_icon_id: str | None = None, - **kwargs, + **kwargs: Any, ) -> PreprodArtifactMobileAppInfo: obj, _ = PreprodArtifactMobileAppInfo.objects.update_or_create( preprod_artifact=preprod_artifact, @@ -3345,11 +3516,11 @@ def create_preprod_artifact_size_comparison( @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_installable_preprod_artifact( - preprod_artifact, + preprod_artifact: PreprodArtifact, url_path: str | None = None, - expiration_date=None, + expiration_date: datetime | None = None, download_count: int = 0, - ): + ) -> InstallablePreprodArtifact: if url_path is None: url_path = str(uuid4()) @@ -3382,7 +3553,7 @@ def create_github_identity( @staticmethod @assume_test_silo_mode(SiloMode.CONTROL) - def create_github_provider(**kwargs) -> IdentityProvider: + def create_github_provider(**kwargs: Any) -> IdentityProvider: identity_provider, _ = IdentityProvider.objects.update_or_create( type="github", external_id="github-app", defaults=kwargs ) @@ -3390,7 +3561,7 @@ def create_github_provider(**kwargs) -> IdentityProvider: @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_seer_autofix_issue_data(group: Group, **kwargs) -> SeerAutofixIssueData: + def create_seer_autofix_issue_data(group: Group, **kwargs: Any) -> SeerAutofixIssueData: kwargs.setdefault("organization_id", group.project.organization_id) kwargs.setdefault("project_id", group.project_id) kwargs.setdefault("source", "night_shift") @@ -3402,7 +3573,9 @@ def create_seer_autofix_issue_data(group: Group, **kwargs) -> SeerAutofixIssueDa @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_seer_agent_write_grant(organization, user, session_id: str = "s1", **kwargs): + def create_seer_agent_write_grant( + organization: Organization, user: User, session_id: str = "s1", **kwargs: Any + ) -> SeerAgentWriteGrant: return SeerAgentWriteGrant.objects.create( organization=organization, user_id=user.id, @@ -3413,11 +3586,11 @@ def create_seer_agent_write_grant(organization, user, session_id: str = "s1", ** @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_seer_workflow_config( - organization, + organization: Organization, *, trigger_actions: Sequence[Action] = (), monitors: Sequence[Detector] = (), - **kwargs, + **kwargs: Any, ) -> SeerWorkflowConfig: config = SeerWorkflowConfig.objects.create(organization=organization, **kwargs) for action in trigger_actions: @@ -3431,35 +3604,43 @@ def create_seer_workflow_config( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_seer_workflow_trigger_binding(config, action) -> SeerWorkflowTriggerBinding: + def create_seer_workflow_trigger_binding( + config: SeerWorkflowConfig, action: Action + ) -> SeerWorkflowTriggerBinding: return SeerWorkflowTriggerBinding.objects.create( workflow_config=config, trigger_action=action ) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_seer_workflow_config_project(config, project) -> SeerWorkflowConfigProject: + def create_seer_workflow_config_project( + config: SeerWorkflowConfig, project: Project + ) -> SeerWorkflowConfigProject: return SeerWorkflowConfigProject.objects.create(config=config, project=project) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_seer_workflow_run(organization, **kwargs) -> SeerWorkflowRun: + def create_seer_workflow_run(organization: Organization, **kwargs: Any) -> SeerWorkflowRun: return SeerWorkflowRun.objects.create(organization=organization, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_seer_workflow_run_execution(run, **kwargs) -> SeerWorkflowRunExecution: + def create_seer_workflow_run_execution( + run: SeerWorkflowRun, **kwargs: Any + ) -> SeerWorkflowRunExecution: return SeerWorkflowRunExecution.objects.create(run=run, **kwargs) @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_seer_run(organization, type: str = SeerRunType.EXPLORER, **kwargs) -> SeerRun: + def create_seer_run( + organization: Organization, type: str = SeerRunType.EXPLORER, **kwargs: Any + ) -> SeerRun: kwargs.setdefault("last_triggered_at", timezone.now()) return SeerRun.objects.create(organization=organization, type=type, **kwargs) @staticmethod def create_seer_agent_run( - run: SeerRun, title: str = "Test run", source: str = "chat", **kwargs + run: SeerRun, title: str = "Test run", source: str = "chat", **kwargs: Any ) -> SeerAgentRun: return SeerAgentRun.objects.create(run=run, title=title, source=source, **kwargs) @@ -3469,7 +3650,7 @@ def create_seer_run_coding_agent_handoff( provider: str = "github_copilot_agent", agent_id: str | None = None, status: str = CodingAgentStatus.PENDING, - **kwargs, + **kwargs: Any, ) -> SeerRunCodingAgentHandoff: if agent_id is None: agent_id = uuid4().hex @@ -3479,5 +3660,7 @@ def create_seer_run_coding_agent_handoff( @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_seer_run_pull_request(run: SeerRun, pull_request, **kwargs) -> SeerRunPullRequest: + def create_seer_run_pull_request( + run: SeerRun, pull_request: PullRequest, **kwargs: Any + ) -> SeerRunPullRequest: return SeerRunPullRequest.objects.create(seer_run=run, pull_request=pull_request, **kwargs) diff --git a/src/sentry/testutils/fixtures.py b/src/sentry/testutils/fixtures.py index 1f68496a383c..8f6d67e9b100 100644 --- a/src/sentry/testutils/fixtures.py +++ b/src/sentry/testutils/fixtures.py @@ -1,8 +1,8 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any from uuid import uuid4 import pytest @@ -71,7 +71,12 @@ # on a per-class method basis from sentry.types.activity import ActivityType from sentry.types.actor import Actor -from sentry.uptime.models import UptimeSubscription, UptimeSubscriptionRegion +from sentry.uptime.models import ( + IntervalSecondsLiteral, + SupportedHTTPMethodsLiteral, + UptimeSubscription, + UptimeSubscriptionRegion, +) from sentry.uptime.types import ( DATA_SOURCE_UPTIME_SUBSCRIPTION, DEFAULT_DOWNTIME_THRESHOLD, @@ -86,14 +91,131 @@ from sentry.workflow_engine.models.data_condition import Condition from sentry.workflow_engine.types import DetectorPriorityLevel +if TYPE_CHECKING: + from django.contrib.sessions.backends.base import SessionBase + + from sentry.ai_monitoring.models import AIConversationMetadata + from sentry.auth.access import RpcBackedAccess + from sentry.data_secrecy.models.data_access_grant import DataAccessGrant + from sentry.discover.models import DiscoverSavedQuery + from sentry.explore.models import ExploreSavedFormula, ExploreSavedQuery + from sentry.hybridcloud.models.webhookpayload import WebhookPayload + from sentry.integrations.models.data_forwarder import DataForwarder + from sentry.integrations.models.data_forwarder_project import DataForwarderProject + from sentry.integrations.models.doc_integration import DocIntegration + from sentry.integrations.models.doc_integration_avatar import DocIntegrationAvatar + from sentry.integrations.models.external_actor import ExternalActor + from sentry.integrations.models.external_issue import ExternalIssue + from sentry.integrations.models.integration_external_project import IntegrationExternalProject + from sentry.integrations.models.integration_feature import IntegrationFeature + from sentry.integrations.models.repository_project_path_config import ( + RepositoryProjectPathConfig, + ) + from sentry.investigations.models import ( + InvestigationOrchestrationCommand, + InvestigationOrchestrationEvent, + InvestigationOrchestrationRun, + ) + from sentry.models.apiapplication import ApiApplication + from sentry.models.apidevicecode import ApiDeviceCode + from sentry.models.apikey import ApiKey + from sentry.models.apitoken import ApiToken + from sentry.models.artifactbundle import ArtifactBundle + from sentry.models.authidentity import AuthIdentity + from sentry.models.authprovider import AuthProvider + from sentry.models.avatars.organization_avatar import OrganizationAvatar + from sentry.models.commit import Commit + from sentry.models.commitauthor import CommitAuthor + from sentry.models.commitfilechange import CommitFileChange + from sentry.models.dashboard import Dashboard, DashboardFavoriteUser + from sentry.models.dashboard_widget import DashboardWidget, DashboardWidgetQuery + from sentry.models.debugfile import ProjectDebugFile + from sentry.models.files.file import File + from sentry.models.grouphistory import GroupHistory + from sentry.models.grouplink import GroupLink + from sentry.models.groupowner import GroupOwner + from sentry.models.groupresolution import GroupResolution + from sentry.models.groupsubscription import GroupSubscription + from sentry.models.organizationmapping import OrganizationMapping + from sentry.models.organizationmemberinvite import OrganizationMemberInvite + from sentry.models.orgauthtoken import OrgAuthToken + from sentry.models.projectbookmark import ProjectBookmark + from sentry.models.projectcodeowners import ProjectCodeOwners + from sentry.models.projectkey import ProjectKey + from sentry.models.projectrepository import ProjectRepository + from sentry.models.pullrequest import PullRequest, PullRequestComment, PullRequestCommit + from sentry.models.release import Release + from sentry.models.releasecommit import ReleaseCommit + from sentry.models.releasefile import ReleaseFile + from sentry.models.repository import Repository + from sentry.models.repositorysettings import RepositorySettings + from sentry.models.userreport import UserReport + from sentry.models.weeklyreportprojectexclusion import WeeklyReportProjectExclusion + from sentry.notebooks.models import ( + Notebook, + NotebookActionRequest, + NotebookBlock, + NotebookBlockExecution, + NotebookBlockExecutionProject, + NotebookComment, + NotebookFavoriteUser, + NotebookProject, + NotebookSeen, + ) + from sentry.notifications.models.inboxitem import InboxItem + from sentry.notifications.models.inboxitemevent import InboxItemEvent + from sentry.notifications.models.notificationaction import NotificationAction + from sentry.notifications.models.notificationsettingoption import NotificationSettingOption + from sentry.notifications.models.notificationsettingprovider import NotificationSettingProvider + from sentry.preprod.models import InstallablePreprodArtifact + from sentry.seer.models.agent_write_grant import SeerAgentWriteGrant + from sentry.seer.models.autofix_issue_data import SeerAutofixIssueData + from sentry.seer.models.project_repository import SeerProjectRepository + from sentry.seer.models.run import ( + SeerAgentRun, + SeerRun, + SeerRunCodingAgentHandoff, + SeerRunPullRequest, + ) + from sentry.sentry_apps.models.platformexternalissue import PlatformExternalIssue + from sentry.sentry_apps.models.sentry_app import SentryApp + from sentry.sentry_apps.models.sentry_app_avatar import SentryAppAvatar + from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation + from sentry.sentry_apps.models.sentry_app_installation_for_provider import ( + SentryAppInstallationForProvider, + ) + from sentry.sentry_apps.models.servicehook import ServiceHook, ServiceHookProject + from sentry.users.models.identity import OrganizationIdentity + from sentry.users.models.user_avatar import UserAvatar + from sentry.users.models.user_option import UserOption + from sentry.users.models.useremail import UserEmail + from sentry.users.models.userrole import UserRole + from sentry.workflow_engine.models import ( + Action, + ActionAlertRuleTriggerAction, + AlertRuleDetector, + AlertRuleWorkflow, + DataCondition, + DataConditionAlertRuleTrigger, + DataConditionGroup, + DataConditionGroupAction, + DataSourceDetector, + DetectorGroup, + DetectorWorkflow, + IncidentGroupOpenPeriod, + WorkflowDataConditionGroup, + ) + from sentry.workflow_engine.types import ActionInvocation + from social_auth.models import UserSocialAuth + class Fixtures: @cached_property - def session(self): + def session(self) -> SessionBase: return Factories.create_session() @cached_property - def projectkey(self): + def projectkey(self) -> ProjectKey: return self.create_project_key(project=self.project) @cached_property @@ -110,14 +232,14 @@ def user(self) -> User: return User.objects.get(email="admin@localhost") @cached_property - def organization(self): + def organization(self) -> Organization: # XXX(dcramer): ensure that your org slug doesnt match your team slug # and the same for your project slug return self.create_organization(name="baz", slug="baz", owner=self.user) @cached_property @assume_test_silo_mode(SiloMode.CELL) - def team(self): + def team(self) -> Team: team = self.create_team(organization=self.organization, name="foo", slug="foo") # XXX: handle legacy team fixture queryset = OrganizationMember.objects.filter(organization=self.organization) @@ -126,23 +248,23 @@ def team(self): return team @cached_property - def project(self): + def project(self) -> Project: return self.create_project(name="Bar", slug="bar", teams=[self.team]) @cached_property - def release(self): + def release(self) -> Release: return self.create_release(project=self.project, version="foo-1.0") @cached_property - def environment(self): + def environment(self) -> Environment: return self.create_environment(name="development", project=self.project) @cached_property - def group(self): + def group(self) -> Group: return self.create_group(message="\u3053\u3093\u306b\u3061\u306f") @cached_property - def event(self): + def event(self) -> Event: return self.store_event( data={ "event_id": "a" * 32, @@ -154,7 +276,7 @@ def event(self): @cached_property @assume_test_silo_mode(SiloMode.CELL) - def activity(self): + def activity(self) -> Activity: return Activity.objects.create( group=self.group, project=self.project, @@ -165,7 +287,7 @@ def activity(self): @cached_property @assume_test_silo_mode(SiloMode.CONTROL) - def integration(self): + def integration(self) -> Integration: integration = Integration.objects.create( provider=IntegrationProviderSlug.GITHUB.value, name="GitHub", @@ -180,221 +302,265 @@ def integration(self): @cached_property @assume_test_silo_mode(SiloMode.CONTROL) - def organization_integration(self): + def organization_integration(self) -> OrganizationIntegration | None: return self.integration.add_organization(self.organization, self.user) - def create_organization(self, *args, **kwargs): + def create_organization(self, *args: Any, **kwargs: Any) -> Organization: return Factories.create_organization(*args, **kwargs) - def create_organization_avatar(self, *args, **kwargs): + def create_organization_avatar(self, *args: Any, **kwargs: Any) -> OrganizationAvatar: return Factories.create_organization_avatar(*args, **kwargs) - def create_notebook(self, *args, **kwargs): + def create_notebook(self, *args: Any, **kwargs: Any) -> Notebook: return Factories.create_notebook(*args, **kwargs) - def create_notebook_project(self, *args, **kwargs): + def create_notebook_project(self, *args: Any, **kwargs: Any) -> NotebookProject: return Factories.create_notebook_project(*args, **kwargs) - def create_notebook_favorite(self, *args, **kwargs): + def create_notebook_favorite(self, *args: Any, **kwargs: Any) -> NotebookFavoriteUser: return Factories.create_notebook_favorite(*args, **kwargs) - def create_notebook_seen(self, *args, **kwargs): + def create_notebook_seen(self, *args: Any, **kwargs: Any) -> NotebookSeen: return Factories.create_notebook_seen(*args, **kwargs) - def create_notebook_comment(self, *args, **kwargs): + def create_notebook_comment(self, *args: Any, **kwargs: Any) -> NotebookComment: return Factories.create_notebook_comment(*args, **kwargs) - def create_notebook_action_request(self, *args, **kwargs): + def create_notebook_action_request(self, *args: Any, **kwargs: Any) -> NotebookActionRequest: return Factories.create_notebook_action_request(*args, **kwargs) - def create_notebook_block(self, *args, **kwargs): + def create_notebook_block(self, *args: Any, **kwargs: Any) -> NotebookBlock: return Factories.create_notebook_block(*args, **kwargs) - def create_notebook_block_execution(self, *args, **kwargs): + def create_notebook_block_execution(self, *args: Any, **kwargs: Any) -> NotebookBlockExecution: return Factories.create_notebook_block_execution(*args, **kwargs) - def create_notebook_block_execution_project(self, *args, **kwargs): + def create_notebook_block_execution_project( + self, *args: Any, **kwargs: Any + ) -> NotebookBlockExecutionProject: return Factories.create_notebook_block_execution_project(*args, **kwargs) - def create_investigation_orchestration_run(self, *args, **kwargs): + def create_investigation_orchestration_run( + self, *args: Any, **kwargs: Any + ) -> InvestigationOrchestrationRun: return Factories.create_investigation_orchestration_run(*args, **kwargs) - def create_investigation_orchestration_event(self, *args, **kwargs): + def create_investigation_orchestration_event( + self, *args: Any, **kwargs: Any + ) -> InvestigationOrchestrationEvent: return Factories.create_investigation_orchestration_event(*args, **kwargs) - def create_investigation_orchestration_command(self, *args, **kwargs): + def create_investigation_orchestration_command( + self, *args: Any, **kwargs: Any + ) -> InvestigationOrchestrationCommand: return Factories.create_investigation_orchestration_command(*args, **kwargs) - def create_member(self, *args, **kwargs): + def create_member(self, *args: Any, **kwargs: Any) -> OrganizationMember: return Factories.create_member(*args, **kwargs) - def create_member_invite(self, *args, **kwargs): + def create_member_invite(self, *args: Any, **kwargs: Any) -> OrganizationMemberInvite: return Factories.create_member_invite(*args, **kwargs) - def create_api_key(self, *args, **kwargs): + def create_api_key(self, *args: Any, **kwargs: Any) -> ApiKey: return Factories.create_api_key(*args, **kwargs) - def create_api_application(self, **kwargs): + def create_api_application(self, **kwargs: Any) -> ApiApplication: kwargs.setdefault("owner", self.user) return Factories.create_api_application(**kwargs) - def create_api_device_code(self, *args, **kwargs): + def create_api_device_code(self, *args: Any, **kwargs: Any) -> ApiDeviceCode: return Factories.create_api_device_code(*args, **kwargs) - def create_auth_provider(self, *args, **kwargs): + def create_auth_provider(self, *args: Any, **kwargs: Any) -> AuthProvider: return Factories.create_auth_provider(*args, **kwargs) - def create_auth_identity(self, *args, **kwargs): + def create_auth_identity(self, *args: Any, **kwargs: Any) -> AuthIdentity: return Factories.create_auth_identity(*args, **kwargs) - def create_user_auth_token(self, *args, **kwargs): + def create_user_auth_token(self, *args: Any, **kwargs: Any) -> ApiToken: return Factories.create_user_auth_token(*args, **kwargs) - def create_org_auth_token(self, *args, **kwargs): + def create_org_auth_token(self, *args: Any, **kwargs: Any) -> OrgAuthToken: return Factories.create_org_auth_token(*args, **kwargs) - def create_team_membership(self, *args, **kwargs): + def create_team_membership(self, *args: Any, **kwargs: Any) -> OrganizationMemberTeam: return Factories.create_team_membership(*args, **kwargs) - def create_team(self, organization=None, **kwargs): + def create_team(self, organization: Organization | None = None, **kwargs: Any) -> Team: if organization is None: organization = self.organization return Factories.create_team(organization=organization, **kwargs) - def create_environment(self, project=None, **kwargs): + def create_environment(self, project: Project | None = None, **kwargs: Any) -> Environment: if project is None: project = self.project return Factories.create_environment(project=project, **kwargs) - def create_project(self, **kwargs) -> Project: + def create_project(self, **kwargs: Any) -> Project: if "teams" not in kwargs: kwargs["teams"] = [self.team] return Factories.create_project(**kwargs) - def create_project_bookmark(self, project=None, *args, **kwargs): + def create_project_bookmark( + self, project: Project | None = None, *args: Any, **kwargs: Any + ) -> ProjectBookmark: if project is None: project = self.project return Factories.create_project_bookmark(project, *args, **kwargs) - def create_ai_conversation_metadata(self, project=None, *args, **kwargs): + def create_ai_conversation_metadata( + self, project: Project | None = None, *args: Any, **kwargs: Any + ) -> AIConversationMetadata: if project is None: project = self.project return Factories.create_ai_conversation_metadata(project, *args, **kwargs) - def create_replay_deletion_job(self, project=None, **kwargs) -> ReplayDeletionJobModel: + def create_replay_deletion_job( + self, project: Project | None = None, **kwargs: Any + ) -> ReplayDeletionJobModel: if project is None: project = self.project return Factories.create_replay_deletion_job(project, **kwargs) - def create_project_key(self, project=None, *args, **kwargs): + def create_project_key( + self, project: Project | None = None, *args: Any, **kwargs: Any + ) -> ProjectKey: if project is None: project = self.project return Factories.create_project_key(project, *args, **kwargs) def create_project_custom_inbound_filter( - self, project=None, *args, **kwargs + self, project: Project | None = None, *args: Any, **kwargs: Any ) -> CustomInboundFilter: if project is None: project = self.project return Factories.create_project_custom_inbound_filter(project, *args, **kwargs) - def create_project_rule(self, project=None, *args, **kwargs) -> Rule: + def create_project_rule( + self, project: Project | None = None, *args: Any, **kwargs: Any + ) -> Rule: if project is None: project = self.project return Factories.create_project_rule(project, *args, **kwargs) - def create_slack_project_rule(self, project=None, *args, **kwargs): + def create_slack_project_rule( + self, project: Project | None = None, *args: Any, **kwargs: Any + ) -> Rule: if project is None: project = self.project return Factories.create_slack_project_rule(project, *args, **kwargs) - def create_release(self, project=None, *args, **kwargs): + def create_release(self, project: Project | None = None, *args: Any, **kwargs: Any) -> Release: if project is None: project = self.project return Factories.create_release(project, *args, **kwargs) - def create_group_release(self, project: Project | None = None, *args, **kwargs) -> GroupRelease: + def create_group_release( + self, project: Project | None = None, *args: Any, **kwargs: Any + ) -> GroupRelease: if project is None: project = self.project return Factories.create_group_release(project, *args, **kwargs) - def create_release_file(self, release_id=None, file=None, name=None, dist_id=None): + def create_release_file( + self, + release_id: int | None = None, + file: File | None = None, + name: str | None = None, + dist_id: int | None = None, + ) -> ReleaseFile: if release_id is None: release_id = self.release.id return Factories.create_release_file(release_id, file, name, dist_id) - def create_artifact_bundle_zip(self, org=None, release=None, *args, **kwargs): + def create_artifact_bundle_zip( + self, org: str | None = None, release: str | None = None, *args: Any, **kwargs: Any + ) -> bytes: return Factories.create_artifact_bundle_zip(org, release, *args, **kwargs) - def create_release_archive(self, org=None, release=None, *args, **kwargs): + def create_release_archive( + self, org: str | None = None, release: str | None = None, *args: Any, **kwargs: Any + ) -> ReleaseFile | None: if org is None: org = self.organization.slug if release is None: release = self.release.version return Factories.create_release_archive(org, release, *args, **kwargs) - def create_artifact_bundle(self, org=None, *args, **kwargs): + def create_artifact_bundle( + self, org: Organization | None = None, *args: Any, **kwargs: Any + ) -> ArtifactBundle: if org is None: org = self.organization return Factories.create_artifact_bundle(org, *args, **kwargs) - def create_code_mapping(self, project=None, repo=None, organization_integration=None, **kwargs): + def create_code_mapping( + self, + project: Project | None = None, + repo: Repository | None = None, + organization_integration: OrganizationIntegration | None = None, + **kwargs: Any, + ) -> RepositoryProjectPathConfig: if project is None: project = self.project if organization_integration is None: organization_integration = self.organization_integration return Factories.create_code_mapping(project, repo, organization_integration, **kwargs) - def create_repo(self, project=None, *args, **kwargs): + def create_repo(self, project: Project | None = None, *args: Any, **kwargs: Any) -> Repository: if project is None: project = self.project return Factories.create_repo(project, *args, **kwargs) - def create_project_repository(self, project=None, **kwargs): + def create_project_repository( + self, project: Project | None = None, **kwargs: Any + ) -> ProjectRepository: if project is None: project = self.project return Factories.create_project_repository(project, **kwargs) - def create_seer_project_repository(self, project=None, **kwargs): + def create_seer_project_repository( + self, project: Project | None = None, **kwargs: Any + ) -> SeerProjectRepository: if project is None: project = self.project return Factories.create_seer_project_repository(project, **kwargs) - def create_repository_settings(self, *args, **kwargs): + def create_repository_settings(self, *args: Any, **kwargs: Any) -> RepositorySettings: return Factories.create_repository_settings(*args, **kwargs) - def create_commit(self, *args, **kwargs): + def create_commit(self, *args: Any, **kwargs: Any) -> Commit: return Factories.create_commit(*args, **kwargs) - def create_commit_author(self, *args, **kwargs): + def create_commit_author(self, *args: Any, **kwargs: Any) -> CommitAuthor: return Factories.create_commit_author(*args, **kwargs) - def create_commit_file_change(self, *args, **kwargs): + def create_commit_file_change(self, *args: Any, **kwargs: Any) -> tuple[CommitFileChange, bool]: return Factories.create_commit_file_change(*args, **kwargs) - def create_pull_request(self, *args, **kwargs): + def create_pull_request(self, *args: Any, **kwargs: Any) -> PullRequest: return Factories.create_pull_request(*args, **kwargs) - def create_pull_request_comment(self, *args, **kwargs): + def create_pull_request_comment(self, *args: Any, **kwargs: Any) -> PullRequestComment: return Factories.create_pull_request_comment(*args, **kwargs) - def create_pull_request_commit(self, *args, **kwargs): + def create_pull_request_commit(self, *args: Any, **kwargs: Any) -> PullRequestCommit: return Factories.create_pull_request_commit(*args, **kwargs) - def create_release_commit(self, *args, **kwargs): + def create_release_commit(self, *args: Any, **kwargs: Any) -> ReleaseCommit: return Factories.create_release_commit(*args, **kwargs) - def create_user(self, *args, **kwargs) -> User: + def create_user(self, *args: Any, **kwargs: Any) -> User: return Factories.create_user(*args, **kwargs) - def create_useremail(self, *args, **kwargs): + def create_useremail(self, *args: Any, **kwargs: Any) -> UserEmail: return Factories.create_useremail(*args, **kwargs) - def create_user_avatar(self, *args, **kwargs): + def create_user_avatar(self, *args: Any, **kwargs: Any) -> UserAvatar: return Factories.create_user_avatar(*args, **kwargs) - def create_user_role(self, *args, **kwargs): + def create_user_role(self, *args: Any, **kwargs: Any) -> UserRole: return Factories.create_user_role(*args, **kwargs) def create_usersocialauth( @@ -403,65 +569,75 @@ def create_usersocialauth( provider: str | None = None, uid: str | None = None, extra_data: dict[str, Any] | None = None, - ): + ) -> UserSocialAuth: if not user: user = self.user return Factories.create_usersocialauth( user=user, provider=provider, uid=uid, extra_data=extra_data ) - def store_event(self, *args, **kwargs) -> Event: + def store_event(self, *args: Any, **kwargs: Any) -> Event: return Factories.store_event(*args, **kwargs) - def create_tempest_credentials(self, project: Project, *args, **kwargs) -> TempestCredentials: + def create_tempest_credentials( + self, project: Project, *args: Any, **kwargs: Any + ) -> TempestCredentials: return Factories.create_tempest_credentials(project, *args, **kwargs) def create_github_identity( - self, user: User | None = None, idp: IdentityProvider | None = None, **kwargs + self, user: User | None = None, idp: IdentityProvider | None = None, **kwargs: Any ) -> Identity: if not user: user = self.user return Factories.create_github_identity(user=user, idp=idp, **kwargs) - def create_github_provider(self, **kwargs) -> IdentityProvider: + def create_github_provider(self, **kwargs: Any) -> IdentityProvider: return Factories.create_github_provider(**kwargs) - def create_group(self, project=None, *args, **kwargs): + def create_group(self, project: Project | None = None, *args: Any, **kwargs: Any) -> Group: if project is None: project = self.project return Factories.create_group(project, *args, **kwargs) - def create_group_activity(self, group=None, *args, **kwargs): + def create_group_activity( + self, group: Group | None = None, *args: Any, **kwargs: Any + ) -> Activity: if group is None: group = self.group return Factories.create_group_activity(group, *args, **kwargs) - def create_group_link(self, group=None, **kwargs): + def create_group_link(self, group: Group | None = None, **kwargs: Any) -> GroupLink: if group is None: group = self.group return Factories.create_group_link(group, **kwargs) - def create_group_resolution(self, group=None, **kwargs): + def create_group_resolution(self, group: Group | None = None, **kwargs: Any) -> GroupResolution: if group is None: group = self.group return Factories.create_group_resolution(group, **kwargs) - def create_group_subscription(self, group=None, **kwargs): + def create_group_subscription( + self, group: Group | None = None, **kwargs: Any + ) -> GroupSubscription: if group is None: group = self.group return Factories.create_group_subscription(group, **kwargs) - def create_group_owner(self, group=None, **kwargs): + def create_group_owner(self, group: Group | None = None, **kwargs: Any) -> GroupOwner: if group is None: group = self.group return Factories.create_group_owner(group, **kwargs) - def create_group_action_log_entry(self, group=None, *args, **kwargs) -> GroupActionLogEntry: + def create_group_action_log_entry( + self, group: Group | None = None, *args: Any, **kwargs: Any + ) -> GroupActionLogEntry: if group is None: group = self.group return Factories.create_group_action_log_entry(group, *args, **kwargs) - def create_group_derived_data(self, group=None, **kwargs) -> GroupDerivedData: + def create_group_derived_data( + self, group: Group | None = None, **kwargs: Any + ) -> GroupDerivedData: if group is None: group = self.group return Factories.create_group_derived_data(group, **kwargs) @@ -483,125 +659,162 @@ def create_n_groups_with_hashes( return groups - def create_file(self, **kwargs): + def create_file(self, **kwargs: Any) -> File: return Factories.create_file(**kwargs) - def create_data_forwarder(self, organization=None, *args, **kwargs): + def create_data_forwarder( + self, organization: Organization | None = None, *args: Any, **kwargs: Any + ) -> DataForwarder: if organization is None: organization = self.organization return Factories.create_data_forwarder(organization, *args, **kwargs) - def create_data_forwarder_project(self, data_forwarder=None, project=None, **kwargs): + def create_data_forwarder_project( + self, + data_forwarder: DataForwarder | None = None, + project: Project | None = None, + **kwargs: Any, + ) -> DataForwarderProject: if project is None: project = self.project + assert data_forwarder is not None return Factories.create_data_forwarder_project(data_forwarder, project, **kwargs) - def create_file_from_path(self, *args, **kwargs): + def create_file_from_path(self, *args: Any, **kwargs: Any) -> File: return Factories.create_file_from_path(*args, **kwargs) - def create_dif_file(self, project: Project | None = None, *args, **kwargs): + def create_dif_file( + self, project: Project | None = None, *args: Any, **kwargs: Any + ) -> ProjectDebugFile: if project is None: project = self.project return Factories.create_dif_file(project, *args, **kwargs) - def create_dif_from_path(self, project=None, *args, **kwargs): + def create_dif_from_path( + self, project: Project | None = None, *args: Any, **kwargs: Any + ) -> ProjectDebugFile: if project is None: project = self.project return Factories.create_dif_from_path(project=project, *args, **kwargs) - def add_user_permission(self, *args, **kwargs): + def add_user_permission(self, *args: Any, **kwargs: Any) -> None: return Factories.add_user_permission(*args, **kwargs) - def create_sentry_app(self, *args, **kwargs): + def create_sentry_app(self, *args: Any, **kwargs: Any) -> SentryApp: return Factories.create_sentry_app(*args, **kwargs) - def create_sentry_app_avatar(self, *args, **kwargs): + def create_sentry_app_avatar(self, *args: Any, **kwargs: Any) -> SentryAppAvatar: return Factories.create_sentry_app_avatar(*args, **kwargs) - def create_internal_integration(self, *args, **kwargs): + def create_internal_integration(self, *args: Any, **kwargs: Any) -> SentryApp: return Factories.create_internal_integration(*args, **kwargs) - def create_internal_integration_token(self, *args, **kwargs): + def create_internal_integration_token(self, *args: Any, **kwargs: Any) -> ApiToken: return Factories.create_internal_integration_token(*args, **kwargs) - def create_sentry_app_installation(self, *args, **kwargs): + def create_sentry_app_installation(self, *args: Any, **kwargs: Any) -> SentryAppInstallation: return Factories.create_sentry_app_installation(*args, **kwargs) - def create_sentry_app_installation_for_provider(self, *args, **kwargs): + def create_sentry_app_installation_for_provider( + self, *args: Any, **kwargs: Any + ) -> SentryAppInstallationForProvider: return Factories.create_sentry_app_installation_for_provider(*args, **kwargs) - def create_stacktrace_link_schema(self, *args, **kwargs): + def create_stacktrace_link_schema(self, *args: Any, **kwargs: Any) -> dict[str, Any]: return Factories.create_stacktrace_link_schema(*args, **kwargs) - def create_issue_link_schema(self, *args, **kwargs): + def create_issue_link_schema(self, *args: Any, **kwargs: Any) -> dict[str, Any]: return Factories.create_issue_link_schema(*args, **kwargs) - def create_alert_rule_action_schema(self, *args, **kwargs): + def create_alert_rule_action_schema(self, *args: Any, **kwargs: Any) -> dict[str, Any]: return Factories.create_alert_rule_action_schema(*args, **kwargs) - def create_sentry_app_feature(self, *args, **kwargs): + def create_sentry_app_feature(self, *args: Any, **kwargs: Any) -> IntegrationFeature: return Factories.create_sentry_app_feature(*args, **kwargs) - def create_doc_integration(self, *args, **kwargs): + def create_doc_integration(self, *args: Any, **kwargs: Any) -> DocIntegration: return Factories.create_doc_integration(*args, **kwargs) - def create_doc_integration_features(self, *args, **kwargs): + def create_doc_integration_features( + self, *args: Any, **kwargs: Any + ) -> list[IntegrationFeature]: return Factories.create_doc_integration_features(*args, **kwargs) - def create_doc_integration_avatar(self, *args, **kwargs): + def create_doc_integration_avatar(self, *args: Any, **kwargs: Any) -> DocIntegrationAvatar: return Factories.create_doc_integration_avatar(*args, **kwargs) - def create_service_hook(self, *args, **kwargs): + def create_service_hook(self, *args: Any, **kwargs: Any) -> ServiceHook: return Factories.create_service_hook(*args, **kwargs) - def create_service_hook_project_for_installation(self, *args, **kwargs): + def create_service_hook_project_for_installation( + self, *args: Any, **kwargs: Any + ) -> ServiceHookProject: return Factories.create_service_hook_project_for_installation(*args, **kwargs) - def create_userreport(self, *args, **kwargs): + def create_userreport(self, *args: Any, **kwargs: Any) -> UserReport: return Factories.create_userreport(*args, **kwargs) - def create_platform_external_issue(self, *args, **kwargs): + def create_platform_external_issue(self, *args: Any, **kwargs: Any) -> PlatformExternalIssue: return Factories.create_platform_external_issue(*args, **kwargs) - def create_integration_external_issue(self, *args, **kwargs): + def create_integration_external_issue(self, *args: Any, **kwargs: Any) -> ExternalIssue: return Factories.create_integration_external_issue(*args, **kwargs) - def create_integration_external_project(self, *args, **kwargs): + def create_integration_external_project( + self, *args: Any, **kwargs: Any + ) -> IntegrationExternalProject: return Factories.create_integration_external_project(*args, **kwargs) - def create_alert_rule(self, organization=None, projects=None, *args, **kwargs) -> AlertRule: + def create_alert_rule( + self, + organization: Organization | None = None, + projects: Sequence[Project] | None = None, + *args: Any, + **kwargs: Any, + ) -> AlertRule: if not organization: organization = self.organization if projects is None: projects = [self.project] return Factories.create_alert_rule(organization, projects, *args, **kwargs) - def create_notification_action(self, organization=None, projects=None, **kwargs): + def create_notification_action( + self, + organization: Organization | None = None, + projects: list[Project] | None = None, + **kwargs: Any, + ) -> NotificationAction: return Factories.create_notification_action( organization=organization, projects=projects, **kwargs ) - def create_notification_setting_option(self, *args, **kwargs): + def create_notification_setting_option( + self, *args: Any, **kwargs: Any + ) -> NotificationSettingOption: return Factories.create_notification_setting_option(*args, **kwargs) - def create_notification_settings_provider(self, *args, **kwargs): + def create_notification_settings_provider( + self, *args: Any, **kwargs: Any + ) -> NotificationSettingProvider: return Factories.create_notification_settings_provider(*args, **kwargs) - def create_inbox_item(self, organization=None, user=None, **kwargs): + def create_inbox_item( + self, organization: Organization | None = None, user: User | None = None, **kwargs: Any + ) -> InboxItem: return Factories.create_inbox_item( organization=organization or self.organization, user=user or self.user, **kwargs ) - def create_inbox_item_event(self, *args, **kwargs): + def create_inbox_item_event(self, *args: Any, **kwargs: Any) -> InboxItemEvent: return Factories.create_inbox_item_event(*args, **kwargs) - def create_weekly_report_project_exclusion(self, **kwargs): + def create_weekly_report_project_exclusion(self, **kwargs: Any) -> WeeklyReportProjectExclusion: return Factories.create_weekly_report_project_exclusion(**kwargs) - def create_user_option(self, *args, **kwargs): + def create_user_option(self, *args: Any, **kwargs: Any) -> UserOption: return Factories.create_user_option(*args, **kwargs) - def create_monitor(self, **kwargs): + def create_monitor(self, **kwargs: Any) -> Monitor: if "owner_user_id" not in kwargs: kwargs["owner_user_id"] = self.user.id @@ -627,16 +840,22 @@ def create_monitor(self, **kwargs): **kwargs, ) - def create_monitor_environment(self, **kwargs): + def create_monitor_environment(self, **kwargs: Any) -> MonitorEnvironment: return MonitorEnvironment.objects.create(**kwargs) - def create_monitor_incident(self, **kwargs): + def create_monitor_incident(self, **kwargs: Any) -> MonitorIncident: return MonitorIncident.objects.create(**kwargs) - def create_monitor_checkin(self, **kwargs): + def create_monitor_checkin(self, **kwargs: Any) -> MonitorCheckIn: return MonitorCheckIn.objects.create(**kwargs) - def create_external_user(self, user=None, organization=None, integration=None, **kwargs): + def create_external_user( + self, + user: User | None = None, + organization: Organization | None = None, + integration: Integration | None = None, + **kwargs: Any, + ) -> ExternalActor: if not user: user = self.user if not organization: @@ -648,7 +867,9 @@ def create_external_user(self, user=None, organization=None, integration=None, * user=user, organization=organization, integration_id=integration.id, **kwargs ) - def create_external_team(self, team=None, integration=None, **kwargs): + def create_external_team( + self, team: Team | None = None, integration: Integration | None = None, **kwargs: Any + ) -> ExternalActor: if not team: team = self.team if not integration: @@ -658,10 +879,15 @@ def create_external_team(self, team=None, integration=None, **kwargs): team=team, organization=team.organization, integration_id=integration.id, **kwargs ) - def create_data_access_grant(self, **kwargs): + def create_data_access_grant(self, **kwargs: Any) -> DataAccessGrant: return Factories.create_data_access_grant(**kwargs) - def create_codeowners(self, project=None, code_mapping=None, **kwargs): + def create_codeowners( + self, + project: Project | None = None, + code_mapping: RepositoryProjectPathConfig | None = None, + **kwargs: Any, + ) -> ProjectCodeOwners: if not project: project = self.project if not code_mapping: @@ -677,7 +903,7 @@ def create_slack_integration( user: RpcUser | User | None = None, identity_external_id: str = "UXXXXXXX1", **kwargs: Any, - ): + ) -> Integration: if user is None: with assume_test_silo_mode(SiloMode.CELL): user = organization.get_default_owner() @@ -746,10 +972,10 @@ def create_organization_integration(self, **integration_params: Any) -> Organiza """Create an OrganizationIntegration entity.""" return Factories.create_organization_integration(**integration_params) - def create_identity(self, *args, **kwargs): + def create_identity(self, *args: Any, **kwargs: Any) -> Identity: return Factories.create_identity(*args, **kwargs) - def create_organization_identity(self, *args, **kwargs): + def create_organization_identity(self, *args: Any, **kwargs: Any) -> OrganizationIdentity: return Factories.create_organization_identity(*args, **kwargs) def create_identity_provider( @@ -760,69 +986,75 @@ def create_identity_provider( ) -> IdentityProvider: return Factories.create_identity_provider(integration=integration, config=config, **kwargs) - def create_group_history(self, *args, **kwargs): + def create_group_history(self, *args: Any, **kwargs: Any) -> GroupHistory: if "user_id" not in kwargs and "team" not in kwargs and "team_id" not in kwargs: kwargs["user_id"] = self.user.id return Factories.create_group_history(*args, **kwargs) - def create_comment(self, *args, **kwargs): + def create_comment(self, *args: Any, **kwargs: Any) -> Activity: return Factories.create_comment(*args, **kwargs) - def create_organization_mapping(self, *args, **kwargs): + def create_organization_mapping(self, *args: Any, **kwargs: Any) -> OrganizationMapping: return Factories.create_org_mapping(*args, **kwargs) - def create_basic_auth_header(self, *args, **kwargs) -> bytes: + def create_basic_auth_header(self, *args: Any, **kwargs: Any) -> bytes: return Factories.create_basic_auth_header(*args, **kwargs) - def create_request_access(self, *args, **kwargs): + def create_request_access(self, *args: Any, **kwargs: Any) -> RpcBackedAccess: return Factories.create_request_access(*args, **kwargs) - def create_webhook_payload(self, *args, **kwargs): + def create_webhook_payload(self, *args: Any, **kwargs: Any) -> WebhookPayload: return Factories.create_webhook_payload(*args, **kwargs) - def create_dashboard(self, *args, **kwargs): + def create_dashboard(self, *args: Any, **kwargs: Any) -> Dashboard: return Factories.create_dashboard(*args, **kwargs) - def create_explore_saved_formula(self, organization=None, *args, **kwargs): + def create_explore_saved_formula( + self, organization: Organization | None = None, *args: Any, **kwargs: Any + ) -> ExploreSavedFormula: if organization is None: organization = self.organization return Factories.create_explore_saved_formula(organization, *args, **kwargs) - def create_explore_saved_query(self, organization=None, **kwargs): + def create_explore_saved_query( + self, organization: Organization | None = None, **kwargs: Any + ) -> ExploreSavedQuery: if organization is None: organization = self.organization return Factories.create_explore_saved_query(organization, **kwargs) - def create_discover_saved_query(self, organization=None, **kwargs): + def create_discover_saved_query( + self, organization: Organization | None = None, **kwargs: Any + ) -> DiscoverSavedQuery: if organization is None: organization = self.organization return Factories.create_discover_saved_query(organization, **kwargs) - def create_dashboard_favorite_user(self, *args, **kwargs): + def create_dashboard_favorite_user(self, *args: Any, **kwargs: Any) -> DashboardFavoriteUser: return Factories.create_dashboard_favorite_user(*args, **kwargs) - def create_dashboard_widget(self, *args, **kwargs): + def create_dashboard_widget(self, *args: Any, **kwargs: Any) -> DashboardWidget: return Factories.create_dashboard_widget(*args, **kwargs) - def create_dashboard_widget_query(self, *args, **kwargs): + def create_dashboard_widget_query(self, *args: Any, **kwargs: Any) -> DashboardWidgetQuery: return Factories.create_dashboard_widget_query(*args, **kwargs) - def create_workflow(self, *args, **kwargs) -> Workflow: + def create_workflow(self, *args: Any, **kwargs: Any) -> Workflow: if "organization" not in kwargs: kwargs["organization"] = self.organization return Factories.create_workflow(*args, **kwargs) - def create_data_source(self, *args, **kwargs) -> DataSource: + def create_data_source(self, *args: Any, **kwargs: Any) -> DataSource: return Factories.create_data_source(*args, **kwargs) def create_data_condition( self, - comparison="10", - type=Condition.EQUAL, - condition_result=None, - condition_group=None, - **kwargs, - ): + comparison: str | int | float | list[object] | Mapping[str, object] = "10", + type: Condition = Condition.EQUAL, + condition_result: str | int | float | list[object] | Mapping[str, object] | None = None, + condition_group: DataConditionGroup | None = None, + **kwargs: Any, + ) -> DataCondition: if condition_result is None: condition_result = str(DetectorPriorityLevel.HIGH.value) if condition_group is None: @@ -840,65 +1072,77 @@ def create_detector( self, project: Project | None = None, type: str | None = ErrorGroupType.slug, - *args, - **kwargs, + *args: Any, + **kwargs: Any, ) -> Detector: if project is None: project = self.create_project(organization=self.organization) return Factories.create_detector(project=project, type=type, *args, **kwargs) - def create_all_projects_detector(self, organization: Organization, **kwargs): + def create_all_projects_detector(self, organization: Organization, **kwargs: Any) -> Detector: return Factories.create_all_projects_detector(organization_id=organization.id, **kwargs) - def create_detector_state(self, *args, **kwargs) -> DetectorState: + def create_detector_state(self, *args: Any, **kwargs: Any) -> DetectorState: return Factories.create_detector_state(*args, **kwargs) - def create_data_source_detector(self, *args, **kwargs): + def create_data_source_detector(self, *args: Any, **kwargs: Any) -> DataSourceDetector: return Factories.create_data_source_detector(*args, **kwargs) - def create_data_condition_group(self, organization=None, **kwargs): + def create_data_condition_group( + self, organization: Organization | None = None, **kwargs: Any + ) -> DataConditionGroup: if organization is None: organization = self.organization return Factories.create_data_condition_group(organization=organization, **kwargs) - def create_data_condition_group_action(self, *args, **kwargs): + def create_data_condition_group_action( + self, *args: Any, **kwargs: Any + ) -> DataConditionGroupAction: return Factories.create_data_condition_group_action(*args, **kwargs) - def create_data_condition_alert_rule_trigger(self, *args, **kwargs): + def create_data_condition_alert_rule_trigger( + self, *args: Any, **kwargs: Any + ) -> DataConditionAlertRuleTrigger: return Factories.create_data_condition_alert_rule_trigger(*args, **kwargs) - def create_detector_workflow(self, *args, **kwargs): + def create_detector_workflow(self, *args: Any, **kwargs: Any) -> DetectorWorkflow: return Factories.create_detector_workflow(*args, **kwargs) - def create_detector_group(self, *args, **kwargs): + def create_detector_group(self, *args: Any, **kwargs: Any) -> DetectorGroup: return Factories.create_detector_group(*args, **kwargs) - def create_alert_rule_detector(self, *args, **kwargs): + def create_alert_rule_detector(self, *args: Any, **kwargs: Any) -> AlertRuleDetector: # TODO: this is only needed during the ACI migration return Factories.create_alert_rule_detector(*args, **kwargs) - def create_action_alert_rule_trigger_action(self, *args, **kwargs): + def create_action_alert_rule_trigger_action( + self, *args: Any, **kwargs: Any + ) -> ActionAlertRuleTriggerAction: # TODO: this is only needed during the ACI migration return Factories.create_action_alert_rule_trigger_action(*args, **kwargs) - def create_alert_rule_workflow(self, *args, **kwargs): + def create_alert_rule_workflow(self, *args: Any, **kwargs: Any) -> AlertRuleWorkflow: # TODO: this is only needed during the ACI migration return Factories.create_alert_rule_workflow(*args, **kwargs) - def create_incident_group_open_period(self, *args, **kwargs): + def create_incident_group_open_period( + self, *args: Any, **kwargs: Any + ) -> IncidentGroupOpenPeriod: # TODO: this is only needed during the ACI migration return Factories.create_incident_group_open_period(*args, **kwargs) - def create_workflow_data_condition_group(self, *args, **kwargs): + def create_workflow_data_condition_group( + self, *args: Any, **kwargs: Any + ) -> WorkflowDataConditionGroup: return Factories.create_workflow_data_condition_group(*args, **kwargs) # workflow_engine.models.action - def create_action(self, *args, **kwargs): + def create_action(self, *args: Any, **kwargs: Any) -> Action: return Factories.create_action(*args, **kwargs) - def create_action_invocation(self, *args, **kwargs): + def create_action_invocation(self, *args: Any, **kwargs: Any) -> ActionInvocation: return Factories.create_action_invocation(*args, **kwargs) def create_uptime_subscription( @@ -907,15 +1151,15 @@ def create_uptime_subscription( subscription_id: str | None = None, status: UptimeSubscription.Status = UptimeSubscription.Status.ACTIVE, url: str | None = None, - host_provider_id="TEST", - host_provider_name="TEST", - url_domain="sentry", - url_domain_suffix="io", - interval_seconds=60, - timeout_ms=100, - method="GET", - headers=None, - body=None, + host_provider_id: str = "TEST", + host_provider_name: str = "TEST", + url_domain: str = "sentry", + url_domain_suffix: str = "io", + interval_seconds: IntervalSecondsLiteral = 60, + timeout_ms: int = 100, + method: SupportedHTTPMethodsLiteral = "GET", + headers: Sequence[Sequence[str]] | None = None, + body: str | None = None, date_updated: None | datetime = None, trace_sampling: bool = False, region_slugs: list[str] | None = None, @@ -956,7 +1200,7 @@ def create_uptime_subscription_region( subscription: UptimeSubscription, region_slug: str, mode: UptimeSubscriptionRegion.RegionMode = UptimeSubscriptionRegion.RegionMode.ACTIVE, - ): + ) -> None: Factories.create_uptime_subscription_region(subscription, region_slug, mode) def create_uptime_detector( @@ -966,7 +1210,7 @@ def create_uptime_detector( uptime_subscription: UptimeSubscription | None = None, status: int = ObjectStatus.ACTIVE, enabled: bool = True, - mode=UptimeMonitorMode.AUTO_DETECTED_ACTIVE, + mode: UptimeMonitorMode = UptimeMonitorMode.AUTO_DETECTED_ACTIVE, name: str | None = None, owner: User | Team | None = None, id: int | None = None, @@ -1047,26 +1291,28 @@ def create_uptime_detector( # Preprod artifact helpers def create_commit_comparison( - self, organization: Organization | None = None, **kwargs + self, organization: Organization | None = None, **kwargs: Any ) -> CommitComparison: if organization is None: organization = self.organization return Factories.create_commit_comparison(organization=organization, **kwargs) - def create_preprod_artifact(self, project: Project | None = None, **kwargs) -> PreprodArtifact: + def create_preprod_artifact( + self, project: Project | None = None, **kwargs: Any + ) -> PreprodArtifact: if project is None: project = self.project return Factories.create_preprod_artifact(project=project, **kwargs) def create_preprod_artifact_mobile_app_info( - self, preprod_artifact: PreprodArtifact, **kwargs + self, preprod_artifact: PreprodArtifact, **kwargs: Any ) -> PreprodArtifactMobileAppInfo: return Factories.create_preprod_artifact_mobile_app_info( preprod_artifact=preprod_artifact, **kwargs ) def create_preprod_artifact_size_metrics( - self, preprod_artifact: PreprodArtifact, **kwargs + self, preprod_artifact: PreprodArtifact, **kwargs: Any ) -> PreprodArtifactSizeMetrics: return Factories.create_preprod_artifact_size_metrics(artifact=preprod_artifact, **kwargs) @@ -1075,7 +1321,7 @@ def create_preprod_artifact_size_comparison( head_size_analysis: PreprodArtifactSizeMetrics, base_size_analysis: PreprodArtifactSizeMetrics, organization: Organization | None = None, - **kwargs, + **kwargs: Any, ) -> PreprodArtifactSizeComparison: if organization is None: organization = self.organization @@ -1087,7 +1333,7 @@ def create_preprod_artifact_size_comparison( ) def create_preprod_snapshot_metrics( - self, preprod_artifact: PreprodArtifact, **kwargs + self, preprod_artifact: PreprodArtifact, **kwargs: Any ) -> PreprodSnapshotMetrics: return Factories.create_preprod_snapshot_metrics( preprod_artifact=preprod_artifact, **kwargs @@ -1097,7 +1343,7 @@ def create_preprod_snapshot_comparison( self, head_snapshot_metrics: PreprodSnapshotMetrics, base_snapshot_metrics: PreprodSnapshotMetrics, - **kwargs, + **kwargs: Any, ) -> PreprodSnapshotComparison: return Factories.create_preprod_snapshot_comparison( head_snapshot_metrics=head_snapshot_metrics, @@ -1106,20 +1352,22 @@ def create_preprod_snapshot_comparison( ) def create_preprod_comparison_approval( - self, preprod_artifact: PreprodArtifact, **kwargs + self, preprod_artifact: PreprodArtifact, **kwargs: Any ) -> PreprodComparisonApproval: return Factories.create_preprod_comparison_approval( preprod_artifact=preprod_artifact, **kwargs ) def create_preprod_build_configuration( - self, project: Project | None = None, **kwargs + self, project: Project | None = None, **kwargs: Any ) -> PreprodBuildConfiguration: if project is None: project = self.project return Factories.create_preprod_build_configuration(project=project, **kwargs) - def create_installable_preprod_artifact(self, preprod_artifact, **kwargs): + def create_installable_preprod_artifact( + self, preprod_artifact: PreprodArtifact, **kwargs: Any + ) -> InstallablePreprodArtifact: return Factories.create_installable_preprod_artifact( preprod_artifact=preprod_artifact, **kwargs ) @@ -1133,7 +1381,7 @@ def create_ios_preprod_artifact( build_number: int = 1, state: int = PreprodArtifact.ArtifactState.PROCESSED, commit_comparison: CommitComparison | None = None, - **kwargs, + **kwargs: Any, ) -> PreprodArtifact: """Create a processed iOS preprod artifact (xcarchive).""" if project is None: @@ -1160,7 +1408,7 @@ def create_android_preprod_artifact( state: int = PreprodArtifact.ArtifactState.PROCESSED, artifact_type: int = PreprodArtifact.ArtifactType.APK, commit_comparison: CommitComparison | None = None, - **kwargs, + **kwargs: Any, ) -> PreprodArtifact: """Create a processed Android preprod artifact (APK or AAB).""" if project is None: @@ -1188,7 +1436,7 @@ def create_ios_preprod_artifact_with_size_metrics( max_install_size: int = 50 * 1024 * 1024, # 50 MB max_download_size: int = 25 * 1024 * 1024, # 25 MB size_analysis_state: int = PreprodArtifactSizeMetrics.SizeAnalysisState.COMPLETED, - **kwargs, + **kwargs: Any, ) -> tuple[PreprodArtifact, PreprodArtifactSizeMetrics]: """ Create a fully processed iOS preprod artifact with completed size metrics. @@ -1227,7 +1475,7 @@ def create_android_preprod_artifact_with_size_metrics( max_install_size: int = 30 * 1024 * 1024, # 30 MB max_download_size: int = 15 * 1024 * 1024, # 15 MB size_analysis_state: int = PreprodArtifactSizeMetrics.SizeAnalysisState.COMPLETED, - **kwargs, + **kwargs: Any, ) -> tuple[PreprodArtifact, PreprodArtifactSizeMetrics]: """ Create a fully processed Android preprod artifact with completed size metrics. @@ -1336,28 +1584,34 @@ def create_preprod_artifact_pair_for_comparison( return head_artifact, head_size_metrics, base_artifact, base_size_metrics - def create_seer_autofix_issue_data(self, group, **kwargs): + def create_seer_autofix_issue_data(self, group: Group, **kwargs: Any) -> SeerAutofixIssueData: return Factories.create_seer_autofix_issue_data(group=group, **kwargs) - def create_seer_run(self, organization=None, **kwargs): + def create_seer_run(self, organization: Organization | None = None, **kwargs: Any) -> SeerRun: if organization is None: organization = self.organization return Factories.create_seer_run(organization=organization, **kwargs) - def create_seer_agent_write_grant(self, organization=None, user=None, **kwargs): + def create_seer_agent_write_grant( + self, organization: Organization | None = None, user: User | None = None, **kwargs: Any + ) -> SeerAgentWriteGrant: return Factories.create_seer_agent_write_grant( organization=organization or self.organization, user=user or self.user, **kwargs, ) - def create_seer_agent_run(self, run, **kwargs): + def create_seer_agent_run(self, run: SeerRun, **kwargs: Any) -> SeerAgentRun: return Factories.create_seer_agent_run(run=run, **kwargs) - def create_seer_run_coding_agent_handoff(self, seer_run, **kwargs): + def create_seer_run_coding_agent_handoff( + self, seer_run: SeerRun, **kwargs: Any + ) -> SeerRunCodingAgentHandoff: return Factories.create_seer_run_coding_agent_handoff(seer_run=seer_run, **kwargs) - def create_seer_run_pull_request(self, run, pull_request, **kwargs): + def create_seer_run_pull_request( + self, run: SeerRun, pull_request: PullRequest, **kwargs: Any + ) -> SeerRunPullRequest: return Factories.create_seer_run_pull_request(run=run, pull_request=pull_request, **kwargs) @pytest.fixture(autouse=True) diff --git a/src/sentry/testutils/helpers/analytics.py b/src/sentry/testutils/helpers/analytics.py index c53e7cc7d8ca..6857ba6601e7 100644 --- a/src/sentry/testutils/helpers/analytics.py +++ b/src/sentry/testutils/helpers/analytics.py @@ -1,13 +1,15 @@ import contextlib from collections.abc import Generator from dataclasses import fields +from typing import cast from unittest.mock import MagicMock, patch from sentry.analytics.event import Event def get_last_analytics_event(mock_record: MagicMock) -> Event: - return mock_record.call_args_list[-1].args[0] + # justification: tests record an Event instance per analytics call. + return cast("Event", mock_record.call_args_list[-1].args[0]) def get_all_analytics_events(mock_record: MagicMock) -> list[Event]: diff --git a/src/sentry/testutils/helpers/apigateway.py b/src/sentry/testutils/helpers/apigateway.py index b39223524166..2e2d30391f21 100644 --- a/src/sentry/testutils/helpers/apigateway.py +++ b/src/sentry/testutils/helpers/apigateway.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Callable, Mapping +from typing import IO, cast from urllib.parse import parse_qs import responses @@ -7,6 +9,7 @@ from django.http import HttpResponseRedirect from django.test import override_settings from django.urls import re_path +from requests import PreparedRequest from rest_framework.permissions import AllowAny, BasePermission from rest_framework.request import Request from rest_framework.response import Response @@ -15,6 +18,7 @@ from sentry.api.base import Endpoint, cell_silo_endpoint, control_silo_endpoint from sentry.api.bases.organization import ControlSiloOrganizationEndpoint, OrganizationEndpoint from sentry.feedback.endpoints.error_page_embed import ErrorEmbedResolver +from sentry.models.organization import Organization from sentry.testutils.cases import APITestCase from sentry.types.cell import Cell from sentry.utils import json @@ -24,7 +28,7 @@ class ControlEndpoint(ControlSiloOrganizationEndpoint): permission_classes: tuple[type[BasePermission], ...] = (AllowAny,) - def get(self, request, organization, **kwargs): + def get(self, request: Request, organization: Organization, **kwargs: object) -> Response: return Response({"proxy": False}) @@ -32,10 +36,10 @@ def get(self, request, organization, **kwargs): class RegionEndpoint(OrganizationEndpoint): permission_classes: tuple[type[BasePermission], ...] = (AllowAny,) - def get(self, request, organization): + def get(self, request: Request, organization: Organization) -> Response: return Response({"proxy": False}) - def post(self, request, organization): + def post(self, request: Request, organization: Organization) -> HttpResponseRedirect: return HttpResponseRedirect("https://zombo.com") @@ -85,34 +89,41 @@ def get(self, request: Request) -> Response: ] + api_urls.urlpatterns -def verify_request_body(body, headers): +def verify_request_body( + body: object, headers: Mapping[str, str] +) -> Callable[[PreparedRequest], tuple[int, Mapping[str, str], str]]: """Wrapper for a callback function for responses.add_callback""" - def request_callback(request): + def request_callback(request: PreparedRequest) -> tuple[int, Mapping[str, str], str]: if request.headers.get("content-type") == "application/json": - assert json.load(request.body) == body + assert json.load(cast(IO[bytes], request.body)) == body else: - assert request.body.read() == body + assert cast(IO[bytes], request.body).read() == body assert (request.headers[key] == headers[key] for key in headers) return 200, {}, json.dumps({"proxy": True}) return request_callback -def verify_request_headers(headers): +def verify_request_headers( + headers: Mapping[str, str], +) -> Callable[[PreparedRequest], tuple[int, Mapping[str, str], str]]: """Wrapper for a callback function for responses.add_callback""" - def request_callback(request): + def request_callback(request: PreparedRequest) -> tuple[int, Mapping[str, str], str]: assert (request.headers[key] == headers[key] for key in headers) return 200, {}, json.dumps({"proxy": True}) return request_callback -def verify_request_params(params, headers): +def verify_request_params( + params: Mapping[str, str | list[str]], headers: Mapping[str, str] +) -> Callable[[PreparedRequest], tuple[int, Mapping[str, str], str]]: """Wrapper for a callback function for responses.add_callback""" - def request_callback(request): + def request_callback(request: PreparedRequest) -> tuple[int, Mapping[str, str], str]: + assert request.url is not None request_params = parse_qs(request.url.split("?")[1]) assert (request.headers[key] == headers[key] for key in headers) for key in params: @@ -126,18 +137,20 @@ def request_callback(request): return request_callback -def verify_file_body(file_body, headers): +def verify_file_body( + file_body: bytes, headers: Mapping[str, str] +) -> Callable[[PreparedRequest], tuple[int, Mapping[str, str], str]]: """Wrapper for a callback function for responses.add_callback""" - def request_callback(request): - assert file_body in request.body.read() + def request_callback(request: PreparedRequest) -> tuple[int, Mapping[str, str], str]: + assert file_body in cast(IO[bytes], request.body).read() assert (request.headers[key] == headers[key] for key in headers) return 200, {}, json.dumps({"proxy": True}) return request_callback -def provision_middleware(): +def provision_middleware() -> list[str]: middleware = list(settings.MIDDLEWARE) if "sentry.hybridcloud.apigateway.middleware.ApiGatewayMiddleware" not in middleware: middleware = ["sentry.hybridcloud.apigateway.middleware.ApiGatewayMiddleware"] + middleware @@ -155,7 +168,7 @@ class ApiGatewayTestCase(APITestCase): address="http://us.internal.sentry.io", ) - def setUp(self): + def setUp(self) -> None: super().setUp() responses.add( responses.GET, @@ -176,11 +189,16 @@ def setUp(self): self.organization = self.create_organization(cell=self.CELL) # Echos the request body and header back for verification - def return_request_body(request): + def return_request_body( + request: PreparedRequest, + ) -> tuple[int, Mapping[str, str], str | bytes | None]: return (200, request.headers, request.body) # Echos the query params and header back for verification - def return_request_params(request): + def return_request_params( + request: PreparedRequest, + ) -> tuple[int, Mapping[str, str], str | bytes | None]: + assert request.url is not None params = parse_qs(request.url.split("?")[1]) return (200, request.headers, json.dumps(params).encode()) 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/testutils/helpers/backups.py b/src/sentry/testutils/helpers/backups.py index 76c9617bfa89..7cdf47800886 100644 --- a/src/sentry/testutils/helpers/backups.py +++ b/src/sentry/testutils/helpers/backups.py @@ -14,6 +14,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa from django.apps import apps from django.db import connections, router +from django.db.models import Model from django.utils import timezone from fido2.ctap2 import AuthenticatorData from fido2.utils import sha256 @@ -287,12 +288,12 @@ def export_to_encrypted_tarball( ) -def is_control_model(model): +def is_control_model(model: type[Model]) -> bool: meta = model._meta return not hasattr(meta, "silo_limit") or SiloMode.CONTROL in meta.silo_limit.modes -def clear_model(model, *, reset_pks: bool): +def clear_model(model: type[Model], *, reset_pks: bool) -> None: using = router.db_for_write(model) with unguarded_write(using=using): manager = model.with_deleted if issubclass(model, ParanoidModel) else model.objects @@ -308,7 +309,7 @@ def clear_model(model, *, reset_pks: bool): @assume_test_silo_mode(SiloMode.CELL) -def clear_database(*, reset_pks: bool = False): +def clear_database(*, reset_pks: bool = False) -> None: """ Deletes all models we care about from the database, in a sequence that ensures we get no foreign key errors. @@ -909,7 +910,9 @@ def create_exhaustive_organization_auth( ) @assume_test_silo_mode(SiloMode.CONTROL) - def create_exhaustive_organization_integration(self, org: Organization): + def create_exhaustive_organization_integration( + self, org: Organization + ) -> OrganizationIntegration: integration = Integration.objects.create( provider="slack", name=f"Slack for {org.slug}", external_id=f"slack:{org.slug}" ) @@ -929,6 +932,8 @@ def create_exhaustive_sentry_app(self, name: str, owner: User, org: Organization ) install = self.create_sentry_app_installation(slug=app.slug, organization=org, user=owner) + assert app.application is not None + assert app.proxy_user is not None updater = SentryAppUpdater(sentry_app=app) updater.schema = {"elements": [self.create_alert_rule_action_schema()]} updater.run(owner) @@ -968,25 +973,26 @@ def create_exhaustive_sentry_app(self, name: str, owner: User, org: Organization return app @assume_test_silo_mode(SiloMode.CELL) - def create_exhaustive_sentry_app_notification(self, app: SentryApp, org: Organization): + def create_exhaustive_sentry_app_notification(self, app: SentryApp, org: Organization) -> None: project = Project.objects.filter(organization=org).first() + assert project is not None self.create_notification_action(organization=org, sentry_app_id=app.id, projects=[project]) @assume_test_silo_mode(SiloMode.CONTROL) - def create_exhaustive_global_configs(self, owner: User): + def create_exhaustive_global_configs(self, owner: User) -> None: self.create_exhaustive_api_keys_for_user(owner) self.create_exhaustive_global_configs_regional() ControlOption.objects.create(key="bar", value="b") @assume_test_silo_mode(SiloMode.CELL) - def create_exhaustive_global_configs_regional(self): + def create_exhaustive_global_configs_regional(self) -> None: _, public_key = generate_key_pair() relay = str(uuid4()) Relay.objects.create(relay_id=relay, public_key=str(public_key), is_internal=True) - RelayUsage.objects.create(relay_id=relay, version="0.0.1", public_key=public_key) + RelayUsage.objects.create(relay_id=relay, version="0.0.1", public_key=str(public_key)) Option.objects.create(key="foo", value="a") - def create_exhaustive_instance(self, *, is_superadmin: bool = False): + def create_exhaustive_instance(self, *, is_superadmin: bool = False) -> None: """ Takes an empty Sentry instance's database, and populates it with an "exhaustive" version of every model. The end result is two users, in one organization, with one full set of @@ -1017,7 +1023,7 @@ def create_exhaustive_instance(self, *, is_superadmin: bool = False): self.create_exhaustive_global_configs(owner) @assume_test_silo_mode(SiloMode.CONTROL) - def create_exhaustive_api_keys_for_user(self, user: User): + def create_exhaustive_api_keys_for_user(self, user: User) -> None: ApiAuthorization.objects.create(user=user) ApiToken.objects.create( user=user, @@ -1026,7 +1032,7 @@ def create_exhaustive_api_keys_for_user(self, user: User): token_type=AuthTokenType.USER, ) - def import_export_then_validate(self, out_name, *, reset_pks: bool = True) -> Any: + def import_export_then_validate(self, out_name: str, *, reset_pks: bool = True) -> Any: return import_export_then_validate(out_name, reset_pks=reset_pks) def json_of_org_and_project(self) -> Any: @@ -1080,7 +1086,7 @@ def sort_by_model_then_pk(a: Any, b: Any) -> int: if model_diff != 0: return model_diff - return a["pk"] - b["pk"] + return int(a["pk"]) - int(b["pk"]) return sorted( json_data, diff --git a/src/sentry/testutils/helpers/datetime.py b/src/sentry/testutils/helpers/datetime.py index 2d2df1b73e6b..5f49d6fe9f8f 100644 --- a/src/sentry/testutils/helpers/datetime.py +++ b/src/sentry/testutils/helpers/datetime.py @@ -21,10 +21,10 @@ def isoformat_z(dt: datetime) -> str: class MockClock: """Returns a distinct, increasing timestamp each time it is called.""" - def __init__(self, initial=None): + def __init__(self, initial: datetime | None = None) -> None: self.time = initial or datetime.now(UTC) - def __call__(self): + def __call__(self) -> datetime: self.time += timedelta(seconds=1) return self.time diff --git a/src/sentry/testutils/helpers/eap.py b/src/sentry/testutils/helpers/eap.py index 60e7df64b4ad..2175ed45e9db 100644 --- a/src/sentry/testutils/helpers/eap.py +++ b/src/sentry/testutils/helpers/eap.py @@ -67,7 +67,8 @@ def _dataset_from_query(query: Mapping[str, Any]) -> str | None: continue if isinstance(value, (list, tuple)): return value[0] if value else None - return value + if isinstance(value, str): + return value return None diff --git a/src/sentry/testutils/helpers/features.py b/src/sentry/testutils/helpers/features.py index d1cc9bd59bdc..3e8cccb9b50c 100644 --- a/src/sentry/testutils/helpers/features.py +++ b/src/sentry/testutils/helpers/features.py @@ -1,7 +1,9 @@ import logging -from collections.abc import Generator, Iterable, Mapping, Sequence -from contextlib import contextmanager +from collections.abc import Callable, Generator, Iterable, Mapping, Sequence +from contextlib import AbstractContextManager, contextmanager from functools import wraps +from types import TracebackType +from typing import Any, cast from unittest.mock import patch import pytest @@ -25,7 +27,9 @@ @contextmanager -def Feature(names: str | Iterable[str] | dict[str, bool]) -> Generator[None]: +def Feature( + names: str | Iterable[str] | dict[str, bool | list[str]], +) -> Generator[None]: """ Control whether a feature is enabled. @@ -61,12 +65,14 @@ def Feature(names: str | Iterable[str] | dict[str, bool]) -> Generator[None]: default_batch_has = sentry.features.batch_has default_batch_has_for_organizations = sentry.features.batch_has_for_organizations - def resolve_feature_name_value_for_org(organization, feature_name_value): + def resolve_feature_name_value_for_org( + organization: Organization, feature_name_value: bool | list[str] | None + ) -> bool | None: if isinstance(feature_name_value, list): return organization.slug in feature_name_value return feature_name_value - def features_override(name, *args, **kwargs): + def features_override(name: str, *args: Any, **kwargs: Any) -> object: if name in names: try: feature = features.get(name, None) @@ -85,7 +91,7 @@ def features_override(name, *args, **kwargs): ), ): raise ValueError("Must provide organization to check feature") - return resolve_feature_name_value_for_org(org, names[name]) + return resolve_feature_name_value_for_org(cast(Organization, org), names[name]) if isinstance(feature, ProjectFeature): project = args[0] if len(args) > 0 else kwargs.get("project", None) @@ -105,9 +111,15 @@ def features_override(name, *args, **kwargs): return default_value def batch_features_override( - _feature_names: Sequence[str], projects=None, organization=None, *args, **kwargs - ): - feature_results = {name: names[name] for name in _feature_names if name in names} + _feature_names: Sequence[str], + projects: Sequence[Project] | None = None, + organization: Organization | None = None, + *args: Any, + **kwargs: Any, + ) -> dict[str, dict[str, bool | list[str] | None]] | None: + feature_results: dict[str, bool | list[str]] = { + name: names[name] for name in _feature_names if name in names + } default_feature_names = [name for name in _feature_names if name not in names] default_feature_results: dict[str, dict[str, bool | None]] = {} if default_feature_names: @@ -118,7 +130,7 @@ def batch_features_override( default_feature_results.update(defaults) if projects: - results = {} + results: dict[str, dict[str, bool | list[str] | None]] = {} for project in projects: result_key = f"project:{project.id}" proj_results = {**feature_results, **default_feature_results.get(result_key, {})} @@ -135,10 +147,14 @@ def batch_features_override( if name.startswith("organization") } return {result_key: results_for_org} + return None def batch_features_override_for_organizations( - _feature_name: str, organizations: Sequence[Organization], *args, **kwargs - ): + _feature_name: str, + organizations: Sequence[Organization], + *args: Any, + **kwargs: Any, + ) -> dict[str, bool | None] | None: if _feature_name in names: return { f"organization:{organization.id}": resolve_feature_name_value_for_org( @@ -147,7 +163,10 @@ def batch_features_override_for_organizations( for organization in organizations } else: - return default_batch_has_for_organizations(_feature_name, organizations, **kwargs) + return cast( + dict[str, bool | None], + default_batch_has_for_organizations(_feature_name, organizations, **kwargs), + ) with ( patch("sentry.features.has") as features_has, @@ -161,19 +180,27 @@ def batch_features_override_for_organizations( class FeatureContextManagerOrDecorator: - def __init__(self, feature_names): + def __init__(self, feature_names: str | Iterable[str] | dict[str, bool | list[str]]) -> None: self.feature_names = feature_names - self._context_manager = None + self._context_manager: AbstractContextManager[None] | None = None - def __enter__(self): + def __enter__(self) -> None: self._context_manager = Feature(self.feature_names) return self._context_manager.__enter__() - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: if self._context_manager: return self._context_manager.__exit__(exc_type, exc_val, exc_tb) + return None - def __call__(self, func_or_cls): + def __call__( + self, func_or_cls: Callable[..., object] | type[object] + ) -> Callable[..., object] | type[object]: # Check if we're decorating a class if isinstance(func_or_cls, type): # Apply feature flag to the entire class @@ -208,9 +235,11 @@ def _feature_fixture() -> Generator[None]: # Check if already wrapped to avoid double-wrapping if not getattr(attr, "_feature_wrapped", False): - def create_wrapped_method(method): + def create_wrapped_method( + method: Callable[..., object], + ) -> Callable[..., object]: @wraps(method) - def wrapped_method(*args, **kwargs): + def wrapped_method(*args: object, **kwargs: object) -> object: with Feature(feature_names): return method(*args, **kwargs) @@ -225,14 +254,16 @@ def wrapped_method(*args, **kwargs): # Decorating a function - wrap it with the feature context @wraps(func_or_cls) - def wrapper(*args, **kwargs): + def wrapper(*args: object, **kwargs: object) -> object: with Feature(self.feature_names): return func_or_cls(*args, **kwargs) return wrapper -def with_feature(names: str | Iterable[str] | dict[str, bool]): +def with_feature( + names: str | Iterable[str] | dict[str, bool | list[str]], +) -> FeatureContextManagerOrDecorator: """ Control whether a feature is enabled. diff --git a/src/sentry/testutils/helpers/github.py b/src/sentry/testutils/helpers/github.py index b6bc753fe04e..75359d23bfdf 100644 --- a/src/sentry/testutils/helpers/github.py +++ b/src/sentry/testutils/helpers/github.py @@ -7,7 +7,7 @@ from collections.abc import Collection, Generator, Mapping from contextlib import contextmanager from datetime import datetime, timedelta -from typing import Any +from typing import Any, cast from uuid import uuid4 import orjson @@ -133,11 +133,15 @@ def send_github_webhook_event( # The DRF APIClient stubs can misinterpret **extra headers as a positional arg client: Any = self.client - return client.post( - self.github_webhook_url, - data=event_data, - content_type="application/json", - **headers, + # justification: the webhook test client posts to the Django view and returns its response. + return cast( + "HttpResponseBase", + client.post( + self.github_webhook_url, + data=event_data, + content_type="application/json", + **headers, + ), ) diff --git a/src/sentry/testutils/helpers/notifications.py b/src/sentry/testutils/helpers/notifications.py index 2743d1b7242f..eb2c83337d9e 100644 --- a/src/sentry/testutils/helpers/notifications.py +++ b/src/sentry/testutils/helpers/notifications.py @@ -1,9 +1,9 @@ from __future__ import annotations import uuid -from collections.abc import Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence from datetime import UTC, datetime -from typing import Any +from typing import Any, cast from sentry.integrations.types import ExternalProviders from sentry.issues.grouptype import ( @@ -16,6 +16,7 @@ ) from sentry.issues.issue_occurrence import IssueEvidence, IssueOccurrence from sentry.models.group import Group +from sentry.models.organization import Organization from sentry.models.rule import Rule from sentry.notifications.notifications.base import BaseNotification from sentry.notifications.types import NotificationActionContext, NotificationOrigin @@ -102,10 +103,10 @@ def get_subject(self, context: Mapping[str, Any] | None = None) -> str: def determine_recipients(self) -> list[Actor]: return [] - def build_attachment_title(self, *args) -> str: + def build_attachment_title(self, recipient: Actor) -> str: return "My Title" - def get_title_link(self, *args): + def get_title_link(self, recipient: Actor, provider: ExternalProviders) -> str | None: return None def get_notification_title( @@ -113,14 +114,14 @@ def get_notification_title( ) -> str: return "Notification Title" - def record_notification_sent(self, *args): + def record_notification_sent(self, recipient: Actor, provider: ExternalProviders) -> None: pass - def build_notification_footer(self, *args) -> str: + def build_notification_footer(self, recipient: Actor, provider: ExternalProviders) -> str: return "" - def get_participants(self): - return [] + def get_participants(self) -> Mapping[ExternalProviders, Iterable[Actor]]: + return cast(Mapping[ExternalProviders, Iterable[Actor]], []) def get_message_actions( self, recipient: Actor, provider: ExternalProviders @@ -140,7 +141,7 @@ def get_message_actions( class AnotherDummyNotification(DummyNotification): - def __init__(self, organization, some_value) -> None: + def __init__(self, organization: Organization, some_value: object) -> None: super().__init__(organization) self.some_value = some_value @@ -155,13 +156,13 @@ def get_notification_title( some_value = context["some_field"] return f"Notification Title with {some_value}" - def build_notification_footer(self, *args) -> str: + def build_notification_footer(self, recipient: Actor, provider: ExternalProviders) -> str: return "Notification Footer" def get_message_description(self, recipient: Actor, provider: ExternalProviders) -> str: return "Message Description" - def get_title_link(self, *args): + def get_title_link(self, recipient: Actor, provider: ExternalProviders) -> str | None: from sentry.integrations.messaging.message_builder import get_title_link return get_title_link(self.group, None, False, True, self, ExternalProviders.SLACK) diff --git a/src/sentry/testutils/helpers/options.py b/src/sentry/testutils/helpers/options.py index 31a5d6f4d62d..9a9e40f955f0 100644 --- a/src/sentry/testutils/helpers/options.py +++ b/src/sentry/testutils/helpers/options.py @@ -1,15 +1,22 @@ +from __future__ import annotations + __all__ = ["override_options"] +from collections.abc import Iterator, Mapping from contextlib import contextmanager +from typing import TYPE_CHECKING from unittest.mock import patch from django.test.utils import override_settings from sentry.utils.types import Any +if TYPE_CHECKING: + from sentry.options.store import Key + @contextmanager -def override_options(options): +def override_options(options: Mapping[str, object]) -> Iterator[None]: """ A context manager for overriding specific configuration Options. @@ -22,13 +29,13 @@ def override_options(options): wrapped = default_manager.store.get original_lookup = OptionsManager.lookup_key - def new_get(key, **kwargs): + def new_get(key: Key, silent: bool = False) -> object | None: try: return options[key.name] except KeyError: - return wrapped(key, **kwargs) + return wrapped(key, silent=silent) - def new_lookup(self: OptionsManager, key: str): + def new_lookup(self: OptionsManager, key: str) -> Key: # use the default key definition if available if key not in options or key in self.registry: return original_lookup(self, key) diff --git a/src/sentry/testutils/helpers/redis.py b/src/sentry/testutils/helpers/redis.py index 4d7bed1f64eb..6e74587b5eca 100644 --- a/src/sentry/testutils/helpers/redis.py +++ b/src/sentry/testutils/helpers/redis.py @@ -1,7 +1,9 @@ +from __future__ import annotations + from collections.abc import Generator from contextlib import contextmanager from typing import Any -from unittest.mock import patch +from unittest.mock import _patch, patch from django.test.utils import override_settings @@ -10,7 +12,7 @@ from sentry.testutils.helpers import override_options -def mock_redis_buffer(): +def mock_redis_buffer() -> _patch[RedisBuffer]: return patch("sentry.buffer.backend", new=RedisBuffer()) diff --git a/src/sentry/testutils/helpers/roles.py b/src/sentry/testutils/helpers/roles.py index 2bc24d59a1e7..66df806ddf34 100644 --- a/src/sentry/testutils/helpers/roles.py +++ b/src/sentry/testutils/helpers/roles.py @@ -1,12 +1,14 @@ +from __future__ import annotations + from typing import Any -from unittest.mock import patch +from unittest.mock import _patch, patch from django.conf import settings from sentry.roles.manager import RoleManager -def granular_roles_granting(*scopes: str) -> Any: +def granular_roles_granting(*scopes: str) -> _patch[Any]: """Patch every granular organization role to also grant `scopes`. For testing endpoints that accept a scope before any role grants it. Token scopes are diff --git a/src/sentry/testutils/helpers/slack.py b/src/sentry/testutils/helpers/slack.py index bb5d3458c242..a08fa46a3491 100644 --- a/src/sentry/testutils/helpers/slack.py +++ b/src/sentry/testutils/helpers/slack.py @@ -1,3 +1,4 @@ +from typing import Any from urllib.parse import parse_qs, quote import responses @@ -79,17 +80,18 @@ def link_team(team: Team, integration: Integration, channel_name: str, channel_i ) -def get_channel(index=0): +def get_channel(index: int = 0) -> dict[str, Any]: """Get the channel ID the Slack message went to""" assert len(responses.calls) >= 1 data = parse_qs(responses.calls[index].request.body) assert "channel" in data channel = json.loads(data["channel"][0]) + assert isinstance(channel, dict) return channel -def get_attachment(index=0): +def get_attachment(index: int = 0) -> tuple[dict[str, Any], str]: assert len(responses.calls) >= 1 data = parse_qs(responses.calls[index].request.body) assert "text" in data @@ -100,16 +102,20 @@ def get_attachment(index=0): return attachments[0], data["text"][0] -def get_attachment_no_text(): +def get_attachment_no_text() -> dict[str, Any]: assert len(responses.calls) >= 1 data = parse_qs(responses.calls[0].request.body) assert "attachments" in data attachments = json.loads(data["attachments"][0]) assert len(attachments) == 1 - return attachments[0] + attachment = attachments[0] + assert isinstance(attachment, dict) + return attachment -def get_blocks_and_fallback_text(index=0): +def get_blocks_and_fallback_text( + index: int = 0, +) -> tuple[list[dict[str, Any]], str]: assert len(responses.calls) >= 1 data = parse_qs(responses.calls[index].request.body) assert "blocks" in data @@ -119,7 +125,7 @@ def get_blocks_and_fallback_text(index=0): return blocks, fallback_text -def get_block_kit_preview_url(index=0) -> str: +def get_block_kit_preview_url(index: int = 0) -> str: assert len(responses.calls) >= 1 data = parse_qs(responses.calls[index].request.body) assert "blocks" in data @@ -139,9 +145,9 @@ def get_block_kit_preview_url(index=0) -> str: def setup_slack_with_identities( organization: Organization, user: User, - identity_provider_external_id="TXXXXXXX1", - identity_external_id="UXXXXXXX1", -): + identity_provider_external_id: str = "TXXXXXXX1", + identity_external_id: str = "UXXXXXXX1", +) -> Integration: integration = install_slack( organization=organization, workspace_id=identity_provider_external_id ) diff --git a/src/sentry/testutils/hybrid_cloud.py b/src/sentry/testutils/hybrid_cloud.py index 0f3a600974b4..591cce157766 100644 --- a/src/sentry/testutils/hybrid_cloud.py +++ b/src/sentry/testutils/hybrid_cloud.py @@ -5,9 +5,10 @@ import ipaddress import os import threading -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Mapping from contextlib import contextmanager -from typing import Any, TypedDict +from types import TracebackType +from typing import Any, TypedDict, cast from unittest.mock import patch from django.db import connections, transaction @@ -30,7 +31,9 @@ def ScheduledDeletion(self) -> type[BaseScheduledDeletion]: return get_cell_scheduled_deletion(SiloMode.get_current_mode()) @assume_test_silo_mode(SiloMode.CONTROL) - def assert_org_member_mapping(self, org_member: OrganizationMember, expected=None): + def assert_org_member_mapping( + self, org_member: OrganizationMember, expected: Mapping[str, object] | None = None + ) -> None: org_member.refresh_from_db() org_member_mapping_query = OrganizationMemberMapping.objects.filter( organization_id=org_member.organization_id, @@ -56,7 +59,7 @@ def assert_org_member_mapping(self, org_member: OrganizationMember, expected=Non assert getattr(org_member_mapping, key) == expected_value @assume_test_silo_mode(SiloMode.CONTROL) - def assert_org_member_mapping_not_exists(self, org_member: OrganizationMember): + def assert_org_member_mapping_not_exists(self, org_member: OrganizationMember) -> None: email = org_member.email user_id = org_member.user_id # only either user_id or email should have a value, but not both. @@ -134,7 +137,7 @@ def __call__(self, execute: Callable[..., Any], *params: Any) -> Any: @contextlib.contextmanager -def enforce_no_cross_transaction_interactions(): +def enforce_no_cross_transaction_interactions() -> Iterator[None]: with contextlib.ExitStack() as stack: for conn in connections.all(): stack.enter_context(conn.execute_wrapper(EnforceNoCrossTransactionWrapper(conn.alias))) @@ -185,7 +188,7 @@ def collect_transaction_queries() -> Iterator[list[TransactionDetails]]: @contextlib.contextmanager -def simulate_on_commit(request: Any): +def simulate_on_commit(request: Any) -> Iterator[None]: """ Deal with the fact that django TestCase class is both used heavily, and also, complicates our ability to correctly test on_commit hooks. Allows the use of django_test_transaction_water_mark to create a 'simulated' @@ -205,7 +208,7 @@ def simulate_on_commit(request: Any): _old_atomic_exit = transaction.Atomic.__exit__ _old_transaction_on_commit = transaction.on_commit - def maybe_flush_commit_hooks(connection: BaseDatabaseWrapper): + def maybe_flush_commit_hooks(connection: BaseDatabaseWrapper) -> None: if connection.closed_in_transaction or connection.needs_rollback: return @@ -221,16 +224,21 @@ def maybe_flush_commit_hooks(connection: BaseDatabaseWrapper): finally: connection.validate_no_atomic_block = old_validate # type: ignore[method-assign] - def new_atomic_exit(self, exc_type, *args, **kwds): - _old_atomic_exit(self, exc_type, *args, **kwds) + def new_atomic_exit( + self: transaction.Atomic, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + _old_atomic_exit(self, exc_type, exc_value, traceback) if exc_type is not None: return - connection = transaction.get_connection(self.using) + connection = transaction.get_connection(cast(str, self.using)) maybe_flush_commit_hooks(connection) - def new_atomic_on_commit(func, using=None): + def new_atomic_on_commit(func: Callable[[], object], using: str | None = None) -> None: _old_transaction_on_commit(func, using) - maybe_flush_commit_hooks(transaction.get_connection(using)) + maybe_flush_commit_hooks(transaction.get_connection(cast(str, using))) for conn in connections.all(): # This value happens to match the number of outer transactions in @@ -266,7 +274,7 @@ def use_split_dbs() -> bool: @contextmanager -def override_allowed_cell_silo_ip_addresses(*allowed_ip_addresses): +def override_allowed_cell_silo_ip_addresses(*allowed_ip_addresses: str) -> Iterator[None]: with patch("sentry.silo.client.get_cell_ip_addresses") as mock_get_cell_ip_addresses: override_value = frozenset(ipaddress.ip_address(str(ip)) for ip in allowed_ip_addresses) mock_get_cell_ip_addresses.return_value = override_value diff --git a/src/sentry/testutils/issue_detection/event_generators.py b/src/sentry/testutils/issue_detection/event_generators.py index cc9d6bf3b3fa..8a15214d6cf7 100644 --- a/src/sentry/testutils/issue_detection/event_generators.py +++ b/src/sentry/testutils/issue_detection/event_generators.py @@ -1,9 +1,11 @@ from __future__ import annotations import os +from collections.abc import Sequence from copy import deepcopy from typing import Any +from sentry.spans.grouping.strategy.base import Span from sentry.testutils.factories import get_fixture_path from sentry.utils import json @@ -40,14 +42,14 @@ def get_event(event_name: str) -> dict[str, Any]: # Duration is in ms -def modify_span_duration(obj, duration): +def modify_span_duration(obj: Span, duration: float) -> Span: obj["start_timestamp"] = 0.0 obj["timestamp"] = duration / 1000.0 return obj # Start is in ms -def modify_span_start(obj, start): +def modify_span_start(obj: Span, start: float) -> Span: duration = obj["timestamp"] - obj["start_timestamp"] obj["start_timestamp"] = start / 1000.0 obj["timestamp"] = obj["start_timestamp"] + duration @@ -55,15 +57,21 @@ def modify_span_start(obj, start): def create_span( - op, duration=100.0, desc="SELECT count() FROM table WHERE id = %s", hash="", data=None -): + op: str, + duration: float = 100.0, + desc: str | None = "SELECT count() FROM table WHERE id = %s", + hash: str = "", + data: dict[str, Any] | None = None, # justification: test payload is dynamic. +) -> Span: return modify_span_duration( SpanBuilder().with_op(op).with_description(desc).with_hash(hash).with_data(data).build(), duration, ) -def create_event(spans, event_id: str = "a" * 16) -> dict[str, Any]: +def create_event( + spans: Sequence[Span], event_id: str = "a" * 16 +) -> dict[str, Any]: # justification: test payload is dynamic. return { "event_id": event_id, "project": PROJECT_ID, diff --git a/src/sentry/testutils/issue_detection/experiments.py b/src/sentry/testutils/issue_detection/experiments.py index 601a9c23d6d5..d1a9973cec49 100644 --- a/src/sentry/testutils/issue_detection/experiments.py +++ b/src/sentry/testutils/issue_detection/experiments.py @@ -1,10 +1,11 @@ +from typing import Any from unittest.mock import patch from sentry.issue_detection.performance_detection import DETECTOR_CLASSES from sentry.testutils.cases import TestCase -def exclude_experimental_detectors(cls: type[TestCase]): +def exclude_experimental_detectors(cls: type[TestCase]) -> type[TestCase]: """ Modifies the setUp method of a given TestCase to exclude any experimental detectors. It does so by patching the DETECTOR_CLASSES variable, which is read by methods like `_detect_performance_problems`. @@ -18,14 +19,18 @@ class SomeTestCase(TestCase): """ cls_setUp = cls.setUp - def exclude_experimental_detectors_setUp(self, *args, **kwargs): - self.patch_detector_classes = patch( + def exclude_experimental_detectors_setUp( + self: TestCase, + *args: Any, # justification: test payload is dynamic. + **kwargs: Any, # justification: test payload is dynamic. + ) -> None: + patch_detector_classes = patch( "sentry.issue_detection.performance_detection.DETECTOR_CLASSES", [cls for cls in DETECTOR_CLASSES if "Experimental" not in cls.__name__], ) - self.patch_detector_classes.start() - self.addCleanup(self.patch_detector_classes.stop) + patch_detector_classes.start() + self.addCleanup(patch_detector_classes.stop) cls_setUp(self, *args, **kwargs) - cls.setUp = exclude_experimental_detectors_setUp # type: ignore[method-assign] + cls.setUp = exclude_experimental_detectors_setUp # type: ignore[assignment] return cls diff --git a/src/sentry/testutils/issue_detection/span_builder.py b/src/sentry/testutils/issue_detection/span_builder.py index 00e58efa6465..99dee58912a6 100644 --- a/src/sentry/testutils/issue_detection/span_builder.py +++ b/src/sentry/testutils/issue_detection/span_builder.py @@ -44,7 +44,7 @@ def with_hash(self, hash: str) -> "SpanBuilder": self.hash = hash return self - def with_data(self, data: dict) -> "SpanBuilder": + def with_data(self, data: dict[str, object] | None) -> "SpanBuilder": self.data = data return self diff --git a/src/sentry/testutils/issue_detection/store_transaction.py b/src/sentry/testutils/issue_detection/store_transaction.py index 80258e3951c9..fea8ba8fcbe0 100644 --- a/src/sentry/testutils/issue_detection/store_transaction.py +++ b/src/sentry/testutils/issue_detection/store_transaction.py @@ -1,6 +1,7 @@ import random from collections.abc import Sequence from datetime import datetime, timedelta +from typing import cast from django.utils import timezone @@ -63,7 +64,11 @@ def store_transaction( "timestamp", ], groupby=None, - filter_keys={"project_id": [project_id], "event_id": [event.event_id]}, + filter_keys={ + "project_id": [project_id], + # justification: snuba accepts hex event id strings for `event_id`. + "event_id": cast("Sequence[int]", [event.event_id]), + }, referrer="_insert_transaction.verify_transaction", ) assert len(result["data"]) == 1 diff --git a/src/sentry/testutils/pytest/fixtures.py b/src/sentry/testutils/pytest/fixtures.py index 11b699a891dc..ce70a172f052 100644 --- a/src/sentry/testutils/pytest/fixtures.py +++ b/src/sentry/testutils/pytest/fixtures.py @@ -5,23 +5,39 @@ including ``db`` fixture in the function resolution scope. """ +from __future__ import annotations + import difflib import os import re import sys from collections.abc import Callable, Generator from concurrent.futures import ThreadPoolExecutor # noqa: S016 - test infra, no tracing needed +from contextlib import AbstractContextManager from string import Template -from typing import Any, ContextManager, Protocol, overload +from typing import TYPE_CHECKING, Any, ContextManager, Protocol, overload import pytest import requests import yaml +from django.conf import LazySettings from django.core.cache import cache +from django.core.cache.backends.base import BaseCache import sentry from sentry.types.activity import ActivityType +if TYPE_CHECKING: + from sentry.models.activity import Activity + from sentry.models.environment import Environment + from sentry.models.group import Group + from sentry.models.organization import Organization + from sentry.models.project import Project + from sentry.models.projectkey import ProjectKey + from sentry.models.team import Team + from sentry.testutils.factories import Factories + from sentry.users.models.user import User + # These chars cannot be used in Windows paths so replace them: # https://docs.microsoft.com/en-us/windows/desktop/FileIO/naming-a-file#naming-conventions @@ -63,7 +79,7 @@ def decorator(function: Callable[P, R]) -> Callable[P, R]: @pytest.fixture -def factories(): +def factories() -> type[Factories]: # XXX(dcramer): hack to prevent recursive imports from sentry.testutils.factories import Factories @@ -83,7 +99,7 @@ def task_runner() -> Callable[[], ContextManager[None]]: @pytest.fixture(scope="function") -def default_user(factories): +def default_user(factories: type[Factories]) -> User: """A default (super)user with email ``admin@localhost`` and password ``admin``. :returns: A :class:`sentry.users.models.user.User` instance. @@ -92,7 +108,7 @@ def default_user(factories): @pytest.fixture(scope="function") -def default_organization(factories, default_user): +def default_organization(factories: type[Factories], default_user: User) -> Organization: """A default organization (slug=``baz``) owned by the ``default_user`` fixture. :returns: A :class:`sentry.models.organization.Organization` instance. @@ -103,7 +119,7 @@ def default_organization(factories, default_user): @pytest.fixture(scope="function") -def default_team(factories, default_organization): +def default_team(factories: type[Factories], default_organization: Organization) -> Team: from sentry.models.organizationmember import OrganizationMember from sentry.models.organizationmemberteam import OrganizationMemberTeam @@ -116,28 +132,30 @@ def default_team(factories, default_organization): @pytest.fixture(scope="function") -def default_project(factories, default_team): +def default_project(factories: type[Factories], default_team: Team) -> Project: return factories.create_project(name="Bar", slug="bar", teams=[default_team]) @pytest.fixture(scope="function") -def default_projectkey(factories, default_project): +def default_projectkey(factories: type[Factories], default_project: Project) -> ProjectKey: return factories.create_project_key(project=default_project) @pytest.fixture(scope="function") -def default_environment(factories, default_project): +def default_environment(factories: type[Factories], default_project: Project) -> Environment: return factories.create_environment(name="development", project=default_project) @pytest.fixture(scope="function") -def default_group(factories, default_project): +def default_group(factories: type[Factories], default_project: Project) -> Group: # こんにちは konichiwa return factories.create_group(project=default_project, message="\u3053\u3093\u306b\u3061\u306f") @pytest.fixture(scope="function") -def default_activity(default_group, default_project, default_user): +def default_activity( + default_group: Group, default_project: Project, default_user: User +) -> Activity: from sentry.models.activity import Activity return Activity.objects.create( @@ -150,9 +168,9 @@ def default_activity(default_group, default_project, default_user): @pytest.fixture() -def dyn_sampling_data(): +def dyn_sampling_data() -> Callable[[bool], dict[str, object]]: # return a function that returns fresh config so we don't accidentally get tests interfering with each other - def inner(active=True): + def inner(active: bool = True) -> dict[str, object]: return { "rules": [ { @@ -185,8 +203,8 @@ def inner(active=True): @pytest.fixture -def log(): - def inner(x): +def log() -> Callable[[str], int]: + def inner(x: str) -> int: return sys.stdout.write(x + "\n") return inner @@ -195,7 +213,7 @@ def inner(x): class ReadableYamlDumper(yaml.dumper.SafeDumper): """Disable pyyaml aliases for identical object references""" - def ignore_aliases(self, data) -> bool: + def ignore_aliases(self, data: object) -> bool: return True @@ -311,7 +329,7 @@ def inner( ) -def _print_insta_diff(reference_file, a, b): +def _print_insta_diff(reference_file: str, a: str, b: str) -> None: __tracebackhide__ = True pytest.fail( INSTA_DIFF_TEMPLATE.substitute( @@ -321,21 +339,21 @@ def _print_insta_diff(reference_file, a, b): ) -def _print_custom_insta_diff(reference_file, diff_text): +def _print_custom_insta_diff(reference_file: str, diff_text: str) -> None: __tracebackhide__ = True pytest.fail(INSTA_DIFF_TEMPLATE.substitute(reference_file=reference_file, diff_text=diff_text)) @pytest.fixture -def call_snuba(settings): - def inner(endpoint): +def call_snuba(settings: LazySettings) -> Callable[[str], requests.Response]: + def inner(endpoint: str) -> requests.Response: return requests.post(settings.SENTRY_SNUBA + endpoint, timeout=30) return inner @pytest.fixture -def reset_snuba(call_snuba): +def reset_snuba(call_snuba: Callable[[str], requests.Response]) -> None: init_endpoints = [ "/tests/events_analytics_platform/drop", "/tests/events/drop", @@ -366,7 +384,7 @@ def skip_group_attributes_snapshots() -> Generator[None]: @pytest.fixture -def set_sentry_option(): +def set_sentry_option() -> Callable[[str, object], AbstractContextManager[None]]: """ A pytest-style wrapper around override_options. @@ -378,13 +396,13 @@ def test_basic(set_sentry_option): """ from sentry.testutils.helpers.options import override_options - def inner(key, value): + def inner(key: str, value: object) -> AbstractContextManager[None]: return override_options({key: value}) return inner @pytest.fixture -def django_cache(): +def django_cache() -> Generator[BaseCache]: yield cache cache.clear() diff --git a/src/sentry/testutils/pytest/kafka.py b/src/sentry/testutils/pytest/kafka.py index e4e5a30ffd7c..efdeeba9be48 100644 --- a/src/sentry/testutils/pytest/kafka.py +++ b/src/sentry/testutils/pytest/kafka.py @@ -1,12 +1,17 @@ import logging import threading import time -from collections.abc import MutableMapping +from collections.abc import Callable, Generator, Mapping, MutableMapping +from concurrent.futures import Future +from contextlib import AbstractContextManager +from typing import cast import pytest +from arroyo.backends.kafka import KafkaPayload from arroyo.processing import StreamProcessor from confluent_kafka import Producer from confluent_kafka.admin import AdminClient +from django.conf import LazySettings from sentry.testutils.pytest import xdist @@ -16,8 +21,8 @@ @pytest.fixture -def kafka_producer(): - def inner(settings): +def kafka_producer() -> Callable[[LazySettings], Producer]: + def inner(settings: LazySettings) -> Producer: producer = Producer( {"bootstrap.servers": settings.KAFKA_CLUSTERS["default"]["common"]["bootstrap.servers"]} ) @@ -27,7 +32,7 @@ def inner(settings): class _KafkaAdminWrapper: - def __init__(self, request, settings): + def __init__(self, request: pytest.FixtureRequest, settings: LazySettings) -> None: self.test_name = request.node.name kafka_config = {} @@ -36,14 +41,14 @@ def __init__(self, request, settings): self.admin_client = AdminClient(kafka_config) - def delete_topic(self, topic_name): + def delete_topic(self, topic_name: str) -> None: try: futures_dict = self.admin_client.delete_topics([topic_name]) self._sync_wait_on_result(futures_dict) except Exception: _log.warning("Could not delete topic %s", topic_name) - def _sync_wait_on_result(self, futures_dict): + def _sync_wait_on_result(self, futures_dict: Mapping[str, Future[object]]) -> None: """ Synchronously waits on all futures returned by the admin_client api. :param futures_dict: the api returns a dict of futures that can be awaited @@ -54,27 +59,27 @@ def _sync_wait_on_result(self, futures_dict): @pytest.fixture -def kafka_admin(request): +def kafka_admin(request: pytest.FixtureRequest) -> Callable[[LazySettings], _KafkaAdminWrapper]: """ A fixture representing a simple wrapper over the admin interface :param request: the pytest request :return: a Kafka admin wrapper """ - def inner(settings): + def inner(settings: LazySettings) -> _KafkaAdminWrapper: return _KafkaAdminWrapper(request, settings) return inner @pytest.fixture(scope="session") -def scope_consumers(): +def scope_consumers() -> Generator[MutableMapping[str, StreamProcessor[KafkaPayload] | None]]: """ Sets up an object to keep track of the scope consumers ( consumers that will only be created once per test session). """ - all_consumers: MutableMapping[str, StreamProcessor | None] = { + all_consumers: MutableMapping[str, StreamProcessor[KafkaPayload] | None] = { xdist.get_kafka_topic("ingest-events"): None, xdist.get_kafka_topic("outcomes"): None, } @@ -96,7 +101,11 @@ def scope_consumers(): @pytest.fixture(scope="function") -def session_ingest_consumer(scope_consumers, kafka_admin, task_runner): +def session_ingest_consumer( + scope_consumers: MutableMapping[str, StreamProcessor[KafkaPayload] | None], + kafka_admin: Callable[[LazySettings], _KafkaAdminWrapper], + task_runner: Callable[[], AbstractContextManager[None]], +) -> Callable[..., StreamProcessor[KafkaPayload]]: """ Returns a factory for a session ingest consumer. @@ -108,7 +117,7 @@ def session_ingest_consumer(scope_consumers, kafka_admin, task_runner): :return: a function factory that creates a consumer at first invocation and returns the cached consumer afterwards. """ - def ingest_consumer(settings): + def ingest_consumer(settings: LazySettings) -> StreamProcessor[KafkaPayload]: from sentry.consumers import get_stream_processor from sentry.utils.batching_kafka_consumer import create_topics @@ -117,7 +126,7 @@ def ingest_consumer(settings): if scope_consumers[topic_event_name] is not None: # reuse whatever was already created (will ignore the settings) - return scope_consumers[topic_event_name] + return cast(StreamProcessor[KafkaPayload], scope_consumers[topic_event_name]) # first time the consumer is requested, create it using settings admin = kafka_admin(settings) @@ -143,7 +152,10 @@ def ingest_consumer(settings): @pytest.fixture(scope="function") -def wait_for_ingest_consumer(session_ingest_consumer, task_runner): +def wait_for_ingest_consumer( + session_ingest_consumer: Callable[..., StreamProcessor[KafkaPayload]], + task_runner: Callable[[], AbstractContextManager[None]], +) -> Callable[..., Callable[..., object | None]]: """ Returns a function that can be used to create a wait loop for the ingest consumer @@ -162,10 +174,13 @@ def wait_for_ingest_consumer(session_ingest_consumer, task_runner): assert result == expected_result """ - def factory(settings, **kwargs): + def factory(settings: LazySettings, **kwargs: object) -> Callable[..., object | None]: consumer = session_ingest_consumer(settings, **kwargs) - def waiter(exit_predicate, max_time=MAX_SECONDS_WAITING_FOR_EVENT): + def waiter( + exit_predicate: Callable[[], object], + max_time: float = MAX_SECONDS_WAITING_FOR_EVENT, + ) -> object | None: """ Implements a wait loop for the ingest consumer :param exit_predicate: A Callable[(),Any] that will be called in a loop after each call diff --git a/src/sentry/testutils/pytest/metrics.py b/src/sentry/testutils/pytest/metrics.py index f6f945c7eb7a..f16bb04d7223 100644 --- a/src/sentry/testutils/pytest/metrics.py +++ b/src/sentry/testutils/pytest/metrics.py @@ -1,6 +1,9 @@ import contextlib import dataclasses import functools +from collections.abc import Callable, Generator +from contextlib import AbstractContextManager +from typing import Any from unittest import mock import pytest @@ -23,7 +26,10 @@ @pytest.fixture(autouse=True) -def control_metrics_access(request, set_sentry_option): +def control_metrics_access( + request: pytest.FixtureRequest, + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], +) -> Generator[None]: from snuba_sdk import MetricsQuery from sentry.sentry_metrics import indexer @@ -49,7 +55,7 @@ def control_metrics_access(request, set_sentry_option): old_resolve = indexer.resolve - def new_resolve(use_case_id, org_id, string): + def new_resolve(use_case_id: UseCaseID, org_id: int, string: str) -> int | None: if ( use_case_id == UseCaseID.TRANSACTIONS and string in STRINGS_THAT_LOOK_LIKE_TAG_VALUES @@ -63,7 +69,7 @@ def new_resolve(use_case_id, org_id, string): old_build_results = snuba._apply_cache_and_build_results - def new_build_results(*args, **kwargs): + def new_build_results(*args: Any, **kwargs: Any) -> Any: if isinstance(args[0][0].request, dict): # We only support snql queries, and metrics only go through snql return old_build_results(*args, **kwargs) @@ -92,8 +98,11 @@ def new_build_results(*args, **kwargs): old_create_snql_in_snuba = tasks._create_snql_in_snuba def new_create_snql_in_snuba( - subscription, snuba_query, snql_query, entity_subscription - ): + subscription: Any, + snuba_query: Any, + snql_query: Any, + entity_subscription: Any, + ) -> Any: query = snql_query.query is_performance_metrics = False is_metrics = False @@ -117,7 +126,7 @@ def new_create_snql_in_snuba( else: should_fail = False - def fail(old_fn, *args, **kwargs): + def fail(old_fn: Callable[..., object], *args: object, **kwargs: object) -> object: nonlocal should_fail should_fail = True return old_fn(*args, **kwargs) @@ -141,8 +150,8 @@ def fail(old_fn, *args, **kwargs): ) -def _validate_query(query, tag_values_are_strings): - def _walk(node): +def _validate_query(query: object, tag_values_are_strings: bool) -> None: + def _walk(node: object) -> None: if isinstance(node, (tuple, list)): for subnode in node: _walk(subnode) diff --git a/src/sentry/testutils/pytest/mocking.py b/src/sentry/testutils/pytest/mocking.py index 77fc22f636ca..166c61606f6c 100644 --- a/src/sentry/testutils/pytest/mocking.py +++ b/src/sentry/testutils/pytest/mocking.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any, ParamSpec, TypeVar +from typing import Any, ParamSpec, TypeVar, cast from unittest.mock import MagicMock from unittest.mock import call as MockCall @@ -107,11 +107,11 @@ def test_getting_dog_with_error(): assert repr(result)== "TypeError('Expected dog, but got cat instead.')" """ - def record_result(result): + def record_result(result: T | Exception) -> None: if isinstance(results, list): - results.append(result) + results.append(cast(T, result)) elif isinstance(results, dict): - results.setdefault(fn.__name__, []).append(result) + results.setdefault(fn.__name__, []).append(cast(T, result)) def wrapped_fn(*args: P.args, **kwargs: P.kwargs) -> T: try: diff --git a/src/sentry/testutils/pytest/relay.py b/src/sentry/testutils/pytest/relay.py index c518f36ba141..2a9cd1d5281c 100644 --- a/src/sentry/testutils/pytest/relay.py +++ b/src/sentry/testutils/pytest/relay.py @@ -1,15 +1,20 @@ # Fixtures used to interact with a test Relay server +from __future__ import annotations import datetime import logging import shutil import sys import time +from collections.abc import Callable, Generator +from contextlib import AbstractContextManager from os import environ, path +from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, cast from unittest import mock from urllib.parse import urlparse +import docker import docker.errors import ephemeral_port_reserve import pytest @@ -18,9 +23,33 @@ from sentry.runner.commands.devservices import get_docker_client from sentry.testutils.pytest import xdist +if TYPE_CHECKING: + from pytest import TempPathFactory + from pytest_django.live_server_helper import LiveServer + _log = logging.getLogger(__name__) +class RelayDockerOptions(TypedDict): + image: str + ports: dict[str, int] + network: str + detach: Literal[True] + name: str + volumes: dict[str, dict[str, str]] + command: list[str] + extra_hosts: dict[str, str] + + +class RelayServerSetup(TypedDict): + url: str + options: RelayDockerOptions + + +class RelayServer(TypedDict): + url: str + + # This helps the Relay CI to specify the generated Docker build before it is published RELAY_TEST_IMAGE = environ.get("RELAY_TEST_IMAGE", "ghcr.io/getsentry/relay:nightly") @@ -47,17 +76,24 @@ def _wait_until_ready(url: str, timeout: float) -> None: raise TimeoutError(last_result) -def _get_template_dir(): +class _Container(Protocol): + def stop(self, timeout: int | None = None) -> object: ... + def kill(self) -> object: ... + def remove(self) -> object: ... + + +def _get_template_dir() -> str: return path.abspath(path.join(path.dirname(__file__), "template")) -def _remove_container_if_exists(docker_client, container_name): +def _remove_container_if_exists(docker_client: docker.DockerClient, container_name: str) -> None: try: - container = docker_client.containers.get(container_name) + # justification: docker does not ship type hints for its container object. + container = cast("_Container", docker_client.containers.get(container_name)) except Exception: pass # container not found else: - actions = [ + actions: list[Callable[[], object]] = [ lambda: container.stop(timeout=1), lambda: container.kill(), lambda: container.remove(), @@ -70,13 +106,15 @@ def _remove_container_if_exists(docker_client, container_name): @pytest.fixture(scope="module") -def relay_server_setup(live_server, tmpdir_factory): +def relay_server_setup( + live_server: LiveServer, tmpdir_factory: TempPathFactory +) -> Generator[RelayServerSetup]: prefix = "test_relay_config_{}_".format( datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f") ) - config_path = tmpdir_factory.mktemp(prefix) - config_path.chmod(0o755) - config_path = str(config_path) + config_dir = tmpdir_factory.mktemp(prefix) + config_dir.chmod(0o755) + config_path = str(config_dir) parsed_live_server_url = urlparse(live_server.url) if parsed_live_server_url.port is not None: @@ -127,7 +165,7 @@ def relay_server_setup(live_server, tmpdir_factory): container_name = _relay_server_container_name() _remove_container_if_exists(docker_client, container_name) - options = { + options: RelayDockerOptions = { "image": RELAY_TEST_IMAGE, "ports": {"%s/tcp" % relay_port: relay_port}, "network": "devservices", @@ -139,13 +177,16 @@ def relay_server_setup(live_server, tmpdir_factory): } # Some structure similar to what the live_server fixture returns - server_info = {"url": f"http://127.0.0.1:{relay_port}", "options": options} + server_info: RelayServerSetup = { + "url": f"http://127.0.0.1:{relay_port}", + "options": options, + } # Relay keeps idle upstream connections open past the global 5s socket timeout. httpd = live_server.thread.httpd handle_error = httpd.handle_error - def _handle_error(request, client_address): + def _handle_error(request: object, client_address: object) -> None: if not isinstance(sys.exception(), TimeoutError): handle_error(request, client_address) @@ -160,7 +201,7 @@ def _handle_error(request, client_address): @pytest.fixture(scope="function") -def relay_server(relay_server_setup): +def relay_server(relay_server_setup: RelayServerSetup) -> Generator[RelayServer]: # Imported lazily: this module is loaded as a pytest plugin before Django is set up, # and override_options pulls in app code that requires configured settings. from sentry.testutils.helpers.options import override_options @@ -223,10 +264,11 @@ def relay_server(relay_server_setup): f"relay did not become ready in time (now: {datetime.datetime.now().isoformat()}, last result: {e}) {readiness_url}:\n{logs}" ) from e - yield {"url": relay_server_setup["url"]} + server: RelayServer = {"url": relay_server_setup["url"]} + yield server -def relay_test_settings(): +def relay_test_settings() -> AbstractContextManager[object]: """ Settings that let a Relay instance running inside a docker container call the app. """ @@ -255,40 +297,48 @@ def relay_test_settings(): @pytest.fixture -def get_relay_store_url(relay_server): - def inner(project_id): +def get_relay_store_url(relay_server: RelayServer) -> Callable[[int | str], str]: + def inner(project_id: int | str) -> str: return "{}/api/{}/store/".format(relay_server["url"], project_id) return inner @pytest.fixture -def get_relay_security_url(relay_server): - def inner(project_id, key): +def get_relay_security_url( + relay_server: RelayServer, +) -> Callable[[int | str, str], str]: + def inner(project_id: int | str, key: str) -> str: return "{}/api/{}/security/?sentry_key={}".format(relay_server["url"], project_id, key) return inner @pytest.fixture -def get_relay_minidump_url(relay_server): - def inner(project_id, key): +def get_relay_minidump_url( + relay_server: RelayServer, +) -> Callable[[int | str, str], str]: + def inner(project_id: int | str, key: str) -> str: return "{}/api/{}/minidump/?sentry_key={}".format(relay_server["url"], project_id, key) return inner @pytest.fixture -def get_relay_unreal_url(relay_server): - def inner(project_id, key): +def get_relay_unreal_url( + relay_server: RelayServer, +) -> Callable[[int | str, str], str]: + def inner(project_id: int | str, key: str) -> str: return "{}/api/{}/unreal/{}/".format(relay_server["url"], project_id, key) return inner @pytest.fixture -def get_relay_attachments_url(relay_server): - def inner(project_id, event_id): +def get_relay_attachments_url( + relay_server: RelayServer, +) -> Callable[[int | str, str], str]: + def inner(project_id: int | str, event_id: str) -> str: return "{}/api/{}/events/{}/attachments/".format(relay_server["url"], project_id, event_id) return inner diff --git a/src/sentry/testutils/pytest/selenium.py b/src/sentry/testutils/pytest/selenium.py index ebbde71a3e83..3748146ca731 100644 --- a/src/sentry/testutils/pytest/selenium.py +++ b/src/sentry/testutils/pytest/selenium.py @@ -1,13 +1,15 @@ # TODO(dcramer): this heavily inspired by pytest-selenium, and it's possible # we could simply inherit from the plugin at this point +from __future__ import annotations + import logging import os import sys import threading -from collections.abc import Callable, MutableSequence +from collections.abc import Callable, Generator, Iterator, MutableSequence, Sequence from contextlib import contextmanager from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any, Generic, Mapping, TypeVar, cast from urllib.parse import urlparse import pytest @@ -20,7 +22,9 @@ from selenium.webdriver.chrome.service import Service as ChromeService from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By +from selenium.webdriver.remote.switch_to import SwitchTo from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.wait import WebDriverWait @@ -28,63 +32,78 @@ from sentry.testutils.silo import assume_test_silo_mode from sentry.utils.retries import TimedRetryPolicy +if TYPE_CHECKING: + from pytest_django.live_server_helper import LiveServer + logger = logging.getLogger("sentry.testutils") +ViewportSize = dict[str, dict[str, int]] + class Browser: - def __init__(self, driver, live_server): + def __init__(self, driver: WebDriver, live_server: LiveServer) -> None: self.driver = driver self.live_server_url = live_server.url self.domain = urlparse(self.live_server_url).hostname self._has_initialized_cookie_store = False - def __getattr__(self, attr): + def __getattr__(self, attr: str) -> Any: + # justification: delegates arbitrary WebDriver attributes. return getattr(self.driver, attr) - def route(self, path, *args, **kwargs): + def route(self, path: str, *args: Any, **kwargs: Any) -> str: """ Return the absolute URI for a given route in Sentry. """ return "{}/{}".format(self.live_server_url, path.lstrip("/").format(*args, **kwargs)) - def get(self, path, *args, **kwargs): + def get(self, path: str, *args: Any, **kwargs: Any) -> Browser: self.driver.get(self.route(path), *args, **kwargs) self._has_initialized_cookie_store = True return self - def post(self, path, *args, **kwargs): - self.driver.post(self.route(path), *args, **kwargs) + def post(self, path: str, *args: Any, **kwargs: Any) -> Browser: + # justification: Selenium's base WebDriver stub omits these HTTP helper methods. + getattr(self.driver, "post")(self.route(path), *args, **kwargs) self._has_initialized_cookie_store = True return self - def put(self, path, *args, **kwargs): - self.driver.put(self.route(path), *args, **kwargs) + def put(self, path: str, *args: Any, **kwargs: Any) -> Browser: + getattr(self.driver, "put")(self.route(path), *args, **kwargs) self._has_initialized_cookie_store = True return self - def delete(self, path, *args, **kwargs): - self.driver.delete(self.route(path), *args, **kwargs) + def delete(self, path: str, *args: Any, **kwargs: Any) -> Browser: + getattr(self.driver, "delete")(self.route(path), *args, **kwargs) self._has_initialized_cookie_store = True return self - def set_emulated_media(self, features, media=""): + def set_emulated_media(self, features: list[dict[str, str]], media: str = "") -> object: """ This is used to emulate different media features (e.g. color scheme) """ - return self.driver.execute_cdp_cmd( + return getattr(self.driver, "execute_cdp_cmd")( "Emulation.setEmulatedMedia", {"media": media, "features": features} ) - def get_content_height(self): + def get_content_height(self) -> int: """ Get height of current DOM contents Adapted from https://stackoverflow.com/questions/41721734/take-screenshot-of-full-page-with-selenium-python-with-chromedriver/52572919#52572919 """ - return self.driver.execute_script("return document.body.parentNode.scrollHeight") + # justification: the script returns the integer scrollHeight property. + return cast( + "int", self.driver.execute_script("return document.body.parentNode.scrollHeight") + ) - def set_window_size(self, width=None, height=None, fit_content=False): + def set_window_size( + self, + width: int | None = None, + height: int | None = None, + fit_content: bool = False, + ) -> ViewportSize: """ Sets the window size. @@ -112,7 +131,9 @@ def set_window_size(self, width=None, height=None, fit_content=False): "current": {"width": width, "height": height}, } - def set_viewport(self, width, height, fit_content): + def set_viewport( + self, width: int | None, height: int | None, fit_content: bool + ) -> Iterator[ViewportSize]: size = self.set_window_size(width, height, fit_content) try: yield size @@ -121,14 +142,25 @@ def set_viewport(self, width, height, fit_content): self.set_window_size(size["previous"]["width"], size["previous"]["height"]) @contextmanager - def full_viewport(self, width=None, height=None, fit_content=True): + def full_viewport( + self, + width: int | None = None, + height: int | None = None, + fit_content: bool = True, + ) -> Iterator[ViewportSize]: return self.set_viewport(width, height, fit_content) @contextmanager - def mobile_viewport(self, width=375, height=812): + def mobile_viewport(self, width: int = 375, height: int = 812) -> Iterator[ViewportSize]: return self.set_viewport(width, height, fit_content=True) - def element(self, selector=None, xpath=None): + def find_element(self, by: str = By.ID, value: str | None = None) -> WebElement: + return self.driver.find_element(by=by, value=value) + + def find_elements(self, by: str = By.ID, value: str | None = None) -> list[WebElement]: + return self.driver.find_elements(by=by, value=value) + + def element(self, selector: str | None = None, xpath: str | None = None) -> WebElement: """ Get an element from the page. This method will wait for the element to show up. """ @@ -140,7 +172,7 @@ def element(self, selector=None, xpath=None): self.wait_until(selector) return self.driver.find_element(by=By.CSS_SELECTOR, value=selector) - def elements(self, selector=None, xpath=None): + def elements(self, selector: str | None = None, xpath: str | None = None) -> list[WebElement]: """ Get elements from the page. This method will wait for the element to show up. """ @@ -152,7 +184,7 @@ def elements(self, selector=None, xpath=None): self.wait_until(selector) return self.driver.find_elements(by=By.CSS_SELECTOR, value=selector) - def element_exists(self, selector=None, xpath=None): + def element_exists(self, selector: str | None = None, xpath: str | None = None) -> bool: """ Check if an element exists on the page. This method will *not* wait for the element. """ @@ -165,24 +197,26 @@ def element_exists(self, selector=None, xpath=None): return False return True - def element_exists_by_test_id(self, test_id): + def element_exists_by_test_id(self, test_id: str) -> bool: """ Check if an element exists on the page using a data-test-id attribute. This method will not wait for the element. """ return self.element_exists('[data-test-id="%s"]' % (test_id)) - def element_exists_by_aria_label(self, label): + def element_exists_by_aria_label(self, label: str) -> bool: """ Check if an element exists on the page using the aria-label attribute. This method will not wait for the element. """ return self.element_exists('[aria-label="%s"]' % (label)) - def click(self, selector=None, xpath=None): + def click(self, selector: str | None = None, xpath: str | None = None) -> None: self.element(selector, xpath=xpath).click() - def click_when_visible(self, selector=None, xpath=None, timeout=3): + def click_when_visible( + self, selector: str | None = None, xpath: str | None = None, timeout: int = 3 + ) -> Browser: """ Waits until ``selector`` is available to be clicked before attempting to click """ @@ -194,10 +228,10 @@ def click_when_visible(self, selector=None, xpath=None, timeout=3): return self - def find_element_by_name(self, name): + def find_element_by_name(self, name: str) -> WebElement: return self.driver.find_element(by=By.NAME, value=name) - def move_to(self, selector=None): + def move_to(self, selector: str | None = None) -> Browser: """ Mouse move to ``selector`` """ @@ -209,7 +243,9 @@ def move_to(self, selector=None): return self - def wait_until_clickable(self, selector=None, xpath=None, timeout=10): + def wait_until_clickable( + self, selector: str | None = None, xpath: str | None = None, timeout: int = 10 + ) -> Browser: """ Waits until ``selector`` is visible and enabled to be clicked, or until ``timeout`` is hit, whichever happens first. @@ -224,7 +260,13 @@ def wait_until_clickable(self, selector=None, xpath=None, timeout=10): return self - def wait_until(self, selector=None, xpath=None, title=None, timeout=10): + def wait_until( + self, + selector: str | None = None, + xpath: str | None = None, + title: str | None = None, + timeout: int = 10, + ) -> Browser: """ Waits until ``selector`` is found in the browser, or until ``timeout`` is hit, whichever happens first. @@ -241,10 +283,16 @@ def wait_until(self, selector=None, xpath=None, title=None, timeout=10): return self - def wait_until_test_id(self, test_id): + def wait_until_test_id(self, test_id: str) -> Browser: return self.wait_until('[data-test-id="%s"]' % (test_id)) - def wait_until_not(self, selector=None, xpath=None, title=None, timeout=10): + def wait_until_not( + self, + selector: str | None = None, + xpath: str | None = None, + title: str | None = None, + timeout: int = 10, + ) -> Browser: """ Waits until ``selector`` is NOT found in the browser, or until ``timeout`` is hit, whichever happens first. @@ -263,7 +311,7 @@ def wait_until_not(self, selector=None, xpath=None, title=None, timeout=10): return self - def wait_until_script_execution(self, script, timeout=10): + def wait_until_script_execution(self, script: str, timeout: int = 10) -> Browser: """ Waits until ``script`` executes and evaluates truthy, or until ``timeout`` is hit, whichever happens first. @@ -273,23 +321,23 @@ def wait_until_script_execution(self, script, timeout=10): return self - def wait_for_images_loaded(self, timeout=10): + def wait_for_images_loaded(self, timeout: int = 10) -> Browser: return self.wait_until_script_execution( """return Object.values(document.querySelectorAll('img')).map(el => el.complete).every(i => i)""", timeout, ) - def wait_for_fonts_loaded(self, timeout=10): + def wait_for_fonts_loaded(self, timeout: int = 10) -> Browser: return self.wait_until_script_execution( """return document.fonts.status === 'loaded'""", timeout, ) @property - def switch_to(self): + def switch_to(self) -> SwitchTo: return self.driver.switch_to - def implicitly_wait(self, duration): + def implicitly_wait(self, duration: float) -> None: """ An implicit wait tells WebDriver to poll the DOM for a certain amount of time when trying to find any element (or elements) not immediately @@ -298,39 +346,44 @@ def implicitly_wait(self, duration): """ self.driver.implicitly_wait(duration) - def get_local_storage_items(self): + def get_local_storage_items(self) -> dict[str, str]: """ Retrieve all items in local storage """ - return self.driver.execute_script( - "Object.fromEntries(Object.entries(window.localStorage));" + # justification: the script returns the string-valued entries of localStorage. + return cast( + "dict[str, str]", + self.driver.execute_script("Object.fromEntries(Object.entries(window.localStorage));"), ) - def get_local_storage_item(self, key): + def get_local_storage_item(self, key: str) -> str | None: """ Retrieve key from local storage, this will fail if you use single quotes in your keys. """ - return self.driver.execute_script(f"window.localStorage.getItem('{key}')") + # justification: the script returns the stored string or null. + return cast( + "str | None", self.driver.execute_script(f"window.localStorage.getItem('{key}')") + ) def save_cookie( self, - name, - value, - domain=None, - path="/", - expires="Tue, 20 Jun 2025 19:07:44 GMT", - max_age=None, - secure=None, - ): + name: str, + value: str, + domain: str | None = None, + path: str = "/", + expires: str = "Tue, 20 Jun 2025 19:07:44 GMT", + max_age: int | None = None, + secure: bool | None = None, + ) -> None: domain = domain or self.domain # Recent changes to Chrome no longer allow us to explicitly set a cookie domain # to be localhost. If no domain is specified, the cookie will be created on # the host of the current url that the browser has visited. if domain == "localhost": domain = None - cookie = { + cookie: dict[str, Any] = { "name": name, "value": value, "expires": expires, @@ -355,7 +408,7 @@ def save_cookie( self.driver.add_cookie(cookie) -def pytest_addoption(parser): +def pytest_addoption(parser: pytest.Parser) -> None: parser.addini("selenium_driver", help="selenium driver (chrome, or firefox)") group = parser.getgroup("selenium", "selenium") @@ -376,7 +429,7 @@ def pytest_addoption(parser): group._addoption("--slow-network", dest="slow_network", help="slow the network (chrome)") -def pytest_configure(config): +def pytest_configure(config: pytest.Config) -> None: if hasattr(config, "workerinput"): return # xdist worker @@ -388,7 +441,7 @@ def pytest_configure(config): @TimedRetryPolicy.wrap(timeout=15, exceptions=(WebDriverException,), log_original_error=True) -def start_chrome(**chrome_args): +def start_chrome(**chrome_args: Any) -> webdriver.Chrome | None: try: return webdriver.Chrome(**chrome_args) except SessionNotCreatedException as e: @@ -399,6 +452,7 @@ def start_chrome(**chrome_args): brew upgrade --cask chromedriver """ ) + return None def _launch_chrome(config: pytest.Config) -> webdriver.Chrome: @@ -420,6 +474,7 @@ def _launch_chrome(config: pytest.Config) -> webdriver.Chrome: chrome_args["service"] = ChromeService(executable_path=chromedriver_path) driver = start_chrome(**chrome_args) + assert driver is not None if config.getoption("slow_network"): driver.set_network_conditions( offline=False, @@ -429,12 +484,15 @@ def _launch_chrome(config: pytest.Config) -> webdriver.Chrome: return driver -class _Background: +_BackgroundResult = TypeVar("_BackgroundResult") + + +class _Background(Generic[_BackgroundResult]): """Runs a function on a thread and hands its result back when joined.""" - def __init__(self, name: str, fn: Callable[[], Any]) -> None: + def __init__(self, name: str, fn: Callable[[], _BackgroundResult]) -> None: self._fn = fn - self._result: Any = None + self._result: _BackgroundResult | None = None self._error: BaseException | None = None self._thread = threading.Thread(target=self._run, name=name, daemon=True) self._thread.start() @@ -445,7 +503,7 @@ def _run(self) -> None: except BaseException as e: self._error = e - def join(self) -> Any: + def join(self) -> _BackgroundResult | None: self._thread.join() if self._error is not None: raise self._error @@ -463,7 +521,7 @@ def _populate_url_resolver() -> None: # The first Chrome launch on a fresh worker and the URLconf import both take # seconds, and neither needs the test databases. Starting them once collection # finishes lets them overlap with the test database setup, which runs next. -_warmup: dict[str, _Background] = {} +_warmup: dict[str, _Background[webdriver.Chrome | None]] = {} @pytest.hookimpl(trylast=True) @@ -508,7 +566,7 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: @pytest.fixture(scope="function") -def browser(request, live_server): +def browser(request: pytest.FixtureRequest, live_server: LiveServer) -> Generator[WebDriver]: window_size = request.config.getoption("window_size") window_width, window_height = map(int, window_size.split("x", 1)) @@ -558,7 +616,10 @@ def browser(request, live_server): @pytest.hookimpl(tryfirst=True, hookwrapper=True) -def pytest_runtest_makereport(item, call): +def pytest_runtest_makereport( + item: pytest.Item, call: pytest.CallInfo[None] +) -> Generator[None, Any]: + # justification: pluggy's hookwrapper outcome has no public generic type here. outcome = yield report = outcome.get_result() summary: MutableSequence[str] = [] @@ -574,7 +635,13 @@ def pytest_runtest_makereport(item, call): report.extra = extra -def _gather_url(item, report, driver, summary, extra): +def _gather_url( + item: pytest.Item, + report: pytest.TestReport, + driver: WebDriver, + summary: MutableSequence[str], + extra: list[object], +) -> None: try: url = driver.current_url except Exception as e: @@ -587,7 +654,13 @@ def _gather_url(item, report, driver, summary, extra): summary.append(f"URL: {url}") -def _gather_screenshot(item, report, driver, summary, extra): +def _gather_screenshot( + item: pytest.Item, + report: pytest.TestReport, + driver: WebDriver, + summary: MutableSequence[str], + extra: list[object], +) -> None: try: screenshot = driver.get_screenshot_as_base64() except Exception as e: @@ -599,7 +672,13 @@ def _gather_screenshot(item, report, driver, summary, extra): extra.append(pytest_html.extras.image(screenshot, "Screenshot")) -def _gather_html(item, report, driver, summary, extra): +def _gather_html( + item: pytest.Item, + report: pytest.TestReport, + driver: WebDriver, + summary: MutableSequence[str], + extra: list[object], +) -> None: try: html = driver.page_source.encode("utf-8") except Exception as e: @@ -611,7 +690,13 @@ def _gather_html(item, report, driver, summary, extra): extra.append(pytest_html.extras.text(html, "HTML")) -def _gather_logs(item, report, driver, summary, extra): +def _gather_logs( + item: pytest.Item, + report: pytest.TestReport, + driver: WebDriver, + summary: MutableSequence[str], + extra: list[object], +) -> None: try: types = driver.log_types except Exception as e: @@ -629,7 +714,8 @@ def _gather_logs(item, report, driver, summary, extra): extra.append(pytest_html.extras.text(format_log(log), "%s Log" % name.title())) -def format_log(log): +def format_log(log: Sequence[Mapping[str, Any]]) -> bytes: + # justification: Selenium returns untyped per-browser log entry mappings. timestamp_format = "%Y-%m-%d %H:%M:%S.%f" entries = [ "{0} {1[level]} - {1[message]}".format( @@ -637,6 +723,4 @@ def format_log(log): ).rstrip() for entry in log ] - log = "\n".join(entries) - log = log.encode("utf-8") - return log + return "\n".join(entries).encode("utf-8") diff --git a/src/sentry/testutils/pytest/sentry.py b/src/sentry/testutils/pytest/sentry.py index d219031756e0..6a14bebd7548 100644 --- a/src/sentry/testutils/pytest/sentry.py +++ b/src/sentry/testutils/pytest/sentry.py @@ -159,7 +159,7 @@ def _forbid_single_host_flushall() -> None: flushall = Redis.flushall - def guarded_flushall(self: Redis, *args: Any, **kwargs: Any) -> Any: + def guarded_flushall(self: Redis[str], *args: Any, **kwargs: Any) -> Any: # Prefixed cluster clients only delete the keys of this worker. if isinstance(self, RedisCluster): return flushall(self, *args, **kwargs) diff --git a/src/sentry/testutils/pytest/stale_database_reads.py b/src/sentry/testutils/pytest/stale_database_reads.py index 721479ecfd6c..82cf510f67d5 100644 --- a/src/sentry/testutils/pytest/stale_database_reads.py +++ b/src/sentry/testutils/pytest/stale_database_reads.py @@ -1,4 +1,5 @@ import dataclasses +from collections.abc import Generator from threading import local from typing import Any from unittest import mock @@ -16,12 +17,12 @@ class StaleDatabaseReads: model_signal_handlers: list[tuple[Any, Any]] transaction_blocks: list[Any] - def clear(self): + def clear(self) -> None: self.model_signal_handlers.clear() self.transaction_blocks.clear() -def _raise_reports(reports: StaleDatabaseReads): +def _raise_reports(reports: StaleDatabaseReads) -> None: if not reports.model_signal_handlers and not reports.transaction_blocks: return @@ -88,14 +89,14 @@ def test_very_special(stale_database_reads): @pytest.fixture(autouse=True) -def stale_database_reads(): +def stale_database_reads() -> Generator[StaleDatabaseReads]: from taskbroker_client.task import Task _state = local() old_send = ModelSignal.send - def send(self, sender, **named): + def send(self: ModelSignal, sender: object, **named: Any) -> Any: _state.in_signal_sender = getattr(sender, "__name__", repr(sender)) try: return old_send(self, sender, **named) @@ -105,7 +106,7 @@ def send(self, sender, **named): old_on_commit = transaction.on_commit # Needs to be hooked for autocommit - def on_commit(*args, **kwargs): + def on_commit(*args: Any, **kwargs: Any) -> Any: _state.in_on_commit = True try: return old_on_commit(*args, **kwargs) @@ -114,7 +115,7 @@ def on_commit(*args, **kwargs): old_atomic = transaction.atomic - def atomic(*args, **kwargs): + def atomic(*args: Any, **kwargs: Any) -> Any: _state.in_atomic = True try: return old_atomic(*args, **kwargs) @@ -124,10 +125,10 @@ def atomic(*args, **kwargs): old_run_and_clear_commit_hooks = BaseDatabaseWrapper.run_and_clear_commit_hooks # Needs to be hooked for running commit hooks inside of TransactionTestCase - def run_and_clear_commit_hooks(*args, **kwargs): + def run_and_clear_commit_hooks(self: BaseDatabaseWrapper, *args: Any, **kwargs: Any) -> Any: _state.in_run_and_clear_commit_hooks = True try: - return old_run_and_clear_commit_hooks(*args, **kwargs) + return old_run_and_clear_commit_hooks(self, *args, **kwargs) finally: _state.in_run_and_clear_commit_hooks = False @@ -135,7 +136,9 @@ def run_and_clear_commit_hooks(*args, **kwargs): old_signal_send = Task._signal_send - def signal_send(self, task, args=(), kwargs=(), **options): + def signal_send( + self: Task[Any, Any], task: Any, args: Any = (), kwargs: Any = (), **options: Any + ) -> Any: in_commit_hook = getattr(_state, "in_on_commit", None) or getattr( _state, "in_run_and_clear_commit_hooks", None ) diff --git a/src/sentry/testutils/pytest/transactional_flush.py b/src/sentry/testutils/pytest/transactional_flush.py index f19a11871510..962567be9f44 100644 --- a/src/sentry/testutils/pytest/transactional_flush.py +++ b/src/sentry/testutils/pytest/transactional_flush.py @@ -143,7 +143,8 @@ def _fixture_teardown(self: TransactionTestCase) -> None: or self.serialized_rollback or any(connections[db_name].vendor != "postgresql" for db_name in db_names) ): - return _original_fixture_teardown(self) + _original_fixture_teardown(self) + return for db_name in db_names: flush_database(db_name) diff --git a/src/sentry/testutils/pytest/unittest.py b/src/sentry/testutils/pytest/unittest.py index 4928255f87a8..a144070f3f2c 100644 --- a/src/sentry/testutils/pytest/unittest.py +++ b/src/sentry/testutils/pytest/unittest.py @@ -1,19 +1,35 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + import pytest +from django.contrib.sessions.backends.base import SessionBase + +if TYPE_CHECKING: + from sentry.models.activity import Activity + from sentry.models.environment import Environment + from sentry.models.group import Group + from sentry.models.organization import Organization + from sentry.models.project import Project + from sentry.models.projectkey import ProjectKey + from sentry.models.team import Team + from sentry.services.eventstore.models import GroupEvent + from sentry.users.models.user import User @pytest.fixture(scope="function") def factories_class( - request, - default_user, - default_organization, - default_team, - default_project, - default_projectkey, - default_environment, - default_group, - default_event, - default_activity, -): + request: pytest.FixtureRequest, + default_user: User, + default_organization: Organization, + default_team: Team, + default_project: Project, + default_projectkey: ProjectKey, + default_environment: Environment, + default_group: Group, + default_event: GroupEvent, + default_activity: Activity, +) -> None: # set a class attribute on the invoking test context request.cls.user = default_user request.cls.organization = default_organization @@ -27,5 +43,5 @@ def factories_class( @pytest.fixture(scope="function") -def session_class(request, session): +def session_class(request: pytest.FixtureRequest, session: SessionBase) -> None: request.cls.session = session diff --git a/src/sentry/testutils/relay.py b/src/sentry/testutils/relay.py index 54641d290f33..365c4700dd8e 100644 --- a/src/sentry/testutils/relay.py +++ b/src/sentry/testutils/relay.py @@ -1,14 +1,19 @@ -from typing import TYPE_CHECKING, Any +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from typing import IO, TYPE_CHECKING, Any, Protocol, cast import pytest import requests import responses +from django.conf import LazySettings from sentry.models.eventattachment import PendingEventAttachment from sentry.services import eventstore from sentry.testutils.helpers import get_auth_header if TYPE_CHECKING: + from sentry.services.eventstore.models import Event from sentry.testutils.cases import TestCase RequiredBaseclass = TestCase @@ -16,6 +21,21 @@ RequiredBaseclass = object +class _IngestWaiter(Protocol): + def __call__[T]( + self, predicate: Callable[[], T | None], timeout: float | None = None + ) -> T | None: ... + + +def _get_event(project_id: int, event_id: str) -> Event | None: + return eventstore.backend.get_event_by_id( + project_id, + event_id, + tenant_ids={"referrer": "relay-test", "organization_id": 123}, + skip_transaction_groupevent=True, + ) + + class RelayStoreHelper(RequiredBaseclass): """ Tests that post to the store entry point should use this helper class @@ -30,12 +50,14 @@ class RelayStoreHelper(RequiredBaseclass): get_relay_store_url: Any auth_header: Any get_relay_security_url: Any - wait_for_ingest_consumer: Any + wait_for_ingest_consumer: _IngestWaiter get_relay_attachments_url: Any get_relay_minidump_url: Any get_relay_unreal_url: Any - def post_and_retrieve_event(self, data, headers=None): + def post_and_retrieve_event( + self, data: object, headers: Mapping[str, str] | None = None + ) -> Event: if headers is None: headers = {"x-sentry-auth": self.auth_header, "content-type": "application/json"} url = self.get_relay_store_url(self.project.id) @@ -50,18 +72,12 @@ def post_and_retrieve_event(self, data, headers=None): resp_body = resp.json() event_id = resp_body["id"] - event = self.wait_for_ingest_consumer( - lambda: eventstore.backend.get_event_by_id( - self.project.id, - event_id, - tenant_ids={"referrer": "relay-test", "organization_id": 123}, - ) - ) + event = self.wait_for_ingest_consumer(lambda: _get_event(self.project.id, event_id)) # check that we found it in Snuba assert event is not None return event - def post_and_retrieve_security_report(self, data): + def post_and_retrieve_security_report(self, data: object) -> Event: url = self.get_relay_security_url(self.project.id, self.projectkey.public_key) responses.add_passthru(url) @@ -73,14 +89,16 @@ def post_and_retrieve_security_report(self, data): ) } - def has_new_event(): + def has_new_event() -> Event | None: # Hack: security report endpoint does not return event ID - for event in eventstore.backend.get_events( + events: Iterable[Event] = eventstore.backend.get_events( eventstore.Filter(project_ids=[self.project.id]), tenant_ids={"referrer": "relay-test", "organization_id": 123}, - ): + ) + for event in events: if event.event_id not in event_ids: return event + return None resp = requests.post(url, json=data) @@ -91,13 +109,19 @@ def has_new_event(): assert event return event - def post_and_try_retrieve_event(self, data, headers=None): + def post_and_try_retrieve_event( + self, data: object, headers: Mapping[str, str] | None = None + ) -> Event | None: try: return self.post_and_retrieve_event(data, headers) except AssertionError: return None - def post_and_retrieve_attachment(self, event_id, files): + def post_and_retrieve_attachment( + self, + event_id: str, + files: Mapping[str, IO[bytes] | tuple[str, IO[bytes]]], + ) -> None: url = self.get_relay_attachments_url(self.project.id, event_id) responses.add_passthru(url) @@ -116,7 +140,11 @@ def post_and_retrieve_attachment(self, event_id, files): assert exists - def post_and_retrieve_minidump(self, files, data): + def post_and_retrieve_minidump( + self, + files: Mapping[str, IO[bytes] | tuple[str, IO[bytes]]] | None, + data: Mapping[str, object] | None, + ) -> Event: url = self.get_relay_minidump_url(self.project.id, self.projectkey.public_key) responses.add_passthru(url) @@ -129,18 +157,12 @@ def post_and_retrieve_minidump(self, files, data): assert resp.ok event_id = resp.text.strip().replace("-", "") - event = self.wait_for_ingest_consumer( - lambda: eventstore.backend.get_event_by_id( - self.project.id, - event_id, - tenant_ids={"referrer": "relay-test", "organization_id": 123}, - ) - ) + event = self.wait_for_ingest_consumer(lambda: _get_event(self.project.id, event_id)) # check that we found it in Snuba assert event is not None return event - def post_and_retrieve_unreal(self, payload): + def post_and_retrieve_unreal(self, payload: bytes) -> Event: url = self.get_relay_unreal_url(self.project.id, self.projectkey.public_key) responses.add_passthru(url) @@ -152,13 +174,7 @@ def post_and_retrieve_unreal(self, payload): assert resp.ok event_id = resp.text.strip().replace("-", "") - event = self.wait_for_ingest_consumer( - lambda: eventstore.backend.get_event_by_id( - self.project.id, - event_id, - tenant_ids={"referrer": "relay-test", "organization_id": 123}, - ) - ) + event = self.wait_for_ingest_consumer(lambda: _get_event(self.project.id, event_id)) # check that we found it in Snuba assert event is not None return event @@ -166,14 +182,14 @@ def post_and_retrieve_unreal(self, payload): @pytest.fixture(autouse=True) def relay_setup_fixtures( self, - settings, - get_relay_store_url, - get_relay_minidump_url, - get_relay_unreal_url, - get_relay_security_url, - get_relay_attachments_url, - wait_for_ingest_consumer, - ): + settings: LazySettings, + get_relay_store_url: Callable[..., str], + get_relay_minidump_url: Callable[..., str], + get_relay_unreal_url: Callable[..., str], + get_relay_security_url: Callable[..., str], + get_relay_attachments_url: Callable[..., str], + wait_for_ingest_consumer: Callable[..., Callable[..., object | None]], + ) -> None: self.auth_header = get_auth_header( "TEST_USER_AGENT/0.0.0", self.projectkey.public_key, @@ -186,4 +202,5 @@ def relay_setup_fixtures( self.get_relay_unreal_url = get_relay_unreal_url self.get_relay_security_url = get_relay_security_url self.get_relay_attachments_url = get_relay_attachments_url - self.wait_for_ingest_consumer = wait_for_ingest_consumer(settings) + # justification: the pytest fixture factory returns an untyped callable. + self.wait_for_ingest_consumer = cast("_IngestWaiter", wait_for_ingest_consumer(settings)) diff --git a/src/sentry/testutils/requests.py b/src/sentry/testutils/requests.py index 7c9a3b4c4527..bae7ccc07b59 100644 --- a/src/sentry/testutils/requests.py +++ b/src/sentry/testutils/requests.py @@ -2,7 +2,7 @@ import functools from collections.abc import Callable -from typing import Optional +from typing import Optional, ParamSpec, TypeVar, cast from django.contrib.auth.models import AnonymousUser from django.core.cache import cache @@ -13,22 +13,31 @@ from sentry.auth.principal import set_request_principal from sentry.middleware.auth import AuthenticationMiddleware from sentry.middleware.placeholder import placeholder_get_response +from sentry.models.organization import Organization from sentry.testutils.factories import Factories from sentry.users.models.user import User from sentry.utils.auth import login -RequestFactory = Callable[[], Optional[tuple[HttpRequest, User]]] +RequestFactory = Callable[[], Optional[tuple[HttpRequest, User | AnonymousUser]]] +_P = ParamSpec("_P") +_R = TypeVar( + "_R", + bound=tuple[HttpRequest, User] | tuple[HttpRequest, AnonymousUser] | None, +) -def request_factory(f): + +def request_factory(f: Callable[_P, _R]) -> Callable[_P, _R]: @functools.wraps(f) - def wrapper(*args, **kwds) -> tuple[HttpRequest, User] | None: + def wrapper(*args: _P.args, **kwds: _P.kwargs) -> _R: result = f(*args, **kwds) if result is not None: request, user = result if not user.is_anonymous: login(request, user) - AuthenticationMiddleware(placeholder_get_response).process_request(request) + AuthenticationMiddleware(placeholder_get_response).process_request( + cast(Request, request) + ) else: set_request_principal(request, user, None) cache.clear() @@ -49,7 +58,7 @@ def make_request() -> tuple[HttpRequest, AnonymousUser]: @request_factory -def make_user_request(org=None) -> tuple[HttpRequest, User]: +def make_user_request(org: Organization | None = None) -> tuple[HttpRequest, User]: request, _ = make_request() user = Factories.create_user() org = org or Factories.create_organization() @@ -60,7 +69,7 @@ def make_user_request(org=None) -> tuple[HttpRequest, User]: @request_factory -def make_user_request_from_org(org=None): +def make_user_request_from_org(org: Organization | None = None) -> tuple[HttpRequest, User]: org = org or Factories.create_organization() request, user = make_user_request(org) request.session["activeorg"] = org.slug diff --git a/src/sentry/testutils/silo.py b/src/sentry/testutils/silo.py index 2610bce50a7e..244e790b0943 100644 --- a/src/sentry/testutils/silo.py +++ b/src/sentry/testutils/silo.py @@ -34,7 +34,7 @@ SENTRY_USE_MONOLITH_DBS = os.environ.get("SENTRY_USE_MONOLITH_DBS", "0") == "1" -def monkey_patch_single_process_silo_mode_state(): +def monkey_patch_single_process_silo_mode_state() -> None: _silo_mode_var: contextvars.ContextVar[SiloMode | None] = contextvars.ContextVar( "silo_mode", default=None ) @@ -197,7 +197,7 @@ def __post_init__(self) -> None: raise ValueError("localities can only be passed along with cells") @contextmanager - def test_config(self, silo_mode: SiloMode): + def test_config(self, silo_mode: SiloMode) -> Generator[None]: with ( ( override_cells(self.cells, localities=self.localities or None) @@ -316,7 +316,11 @@ def replacement_test_method(*args: Any, **kwargs: Any) -> None: new_sig = orig_sig.replace(parameters=new_params) new_test_method.__setattr__("__signature__", new_sig) - return pytest.mark.parametrize("silo_mode", sorted(self.silo_modes))(new_test_method) + # justification: pytest.mark.parametrize returns a decorator for test methods. + return cast( + "Callable[..., None]", + pytest.mark.parametrize("silo_mode", sorted(self.silo_modes))(new_test_method), + ) def apply(self, decorated_obj: Any) -> Any: is_test_case_class = isinstance(decorated_obj, type) and issubclass(decorated_obj, TestCase) @@ -447,14 +451,14 @@ def unpack_modes() -> Iterable[SiloMode]: yield -def protected_table(table: str, operation: str) -> re.Pattern: +def protected_table(table: str, operation: str) -> re.Pattern[str]: return re.compile(f'{operation}[^"]+"{table}"', re.IGNORECASE) -_protected_operations: list[re.Pattern] = [] +_protected_operations: list[re.Pattern[str]] = [] -def get_protected_operations() -> list[re.Pattern]: +def get_protected_operations() -> list[re.Pattern[str]]: from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey from sentry.hybridcloud.outbox.base import ReplicatedCellModel, ReplicatedControlModel @@ -605,7 +609,7 @@ def validate_no_cross_silo_deletions( for model_class in iter_models(app_name): if not hasattr(model_class._meta, "silo_limit"): continue - deletion_task: BaseDeletionTask = deletions.get(model=model_class, query={}) + deletion_task: BaseDeletionTask[Any] = deletions.get(model=model_class, query={}) for relation in deletion_task.get_child_relations( model_class(**instantiation_params.get(model_class, {})) ): @@ -644,7 +648,7 @@ def validate_relation_does_not_cross_silo_foreign_keys( ) -def validate_hcfk_has_global_id(model: type[Model], related_model: type[Model]): +def validate_hcfk_has_global_id(model: type[Model], related_model: type[Model]) -> None: # HybridCloudForeignKey can point to region models if they have snowflake ids if uses_snowflake_id(related_model): return diff --git a/src/sentry/toolbar/views/iframe_view.py b/src/sentry/toolbar/views/iframe_view.py index cfea56de7eb5..7be6d8d51f5d 100644 --- a/src/sentry/toolbar/views/iframe_view.py +++ b/src/sentry/toolbar/views/iframe_view.py @@ -13,7 +13,7 @@ TEMPLATE = "sentry/toolbar/iframe.html" -def _get_referrer(request) -> str | None: +def _get_referrer(request: HttpRequest) -> str | None: # 1 R is because of legacy http reasons: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer return request.META.get("HTTP_REFERER") @@ -30,17 +30,37 @@ def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpRespo def handle_disabled_member(self, organization: Organization) -> HttpResponse: return self._respond_with_state("logged-out") - def handle_not_2fa_compliant(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def handle_not_2fa_compliant( + self, + request: HttpRequest, + *args: Any, # justification: Django view arguments are variadic. + **kwargs: Any, # justification: Django view arguments are variadic. + ) -> HttpResponse: return self._respond_with_state("logged-out") - def handle_auth_required(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def handle_auth_required( + self, + request: HttpRequest, + *args: Any, # justification: Django view arguments are variadic. + **kwargs: Any, + ) -> HttpResponse: return self._respond_with_state("logged-out") - def handle_permission_required(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def handle_permission_required( + self, + request: HttpRequest, + *args: Any, # justification: Django view arguments are variadic. + **kwargs: Any, # justification: Django view arguments are variadic. + ) -> HttpResponse: return self._respond_with_state("missing-project") def get( - self, request: HttpRequest, organization: Organization, project: Project, *args, **kwargs + self, + request: HttpRequest, + organization: Organization, + project: Project, + *args: Any, # justification: Django view arguments are variadic. + **kwargs: Any, # justification: Django view arguments are variadic. ) -> HttpResponse: referrer = _get_referrer(request) or "" allowed_origins: list[str] = project.get_option("sentry:toolbar_allowed_origins") @@ -50,7 +70,7 @@ def get( return self._respond_with_state("invalid-domain") - def _respond_with_state(self, state: str): + def _respond_with_state(self, state: str) -> HttpResponse: response = self.respond( TEMPLATE, status=200, # always return 200 so the html will render inside the iframe diff --git a/src/sentry/toolbar/views/login_success_view.py b/src/sentry/toolbar/views/login_success_view.py index cbde54d7861a..c003afdf9336 100644 --- a/src/sentry/toolbar/views/login_success_view.py +++ b/src/sentry/toolbar/views/login_success_view.py @@ -1,6 +1,7 @@ from django.conf import settings -from django.http import HttpRequest +from django.http import HttpRequest, HttpResponse +from sentry.models.organization import Organization from sentry.web.frontend.base import OrganizationView, cell_silo_view TEMPLATE = "sentry/toolbar/login-success.html" @@ -11,7 +12,9 @@ @cell_silo_view class LoginSuccessView(OrganizationView): - def get(self, request: HttpRequest, organization, project_id_or_slug): + def get( + self, request: HttpRequest, organization: Organization, project_id_or_slug: str + ) -> HttpResponse: delay_ms = int(request.GET.get("delay") or 3000) return self.respond( TEMPLATE, diff --git a/src/sentry/tsdb/base.py b/src/sentry/tsdb/base.py index b923e31860c0..8ad193813c1d 100644 --- a/src/sentry/tsdb/base.py +++ b/src/sentry/tsdb/base.py @@ -1,4 +1,4 @@ -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence from datetime import datetime, timedelta from enum import Enum from typing import Any, TypedDict, TypeVar @@ -283,7 +283,11 @@ def get_active_series( return rollups def make_series( - self, default, start: datetime, end: datetime | None = None, rollup: int | None = None + self, + default: int | Callable[[int], int], + start: datetime, + end: datetime | None = None, + rollup: int | None = None, ) -> list[tuple[int, int]]: f = default if callable(default) else lambda timestamp: default return [ @@ -404,7 +408,7 @@ def get_range( end: datetime, rollup: int | None = None, environment_ids: Sequence[int] | None = None, - conditions=None, + conditions: list[SnubaCondition] | None = None, use_cache: bool = False, jitter_value: int | None = None, tenant_ids: dict[str, str | int] | None = None, @@ -466,7 +470,7 @@ def get_sums_data( end: datetime, rollup: int | None = None, environment_ids: Sequence[int] | None = None, - conditions=None, + conditions: list[SnubaCondition] | None = None, use_cache: bool = False, jitter_value: int | None = None, tenant_ids: dict[str, str | int] | None = None, diff --git a/src/sentry/tsdb/dummy.py b/src/sentry/tsdb/dummy.py index b622774412f8..afa580bdf770 100644 --- a/src/sentry/tsdb/dummy.py +++ b/src/sentry/tsdb/dummy.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Any -from sentry.tsdb.base import BaseTSDB, TSDBItem, TSDBKey, TSDBModel +from sentry.tsdb.base import BaseTSDB, SnubaCondition, TSDBItem, TSDBKey, TSDBModel def _environment_ids(environment_ids: Iterable[int | None] | None) -> set[int | None]: @@ -16,13 +16,35 @@ class DummyTSDB(BaseTSDB): A no-op time-series storage. """ - def incr(self, model, key: TSDBKey, timestamp=None, count=1, environment_id=None): + def incr( + self, + model: TSDBModel, + key: TSDBKey, + timestamp: datetime | None = None, + count: int = 1, + environment_id: int | None = None, + ) -> None: self.validate_arguments([model], [environment_id]) - def merge(self, model, destination, sources, timestamp=None, environment_ids=None): + def merge( + self, + model: TSDBModel, + destination: int, + sources: list[int], + timestamp: datetime | None = None, + environment_ids: Iterable[int] | None = None, + ) -> None: self.validate_arguments([model], _environment_ids(environment_ids)) - def delete(self, models, keys, start=None, end=None, timestamp=None, environment_ids=None): + def delete( + self, + models: list[TSDBModel], + keys: list[int], + start: datetime | None = None, + end: datetime | None = None, + timestamp: datetime | None = None, + environment_ids: Iterable[int | None] | None = None, + ) -> None: self.validate_arguments(models, _environment_ids(environment_ids)) def get_range( @@ -33,7 +55,7 @@ def get_range( end: datetime, rollup: int | None = None, environment_ids: Sequence[int] | None = None, - conditions=None, + conditions: list[SnubaCondition] | None = None, use_cache: bool = False, jitter_value: int | None = None, tenant_ids: dict[str, str | int] | None = None, @@ -46,7 +68,14 @@ def get_range( _, series = self.get_optimal_rollup_series(start, end, rollup) return {k: [(ts, 0) for ts in series] for k in keys} - def record(self, model, key, values, timestamp=None, environment_id=None): + def record( + self, + model: TSDBModel, + key: int, + values: Iterable[str], + timestamp: datetime | None = None, + environment_id: int | None = None, + ) -> None: self.validate_arguments([model], [environment_id]) def get_distinct_counts_series( @@ -66,31 +95,42 @@ def get_distinct_counts_series( def get_distinct_counts_totals( self, - model, + model: TSDBModel, keys: Sequence[TSDBKey], - start, - end=None, - rollup=None, - environment_id=None, - use_cache=False, - jitter_value=None, - tenant_ids=None, - referrer_suffix=None, - conditions=None, + start: datetime, + end: datetime | None = None, + rollup: int | None = None, + environment_id: int | None = None, + use_cache: bool = False, + jitter_value: int | None = None, + tenant_ids: dict[str, int | str] | None = None, + referrer_suffix: str | None = None, + conditions: list[SnubaCondition] | None = None, group_on_time: bool = False, project_ids: Sequence[int] | None = None, - ): + ) -> Mapping[TSDBKey, int]: self.validate_arguments([model], [environment_id]) return {k: 0 for k in keys} def merge_distinct_counts( - self, model, destination, sources, timestamp=None, environment_ids=None - ): + self, + model: TSDBModel, + destination: int, + sources: list[int], + timestamp: datetime | None = None, + environment_ids: Iterable[int] | None = None, + ) -> None: self.validate_arguments([model], _environment_ids(environment_ids)) def delete_distinct_counts( - self, models, keys, start=None, end=None, timestamp=None, environment_ids=None - ): + self, + models: list[TSDBModel], + keys: list[int], + start: datetime | None = None, + end: datetime | None = None, + timestamp: datetime | None = None, + environment_ids: Iterable[int] | None = None, + ) -> None: self.validate_arguments(models, _environment_ids(environment_ids)) def get_frequency_series( @@ -112,5 +152,5 @@ def get_frequency_series( for key, members in items.items() } - def flush(self): + def flush(self) -> None: pass diff --git a/src/sentry/tsdb/snuba.py b/src/sentry/tsdb/snuba.py index 94e57178501c..f1b23d1a44e4 100644 --- a/src/sentry/tsdb/snuba.py +++ b/src/sentry/tsdb/snuba.py @@ -3,10 +3,10 @@ import dataclasses import functools import itertools -from collections.abc import Mapping, Sequence, Set +from collections.abc import Iterable, Mapping, Sequence, Set from copy import deepcopy from datetime import datetime -from typing import Any +from typing import Any, cast from snuba_sdk import ( Column, @@ -31,7 +31,7 @@ from sentry.issues.grouptype import GroupCategory from sentry.issues.query import manual_group_on_time_aggregation from sentry.snuba.dataset import Dataset -from sentry.tsdb.base import TSDBItem, TSDBKey, TSDBModel +from sentry.tsdb.base import SnubaCondition, TSDBItem, TSDBKey, TSDBModel from sentry.tsdb.dummy import DummyTSDB from sentry.utils import outcomes, snuba from sentry.utils.dates import to_datetime @@ -45,6 +45,8 @@ LIMIT = 10000 +TSDBResult = dict[str | int, "TSDBResult | int"] + @dataclasses.dataclass class SnubaModelQuerySettings: @@ -215,10 +217,15 @@ class SnubaTSDB(DummyTSDB): ) ) - def __init__(self, **options): - super().__init__(**options) + def __init__( + self, + rollups: Iterable[tuple[int, int]] | None = None, + legacy_rollups: dict[int, int] | None = None, + **options: object, + ) -> None: + super().__init__(rollups=rollups, legacy_rollups=legacy_rollups, **options) - def __manual_group_on_time_aggregation(self, rollup, time_column_alias) -> list[Any]: + def __manual_group_on_time_aggregation(self, rollup: int, time_column_alias: str) -> list[Any]: """ Explicitly builds an aggregation expression in-place of using a `TimeSeriesProcessor` on the snuba entity. Older tables and queries that target that table had syntactic sugar on the `time` column and would apply @@ -226,7 +233,7 @@ def __manual_group_on_time_aggregation(self, rollup, time_column_alias) -> list[ we need to manually insert the equivalent query to get the same result. """ - def rollup_agg(rollup_granularity, alias): + def rollup_agg(rollup_granularity: int, alias: str) -> list[Any] | None: if rollup_granularity == 60: return ["toUnixTimestamp", [["toStartOfMinute", "timestamp"]], alias] elif rollup_granularity == 3600: @@ -254,21 +261,21 @@ def rollup_agg(rollup_granularity, alias): def get_data( self, - model, - keys, - start, - end, - rollup=None, - environment_ids=None, - aggregation="count()", - group_on_model=True, - group_on_time=False, - conditions=None, - use_cache=False, - jitter_value=None, + model: TSDBModel, + keys: Sequence[TSDBKey] | Set[TSDBKey] | Mapping[TSDBKey, Sequence[object]], + start: datetime, + end: datetime | None, + rollup: int | None = None, + environment_ids: Sequence[int] | None = None, + aggregation: str = "count()", + group_on_model: bool = True, + group_on_time: bool = False, + conditions: Sequence[object] | None = None, + use_cache: bool = False, + jitter_value: int | None = None, tenant_ids: dict[str, str | int] | None = None, referrer_suffix: str | None = None, - ): + ) -> TSDBResult: if model in self.non_outcomes_snql_query_settings: # no way around having to explicitly map legacy condition format to SnQL since this function # is used everywhere that expects `conditions` to be legacy format @@ -276,7 +283,7 @@ def get_data( for cond in conditions or (): if not is_condition(cond): or_conditions = [] - for or_cond in cond: + for or_cond in cast("Sequence[SnubaCondition]", cond): or_conditions.append(parse_condition(or_cond)) if len(or_conditions) > 1: @@ -284,7 +291,7 @@ def get_data( else: parsed_conditions.extend(or_conditions) else: - parsed_conditions.append(parse_condition(cond)) + parsed_conditions.append(parse_condition(cast("SnubaCondition", cond))) return self.__get_data_snql( model, @@ -327,7 +334,7 @@ def get_data( def __get_data_snql( self, model: TSDBModel, - keys: Sequence | Set | Mapping, + keys: Sequence[TSDBKey] | Set[TSDBKey] | Mapping[TSDBKey, Sequence[object]], start: datetime, end: datetime | None, rollup: int | None = None, @@ -342,7 +349,7 @@ def __get_data_snql( is_grouprelease: bool = False, tenant_ids: dict[str, str | int] | None = None, referrer_suffix: str | None = None, - ): + ) -> TSDBResult: """ Similar to __get_data_legacy but uses the SnQL format. For future additions, prefer using this impl over the legacy format. @@ -416,9 +423,13 @@ def __get_data_snql( if model_query_settings.conditions is not None: conditions += model_query_settings.conditions - project_ids = infer_project_ids_from_related_models(keys_map) + project_ids = infer_project_ids_from_related_models( + cast("Mapping[str, Sequence[int]]", keys_map) + ) keys_map["project_id"] = project_ids - forward, reverse = get_snuba_translators(keys_map, is_grouprelease) + forward, reverse = get_snuba_translators( + cast("Mapping[str, Sequence[int]]", keys_map), is_grouprelease + ) # resolve filter_key values to the right values environment.id -> environment.name, etc. mapped_filter_conditions = [] @@ -479,7 +490,11 @@ def __get_data_snql( if group_on_time: keys_map["time"] = series - self.zerofill(result, groupby, keys_map) + self.zerofill( + cast("TSDBResult", result), + groupby, + cast("Mapping[str, Sequence[object] | None]", keys_map), + ) self.trim(result, groupby, keys) if group_on_time and manual_group_on_time: @@ -490,21 +505,21 @@ def __get_data_snql( def __get_data_legacy( self, - model, - keys, - start, - end, - rollup=None, - environment_ids=None, - aggregation="count()", - group_on_model=True, - group_on_time=False, - conditions=None, - use_cache=False, - jitter_value=None, - tenant_ids=None, - referrer_suffix=None, - ): + model: TSDBModel, + keys: Sequence[TSDBKey] | Set[TSDBKey] | Mapping[TSDBKey, Sequence[object]], + start: datetime, + end: datetime | None, + rollup: int | None = None, + environment_ids: Sequence[int] | None = None, + aggregation: str = "count()", + group_on_model: bool = True, + group_on_time: bool = False, + conditions: Sequence[object] | None = None, + use_cache: bool = False, + jitter_value: int | None = None, + tenant_ids: dict[str, str | int] | None = None, + referrer_suffix: str | None = None, + ) -> TSDBResult: """ Normalizes all the TSDB parameters and sends a query to snuba. @@ -517,7 +532,7 @@ def __get_data_legacy( TSDBModel.key_total_blacklisted, TSDBModel.key_total_rejected, ]: - keys = list(set(map(lambda x: int(x), keys))) + keys = cast("Sequence[TSDBKey]", list({int(x) for x in keys})) model_requires_manual_group_on_time = model in ( TSDBModel.group_generic, @@ -580,9 +595,9 @@ def __get_data_legacy( end = to_datetime(series[-1] + rollup) limit = min(LIMIT, int(len(keys) * ((end - start).total_seconds() / rollup))) - conditions = conditions if conditions is not None else [] + conditions_list = list(conditions) if conditions is not None else [] if model_query_settings.conditions is not None: - conditions += deepcopy(model_query_settings.conditions) + conditions_list += deepcopy(model_query_settings.conditions) # copy because we modify the conditions in snuba.query orderby = [] @@ -606,7 +621,7 @@ def __get_data_legacy( start=start, end=end, groupby=groupby, - conditions=conditions, + conditions=conditions_list, filter_keys=keys_map, aggregations=aggregations, rollup=rollup, @@ -633,17 +648,26 @@ def __get_data_legacy( else: keys_map[group_on_time_column_alias] = series - self.zerofill(result, groupby, keys_map) + self.zerofill( + cast("TSDBResult", result), + groupby, + cast("Mapping[str, Sequence[object] | None]", keys_map), + ) self.trim(result, groupby, keys) if group_on_time and model_requires_manual_group_on_time: # unroll aggregated data self.unnest(result, aggregated_as) - return result + return cast("dict[str | int, TSDBResult | int]", result) else: - return result + return cast("dict[str | int, TSDBResult | int]", result) - def zerofill(self, result, groups, flat_keys): + def zerofill( + self, + result: TSDBResult, + groups: Sequence[str], + flat_keys: Mapping[str, Sequence[object] | None], + ) -> None: """ Fills in missing keys in the nested result with zeroes. `result` is the nested result @@ -654,15 +678,17 @@ def zerofill(self, result, groups, flat_keys): if len(groups) > 0: group, subgroups = groups[0], groups[1:] # Zerofill missing keys - for k in flat_keys[group]: - if k not in result: - result[k] = 0 if len(groups) == 1 else {} + for k in flat_keys[group] or (): + key = cast("str | int", k) + if key not in result: + result[key] = 0 if len(groups) == 1 else {} if subgroups: for v in result.values(): - self.zerofill(v, subgroups, flat_keys) + if isinstance(v, dict): + self.zerofill(v, subgroups, flat_keys) - def trim(self, result, groups, keys): + def trim(self, result: object, groups: Sequence[str], keys: object) -> None: """ Similar to zerofill, but removes keys that should not exist. Uses the non-flattened version of keys, so that different sets @@ -670,17 +696,18 @@ def trim(self, result, groups, keys): """ if len(groups) > 0: group, subgroups = groups[0], groups[1:] + keys_map = cast("Mapping[object, object]", keys) if isinstance(result, dict): for rk in list(result.keys()): if group == "time": # Skip over time group self.trim(result[rk], subgroups, keys) - elif rk in keys: - if isinstance(keys, dict): - self.trim(result[rk], subgroups, keys[rk]) + elif rk in keys_map: + if isinstance(keys_map, dict): + self.trim(result[rk], subgroups, keys_map[rk]) else: del result[rk] - def unnest(self, result, aggregated_as): + def unnest(self, result: object, aggregated_as: str) -> None: """ Unnests the aggregated value in results and places it one level higher to conform to the proper result format @@ -715,7 +742,7 @@ def unnest(self, result, aggregated_as): else: self.unnest(val, aggregated_as) - def get_aggregate_function(self, model) -> str: + def get_aggregate_function(self, model: TSDBModel) -> str: model_query_settings = self.model_query_settings.get(model) assert model_query_settings is not None, f"Unsupported TSDBModel: {model.name}" @@ -733,7 +760,7 @@ def get_sums_data( end: datetime, rollup: int | None = None, environment_ids: Sequence[int] | None = None, - conditions=None, + conditions: list[SnubaCondition] | None = None, use_cache: bool = False, jitter_value: int | None = None, tenant_ids: dict[str, str | int] | None = None, @@ -746,20 +773,23 @@ def get_sums_data( if self._should_use_upsampled_aggregation(model, project_ids): aggregation = "upsampled_count" - result: Mapping[TSDBKey, int] = self.get_data( - model, - keys, - start, - end, - rollup, - environment_ids, - aggregation=aggregation, - group_on_time=group_on_time, - conditions=conditions, - use_cache=use_cache, - jitter_value=jitter_value, - tenant_ids=tenant_ids, - referrer_suffix=referrer_suffix, + result = cast( + "Mapping[TSDBKey, int]", + self.get_data( + model, + keys, + start, + end, + rollup, + environment_ids, + aggregation=aggregation, + group_on_time=group_on_time, + conditions=conditions, + use_cache=use_cache, + jitter_value=jitter_value, + tenant_ids=tenant_ids, + referrer_suffix=referrer_suffix, + ), ) return result @@ -791,7 +821,7 @@ def get_range( end: datetime, rollup: int | None = None, environment_ids: Sequence[int] | None = None, - conditions=None, + conditions: list[SnubaCondition] | None = None, use_cache: bool = False, jitter_value: int | None = None, tenant_ids: dict[str, str | int] | None = None, @@ -827,19 +857,22 @@ def get_range( # {group:{timestamp:count, ...}} # into # {group: [(timestamp, count), ...]} - return {k: sorted(result[k].items()) for k in result} + return cast( + "dict[TSDBKey, list[tuple[int, int]]]", + {k: sorted(cast("Mapping[int, int]", result[k]).items()) for k in result}, + ) def get_distinct_counts_series( self, - model, + model: TSDBModel, keys: Sequence[TSDBKey], - start, - end=None, - rollup=None, - environment_id=None, - tenant_ids=None, + start: datetime, + end: datetime | None = None, + rollup: int | None = None, + environment_id: int | None = None, + tenant_ids: dict[str, str | int] | None = None, project_ids: Sequence[int] | None = None, - ): + ) -> dict[TSDBKey, list[tuple[int, int]]]: result = self.get_data( model, keys, @@ -855,38 +888,44 @@ def get_distinct_counts_series( # {group:{timestamp:count, ...}} # into # {group: [(timestamp, count), ...]} - return {k: sorted(result[k].items()) for k in result} + return cast( + "dict[TSDBKey, list[tuple[int, int]]]", + {k: sorted(cast("Mapping[int, int]", result[k]).items()) for k in result}, + ) def get_distinct_counts_totals( self, - model, + model: TSDBModel, keys: Sequence[TSDBKey], - start, - end=None, - rollup=None, - environment_id=None, - use_cache=False, - jitter_value=None, - tenant_ids=None, - referrer_suffix=None, - conditions=None, + start: datetime, + end: datetime | None = None, + rollup: int | None = None, + environment_id: int | None = None, + use_cache: bool = False, + jitter_value: int | None = None, + tenant_ids: dict[str, str | int] | None = None, + referrer_suffix: str | None = None, + conditions: list[SnubaCondition] | None = None, group_on_time: bool = False, project_ids: Sequence[int] | None = None, ) -> Mapping[TSDBKey, int]: - return self.get_data( - model, - keys, - start, - end, - rollup, - [environment_id] if environment_id is not None else None, - aggregation="uniq", - use_cache=use_cache, - jitter_value=jitter_value, - tenant_ids=tenant_ids, - referrer_suffix=referrer_suffix, - conditions=conditions, - group_on_time=group_on_time, + return cast( + "Mapping[TSDBKey, int]", + self.get_data( + model, + keys, + start, + end, + rollup, + [environment_id] if environment_id is not None else None, + aggregation="uniq", + use_cache=use_cache, + jitter_value=jitter_value, + tenant_ids=tenant_ids, + referrer_suffix=referrer_suffix, + conditions=conditions, + group_on_time=group_on_time, + ), ) def get_frequency_series( @@ -915,9 +954,17 @@ def get_frequency_series( # {group:{timestamp:{agg:count}}} # into # {group: [(timestamp, {agg: count, ...}), ...]} - return {k: sorted(result[k].items()) for k in result} + return cast( + "dict[TSDBKey, list[tuple[float, dict[TSDBItem, float]]]]", + { + k: sorted(cast("Mapping[float, dict[TSDBItem, float]]", result[k]).items()) + for k in result + }, + ) - def flatten_keys(self, items: Mapping | Sequence | Set) -> tuple[list, Sequence | None]: + def flatten_keys( + self, items: Mapping[TSDBKey, Sequence[object]] | Sequence[object] | Set[object] + ) -> tuple[list[object], Sequence[object] | None]: """ Returns a normalized set of keys based on the various formats accepted by TSDB methods. The input is either just a plain list of keys for the diff --git a/src/sentry/types/actor.py b/src/sentry/types/actor.py index 3311c262e5bf..b08d05a538ca 100644 --- a/src/sentry/types/actor.py +++ b/src/sentry/types/actor.py @@ -1,7 +1,7 @@ from collections import defaultdict from collections.abc import Iterable, MutableMapping, Sequence from enum import Enum -from typing import TYPE_CHECKING, Any, Protocol, Union, overload +from typing import TYPE_CHECKING, Any, Literal, Protocol, Union, overload from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers @@ -39,10 +39,22 @@ class InvalidActor(ObjectDoesNotExist): pass + @overload + @classmethod + def resolve_many( + cls, actors: Sequence["Actor"], filter_none: Literal[True] = True + ) -> list["Team | RpcUser"]: ... + + @overload + @classmethod + def resolve_many( + cls, actors: Sequence["Actor"], filter_none: Literal[False] + ) -> list["Team | RpcUser | None"]: ... + @classmethod def resolve_many( cls, actors: Sequence["Actor"], filter_none: bool = True - ) -> list["Team | RpcUser | None"]: + ) -> Sequence["Team | RpcUser | None"]: """ Resolve a list of actors in a batch to the Team/User the Actor references. diff --git a/src/sentry/types/cell.py b/src/sentry/types/cell.py index fa98038484eb..3217f5a8ac91 100644 --- a/src/sentry/types/cell.py +++ b/src/sentry/types/cell.py @@ -52,6 +52,8 @@ def to_url(self, path: str) -> str: base_url = options.get("system.url-prefix") else: base_url = generate_locality_url(self.name) + if not isinstance(base_url, str): + raise TypeError("system.url-prefix is not configured") return urljoin(base_url, path) def api_serialize(self) -> dict[str, Any]: diff --git a/src/sentry/unmerge.py b/src/sentry/unmerge.py index 9a0eb91f09c7..bb80cdcc26ea 100644 --- a/src/sentry/unmerge.py +++ b/src/sentry/unmerge.py @@ -124,7 +124,7 @@ def get_activity_args(self) -> Mapping[str, Any]: return {"fingerprints": self.fingerprints} -_REPLACEMENT_TYPE_LABELS: BidirectionalMapping = BidirectionalMapping( +_REPLACEMENT_TYPE_LABELS: BidirectionalMapping[type[UnmergeReplacement]] = BidirectionalMapping( { PrimaryHashUnmergeReplacement: "primary_hash", } diff --git a/src/sentry/uptime/autodetect/ranking.py b/src/sentry/uptime/autodetect/ranking.py index 0ba746643923..720a017135d0 100644 --- a/src/sentry/uptime/autodetect/ranking.py +++ b/src/sentry/uptime/autodetect/ranking.py @@ -34,7 +34,7 @@ KEY_EXPIRY = ORGANIZATION_FLUSH_FREQUENCY * 2 -def add_base_url_to_rank(project: Project, base_url: str): +def add_base_url_to_rank(project: Project, base_url: str) -> None: """ Takes a project and valid base url and stores ranking information about it in Redis. @@ -102,7 +102,7 @@ def delete_candidate_projects_for_org(org: Organization) -> None: cluster.delete(key) -def get_candidate_urls_for_project(project: Project, limit=5) -> list[tuple[str, int]]: +def get_candidate_urls_for_project(project: Project, limit: int = 5) -> list[tuple[str, int]]: """ Gets all the candidate urls for a project. Returns a tuple of (url, times_url_seen). Urls are sorted by `times_url_seen` desc. @@ -182,4 +182,4 @@ def should_autodetect_for_organization(organization: Organization) -> bool: def should_autodetect_for_project(project: Project) -> bool: - return project.get_option("sentry:uptime_autodetection", True) + return bool(project.get_option("sentry:uptime_autodetection", True)) diff --git a/src/sentry/uptime/autodetect/tasks.py b/src/sentry/uptime/autodetect/tasks.py index 05f089e24d5b..ed808ad776fc 100644 --- a/src/sentry/uptime/autodetect/tasks.py +++ b/src/sentry/uptime/autodetect/tasks.py @@ -57,7 +57,7 @@ namespace=uptime_tasks, processing_deadline_duration=60, ) -def schedule_autodetections(): +def schedule_autodetections() -> None: """ Runs regularly and fires off a task for each autodetection bucket that needs to be run since this task last ran. @@ -97,7 +97,7 @@ def schedule_autodetections(): name="sentry.uptime.detectors.tasks.process_detection_bucket", namespace=uptime_tasks, ) -def process_autodetection_bucket(bucket: str): +def process_autodetection_bucket(bucket: str) -> None: """ Schedules url autodetection for all projects in this time bucket that saw promising urls. """ @@ -114,7 +114,7 @@ def process_autodetection_bucket(bucket: str): namespace=uptime_tasks, processing_deadline_duration=20, ) -def process_organization_url_ranking(organization_id: int): +def process_organization_url_ranking(organization_id: int) -> None: org = Organization.objects.get_from_cache(id=organization_id) logger.info( "uptime.process_organization", diff --git a/src/sentry/uptime/checker_api.py b/src/sentry/uptime/checker_api.py index 8f9fc37dc95b..e55ab2625301 100644 --- a/src/sentry/uptime/checker_api.py +++ b/src/sentry/uptime/checker_api.py @@ -1,15 +1,28 @@ import uuid +from collections.abc import Sequence +from typing import NotRequired, TypedDict import requests from sentry.conf.types.uptime import UptimeRegionConfig from sentry.uptime.models import UptimeRegionScheduleMode -from sentry.uptime.types import CheckConfig +from sentry.uptime.types import CheckConfig, RequestHeader, RequestMethod + + +class _PreviewCheckData(TypedDict): + url: str + timeout_ms: int | float + method: NotRequired[RequestMethod] + headers: NotRequired[Sequence[RequestHeader]] + body: NotRequired[str] + assertion: NotRequired[object] # Create a "preview check" that we send to an uptime checker to validate the config # (in particular, the assertion.) -def create_preview_check(validated_data, region: UptimeRegionConfig) -> CheckConfig: +def create_preview_check( + validated_data: _PreviewCheckData, region: UptimeRegionConfig +) -> CheckConfig: config: CheckConfig = { "subscription_id": uuid.UUID(int=0).hex, "url": validated_data.get("url"), @@ -21,17 +34,21 @@ def create_preview_check(validated_data, region: UptimeRegionConfig) -> CheckCon "region_schedule_mode": UptimeRegionScheduleMode.ROUND_ROBIN.value, } - if validated_data.get("method") is not None: - config["request_method"] = validated_data.get("method") + method = validated_data.get("method") + if method is not None: + config["request_method"] = method - if validated_data.get("headers") is not None: - config["request_headers"] = validated_data.get("headers") + headers = validated_data.get("headers") + if headers is not None: + config["request_headers"] = headers - if validated_data.get("body") is not None: - config["request_body"] = validated_data.get("body") + body = validated_data.get("body") + if body is not None: + config["request_body"] = body - if validated_data.get("assertion") is not None: - config["assertion"] = validated_data.get("assertion") + assertion = validated_data.get("assertion") + if assertion is not None: + config["assertion"] = assertion return config diff --git a/src/sentry/uptime/config_producer.py b/src/sentry/uptime/config_producer.py index ba4b0e16432d..e6cd7b722235 100644 --- a/src/sentry/uptime/config_producer.py +++ b/src/sentry/uptime/config_producer.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) -def produce_config(destination_region_slug: str, config: CheckConfig): +def produce_config(destination_region_slug: str, config: CheckConfig) -> None: jsonschema.validate(config, CHECK_CONFIG_SCHEMA) _send_to_redis( destination_region_slug, @@ -27,7 +27,7 @@ def produce_config(destination_region_slug: str, config: CheckConfig): ) -def produce_config_removal(destination_region_slug: str, subscription_id: str): +def produce_config_removal(destination_region_slug: str, subscription_id: str) -> None: _send_to_redis(destination_region_slug, UUID(subscription_id), None) @@ -60,7 +60,7 @@ def _send_to_redis( key = subscription_id.hex config_key, update_key = get_partition_keys(subscription_id, region_config) - cluster: RedisCluster | StrictRedis = redis.redis_clusters.get_binary( + cluster: RedisCluster[bytes] | StrictRedis[bytes] = redis.redis_clusters.get_binary( region_config.config_redis_cluster ) pipe = cluster.pipeline() diff --git a/src/sentry/uptime/consumers/eap_producer.py b/src/sentry/uptime/consumers/eap_producer.py index 6e858bbfe91a..668cc2f36b67 100644 --- a/src/sentry/uptime/consumers/eap_producer.py +++ b/src/sentry/uptime/consumers/eap_producer.py @@ -1,7 +1,7 @@ import logging from arroyo import Topic as ArroyoTopic -from arroyo.backends.kafka import KafkaPayload +from arroyo.backends.kafka import KafkaPayload, KafkaProducer from sentry_kafka_schemas.codecs import Codec from sentry_kafka_schemas.schema_types.uptime_results_v1 import CheckResult from sentry_protos.snuba.v1.trace_item_pb2 import TraceItem @@ -22,7 +22,9 @@ EAP_ITEMS_CODEC: Codec[TraceItem] = get_topic_codec(Topic.SNUBA_ITEMS) -def _get_eap_items_producer(name: str = "sentry.uptime.consumers.eap_producer"): +def _get_eap_items_producer( + name: str = "sentry.uptime.consumers.eap_producer", +) -> KafkaProducer: """Get a Kafka producer for EAP TraceItems.""" return get_arroyo_producer( name, diff --git a/src/sentry/uptime/consumers/results_consumer.py b/src/sentry/uptime/consumers/results_consumer.py index 74cbd20b1fcc..b2425cc1a961 100644 --- a/src/sentry/uptime/consumers/results_consumer.py +++ b/src/sentry/uptime/consumers/results_consumer.py @@ -8,6 +8,7 @@ from io import BytesIO from uuid import UUID +from redis.client import StrictRedis from sentry_kafka_schemas.schema_types.uptime_results_v1 import ( CHECKSTATUS_DISALLOWED_BY_ROBOTS, CHECKSTATUS_FAILURE, @@ -15,6 +16,7 @@ CHECKSTATUS_SUCCESS, CheckResult, ) +from sentry_redis_tools.clients import RedisCluster from sentry import features, options from sentry.conf.types.kafka_definition import Topic @@ -177,7 +179,7 @@ def create_uptime_response_capture( capture = UptimeResponseCapture.objects.create( uptime_subscription=subscription, file_id=file.id, - scheduled_check_time_ms=result["scheduled_check_time_ms"], + scheduled_check_time_ms=int(result["scheduled_check_time_ms"]), ) metrics.incr("uptime.response_capture.created", sample_rate=1.0) @@ -312,7 +314,7 @@ def create_backfill_misses( result: CheckResult, last_update_ms: int, metric_tags: dict[str, str], - cluster, + cluster: RedisCluster[str] | StrictRedis[str], ) -> None: """ Create synthetic missed check results for gaps in the check timeline. @@ -407,7 +409,7 @@ def process_result_internal( uptime_subscription: UptimeSubscription, result: CheckResult, metric_tags: dict[str, str], - cluster, + cluster: RedisCluster[str] | StrictRedis[str], subscription_regions: list[UptimeSubscriptionRegion], ) -> None: """ @@ -484,7 +486,7 @@ def handle_active_result( uptime_subscription: UptimeSubscription, result: CheckResult, metric_tags: dict[str, str], -): +) -> None: packet = UptimePacketValue( check_result=result, subscription=uptime_subscription, @@ -507,7 +509,7 @@ def queue_result_for_retry( subscription: UptimeSubscription, result: CheckResult, metric_tags: dict[str, str], - cluster, + cluster: RedisCluster[str] | StrictRedis[str], ) -> None: """ Queue an out-of-order result for retry processing. @@ -594,7 +596,7 @@ def queue_result_for_retry( if lock_ctx is not None: lock_ctx.__exit__(None, None, None) - def handle_result(self, subscription: UptimeSubscription | None, result: CheckResult): + def handle_result(self, subscription: UptimeSubscription | None, result: CheckResult) -> None: if random.random() < 0.01: logger.info("process_result", extra=result) diff --git a/src/sentry/uptime/consumers/tasks.py b/src/sentry/uptime/consumers/tasks.py index d5f07a4f64c5..85a813ab84e3 100644 --- a/src/sentry/uptime/consumers/tasks.py +++ b/src/sentry/uptime/consumers/tasks.py @@ -38,7 +38,7 @@ namespace=uptime_tasks, retry=Retry(times=3, delay=1, on=(Exception,)), ) -def process_uptime_backlog(subscription_id: str, attempt: int = 1): +def process_uptime_backlog(subscription_id: str, attempt: int = 1) -> None: """ Process buffered out-of-order results for a subscription. @@ -69,7 +69,7 @@ def process_uptime_backlog(subscription_id: str, attempt: int = 1): subscription_interval_ms = subscription.interval_seconds * 1000 expected_next_ms = last_update_ms + subscription_interval_ms processed_count = 0 - queued_results_raw: list[tuple[bytes, float]] = cluster.zrange( + queued_results_raw: list[tuple[str, float]] = cluster.zrange( backlog_key, 0, -1, withscores=True ) host_provider = get_host_provider_if_valid(subscription) diff --git a/src/sentry/uptime/endpoints/project_uptime_alert_checks_index.py b/src/sentry/uptime/endpoints/project_uptime_alert_checks_index.py index 6a17014dbfa4..a50bf3dd8498 100644 --- a/src/sentry/uptime/endpoints/project_uptime_alert_checks_index.py +++ b/src/sentry/uptime/endpoints/project_uptime_alert_checks_index.py @@ -174,7 +174,8 @@ def _serialize_response( for row_idx in range(len(column_values[0].results)) ] - return serialize(entries) + # justification: the serializer emits one payload per uptime check entry. + return cast("list[EapCheckEntrySerializerResponse]", serialize(entries)) def _transform_row( self, @@ -239,9 +240,11 @@ def _extract_assertion_failure_data(self, val: AttributeValue | None) -> Asserti return None try: - return json.loads(raw) + parsed = json.loads(raw) except (TypeError, ValueError): return None + # justification: the stored assertion is the Assertion object written by the uptime consumer. + return cast("Assertion", parsed) if isinstance(parsed, dict) else None def _extract_check_status_reason( self, check_status_reason_val: AttributeValue | None diff --git a/src/sentry/uptime/endpoints/serializers.py b/src/sentry/uptime/endpoints/serializers.py index 0814cfca3ff7..b4c6693d4d55 100644 --- a/src/sentry/uptime/endpoints/serializers.py +++ b/src/sentry/uptime/endpoints/serializers.py @@ -1,6 +1,7 @@ -from collections.abc import MutableMapping, Sequence +from collections.abc import Mapping, MutableMapping, Sequence from typing import Any, Literal, TypedDict, cast, override +from django.contrib.auth.models import AnonymousUser from django.db.models import prefetch_related_objects from sentry_kafka_schemas.schema_types.snuba_uptime_results_v1 import ( Assertion, @@ -19,6 +20,8 @@ IncidentStatus, UptimeSummary, ) +from sentry.users.models.user import User +from sentry.users.services.user import RpcUser from sentry.workflow_engine.models import DataSourceDetector, DetectorState from sentry.workflow_engine.models.detector import Detector from sentry.workflow_engine.types import DetectorPriorityLevel @@ -44,7 +47,13 @@ class UptimeSubscriptionSerializerResponse(TypedDict): @register(UptimeSubscription) class UptimeSubscriptionSerializer(Serializer[dict[str, Any]]): @override - def serialize(self, obj: UptimeSubscription, attrs, user, **kwargs) -> dict[str, Any]: + def serialize( + self, + obj: UptimeSubscription, + attrs: Mapping[Any, Any], # justification: serializer attrs and kwargs are dynamic. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer attrs and kwargs are dynamic. + ) -> dict[str, Any]: # justification: serializer attrs and kwargs are dynamic. return { "url": obj.url, "method": obj.method, @@ -116,7 +125,13 @@ def get_attrs( for detector in item_list } - def serialize(self, obj: Detector, attrs, user, **kwargs) -> UptimeDetectorSerializerResponse: + def serialize( + self, + obj: Detector, + attrs: Mapping[Any, Any], # justification: serializer attrs and kwargs are dynamic. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer attrs and kwargs are dynamic. + ) -> UptimeDetectorSerializerResponse: uptime_subscription = attrs["uptime_subscription"] detector_state = attrs["detector_state"] @@ -176,7 +191,11 @@ class EapCheckEntrySerializerResponse(TypedDict): @register(EapCheckEntry) class EapCheckEntrySerializer(Serializer[EapCheckEntrySerializerResponse]): def serialize( - self, obj: EapCheckEntry, attrs, user, **kwargs + self, + obj: EapCheckEntry, + attrs: Mapping[Any, Any], # justification: serializer attrs and kwargs are dynamic. + user: User | RpcUser | AnonymousUser, + **kwargs: Any, # justification: serializer attrs and kwargs are dynamic. ) -> EapCheckEntrySerializerResponse: check_status = cast(SerializedCheckStatus, obj.check_status) diff --git a/src/sentry/uptime/endpoints/utils.py b/src/sentry/uptime/endpoints/utils.py index f1a5d8b02e12..3bb3c377cb5f 100644 --- a/src/sentry/uptime/endpoints/utils.py +++ b/src/sentry/uptime/endpoints/utils.py @@ -77,7 +77,8 @@ def authorize_and_map_uptime_detector_subscription_ids( if set(detector_ids_ints) != validated_detector_ids: raise ValueError("Invalid detector ids provided") - sub_id_formatter = lambda sub_id: uuid.UUID(sub_id).hex + def sub_id_formatter(sub_id: str) -> str: + return uuid.UUID(sub_id).hex subscription_id_to_detector_id = { sub_id_formatter(detector_data[1]): detector_data[0] diff --git a/src/sentry/uptime/endpoints/validators.py b/src/sentry/uptime/endpoints/validators.py index ba9cb2323794..b2493d7042ba 100644 --- a/src/sentry/uptime/endpoints/validators.py +++ b/src/sentry/uptime/endpoints/validators.py @@ -1,12 +1,14 @@ import logging from collections.abc import Sequence -from typing import Any, override +from typing import Any, cast, 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, @@ -42,7 +45,9 @@ update_uptime_detector, update_uptime_subscription, ) -from sentry.uptime.types import UptimeMonitorMode +from sentry.uptime.types import CheckConfig, 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: dict[str, Any], # justification: validated alert payload is decoded JSON. + uptime_subscription: UptimeSubscription | None, + organization: Organization, + user: User | RpcUser | AnonymousUser | None, +) -> None: validated_data = attrs if uptime_subscription is not None: validated_data = {} @@ -172,13 +182,17 @@ def _validate_check_config( region = get_region_config(get_active_regions()[0].slug) assert region is not None - check_config = checker_api.create_preview_check(validated_data, region) + check_config = checker_api.create_preview_check( + cast(checker_api._PreviewCheckData, validated_data), region + ) result = checker_api.invoke_checker_validator(check_config, region) if result is not None and result.status_code >= 400: raise serializers.ValidationError({"assertion": result.json()}) -class UptimeValidatorBase(CamelSnakeSerializer): +class UptimeValidatorBase( + CamelSnakeSerializer[Any] # justification: validated alert payload is decoded JSON. +): # justification: validated alert payload is decoded JSON. url = URLField(required=True, max_length=255) timeout_ms = serializers.IntegerField( required=True, @@ -210,7 +224,10 @@ class UptimeValidatorBase(CamelSnakeSerializer): @extend_schema_serializer() class UptimeCheckPreviewValidator(UptimeValidatorBase): - def validate(self, attrs): + def validate( + self, + attrs: dict[str, Any], # justification: validated alert payload is decoded JSON. + ) -> dict[str, Any]: # justification: validated alert payload is decoded JSON. _validate_request_size( attrs.get("method", "GET"), attrs.get("url", ""), @@ -224,16 +241,21 @@ 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): + def create( + self, + validated_data: dict[str, Any], # justification: validated alert payload is decoded JSON. + ) -> CheckConfig: region_config = get_region_config(get_active_regions()[0].slug) assert region_config is not None - return checker_api.create_preview_check(validated_data, region_config) + return checker_api.create_preview_check( + cast(checker_api._PreviewCheckData, validated_data), region_config + ) @extend_schema_serializer() @@ -288,7 +310,10 @@ class UptimeMonitorValidator(UptimeValidatorBase): help_text="Number of consecutive failed checks required to mark monitor as down.", ) - def validate(self, attrs): + def validate( + self, + attrs: dict[str, Any], # justification: validated alert payload is decoded JSON. + ) -> dict[str, Any]: # justification: validated alert payload is decoded JSON. # When creating a new uptime monitor, check if we would exceed the organization limit if not self.instance: organization = self.context["organization"] @@ -326,19 +351,22 @@ 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): + def create( + self, + validated_data: dict[str, Any], # justification: validated alert payload is decoded JSON. + ) -> Detector: # justification: validated alert payload is decoded JSON. if validated_data.get("environment") is not None: environment = Environment.get_or_create( project=self.context["project"], @@ -357,7 +385,7 @@ def create(self, validated_data): interval_seconds=validated_data["interval_seconds"], timeout_ms=validated_data["timeout_ms"], name=validated_data["name"], - status=validated_data.get("status"), + status=cast(int, validated_data.get("status")), mode=validated_data.get("mode", UptimeMonitorMode.MANUAL), owner=validated_data.get("owner"), trace_sampling=validated_data.get("trace_sampling", False), @@ -378,7 +406,11 @@ def create(self, validated_data): return detector - def update(self, instance: Detector, data): + def update( + self, + instance: Detector, + data: dict[str, Any], # justification: validated alert payload is decoded JSON. + ) -> Detector: # justification: validated alert payload is decoded JSON. uptime_subscription = get_uptime_subscription(instance) url = data["url"] if "url" in data else uptime_subscription.url @@ -529,16 +561,19 @@ 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]): + def validate( + self, + attrs: dict[str, Any], # justification: validated alert payload is decoded JSON. + ) -> dict[str, Any]: # justification: validated alert payload is decoded JSON. attrs = super().validate(attrs) headers = [] @@ -581,7 +616,9 @@ def create_source(self, validated_data: dict[str, Any]) -> UptimeSubscription: return uptime_subscription -class UptimeDomainCheckFailureConfigValidator(CamelSnakeSerializer): +class UptimeDomainCheckFailureConfigValidator( + CamelSnakeSerializer[dict[str, Any]] # justification: validated alert payload is decoded JSON. +): # justification: validated alert payload is decoded JSON. """ Validator for the uptime detector config field. """ @@ -606,7 +643,13 @@ class UptimeDomainCheckFailureConfigValidator(CamelSnakeSerializer): ) mode = serializers.IntegerField(required=False, default=UptimeMonitorMode.MANUAL) - def bind(self, field_name, parent): + def bind( + self, + field_name: str, + parent: serializers.BaseSerializer[ + Any # justification: decoded alert JSON + ], # justification: validated alert payload is decoded JSON. + ) -> None: # justification: validated alert payload is decoded JSON. super().bind(field_name, parent) if not parent: return @@ -763,7 +806,11 @@ 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], # justification: validated alert payload is decoded JSON. + ) -> Detector: # justification: validated alert payload is decoded JSON. subscription = get_uptime_subscription(instance) update_uptime_subscription( detector=instance, diff --git a/src/sentry/uptime/models.py b/src/sentry/uptime/models.py index 71c175c22a78..4919e4470404 100644 --- a/src/sentry/uptime/models.py +++ b/src/sentry/uptime/models.py @@ -153,7 +153,7 @@ class Meta: def get_org_from_detector(detector: Detector) -> tuple[Organization] | None: - if detector.project_id is None: + if not detector.project_id: return None try: return (detector.linked_project.organization,) @@ -234,7 +234,7 @@ def bulk_get_query_object( @override @staticmethod - def related_model(instance) -> list[ModelRelation]: + def related_model(instance: DataSource) -> list[ModelRelation]: return [ModelRelation(UptimeSubscription, {"id": instance.source_id})] @override @@ -254,7 +254,9 @@ def get_relocation_model_name() -> str: return "uptime.uptimesubscription" -def get_detector(uptime_subscription: UptimeSubscription, prefetch_workflow_data=False) -> Detector: +def get_detector( + uptime_subscription: UptimeSubscription, prefetch_workflow_data: bool = False +) -> Detector: """ Fetches a workflow_engine Detector given an existing uptime_subscription. This is used during the transition period moving uptime to detector. @@ -283,7 +285,7 @@ def get_uptime_subscription(detector: Detector) -> UptimeSubscription: return UptimeSubscription.objects.get_from_cache(id=int(data_source.source_id)) -def get_audit_log_data(detector: Detector): +def get_audit_log_data(detector: Detector) -> dict[str, object]: """Get audit log data from a detector.""" uptime_subscription = get_uptime_subscription(detector) @@ -335,9 +337,11 @@ class Meta: models.Index(fields=["date_added"]), ] - def delete(self, *args, **kwargs): + def delete( + self, using: str | None = None, keep_parents: bool = False + ) -> tuple[int, dict[str, int]]: try: File.objects.get(pk=self.file_id).delete() except File.DoesNotExist: pass - return super().delete(*args, **kwargs) + return super().delete(using=using, keep_parents=keep_parents) diff --git a/src/sentry/uptime/subscriptions/subscriptions.py b/src/sentry/uptime/subscriptions/subscriptions.py index a91d18002ebf..2df67006bbcb 100644 --- a/src/sentry/uptime/subscriptions/subscriptions.py +++ b/src/sentry/uptime/subscriptions/subscriptions.py @@ -160,7 +160,7 @@ def create_uptime_subscription( mode=region_config.mode, ) - def commit_tasks(): + def commit_tasks() -> None: create_remote_uptime_subscription.delay(subscription.id) fetch_subscription_rdap_info.delay(subscription.id) @@ -180,7 +180,7 @@ def update_uptime_subscription( trace_sampling: bool | NotSet = NOT_SET, assertion: Any | NotSet = NOT_SET, response_capture_enabled: bool | NotSet = NOT_SET, -): +) -> None: """ Updates an existing uptime subscription. This updates the row in postgres, and fires a task that will send the config to the uptime check system. @@ -228,7 +228,7 @@ def update_uptime_subscription( # Associate active regions with this subscription check_and_update_regions(subscription, load_regions_for_uptime_subscription(subscription.id)) - def commit_tasks(): + def commit_tasks() -> None: update_remote_uptime_subscription.delay(subscription.id) fetch_subscription_rdap_info.delay(subscription.id) if interval_updated: @@ -240,7 +240,7 @@ def commit_tasks(): transaction.on_commit(commit_tasks, using=router.db_for_write(UptimeSubscription)) -def delete_uptime_subscription(uptime_subscription: UptimeSubscription): +def delete_uptime_subscription(uptime_subscription: UptimeSubscription) -> None: """ Deletes an existing uptime subscription. This updates the row in postgres, and fires a task that will send the deletion to the external system and remove the row once successful. @@ -397,7 +397,7 @@ def update_uptime_detector( downtime_threshold: int | NotSet = NOT_SET, assertion: Any | NotSet = NOT_SET, response_capture_enabled: bool | NotSet = NOT_SET, -): +) -> None: """ Updates a uptime detector and its associated uptime subscription. """ @@ -474,7 +474,7 @@ def update_uptime_detector( detector.refresh_from_db() -def disable_uptime_detector(detector: Detector, skip_quotas: bool = False): +def disable_uptime_detector(detector: Detector, skip_quotas: bool = False) -> None: """ Disables a uptime detector. If the UptimeSubscription no longer has any active detectors, it will also be disabled. @@ -537,7 +537,7 @@ def ensure_uptime_seat(detector: Detector) -> None: def enable_uptime_detector( detector: Detector, ensure_assignment: bool = False, skip_quotas: bool = False -): +) -> None: """ Enable a uptime detector. If the uptime subscription was also disabled it will be re-activated and the remote subscription will be published. @@ -572,11 +572,11 @@ def enable_uptime_detector( ) -def remove_uptime_seat(detector: Detector): +def remove_uptime_seat(detector: Detector) -> None: quotas.backend.remove_seat(seat_object=detector) -def delete_uptime_detector(detector: Detector): +def delete_uptime_detector(detector: Detector) -> None: uptime_subscription = get_uptime_subscription(detector) remove_uptime_seat(detector) @@ -662,13 +662,13 @@ def check_and_update_regions( class MaxUrlsForDomainReachedException(Exception): - def __init__(self, domain, suffix, max_urls): + def __init__(self, domain: str, suffix: str, max_urls: int) -> None: self.domain = domain self.suffix = suffix self.max_urls = max_urls -def check_url_limits(url): +def check_url_limits(url: str) -> None: """ Determines if a URL's domain has reached the global maximum (MAX_MONITORS_PER_DOMAIN). In the case that it has a `MaxUrlsForDomainReachedException` will be raised. diff --git a/src/sentry/uptime/subscriptions/tasks.py b/src/sentry/uptime/subscriptions/tasks.py index d2a433fa46e6..16c7ffa736ce 100644 --- a/src/sentry/uptime/subscriptions/tasks.py +++ b/src/sentry/uptime/subscriptions/tasks.py @@ -48,7 +48,7 @@ namespace=uptime_tasks, retry=Retry(times=5, delay=5), ) -def create_remote_uptime_subscription(uptime_subscription_id, **kwargs): +def create_remote_uptime_subscription(uptime_subscription_id: int, **kwargs: object) -> None: try: subscription = UptimeSubscription.objects.get(id=uptime_subscription_id) except UptimeSubscription.DoesNotExist: @@ -74,8 +74,8 @@ def create_remote_uptime_subscription(uptime_subscription_id, **kwargs): retry=Retry(times=5, delay=5), ) def update_remote_uptime_subscription( - uptime_subscription_id: int, region_slugs: list[str] | None = None, **kwargs -): + uptime_subscription_id: int, region_slugs: list[str] | None = None, **kwargs: object +) -> None: """ Pushes details of an uptime subscription to uptime subscription regions. When ``region_slugs`` is given, only those regions are pushed to. @@ -120,7 +120,7 @@ def update_remote_uptime_subscription( namespace=uptime_tasks, retry=Retry(times=5, delay=5), ) -def delete_remote_uptime_subscription(uptime_subscription_id, **kwargs): +def delete_remote_uptime_subscription(uptime_subscription_id: int, **kwargs: object) -> None: try: subscription = UptimeSubscription.objects.get(id=uptime_subscription_id) except UptimeSubscription.DoesNotExist: @@ -147,7 +147,7 @@ def delete_remote_uptime_subscription(uptime_subscription_id, **kwargs): def send_uptime_subscription_config( region: UptimeSubscriptionRegion, subscription: UptimeSubscription -): +) -> None: if subscription.subscription_id is None: subscription.subscription_id = uuid4().hex produce_config( @@ -191,7 +191,7 @@ def send_uptime_config_deletion(destination_region_slug: str, subscription_id: s name="sentry.uptime.tasks.subscription_checker", namespace=uptime_tasks, ) -def subscription_checker(**kwargs): +def subscription_checker(**kwargs: object) -> None: """ Checks for subscriptions stuck in a transition status and attempts to repair them. This typically happens when we had some kind of error running the task the first time around. @@ -221,7 +221,7 @@ def subscription_checker(**kwargs): name="sentry.uptime.tasks.broken_monitor_checker", namespace=uptime_tasks, ) -def broken_monitor_checker(**kwargs): +def broken_monitor_checker(**kwargs: object) -> None: """ This checks for auto created uptime monitors that have been broken for a long time and disables them. """ @@ -267,7 +267,7 @@ def broken_monitor_checker(**kwargs): namespace=uptime_tasks, processing_deadline_duration=60, ) -def config_drift_dispatcher(**kwargs): +def config_drift_dispatcher(**kwargs: object) -> None: """ Fans out this hour's slice of the Redis/Postgres config drift sweep; see sweep_slice for how a slice is chosen. @@ -314,7 +314,9 @@ def _find_store(cluster: str, key_prefix: str) -> ConfigStore | None: expires=SWEEP_RUN_INTERVAL, retry=Retry(times=3, delay=120, on=(Exception, ProcessingDeadlineExceeded)), ) -def check_missing_configs(subscription_id_prefix: str, cluster: str, key_prefix: str, **kwargs): +def check_missing_configs( + subscription_id_prefix: str, cluster: str, key_prefix: str, **kwargs: object +) -> None: """ Postgres → Redis direction of the drift sweep: for ACTIVE subscriptions in this subscription_id prefix, count those whose config is absent from one store, and @@ -348,7 +350,7 @@ def check_missing_configs(subscription_id_prefix: str, cluster: str, key_prefix: expires=SWEEP_RUN_INTERVAL, retry=Retry(times=3, delay=120, on=(Exception, ProcessingDeadlineExceeded)), ) -def check_orphaned_configs(cluster: str, key_prefix: str, partition: int, **kwargs): +def check_orphaned_configs(cluster: str, key_prefix: str, partition: int, **kwargs: object) -> None: """ Redis → Postgres direction of the drift sweep: for one config partition in one store, count configs that no ACTIVE, CREATING or UPDATING subscription owns in a region served diff --git a/src/sentry/uptime/utils.py b/src/sentry/uptime/utils.py index 1f14edffc062..e5faf26dbf48 100644 --- a/src/sentry/uptime/utils.py +++ b/src/sentry/uptime/utils.py @@ -37,7 +37,7 @@ def build_backlog_schedule_lock_key(subscription_id: str) -> str: return f"uptime:backlog_schedule_lock:{subscription_id}" -def get_cluster() -> RedisCluster | StrictRedis: +def get_cluster() -> "RedisCluster[str] | StrictRedis[str]": return redis.redis_clusters.get(settings.SENTRY_UPTIME_DETECTOR_CLUSTER) diff --git a/src/sentry/users/api/endpoints/user_authenticator_details.py b/src/sentry/users/api/endpoints/user_authenticator_details.py index a9cc2868efd6..114fe71f8f46 100644 --- a/src/sentry/users/api/endpoints/user_authenticator_details.py +++ b/src/sentry/users/api/endpoints/user_authenticator_details.py @@ -38,6 +38,8 @@ def _get_device_for_rename( ) -> dict[str, Any] | None: devices = authenticator.config for device in devices["devices"]: + if not isinstance(device, dict): + continue # this is for devices registered with webauthn, since the stored data is not a string, we need to decode it if isinstance(device["binding"], AuthenticatorData): if decode_credential_id(device) == interface_device_id: 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/endpoints/user_details.py b/src/sentry/users/api/endpoints/user_details.py index 7ac89efcd8b5..a97e6a046db3 100644 --- a/src/sentry/users/api/endpoints/user_details.py +++ b/src/sentry/users/api/endpoints/user_details.py @@ -1,7 +1,7 @@ import logging from dataclasses import dataclass from datetime import timedelta -from typing import Any, NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict, cast from django.conf import settings from django.contrib.auth import logout @@ -177,7 +177,8 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: for field in settings.SENTRY_MANAGED_USER_FIELDS: attrs.pop(field, None) - return super().validate(attrs) + # justification: DRF returns the validated attribute mapping. + return cast("dict[str, Any]", super().validate(attrs)) class SuperuserUserSerializer(BaseUserSerializer): diff --git a/src/sentry/users/api/serializers/authenticator.py b/src/sentry/users/api/serializers/authenticator.py index 5e6948f10ccb..232bd2f90642 100644 --- a/src/sentry/users/api/serializers/authenticator.py +++ b/src/sentry/users/api/serializers/authenticator.py @@ -83,7 +83,7 @@ def serialize( class SmsInterfaceSerializerResponse(AuthenticatorInterfaceSerializerResponse): - phone: str + phone: str | None @register(SmsInterface) 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/users/models/identity.py b/src/sentry/users/models/identity.py index b5629d3d67cf..c7606836c00f 100644 --- a/src/sentry/users/models/identity.py +++ b/src/sentry/users/models/identity.py @@ -2,7 +2,7 @@ import logging from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, cast from django.conf import settings from django.contrib.postgres.fields.array import ArrayField @@ -261,20 +261,20 @@ class Meta: def link_provider_identity( user: User | RpcUser, - identity_data: dict[str, Any], + identity_data: Mapping[str, object], organization_id: int, ) -> Identity | None: with transaction.atomic(router.db_for_write(Identity)): idp, _ = IdentityProvider.objects.get_or_create( - type=identity_data["type"], - external_id=identity_data["idp_external_id"], + type=cast(str, identity_data["type"]), + external_id=cast(str, identity_data["idp_external_id"]), defaults={"config": identity_data.get("idp_config", {})}, ) linked_identity = Identity.objects.link_identity( user=user, idp=idp, - external_id=identity_data["id"], + external_id=cast(str, identity_data["id"]), should_reattach=False, defaults={ "scopes": identity_data.get("scopes", []), diff --git a/src/sentry/users/models/user.py b/src/sentry/users/models/user.py index c2a56f54f8de..67de9ebcf7c1 100644 --- a/src/sentry/users/models/user.py +++ b/src/sentry/users/models/user.py @@ -260,10 +260,10 @@ def save(self, *args: Any, **kwargs: Any) -> None: # for testing purposes, we want to be able to create new users with existing emails # if we're relocating a user, then we would have handled email_unique in the relocation logic if not is_test_user: - if self.pk is None and not is_relocated_user: + if not self.pk and not is_relocated_user: # new users should set email_unique self.email_unique = self.email - elif self.pk is None and is_relocated_user: + elif not self.pk and is_relocated_user: # If the user is new, relocated blank email_unique to dodge # integrity errors. self.email_unique = None diff --git a/src/sentry/users/web/accounts_form.py b/src/sentry/users/web/accounts_form.py index 1a39d0c8c5fb..9911972405c5 100644 --- a/src/sentry/users/web/accounts_form.py +++ b/src/sentry/users/web/accounts_form.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import Any +from typing import Any, cast from django import forms from django.conf import settings @@ -60,7 +60,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) def clean_password(self) -> str: - password = self.cleaned_data["password"] + # justification: the password field's cleaned value is a string. + password = cast("str", self.cleaned_data["password"]) password_validation.validate_password(password, user=self.user) return password @@ -96,6 +97,7 @@ def clean_password(self) -> str: raise forms.ValidationError( _("You must confirm your current password to make changes.") ) + assert isinstance(value, str) return value @@ -126,7 +128,8 @@ def clean_username(self) -> str | None: return value.lower() def clean_password(self) -> str: - password = self.cleaned_data["password"] + # justification: the password field's cleaned value is a string. + password = cast("str", self.cleaned_data["password"]) password_validation.validate_password(password, user=self.user) return password diff --git a/src/sentry/utils/arroyo_producer.py b/src/sentry/utils/arroyo_producer.py index ddf2444b2503..4d15fd818d06 100644 --- a/src/sentry/utils/arroyo_producer.py +++ b/src/sentry/utils/arroyo_producer.py @@ -5,6 +5,7 @@ import threading from collections import deque from collections.abc import Callable +from typing import TypedDict, Unpack from arroyo.backends.abstract import ProducerFuture from arroyo.backends.kafka import ( @@ -24,6 +25,10 @@ _ProducerFuture = ProducerFuture[BrokerValue[KafkaPayload]] +class _KafkaProducerKwargs(TypedDict, total=False): + use_simple_futures: bool + + class SingletonProducer: """ A Kafka producer that can be instantiated as a global @@ -101,9 +106,9 @@ def _shutdown(self) -> None: def get_arroyo_producer( name: str, topic: Topic | str, - additional_config: dict | None = None, + additional_config: dict[str, object] | None = None, exclude_config_keys: list[str] | None = None, - **kafka_producer_kwargs, + **kafka_producer_kwargs: Unpack[_KafkaProducerKwargs], ) -> KafkaProducer: """ Get an arroyo producer for a given topic. diff --git a/src/sentry/utils/audit.py b/src/sentry/utils/audit.py index 9d0328c77a9a..a82e29879fd7 100644 --- a/src/sentry/utils/audit.py +++ b/src/sentry/utils/audit.py @@ -163,7 +163,7 @@ def create_audit_entry_from_user( if logger: # Only use the api_name for the logger message when the entry # is a real AuditLogEntry record - if entry.event is not None: + if entry.event: logger.info(audit_log.get(entry.event).api_name, extra=extra) else: logger.info(entry, extra=extra) @@ -172,7 +172,8 @@ def create_audit_entry_from_user( def get_api_key_for_audit_log(request: HttpRequest) -> ApiKey | None: - return request.auth if hasattr(request, "auth") and isinstance(request.auth, ApiKey) else None + auth = getattr(request, "auth", None) + return auth if isinstance(auth, ApiKey) else None def get_org_auth_token_for_audit_log(request: HttpRequest) -> OrgAuthToken | None: @@ -201,6 +202,7 @@ def _create_project_delete_log( ) -> None: delete_log = DeletedProject() + assert entry.target_object is not None project = Project.objects.get(id=entry.target_object) delete_log.name = project.name delete_log.slug = project.slug @@ -219,6 +221,7 @@ def _create_project_delete_log( def _create_team_delete_log(entry: AuditLogEntry, audit_log_actor: RpcAuditLogEntryActor) -> None: delete_log = DeletedTeam() + assert entry.target_object is not None team = Team.objects.get(id=entry.target_object) delete_log.name = team.name delete_log.slug = team.slug @@ -278,7 +281,7 @@ def create_system_audit_entry( if logger: # Only use the api_name for the logger message when the entry # is a real AuditLogEntry record - if entry.event is not None: + if entry.event: logger.info(audit_log.get(entry.event).api_name, extra=extra) else: logger.info(entry, extra=extra) diff --git a/src/sentry/utils/auth.py b/src/sentry/utils/auth.py index 4b44f121f3f9..bb861d076005 100644 --- a/src/sentry/utils/auth.py +++ b/src/sentry/utils/auth.py @@ -198,14 +198,16 @@ def _get_login_redirect(request: HttpRequest, default: str | None = None) -> str # If we have a different URL to go after the 2fa flow we want to go to # that now here. after_2fa = request.session.pop("_after_2fa", None) - if after_2fa is not None: + if isinstance(after_2fa, str): return after_2fa login_url = request.session.pop("_next", None) if not login_url: return default - if not is_valid_redirect(login_url, allowed_hosts=(request.get_host(),)): + if not isinstance(login_url, str) or not is_valid_redirect( + login_url, allowed_hosts=(request.get_host(),) + ): login_url = default return login_url diff --git a/src/sentry/utils/circuit_breaker.py b/src/sentry/utils/circuit_breaker.py index b86768562636..edc4a0c0f350 100644 --- a/src/sentry/utils/circuit_breaker.py +++ b/src/sentry/utils/circuit_breaker.py @@ -3,6 +3,7 @@ the `CircuitBreaker` class found in `circuit_breaker2.py` instead. """ +from collections.abc import Callable from typing import TypedDict from django.core.cache import cache @@ -11,8 +12,8 @@ from sentry.utils import metrics DEFAULT_ERROR_LIMIT = 30 -ERROR_COUNT_CACHE_KEY = lambda key: f"circuit_breaker:{key}-error-count" -PASSTHROUGH_RATELIMIT_KEY = lambda key: f"circuit_breaker:{key}-passthrough" +ERROR_COUNT_CACHE_KEY: Callable[[str], str] = lambda key: f"circuit_breaker:{key}-error-count" +PASSTHROUGH_RATELIMIT_KEY: Callable[[str], str] = lambda key: f"circuit_breaker:{key}-passthrough" class CircuitBreakerPassthrough(TypedDict, total=True): 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..aaf90d4634b4 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, Iterable, 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, cast import sentry_sdk import sentry_sdk.scope @@ -24,22 +24,22 @@ class PriorityTask[T](NamedTuple): priority: int item: tuple[sentry_sdk.Scope, sentry_sdk.Scope, Callable[[], T], Future[T]] - def __eq__(self, b): - return self.priority == b.priority + def __eq__(self, other: object) -> bool: + return self.priority == cast(PriorityTask[object], other).priority - def __lt__(self, b): - return self.priority < b.priority + def __lt__(self, other: object) -> bool: + return self.priority < cast(PriorityTask[object], other).priority 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``. @@ -249,39 +255,39 @@ class ContextPropagatingThreadPoolExecutor(_ThreadPoolExecutor): available in worker threads without manual propagation. """ - def submit(self, fn, /, *args, **kwargs): + def submit[T, **P](self, fn: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs) -> Future[T]: ctx = contextvars.copy_context() - return super().submit(ctx.run, fn, *args, **kwargs) + return super().submit(ctx.run, functools.partial(fn, *args, **kwargs)) -class FutureSet: +class FutureSet[T]: """\ Coordinates a set of ``Future`` objects (either from ``concurrent.futures``, or otherwise API compatible), and allows for attaching a callback when all futures have completed execution. """ - def __init__(self, futures): - self.__pending = set(futures) - self.__completed = set() - self.__callbacks = [] + def __init__(self, futures: Iterable[Future[T]]) -> None: + self.__pending: set[Future[T]] = set(futures) + self.__completed: set[Future[T]] = set() + self.__callbacks: list[Callable[[FutureSet[T]], None]] = [] self.__lock = threading.Lock() for future in futures: future.add_done_callback(self.__mark_completed) - def __iter__(self): + def __iter__(self) -> Iterator[Future[T]]: with self.__lock: futures = self.__pending | self.__completed return iter(futures) - def __execute_callback(self, callback): + def __execute_callback(self, callback: Callable[[FutureSet[T]], None]) -> None: try: callback(self) except Exception as error: logger.warning("Error when calling callback %r: %s", callback, error, exc_info=True) - def __mark_completed(self, future): + def __mark_completed(self, future: Future[T]) -> None: with self.__lock: self.__pending.remove(future) self.__completed.add(future) @@ -291,7 +297,7 @@ def __mark_completed(self, future): for callback in self.__callbacks: self.__execute_callback(callback) - def add_done_callback(self, callback): + def add_done_callback(self, callback: Callable[[FutureSet[T]], None]) -> None: with self.__lock: remaining = len(self.__pending) if remaining > 0: diff --git a/src/sentry/utils/cursored_scheduler.py b/src/sentry/utils/cursored_scheduler.py index 636f4c3a6b3a..9de2db6b62cd 100644 --- a/src/sentry/utils/cursored_scheduler.py +++ b/src/sentry/utils/cursored_scheduler.py @@ -99,6 +99,8 @@ def has_my_feature(orgs: Sequence[Organization]) -> Sequence[int]: from django.conf import settings from django.core.cache import cache from django.db.models import Model, QuerySet +from redis.client import StrictRedis +from sentry_redis_tools.clients import RedisCluster from taskbroker_client.task import Task from sentry.locks import locks @@ -355,7 +357,7 @@ def _is_cycle_start_due(self) -> bool: return True return time.time() >= float(next_start_at) - def _finalize_cycle(self): + def _finalize_cycle(self) -> None: """Reset cursor, batch size, and PK list. The next cycle starts once it is due.""" self._emit_cycle_duration() cache.set(self.cache_key, 0, self.cache_ttl) @@ -440,5 +442,5 @@ def _get_cached_pks_page(self, offset: int, count: int) -> list[int]: raw = client.lrange(self.pk_list_cache_key, offset, offset + count - 1) return [int(v) for v in raw] - def _get_redis_client(self): + def _get_redis_client(self) -> RedisCluster[str] | StrictRedis[str]: return redis.redis_clusters.get("default") diff --git a/src/sentry/utils/datastructures.py b/src/sentry/utils/datastructures.py index eac2a2394b86..d8acc1203cb9 100644 --- a/src/sentry/utils/datastructures.py +++ b/src/sentry/utils/datastructures.py @@ -1,9 +1,13 @@ -from collections.abc import Hashable, MutableMapping +from collections.abc import Hashable, Iterator, MutableMapping +from typing import TypeVar, overload __unset__ = object() +KT = TypeVar("KT", bound=Hashable) +DT = TypeVar("DT") -class BidirectionalMapping(MutableMapping): + +class BidirectionalMapping(MutableMapping[KT, object]): """\ An associative data structure in which the ``(key, value)`` pairs form a one-to-one correspondence in both directions. @@ -13,16 +17,16 @@ class BidirectionalMapping(MutableMapping): provided to ``get_key``. """ - def __init__(self, data): + def __init__(self, data: MutableMapping[KT, object]) -> None: self.__data = data self.__inverse = {v: k for k, v in self.__data.items()} if len(self.__data) != len(self.__inverse): raise ValueError("duplicate value provided") - def __getitem__(self, key): + def __getitem__(self, key: KT) -> object: return self.__data[key] - def __setitem__(self, key, value): + def __setitem__(self, key: KT, value: object) -> None: if not isinstance(key, Hashable): raise TypeError("key must be hashable") @@ -39,16 +43,22 @@ def __setitem__(self, key, value): self.__data[key] = value self.__inverse[value] = key - def __delitem__(self, key): + def __delitem__(self, key: KT) -> None: del self.__inverse[self.__data.pop(key)] - def __iter__(self): + def __iter__(self) -> Iterator[KT]: return iter(self.__data) def __len__(self) -> int: return len(self.__data) - def get_key(self, value, default=__unset__): + @overload + def get_key(self, value: object) -> KT: ... + + @overload + def get_key(self, value: object, default: DT) -> KT | DT: ... + + def get_key(self, value: object, default: object = __unset__) -> object: try: return self.__inverse[value] except KeyError: @@ -57,5 +67,5 @@ def get_key(self, value, default=__unset__): else: return default - def inverse(self): + def inverse(self) -> dict[object, KT]: return self.__inverse.copy() diff --git a/src/sentry/utils/db.py b/src/sentry/utils/db.py index 29eb982d256d..7cfcab520dfe 100644 --- a/src/sentry/utils/db.py +++ b/src/sentry/utils/db.py @@ -1,14 +1,20 @@ import logging -from collections.abc import Generator, Sequence +from collections.abc import Callable, Generator, Sequence from contextlib import ExitStack, contextmanager from datetime import timedelta from functools import wraps +from types import TracebackType +from typing import ParamSpec, TypeVar, cast from django.db import DEFAULT_DB_ALIAS, connections, router, transaction +from django.db.models import Model from django.db.utils import OperationalError, ProgrammingError from sentry_sdk import traces from sentry_sdk.integrations import Integration +P = ParamSpec("P") +R = TypeVar("R") + @contextmanager def statement_timeout(alias: str, timeout: timedelta) -> Generator[None]: @@ -29,9 +35,11 @@ def statement_timeout(alias: str, timeout: timedelta) -> Generator[None]: yield -def handle_db_failure(func, model, wrap_in_transaction: bool = True): +def handle_db_failure( + func: Callable[P, R], model: type[Model], wrap_in_transaction: bool = True +) -> Callable[P, R | None]: @wraps(func) - def wrapped(*args, **kwargs): + def wrapped(*args: P.args, **kwargs: P.kwargs) -> R | None: try: if wrap_in_transaction: with transaction.atomic(router.db_for_write(model)): @@ -40,7 +48,7 @@ def wrapped(*args, **kwargs): return func(*args, **kwargs) except (ProgrammingError, OperationalError): logging.exception("Failed processing signal %s", func.__name__) - return + return None return wrapped @@ -71,33 +79,40 @@ class DjangoAtomicIntegration(Integration): identifier = "django_atomic" @staticmethod - def setup_once(): + def setup_once() -> None: from django.db.transaction import Atomic original_enter = Atomic.__enter__ original_exit = Atomic.__exit__ - def _enter(self): - self._sentry_sdk_span = traces.start_span( + def _enter(self: Atomic) -> None: + span = traces.start_span( name="transaction.atomic", attributes={ "sentry.op": "transaction.atomic", - "using": self.using, + "using": cast(str, self.using), }, ) - self._sentry_sdk_span.__enter__() + span.__enter__() + setattr(self, "_sentry_sdk_span", span) return original_enter(self) - def _exit(self, exc_type, exc_value, traceback): + def _exit( + self: Atomic, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: rv = original_exit(self, exc_type, exc_value, traceback) - if hasattr(self, "_sentry_sdk_span"): - self._sentry_sdk_span.__exit__(exc_type, exc_value, traceback) - del self._sentry_sdk_span + span = getattr(self, "_sentry_sdk_span", None) + if span is not None: + span.__exit__(exc_type, exc_value, traceback) + delattr(self, "_sentry_sdk_span") return rv Atomic.__enter__ = _enter # type: ignore[method-assign] Atomic.__exit__ = _exit # type: ignore[method-assign] -def table_exists(name, using=DEFAULT_DB_ALIAS): +def table_exists(name: str, using: str = DEFAULT_DB_ALIAS) -> bool: return name in connections[using].introspection.table_names() diff --git a/src/sentry/utils/email/message_builder.py b/src/sentry/utils/email/message_builder.py index f8a07e79ae37..5da77bc84bbe 100644 --- a/src/sentry/utils/email/message_builder.py +++ b/src/sentry/utils/email/message_builder.py @@ -72,8 +72,11 @@ def inline_css(value: str) -> str: toronado.inline(tree) # CSS media query support is inconsistent when the DOCTYPE declaration is # missing, so we force it to HTML5 here. - html = lxml.html.tostring(tree, doctype="", encoding=None).decode("utf-8") - return html + # justification: lxml returns bytes when encoding is None. + html = lxml.html.tostring(tree, doctype="", encoding=None) + if not isinstance(html, bytes): + raise TypeError("HTML serialization returned no bytes") + return html.decode("utf-8") def message_to_dict(message: EmailMultiAlternatives) -> dict[str, Any]: diff --git a/src/sentry/utils/email/send.py b/src/sentry/utils/email/send.py index 748e848995d7..0947dcad9afc 100644 --- a/src/sentry/utils/email/send.py +++ b/src/sentry/utils/email/send.py @@ -16,6 +16,8 @@ def send_messages(messages: Sequence[EmailMultiAlternatives], fail_silently: bool = False) -> int: connection = get_connection(fail_silently=fail_silently) sent = connection.send_messages(messages) + if not isinstance(sent, int): + raise TypeError("Mail backend returned no sent message count") metrics.incr("email.sent", sent, skip_internal=False, tags={"success": True}) failed = len(messages) - sent if failed > 0: diff --git a/src/sentry/utils/env.py b/src/sentry/utils/env.py index 2f3bac7c2047..27d75d5d6418 100644 --- a/src/sentry/utils/env.py +++ b/src/sentry/utils/env.py @@ -37,8 +37,10 @@ def gcp_project_id() -> str: if adc_path: with open(adc_path) as fp: adc = json.load(fp) - if adc.get("quota_project_id") is not None: - return adc.get("quota_project_id") + if isinstance(adc, dict): + quota_project_id = adc.get("quota_project_id") + if isinstance(quota_project_id, str): + return quota_project_id return "__unknown_gcp_project__" diff --git a/src/sentry/utils/function_cache.py b/src/sentry/utils/function_cache.py index 08cebb50f819..b3aab737e749 100644 --- a/src/sentry/utils/function_cache.py +++ b/src/sentry/utils/function_cache.py @@ -6,7 +6,7 @@ from datetime import timedelta from decimal import Decimal from functools import partial, update_wrapper -from typing import Generic, TypeVar, TypeVarTuple +from typing import Generic, TypeVar, TypeVarTuple, cast from django.core.cache import cache from django.db import models @@ -131,7 +131,8 @@ def __call__(self, *args: *Ts) -> R: if cached_val is None: cached_val = self.func(*args) cache.set(cache_key, cached_val, timeout=self.cache_ttl.total_seconds()) - return cached_val + # justification: this cache only stores values returned by self.func. + return cast("R", cached_val) def batch(self, args_list: Sequence[tuple[*Ts]]) -> list[R]: cache_keys = [cache_key_for_cached_func(self.func, *args) for args in args_list] diff --git a/src/sentry/utils/geo.py b/src/sentry/utils/geo.py index 6461c84a0e58..5d0e03c194a5 100644 --- a/src/sentry/utils/geo.py +++ b/src/sentry/utils/geo.py @@ -53,6 +53,9 @@ def _init_geoip_rust() -> None: from sentry_relay.processing import GeoIpLookup + if settings.GEOIP_PATH_MMDB is None: + return + try: rust_geoip = GeoIpLookup.from_path(settings.GEOIP_PATH_MMDB) except Exception: diff --git a/src/sentry/utils/github.py b/src/sentry/utils/github.py index 3641b2ac8b5c..e98d66278cd3 100644 --- a/src/sentry/utils/github.py +++ b/src/sentry/utils/github.py @@ -30,7 +30,10 @@ def get(self, url: str) -> dict[str, Any]: ) except HTTPError as e: raise ApiError.from_response(e.response) - return resp.json() + data = resp.json() + if not isinstance(data, dict): + raise ValueError("GitHub returned a non-object response") + return data class GitHubKeysPayload(BaseModel): diff --git a/src/sentry/utils/http.py b/src/sentry/utils/http.py index 0119bb3b5aab..c63a5716f59c 100644 --- a/src/sentry/utils/http.py +++ b/src/sentry/utils/http.py @@ -248,7 +248,8 @@ def is_mcp_request(request: HttpRequest | Request) -> bool: Whether the request originated from the official Sentry MCP server, identified by its `sentry-mcp/` user-agent prefix. """ - return request.META.get("HTTP_USER_AGENT", "").startswith(MCP_USER_AGENT_PREFIX) + user_agent = request.META.get("HTTP_USER_AGENT", "") + return bool(isinstance(user_agent, str) and user_agent.startswith(MCP_USER_AGENT_PREFIX)) def get_mcp_client_family(request: HttpRequest | Request) -> str | None: @@ -261,10 +262,14 @@ def get_mcp_client_family(request: HttpRequest | Request) -> str | None: Declared by the client, so untrusted -- unlike `is_mcp_request`, which is derived. """ - family = request.META.get(MCP_CLIENT_FAMILY_HEADER, "").strip().lower() + family = request.META.get(MCP_CLIENT_FAMILY_HEADER, "") + if not isinstance(family, str): + return None + family = family.strip().lower() if not family or family in MCP_CATCHALL_CLIENT_FAMILIES: return None - return LEGACY_MCP_CLIENT_FAMILY_ALIASES.get(family, family) + alias = LEGACY_MCP_CLIENT_FAMILY_ALIASES.get(family, family) + return alias if isinstance(alias, str) else None def percent_encode(val: str) -> str: @@ -341,7 +346,7 @@ def request_body_stream( if uwsgi: if request.headers.get("Transfer-Encoding", "").lower() == "chunked": - def stream_generator(): + def stream_generator() -> Generator[bytes]: while True: chunk = uwsgi.chunked_read() if not chunk: diff --git a/src/sentry/utils/integrationdocs.py b/src/sentry/utils/integrationdocs.py index eef240f92f45..e57faa1793b7 100644 --- a/src/sentry/utils/integrationdocs.py +++ b/src/sentry/utils/integrationdocs.py @@ -37,6 +37,7 @@ def load_doc(path: str) -> dict[str, Any] | None: try: with open(doc_path, encoding="utf-8") as f: - return orjson.loads(f.read()) + doc = orjson.loads(f.read()) except OSError: return None + return doc if isinstance(doc, dict) else None 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/last_seen.py b/src/sentry/utils/last_seen.py index af41b338dcb6..70f2dd4b8687 100644 --- a/src/sentry/utils/last_seen.py +++ b/src/sentry/utils/last_seen.py @@ -8,6 +8,8 @@ from django.core.cache import cache from django.db.utils import OperationalError +from sentry.metrics.base import MutableTags + LAST_SEEN_INTERVAL_SECONDS = 60 @@ -36,7 +38,7 @@ def try_bump_last_seen( datetime: dt, bump_key: str, cache_key: str, - metrics_tags: dict[str, str], + metrics_tags: MutableTags, ) -> BumpResult: """Throttled last_seen bump — at most once per 60s per row via a cache-based lock.""" if instance.last_seen >= datetime - timedelta(seconds=LAST_SEEN_INTERVAL_SECONDS): diff --git a/src/sentry/utils/locking/backends/migration.py b/src/sentry/utils/locking/backends/migration.py index a2c97656adeb..f0d8fdbeccd6 100644 --- a/src/sentry/utils/locking/backends/migration.py +++ b/src/sentry/utils/locking/backends/migration.py @@ -34,7 +34,10 @@ def __init__(self, rate_option: str, check_new_option: str) -> None: self.check_new_option = check_new_option def rate(self) -> float: - return options.get(self.rate_option) + rate = options.get(self.rate_option) + if not isinstance(rate, (int, float)): + raise TypeError(f"{self.rate_option} must be a number") + return float(rate) def check_new(self) -> bool: return self.rate() > 0 or options.get(self.check_new_option) diff --git a/src/sentry/utils/managers.py b/src/sentry/utils/managers.py index 676c828100b7..1ffb46fb8ff3 100644 --- a/src/sentry/utils/managers.py +++ b/src/sentry/utils/managers.py @@ -2,31 +2,32 @@ class InstanceManager: - def __init__(self, class_list=None, instances=True): + def __init__(self, class_list: list[str] | None = None, instances: bool = True) -> None: if class_list is None: class_list = [] self.instances = instances + self.cache: list[object] | None = None self.update(class_list) - def get_class_list(self): + def get_class_list(self) -> list[str]: return self.class_list - def add(self, class_path): + def add(self, class_path: str) -> None: self.cache = None self.class_list.append(class_path) - def remove(self, class_path): + def remove(self, class_path: str) -> None: self.cache = None self.class_list.remove(class_path) - def update(self, class_list): + def update(self, class_list: list[str]) -> None: """ Updates the class list and wipes the cache. """ self.cache = None self.class_list = class_list - def all(self): + def all(self) -> list[object]: """ Returns a list of cached instances. """ @@ -38,7 +39,7 @@ def all(self): if self.cache is not None: return self.cache - results = [] + results: list[object] = [] for cls_path in class_list: module_name, class_name = cls_path.rsplit(".", 1) try: diff --git a/src/sentry/utils/marketo_client.py b/src/sentry/utils/marketo_client.py index 52a403bb32f1..35aed0bbf050 100644 --- a/src/sentry/utils/marketo_client.py +++ b/src/sentry/utils/marketo_client.py @@ -1,4 +1,5 @@ -from typing import TypedDict +from collections.abc import Mapping +from typing import Any, TypedDict, cast from django.conf import settings @@ -29,15 +30,26 @@ class MarketoClient: OAUTH_URL = "/identity/oauth/token" SUBMIT_FORM_URL = "/rest/v1/leads/submitForm.json" - def make_request(self, url: str, *args, method="GET", **kwargs): + def make_request( + self, url: str, *args: object, method: str = "GET", **kwargs: Any + ) -> dict[str, object]: base_url = settings.MARKETO_BASE_URL or "" full_url = base_url + url session = http.build_session() resp = getattr(session, method.lower())(full_url, *args, **kwargs) resp.raise_for_status() - return resp.json() + data = resp.json() + if not isinstance(data, dict): + raise ValueError("Marketo returned a non-object response") + return data - def make_rest_request(self, url: str, *args, headers=None, **kwargs): + def make_rest_request( + self, + url: str, + *args: object, + headers: dict[str, str] | None = None, + **kwargs: Any, + ) -> dict[str, object]: if headers is None: headers = {} headers["Authorization"] = f"Bearer {self.token}" @@ -45,19 +57,19 @@ def make_rest_request(self, url: str, *args, headers=None, **kwargs): data = self.make_request(url, *args, headers=headers, **kwargs) if not data.get("success"): - raise MarketoError(data) + raise MarketoError(cast(MarketoErrorResponse, data)) # not handling field level errors where success=True return data - def retrieve_token(self): + def retrieve_token(self) -> dict[str, object]: client_id = settings.MARKETO_CLIENT_ID clint_secret = settings.MARKETO_CLIENT_SECRET url = f"{self.OAUTH_URL}?grant_type=client_credentials&client_id={client_id}&client_secret={clint_secret}" return self.make_request(url) - def submit_form(self, fields): + def submit_form(self, fields: Mapping[str, object]) -> dict[str, object]: body = { "formId": settings.MARKETO_FORM_ID, "input": [ @@ -70,6 +82,6 @@ def submit_form(self, fields): return self.make_rest_request(self.SUBMIT_FORM_URL, method="POST", json=body) @property - def token(self): + def token(self) -> str: resp = self.retrieve_token() - return resp["access_token"] + return cast(str, resp["access_token"]) # justification: token response is decoded JSON. diff --git a/src/sentry/utils/meta.py b/src/sentry/utils/meta.py index 113bda0d2cb4..7c56ca7dfc4b 100644 --- a/src/sentry/utils/meta.py +++ b/src/sentry/utils/meta.py @@ -1,5 +1,5 @@ -from collections.abc import Mapping -from typing import Literal, NotRequired, TypedDict +from collections.abc import Iterator, Mapping +from typing import Literal, NotRequired, TypedDict, cast RemarkType = Literal["a", "x", "s", "m", "p", "e"] @@ -27,11 +27,13 @@ class Meta: dict. Alternatively, use the ``merge`` or ``add_error`` convenience methods. """ - def __init__(self, meta=None, path=None): + def __init__( + self, meta: dict[str, object] | None = None, path: list[str] | None = None + ) -> None: self._meta = {} if meta is None else meta self._path = path or [] - def enter(self, *path): + def enter(self, *path: object) -> "Meta": """ Enters into sub meta data at the specified path. This always returns a new ``Meta`` object, regardless whether the path already exists. @@ -39,13 +41,13 @@ def enter(self, *path): return Meta(self._meta, path=self._path + [str(p) for p in path]) @property - def path(self): + def path(self) -> str: """ Returns the full path of this meta instance, joined with dots ("."). """ return ".".join(self._path) - def raw(self): + def raw(self) -> dict[str, object]: """ Returns the raw meta tree at the current path, if it exists; otherwise an empty object. This will contain both the meta data of the key ("") @@ -56,19 +58,19 @@ def raw(self): """ meta = self._meta for key in self._path: - meta = meta.get(key) or {} + meta = cast(dict[str, object], meta.get(key) or {}) return meta - def get(self): + def get(self) -> dict[str, object]: """ Returns meta data of the item at the current path, or an empty dict. It is not safe to mutate the return value since it might be detached from the actual meta tree. """ - return self.raw().get("") or {} + return cast(dict[str, object], self.raw().get("") or {}) - def create(self): + def create(self) -> dict[str, object]: """ Creates an empty meta data entry corresponding to the current path. This recursively creates the entire parent tree. @@ -77,11 +79,11 @@ def create(self): for key in self._path + [""]: if key not in meta or meta[key] is None: meta[key] = {} - meta = meta[key] + meta = cast(dict[str, object], meta[key]) return meta - def merge(self, other): + def merge(self, other: "Meta") -> dict[str, object] | None: """ Merges meta data of the given other ``Meta`` object into the current path. @@ -89,20 +91,21 @@ def merge(self, other): If no meta data entry exists for the current path, it is created, along with the entire parent tree. """ - other = other.get() - if not other: - return + other_data = other.get() + if not other_data: + return None meta = self.create() - err = meta.get("err") - meta.update(other) + err = cast("list[object] | None", meta.get("err")) + meta.update(other_data) - if err and other.get("err"): - meta["err"] = err + other["err"] + other_err = cast("list[object] | None", other_data.get("err")) + if err and other_err: + meta["err"] = err + other_err return meta - def iter_errors(self): + def iter_errors(self) -> Iterator[list[object]]: """ Iterates over meta errors of the item at the current path, if any. @@ -110,19 +113,29 @@ def iter_errors(self): - ``type`` is the error constant also used in EventError - ``data`` a dictionary of additional error infos """ - return (([err, {}] if isinstance(err, str) else err) for err in self.get().get("err") or ()) + errs = cast( # justification: meta is decoded event JSON + "list[object] | None", self.get().get("err") + ) + return ( + [err, {}] + if isinstance(err, str) + else cast( # justification: meta is decoded event JSON + "list[object]", err + ) + for err in errs or () + ) - def get_event_errors(self): + def get_event_errors(self) -> list[dict[str, object]]: """ Returns all errors of the item at the current path in EventError schema, which can directly be stored to an event's "errors" list. """ - errors = [] + errors: list[dict[str, object]] = [] value = self.get().get("val") for error, data in self.iter_errors(): - eventerror = dict(data) + eventerror: dict[str, object] = dict(cast("Mapping[str, object]", data)) eventerror["type"] = error if self._path: @@ -136,7 +149,9 @@ def get_event_errors(self): return errors - def add_error(self, error, value=None, data=None): + def add_error( + self, error: object, value: object = None, data: Mapping[object, object] | None = None + ) -> None: """ Adds an error to the meta data at the current path. The ``error`` argument is converted to string. If optional ``data`` is specified, the @@ -153,15 +168,15 @@ def add_error(self, error, value=None, data=None): if "err" not in meta or meta["err"] is None: meta["err"] = [] - error = str(error) + error_entry: object = str(error) if isinstance(data, Mapping): - error = [error, dict(data)] - meta["err"].append(error) + error_entry = [error_entry, dict(data)] + cast("list[object]", meta["err"]).append(error_entry) if value is not None: meta["val"] = value - def add_remark(self, rem: Remark, value=None): + def add_remark(self, rem: Remark, value: object = None) -> None: """ Adds a remark to the meta data at the current path. @@ -188,12 +203,12 @@ def add_remark(self, rem: Remark, value=None): if range_end is not None: rem_list.append(range_end) - meta["rem"].append(rem_list) + cast("list[object]", meta["rem"]).append(rem_list) if value is not None: meta["val"] = value - def __iter__(self): + def __iter__(self) -> Iterator["Meta"]: """ Iterates all child meta entries that potentially have errors set. """ diff --git a/src/sentry/utils/mockdata/core.py b/src/sentry/utils/mockdata/core.py index 10f59a67d62a..c6b713d6805a 100644 --- a/src/sentry/utils/mockdata/core.py +++ b/src/sentry/utils/mockdata/core.py @@ -59,6 +59,7 @@ MonitorEnvironment, MonitorStatus, ) +from sentry.services.eventstore.models import Event from sentry.services.organization import organization_provisioning_service from sentry.signals import mocks_loaded from sentry.similarity import features @@ -103,13 +104,15 @@ 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)) -def create_sample_event(*args, **kwargs): +def create_sample_event( # justification: dev CLI forwards the sample factory signature. + *args: Any, **kwargs: Any +) -> Event | None: try: event = _create_sample_event(*args, **kwargs) except HashDiscarded as e: @@ -118,10 +121,11 @@ def create_sample_event(*args, **kwargs): if event is not None: features.record([event]) return event + return None -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 +150,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 @@ -170,11 +174,12 @@ def generate_tombstones(project, user): prev_group_id += 1 -def create_sample_time_series(event, release=None): +def create_sample_time_series(event: Event | None, release: Release | None = None) -> None: if event is None: return group = event.group + assert group is not None project = group.project key = project.key_set.all()[0] @@ -564,6 +569,7 @@ def generate_events( ) generated_events.append(event1) + assert event1 is not None EventAttachment.objects.create( project_id=project.id, event_id=event1.event_id, @@ -630,6 +636,7 @@ def generate_events( user=generate_user(), ) if event5: + assert event5.group is not None Commit.objects.get_or_create( organization_id=project.organization_id, repository_id=repository.id, @@ -647,6 +654,7 @@ def generate_events( create_sample_event(project=project, environment=next(ENVIRONMENTS), platform="csp") if event3: + assert event3.group is not None UserReport.objects.create( project_id=project.id, event_id=event3.event_id, @@ -660,8 +668,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 = [ @@ -810,7 +821,7 @@ def create_mock_transactions( if load_performance_issues: - def load_n_plus_one_issue(): + def load_n_plus_one_issue() -> None: trace_id = uuid4().hex transaction_user = generate_user() frontend_root_span_id = uuid4().hex[:16] @@ -830,7 +841,7 @@ def load_n_plus_one_issue(): "hash": "858fea692d4d93e8", } - def make_repeating_span(duration): + def make_repeating_span(duration: int) -> dict[str, object]: nonlocal n_plus_one_db_current_offset nonlocal n_plus_one_db_duration n_plus_one_db_duration += timedelta(milliseconds=duration) + timedelta( @@ -930,7 +941,7 @@ def make_repeating_span(duration): ], ) - def load_uncompressed_asset_issue(): + def load_uncompressed_asset_issue() -> None: time.sleep(1.0) transaction_user = generate_user() trace_id = uuid4().hex @@ -976,7 +987,7 @@ def load_uncompressed_asset_issue(): spans=[parent_span] + spans, ) - def load_consecutive_db_issue(): + def load_consecutive_db_issue() -> None: time.sleep(1.0) transaction_user = generate_user() trace_id = uuid4().hex @@ -1035,7 +1046,7 @@ def load_consecutive_db_issue(): spans=[parent_span] + spans, ) - def load_render_blocking_asset_issue(): + def load_render_blocking_asset_issue() -> None: transaction_user = generate_user() trace_id = uuid4().hex parent_span_id = uuid4().hex[:16] @@ -1069,14 +1080,14 @@ def load_render_blocking_asset_issue(): }, ) - def load_m_n_plus_one_issue(): + def load_m_n_plus_one_issue() -> None: trace_id = uuid4().hex transaction_user = generate_user() parent_span_id = uuid4().hex[:16] duration = 200 - def make_repeating_span(i): + def make_repeating_span(i: int) -> dict[str, object]: start_timestamp = timestamp + timedelta(milliseconds=i * (duration + 1)) end_timestamp = start_timestamp + timedelta(milliseconds=duration) op = "http" if i % 2 == 0 else "db" @@ -1119,7 +1130,7 @@ def make_repeating_span(i): spans=[parent_span] + repeating_spans, ) - def generate_performance_issues(): + def generate_performance_issues() -> None: click.echo(f" > Loading performance issues data") # NOQA click.echo(f" > Loading n plus one issue") # NOQA load_n_plus_one_issue() @@ -1135,7 +1146,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 +1180,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() @@ -1212,17 +1223,19 @@ def create_mock_user_feedback(project, has_attachment=True): create_feedback_issue(event, project, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE) if has_attachment: - create_mock_attachment(event["event_id"], project) + event_id = event["event_id"] + assert isinstance(event_id, str) + create_mock_attachment(event_id, project) 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/query.py b/src/sentry/utils/query.py index b20fe8e61fb2..bd47f040a49c 100644 --- a/src/sentry/utils/query.py +++ b/src/sentry/utils/query.py @@ -310,7 +310,10 @@ def get_total_count(self) -> int: "SELECT CAST(GREATEST(reltuples, 0) AS BIGINT) AS estimate FROM pg_class WHERE relname = %s", (self.queryset.model._meta.db_table,), ) - return cursor.fetchone()[0] + row = cursor.fetchone() + if row is None or not isinstance(row[0], int): + raise TypeError("Row estimate query returned no integer") + return row[0] class WithProgressBar[V]: diff --git a/src/sentry/utils/redis.py b/src/sentry/utils/redis.py index 1ffae902cfe5..cc5a8d943e7b 100644 --- a/src/sentry/utils/redis.py +++ b/src/sentry/utils/redis.py @@ -122,6 +122,8 @@ def _cfg(self, key: str) -> dict[str, Any]: if not self._supports(cfg): raise KeyError("Invalid cluster type, expected redis cluster") + if not isinstance(cfg, dict): + raise KeyError(f"Invalid cluster config: {key}") return cfg @overload diff --git a/src/sentry/utils/rollout.py b/src/sentry/utils/rollout.py index e3232252cd3f..5d5a199f4c15 100644 --- a/src/sentry/utils/rollout.py +++ b/src/sentry/utils/rollout.py @@ -326,7 +326,9 @@ def should_check_experiment(cls, callsite: str) -> bool: # just can't validate values inside a dictionary, hence the check above sample_rate = options.get(cls._experiment_sample_rate_option()) # Defaults to 1.0 - return random.random() < sample_rate + if not isinstance(sample_rate, (int, float)): + sample_rate = 1.0 + return bool(random.random() < sample_rate) @classmethod def should_use_experimental_data(cls, callsite: str) -> bool: diff --git a/src/sentry/utils/rust.py b/src/sentry/utils/rust.py index 3ad83a04434a..3635b5d13032 100644 --- a/src/sentry/utils/rust.py +++ b/src/sentry/utils/rust.py @@ -1,8 +1,11 @@ import re +from collections.abc import MutableMapping, Sequence +from typing import cast import sentry_sdk from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor +from sentry_sdk.types import Event, Hint from sentry.utils.safe import get_path @@ -96,12 +99,12 @@ } -def get_filename(abs_path): +def get_filename(abs_path: str) -> str: """Returns the basename of the given absolute path.""" return abs_path.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] -def strip_symbol(symbol): +def strip_symbol(symbol: str | None) -> str | None: """Strips rust function hashes of the given symbol.""" if symbol: match = HASH_FUNC_RE.match(symbol) @@ -111,12 +114,12 @@ def strip_symbol(symbol): return symbol -def demangle_rust(symbol): +def demangle_rust(symbol: str) -> str: """Demangles common escapes in the given rust symbol.""" return RUST_ESCAPES_RE.sub(lambda m: RUST_ESCAPES.get(m.group(1), ""), symbol) -def starts_with(function, pattern): +def starts_with(function: str, pattern: str) -> bool: """ Returns whether the given function name matches the pattern. This takes trait implementation and name mangling into account. @@ -124,17 +127,17 @@ def starts_with(function, pattern): return PATTERN_MATCH_RE.sub("", function).replace(".", ":").startswith(pattern) -def matches_frame(function, patterns): +def matches_frame(function: str, patterns: Sequence[str]) -> bool: """Returns whether the given function name matches any of the patterns.""" return any(starts_with(function, p) for p in patterns) -def frame_from_match(match, last_addr): +def frame_from_match(match: re.Match[str], last_addr: str | None) -> dict[str, object]: """Creates a sentry stack frame from a backtrace entry.""" - symbol = strip_symbol(match.group("symbol")) + symbol = cast("str", strip_symbol(match.group("symbol"))) function = demangle_rust(symbol) - frame = { + frame: dict[str, object] = { "function": function, "in_app": not matches_frame(function, SYSTEM_FRAMES), "instruction_addr": match.group("addr") or match.group("addr_linux") or last_addr, @@ -161,22 +164,26 @@ def frame_from_match(match, last_addr): return frame -def frames_from_rust_info(rust_info): +def frames_from_rust_info(rust_info: str) -> list[dict[str, object]]: """ Extracts a list of frames from the given rust_info string. Border frames from the python interpreter, FFI calls, panic unwinding and backtrace generation are trimmed off. """ - frames = [] - last_addr = None + frames: list[dict[str, object]] = [] + last_addr: str | None = None for m in FRAME_RE.finditer(rust_info): frame = frame_from_match(m, last_addr) - last_addr = frame["instruction_addr"] + last_addr = cast("str | None", frame["instruction_addr"]) frames.append(frame) end = next( - (i for i, f in enumerate(frames) if matches_frame(f["function"], OUTER_BORDER_FRAMES)), + ( + i + for i, f in enumerate(frames) + if matches_frame(cast("str", f["function"]), OUTER_BORDER_FRAMES) + ), len(frames), ) @@ -184,7 +191,7 @@ def frames_from_rust_info(rust_info): ( i for i, f in enumerate(reversed(frames)) - if matches_frame(f["function"], INNER_BORDER_FRAMES) + if matches_frame(cast("str", f["function"]), INNER_BORDER_FRAMES) ), 0, ) @@ -192,15 +199,15 @@ def frames_from_rust_info(rust_info): return frames[start:end] -def strip_backtrace_message(target, field): +def strip_backtrace_message(target: MutableMapping[str, object] | None, field: str) -> None: """ Strips the backtrace off a message, if it contains one. """ - if target and isinstance(target.get(field), str): - target[field] = target[field].split("\n\nstacktrace:", 1)[0].strip() + if target is not None and isinstance(value := target.get(field), str): + target[field] = value.split("\n\nstacktrace:", 1)[0].strip() -def merge_rust_info_frames(event, hint): +def merge_rust_info_frames(event: Event, hint: Hint) -> Event: """ Adds rust exception backtraces to the python traceback. @@ -215,8 +222,9 @@ def merge_rust_info_frames(event, hint): if "exc_info" not in hint: return event - exc_type, exc_value, tb = hint["exc_info"] - if not hasattr(exc_value, "rust_info") or not exc_value.rust_info: + _exc_type, exc_value, _tb = hint["exc_info"] + rust_info = cast("str | None", getattr(exc_value, "rust_info", None)) + if not rust_info: return event exception = get_path(event, "exception", "values", 0) @@ -225,17 +233,18 @@ def merge_rust_info_frames(event, hint): return event # Update the platform - event["platform"] = "native" + cast("dict[str, object]", event)["platform"] = "native" for frame in stacktrace: frame["platform"] = "python" # Remove rust_info from messages strip_backtrace_message(exception, "value") - strip_backtrace_message(event.get("logentry"), "message") - strip_backtrace_message(event.get("logentry"), "formatted") + logentry = cast("MutableMapping[str, object] | None", event.get("logentry")) + strip_backtrace_message(logentry, "message") + strip_backtrace_message(logentry, "formatted") # Extend the stacktrace - frames = frames_from_rust_info(exc_value.rust_info) + frames = frames_from_rust_info(rust_info) if frames: stacktrace.extend(reversed(frames)) @@ -246,9 +255,9 @@ class RustInfoIntegration(Integration): identifier = "rust_info" @staticmethod - def setup_once(): + def setup_once() -> None: @add_global_event_processor - def processor(event, hint): + def processor(event: Event, hint: Hint) -> Event: if sentry_sdk.get_client().get_integration(RustInfoIntegration) is None: return event diff --git a/src/sentry/utils/safe.py b/src/sentry/utils/safe.py index bd22dc1e7156..a6c5be7db1b2 100644 --- a/src/sentry/utils/safe.py +++ b/src/sentry/utils/safe.py @@ -38,12 +38,12 @@ def safe_execute(func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R | def trim( - value, - max_size=settings.SENTRY_MAX_VARIABLE_SIZE, - max_depth=6, - _depth=0, - _size=0, -): + value: Any, # justification: dynamic decoded JSON + max_size: int = settings.SENTRY_MAX_VARIABLE_SIZE, + max_depth: int = 6, + _depth: int = 0, + _size: int = 0, +) -> Any: # justification: dynamic decoded JSON """ Truncates a value to ```MAX_VARIABLE_SIZE```. @@ -424,7 +424,8 @@ def _trim_to_json_size(string_to_trim: str, max_json_bytes: int) -> str: return longest_okay_slice + "..." -def get_path(data: PathSearchable, *path, should_log=False, **kwargs): +# justification: framework kwargs +def get_path(data: PathSearchable, *path: Any, should_log: bool = False, **kwargs: Any) -> Any: """ Safely resolves data from a recursive data structure. A value is only returned if the full path exists, otherwise ``None`` is returned. @@ -473,7 +474,7 @@ def get_path(data: PathSearchable, *path, should_log=False, **kwargs): return data if data is not None else default -def set_path(data, *path, **kwargs): +def set_path(data: Any, *path: Any, **kwargs: Any) -> bool: # justification: framework kwargs """ Recursively traverses or creates the specified path and sets the given value argument. `None` is treated like a missing value. If a non-mapping item is @@ -513,7 +514,8 @@ def set_path(data, *path, **kwargs): return False -def setdefault_path(data, *path, **kwargs): +# justification: framework kwargs +def setdefault_path(data: Any, *path: Any, **kwargs: Any) -> bool: """ Recursively traverses or creates the specified path and sets the given value argument if it does not exist. `None` is treated like a missing value. If a @@ -526,7 +528,7 @@ def setdefault_path(data, *path, **kwargs): return set_path(data, *path, **kwargs) -def safe_urlencode(query, **kwargs): +def safe_urlencode(query: Any, **kwargs: Any) -> str: # justification: framework kwargs """ django.utils.http.urlencode wrapper that replaces query parameter values of None with empty string so that urlencode doesn't raise TypeError diff --git a/src/sentry/utils/samples.py b/src/sentry/utils/samples.py index 9647c1648924..305995c4449f 100644 --- a/src/sentry/utils/samples.py +++ b/src/sentry/utils/samples.py @@ -2,7 +2,9 @@ import os.path import random import time +from collections.abc import Sequence from datetime import datetime, timedelta, timezone +from typing import Any, cast from uuid import uuid4 import sentry_sdk @@ -11,6 +13,8 @@ from sentry.constants import DATA_ROOT, INTEGRATION_ID_TO_PLATFORM_DATA, STATIC_ROOT from sentry.event_manager import EventManager, set_tag from sentry.interfaces.user import User as UserInterface +from sentry.models.project import Project +from sentry.services.eventstore.models import Event from sentry.spans.grouping.utils import hash_values from sentry.utils import json from sentry.utils.dates import deprecated_utcnow @@ -20,14 +24,14 @@ 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) return random_value -def random_ip(): +def random_ip() -> str: not_valid = [10, 127, 169, 172, 192] first = random.randrange(1, 256) @@ -44,7 +48,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 +58,7 @@ def random_geo(): ) -def random_username(): +def random_username() -> str: return random.choice( [ "jess", @@ -73,7 +77,7 @@ def random_username(): ) -def name_for_username(username): +def name_for_username(username: str) -> str: return { "ben": "Ben Vinegar", "chris": "Chris Jennings", @@ -89,35 +93,42 @@ def name_for_username(username): }.get(username, username.replace("_", " ").title()) -def generate_user(username=None, email=None, ip_address=None, id=None): +def generate_user( + username: str | None = None, + email: str | None = None, + ip_address: str | None = None, + id: int | None = None, +) -> dict[str, Any]: if username is None and email is None: username = random_username() email = f"{username}@example.com" - return UserInterface.to_python( + user = UserInterface.to_python( { "id": id, "username": username, "email": email, "ip_address": ip_address or random_ip(), - "name": name_for_username(username), + "name": name_for_username(cast(str, username)), "geo": random_geo(), } - ).to_json() + ) + assert user is not None + return user.to_json() def load_data( - platform: str, + platform: str | None, 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, +) -> dict[str, Any]: # 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 # has production consequences. @@ -128,7 +139,7 @@ def load_data( # a fake event is generated to go through the pipeline. data = None language = None - platform_data = INTEGRATION_ID_TO_PLATFORM_DATA.get(platform) + platform_data = INTEGRATION_ID_TO_PLATFORM_DATA.get(platform or "") if platform_data is not None and platform_data["type"] != "language": language = platform_data["language"] @@ -162,7 +173,7 @@ def load_data( if not os.path.isfile(json_path): raise IsADirectoryError("expected file but found a directory instead") - if not sample_name: + if not sample_name and platform is not None: try: sample_name = INTEGRATION_ID_TO_PLATFORM_DATA[platform]["name"] except KeyError: @@ -176,7 +187,11 @@ def load_data( break if data is None: - return + # justification: callers probe for a missing sample and treat None as absent. + return cast("dict[str, Any]", None) + + if not isinstance(data, dict): + raise ValueError("Sample data is not a JSON object") if platform in ("csp", "hkpk", "expectct", "expectstaple"): return data @@ -309,7 +324,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) @@ -323,7 +338,7 @@ def load_console_screenshot(): return None -def create_console_screenshot_attachment(event, project, platform): +def create_console_screenshot_attachment(event: Event, project: Project, platform: str) -> None: from sentry.attachments.base import CachedAttachment from sentry.models.eventattachment import EventAttachment @@ -348,6 +363,7 @@ def create_console_screenshot_attachment(event, project, platform): file_result = EventAttachment.putfile(project.id, attachment) + assert attachment.name is not None EventAttachment.objects.create( event_id=event.event_id, project_id=project.id, @@ -364,7 +380,7 @@ def create_console_screenshot_attachment(event, project, platform): return None -def create_n_plus_one_issue(data): +def create_n_plus_one_issue(data: dict[str, Any]) -> None: timestamp = datetime.fromtimestamp(data["start_timestamp"]) n_plus_one_db_duration = timedelta(milliseconds=100) n_plus_one_db_current_offset = timestamp @@ -417,7 +433,7 @@ def create_n_plus_one_issue(data): ) -def create_db_main_thread_issue(data): +def create_db_main_thread_issue(data: dict[str, Any]) -> None: timestamp = datetime.fromtimestamp(data["start_timestamp"]) span_duration = timedelta(milliseconds=100) parent_span_id = data["spans"][0]["parent_span_id"] @@ -444,22 +460,22 @@ def create_db_main_thread_issue(data): def create_sample_event( - project, - platform=None, - default=None, - raw=True, - sample_name=None, - timestamp=None, - start_timestamp=None, - trace=None, - span_id=None, - spans=None, - tagged=False, - performance_issues=None, - **kwargs, -): + project: Project, + platform: str | None = None, + default: str | None = None, + raw: bool = True, + sample_name: str | None = None, + timestamp: datetime | None = None, + start_timestamp: datetime | None = None, + trace: str | None = None, + span_id: str | None = None, + spans: Sequence[dict[str, object]] | None = None, + tagged: bool = False, + performance_issues: Sequence[str] | None = None, + **kwargs: Any, +) -> Event | None: if not platform and not default: - return + return None data = load_data( platform, @@ -471,6 +487,7 @@ def create_sample_event( span_id, spans, ) + assert data is not None for key in ["parent_span_id", "hash", "exclusive_time"]: if key in kwargs: @@ -490,8 +507,12 @@ def create_sample_event( def create_sample_event_basic( - data, project_id, raw=True, skip_send_first_transaction=False, tagged=False -): + data: dict[str, Any], + project_id: int, + raw: bool = True, + skip_send_first_transaction: bool = False, + tagged: bool = False, +) -> Event: if tagged: set_tag(data, "sample_event", "yes") manager = EventManager(data) @@ -501,7 +522,15 @@ def create_sample_event_basic( ) -def create_trace(slow, start_timestamp, timestamp, user, trace_id, parent_span_id, data): +def create_trace( + slow: bool, + start_timestamp: datetime, + timestamp: datetime, + user: object, + trace_id: str, + parent_span_id: str | None, + data: dict[str, Any], +) -> None: """A recursive function that creates the events of a trace""" frontend = data.get("frontend") mobile = data.get("mobile") diff --git a/src/sentry/utils/sdk.py b/src/sentry/utils/sdk.py index 20249aebffb1..a269d0aa1478 100644 --- a/src/sentry/utils/sdk.py +++ b/src/sentry/utils/sdk.py @@ -42,6 +42,7 @@ # Can't import models in utils because utils should be the bottom of the food chain if TYPE_CHECKING: from sentry.models.organization import Organization + from sentry.models.projectkey import ProjectKey from sentry.organizations.services.organization import RpcOrganization @@ -121,7 +122,7 @@ def _current_stack_filenames() -> Generator[str]: f = f.f_back -def is_current_event_safe(): +def is_current_event_safe() -> bool: """ Tests the current stack for unsafe locations that would likely cause recursion if an attempt to send to Sentry was made. @@ -144,7 +145,7 @@ def is_current_event_safe(): return True -def set_current_event_project(project_id): +def set_current_event_project(project_id: int) -> None: """ Set the current project on the SDK isolation scope for outgoing crash reports. @@ -161,7 +162,7 @@ def set_current_event_project(project_id): scope.set_attribute("project", project_id) -def get_project_key(): +def get_project_key() -> ProjectKey | None: from sentry.models.projectkey import ProjectKey if not settings.SENTRY_PROJECT: @@ -197,7 +198,7 @@ def get_project_key(): return key -def traces_sampler(sampling_context): +def traces_sampler(sampling_context: dict[str, Any]) -> float: wsgi_path = sampling_context.get("wsgi_environ", {}).get("PATH_INFO") if wsgi_path: if wsgi_path in SAMPLED_ROUTES: @@ -211,8 +212,9 @@ def traces_sampler(sampling_context): return float(custom_sample_rate) # If there's already a sampling decision, just use that - if sampling_context["parent_sampled"] is not None: - return sampling_context["parent_sampled"] + parent_sampled = sampling_context["parent_sampled"] + if parent_sampled is not None: + return float(parent_sampled) if "taskworker" in sampling_context: task_name = sampling_context["taskworker"].get("task") @@ -224,7 +226,7 @@ def traces_sampler(sampling_context): return float(settings.SENTRY_BACKEND_APM_SAMPLING or 0) -def profiles_sampler(sampling_context): +def profiles_sampler(sampling_context: dict[str, Any]) -> float: PROFILES_SAMPLING_RATE = { "consumer.join": options.get("consumer.join.profiling.rate"), "spans.process.process_message": options.get("spans.process-spans.profiling.rate"), @@ -232,8 +234,8 @@ def profiles_sampler(sampling_context): if "transaction_context" in sampling_context: transaction_name = sampling_context["transaction_context"].get("name") - if transaction_name in PROFILES_SAMPLING_RATE: - return PROFILES_SAMPLING_RATE[transaction_name] + if isinstance(transaction_name, str) and transaction_name in PROFILES_SAMPLING_RATE: + return float(PROFILES_SAMPLING_RATE[transaction_name]) # Default to the sampling rate in settings return float(settings.SENTRY_PROFILES_SAMPLE_RATE or 0) @@ -347,7 +349,7 @@ def _get_sdk_options() -> tuple[SdkConfig, Dsns]: return sdk_options, dsns -def configure_sdk(): +def configure_sdk() -> None: """ Setup and initialize the Sentry SDK. """ @@ -529,8 +531,11 @@ def check_current_scope_transaction( def capture_exception_with_scope_check( - error, scope: Scope | None = None, request: Request | None = None, **scope_args -): + error: BaseException, + scope: Scope | None = None, + request: Request | None = None, + **scope_args: Any, +) -> str | None: """ A wrapper around `sentry_sdk.capture_exception` which checks scope `transaction` against the given Request object, to help debug scope bleed problems. @@ -635,7 +640,7 @@ def parse_org_slug(x: Organization | RpcOrganization | str) -> str: scope.set_attribute("organization.source", source or "unknown") -def get_trace_id(): +def get_trace_id() -> str | None: span = traces.get_current_span() if isinstance(span, traces.StreamedSpan): return span.trace_id @@ -722,7 +727,7 @@ class SentrySDKLogger: DEFAULT_MAX_DEPTH = 6 - def __init__(self): + def __init__(self) -> None: self.trace = functools.partial(self._log_to_sentry, "trace") self.debug = functools.partial(self._log_to_sentry, "debug") self.info = functools.partial(self._log_to_sentry, "info") diff --git a/src/sentry/utils/security/orgauthtoken_token.py b/src/sentry/utils/security/orgauthtoken_token.py index 4dd792afb972..36a16093128b 100644 --- a/src/sentry/utils/security/orgauthtoken_token.py +++ b/src/sentry/utils/security/orgauthtoken_token.py @@ -43,7 +43,7 @@ def parse_token(token: str) -> dict[str, Any] | None: try: payload_str = b64decode((payload_hashed).encode("ascii")).decode("ascii") payload = json.loads(payload_str) - if not payload.get("iat"): + if not isinstance(payload, dict) or not payload.get("iat"): return None return payload except Exception: diff --git a/src/sentry/utils/sentry_apps/request_buffer.py b/src/sentry/utils/sentry_apps/request_buffer.py index d1c70c650836..fbc01f3152da 100644 --- a/src/sentry/utils/sentry_apps/request_buffer.py +++ b/src/sentry/utils/sentry_apps/request_buffer.py @@ -2,7 +2,7 @@ import logging from collections.abc import Mapping -from typing import TYPE_CHECKING, NotRequired, TypedDict, overload +from typing import TYPE_CHECKING, NotRequired, TypedDict, cast, overload from dateutil.parser import parse as parse_date from django.conf import settings @@ -90,9 +90,12 @@ def _convert_redis_request(self, redis_request: str, event: str) -> SentryAppReq Add the event type to the dict so that the request can be identified correctly """ request = json.loads(redis_request) + if not isinstance(request, dict): + raise TypeError("Buffered Sentry app request is not an object") request["event_type"] = event - return request + # justification: the buffer stores SentryAppRequest payloads. + return cast("SentryAppRequest", request) def _add_to_buffer_pipeline( self, buffer_key: str, item: object, pipeline: Pipeline[str] diff --git a/src/sentry/utils/sentry_apps/webhooks.py b/src/sentry/utils/sentry_apps/webhooks.py index 2a454c7c6829..7bf98a5eea42 100644 --- a/src/sentry/utils/sentry_apps/webhooks.py +++ b/src/sentry/utils/sentry_apps/webhooks.py @@ -92,7 +92,7 @@ def ignore_unpublished_app_errors( func: Callable[Concatenate[SentryApp | RpcSentryApp, P], R], ) -> Callable[Concatenate[SentryApp | RpcSentryApp, P], R | None]: def wrapper( - sentry_app: SentryApp | RpcSentryApp, *args: P.args, **kwargs: P.kwargs + sentry_app: SentryApp | RpcSentryApp, /, *args: P.args, **kwargs: P.kwargs ) -> R | None: try: return func(sentry_app, *args, **kwargs) @@ -260,11 +260,15 @@ def _webhook_timeout( def _get_webhook_timeout(org_id: int) -> float: timeout_seconds = options.get("sentry-apps.webhook.timeout.sec") + if not isinstance(timeout_seconds, (int, float)): + raise TypeError("sentry-apps.webhook.timeout.sec must be a number") + timeout_seconds = float(timeout_seconds) overrides = options.get("sentry-apps.override.organization_ids.webhook.timeouts.sec").get( str(org_id) ) if overrides: - return overrides.get("webhook_timeout_override", timeout_seconds) + override = overrides.get("webhook_timeout_override", timeout_seconds) + return float(override) if isinstance(override, (int, float)) else timeout_seconds return timeout_seconds diff --git a/src/sentry/utils/services.py b/src/sentry/utils/services.py index 1c17f2622c06..d29805bdb44e 100644 --- a/src/sentry/utils/services.py +++ b/src/sentry/utils/services.py @@ -6,7 +6,7 @@ import logging import threading from collections.abc import Callable, Mapping, Sequence -from typing import Any +from typing import Any, cast from sentry import options from sentry.conf.types.service_options import ServiceOptions @@ -24,7 +24,8 @@ def resolve_callable[CallableT: Callable[..., object]](value: str | CallableT) -> CallableT: if isinstance(value, str): - return import_string(value) + # justification: the configured import path names a CallableT. + return cast("CallableT", import_string(value)) elif callable(value): return value else: @@ -286,9 +287,10 @@ def call_backend_method(context: Context, backend: Service, is_primary: bool) -> block=True, ) - if self.callback is not None: + callback = self.callback + if callback is not None: FutureSet([_f for _f in results if _f]).add_done_callback( - lambda *a, **k: self.callback( + lambda *a, **k: callback( context, attribute_name, callargs, selected_backend_names, results ) ) diff --git a/src/sentry/utils/session_store.py b/src/sentry/utils/session_store.py index 1c0965ded955..5b004967a0df 100644 --- a/src/sentry/utils/session_store.py +++ b/src/sentry/utils/session_store.py @@ -1,7 +1,13 @@ +from __future__ import annotations + +from collections.abc import Mapping from uuid import uuid4 import sentry_sdk from django.conf import settings +from django.http import HttpRequest +from redis.client import StrictRedis +from sentry_redis_tools.clients import RedisCluster from sentry.utils import redis from sentry.utils.json import dumps, loads @@ -51,13 +57,13 @@ class RedisSessionStore: redis_namespace = "session-cache" - def __init__(self, request, prefix, ttl=EXPIRATION_TTL): + def __init__(self, request: HttpRequest, prefix: str, ttl: int = EXPIRATION_TTL) -> None: self.request = request self.prefix = prefix self.ttl = ttl @property - def _client(self): + def _client(self) -> RedisCluster[str] | StrictRedis[str]: return redis.redis_clusters.get(settings.SENTRY_SESSION_STORE_REDIS_CLUSTER) @property @@ -65,14 +71,14 @@ def session_key(self) -> str: return f"store:{self.prefix}" @property - def redis_key(self): + def redis_key(self) -> str | None: return self.request.session.get(self.session_key) - def mark_session(self): + def mark_session(self) -> None: # Subclasses may override to mark session as modified pass - def regenerate(self, initial_state=None): + def regenerate(self, initial_state: Mapping[str, object] | None = None) -> None: if initial_state is None: initial_state = {} @@ -84,7 +90,7 @@ def regenerate(self, initial_state=None): value = dumps(initial_state) self._client.setex(redis_key, self.ttl, value) - def clear(self): + def clear(self) -> None: if not self.redis_key: return @@ -94,10 +100,10 @@ def clear(self): del session[self.session_key] self.mark_session() - def is_valid(self): + def is_valid(self) -> bool: return bool(self.redis_key and self.get_state() is not None) - def get_state(self): + def get_state(self) -> dict[str, object] | None: if not self.redis_key: return None @@ -106,16 +112,17 @@ def get_state(self): return None try: - return loads(state_json) + state = loads(state_json) + return state if isinstance(state, dict) else None except Exception as e: sentry_sdk.capture_exception(e) return None -def redis_property(key: str): +def redis_property(key: str) -> property: """Declare a property backed by Redis on a RedisSessionStore class.""" - def getter(store: "RedisSessionStore"): + def getter(store: RedisSessionStore) -> object | None: state = store.get_state() try: @@ -123,13 +130,15 @@ def getter(store: "RedisSessionStore"): except KeyError as e: raise AttributeError(e) - def setter(store: "RedisSessionStore", value): + def setter(store: RedisSessionStore, value: object) -> None: state = store.get_state() if state is None: return state[key] = value - store._client.setex(store.redis_key, store.ttl, dumps(state)) + redis_key = store.redis_key + assert redis_key is not None + store._client.setex(redis_key, store.ttl, dumps(state)) return property(getter, setter) diff --git a/src/sentry/utils/snowflake.py b/src/sentry/utils/snowflake.py index 2d46cbf8cd8f..610d2956ed97 100644 --- a/src/sentry/utils/snowflake.py +++ b/src/sentry/utils/snowflake.py @@ -60,7 +60,7 @@ def save_with_snowflake_id( save_callback() return except IntegrityError: - instance.id = None + setattr(instance, "id", None) raise MaxSnowflakeRetryError diff --git a/src/sentry/utils/snuba.py b/src/sentry/utils/snuba.py index b903f0157fc8..eaa3b64f20e6 100644 --- a/src/sentry/utils/snuba.py +++ b/src/sentry/utils/snuba.py @@ -7,12 +7,21 @@ import os import re from collections import namedtuple -from collections.abc import Callable, Collection, Mapping, MutableMapping, Sequence +from collections.abc import ( + Callable, + Collection, + Iterable, + Iterator, + Mapping, + MutableMapping, + Sequence, +) from contextlib import contextmanager from copy import deepcopy from datetime import datetime, timedelta, timezone from hashlib import sha1 -from typing import Any +from types import TracebackType +from typing import Any, Literal, Self, cast, overload from urllib.parse import urlparse import sentry_sdk @@ -21,11 +30,14 @@ from dateutil.parser import parse as parse_datetime from django.conf import settings from django.core.cache import cache +from django.db.models import Model from sentry_sdk import traces from sentry_sdk.traces import StreamedSpan from snuba_sdk import Column, DeleteQuery, Function, MetricsQuery, Request from snuba_sdk.legacy import json_to_snql from snuba_sdk.query import SelectableExpression +from urllib3.connectionpool import ConnectionPool +from urllib3.response import BaseHTTPResponse from sentry.api.helpers.error_upsampling import ( UPSAMPLED_ERROR_AGGREGATION, @@ -82,7 +94,7 @@ SNUBA_INFO_FILE = os.environ.get("SENTRY_SNUBA_INFO_FILE", "") -def log_snuba_info(content): +def log_snuba_info(content: str) -> None: if SNUBA_INFO_FILE: with open(SNUBA_INFO_FILE, "a") as file: file.writelines(content) @@ -343,7 +355,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) @@ -488,7 +500,7 @@ class QueryOutsideGroupActivityError(Exception): @contextmanager -def options_override(overrides): +def options_override(overrides: Mapping[str, bool]) -> Iterator[None]: """\ NOT THREAD SAFE! @@ -523,13 +535,13 @@ class RetrySkipTimeout(urllib3.Retry): def increment( self, - method=None, - url=None, - response=None, - error=None, - _pool=None, - _stacktrace=None, - ): + method: str | None = None, + url: str | None = None, + response: BaseHTTPResponse | None = None, + error: Exception | None = None, + _pool: ConnectionPool | None = None, + _stacktrace: TracebackType | None = None, + ) -> Self: """ Just rely on the parent class unless we have a read timeout. In that case immediately give up @@ -593,7 +605,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 +618,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 +653,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 +690,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: @@ -742,7 +755,9 @@ def get_query_params_to_update_for_projects( return organization_id, params -def get_query_params_to_update_for_organizations(query_params): +def get_query_params_to_update_for_organizations( + query_params: SnubaQueryParams, +) -> tuple[int, dict[str, Any]]: """ Get the organization ID and query params that need to be updated for organization based datasets, before we send the query to Snuba. @@ -810,7 +825,9 @@ def _prepare_start_end( return start, end -def _prepare_query_params(query_params: SnubaQueryParams, referrer: str | None = None): +def _prepare_query_params( + query_params: SnubaQueryParams, referrer: str | None = None +) -> tuple[dict[str, Any], Translator, Translator]: kwargs = deepcopy(query_params.kwargs) query_params_conditions = deepcopy(query_params.conditions) @@ -905,18 +922,18 @@ class SnubaQueryParams: def __init__( self, - dataset=None, + dataset: Dataset | None = None, start: datetime | None = None, end: datetime | None = None, - groupby=None, - conditions=None, - filter_keys=None, - aggregations=None, - rollup=None, - referrer=None, - is_grouprelease=False, - **kwargs, - ): + groupby: list[str] | None = None, + conditions: list[tuple[str, str, Any] | list[Any]] | None = None, + filter_keys: dict[str, Sequence[int]] | None = None, + aggregations: list[list[Any]] | None = None, + rollup: int | None = None, + referrer: str | None = None, + is_grouprelease: bool = False, + **kwargs: Any, + ) -> None: # TODO: instead of having events be the default, make dataset required. self.dataset = dataset or Dataset.Events self.start = start or datetime( @@ -946,17 +963,17 @@ def __init__( }: self._preprocess_group_id_redirects() - def _preprocess_group_id_redirects(self): + def _preprocess_group_id_redirects(self) -> None: # Conditions are a series of "AND" statements. For "group_id", to dedupe, # we pre-collapse these. This helps us reduce the size of queries. - in_groups = None - out_groups: set[int | str] = set() + in_groups: set[int] | None = None + out_groups: set[int] = set() if "group_id" in self.filter_keys: self.filter_keys = self.filter_keys.copy() in_groups = set(self.filter_keys["group_id"]) del self.filter_keys["group_id"] - new_conditions = [] + new_conditions: list[tuple[str, str, Any] | list[Any]] = [] for triple in self.conditions: if triple[0] != "group_id": @@ -981,41 +998,41 @@ def _preprocess_group_id_redirects(self): elif op == "!=": out_groups.add(triple[2]) - out_groups = get_all_merged_group_ids(out_groups) - triple = None + merged_out_groups = get_all_merged_group_ids(out_groups) + new_triple: list[Any] | None = None # If there is an "IN" statement, we don't need a "NOT IN" statement. We can # just subtract the NOT IN groups from the IN groups. if in_groups is not None: - in_groups.difference_update(out_groups) + in_groups.difference_update(merged_out_groups) # An "group_id IN ()" clause breaks clickhouse. # Better to make the exception (& expectations) clear. if len(in_groups) > 0: - triple = ["group_id", "IN", get_all_merged_group_ids(in_groups)] + new_triple = ["group_id", "IN", get_all_merged_group_ids(in_groups)] else: raise EmptyGroupIdIntersectionError("Found empty intersection of group_ids") - elif len(out_groups) > 0: - triple = ["group_id", "NOT IN", out_groups] + elif len(merged_out_groups) > 0: + new_triple = ["group_id", "NOT IN", merged_out_groups] - if triple is not None: - new_conditions.append(triple) + if new_triple is not None: + new_conditions.append(new_triple) self.conditions = new_conditions def raw_query( - dataset=None, - start=None, - end=None, - groupby=None, - conditions=None, - filter_keys=None, - aggregations=None, - rollup=None, - referrer=None, - is_grouprelease=False, - use_cache=False, - **kwargs, + dataset: Dataset | None = None, + start: datetime | None = None, + end: datetime | None = None, + groupby: list[str] | None = None, + conditions: list[tuple[str, str, Any] | list[Any]] | None = None, + filter_keys: dict[str, Sequence[int]] | None = None, + aggregations: list[list[Any]] | None = None, + rollup: int | None = None, + referrer: str | None = None, + is_grouprelease: bool = False, + use_cache: bool = False, + **kwargs: Any, ) -> Mapping[str, Any]: """ Sends a query to snuba. See `SnubaQueryParams` docstring for param @@ -1055,7 +1072,7 @@ class SnubaRequest: def __post_init__(self) -> None: self.validate() - def validate(self): + def validate(self) -> None: if self.referrer: validate_referrer(self.referrer) @@ -1508,8 +1525,9 @@ def _raw_delete_query( "snuba.referrer": referrer, }, ): - return _snuba_pool.urlopen( - "DELETE", f"/{query.storage_name}", body=body, headers=headers + return cast( + urllib3.response.HTTPResponse, + _snuba_pool.urlopen("DELETE", f"/{query.storage_name}", body=body, headers=headers), ) @@ -1536,8 +1554,9 @@ def _raw_mql_query(request: Request, headers: Mapping[str, str]) -> urllib3.resp "snuba.referrer": referrer, }, ): - return _snuba_pool.urlopen( - "POST", f"/{request.dataset}/mql", body=body, headers=headers + return cast( + urllib3.response.HTTPResponse, + _snuba_pool.urlopen("POST", f"/{request.dataset}/mql", body=body, headers=headers), ) @@ -1563,35 +1582,88 @@ def _raw_snql_query(request: Request, headers: Mapping[str, str]) -> urllib3.res "snuba.referrer": referrer, }, ): - return _snuba_pool.urlopen( - "POST", f"/{request.dataset}/snql", body=body, headers=headers + return cast( + urllib3.response.HTTPResponse, + _snuba_pool.urlopen("POST", f"/{request.dataset}/snql", body=body, headers=headers), ) +@overload +def query( + dataset: Dataset | None = None, + start: datetime | None = None, + end: datetime | None = None, + groupby: list[str] | None = None, + conditions: Sequence[tuple[str, str, Any] | Sequence[Any]] | None = None, + filter_keys: Mapping[str, Sequence[Any]] | None = None, + aggregations: Sequence[Sequence[Any]] | None = None, + selected_columns: Sequence[Any] | None = None, + totals: Literal[False] | None = False, + use_cache: bool = False, + **kwargs: Any, +) -> dict[str | int, Any]: ... + + +@overload +def query( + dataset: Dataset | None = None, + start: datetime | None = None, + end: datetime | None = None, + groupby: list[str] | None = None, + conditions: Sequence[tuple[str, str, Any] | Sequence[Any]] | None = None, + filter_keys: Mapping[str, Sequence[Any]] | None = None, + aggregations: Sequence[Sequence[Any]] | None = None, + selected_columns: Sequence[Any] | None = None, + totals: Literal[True] = True, + use_cache: bool = False, + **kwargs: Any, +) -> tuple[dict[str | int, Any], Any]: ... + + +@overload +def query( + dataset: Dataset | None = None, + start: datetime | None = None, + end: datetime | None = None, + groupby: list[str] | None = None, + conditions: Sequence[tuple[str, str, Any] | Sequence[Any]] | None = None, + filter_keys: Mapping[str, Sequence[Any]] | None = None, + aggregations: Sequence[Sequence[Any]] | None = None, + selected_columns: Sequence[Any] | None = None, + totals: bool | None = None, + use_cache: bool = False, + **kwargs: Any, +) -> dict[str | int, Any] | tuple[dict[str | int, Any], Any]: ... + + def query( - dataset=None, + dataset: Dataset | None = None, start: datetime | None = None, end: datetime | None = None, - groupby=None, - conditions=None, - filter_keys=None, - aggregations=None, - selected_columns=None, - totals=None, - use_cache=False, - **kwargs, -): - aggregations = aggregations or [["count()", "", "aggregate"]] - filter_keys = filter_keys or {} - selected_columns = selected_columns or [] - groupby = groupby or [] + groupby: list[str] | None = None, + conditions: Sequence[tuple[str, str, Any] | Sequence[Any]] | None = None, + filter_keys: Mapping[str, Sequence[Any]] | None = None, + aggregations: Sequence[Sequence[Any]] | None = None, + selected_columns: Sequence[Any] | None = None, + totals: bool | None = None, + use_cache: bool = False, + **kwargs: Any, +) -> dict[str | int, Any] | tuple[dict[str | int, Any], Any]: + parsed_aggregations: list[list[Any]] = ( + [list(a) for a in aggregations] if aggregations else [["count()", "", "aggregate"]] + ) + filter_keys = dict(filter_keys) if filter_keys else {} + selected_columns = list(selected_columns) if selected_columns else [] + groupby = list(groupby) if groupby else [] if dataset == Dataset.Events and filter_keys.get("project_id"): project_filter = filter_keys.get("project_id") - project_ids = ( - project_filter if isinstance(project_filter, (list, tuple)) else [project_filter] + project_ids: Sequence[int] = ( + cast("Sequence[int]", project_filter) + if isinstance(project_filter, (list, tuple)) + else [cast("int", project_filter)] ) - _convert_count_aggregations_for_error_upsampling(aggregations, project_ids) + _convert_count_aggregations_for_error_upsampling(parsed_aggregations, project_ids) try: body = raw_query( @@ -1599,9 +1671,14 @@ def query( start=start, end=end, groupby=groupby, - conditions=conditions, - filter_keys=filter_keys, - aggregations=aggregations, + conditions=( + cast(list[tuple[str, str, Any] | list[Any]], list(conditions)) + if conditions is not None + else None + ), + # justification: Snuba accepts string event ids in filter_keys despite the int-typed signature. + filter_keys=cast(dict[str, Sequence[int]], filter_keys), + aggregations=parsed_aggregations, selected_columns=selected_columns, totals=totals, use_cache=use_cache, @@ -1614,7 +1691,7 @@ def query( return {} # Validate and scrub response, and translate snuba keys back to IDs - aggregate_names = [a[2] for a in aggregations] + aggregate_names = [a[2] for a in parsed_aggregations] selected_names = [c[2] if isinstance(c, (list, tuple)) else c for c in selected_columns] expected_cols = set(groupby + aggregate_names + selected_names) got_cols = {c["name"] for c in body["meta"]} @@ -1632,10 +1709,10 @@ def query( def nest_groups( - data: Sequence[MutableMapping], + data: Sequence[MutableMapping[str, Any]], groups: Sequence[str] | None, aggregate_cols: Sequence[str], -) -> dict | Any: +) -> dict[Any, Any] | Any: """ Build a nested mapping from query response rows. Each group column gives a new level of nesting and the leaf result is the aggregate @@ -1655,8 +1732,8 @@ def nest_groups( return {k: nest_groups(v, rest, aggregate_cols) for k, v in inter.items()} -def resolve_column(dataset) -> Callable: - def _resolve_column(col): +def resolve_column(dataset: Dataset) -> Callable[[Any], Any]: + def _resolve_column(col: Any) -> Any: if col is None: return col if isinstance(col, int) or isinstance(col, float): @@ -1722,7 +1799,7 @@ def _resolve_column(col): return _resolve_column -def resolve_condition(cond: list, column_resolver: Callable[[Any], Any]) -> list: +def resolve_condition(cond: list[Any], column_resolver: Callable[[Any], Any]) -> list[Any]: """ When conditions have been parsed by the api.event_search module we can end up with conditions that are not valid on the current dataset @@ -1738,7 +1815,7 @@ def resolve_condition(cond: list, column_resolver: Callable[[Any], Any]) -> list """ index = get_function_index(cond) - def _passthrough_arg(arg): + def _passthrough_arg(arg: object) -> object: if isinstance(arg, str): return f"'{arg}'" elif isinstance(arg, datetime): @@ -1791,7 +1868,7 @@ def _passthrough_arg(arg): raise ValueError("Unexpected condition format %s" % cond) -def aliased_query(**kwargs): +def aliased_query(**kwargs: Any) -> Mapping[str, Any]: """ Wrapper around raw_query that selects the dataset based on the selected_columns, conditions and groupby parameters. @@ -1809,13 +1886,13 @@ def aliased_query(**kwargs): return _aliased_query_impl(**kwargs) -def _aliased_query_impl(**kwargs): +def _aliased_query_impl(**kwargs: Any) -> Mapping[str, Any]: return raw_query(**aliased_query_params(**kwargs)) def resolve_conditions( - conditions: Sequence | None, column_resolver: Callable[[Any], Any] -) -> list | None: + conditions: Sequence[Any] | None, column_resolver: Callable[[Any], Any] +) -> list[Any] | None: if conditions is None: return conditions @@ -1829,19 +1906,19 @@ def resolve_conditions( def aliased_query_params( - start=None, - end=None, - groupby=None, - conditions=None, - filter_keys=None, - aggregations=None, - selected_columns=None, - arrayjoin=None, - having=None, - dataset=None, - orderby=None, - condition_resolver: Callable | None = None, - **kwargs, + start: datetime | None = None, + end: datetime | None = None, + groupby: list[str] | None = None, + conditions: list[Any] | None = None, + filter_keys: dict[str, Sequence[int]] | None = None, + aggregations: list[list[Any]] | None = None, + selected_columns: list[Any] | None = None, + arrayjoin: str | None = None, + having: list[Any] | None = None, + dataset: Dataset | None = None, + orderby: list[str] | None = None, + condition_resolver: Callable[..., Any] | None = None, + **kwargs: Any, ) -> dict[str, Any]: if dataset is None: raise ValueError("A dataset is required, and is no longer automatically detected.") @@ -1883,14 +1960,18 @@ def aliased_query_params( # to sum(sample_weight) for allowlisted projects. if dataset == Dataset.Events and filter_keys and filter_keys.get("project_id") and aggregations: project_filter = filter_keys.get("project_id") - project_ids = ( - project_filter if isinstance(project_filter, (list, tuple)) else [project_filter] + project_ids: Sequence[int] = ( + cast("Sequence[int]", project_filter) + if isinstance(project_filter, (list, tuple)) + else [cast("int", project_filter)] ) _convert_count_aggregations_for_error_upsampling(aggregations, project_ids) if conditions: if condition_resolver: - column_resolver: Callable = functools.partial(condition_resolver, dataset=dataset) + column_resolver: Callable[..., Any] = functools.partial( + condition_resolver, dataset=dataset + ) else: column_resolver = resolve_func resolved_conditions = resolve_conditions(conditions, column_resolver) @@ -1927,7 +2008,9 @@ def aliased_query_params( # TODO: (evanh) Since we are assuming that all string values are columns, # this will get tricky if we ever have complex columns where there are # string arguments to the functions that aren't columns -def resolve_complex_column(col, resolve_func, ignored): +def resolve_complex_column( + col: list[Any], resolve_func: Callable[[Any], Any], ignored: Collection[str] +) -> None: args = col[1] for i in range(len(args)): @@ -1952,7 +2035,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 @@ -1983,7 +2066,9 @@ def get_json_type(snuba_type): # is implemented here for simplicity. -def get_snuba_translators(filter_keys, is_grouprelease=False): +def get_snuba_translators( + filter_keys: Mapping[str, Sequence[int]], is_grouprelease: bool = False +) -> tuple[Translator, Translator]: """ Some models are stored differently in snuba, eg. as the environment name instead of the environment ID. Here we create and return forward() @@ -2004,26 +2089,27 @@ def get_snuba_translators(filter_keys, is_grouprelease=False): """ # Helper lambdas to compose translator functions - def identity(x): + def identity(x: Any) -> Any: return x - def compose(f, g): + def compose(f: Translator, g: Translator) -> Translator: return lambda x: f(g(x)) - def replace(d, key, val): + def replace(d: dict[str, Any], key: str, val: Any) -> dict[str, Any]: return d.update({key: val}) or d - forward = identity - reverse = identity + forward: Translator = identity + reverse: Translator = identity - map_columns = { + map_columns: dict[str, tuple[type[Model], str, Translator]] = { "environment": (Environment, "name", lambda name: None if name == "" else name), "tags[sentry:release]": (Release, "version", identity), "release": (Release, "version", identity), } for col, (model, field, fmt) in map_columns.items(): - fwd, rev = None, None + fwd: Translator | None = None + rev: Translator | None = None ids = filter_keys.get(col) if not ids: continue @@ -2105,7 +2191,7 @@ def replace(d, key, val): return (forward, reverse) -def get_related_project_ids(column, ids): +def get_related_project_ids(column: str, ids: Sequence[int]) -> Iterable[int]: """ Get the project_ids from a model that has a foreign key to project. """ @@ -2125,7 +2211,7 @@ def get_related_project_ids(column, ids): return [] -def shrink_time_window(issues: Collection | None, start: datetime) -> datetime: +def shrink_time_window(issues: Collection[int] | None, start: datetime) -> datetime: """\ If a single issue is passed in, shrink the `start` parameter to be briefly before the `first_seen` in order to hopefully eliminate a large percentage of rows scanned. @@ -2148,7 +2234,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 @@ -2185,11 +2273,11 @@ def quantize_time(time, key_hash, duration=300, rounding=ROUND_DOWN): ) -def is_measurement(key): - return isinstance(key, str) and MEASUREMENTS_KEY_RE.match(key) +def is_measurement(key: object) -> bool: + return bool(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 +2296,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 +2304,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 +2314,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,25 +2333,27 @@ 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 -def process_value(value: None | str | int | float | list[str] | list[int] | list[float]): +def process_value( + value: None | str | int | float | list[Any], +) -> None | str | int | float | list[Any]: if isinstance(value, float): # 0 for nan, and none for inf were chosen arbitrarily, nan and inf are # invalid json so needed to pick something valid to use instead @@ -2282,7 +2372,7 @@ def process_value(value: None | str | int | float | list[str] | list[int] | list def get_upsampled_count_snql_with_alias(alias: str) -> list[SelectableExpression]: - return Function( + function = Function( function="toInt64", parameters=[ Function( @@ -2299,6 +2389,8 @@ def get_upsampled_count_snql_with_alias(alias: str) -> list[SelectableExpression ], alias=alias, ) + # justification: snuba_sdk is skipped by mypy, so its constructors return Any. + return cast("list[SelectableExpression]", function) def _convert_count_aggregations_for_error_upsampling( diff --git a/src/sentry/utils/strings.py b/src/sentry/utils/strings.py index b9da4f573b7b..970072e0ff64 100644 --- a/src/sentry/utils/strings.py +++ b/src/sentry/utils/strings.py @@ -30,7 +30,10 @@ def unescape_string(value: str) -> str: """Unescapes a backslash escaped string.""" value = INVALID_ESCAPE.sub(r"\1\\", value) - return ast.literal_eval(f'"""{value}"""') + unescaped = ast.literal_eval(f'"""{value}"""') + if not isinstance(unescaped, str): + raise ValueError("Unescaped value is not a string") + return unescaped def strip_lone_surrogates(string: str) -> str: diff --git a/src/sentry/utils/types.py b/src/sentry/utils/types.py index ae15e75a25be..f97cbfc3afd3 100644 --- a/src/sentry/utils/types.py +++ b/src/sentry/utils/types.py @@ -43,7 +43,8 @@ def __call__(self, value: object | None = None) -> T: raise InvalidTypeError(f"{value!r} is not a valid {self!r}") def convert(self, value: TAny) -> T | None: - return value + # justification: the base coercion accepts the value unchanged. + return typing.cast("T | None", value) def _default(self) -> T: return self.default @@ -123,7 +124,7 @@ class StringType(Type[str]): compatible_types = (str,) -class DictType(Type[dict]): +class DictType(Type[dict[str, TAny]]): """Coerce a dict out of a json/yaml string""" name = "dictionary" @@ -135,12 +136,13 @@ def _default(self) -> dict[str, TAny]: def convert(self, value: TAny) -> dict[str, TAny] | None: try: - return safe_load(value) + data = safe_load(value) except (AttributeError, ParserError, ScannerError): return None + return data if isinstance(data, dict) else None -class SequenceType(Type[list]): +class SequenceType(Type[list[TAny]]): """Coerce a list out of a json/yaml string or a list""" name = "sequence" @@ -159,7 +161,7 @@ def convert(self, value: TAny) -> list[TAny] | None: return None if isinstance(value, tuple): value = list(value) - return value + return value if isinstance(value, list) else None # Initialize singletons of each type for easy reuse @@ -172,7 +174,7 @@ def convert(self, value: TAny) -> list[TAny] | None: Sequence = SequenceType() # Mapping for basic types into what their Type is -_type_mapping: dict[type[object], Type] = { +_type_mapping: dict[type[object], Type[TAny]] = { bool: Bool, int: Int, float: Float, @@ -204,14 +206,18 @@ def type_from_value(value: str) -> StringType: ... @typing.overload -def type_from_value(value: dict) -> DictType: ... +def type_from_value(value: dict[TAny, TAny]) -> DictType: ... @typing.overload -def type_from_value(value: list) -> SequenceType: ... +def type_from_value(value: list[TAny]) -> SequenceType: ... -def type_from_value(value): +@typing.overload +def type_from_value(value: object) -> Type[TAny]: ... + + +def type_from_value(value: object) -> Type[TAny]: """Fetch Type based on a primitive value""" return _type_mapping[type(value)] diff --git a/src/sentry/utils/validators.py b/src/sentry/utils/validators.py index d4fb6bbef7ae..4c343c637fff 100644 --- a/src/sentry/utils/validators.py +++ b/src/sentry/utils/validators.py @@ -15,25 +15,25 @@ HEXADECIMAL_16_DIGITS = re.compile("^[0-9a-fA-F]{1,16}$") -def normalize_event_id(value): +def normalize_event_id(value: object) -> str | None: try: return uuid.UUID(force_str(value)).hex except (TypeError, AttributeError, ValueError): return None -def is_event_id(value): +def is_event_id(value: object) -> bool: return normalize_event_id(value) is not None -def is_event_id_or_list(value): +def is_event_id_or_list(value: object) -> bool: if isinstance(value, list): return all(is_event_id(item) for item in value) else: return is_event_id(value) -def normalize_event_id_strict(value): +def normalize_event_id_strict(value: object) -> object: """Normalize a UUID by stripping dashes. Returns the original value if normalization fails.""" result = normalize_event_id(value) return result if result is not None else value @@ -43,16 +43,16 @@ def is_span_id(value: object) -> TypeGuard[str]: return bool(HEXADECIMAL_16_DIGITS.search(force_str(value))) -def normalize_span_id(value): +def normalize_span_id(value: object) -> str: return force_str(value).lower() -def is_span_id_or_list(value): +def is_span_id_or_list(value: object) -> bool: if isinstance(value, list): return all(is_span_id(item) for item in value) else: return is_span_id(value) -def is_empty_string(value): +def is_empty_string(value: object) -> bool: return force_str(value) == "" diff --git a/src/sentry/web/api.py b/src/sentry/web/api.py index caf51f2522f5..714975502409 100644 --- a/src/sentry/web/api.py +++ b/src/sentry/web/api.py @@ -1,5 +1,5 @@ from django.conf import settings -from django.http import HttpResponse +from django.http import HttpRequest, HttpResponse from django.utils.cache import patch_cache_control from django.views.decorators.cache import cache_control from django.views.generic.base import View as BaseView @@ -74,7 +74,7 @@ def get(self, request: Request) -> HttpResponse: @all_silo_view @cache_control(max_age=3600, public=True) -def robots_txt(request): +def robots_txt(request: HttpRequest) -> HttpResponse: if settings.SENTRY_MODE == SentryMode.SAAS and not request.subdomain: return HttpResponse(ROBOTS_SENTRY_IO, content_type="text/plain") @@ -83,7 +83,7 @@ def robots_txt(request): @all_silo_view @cache_control(max_age=3600, public=True) -def security_txt(request): +def security_txt(request: HttpRequest) -> HttpResponse: if settings.SENTRY_MODE == SentryMode.SELF_HOSTED: return HttpResponse(status=404) @@ -92,7 +92,7 @@ def security_txt(request): @control_silo_view @cache_control(max_age=3600, public=True) -def mcp_json(request): +def mcp_json(request: HttpRequest) -> HttpResponse: if settings.SENTRY_MODE == SentryMode.SELF_HOSTED: return HttpResponse(status=404) @@ -144,7 +144,9 @@ def mcp_json(request): AGENT_SKILLS_INDEX: dict[str, list[object]] = {"skills": []} -def _saas_only_json_response(request, payload, content_type="application/json"): +def _saas_only_json_response( + request: HttpRequest, payload: object, content_type: str = "application/json" +) -> HttpResponse: if settings.SENTRY_MODE != SentryMode.SAAS or request.subdomain: response = HttpResponse(status=404) patch_cache_control(response, no_store=True) @@ -156,12 +158,12 @@ def _saas_only_json_response(request, payload, content_type="application/json"): @control_silo_view -def api_catalog(request): +def api_catalog(request: HttpRequest) -> HttpResponse: return _saas_only_json_response(request, API_CATALOG, "application/linkset+json") @control_silo_view -def oauth_authorization_server(request): +def oauth_authorization_server(request: HttpRequest) -> HttpResponse: payload = { **OAUTH_AUTHORIZATION_SERVER, "scopes_supported": sorted(settings.SENTRY_SCOPES), @@ -170,7 +172,7 @@ def oauth_authorization_server(request): @control_silo_view -def oauth_protected_resource(request): +def oauth_protected_resource(request: HttpRequest) -> HttpResponse: payload = { **OAUTH_PROTECTED_RESOURCE, "scopes_supported": sorted(settings.SENTRY_SCOPES), @@ -179,16 +181,16 @@ def oauth_protected_resource(request): @control_silo_view -def mcp_server_card(request): +def mcp_server_card(request: HttpRequest) -> HttpResponse: return _saas_only_json_response(request, MCP_SERVER_CARD) @control_silo_view -def agent_skills_index(request): +def agent_skills_index(request: HttpRequest) -> HttpResponse: return _saas_only_json_response(request, AGENT_SKILLS_INDEX) @all_silo_view @cache_control(max_age=3600, public=True) -def not_found(request): +def not_found(request: HttpRequest) -> HttpResponse: return HttpResponse(status=404) diff --git a/src/sentry/web/client_config.py b/src/sentry/web/client_config.py index 3c0d88a42f9b..38a893de3809 100644 --- a/src/sentry/web/client_config.py +++ b/src/sentry/web/client_config.py @@ -8,11 +8,11 @@ from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.contrib.messages import get_messages +from django.contrib.messages.storage.base import Message from django.contrib.sessions.backends.base import SessionBase from django.core.cache import cache from django.http import HttpRequest from packaging.version import parse as parse_version -from rest_framework.request import Request import sentry from sentry import features, options @@ -63,7 +63,7 @@ def _get_support_mail() -> str | None: return options.get("system.support-email") or options.get("system.admin-email") or None -def _get_version_info(): +def _get_version_info() -> dict[str, str | bool]: current = sentry.VERSION latest = options.get("sentry:latest_version") or current @@ -78,7 +78,7 @@ def _get_version_info(): } -def _needs_upgrade(): +def _needs_upgrade() -> bool: version_configured = options.get("sentry:version-configured") if not version_configured: # If we were never previously upgraded (being a new install) @@ -137,10 +137,10 @@ def _get_public_dsn() -> str | None: else: result = "" cache.set(cache_key, result, 60) - return result + return result if isinstance(result, str) else None -def _delete_activeorg(session): +def _delete_activeorg(session: SessionBase | None) -> None: if session and "activeorg" in session: del session["activeorg"] @@ -183,7 +183,7 @@ def _resolve_last_org( class _ClientConfig: def __init__( self, - request: Request | None = None, + request: HttpRequest | None = None, org_context: RpcUserOrganizationContext | None = None, ) -> None: self.request = request @@ -239,7 +239,7 @@ def public_dsn(self) -> str | None: return _get_public_dsn() @property - def messages(self): + def messages(self) -> Iterable[Message]: if self.request is None: return [] return get_messages(self.request) @@ -320,7 +320,7 @@ def user_details(self) -> Mapping[str, Any] | None: filter={"user_ids": [self.user.id]}, serializer=UserSerializeType.SELF_DETAILED, auth_context=AuthenticationContext( - auth=self.request.auth if self.request is not None else None, + auth=getattr(self.request, "auth", None) if self.request is not None else None, user=serialize_generic_user(self.user), ), ) @@ -330,6 +330,8 @@ def user_details(self) -> Mapping[str, Any] | None: (user_details,) = query_result user_details = json.loads(json.dumps(user_details)) + if not isinstance(user_details, dict): + return None if self._is_superuser(): user_details["isSuperuser"] = self.user.is_superuser return user_details @@ -498,7 +500,7 @@ def get_context(self) -> Mapping[str, Any]: def get_client_config( - request=None, org_context: RpcUserOrganizationContext | None = None + request: HttpRequest | None = None, org_context: RpcUserOrganizationContext | None = None ) -> Mapping[str, Any]: """ Provides initial bootstrap data needed to boot the frontend application. diff --git a/src/sentry/web/forms/accounts.py b/src/sentry/web/forms/accounts.py index ef0c25285071..9d81ce9e87ad 100644 --- a/src/sentry/web/forms/accounts.py +++ b/src/sentry/web/forms/accounts.py @@ -1,12 +1,17 @@ from __future__ import annotations -from typing import Any +from collections.abc import Mapping, MutableMapping +from typing import TYPE_CHECKING, Any from django import forms from django.conf import settings from django.contrib.auth import authenticate, get_user_model +from django.core.files.uploadedfile import UploadedFile from django.db.models import Field +from django.forms.renderers import BaseRenderer +from django.forms.utils import ErrorList from django.http.request import HttpRequest +from django.utils.datastructures import MultiValueDict from django.utils.safestring import mark_safe from django.utils.text import capfirst from django.utils.translation import gettext_lazy as _ @@ -20,6 +25,11 @@ TIMEZONE_CHOICES = get_timezone_choices() +if TYPE_CHECKING: + _UserModelForm = forms.ModelForm[User] +else: + _UserModelForm = forms.ModelForm + class AuthenticationForm(forms.Form): username_field: Field[Any, Any] @@ -66,22 +76,22 @@ def __init__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: if not self.fields["username"].label: self.fields["username"].label = capfirst(self.username_field.verbose_name) - def clean_username(self, value=None): + def clean_username(self, value: str | None = None) -> str | None: if not value: value = self.cleaned_data.get("username") or "" value = value.strip(" \n\t\r\0") if not value: - return + return None return value.lower() - def is_rate_limited(self): + def is_rate_limited(self) -> bool: if self._is_ip_rate_limited(): return True if self._is_user_rate_limited(): return True return False - def _is_ip_rate_limited(self): + def _is_ip_rate_limited(self) -> bool: limit = options.get("auth.ip-rate-limit") if not limit: return False @@ -89,7 +99,7 @@ def _is_ip_rate_limited(self): ip_address = self.request.META["REMOTE_ADDR"] return ratelimiter.backend.is_limited(f"auth:ip:{ip_address}", limit) - def _is_user_rate_limited(self): + def _is_user_rate_limited(self) -> bool: limit = options.get("auth.user-rate-limit") if not limit: return False @@ -131,7 +141,7 @@ def clean(self) -> dict[str, Any] | None: self.check_for_test_cookie() return self.cleaned_data - def check_for_test_cookie(self): + def check_for_test_cookie(self) -> None: if not self.request.session.test_cookie_worked(): logger.info( "user.auth.no-cookies", @@ -143,16 +153,16 @@ def check_for_test_cookie(self): # tabs open - the first successful login would delete the cookie, # causing subsequent tabs to fail with a "cookies not enabled" error. - def get_user_id(self): + def get_user_id(self) -> int | None: if self.user_cache: return self.user_cache.id return None - def get_user(self): + def get_user(self) -> User | None: return self.user_cache -class PasswordlessRegistrationForm(forms.ModelForm): +class PasswordlessRegistrationForm(_UserModelForm): name = forms.CharField( label=_("Name"), max_length=200, @@ -178,8 +188,35 @@ class PasswordlessRegistrationForm(forms.ModelForm): ) timezone = forms.CharField(widget=forms.HiddenInput(), required=False) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__( + self, + data: Mapping[str, object] | None = None, + files: MultiValueDict[str, UploadedFile] | None = None, + auto_id: bool | str = "id_%s", + prefix: str | None = None, + initial: MutableMapping[str, object] | None = None, + error_class: type[ErrorList] = ErrorList, + label_suffix: str | None = None, + empty_permitted: bool = False, + instance: User | None = None, + use_required_attribute: bool | None = None, + renderer: BaseRenderer | None = None, + **kwargs: object, + ) -> None: + super().__init__( + data=data, + files=files, + auto_id=auto_id, + prefix=prefix, + initial=initial, + error_class=error_class, + label_suffix=label_suffix, + empty_permitted=empty_permitted, + instance=instance, + use_required_attribute=use_required_attribute, + renderer=renderer, + **kwargs, + ) if not newsletter.backend.is_enabled(): del self.fields["subscribe"] else: @@ -200,17 +237,17 @@ class Meta: fields = ("username", "name") model = User - def clean_username(self): + def clean_username(self) -> str | None: value = (self.cleaned_data.get("username") or "").strip() if not value: - return + return None if User.objects.filter(username__iexact=value).exists(): raise forms.ValidationError( _("An account is already registered with that email address.") ) return value.lower() - def save(self, commit=True): + def save(self, commit: bool = True) -> User: user = super().save(commit=False) user.email = user.username if commit: diff --git a/src/sentry/web/forms/fields.py b/src/sentry/web/forms/fields.py index 4e80ba4ce741..e8c72fa9aa2f 100644 --- a/src/sentry/web/forms/fields.py +++ b/src/sentry/web/forms/fields.py @@ -8,7 +8,7 @@ class CustomTypedChoiceField(TypedChoiceField): # A patched version of TypedChoiceField which correctly validates a 0 # as a real input that may be invalid # See https://github.com/django/django/pull/3774 - def validate(self, value): + def validate(self, value: object) -> None: """ Validates that the input is in self.choices. """ @@ -23,10 +23,9 @@ def validate(self, value): ) -def email_address_validator(value): +def email_address_validator(value: str) -> None: if not is_valid_email_address(value): raise ValidationError(_("Enter a valid email address."), code="invalid") - return value class AllowedEmailField(EmailField): diff --git a/src/sentry/web/frontend/auth_channel_login.py b/src/sentry/web/frontend/auth_channel_login.py index b646de00ac4d..4feffc725c59 100644 --- a/src/sentry/web/frontend/auth_channel_login.py +++ b/src/sentry/web/frontend/auth_channel_login.py @@ -1,4 +1,7 @@ +from collections.abc import Mapping + from django.contrib.auth import REDIRECT_FIELD_NAME +from django.http import HttpRequest, HttpResponseBase from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache @@ -15,11 +18,13 @@ @control_silo_view class AuthChannelLoginView(AuthOrganizationLoginView): @method_decorator(never_cache) - def handle(self, request, channel, resource_id): # type: ignore[override] # intentional signature change + def handle( # type: ignore[override] # intentional signature change + self, request: HttpRequest, channel: str, resource_id: str + ) -> HttpResponseBase: if request.subdomain is not None: return self.redirect(reverse("sentry-auth-organization", args=[request.subdomain])) - config_data = request.GET.get("config_data", "") + config_data: str | Mapping[str, object] = request.GET.get("config_data", "") if not config_data: # NOTE: This provider_config may differ per provider diff --git a/src/sentry/web/frontend/auth_login.py b/src/sentry/web/frontend/auth_login.py index 80d4245ac320..dbb4e2566a87 100644 --- a/src/sentry/web/frontend/auth_login.py +++ b/src/sentry/web/frontend/auth_login.py @@ -18,18 +18,18 @@ class AdditionalContext: - def __init__(self): - self._callbacks = set() + def __init__(self) -> None: + self._callbacks: set[Callable[[HttpRequest], dict[str, object]]] = set() - def add_callback(self, callback: Callable[[HttpRequest], dict]) -> None: + def add_callback(self, callback: Callable[[HttpRequest], dict[str, object]]) -> None: """ Callback should take a request object and return a dict of key-value pairs to add to the context. """ self._callbacks.add(callback) - def run_callbacks(self, request: HttpRequest) -> dict: - context = {} + def run_callbacks(self, request: HttpRequest) -> dict[str, object]: + context: dict[str, object] = {} for cb in self._callbacks: try: result = cb(request) @@ -43,6 +43,7 @@ def run_callbacks(self, request: HttpRequest) -> dict: @control_silo_view +@method_decorator(never_cache, name="handle") class AuthPageView(BaseView, ReactMixin): auth_required = False enforce_rate_limit = True @@ -50,11 +51,7 @@ class AuthPageView(BaseView, ReactMixin): limit_overrides={"GET": {RateLimitCategory.IP: RateLimit(limit=20, window=1)}} ) - @method_decorator(never_cache) - def handle(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase: - return super().handle(request, *args, **kwargs) - - def get(self, request: HttpRequest, **kwargs) -> HttpResponseBase: + def get(self, request: HttpRequest, **kwargs: object) -> HttpResponseBase: customer_domain_redirect = self.get_customer_domain_login_redirect(request) if customer_domain_redirect is not None: return customer_domain_redirect diff --git a/src/sentry/web/frontend/auth_organization_login.py b/src/sentry/web/frontend/auth_organization_login.py index 9582149ef874..082eee24e572 100644 --- a/src/sentry/web/frontend/auth_organization_login.py +++ b/src/sentry/web/frontend/auth_organization_login.py @@ -20,7 +20,9 @@ class AuthOrganizationLoginView(AuthPageView): - def handle_sso(self, request: HttpRequest, organization: RpcOrganization, auth_provider): + def handle_sso( + self, request: HttpRequest, organization: RpcOrganization, auth_provider: AuthProvider + ) -> HttpResponseBase: helper = AuthHelper( request=request, organization=organization, @@ -44,7 +46,7 @@ def handle_sso(self, request: HttpRequest, organization: RpcOrganization, auth_p return helper.current_step() @method_decorator(never_cache) - def handle(self, request: HttpRequest, organization_slug) -> HttpResponseBase: + def handle(self, request: HttpRequest, organization_slug: str) -> HttpResponseBase: if request.method == "GET": if ( request.resolver_match diff --git a/src/sentry/web/frontend/base.py b/src/sentry/web/frontend/base.py index bf622c7a6d88..a338eb4ebeb0 100644 --- a/src/sentry/web/frontend/base.py +++ b/src/sentry/web/frontend/base.py @@ -9,6 +9,7 @@ from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME +from django.core.files.base import File as DjangoFile from django.http import ( HttpRequest, HttpResponse, @@ -129,7 +130,7 @@ def __call__(self, decorated_obj: Any) -> Any: class CellSiloView(ViewSiloLimit): def __init__( - self, cell_resolver: CellRequestResolver | None = None, *args, **kwargs: Any + self, cell_resolver: CellRequestResolver | None = None, *args: bool, **kwargs: Any ) -> None: super().__init__([SiloMode.CELL], *args, **kwargs) self.cell_resolver = cell_resolver @@ -880,7 +881,7 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: if size_s: try: size = int(size_s) - photo_file = avatar.get_cached_photo(size) + photo_file: bytes | DjangoFile[bytes] | None = avatar.get_cached_photo(size) except ValueError: return HttpResponseBadRequest() else: diff --git a/src/sentry/web/frontend/csv.py b/src/sentry/web/frontend/csv.py index baab2db72ae7..7c4428e40d21 100644 --- a/src/sentry/web/frontend/csv.py +++ b/src/sentry/web/frontend/csv.py @@ -22,11 +22,11 @@ class CsvResponder(Generic[T]): def get_header(self) -> tuple[str, ...]: raise NotImplementedError - def get_row(self, item: T) -> tuple[str, ...]: + def get_row(self, item: T) -> tuple[object, ...]: raise NotImplementedError def respond(self, iterable: Iterable[T], filename: str) -> StreamingHttpResponse: - def row_iter() -> Generator[tuple[str, ...]]: + def row_iter() -> Generator[tuple[object, ...]]: header = self.get_header() if header: yield header diff --git a/src/sentry/web/frontend/debug/debug_new_user_feedback_email.py b/src/sentry/web/frontend/debug/debug_new_user_feedback_email.py index 9548ff8a0c3c..fc739653fea7 100644 --- a/src/sentry/web/frontend/debug/debug_new_user_feedback_email.py +++ b/src/sentry/web/frontend/debug/debug_new_user_feedback_email.py @@ -1,3 +1,5 @@ +from datetime import UTC, datetime + from django.http import HttpRequest, HttpResponse from django.views.generic import View @@ -17,10 +19,15 @@ def get(self, request: HttpRequest) -> HttpResponse: project = Project(id=1, organization=org, slug="project", name="My Project") event = create_sample_event( - project=project, platform="python", event_id="595", timestamp=1452683305 + project=project, + platform="python", + event_id="595", + timestamp=datetime.fromtimestamp(1452683305, tz=UTC), ) + assert event is not None group = event.group + assert group is not None link = absolute_uri( f"/{project.organization.slug}/{project.slug}/issues/{group.id}/feedback/" ) diff --git a/src/sentry/web/frontend/debug/debug_unable_to_delete_repository.py b/src/sentry/web/frontend/debug/debug_unable_to_delete_repository.py index 5acb43842cf0..2500ac8901d6 100644 --- a/src/sentry/web/frontend/debug/debug_unable_to_delete_repository.py +++ b/src/sentry/web/frontend/debug/debug_unable_to_delete_repository.py @@ -13,7 +13,7 @@ class DebugUnableToDeleteRepository(View): def get(self, request: HttpRequest) -> HttpResponse: repo = Repository(name="getsentry/sentry", provider="integrations:example") - repo.get_provider = lambda: SimpleNamespace(name="Example") # type: ignore[method-assign] + setattr(repo, "get_provider", lambda: SimpleNamespace(name="Example")) email = repo.generate_delete_fail_email("An internal server error occurred") return MailPreview( diff --git a/src/sentry/web/frontend/debug/debug_weekly_report.py b/src/sentry/web/frontend/debug/debug_weekly_report.py index 5dba2a36eef4..640383f8207b 100644 --- a/src/sentry/web/frontend/debug/debug_weekly_report.py +++ b/src/sentry/web/frontend/debug/debug_weekly_report.py @@ -114,11 +114,11 @@ def get_context(self, request: AuthenticatedHttpRequest) -> dict[str, Any] | Non ) project_context = ProjectContext(project) project_context.error_count_by_day = { - start_timestamp + (i * ONE_DAY): random.randint(0, daily_maximum) + int(start_timestamp + (i * ONE_DAY)): random.randint(0, daily_maximum) for i in range(0, 7) } project_context.issue_count_by_day = { - start_timestamp + (i * ONE_DAY): random.randint(0, daily_maximum // 10) + int(start_timestamp + (i * ONE_DAY)): random.randint(0, daily_maximum // 10) for i in range(0, 7) } diff --git a/src/sentry/web/frontend/disabled_member_view.py b/src/sentry/web/frontend/disabled_member_view.py index 30270fddc910..75360bd63f7b 100644 --- a/src/sentry/web/frontend/disabled_member_view.py +++ b/src/sentry/web/frontend/disabled_member_view.py @@ -1,8 +1,13 @@ from django.http import HttpRequest, HttpResponse from django.urls import reverse +from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember from sentry.organizations.services.organization import organization_service +from sentry.organizations.services.organization.model import ( + RpcOrganization, + RpcUserOrganizationContext, +) from sentry.web.frontend.base import control_silo_view from .react_page import ReactPageView @@ -10,10 +15,14 @@ @control_silo_view class DisabledMemberView(ReactPageView): - def is_member_disabled_from_limit(self, request: object, organization) -> bool: + def is_member_disabled_from_limit( + self, request: HttpRequest, organization: RpcUserOrganizationContext | RpcOrganization + ) -> bool: return False - def handle(self, request: HttpRequest, organization, **kwargs) -> HttpResponse: + def handle( + self, request: HttpRequest, organization: Organization, **kwargs: object + ) -> HttpResponse: user = request.user # if org member is not restricted, redirect user out of the disabled view try: 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/generic.py b/src/sentry/web/frontend/generic.py index 4cd2319eacb0..dd9b3f42994d 100644 --- a/src/sentry/web/frontend/generic.py +++ b/src/sentry/web/frontend/generic.py @@ -1,10 +1,18 @@ import os import posixpath +from typing import cast from urllib.parse import unquote from django.conf import settings from django.contrib.staticfiles import finders -from django.http import Http404, HttpResponse, HttpResponseNotFound, HttpResponseNotModified +from django.http import ( + Http404, + HttpRequest, + HttpResponse, + HttpResponseBase, + HttpResponseNotFound, + HttpResponseNotModified, +) from django.views import static from requests.exceptions import RequestException @@ -18,12 +26,12 @@ from sentry.web.frontend.base import all_silo_view, control_silo_view -def dev_favicon(request, extension): +def dev_favicon(request: HttpRequest, extension: str) -> HttpResponseBase: document_root, path = resolve("sentry/images/favicon-dev.png") return static.serve(request, path, document_root=document_root) -def resolve(path): +def resolve(path: str) -> tuple[str, str]: # Mostly yanked from Django core and changed to return the path: # See: https://github.com/django/django/blob/1.6.11/django/contrib/staticfiles/views.py normalized_path = posixpath.normpath(unquote(path)).lstrip("/") @@ -41,7 +49,7 @@ def resolve(path): @all_silo_view -def frontend_app_static_media(request, **kwargs): +def frontend_app_static_media(request: HttpRequest, **kwargs: str) -> HttpResponseBase: """ Serve static files that should not have any versioned paths/filenames. These assets will have cache headers to say that it can be cached by a @@ -57,7 +65,8 @@ def frontend_app_static_media(request, **kwargs): if not settings.DEBUG: response["Cache-Control"] = NO_CACHE - return response + # justification: the rendered response is a Django HttpResponseBase. + return cast("HttpResponseBase", response) # In-memory stash of the proxied service worker bundle, keyed by the frontend @@ -102,7 +111,7 @@ def _fetch_worker_bundle(commit_sha: str) -> bytes | None: @control_silo_view -def service_worker(request): +def service_worker(request: HttpRequest) -> HttpResponseBase: """ Serve the service worker script from our own origin. @@ -153,7 +162,7 @@ def service_worker(request): @all_silo_view -def static_media(request, **kwargs): +def static_media(request: HttpRequest, **kwargs: str) -> HttpResponseBase: """ Serve static files below a given point in the directory structure. """ diff --git a/src/sentry/web/frontend/group_event_json.py b/src/sentry/web/frontend/group_event_json.py index 57f1270546eb..5f4b2b6a0eca 100644 --- a/src/sentry/web/frontend/group_event_json.py +++ b/src/sentry/web/frontend/group_event_json.py @@ -1,7 +1,10 @@ +from __future__ import annotations + from django.http import Http404, HttpRequest, HttpResponse from sentry.models.group import Group, get_group_with_redirect from sentry.models.groupmeta import GroupMeta +from sentry.models.organization import Organization from sentry.services import eventstore from sentry.services.eventstore.models import Event, GroupEvent from sentry.utils import json @@ -12,7 +15,13 @@ class GroupEventJsonView(OrganizationView): required_scope = "event:read" - def get(self, request: HttpRequest, organization, group_id, event_id_or_latest) -> HttpResponse: + def get( + self, + request: HttpRequest, + organization: Organization, + group_id: str, + event_id_or_latest: str, + ) -> HttpResponse: try: # TODO(tkaemming): This should *actually* redirect, see similar # comment in ``GroupEndpoint.convert_args``. diff --git a/src/sentry/web/frontend/group_tag_export.py b/src/sentry/web/frontend/group_tag_export.py index 4c04f29821cc..8ecec68ac464 100644 --- a/src/sentry/web/frontend/group_tag_export.py +++ b/src/sentry/web/frontend/group_tag_export.py @@ -11,6 +11,8 @@ IssuesByTagProcessor, ) from sentry.models.environment import Environment +from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.ratelimits.config import RateLimitConfig from sentry.types.ratelimit import RateLimit, RateLimitCategory from sentry.web.frontend.base import ProjectView, cell_silo_view @@ -24,7 +26,7 @@ def __init__(self, key: str) -> None: def get_header(self) -> tuple[str, ...]: return tuple(IssuesByTagProcessor.get_header_fields(self.key)) - def get_row(self, item: GroupTagValueAndEventUser) -> tuple[str, ...]: + def get_row(self, item: GroupTagValueAndEventUser) -> tuple[str | int | None, ...]: fields = IssuesByTagProcessor.get_header_fields(self.key) item_dict = IssuesByTagProcessor.serialize_row(item, self.key) return tuple(item_dict[field] for field in fields) @@ -45,7 +47,14 @@ class GroupTagExportView(ProjectView): } ) - def get(self, request: Request, organization, project, group_id, key) -> HttpResponseBase: + def get( + self, + request: Request, + organization: Organization, + project: Project, + group_id: int | str, + key: str, + ) -> HttpResponseBase: # If the environment doesn't exist then the tag can't possibly exist try: environment_id = get_environment_id(request, project.organization_id) diff --git a/src/sentry/web/frontend/idp_email_verification.py b/src/sentry/web/frontend/idp_email_verification.py index cdcd24aba215..aef1384d74b0 100644 --- a/src/sentry/web/frontend/idp_email_verification.py +++ b/src/sentry/web/frontend/idp_email_verification.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Mapping from django.http import HttpRequest from django.http.response import HttpResponse @@ -46,7 +47,9 @@ def handle(self, request: HttpRequest, key: str) -> HttpResponse: return render_to_response("sentry/idp_account_not_verified.html", request=request) @staticmethod - def _recover_org_slug(verification_value): + def _recover_org_slug( + verification_value: Mapping[str, str | int | None], + ) -> str | None: organization_id = verification_value.get("organization_id") if organization_id is None: return None diff --git a/src/sentry/web/frontend/mailgun_inbound_webhook.py b/src/sentry/web/frontend/mailgun_inbound_webhook.py index cef62a0bc42a..a443649df63b 100644 --- a/src/sentry/web/frontend/mailgun_inbound_webhook.py +++ b/src/sentry/web/frontend/mailgun_inbound_webhook.py @@ -5,6 +5,7 @@ from django.conf import settings from django.http import HttpRequest, HttpResponse +from django.http.response import HttpResponseBase from django.utils.crypto import constant_time_compare from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt @@ -23,7 +24,7 @@ @control_silo_view class MailgunInboundWebhookView(View): - def verify(self, api_key, token, timestamp, signature): + def verify(self, api_key: str, token: str, timestamp: str, signature: str) -> bool: return constant_time_compare( signature, hmac.new( @@ -34,7 +35,8 @@ def verify(self, api_key, token, timestamp, signature): ) @method_decorator(csrf_exempt) - def dispatch(self, *args, **kwargs): + # justification: framework kwargs + def dispatch(self, *args: Any, **kwargs: Any) -> HttpResponseBase: return super().dispatch(*args, **kwargs) def post(self, request: HttpRequest) -> HttpResponse: diff --git a/src/sentry/web/frontend/oauth_device_authorization.py b/src/sentry/web/frontend/oauth_device_authorization.py index 67cab7547a6a..3575514005f8 100644 --- a/src/sentry/web/frontend/oauth_device_authorization.py +++ b/src/sentry/web/frontend/oauth_device_authorization.py @@ -4,6 +4,7 @@ from django.conf import settings from django.http import HttpRequest, HttpResponse +from django.http.response import HttpResponseBase from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_exempt @@ -65,7 +66,7 @@ class OAuthDeviceAuthorizationView(View): @csrf_exempt @method_decorator(never_cache) - def dispatch(self, request, *args, **kwargs): + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponseBase: return super().dispatch(request, *args, **kwargs) def error( diff --git a/src/sentry/web/frontend/oauth_token.py b/src/sentry/web/frontend/oauth_token.py index 3ade49bc3fbc..f6031cfa1950 100644 --- a/src/sentry/web/frontend/oauth_token.py +++ b/src/sentry/web/frontend/oauth_token.py @@ -3,10 +3,10 @@ import base64 import logging from datetime import datetime -from typing import Any, Literal, NotRequired, TypedDict +from typing import Literal, NotRequired, TypedDict from django.db import router, transaction -from django.http import HttpRequest, HttpResponse +from django.http import HttpRequest, HttpResponse, HttpResponseBase from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache @@ -50,10 +50,17 @@ class _TokenInformation(TypedDict): token_type: Literal["Bearer"] scope: str user: _TokenInformationUser - id_token: NotRequired[OpenIDToken] + id_token: NotRequired[str] organization_id: NotRequired[str] +class _TokenGrantResult(TypedDict, total=False): + token: ApiToken + id_token: str + error: str + reason: str + + @control_silo_view class OAuthTokenView(View): # Token responses must not be cached per RFC 6749 §5.1/§5.2. We apply @@ -61,11 +68,17 @@ class OAuthTokenView(View): # appropriately without repeating headers across handlers. @csrf_exempt @method_decorator(never_cache) - def dispatch(self, request, *args, **kwargs): + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponseBase: return super().dispatch(request, *args, **kwargs) # Note: the reason parameter is for internal use only - def error(self, request: HttpRequest, name, reason=None, status=400): + def error( + self, + request: HttpRequest | Request, + name: str, + reason: str | None = None, + status: int = 400, + ) -> HttpResponse: client_id = request.POST.get("client_id") logger.error( @@ -280,11 +293,11 @@ def post(self, request: Request) -> HttpResponse: return self.error( request=request, name=token_data["error"], - reason=token_data["reason"] if "reason" in token_data else None, + reason=token_data.get("reason"), ) return self.process_token_details( token=token_data["token"], - id_token=token_data["id_token"] if "id_token" in token_data else None, + id_token=token_data.get("id_token"), ) def _extract_basic_auth_credentials( @@ -370,7 +383,7 @@ def _extract_basic_auth_credentials( # Neither header nor body provided credentials. return (None, None), None - def get_access_tokens(self, request: Request, application: ApiApplication) -> dict: + def get_access_tokens(self, request: Request, application: ApiApplication) -> _TokenGrantResult: code = request.POST.get("code") try: grant = ApiGrant.objects.get(application=application, code=code) @@ -398,7 +411,7 @@ def get_access_tokens(self, request: Request, application: ApiApplication) -> di "reason": str(e) if str(e) else "grant expired", } - token_data: dict[str, Any] = {"token": api_token} + token_data: _TokenGrantResult = {"token": api_token} # OpenID token generation (stays in endpoint) if grant_has_openid and options.get("codecov.signing_secret"): @@ -420,7 +433,7 @@ def get_access_tokens(self, request: Request, application: ApiApplication) -> di return token_data - def get_refresh_token(self, request: Request, application: ApiApplication) -> dict: + def get_refresh_token(self, request: Request, application: ApiApplication) -> _TokenGrantResult: refresh_token_code = request.POST.get("refresh_token") scope = request.POST.get("scope") @@ -626,9 +639,7 @@ def handle_device_code_grant( reason="device code in invalid state", ) - def process_token_details( - self, token: ApiToken, id_token: OpenIDToken | None = None - ) -> HttpResponse: + def process_token_details(self, token: ApiToken, id_token: str | None = None) -> HttpResponse: token_information: _TokenInformation = { "access_token": token.token, "refresh_token": token.refresh_token, diff --git a/src/sentry/web/frontend/openidtoken.py b/src/sentry/web/frontend/openidtoken.py index d39497694aa1..f9b071c72cbb 100644 --- a/src/sentry/web/frontend/openidtoken.py +++ b/src/sentry/web/frontend/openidtoken.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, timedelta from typing import Protocol from django.utils import timezone @@ -10,7 +10,7 @@ DEFAULT_EXPIRATION = timedelta(minutes=10) -def default_expiration(): +def default_expiration() -> datetime: return timezone.now() + DEFAULT_EXPIRATION @@ -34,14 +34,14 @@ class OpenIDToken: def __init__( self, - aud, - sub, - shared_secret, - iss="https://sentry.io", - exp=None, - iat=None, - nonce=None, - ): + aud: str, + sub: int | str, + shared_secret: str, + iss: str = "https://sentry.io", + exp: datetime | None = None, + iat: datetime | None = None, + nonce: str | None = None, + ) -> None: self.shared_secret = shared_secret self.aud = aud self.sub = str(sub) # Convert to string per OpenID Connect spec @@ -68,8 +68,8 @@ def get_signed_id_token(self, grant: GrantLike) -> str: claims["nonce"] = self.nonce return jwt_utils.encode(claims, self.shared_secret, headers={**headers, "alg": "HS256"}) - def _get_user_details(self, grant: GrantLike) -> dict: - user_details = {} + def _get_user_details(self, grant: GrantLike) -> dict[str, object]: + user_details: dict[str, object] = {} if grant.has_scope("profile"): profile_details = { "name": grant.user.name, diff --git a/src/sentry/web/frontend/organization_auth_settings.py b/src/sentry/web/frontend/organization_auth_settings.py index ed3b9e56e339..07c30618e1a5 100644 --- a/src/sentry/web/frontend/organization_auth_settings.py +++ b/src/sentry/web/frontend/organization_auth_settings.py @@ -13,6 +13,7 @@ from sentry import audit_log, features, roles from sentry.auth import manager from sentry.auth.helper import AuthHelper +from sentry.auth.provider import Provider from sentry.auth.providers.saml2.generic.provider import GenericSAML2Provider from sentry.auth.services.auth import RpcAuthProvider, auth_service from sentry.auth.store import FLOW_SETUP_PROVIDER @@ -36,7 +37,12 @@ logger = logging.getLogger("sentry.saml_setup_error") -def auth_provider_settings_form(provider, auth_provider, organization, request): +def auth_provider_settings_form( + provider: Provider, + auth_provider: AuthProvider | RpcAuthProvider, + organization: RpcOrganization, + request: HttpRequest, +) -> forms.Form: # Determine the default role choices the current user is allowed to assign if is_active_superuser(request): role_choices = roles.get_choices() @@ -119,7 +125,7 @@ class OrganizationAuthSettingsView(ControlSiloOrganizationView): def _disable_provider( self, request: HttpRequest, organization: RpcOrganization, auth_provider: RpcAuthProvider - ): + ) -> None: user = request.user sending_email = "" if hasattr(user, "email"): @@ -140,7 +146,7 @@ def _disable_provider( def handle_existing_provider( self, request: HttpRequest, organization: RpcOrganization, auth_provider: RpcAuthProvider - ): + ) -> HttpResponseBase: provider = auth_provider.get_provider() if request.method == "POST": diff --git a/src/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/sentry/web/frontend/react_page.py b/src/sentry/web/frontend/react_page.py index e89099d005f1..4b66420a8ed5 100644 --- a/src/sentry/web/frontend/react_page.py +++ b/src/sentry/web/frontend/react_page.py @@ -2,6 +2,7 @@ import logging from fnmatch import fnmatch +from typing import Any from django.conf import settings from django.contrib.auth.models import AnonymousUser @@ -43,7 +44,9 @@ ] -def resolve_redirect_url(request: HttpRequest, org_slug: str, user_id=None): +def resolve_redirect_url( + request: HttpRequest, org_slug: str, user_id: int | None = None +) -> str | None: org_context = organization_service.get_organization_by_slug( slug=org_slug, only_visible=False, @@ -73,7 +76,8 @@ def resolve_activeorg_redirect_url(request: HttpRequest) -> str | None: class ReactMixin: - def meta_tags(self, request: HttpRequest, **kwargs): + # justification: framework kwargs + def meta_tags(self, request: HttpRequest, **kwargs: Any) -> dict[str, str]: return {} def preconnect(self) -> list[str]: @@ -89,7 +93,11 @@ def dns_prefetch(self) -> list[str]: return [generate_locality_url(locality_name) for locality_name in localities] def handle_react( - self, request: HttpRequest, *, organization: Organization | None = None, **kwargs + self, + request: HttpRequest, + *, + organization: Organization | None = None, + **kwargs: Any, # justification: framework kwargs ) -> HttpResponse: org_context = getattr(self, "active_organization", None) react_config = get_client_config(request, org_context) @@ -192,7 +200,8 @@ def handle_react( # our tests frequently redirect to HTML views @control_silo_view class ReactPageView(ControlSiloOrganizationView, ReactMixin): - def handle_auth_required(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + # justification: framework kwargs + def handle_auth_required(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: # If user is a superuser (but not active, because otherwise this method would never be called) # Then allow client to handle the route and respond to any API request errors if request.user.is_superuser: @@ -201,11 +210,18 @@ def handle_auth_required(self, request: HttpRequest, *args, **kwargs) -> HttpRes # For normal users, let parent class handle (e.g. redirect to login page) return super().handle_auth_required(request, *args, **kwargs) - def handle(self, request: HttpRequest, organization, **kwargs) -> HttpResponse: + def handle( + # justification: framework kwargs + self, + request: HttpRequest, + organization: Organization, + **kwargs: Any, # justification: Django view args + ) -> HttpResponse: return self.handle_react(request, organization=organization) @control_silo_view class GenericReactPageView(BaseView, ReactMixin): - def handle(self, request: HttpRequest, **kwargs) -> HttpResponse: + # justification: framework kwargs + def handle(self, request: HttpRequest, **kwargs: Any) -> HttpResponse: return self.handle_react(request, **kwargs) diff --git a/src/sentry/web/frontend/release_webhook.py b/src/sentry/web/frontend/release_webhook.py index 7056ae520482..ce33cbd1e045 100644 --- a/src/sentry/web/frontend/release_webhook.py +++ b/src/sentry/web/frontend/release_webhook.py @@ -3,6 +3,7 @@ from hashlib import sha256 from django.http import HttpRequest, HttpResponse +from django.http.response import HttpResponseBase from django.utils.crypto import constant_time_compare from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt @@ -20,7 +21,7 @@ @cell_silo_view class ReleaseWebhookView(View): - def verify(self, plugin_id, project_id, token, signature): + def verify(self, plugin_id: str, project_id: str, token: str, signature: str) -> bool: return constant_time_compare( signature, hmac.new( @@ -31,10 +32,10 @@ def verify(self, plugin_id, project_id, token, signature): ) @method_decorator(csrf_exempt) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponseBase: + return super().dispatch(request, *args, **kwargs) - def _handle_builtin(self, request: HttpRequest, project): + def _handle_builtin(self, request: HttpRequest, project: Project) -> HttpResponse: endpoint = f"/projects/{project.organization.slug}/{project.slug}/releases/" try: @@ -63,7 +64,9 @@ def _handle_builtin(self, request: HttpRequest, project): status=resp.status_code, content=json.dumps(resp.data), content_type="application/json" ) - def post(self, request: HttpRequest, plugin_id, project_id, signature) -> HttpResponse: + def post( + self, request: HttpRequest, plugin_id: str, project_id: str, signature: str + ) -> HttpResponse: try: project = Project.objects.get_from_cache(id=project_id) except ValueError: diff --git a/src/sentry/web/frontend/setup_wizard.py b/src/sentry/web/frontend/setup_wizard.py index 62eb9adcf436..3a7f60f53bdb 100644 --- a/src/sentry/web/frontend/setup_wizard.py +++ b/src/sentry/web/frontend/setup_wizard.py @@ -1,8 +1,9 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Callable, Mapping, Sequence from datetime import date -from typing import Any +from typing import Any, cast from urllib.parse import parse_qsl, urlparse, urlunparse from django.conf import settings @@ -10,6 +11,7 @@ from django.http import Http404, HttpRequest, HttpResponse from django.http.response import HttpResponseBase from django.shortcuts import get_object_or_404 +from rest_framework.response import Response from sentry.api.base import allow_cors_options from sentry.api.endpoints.setup_wizard import SETUP_WIZARD_CACHE_KEY, SETUP_WIZARD_CACHE_TIMEOUT @@ -44,6 +46,16 @@ from sentry.web.helpers import render_to_response +def _allow_cors_options( + view: Callable[..., HttpResponseBase], +) -> Callable[..., HttpResponseBase]: + # justification: the shared decorator is typed for DRF endpoints but only passes the request through. + return cast( + "Callable[..., HttpResponseBase]", + allow_cors_options(cast("Callable[..., Response]", view)), + ) + + @control_silo_view class SetupWizardView(BaseView): def handle_auth_required(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: @@ -64,8 +76,8 @@ def handle_auth_required(self, request: HttpRequest, *args: Any, **kwargs: Any) return self.redirect(add_params_to_url(redirect_to, request.GET)) return super().handle_auth_required(request, *args, **kwargs) - @allow_cors_options - def get(self, request: HttpRequest, wizard_hash) -> HttpResponseBase: + @_allow_cors_options + def get(self, request: HttpRequest, wizard_hash: str) -> HttpResponseBase: """ This opens a page where with an active session fill stuff into the cache Redirects to organization whenever cache has been deleted @@ -98,9 +110,9 @@ def get(self, request: HttpRequest, wizard_hash) -> HttpResponseBase: ).order_by("-date_created") # {'us': {'org_ids': [...], 'projects': [...], 'keys': [...]}} - region_data_map: dict[str, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list)) + region_data_map: dict[str, dict[str, list[int]]] = defaultdict(lambda: defaultdict(list)) - org_mappings_map = {} + org_mappings_map: dict[int, dict[str, object]] = {} for mapping in org_mappings: region_data_map[mapping.cell_name]["org_ids"].append(mapping.organization_id) serialized_mapping = serialize_org_mapping(mapping) @@ -129,8 +141,8 @@ def get(self, request: HttpRequest, wizard_hash) -> HttpResponseBase: return render_to_response("sentry/setup-wizard.html", context, request) - @allow_cors_options - def post(self, request: HttpRequest, wizard_hash=None) -> HttpResponse: + @_allow_cors_options + def post(self, request: HttpRequest, wizard_hash: str | None = None) -> HttpResponse: """ This updates the cache content for a specific hash """ @@ -184,16 +196,17 @@ def post(self, request: HttpRequest, wizard_hash=None) -> HttpResponse: default_cache.set(key, cache_data, SETUP_WIZARD_CACHE_TIMEOUT) return HttpResponse(status=200, content="{}", content_type="application/json") - @allow_cors_options - def options(self, request, *args, **kwargs): + @_allow_cors_options + def options(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase: return super().options(request, *args, **kwargs) - @allow_cors_options - def dispatch(self, request, *args, **kwargs): + @_allow_cors_options + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase: return super().dispatch(request, *args, **kwargs) -def serialize_org_mapping(mapping: OrganizationMapping): +def serialize_org_mapping(mapping: OrganizationMapping) -> dict[str, object]: + assert mapping.status is not None status = OrganizationStatus(mapping.status) return { "id": mapping.organization_id, @@ -204,14 +217,18 @@ def serialize_org_mapping(mapping: OrganizationMapping): } -def serialize_project_key(project_key: RpcProjectKey): +def serialize_project_key(project_key: RpcProjectKey) -> dict[str, object]: return { "dsn": {"public": project_key.dsn_public}, "isActive": project_key.is_active, } -def serialize_project(project: RpcProject, organization: dict, keys: list[dict]): +def serialize_project( + project: RpcProject, + organization: Mapping[str, object], + keys: Sequence[Mapping[str, object]], +) -> dict[str, object]: return { "slug": project.slug, "id": project.id, @@ -225,7 +242,7 @@ def serialize_project(project: RpcProject, organization: dict, keys: list[dict]) def get_cache_data( mapping: OrganizationMapping, project: RpcProject, user: User | AnonymousUser | RpcUser -): +) -> dict[str, object]: project_key = project_key_service.get_default_project_key( organization_id=mapping.organization_id, project_id=project.id, @@ -245,7 +262,7 @@ def get_cache_data( return {"apiKeys": serialized_token, "projects": [enriched_project]} -def get_token(mappings: list[OrganizationMapping], user: RpcUser): +def get_token(mappings: list[OrganizationMapping], user: RpcUser) -> str | Mapping[str, object]: can_use_org_tokens = len(mappings) == 1 # If only one org, try to generate an org auth token @@ -257,16 +274,19 @@ def get_token(mappings: list[OrganizationMapping], user: RpcUser): return token # Otherwise, generate a user token - token = ApiToken.objects.create( + api_token = ApiToken.objects.create( user_id=user.id, scope_list=["project:releases"], token_type=AuthTokenType.USER, expires_at=None, ) - return serialize(token) + # justification: the token serializer emits the API token payload. + return cast("str | Mapping[str, object]", serialize(api_token)) -def get_org_token(mapping: OrganizationMapping, user: User | RpcUser | AnonymousUser): +def get_org_token( + mapping: OrganizationMapping, user: User | RpcUser | AnonymousUser +) -> Mapping[str, object] | None: try: token_str = generate_token( mapping.slug, generate_locality_url(get_locality_name_for_cell(mapping.cell_name)) @@ -283,4 +303,5 @@ def get_org_token(mapping: OrganizationMapping, user: User | RpcUser | Anonymous token_last_characters=token_str[-4:], token_hashed=token_hashed, ) - return serialize(token, user, token=token_str) + # justification: the token serializer emits the API token payload. + return cast("Mapping[str, object]", serialize(token, user, token=token_str)) diff --git a/src/sentry/web/frontend/twofactor.py b/src/sentry/web/frontend/twofactor.py index 83249ffa5f4f..1789fd7147a0 100644 --- a/src/sentry/web/frontend/twofactor.py +++ b/src/sentry/web/frontend/twofactor.py @@ -20,7 +20,7 @@ def handle(self, request: HttpRequest) -> HttpResponse: @control_silo_view -def u2f_appid(request): +def u2f_appid(request: HttpRequest) -> HttpResponse: facets = options.get("u2f.facets") if not facets: facets = [options.get("system.url-prefix")] diff --git a/src/sentry/workflow_engine/buffer/redis_hash_sorted_set_buffer.py b/src/sentry/workflow_engine/buffer/redis_hash_sorted_set_buffer.py index 33f986f2494f..c4ba9b55784c 100644 --- a/src/sentry/workflow_engine/buffer/redis_hash_sorted_set_buffer.py +++ b/src/sentry/workflow_engine/buffer/redis_hash_sorted_set_buffer.py @@ -161,7 +161,10 @@ def get_hash( def get_hash_length(self, model: type[models.Model], filters: Mapping[str, BufferField]) -> int: """Get the number of fields in a Redis hash.""" key = make_key(model, filters) - return self._execute_redis_operation(key, "hlen") + length = self._execute_redis_operation(key, "hlen") + if not isinstance(length, int): + raise TypeError("Redis HLEN returned no integer") + return length def delete_hash( self, @@ -269,7 +272,10 @@ def _calculate_key_slot(self, key: str) -> int: # Use the cluster client's built-in slot calculation try: - return self.cluster.connection_pool.nodes.keyslot(key) + slot = self.cluster.connection_pool.nodes.keyslot(key) + if not isinstance(slot, int): + raise TypeError("Redis cluster keyslot returned no integer") + return slot except AttributeError: # Fallback for standalone Redis using cluster client return 0 # Treat as single slot diff --git a/src/sentry/workflow_engine/caches/mapping.py b/src/sentry/workflow_engine/caches/mapping.py index dfc8c607a01a..5924161e365d 100644 --- a/src/sentry/workflow_engine/caches/mapping.py +++ b/src/sentry/workflow_engine/caches/mapping.py @@ -1,4 +1,5 @@ from collections.abc import Callable, Collection, Mapping +from typing import cast from sentry.utils.cache import cache @@ -50,7 +51,8 @@ def key(self, input: K) -> str: return base_key def get(self, input: K) -> V | None: - return cache.get(self.key(input)) + # justification: this cache only stores values of type V. + return cast("V | None", cache.get(self.key(input))) def set(self, input: K, value: V) -> None: cache.set(self.key(input), value, self._ttl_seconds) diff --git a/src/sentry/workflow_engine/caches/workflow.py b/src/sentry/workflow_engine/caches/workflow.py index f2799bbb949e..943ac62846df 100644 --- a/src/sentry/workflow_engine/caches/workflow.py +++ b/src/sentry/workflow_engine/caches/workflow.py @@ -183,7 +183,7 @@ def _query_workflows_by_detector_ids( env_by_detector: dict[int, set[Workflow]] = {d_id: set() for d_id in detector_ids} for dw in detector_workflow_mappings: - if dw.workflow.environment_id is None: + if not dw.workflow.environment_id: global_by_detector[dw.detector_id].add(dw.workflow) else: env_by_detector[dw.detector_id].add(dw.workflow) diff --git a/src/sentry/workflow_engine/endpoints/serializers/workflow_group_history_serializer.py b/src/sentry/workflow_engine/endpoints/serializers/workflow_group_history_serializer.py index 2833c25c8198..9babd9bc5cbf 100644 --- a/src/sentry/workflow_engine/endpoints/serializers/workflow_group_history_serializer.py +++ b/src/sentry/workflow_engine/endpoints/serializers/workflow_group_history_serializer.py @@ -1,9 +1,9 @@ -from collections.abc import Mapping, MutableMapping, Sequence +from collections.abc import Callable, Mapping, MutableMapping, Sequence from dataclasses import dataclass from datetime import datetime from typing import Any, NotRequired, TypedDict, cast -from django.db.models import Count, Max, OuterRef, Subquery +from django.db.models import Count, Max, Model, OuterRef, QuerySet, Subquery from rest_framework.exceptions import ParseError from sentry.api.paginator import OffsetPaginator @@ -175,8 +175,9 @@ def fetch_workflow_groups_paginated( return cast( CursorResult[WorkflowGroupHistory], OffsetPaginator( - qs, + # justification: values() yields typed dict rows; the paginator only slices them and hands them to on_results. + cast("QuerySet[Model]", qs), order_by=order_by, - on_results=convert_results, + on_results=cast("Callable[[Sequence[Model]], Sequence[Model]]", convert_results), ).get_result(per_page, cursor, known_hits=group_count), ) diff --git a/src/sentry/workflow_engine/endpoints/validators/base/action.py b/src/sentry/workflow_engine/endpoints/validators/base/action.py index 80158529b9ad..7d8e6f6ec6b1 100644 --- a/src/sentry/workflow_engine/endpoints/validators/base/action.py +++ b/src/sentry/workflow_engine/endpoints/validators/base/action.py @@ -41,18 +41,18 @@ def _get_action_handler(self) -> builtins.type[ActionHandler]: except NoRegistrationExistsError: raise serializers.ValidationError(f"Invalid action type: {action_type}") from None - def validate_data(self, value: Any) -> ActionData: + def validate_data(self, value: ActionData) -> ActionData: data_schema = self._get_action_handler().data_schema return validate_json_schema(value, data_schema) - def validate_status(self, value: Any) -> int: + def validate_status(self, value: int | str | None) -> int: if value is None: return ObjectStatus.ACTIVE if isinstance(value, str): return ObjectStatus.from_str(value) return value - def validate_config(self, value: Any) -> ActionConfig: + def validate_config(self, value: ActionConfig) -> ActionConfig: action_handler = self._get_action_handler() config_transformer = action_handler.get_config_transformer() @@ -63,7 +63,7 @@ def validate_config(self, value: Any) -> ActionConfig: # No transformer, validate directly against config schema return validate_json_schema(value, action_handler.config_schema) - def validate_type(self, value: Any) -> str: + def validate_type(self, value: str) -> str: try: action_type = Action.Type(value) except ValueError: diff --git a/src/sentry/workflow_engine/endpoints/validators/base/data_condition.py b/src/sentry/workflow_engine/endpoints/validators/base/data_condition.py index 03ea511fa19e..dfaea8cd399c 100644 --- a/src/sentry/workflow_engine/endpoints/validators/base/data_condition.py +++ b/src/sentry/workflow_engine/endpoints/validators/base/data_condition.py @@ -1,5 +1,5 @@ from abc import abstractmethod -from typing import Any, Generic, NotRequired, TypedDict, TypeVar +from typing import Any, Generic, NotRequired, TypedDict, TypeVar, cast from jsonschema import ValidationError as JsonValidationError from rest_framework import serializers @@ -48,10 +48,11 @@ class BaseDataConditionValidator( ): @property def condition_type(self) -> Condition: + # justification: the ChoiceField restricts condition types to Condition members. if isinstance(self.initial_data, list) and self.initial_data: - return self.initial_data[0].get("type") + return cast("Condition", self.initial_data[0].get("type")) - return self.initial_data.get("type") + return cast("Condition", self.initial_data.get("type")) def _get_handler(self) -> type[DataConditionHandler[Any]] | None: if self._is_operator_condition(): diff --git a/src/sentry/workflow_engine/endpoints/validators/base/workflow.py b/src/sentry/workflow_engine/endpoints/validators/base/workflow.py index e5352383b1d2..667369043f63 100644 --- a/src/sentry/workflow_engine/endpoints/validators/base/workflow.py +++ b/src/sentry/workflow_engine/endpoints/validators/base/workflow.py @@ -1,4 +1,4 @@ -from typing import Any, NotRequired, TypedDict, TypeVar +from typing import Any, NotRequired, TypedDict, TypeVar, cast from django.db import router, transaction from rest_framework import serializers @@ -116,9 +116,10 @@ def _split_action_and_condition_group( return actions, action_filter - def validate_config(self, value: Any) -> bool: + def validate_config[T](self, value: T) -> T: schema = Workflow.config_schema - return validate_json_schema(value, schema) + validate_json_schema(value, schema) + return value def validate_triggers(self, value: InputData) -> InputData: if "workflow" in self.context: @@ -211,7 +212,8 @@ def _update_or_create( if not serializer.is_valid(): raise serializers.ValidationError(serializer.errors) - return serializer.save() + # justification: the serializer saves and returns the validated model instance. + return cast("ModelType", serializer.save()) def _update_or_create_action( self, @@ -357,7 +359,7 @@ def update(self, instance: Workflow, validated_data: InputData) -> Workflow: ) # Bind new condition group to workflow if it didn't have one before - if instance.when_condition_group_id is None: + if not instance.when_condition_group_id: instance.when_condition_group = when_condition_group # Update the Action Filters and Actions diff --git a/src/sentry/workflow_engine/endpoints/validators/error_detector.py b/src/sentry/workflow_engine/endpoints/validators/error_detector.py index b0766cad5df4..2e3594e7f3e2 100644 --- a/src/sentry/workflow_engine/endpoints/validators/error_detector.py +++ b/src/sentry/workflow_engine/endpoints/validators/error_detector.py @@ -45,13 +45,13 @@ def validate_condition_group(self, value: Any) -> Any: return None - def validate_name(self, value: Any) -> str: + def validate_name(self, value: str) -> str: # if name is different from existing, raise an error if self.instance and self.instance.name != value: raise serializers.ValidationError("Name changes are not supported for error detectors") return value - def validate_fingerprinting_rules(self, value: Any) -> str | None: + def validate_fingerprinting_rules(self, value: str | None) -> str | None: if not value: return value @@ -62,7 +62,7 @@ def validate_fingerprinting_rules(self, value: Any) -> str | None: return value - def validate_resolve_age(self, value: Any) -> int | None: + def validate_resolve_age(self, value: int | None) -> int | None: if value is not None and value < 0: raise serializers.ValidationError("Resolve age must be a non-negative number") diff --git a/src/sentry/workflow_engine/endpoints/validators/utils.py b/src/sentry/workflow_engine/endpoints/validators/utils.py index 09c916311121..017bfe6dc238 100644 --- a/src/sentry/workflow_engine/endpoints/validators/utils.py +++ b/src/sentry/workflow_engine/endpoints/validators/utils.py @@ -47,7 +47,9 @@ def is_system_created_detector(detector: Detector) -> bool: def can_edit_user_created_detectors(request: Request, project: Project) -> bool: - return request.access.has_any_project_scope(project, USER_CREATED_DETECTOR_REQUIRED_SCOPES) + return bool( + request.access.has_any_project_scope(project, USER_CREATED_DETECTOR_REQUIRED_SCOPES) + ) def can_edit_detectors(detectors: QuerySet[Detector], request: Request) -> bool: @@ -130,8 +132,10 @@ def can_edit_detector_workflow_connections(detector: Detector, request: Request) if not detector.project: return can_edit_all_project_detector_workflow_connections(request) - return request.access.has_any_project_scope( - detector.linked_project, USER_CREATED_DETECTOR_REQUIRED_SCOPES + return bool( + request.access.has_any_project_scope( + detector.linked_project, USER_CREATED_DETECTOR_REQUIRED_SCOPES + ) ) @@ -484,7 +488,7 @@ def log_alerting_quota_hit( metrics.incr("workflow_engine.quota.limit_hit", tags={"object_type": object_type}) -def validate_json_schema(value: Any, schema: Any) -> Any: +def validate_json_schema[T](value: T, schema: Any) -> T: try: validate(value, schema) except JsonValidationError as e: @@ -541,7 +545,7 @@ def is_workflow_connected_to_all_projects_detector(workflow: Workflow) -> bool: def can_edit_all_project_detector_workflow_connections(request: Request) -> bool: - return request.access.has_scope("org:write") + return bool(request.access.has_scope("org:write")) def should_include_all_projects_detector(request: Request, organization: Organization) -> bool: diff --git a/src/sentry/workflow_engine/handlers/condition/event_created_by_detector_handler.py b/src/sentry/workflow_engine/handlers/condition/event_created_by_detector_handler.py index ae89a799ec13..af439c0f1db8 100644 --- a/src/sentry/workflow_engine/handlers/condition/event_created_by_detector_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/event_created_by_detector_handler.py @@ -21,4 +21,4 @@ def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: if event.occurrence is None or event.occurrence.evidence_data is None: return False - return event.occurrence.evidence_data.get("detector_id", None) == comparison + return bool(event.occurrence.evidence_data.get("detector_id", None) == comparison) diff --git a/src/sentry/workflow_engine/handlers/condition/event_frequency_handlers.py b/src/sentry/workflow_engine/handlers/condition/event_frequency_handlers.py index bfbb0342b259..9869d553509a 100644 --- a/src/sentry/workflow_engine/handlers/condition/event_frequency_handlers.py +++ b/src/sentry/workflow_engine/handlers/condition/event_frequency_handlers.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from sentry.workflow_engine.handlers.condition.event_attribute_handler import ( @@ -52,7 +53,10 @@ class EventFrequencyCountHandler(ActionFilterDataConditionHandler[list[int]]): def evaluate_value(value: list[int], comparison: Any) -> DataConditionResult: if not isinstance(value, list) or len(value) != 1: return False - return value[0] > comparison["value"] + threshold = comparison.get("value") if isinstance(comparison, Mapping) else None + if not isinstance(threshold, (int, float)): + return False + return value[0] > threshold @condition_handler_registry.register(Condition.EVENT_FREQUENCY_PERCENT) @@ -86,7 +90,10 @@ class EventFrequencyPercentHandler(ActionFilterDataConditionHandler[list[int]]): def evaluate_value(value: list[int], comparison: Any) -> DataConditionResult: if not isinstance(value, list) or len(value) != 2: return False - return percent_increase(value[0], value[1]) > comparison["value"] + threshold = comparison.get("value") if isinstance(comparison, Mapping) else None + if not isinstance(threshold, (int, float)): + return False + return percent_increase(value[0], value[1]) > threshold # Percent sessions values must be between 0-100 (%) diff --git a/src/sentry/workflow_engine/handlers/condition/event_frequency_query_handlers.py b/src/sentry/workflow_engine/handlers/condition/event_frequency_query_handlers.py index f0d4a9440c8f..29de20d8be29 100644 --- a/src/sentry/workflow_engine/handlers/condition/event_frequency_query_handlers.py +++ b/src/sentry/workflow_engine/handlers/condition/event_frequency_query_handlers.py @@ -495,6 +495,8 @@ def get_session_count( ) cache.set(cache_key, session_count_last_hour, 600) + if not isinstance(session_count_last_hour, int): + raise TypeError("Session count is not an integer") return session_count_last_hour def get_session_interval(self, session_count: int, duration: timedelta) -> int | None: diff --git a/src/sentry/workflow_engine/handlers/condition/event_seen_count_handler.py b/src/sentry/workflow_engine/handlers/condition/event_seen_count_handler.py index 1eb79b7e6acf..b8bf1cfcda17 100644 --- a/src/sentry/workflow_engine/handlers/condition/event_seen_count_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/event_seen_count_handler.py @@ -14,4 +14,4 @@ class EventSeenCountConditionHandler(ActionFilterDataConditionHandler[WorkflowEv @staticmethod def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: group = event_data.group - return group.times_seen == comparison + return bool(group.times_seen == comparison) diff --git a/src/sentry/workflow_engine/handlers/condition/issue_priority_equals.py b/src/sentry/workflow_engine/handlers/condition/issue_priority_equals.py index ca4932c95e41..d131c97209d4 100644 --- a/src/sentry/workflow_engine/handlers/condition/issue_priority_equals.py +++ b/src/sentry/workflow_engine/handlers/condition/issue_priority_equals.py @@ -47,4 +47,4 @@ def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: return False group = event_data.group - return group.priority == comparison + return bool(group.priority == comparison) diff --git a/src/sentry/workflow_engine/handlers/condition/issue_priority_greater_or_equal_handler.py b/src/sentry/workflow_engine/handlers/condition/issue_priority_greater_or_equal_handler.py index d5e6730eda80..71950b6cf74f 100644 --- a/src/sentry/workflow_engine/handlers/condition/issue_priority_greater_or_equal_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/issue_priority_greater_or_equal_handler.py @@ -49,4 +49,4 @@ def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: return False group = event_data.group - return group.priority >= comparison + return bool(group.priority >= comparison) diff --git a/src/sentry/workflow_engine/handlers/condition/issue_resolution_condition_handler.py b/src/sentry/workflow_engine/handlers/condition/issue_resolution_condition_handler.py index b1acc53c219c..0e66e4574975 100644 --- a/src/sentry/workflow_engine/handlers/condition/issue_resolution_condition_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/issue_resolution_condition_handler.py @@ -17,4 +17,4 @@ class IssueResolutionConditionHandler(WorkflowTriggerDataConditionHandler): @staticmethod def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: group = event_data.group - return group.status == comparison + return bool(group.status == comparison) diff --git a/src/sentry/workflow_engine/handlers/condition/reappeared_event_handler.py b/src/sentry/workflow_engine/handlers/condition/reappeared_event_handler.py index 99c913af34be..8d164b8f2bfd 100644 --- a/src/sentry/workflow_engine/handlers/condition/reappeared_event_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/reappeared_event_handler.py @@ -26,4 +26,4 @@ def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: if has_escalated is None: return False - return has_escalated == comparison + return bool(has_escalated == comparison) diff --git a/src/sentry/workflow_engine/handlers/condition/regression_event_handler.py b/src/sentry/workflow_engine/handlers/condition/regression_event_handler.py index 5139a4cdfc70..fd41c7bd842e 100644 --- a/src/sentry/workflow_engine/handlers/condition/regression_event_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/regression_event_handler.py @@ -26,4 +26,4 @@ def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: if state is None: return False - return state["is_regression"] == comparison + return bool(state["is_regression"] == comparison) diff --git a/src/sentry/workflow_engine/handlers/detector/base.py b/src/sentry/workflow_engine/handlers/detector/base.py index cd26368bafe4..057382800348 100644 --- a/src/sentry/workflow_engine/handlers/detector/base.py +++ b/src/sentry/workflow_engine/handlers/detector/base.py @@ -186,7 +186,7 @@ def create_occurrence( def get_event_id(self, event_data: EventData) -> str: id_in_event_data = event_data.get("event_id") - if id_in_event_data: + if isinstance(id_in_event_data, str) and id_in_event_data: return id_in_event_data return str(uuid4()) diff --git a/src/sentry/workflow_engine/handlers/detector/condition.py b/src/sentry/workflow_engine/handlers/detector/condition.py index 729058fad006..02fc205fa050 100644 --- a/src/sentry/workflow_engine/handlers/detector/condition.py +++ b/src/sentry/workflow_engine/handlers/detector/condition.py @@ -36,7 +36,7 @@ class DetectorHandler(BaseDetectorHandler[DataPacketType, DataPacketEvaluationTy def __init__(self, detector: Detector): super().__init__(detector) - if detector.workflow_condition_group_id is not None: + if detector.workflow_condition_group_id: try: # Check if workflow_condition_group is already prefetched if Detector.workflow_condition_group.is_cached(detector): diff --git a/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py b/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py index dc85a494d42e..0d49de365a31 100644 --- a/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py +++ b/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py @@ -56,7 +56,7 @@ def seer_activity_handler( activity: Activity, detector_id: DetectorId | None = None, ) -> None: - logging_ctx = { + logging_ctx: dict[str, object] = { "activity_type": activity.type, "group_id": group.id, "project_id": group.project_id, @@ -165,7 +165,7 @@ def activity_handler( If there's a need for greater flexibility, create a new handler in the registry. But, these custom handlers will not have the platform metrics or logging. """ - logging_ctx = { + logging_ctx: dict[str, object] = { "activity_type": activity.type, "group_id": group.id, "project_id": group.project_id, diff --git a/src/sentry/workflow_engine/models/alertrule_detector.py b/src/sentry/workflow_engine/models/alertrule_detector.py index d4a1c9df4b1b..f47c65590067 100644 --- a/src/sentry/workflow_engine/models/alertrule_detector.py +++ b/src/sentry/workflow_engine/models/alertrule_detector.py @@ -44,8 +44,8 @@ class AlertRuleDetector(DefaultFieldsModel): objects: ClassVar[AlertRuleDetectorManager] = AlertRuleDetectorManager() objects_for_deletion: ClassVar[BaseManager["AlertRuleDetector"]] = BaseManager() - alert_rule_id = BoundedBigIntegerField(null=True, db_index=True) - rule_id = BoundedBigIntegerField(null=True, db_index=True) + alert_rule_id = BoundedBigIntegerField[int | None, int | None](null=True, db_index=True) + rule_id = BoundedBigIntegerField[int | None, int | None](null=True, db_index=True) detector = FlexibleForeignKey("workflow_engine.Detector") class Meta: diff --git a/src/sentry/workflow_engine/models/alertrule_workflow.py b/src/sentry/workflow_engine/models/alertrule_workflow.py index 01e84504e825..d38a5d8269eb 100644 --- a/src/sentry/workflow_engine/models/alertrule_workflow.py +++ b/src/sentry/workflow_engine/models/alertrule_workflow.py @@ -18,8 +18,8 @@ class AlertRuleWorkflow(DefaultFieldsModel): __relocation_scope__ = RelocationScope.Organization - alert_rule_id = BoundedBigIntegerField(null=True, db_index=True) - rule_id = BoundedBigIntegerField(null=True, db_index=True) + alert_rule_id = BoundedBigIntegerField[int | None, int | None](null=True, db_index=True) + rule_id = BoundedBigIntegerField[int | None, int | None](null=True, db_index=True) workflow = FlexibleForeignKey("workflow_engine.Workflow") class Meta: diff --git a/src/sentry/workflow_engine/models/data_condition.py b/src/sentry/workflow_engine/models/data_condition.py index e243396ac830..774ea02aa365 100644 --- a/src/sentry/workflow_engine/models/data_condition.py +++ b/src/sentry/workflow_engine/models/data_condition.py @@ -176,7 +176,11 @@ def _evaluate_operator( op = CONDITION_OPS[condition_type] try: - return op(cast(Any, value), self.comparison) + # justification: condition ops return the declared evaluation result types. + return cast( + "DetectorPriorityLevel | int | float | bool | None | ConditionError", + op(cast(Any, value), self.comparison), + ) except TypeError: logger.exception( "Invalid comparison for data condition", diff --git a/src/sentry/workflow_engine/models/datacondition_alertruletrigger.py b/src/sentry/workflow_engine/models/datacondition_alertruletrigger.py index 8423ae787ba5..3f1b7e139765 100644 --- a/src/sentry/workflow_engine/models/datacondition_alertruletrigger.py +++ b/src/sentry/workflow_engine/models/datacondition_alertruletrigger.py @@ -18,7 +18,7 @@ class DataConditionAlertRuleTrigger(DefaultFieldsModel): __relocation_scope__ = RelocationScope.Organization - alert_rule_trigger_id = BoundedBigIntegerField(null=True) + alert_rule_trigger_id = BoundedBigIntegerField[int | None, int | None](null=True) data_condition = FlexibleForeignKey("workflow_engine.DataCondition") class Meta: diff --git a/src/sentry/workflow_engine/models/detector.py b/src/sentry/workflow_engine/models/detector.py index 546dddcbb5a2..8e35e803e2d9 100644 --- a/src/sentry/workflow_engine/models/detector.py +++ b/src/sentry/workflow_engine/models/detector.py @@ -3,7 +3,7 @@ import builtins import logging from collections.abc import Callable -from typing import TYPE_CHECKING, Any, ClassVar, Self, TypedDict +from typing import TYPE_CHECKING, Any, ClassVar, Self, TypedDict, cast from django.conf import settings from django.db import models @@ -163,7 +163,8 @@ def get_default_detector_for_project(cls, project_id: int, detector_type: str) - if detector is None: detector = cls.objects.get(project_id=project_id, type=detector_type) cache.set(cache_key, detector, cls.CACHE_TTL) - return detector + # justification: the cache only stores Detector instances under this key. + return cast("Detector", detector) @classmethod def get_error_detector_for_project(cls, project_id: int) -> Detector: diff --git a/src/sentry/workflow_engine/models/detector_state.py b/src/sentry/workflow_engine/models/detector_state.py index d4de6f6cd3b4..9dbd080ca6c6 100644 --- a/src/sentry/workflow_engine/models/detector_state.py +++ b/src/sentry/workflow_engine/models/detector_state.py @@ -38,7 +38,7 @@ class DetectorState(DefaultFieldsModel): # A unix epoch timestamp in milliseconds that rotates when the detector leaves OK after # staying OK for its handler's `regression_timeout` # This is used for creating unique fingerprints - activation_id = BoundedBigIntegerField(null=True) + activation_id = BoundedBigIntegerField[int | None, int | None](null=True) @property def priority_level(self) -> DetectorPriorityLevel: diff --git a/src/sentry/workflow_engine/models/incident_groupopenperiod.py b/src/sentry/workflow_engine/models/incident_groupopenperiod.py index 20ddee9e7310..b7e920927a6a 100644 --- a/src/sentry/workflow_engine/models/incident_groupopenperiod.py +++ b/src/sentry/workflow_engine/models/incident_groupopenperiod.py @@ -20,7 +20,7 @@ class IncidentGroupOpenPeriod(DefaultFieldsModel): __relocation_scope__ = RelocationScope.Excluded - incident_id = BoundedBigIntegerField(null=True, unique=True) + incident_id = BoundedBigIntegerField[int | None, int | None](null=True, unique=True) incident_identifier = models.IntegerField(null=True) group_open_period = FlexibleForeignKey("sentry.GroupOpenPeriod", unique=True) diff --git a/src/sentry/workflow_engine/models/workflow.py b/src/sentry/workflow_engine/models/workflow.py index 766053452dbf..8bd37b742553 100644 --- a/src/sentry/workflow_engine/models/workflow.py +++ b/src/sentry/workflow_engine/models/workflow.py @@ -139,7 +139,7 @@ def evaluate_trigger_conditions( process_data_condition_group, ) - if self.when_condition_group_id is None: + if not self.when_condition_group_id: return ( DataConditionGroupEvaluation( result=True, diff --git a/src/sentry/workflow_engine/processors/action.py b/src/sentry/workflow_engine/processors/action.py index 9169e91e497f..ce8eae35e63a 100644 --- a/src/sentry/workflow_engine/processors/action.py +++ b/src/sentry/workflow_engine/processors/action.py @@ -129,8 +129,8 @@ def _bulk_insert_workflow_action_group_statuses( exists_checks=True after an IntegrityError. """ # XXX: the query does not currently include batch size limit like bulk_create does - values_placeholders = [] - values_data = [] + values_placeholders: list[str] = [] + values_data: list[int | datetime] = [] for s in missing_statuses: values_placeholders.append("(%s, %s, %s, %s, %s)") values_data.extend([s.workflow_id, s.action_id, s.group_id, now, now]) diff --git a/src/sentry/workflow_engine/processors/detector.py b/src/sentry/workflow_engine/processors/detector.py index 73c58eed01b4..db2d5d292e0f 100644 --- a/src/sentry/workflow_engine/processors/detector.py +++ b/src/sentry/workflow_engine/processors/detector.py @@ -2,6 +2,7 @@ import logging from dataclasses import dataclass, field +from typing import cast from sentry_sdk import traces @@ -71,7 +72,8 @@ def get_all_projects_detector(organization_id: int) -> Detector | None: if cached is not _DETECTOR_SENTINEL: metrics_tags["cache_hit"] = "true" metrics_tags["detector_found"] = "true" if cached is not None else "false" - return cached + # justification: the cache stores either a Detector or None. + return cast("Detector | None", cached) result = query_all_projects_detector(organization_id=organization_id) metrics_tags["cache_hit"] = "false" metrics_tags["detector_found"] = "true" if result is not None else "false" @@ -81,7 +83,7 @@ def get_all_projects_detector(organization_id: int) -> Detector | None: def invalidate_all_projects_detector_cache(instance: Detector) -> None: - if instance.project_id is None: + if not instance.project_id: organization_id = instance.config.get("organization_id") if organization_id is not None: cache_key = _get_all_projects_detector_cache_key(organization_id) diff --git a/src/sentry/workflow_engine/processors/evaluations/eap.py b/src/sentry/workflow_engine/processors/evaluations/eap.py index 64c7e1cea98e..ede5a649a791 100644 --- a/src/sentry/workflow_engine/processors/evaluations/eap.py +++ b/src/sentry/workflow_engine/processors/evaluations/eap.py @@ -8,7 +8,7 @@ from datetime import datetime from enum import Enum from functools import partial -from typing import TYPE_CHECKING, overload +from typing import TYPE_CHECKING, cast, overload from arroyo import Topic as ArroyoTopic from arroyo.backends.kafka import FutureTrackingProducer, KafkaPayload, KafkaProducer @@ -262,7 +262,8 @@ def serialize(self, value: object) -> EAPAttributeValue: return None if isinstance(value, Enum): - return self.serialize(value.value) + # justification: serialization recurses into the enum's raw value. + return cast("EAPAttributeValue | None", self.serialize(value.value)) if isinstance(value, datetime): return value.isoformat() diff --git a/src/sentry/workflow_engine/processors/project_transfer.py b/src/sentry/workflow_engine/processors/project_transfer.py index db19c485117e..dbcaed239ef3 100644 --- a/src/sentry/workflow_engine/processors/project_transfer.py +++ b/src/sentry/workflow_engine/processors/project_transfer.py @@ -21,8 +21,8 @@ def _simple_shallow_clone[T: DefaultFieldsModel](original: T, **overrides: Any) -> T: new = copy.copy(original) - new.pk = None - new.id = None + setattr(new, "pk", None) + setattr(new, "id", None) for attr, value in overrides.items(): setattr(new, attr, value) new.save() diff --git a/src/sentry/workflow_engine/processors/workflow_fire_history.py b/src/sentry/workflow_engine/processors/workflow_fire_history.py index 5e0949733b25..8199c14bd0cd 100644 --- a/src/sentry/workflow_engine/processors/workflow_fire_history.py +++ b/src/sentry/workflow_engine/processors/workflow_fire_history.py @@ -45,7 +45,7 @@ def create_workflow_fire_histories( ).values_list("workflow_id", flat=True) ) - event_id = ( + event_id = str( event_data.event.event_id if isinstance(event_data.event, GroupEvent) else event_data.event.id diff --git a/src/sentry/workflow_engine/receivers/detector_workflow.py b/src/sentry/workflow_engine/receivers/detector_workflow.py index ea589f9b74b3..e2e901dfc3a0 100644 --- a/src/sentry/workflow_engine/receivers/detector_workflow.py +++ b/src/sentry/workflow_engine/receivers/detector_workflow.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Literal from django.db import router, transaction from django.db.models.signals import pre_delete, pre_save @@ -61,7 +61,7 @@ def invalidate_processing_workflows_cache_delete_relationship( # If we don't have a workflow (e.g., during cascade delete), we should # invalidate all environments related to the detector try: - env_id = instance.workflow.environment_id + env_id: int | None | Literal["default"] = instance.workflow.environment_id except Workflow.DoesNotExist: env_id = DEFAULT_VALUE diff --git a/src/sentry/workflow_engine/tasks/actions.py b/src/sentry/workflow_engine/tasks/actions.py index 22da53e83e19..ccf73eb3d38b 100644 --- a/src/sentry/workflow_engine/tasks/actions.py +++ b/src/sentry/workflow_engine/tasks/actions.py @@ -75,7 +75,7 @@ def build_trigger_action_task_params( elif isinstance(event_data.event, Activity): activity_id = event_data.event.id - task_params = { + task_params: dict[str, object] = { "action_id": action.id, "workflow_id": workflow_id, "event_id": event_id, diff --git a/src/social_auth/admin.py b/src/social_auth/admin.py index 0f574d364e9f..eb926d5acc79 100644 --- a/src/social_auth/admin.py +++ b/src/social_auth/admin.py @@ -6,12 +6,14 @@ _User = UserSocialAuth.user_model() if hasattr(_User, "USERNAME_FIELD"): - username_field = _User.USERNAME_FIELD + username_field: str | None = _User.USERNAME_FIELD elif hasattr(_User, "username"): username_field = "username" else: username_field = None +assert username_field is not None + fieldnames = ("first_name", "last_name", "email") + (username_field,) all_names = get_all_field_names(_User) user_search_fields = ["user__" + name for name in fieldnames if name in all_names] diff --git a/src/social_auth/backends/__init__.py b/src/social_auth/backends/__init__.py index af5e29a7c56e..89c333c0e584 100644 --- a/src/social_auth/backends/__init__.py +++ b/src/social_auth/backends/__init__.py @@ -14,7 +14,8 @@ import logging import threading -from typing import Any +from collections.abc import Iterable, Mapping +from typing import Any, cast from urllib.error import HTTPError from urllib.parse import parse_qsl, urlencode from urllib.request import Request @@ -22,9 +23,14 @@ import requests from django.conf import settings from django.contrib.auth import authenticate, load_backend +from django.contrib.auth.base_user import AbstractBaseUser +from django.core.exceptions import ImproperlyConfigured +from django.http import HttpRequest from django.utils.crypto import constant_time_compare, get_random_string from requests_oauthlib import OAuth1 +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 import json from sentry.utils.auth import record_suspended_user_rejection @@ -78,7 +84,7 @@ class SocialAuthBackend: name = "" # provider name, it's stored in database supports_inactive_user = False - def authenticate(self, request, *args, **kwargs): + def authenticate(self, request: HttpRequest | None, *args: Any, **kwargs: Any) -> User | None: """Authenticate user using social credentials Authentication is made if this is the correct backend, backend @@ -92,7 +98,8 @@ def authenticate(self, request, *args, **kwargs): if not (self.name and kwargs.get(self.name) and "response" in kwargs): return None - response = kwargs.get("response") + # justification: runtime guard above validates the shape + response = cast("Mapping[str, Any]", kwargs.get("response")) pipeline = PIPELINE kwargs = kwargs.copy() kwargs["backend"] = self @@ -106,18 +113,26 @@ def authenticate(self, request, *args, **kwargs): out = self.pipeline(pipeline, request, *args, **kwargs) if not isinstance(out, dict): - return out + # justification: the social auth pipeline returns either a user or pipeline state. + return cast("User | None", out) social_user = out.get("social_user") if social_user: # define user.social_user attribute to track current social # account - user = social_user.user - user.social_user = social_user - user.is_new = out.get("is_new") + user: User = social_user.user + setattr(user, "social_user", social_user) + setattr(user, "is_new", out.get("is_new")) return user + return None - def pipeline(self, pipeline, request, *args, **kwargs): + def pipeline( + self, + pipeline: Iterable[str], + request: HttpRequest | None, + *args: Any, + **kwargs: Any, + ) -> Any: """Pipeline""" out = kwargs.copy() @@ -154,15 +169,21 @@ def pipeline(self, pipeline, request, *args, **kwargs): clean_partial_pipeline(kwargs["request"]) return out - def extra_data(self, user, uid, response, details): + def extra_data( + self, + user: RpcUser | AbstractBaseUser, + uid: str, + response: Mapping[str, Any], + details: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: """Return default blank user extra data""" return {} - def get_user_id(self, details, response): + def get_user_id(self, details: Mapping[str, Any], response: Mapping[str, Any]) -> str: """Must return a unique ID from values returned on details""" raise NotImplementedError("Implement in subclass") - def get_user_details(self, response): + def get_user_details(self, response: Mapping[str, Any]) -> dict[str, Any]: """Must return user details in a know internal struct: {'username': , 'email': , @@ -173,7 +194,7 @@ def get_user_details(self, response): raise NotImplementedError("Implement in subclass") @classmethod - def tokens(cls, instance): + def tokens(cls, instance: UserSocialAuth) -> dict[str, Any]: """Return the tokens needed to authenticate the access to any API the service might provide. The return value will be a dictionary with the token type name as key and the token value. @@ -185,7 +206,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. @@ -215,12 +236,19 @@ class OAuthBackend(SocialAuthBackend): EXTRA_DATA: list[tuple[str, str]] | None = None ID_KEY = "id" - def get_user_id(self, details, response): + def get_user_id(self, details: Mapping[str, Any], response: Mapping[str, Any]) -> str: """OAuth providers return an unique user id in response""" - return response[self.ID_KEY] + # justification: OAuth providers return a string identifier for the ID key. + return cast("str", response[self.ID_KEY]) @classmethod - def extra_data(cls, user, uid, response, details=None): + def extra_data( + cls, + user: RpcUser | AbstractBaseUser, + uid: str, + response: Mapping[str, Any], + details: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: """Return access_token and extra defined names to store in extra_data field""" data = {"access_token": response.get("access_token", "")} @@ -260,8 +288,9 @@ class BaseAuth: """ AUTH_BACKEND: type[SocialAuthBackend] + request: Any - def __init__(self, request, redirect): + def __init__(self, request: HttpRequest, redirect: str) -> None: self.request = request # TODO(python3): use {**x, **y} syntax once 2.7 support is dropped data = request.GET.copy() @@ -269,19 +298,19 @@ def __init__(self, request, redirect): self.data = data self.redirect = redirect - def auth_url(self): + def auth_url(self) -> str: """Must return redirect URL to auth provider""" raise NotImplementedError("Implement in subclass") - def auth_html(self): + def auth_html(self) -> str: """Must return login HTML content returned by provider""" raise NotImplementedError("Implement in subclass") - def auth_complete(self, *args, **kwargs): + def auth_complete(self, *args: Any, **kwargs: Any) -> RpcUser | None: """Completes logging process, must return user instance""" raise NotImplementedError("Implement in subclass") - def to_session_dict(self, next_idx, *args, **kwargs): + def to_session_dict(self, next_idx: int, *args: Any, **kwargs: Any) -> dict[str, Any]: """Returns dict to store on session for partial pipeline.""" backend = kwargs["backend"] kwargs["backend"] = f"{backend.__module__}.{backend.__class__.__name__}" @@ -292,7 +321,9 @@ def to_session_dict(self, next_idx, *args, **kwargs): "kwargs": {key: model_to_ctype(val) for key, val in kwargs.items()}, } - def from_session_dict(self, session_data, *args, **kwargs): + def from_session_dict( + self, session_data: Mapping[str, Any], *args: Any, **kwargs: Any + ) -> tuple[int, tuple[Any, ...], dict[str, Any]]: """Takes session saved data to continue pipeline and merges with any new extra argument needed. Returns tuple with next pipeline index entry, arguments and keyword arguments to continue the process.""" @@ -309,20 +340,25 @@ def from_session_dict(self, session_data, *args, **kwargs): return (session_data["next"], args, saved_kwargs) - def continue_pipeline(self, *args, **kwargs): + def continue_pipeline(self, *args: Any, **kwargs: Any) -> RpcUser | None: """Continue previous halted pipeline""" kwargs.update({"auth": self, self.AUTH_BACKEND.name: True}) - return authenticate(*args, **kwargs) + return cast("RpcUser | None", authenticate(*args, **kwargs)) - def request_token_extra_arguments(self): + def request_token_extra_arguments(self) -> dict[str, Any]: """Return extra arguments needed on request-token process, setting is per backend and defined by: _REQUEST_TOKEN_EXTRA_ARGUMENTS. """ backend_name = self.AUTH_BACKEND.name.upper().replace("-", "_") - return setting(backend_name + "_REQUEST_TOKEN_EXTRA_ARGUMENTS", {}) + value = setting(backend_name + "_REQUEST_TOKEN_EXTRA_ARGUMENTS", {}) + if not isinstance(value, dict): + raise ImproperlyConfigured( + f"{backend_name}_REQUEST_TOKEN_EXTRA_ARGUMENTS must be a dict" + ) + return value - def auth_extra_arguments(self): + def auth_extra_arguments(self) -> dict[str, Any]: """Return extra arguments needed on auth process, setting is per backend and defined by: _AUTH_EXTRA_ARGUMENTS. @@ -330,6 +366,8 @@ def auth_extra_arguments(self): """ backend_name = self.AUTH_BACKEND.name.upper().replace("-", "_") extra_arguments = setting(backend_name + "_AUTH_EXTRA_ARGUMENTS", {}) + if not isinstance(extra_arguments, dict): + raise ImproperlyConfigured(f"{backend_name}_AUTH_EXTRA_ARGUMENTS must be a dict") for key, value in extra_arguments.items(): if key in self.data: extra_arguments[key] = self.data[key] @@ -338,23 +376,23 @@ 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 - def disconnect(self, user, association_id=None): + def disconnect(self, user: User, association_id: int | None = None) -> None: """Deletes current backend from user if associated. Override if extra operations are needed. """ name = self.AUTH_BACKEND.name do_revoke = setting("SOCIAL_AUTH_REVOKE_TOKENS_ON_DISCONNECT") - filter_args = {} + filter_args: dict[str, Any] = {} if association_id: filter_args["id"] = association_id @@ -367,7 +405,7 @@ def disconnect(self, user, association_id=None): instance.revoke_token(drop_token=False) instances.delete() - def build_absolute_uri(self, path=None): + def build_absolute_uri(self, path: str | None = None) -> str: return absolute_uri(path) @@ -381,38 +419,38 @@ class OAuthAuth(BaseAuth): DEFAULT_SCOPE: list[str] | None = None SCOPE_SEPARATOR = " " - def __init__(self, request, redirect): + def __init__(self, request: HttpRequest, redirect: str) -> None: """Init method""" super().__init__(request, redirect) self.redirect_uri = self.build_absolute_uri(self.redirect) @classmethod - def get_key_and_secret(cls): + def get_key_and_secret(cls) -> tuple[str, str]: """Return tuple with Consumer Key and Consumer Secret for current service provider. Must return (key, secret), order *must* be respected. """ 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)) - def get_scope(self): + def get_scope(self) -> list[str]: """Return list with needed access scope""" scope: list[str] = self.DEFAULT_SCOPE or [] if self.SCOPE_VAR_NAME: 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: param[self.SCOPE_PARAMETER_NAME] = self.SCOPE_SEPARATOR.join(scope) return param - def user_data(self, access_token, *args, **kwargs): + def user_data(self, access_token: str, *args: Any, **kwargs: Any) -> dict[str, Any] | None: """Loads user data from service. Implement in subclass""" return {} @@ -430,7 +468,7 @@ class BaseOAuth1(OAuthAuth): REQUEST_TOKEN_URL = "" ACCESS_TOKEN_URL = "" - def auth_url(self): + def auth_url(self) -> str: """Return redirect url""" token = self.unauthorized_token() name = self.AUTH_BACKEND.name + "unauthorized_token_name" @@ -440,7 +478,7 @@ def auth_url(self): self.request.session.modified = True return self.oauth_authorization_request(token) - def auth_complete(self, *args, **kwargs): + def auth_complete(self, *args: Any, **kwargs: Any) -> RpcUser | None: """Return user, might be logged in""" # Multiple unauthorized tokens are supported (see #521) name = self.AUTH_BACKEND.name + "unauthorized_token_name" @@ -469,16 +507,16 @@ def auth_complete(self, *args, **kwargs): raise return self.do_auth(access_token, *args, **kwargs) - def do_auth(self, access_token, *args, **kwargs): + def do_auth(self, access_token: Any, *args: Any, **kwargs: Any) -> RpcUser | None: """Finish the auth process once the access_token was retrieved""" data = self.user_data(access_token) if data is not None: data["access_token"] = access_token.to_string() kwargs.update({"auth": self, "response": data, self.AUTH_BACKEND.name: True}) - return authenticate(*args, **kwargs) + return cast("RpcUser | None", authenticate(*args, **kwargs)) - def unauthorized_token(self): + def unauthorized_token(self) -> Any: """Return request for unauthorized token (first stage)""" params = self.request_token_extra_arguments() params.update(self.get_scope_argument()) @@ -490,7 +528,7 @@ def unauthorized_token(self): ) return response.content - def oauth_authorization_request(self, token): + def oauth_authorization_request(self, token: Any) -> str: """Generate OAuth request to authorize token.""" if not isinstance(token, dict): token = parse_qs(token) @@ -500,7 +538,7 @@ def oauth_authorization_request(self, token): params["redirect_uri"] = self.redirect_uri return self.AUTHORIZATION_URL + "?" + urlencode(params) - def oauth_auth(self, token=None, oauth_verifier=None): + def oauth_auth(self, token: Any = None, oauth_verifier: str | None = None) -> OAuth1: key, secret = self.get_key_and_secret() oauth_verifier = oauth_verifier or self.data.get("oauth_verifier") token = token or {} @@ -513,16 +551,22 @@ def oauth_auth(self, token=None, oauth_verifier=None): verifier=oauth_verifier, ) - def oauth_request(self, token, url, extra_params=None, method="GET"): + def oauth_request( + self, + token: Any, + url: str, + extra_params: Mapping[str, Any] | None = None, + method: str = "GET", + ) -> Any: """Generate OAuth request, setups callback url""" return self.request(url, auth=self.oauth_auth(token)) - def fetch_response(self, request): + def fetch_response(self, request: Any) -> str: """Executes request and fetches service response""" response = dsa_urlopen(request.to_url()) return "\n".join(response.readlines()) - def access_token(self, token): + def access_token(self, token: Any) -> Any: """Return request for access token value""" return self.request(self.ACCESS_TOKEN_URL, auth=self.oauth_auth(token)) @@ -546,18 +590,18 @@ class BaseOAuth2(OAuthAuth): REDIRECT_STATE = True STATE_PARAMETER = True - def state_token(self): + def state_token(self) -> str: """Generate csrf token to include as state parameter.""" return get_random_string(32) - def get_redirect_uri(self, state=None): + def get_redirect_uri(self, state: str | None = None) -> str: """Build redirect_uri with redirect_state parameter.""" uri = self.redirect_uri if self.REDIRECT_STATE and state: uri = url_add_parameters(uri, {"redirect_state": state}) return uri - def auth_params(self, state=None): + def auth_params(self, state: str | None = None) -> dict[str, Any]: client_id, client_secret = self.get_key_and_secret() params = {"client_id": client_id, "redirect_uri": self.get_redirect_uri(state)} if self.STATE_PARAMETER and state: @@ -566,7 +610,7 @@ def auth_params(self, state=None): params["response_type"] = self.RESPONSE_TYPE return params - def auth_url(self): + def auth_url(self) -> str: """Return redirect url""" if self.STATE_PARAMETER or self.REDIRECT_STATE: # Store state in session for further request validation. The state @@ -587,7 +631,7 @@ def auth_url(self): query_string = self._get_safe_query_string() return self.AUTHORIZATION_URL + "?" + urlencode(params) + query_string - def _get_safe_query_string(self): + def _get_safe_query_string(self) -> str: """ Returns filtered query string without client_id parameter. """ @@ -609,12 +653,14 @@ def _get_safe_query_string(self): else: return "" - def validate_state(self): + def validate_state(self) -> str | None: """Validate state value. Raises exception on error, returns state value if valid.""" if not self.STATE_PARAMETER and not self.REDIRECT_STATE: return None - state = self.request.session.get(self.AUTH_BACKEND.name + "_state") + state: str | None = self.request.session.get(self.AUTH_BACKEND.name + "_state") + if not isinstance(state, str): + state = None if state: request_state = self.data.get("state") or self.data.get("redirect_state") if not request_state: @@ -625,12 +671,12 @@ def validate_state(self): raise AuthStateForbidden(self) return state - def process_error(self, data): + def process_error(self, data: Mapping[str, Any]) -> None: error = data.get("error_description") or data.get("error") if error: raise AuthFailed(self, error) - def auth_complete_params(self, state=None): + def auth_complete_params(self, state: str | None = None) -> dict[str, Any]: client_id, client_secret = self.get_key_and_secret() return { "grant_type": "authorization_code", # request auth code @@ -641,10 +687,10 @@ def auth_complete_params(self, state=None): } @classmethod - def auth_headers(cls): + def auth_headers(cls) -> dict[str, str]: return {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"} - def auth_complete(self, *args, **kwargs): + def auth_complete(self, *args: Any, **kwargs: Any) -> RpcUser | None: """Completes logging process, must return user instance""" self.process_error(self.data) params = self.auth_complete_params(self.validate_state()) @@ -669,7 +715,7 @@ def auth_complete(self, *args, **kwargs): return self.do_auth(response["access_token"], response=response, *args, **kwargs) @classmethod - def refresh_token_params(cls, token, provider): + def refresh_token_params(cls, token: str, provider: str) -> dict[str, str]: client_id, client_secret = cls.get_key_and_secret() return { "refresh_token": token, @@ -679,28 +725,28 @@ def refresh_token_params(cls, token, provider): } @classmethod - def refresh_token(cls, token, provider): + def refresh_token(cls, token: str, provider: str) -> Any: params = cls.refresh_token_params(token, provider) response = requests.post(cls.ACCESS_TOKEN_URL, data=params, headers=cls.auth_headers()) response.raise_for_status() return response.json() @classmethod - def revoke_token_params(cls, token, uid): + def revoke_token_params(cls, token: str, uid: str) -> dict[str, str] | None: return None @classmethod - def revoke_token_headers(cls, token, uid): + def revoke_token_headers(cls, token: str, uid: str) -> dict[str, str] | None: return None @classmethod - def process_revoke_token_response(cls, response): - return response.code == 200 + def process_revoke_token_response(cls, response: Any) -> bool: + return bool(response.code == 200) @classmethod - def revoke_token(cls, token, uid): + def revoke_token(cls, token: str, uid: str) -> bool | None: if not cls.REVOKE_TOKEN_URL: - return + return None url = cls.REVOKE_TOKEN_URL.format(token=token, uid=uid) params = cls.revoke_token_params(token, uid) or {} headers = cls.revoke_token_headers(token, uid) or {} @@ -715,13 +761,13 @@ def revoke_token(cls, token, uid): response = dsa_urlopen(request) return cls.process_revoke_token_response(response) - def do_auth(self, access_token, *args, **kwargs): + def do_auth(self, access_token: Any, *args: Any, **kwargs: Any) -> RpcUser | None: """Finish the auth process once the access_token was retrieved""" data = self.user_data(access_token, *args, **kwargs) response = kwargs.get("response") or {} response.update(data or {}) kwargs.update({"auth": self, "response": response, self.AUTH_BACKEND.name: True}) - return authenticate(*args, **kwargs) + return cast("RpcUser | None", authenticate(*args, **kwargs)) # Cache for discovered backends. @@ -730,7 +776,7 @@ def do_auth(self, access_token, *args, **kwargs): _import_lock = threading.Lock() -def get_backends(force_load=False): +def get_backends(force_load: bool = False) -> dict[str, type[BaseAuth]]: """ Entry point to the BACKENDS cache. If BACKENDSCACHE hasn't been populated, each of the modules referenced in @@ -764,7 +810,7 @@ def get_backends(force_load=False): return BACKENDSCACHE -def get_backend(name, *args, **kwargs): +def get_backend(name: str, *args: Any, **kwargs: Any) -> BaseAuth | None: get_backends() try: diff --git a/src/social_auth/backends/asana.py b/src/social_auth/backends/asana.py index a0cf282a4cda..1bc4c0a94a34 100644 --- a/src/social_auth/backends/asana.py +++ b/src/social_auth/backends/asana.py @@ -4,8 +4,12 @@ and put into sentry.conf.py """ +from collections.abc import Mapping +from typing import Any + import requests +from sentry.users.services.user import RpcUser from social_auth.backends import BaseOAuth2, OAuthBackend from social_auth.exceptions import AuthCanceled, AuthUnknownError @@ -26,7 +30,7 @@ class AsanaBackend(OAuthBackend): ] ID_KEY = "gid" - def get_user_details(self, response): + def get_user_details(self, response: Mapping[str, Any]) -> dict[str, Any]: """Return user details from Asana account""" return { @@ -46,17 +50,18 @@ class AsanaAuth(BaseOAuth2): SETTINGS_SECRET_NAME = "ASANA_CLIENT_SECRET" REDIRECT_STATE = False - def user_data(self, access_token, *args, **kwargs): + def user_data(self, access_token: str, *args: Any, **kwargs: Any) -> dict[str, Any] | None: """Loads user data from service""" headers = {"Authorization": f"Bearer {access_token}"} try: resp = requests.get(ASANA_USER_DETAILS_URL, headers=headers) resp.raise_for_status() - return resp.json()["data"] + data = resp.json()["data"] + return data if isinstance(data, dict) else None except ValueError: return None - def auth_complete(self, *args, **kwargs): + def auth_complete(self, *args: Any, **kwargs: Any) -> RpcUser | None: """Completes logging process, must return user instance""" self.process_error(self.data) params = self.auth_complete_params(self.validate_state()) diff --git a/src/social_auth/backends/bitbucket.py b/src/social_auth/backends/bitbucket.py index 54407f09f3e6..074ab03ceb4a 100644 --- a/src/social_auth/backends/bitbucket.py +++ b/src/social_auth/backends/bitbucket.py @@ -11,9 +11,13 @@ extend it. """ +from collections.abc import Mapping +from typing import Any, cast + from sentry.integrations.types import IntegrationProviderSlug from sentry.utils import json from social_auth.backends import BaseOAuth1, OAuthBackend +from social_auth.models import UserSocialAuth from social_auth.utils import dsa_urlopen # Bitbucket configuration @@ -37,23 +41,27 @@ class BitbucketBackend(OAuthBackend): ("last_name", "last_name"), ] - def get_user_details(self, response): + def get_user_details(self, response: Mapping[str, Any]) -> dict[str, Any]: """Return user details from Bitbucket account""" return { "username": response.get("username"), "email": response.get("email"), - "fullname": " ".join((response.get("first_name"), response.get("last_name"))), + "fullname": " ".join( + # justification: provider payload names are strings + cast("tuple[str, str]", (response.get("first_name"), response.get("last_name"))) + ), "first_name": response.get("first_name"), "last_name": response.get("last_name"), } - def get_user_id(self, details, response): + def get_user_id(self, details: Mapping[str, Any], response: Mapping[str, Any]) -> str: """Return the user id, Bitbucket only provides username as a unique identifier""" - return response["username"] + username = response["username"] + return username if isinstance(username, str) else str(username) @classmethod - def tokens(cls, instance): + def tokens(cls, instance: UserSocialAuth) -> dict[str, Any]: """Return the tokens needed to authenticate the access to any API the service might provide. Bitbucket uses a pair of OAuthToken consisting on a oauth_token and oauth_token_secret. @@ -77,7 +85,7 @@ class BitbucketAuth(BaseOAuth1): SETTINGS_SECRET_NAME = "BITBUCKET_CONSUMER_SECRET" DEFAULT_SCOPE = ["webhook", "repository", "issue"] - def user_data(self, access_token): + def user_data(self, access_token: str, *args: Any, **kwargs: Any) -> dict[str, Any] | None: """Return user data provided""" # Bitbucket has a bit of an indirect route to obtain user data from an # authenticated query: First obtain the user's email via an @@ -103,6 +111,8 @@ def user_data(self, access_token): # BITBUCKET_USER_DATA_URL and the user's email response = dsa_urlopen(BITBUCKET_USER_DATA_URL + email) user_details = json.load(response)["user"] + if not isinstance(user_details, dict): + return None user_details["email"] = email return user_details except ValueError: diff --git a/src/social_auth/backends/github.py b/src/social_auth/backends/github.py index 755e885780e9..4b5c3808ed36 100644 --- a/src/social_auth/backends/github.py +++ b/src/social_auth/backends/github.py @@ -11,6 +11,8 @@ field, check OAuthBackend class for details on how to extend it. """ +from collections.abc import Mapping +from typing import Any from urllib.error import HTTPError from urllib.request import Request @@ -40,7 +42,7 @@ class GithubBackend(OAuthBackend): # Default extra data to store EXTRA_DATA = [("id", "id"), ("expires", "expires")] - def _fetch_emails(self, access_token): + def _fetch_emails(self, access_token: Any) -> list[Any]: """Fetch private emails from Github account""" req = Request( GITHUB_USER_DATA_URL + "/emails", headers={"Authorization": "token %s" % access_token} @@ -50,9 +52,9 @@ def _fetch_emails(self, access_token): data = json.load(dsa_urlopen(req)) except (ValueError, HTTPError): data = [] - return data + return data if isinstance(data, list) else [] - def get_user_details(self, response): + def get_user_details(self, response: Mapping[str, Any]) -> dict[str, Any]: """Return user details from Github account""" name = response.get("name") or "" details = {"username": response.get("login")} @@ -89,14 +91,15 @@ class GithubAuth(BaseOAuth2): GITHUB_ORGANIZATION = settings.GITHUB_ORGANIZATION - def user_data(self, access_token, *args, **kwargs): + def user_data(self, access_token: str, *args: Any, **kwargs: Any) -> dict[str, Any] | None: """Loads user data from service""" req = Request(GITHUB_USER_DATA_URL, headers={"Authorization": "token %s" % access_token}) try: - data = json.load(dsa_urlopen(req)) + loaded = json.load(dsa_urlopen(req)) except ValueError: - data = None + loaded = None + data = loaded if isinstance(loaded, dict) else None # if we have a github organization defined, test that the current users # is a member of that organization. diff --git a/src/social_auth/backends/pipeline/associate.py b/src/social_auth/backends/pipeline/associate.py index 564290175439..517c37abfd2a 100644 --- a/src/social_auth/backends/pipeline/associate.py +++ b/src/social_auth/backends/pipeline/associate.py @@ -1,10 +1,19 @@ +from collections.abc import Mapping +from typing import Any + from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist +from sentry.users.models.user import User from social_auth.exceptions import AuthException from social_auth.models import UserSocialAuth -def associate_by_email(details, user=None, *args, **kwargs): +def associate_by_email( + details: Mapping[str, Any], + user: User | None = None, + *args: object, + **kwargs: object, +) -> dict[str, User] | None: """Return user entry with same email address as one returned on details.""" if user: return None @@ -21,3 +30,4 @@ def associate_by_email(details, user=None, *args, **kwargs): raise AuthException(kwargs["backend"], "Not unique email address.") except ObjectDoesNotExist: pass + return None diff --git a/src/social_auth/backends/pipeline/misc.py b/src/social_auth/backends/pipeline/misc.py index fe974ac37d6e..f05f960b27b8 100644 --- a/src/social_auth/backends/pipeline/misc.py +++ b/src/social_auth/backends/pipeline/misc.py @@ -1,8 +1,16 @@ -from social_auth.backends import PIPELINE +from django.http import HttpRequest + +from social_auth.backends import PIPELINE, BaseAuth from social_auth.utils import setting -def save_status_to_session(request, auth, pipeline_index, *args, **kwargs): +def save_status_to_session( + request: HttpRequest, + auth: BaseAuth, + pipeline_index: int, + *args: object, + **kwargs: object, +) -> None: """Saves current social-auth status to session.""" next_entry = setting("SOCIAL_AUTH_PIPELINE_RESUME_ENTRY") diff --git a/src/social_auth/backends/pipeline/social.py b/src/social_auth/backends/pipeline/social.py index f23ed1151a89..88361fe02f07 100644 --- a/src/social_auth/backends/pipeline/social.py +++ b/src/social_auth/backends/pipeline/social.py @@ -1,9 +1,20 @@ +from collections.abc import Mapping +from typing import Any + from django.db import IntegrityError +from sentry.users.models.user import User +from social_auth.backends import SocialAuthBackend from social_auth.models import UserSocialAuth -def social_auth_user(backend, uid, user, *args, **kwargs): +def social_auth_user( + backend: SocialAuthBackend, + uid: str, + user: User, + *args: object, + **kwargs: object, +) -> dict[str, object]: """ Return UserSocialAuth details. """ @@ -11,7 +22,14 @@ def social_auth_user(backend, uid, user, *args, **kwargs): return {"social_user": social_user, "user": user, "new_association": False} -def associate_user(backend, user, uid, social_user=None, *args, **kwargs): +def associate_user( + backend: SocialAuthBackend, + user: User | None, + uid: str, + social_user: UserSocialAuth | None = None, + *args: object, + **kwargs: object, +) -> dict[str, object] | None: """Associate user social account with user instance.""" if social_user or not user: return None @@ -27,7 +45,16 @@ def associate_user(backend, user, uid, social_user=None, *args, **kwargs): return {"social_user": social, "user": social.user, "new_association": True} -def load_extra_data(backend, details, response, uid, user, social_user=None, *args, **kwargs): +def load_extra_data( + backend: SocialAuthBackend, + details: Mapping[str, Any], + response: Mapping[str, Any], + uid: str, + user: User, + social_user: UserSocialAuth | None = None, + *args: object, + **kwargs: object, +) -> dict[str, object] | None: """Load extra data from provider and store it on current UserSocialAuth extra_data field. """ @@ -43,3 +70,4 @@ def load_extra_data(backend, details, response, uid, user, social_user=None, *ar social_user.extra_data = extra_data social_user.save() return {"social_user": social_user} + return None diff --git a/src/social_auth/backends/pipeline/user.py b/src/social_auth/backends/pipeline/user.py index f8c9a5b1f4be..81ef3538274d 100644 --- a/src/social_auth/backends/pipeline/user.py +++ b/src/social_auth/backends/pipeline/user.py @@ -1,15 +1,27 @@ +from collections.abc import Callable, Mapping +from typing import Any, cast from uuid import uuid4 +from sentry.users.models.user import User +from social_auth.backends import SocialAuthBackend from social_auth.django_compat import get_all_field_names from social_auth.models import UserSocialAuth from social_auth.utils import module_member, setting -slugify = module_member(setting("SOCIAL_AUTH_SLUGIFY_FUNCTION", "django.utils.text.slugify")) +# justification: the callable is loaded from a user-configured import path. +slugify = cast( + "Callable[[str], str]", + module_member(setting("SOCIAL_AUTH_SLUGIFY_FUNCTION", "django.utils.text.slugify")), +) def get_username( - details, user=None, user_exists=UserSocialAuth.simple_user_exists, *args, **kwargs -): + details: Mapping[str, Any], + user: User | None = None, + user_exists: Callable[..., bool] = UserSocialAuth.simple_user_exists, + *args: object, + **kwargs: object, +) -> dict[str, object]: """Return an username for new user. Return current user username if user was given. """ @@ -45,7 +57,16 @@ def get_username( return {"username": final_username} -def create_user(backend, details, response, uid, username, user=None, *args, **kwargs): +def create_user( + backend: SocialAuthBackend, + details: Mapping[str, Any], + response: Mapping[str, Any], + uid: str, + username: str | None, + user: User | None = None, + *args: object, + **kwargs: object, +) -> dict[str, object] | None: """Create user. Depends on get_username pipeline.""" if user: return {"user": user} @@ -54,7 +75,7 @@ def create_user(backend, details, response, uid, username, user=None, *args, **k # Avoid hitting field max length email = details.get("email") - original_email = None + original_email: str | None = None if email and UserSocialAuth.email_max_length() < len(email): original_email = email email = "" @@ -66,7 +87,7 @@ def create_user(backend, details, response, uid, username, user=None, *args, **k } -def _ignore_field(name, is_new=False): +def _ignore_field(name: str, is_new: bool = False) -> bool: if name in ("username", "id", "pk"): return True @@ -75,16 +96,23 @@ def _ignore_field(name, is_new=False): return False -def django_orm_maxlength_truncate(backend, details, user=None, is_new=False, *args, **kwargs): +def django_orm_maxlength_truncate( + backend: SocialAuthBackend, + details: Mapping[str, Any], + user: User | None = None, + is_new: bool = False, + *args: object, + **kwargs: object, +) -> dict[str, dict[str, Any]] | None: """Truncate any value in details that corresponds with a field in the user model. Add this entry to the pipeline before update_user_details""" if user is None: - return - out = {} + return None + out: dict[str, Any] = {} names = get_all_field_names(user) for name, value in details.items(): if name in names and not _ignore_field(name, is_new): - max_length = user._meta.get_field(name).max_length + max_length = getattr(user._meta.get_field(name), "max_length", None) try: if max_length and len(value) > max_length: value = value[:max_length] @@ -94,7 +122,15 @@ def django_orm_maxlength_truncate(backend, details, user=None, is_new=False, *ar return {"details": out} -def update_user_details(backend, details, response, user=None, is_new=False, *args, **kwargs): +def update_user_details( + backend: SocialAuthBackend, + details: Mapping[str, Any], + response: Mapping[str, Any], + user: User | None = None, + is_new: bool = False, + *args: object, + **kwargs: object, +) -> None: """Update user details using data from provider.""" if user is None: return diff --git a/src/social_auth/backends/visualstudio.py b/src/social_auth/backends/visualstudio.py index 397f9c6562c7..56bcb3ba73b1 100644 --- a/src/social_auth/backends/visualstudio.py +++ b/src/social_auth/backends/visualstudio.py @@ -4,6 +4,9 @@ and put into sentry.conf.py """ +from collections.abc import Mapping +from typing import Any + import requests from django.conf import settings from django.urls import reverse @@ -25,7 +28,7 @@ class VisualStudioBackend(OAuthBackend): name = "visualstudio" EXTRA_DATA = [("id", "id"), ("refresh_token", "refresh_token")] - def get_user_details(self, response): + def get_user_details(self, response: Mapping[str, Any]) -> dict[str, Any]: """Return user details from Visual Studio account""" return { "email": response.get("email"), @@ -47,7 +50,7 @@ class VisualStudioAuth(BaseOAuth2): DEFAULT_SCOPE = settings.VISUALSTUDIO_SCOPES RESPONSE_TYPE = "Assertion" - def user_data(self, access_token, *args, **kwargs): + def user_data(self, access_token: str, *args: Any, **kwargs: Any) -> dict[str, Any]: """Loads user data from service""" resp = requests.get( VISUALSTUDIO_USER_DETAILS_URL, @@ -61,7 +64,7 @@ def user_data(self, access_token, *args, **kwargs): "full_name": content["displayName"], } - def auth_complete_params(self, state=None): + def auth_complete_params(self, state: str | None = None) -> dict[str, Any]: secret = setting(self.SETTINGS_CLIENT_SECRET_NAME) return { "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", @@ -72,7 +75,7 @@ def auth_complete_params(self, state=None): } @classmethod - def refresh_token_params(cls, token, provider): + def refresh_token_params(cls, token: str, provider: str) -> dict[str, str]: secret = setting(cls.SETTINGS_CLIENT_SECRET_NAME) return { diff --git a/src/social_auth/decorators.py b/src/social_auth/decorators.py index 7451425474cc..fc6c395b72d3 100644 --- a/src/social_auth/decorators.py +++ b/src/social_auth/decorators.py @@ -1,29 +1,36 @@ +from collections.abc import Callable from functools import wraps +from django.http import HttpRequest, HttpResponseBase from django.urls import reverse from social_auth.backends import get_backend from social_auth.exceptions import WrongBackend -def dsa_view(redirect_name=None): +def dsa_view( + redirect_name: str | None = None, +) -> Callable[[Callable[..., HttpResponseBase]], Callable[..., HttpResponseBase]]: """Decorate djangos-social-auth views. Will check and retrieve backend or return HttpResponseServerError if backend is not found. redirect_name parameter is used to build redirect URL used by backend. """ - def dec(func): + def dec(func: Callable[..., HttpResponseBase]) -> Callable[..., HttpResponseBase]: @wraps(func) - def wrapper(request, backend, *args, **kwargs): + def wrapper( + request: HttpRequest, backend: str, *args: object, **kwargs: object + ) -> HttpResponseBase: if redirect_name: redirect = reverse(redirect_name, args=(backend,)) else: redirect = request.path - request.social_auth_backend = get_backend(backend, request, redirect) - if request.social_auth_backend is None: + social_auth_backend = get_backend(backend, request, redirect) + setattr(request, "social_auth_backend", social_auth_backend) + if social_auth_backend is None: raise WrongBackend(backend) - return func(request, request.social_auth_backend, *args, **kwargs) + return func(request, social_auth_backend, *args, **kwargs) return wrapper diff --git a/src/social_auth/django_compat.py b/src/social_auth/django_compat.py index 3a4fb30a74f3..2350708d82d6 100644 --- a/src/social_auth/django_compat.py +++ b/src/social_auth/django_compat.py @@ -1,9 +1,11 @@ from itertools import chain +from django.db.models import Model + # https://docs.djangoproject.com/en/1.10/ref/models/meta/#migrating-from-the-old-api # https://github.com/python-social-auth/social-app-django/commit/65b68d5e47f6990625c19afe8317397bdbbb11cd -def get_all_field_names(model): +def get_all_field_names(model: Model | type[Model]) -> list[str]: return list( set( chain.from_iterable( diff --git a/src/social_auth/exceptions.py b/src/social_auth/exceptions.py index 63d8a72f8dc2..050d1982283d 100644 --- a/src/social_auth/exceptions.py +++ b/src/social_auth/exceptions.py @@ -11,7 +11,7 @@ def __str__(self) -> str: class WrongBackend(BackendError): - def __init__(self, backend_name): + def __init__(self, backend_name: str) -> None: self.backend_name = backend_name def __str__(self) -> str: @@ -30,7 +30,7 @@ def __str__(self) -> str: class AuthException(SocialAuthBaseException): """Auth process exception.""" - def __init__(self, backend, *args, **kwargs): + def __init__(self, backend: object, *args: object, **kwargs: object) -> None: self.backend = backend super().__init__(*args, **kwargs) @@ -71,7 +71,7 @@ def __str__(self) -> str: class AuthMissingParameter(AuthException): """Missing parameter needed to start or complete the process.""" - def __init__(self, backend, parameter, *args, **kwargs): + def __init__(self, backend: object, parameter: str, *args: object, **kwargs: object) -> None: self.parameter = parameter super().__init__(backend, *args, **kwargs) diff --git a/src/social_auth/fields.py b/src/social_auth/fields.py index 252a7262d775..49a114c3b00e 100644 --- a/src/social_auth/fields.py +++ b/src/social_auth/fields.py @@ -10,7 +10,7 @@ from sentry.utils import json -class JSONField(TextField): +class JSONField(TextField[object, object]): """Simple JSON field that stores python structures as JSON strings on database. """ @@ -23,7 +23,7 @@ def contribute_to_class(self, cls: type[Model], name: str, private_only: bool = super().contribute_to_class(cls, name, private_only=private_only) setattr(cls, name, Creator(self)) - def to_python(self, value): + def to_python(self, value: object) -> object: """ Convert the input JSON value into python structures, raises django.core.exceptions.ValidationError if the data can't be converted. @@ -38,7 +38,7 @@ def to_python(self, value): else: return value - def validate(self, value, model_instance): + def validate(self, value: object, model_instance: Model | None) -> None: """Check value is a valid JSON string, raise ValidationError on error.""" if isinstance(value, str): @@ -48,17 +48,17 @@ def validate(self, value, model_instance): except Exception as e: raise ValidationError(str(e)) - def get_prep_value(self, value): + def get_prep_value(self, value: object) -> str: """Convert value to JSON string before save""" try: return json.dumps(value) except Exception as e: raise ValidationError(str(e)) - def value_to_string(self, obj): + def value_to_string(self, obj: Model) -> str: """Return value from object converted to string properly""" return smart_str(self.value_from_object(obj)) - def value_from_object(self, obj): + def value_from_object(self, obj: Model) -> str: """Return value dumped to string.""" return self.get_prep_value(super().value_from_object(obj)) diff --git a/src/social_auth/models.py b/src/social_auth/models.py index 248574d55cd9..788577a736d0 100644 --- a/src/social_auth/models.py +++ b/src/social_auth/models.py @@ -1,12 +1,15 @@ from __future__ import annotations import re +from typing import Any, cast from django.apps import apps from django.conf import settings from django.db import models +from django.db.models import QuerySet from sentry.db.models import control_silo_model +from sentry.users.models.user import User from .utils import setting @@ -40,18 +43,20 @@ def __str__(self) -> str: """Return associated user unicode representation""" return f"{self.user} - {self.provider.title()}" - def get_backend(self): + def get_backend( + self, + ) -> Any: # justification: backend subclasses define provider-specific auth operations. from .utils import get_backend return get_backend(instance=self) @property - def tokens(self): + def tokens(self) -> dict[str, object]: from .utils import tokens return tokens(instance=self) - def revoke_token(self, drop_token=True): + def revoke_token(self, drop_token: bool = True) -> None: """Attempts to revoke permissions for provider.""" if "access_token" in self.tokens: success = self.get_backend().revoke_token(self.tokens["access_token"], self.uid) @@ -59,7 +64,7 @@ def revoke_token(self, drop_token=True): self.extra_data.pop("access_token", None) self.save() - def refresh_token(self): + def refresh_token(self) -> None: refresh_token = self.extra_data.get("refresh_token") if refresh_token: backend = self.get_backend() @@ -78,11 +83,11 @@ def refresh_token(self): self.save() @classmethod - def clean_username(cls, value): + def clean_username(cls, value: str) -> str: return CLEAN_USERNAME_REGEX.sub("", value) @classmethod - def user_username(cls, user): + def user_username(cls, user: object) -> object: if hasattr(user, "USERNAME_FIELD"): # Django 1.5 custom user model, 'username' is just for internal # use, doesn't imply that the model should have an username field @@ -92,7 +97,7 @@ def user_username(cls, user): return getattr(user, field_name) @classmethod - def username_field(cls, values): + def username_field(cls, values: dict[str, object]) -> dict[str, object]: user_model = cls.user_model() if hasattr(user_model, "USERNAME_FIELD"): # Django 1.5 custom user model, 'username' is just for internal @@ -101,7 +106,7 @@ def username_field(cls, values): return values @classmethod - def simple_user_exists(cls, *args, **kwargs): + def simple_user_exists(cls, *args: object, **kwargs: object) -> bool: """ Return True/False if a User instance exists with the given arguments. Arguments are directly passed to filter() manager method. @@ -111,42 +116,44 @@ def simple_user_exists(cls, *args, **kwargs): return cls.user_model().objects.filter(*args, **kwargs).exists() @classmethod - def create_user(cls, *args, **kwargs): - kwargs = cls.username_field(kwargs) + def create_user( + cls, *args: Any, **kwargs: Any + ) -> User: # justification: forwards dynamic pipeline fields to the user manager. + kwargs = cast("dict[str, Any]", cls.username_field(kwargs)) return cls.user_model().objects.create_user(*args, **kwargs) @classmethod - def get_user(cls, pk): + def get_user(cls, pk: str | int) -> User | None: try: return cls.user_model().objects.get(pk=pk) except cls.user_model().DoesNotExist: return None @classmethod - def get_user_by_email(cls, email): + def get_user_by_email(cls, email: str) -> User: """Case insensitive search""" # Do case-insensitive match, since real-world email address is # case-insensitive. return cls.user_model().objects.get(email__iexact=email) @classmethod - def resolve_user_or_id(cls, user_or_id): + def resolve_user_or_id(cls, user_or_id: object) -> User: if isinstance(user_or_id, cls.user_model()): return user_or_id - return cls.user_model().objects.get(pk=user_or_id) + return cls.user_model().objects.get(pk=cast("str | int", user_or_id)) @classmethod - def get_social_auth_for_user(cls, user): + def get_social_auth_for_user(cls, user: User) -> QuerySet[UserSocialAuth]: return user.social_auth.all() @classmethod - def create_social_auth(cls, user, uid, provider): + def create_social_auth(cls, user: User, uid: object, provider: str) -> UserSocialAuth: if not isinstance(uid, str): uid = str(uid) return cls.objects.create(user_id=user.id, uid=uid, provider=provider) @classmethod - def get_social_auth(cls, provider, uid, user): + def get_social_auth(cls, provider: str, uid: str, user: User) -> UserSocialAuth | None: try: instance = cls.objects.get(provider=provider, uid=uid, user_id=user.id) return instance @@ -154,20 +161,21 @@ def get_social_auth(cls, provider, uid, user): return None @classmethod - def username_max_length(cls): + def username_max_length(cls) -> int: return cls._field_length("USERNAME_FIELD", "username") @classmethod - def email_max_length(cls): + def email_max_length(cls) -> int: return cls._field_length("EMAIL_FIELD", "email") @classmethod - def _field_length(self, setting_name, default_name): + def _field_length(self, setting_name: str, default_name: str) -> int: model = UserSocialAuth.user_model() field_name = getattr(model, setting_name, default_name) - return model._meta.get_field(field_name).max_length + field = cast("models.Field[Any, Any]", model._meta.get_field(field_name)) + return cast(int, field.max_length) @classmethod - def user_model(cls): + def user_model(cls) -> type[User]: db, name = AUTH_USER_MODEL.split(".") - return apps.get_model(db, name) + return cast(type[User], apps.get_model(db, name)) diff --git a/src/social_auth/utils.py b/src/social_auth/utils.py index 9850b6f5c2a7..9efa3e831a70 100644 --- a/src/social_auth/utils.py +++ b/src/social_auth/utils.py @@ -1,8 +1,9 @@ from __future__ import annotations import logging +from collections.abc import Mapping, Sequence from importlib import import_module -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from urllib.parse import parse_qs as urlparse_parse_qs from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse from urllib.request import urlopen @@ -10,6 +11,7 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.db.models import Model +from django.http import HttpRequest from sentry.hybridcloud.rpc import RpcModel from sentry.users.services.user import RpcUser @@ -35,12 +37,14 @@ def tokens(instance: UserSocialAuth | RpcUserSocialAuth) -> dict[str, Any]: """Return access_token stored in extra_data or None""" backend = instance.get_backend() if backend: - return backend.AUTH_BACKEND.tokens(instance) + return backend.AUTH_BACKEND.tokens(cast("UserSocialAuth", instance)) else: return {} -def sanitize_log_data(secret, data=None, leave_characters=LEAVE_CHARS): +def sanitize_log_data( + secret: str, data: str | None = None, leave_characters: int = LEAVE_CHARS +) -> str: """ Clean private/secret data from log statements and other data. @@ -58,19 +62,19 @@ def sanitize_log_data(secret, data=None, leave_characters=LEAVE_CHARS): return replace_secret -def setting(name, default=None): +def setting(name: str, default: Any = None) -> Any: """Return setting value for given name or default value.""" return getattr(settings, name, default) -def backend_setting(backend, name, default=None): +def backend_setting(backend: object, name: str, default: Any = None) -> Any: """ Looks for setting value following these rules: 1. Search for prefixed setting 2. Search for setting given by name 3. Return default """ - backend_name = get_backend_name(backend) + backend_name = cast("str", get_backend_name(backend)) setting_name = "{}_{}".format(backend_name.upper().replace("-", "_"), name) if hasattr(settings, setting_name): return setting(setting_name) @@ -84,7 +88,7 @@ def backend_setting(backend, name, default=None): logger.setLevel(logging.DEBUG) -def log(level, *args, **kwargs): +def log(level: str, *args: Any, **kwargs: Any) -> None: """Small wrapper around logger functions.""" { "debug": logger.debug, @@ -94,7 +98,7 @@ def log(level, *args, **kwargs): }[level](*args, **kwargs) -def model_to_ctype(val): +def model_to_ctype(val: Any) -> Any: """Converts values that are instance of Model to a dictionary with enough information to retrieve the instance back later.""" if isinstance(val, Model): @@ -104,7 +108,7 @@ def model_to_ctype(val): return val -def ctype_to_model(val): +def ctype_to_model(val: Any) -> Any: """Converts back the instance saved by model_to_ctype function.""" if isinstance(val, dict) and "pk" in val and "ctype" in val: ctype = ContentType.objects.get_for_id(val["ctype"]) @@ -117,7 +121,7 @@ def ctype_to_model(val): return val -def clean_partial_pipeline(request): +def clean_partial_pipeline(request: HttpRequest) -> None: """Cleans any data for partial pipeline.""" name = setting("SOCIAL_AUTH_PARTIAL_PIPELINE_KEY", "partial_pipeline") # Check for key to avoid flagging the session as modified unnecessary @@ -125,7 +129,7 @@ def clean_partial_pipeline(request): request.session.pop(name, None) -def url_add_parameters(url, params): +def url_add_parameters(url: str, params: Mapping[str, Any] | None) -> str: """Adds parameters to URL, parameter will be repeated if already present""" if params: fragments = list(urlparse(url)) @@ -134,7 +138,7 @@ def url_add_parameters(url, params): return url -def dsa_urlopen(*args, **kwargs): +def dsa_urlopen(*args: Any, **kwargs: Any) -> Any: """Like urllib2.urlopen but sets a timeout defined by SOCIAL_AUTH_URLOPEN_TIMEOUT setting if defined (and not already in kwargs).""" @@ -144,22 +148,22 @@ def dsa_urlopen(*args, **kwargs): return urlopen(*args, **kwargs) -def get_backend_name(backend): +def get_backend_name(backend: object) -> str | None: return getattr(getattr(backend, "AUTH_BACKEND", backend), "name", None) -def module_member(name): +def module_member(name: str) -> Any: mod, member = name.rsplit(".", 1) module = import_module(mod) return getattr(module, member) -def parse_qs(value): +def parse_qs(value: str) -> dict[str, str]: """Like urlparse.parse_qs but transform list values to single items""" return drop_lists(urlparse_parse_qs(value)) -def drop_lists(value): +def drop_lists(value: Mapping[str, Sequence[str]]) -> dict[str, str]: return {key: val[0] for key, val in value.items()} diff --git a/src/social_auth/views.py b/src/social_auth/views.py index 8024e4137983..1586a4185682 100644 --- a/src/social_auth/views.py +++ b/src/social_auth/views.py @@ -9,12 +9,15 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.decorators import login_required -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.utils.http import url_has_allowed_host_and_scheme from django.views.decorators.csrf import csrf_exempt +from sentry.users.services.user import RpcUser from sentry.web.frontend.base import control_silo_view +from social_auth.backends import BaseAuth from social_auth.decorators import dsa_view from social_auth.exceptions import AuthException from social_auth.utils import backend_setting, clean_partial_pipeline, setting @@ -26,7 +29,7 @@ @control_silo_view @dsa_view(setting("SOCIAL_AUTH_COMPLETE_URL_NAME", "socialauth_associate_complete_auth_sso")) -def auth(request, backend): +def auth(request: HttpRequest, backend: BaseAuth) -> HttpResponse: """Authenticate using social backend""" data = request.POST if request.method == "POST" else request.GET @@ -59,7 +62,9 @@ def auth(request, backend): @csrf_exempt @login_required @dsa_view() -def complete(request, backend, *args, **kwargs): +def complete( + request: HttpRequest, backend: BaseAuth, *args: object, **kwargs: object +) -> HttpResponse: """Authentication complete process""" # pop redirect value before the session is trashed on login() redirect_value = request.session.get(REDIRECT_FIELD_NAME, "") @@ -92,7 +97,13 @@ def complete(request, backend, *args, **kwargs): @control_silo_view -def auth_complete(request, backend, user, *args, **kwargs): +def auth_complete( + request: HttpRequest, + backend: BaseAuth, + user: AbstractBaseUser | None, + *args: object, + **kwargs: object, +) -> AbstractBaseUser | RpcUser | HttpResponse | None: """Complete auth process. Return authenticated user or None.""" if request.session.get(PIPELINE_KEY): data = request.session.pop(PIPELINE_KEY) 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/acceptance/chartcuterie/test_chart_renderer.py b/tests/acceptance/chartcuterie/test_chart_renderer.py index cf38b0397ac9..ede8010f22b9 100644 --- a/tests/acceptance/chartcuterie/test_chart_renderer.py +++ b/tests/acceptance/chartcuterie/test_chart_renderer.py @@ -19,6 +19,7 @@ def test_debug_renders(self) -> None: for image in images: src = image.get_attribute("src") + assert src is not None resp = self.client.get(src) # Ensure our chart images actually look like pngs diff --git a/tests/acceptance/test_accept_organization_invite.py b/tests/acceptance/test_accept_organization_invite.py index ab44e9cf562c..065ecc072e79 100644 --- a/tests/acceptance/test_accept_organization_invite.py +++ b/tests/acceptance/test_accept_organization_invite.py @@ -32,7 +32,9 @@ def setUp(self) -> None: ) def open_invite(self) -> str: - invite_path = self.member.get_invite_link().split("/", 3)[-1] + invite_link = self.member.get_invite_link() + assert invite_link is not None + invite_path = invite_link.split("/", 3)[-1] self.browser.get(invite_path) self.browser.wait_until(xpath="//h1[normalize-space(.)='Accept Invitation']") return f"/{invite_path}" diff --git a/tests/acceptance/test_account_settings.py b/tests/acceptance/test_account_settings.py index 7acef5c58b96..ab746ec3700e 100644 --- a/tests/acceptance/test_account_settings.py +++ b/tests/acceptance/test_account_settings.py @@ -52,7 +52,7 @@ def test_navigate_account_settings(self) -> None: secondary_navigation = '[role="navigation"][aria-label="Secondary Navigation"]' links = self.browser.elements(f"{secondary_navigation} a[href]") assert len(links) == 10 - assert all("/settings/account/" in link.get_attribute("href") for link in links) + assert all("/settings/account/" in (link.get_attribute("href") or "") for link in links) for path, title in [ ("security", "Security"), @@ -88,7 +88,8 @@ def test_navigate_account_settings_on_mobile(self) -> None: self.options({"system.url-prefix": self.browser.live_server_url}), self.browser.full_viewport(width=375, height=812, fit_content=False), ): - self.browser.driver.execute_cdp_cmd( + # justification: Selenium's base WebDriver stub omits the CDP helper. + getattr(self.browser.driver, "execute_cdp_cmd")( "Emulation.setTouchEmulationEnabled", {"enabled": True} ) try: @@ -106,6 +107,7 @@ def test_navigate_account_settings_on_mobile(self) -> None: self.browser.wait_until('input[name="email"]') assert self.browser.driver.current_url.endswith("/settings/account/emails/") finally: - self.browser.driver.execute_cdp_cmd( + # justification: Selenium's base WebDriver stub omits the CDP helper. + getattr(self.browser.driver, "execute_cdp_cmd")( "Emulation.setTouchEmulationEnabled", {"enabled": False} ) diff --git a/tests/acceptance/test_emails.py b/tests/acceptance/test_emails.py index 8a07145ba5b9..69f8c35da3ef 100644 --- a/tests/acceptance/test_emails.py +++ b/tests/acceptance/test_emails.py @@ -90,6 +90,8 @@ def test_emails(self) -> None: self.browser.wait_until("#preview") elem = self.browser.find_element(by=By.CSS_SELECTOR, value="#preview pre") text_src = elem.get_attribute("innerHTML") + if text_src is None: + raise AssertionError("Email preview has no HTML") # Avoid relying on IDs as this can cause flakey tests text_src = redact_ips(redact_ids(replace_amp(text_src))) diff --git a/tests/acceptance/test_issue_details.py b/tests/acceptance/test_issue_details.py index 48a760e64724..caa25480ade2 100644 --- a/tests/acceptance/test_issue_details.py +++ b/tests/acceptance/test_issue_details.py @@ -52,6 +52,7 @@ def create_sample_event( event = self.store_event( data=event_data, project_id=self.project.id, assert_no_errors=False ) + assert event.group is not None event.group.update( first_seen=datetime(2015, 8, 13, 3, 8, 25, tzinfo=timezone.utc), last_seen=time ) diff --git a/tests/acceptance/test_issue_details_workflow.py b/tests/acceptance/test_issue_details_workflow.py index 2e900004335e..89c82201c609 100644 --- a/tests/acceptance/test_issue_details_workflow.py +++ b/tests/acceptance/test_issue_details_workflow.py @@ -38,6 +38,7 @@ def create_sample_event( project_id=self.project.id, assert_no_errors=False, ) + assert event.group is not None event.group.update( first_seen=datetime(2015, 8, 13, 3, 8, 25, tzinfo=timezone.utc), last_seen=datetime(2016, 1, 13, 3, 8, 25, tzinfo=timezone.utc), diff --git a/tests/acceptance/test_issue_tag_values.py b/tests/acceptance/test_issue_tag_values.py index 932678d958d3..5e64efc0ed22 100644 --- a/tests/acceptance/test_issue_tag_values.py +++ b/tests/acceptance/test_issue_tag_values.py @@ -30,10 +30,12 @@ def create_issue(self) -> Event: return self.store_event(data=event_data, project_id=self.project.id) def test_user_tag(self) -> None: + assert self.event.group_id is not None self.page.visit_tag_values(self.org.slug, self.event.group_id, "user") assert self.browser.element_exists_by_test_id("group-tag-value") def test_url_tag(self) -> None: + assert self.event.group_id is not None self.page.visit_tag_values(self.org.slug, self.event.group_id, "url") assert self.browser.element_exists_by_test_id("group-tag-url") diff --git a/tests/acceptance/test_organization_developer_settings.py b/tests/acceptance/test_organization_developer_settings.py index c6357fcd4cde..8b1c9307f53d 100644 --- a/tests/acceptance/test_organization_developer_settings.py +++ b/tests/acceptance/test_organization_developer_settings.py @@ -89,7 +89,9 @@ def test_edit_integration_schema(self) -> None: # WebElement.clear() doesn't fire input events, so React-controlled # textareas keep their old form state. Backspace each character so # React's onChange runs. - textarea.send_keys(Keys.BACKSPACE * len(textarea.get_attribute("value"))) + current_value = textarea.get_attribute("value") + assert current_value is not None + textarea.send_keys(Keys.BACKSPACE * len(current_value)) textarea.send_keys("{}") self.browser.click('[aria-label="Save Changes"]') diff --git a/tests/acceptance/test_organization_global_selection_header.py b/tests/acceptance/test_organization_global_selection_header.py index aa9e4a85756f..3e3050ed6793 100644 --- a/tests/acceptance/test_organization_global_selection_header.py +++ b/tests/acceptance/test_organization_global_selection_header.py @@ -302,6 +302,7 @@ def test_issue_details_to_stream_with_initial_env_no_project(self, mock_now: Mag mock_now.return_value = datetime.now(timezone.utc) self.create_issues() + assert self.issue_2.group is not None self.issue_details.visit_issue_in_environment(self.org.slug, self.issue_2.group.id, "prod") # Make sure issue's project is in URL and in header diff --git a/tests/acceptance/test_organization_group_index.py b/tests/acceptance/test_organization_group_index.py index e8ca24e2cc1a..cd2635f4e5b2 100644 --- a/tests/acceptance/test_organization_group_index.py +++ b/tests/acceptance/test_organization_group_index.py @@ -41,6 +41,7 @@ def create_issues(self) -> None: }, project_id=self.project.id, ) + assert self.event_a.group is not None add_group_to_inbox(self.event_a.group, GroupInboxReason.NEW) self.event_b = self.store_event( data={ @@ -51,6 +52,7 @@ def create_issues(self) -> None: }, project_id=self.project.id, ) + assert self.event_b.group is not None add_group_to_inbox(self.event_b.group, GroupInboxReason.NEW) def test_with_onboarding(self) -> None: @@ -80,6 +82,7 @@ def test_resolve_issues_removal(self, mock_now: MagicMock) -> None: mock_now.return_value = datetime.now(timezone.utc) self.create_issues() group1 = self.event_a.group + assert group1 is not None self.page.visit_issue_list(self.org.slug) self.page.wait_for_stream() @@ -100,6 +103,7 @@ def test_archive_issues(self, mock_now: MagicMock) -> None: self.create_issues() group1 = self.event_a.group + assert group1 is not None self.page.visit_issue_list(self.org.slug) self.page.wait_for_stream() @@ -120,6 +124,7 @@ def test_delete_issues(self, mock_now: MagicMock) -> None: self.create_issues() group1 = self.event_a.group + assert group1 is not None self.page.visit_issue_list(self.org.slug) self.page.wait_for_stream() @@ -140,7 +145,9 @@ def test_merge_issues(self, mock_now: MagicMock) -> None: self.create_issues() group1 = self.event_a.group + assert group1 is not None group2 = self.event_b.group + assert group2 is not None self.page.visit_issue_list(self.org.slug) self.page.wait_for_stream() diff --git a/tests/acceptance/test_organization_security_privacy.py b/tests/acceptance/test_organization_security_privacy.py index ca42a4e0348d..501c41b709c2 100644 --- a/tests/acceptance/test_organization_security_privacy.py +++ b/tests/acceptance/test_organization_security_privacy.py @@ -18,13 +18,13 @@ def load_organization_helper(self) -> None: '[data-test-id="organization-settings-security-and-privacy"]' ) - def renders_2fa_setting(self) -> bool: - return self.browser.wait_until("input[type='checkbox'][name='require2FA']") + def renders_2fa_setting(self) -> None: + self.browser.wait_until("input[type='checkbox'][name='require2FA']") def test_renders_2fa_setting_for_owner(self) -> None: self.browser.get(self.path) self.load_organization_helper() - assert self.renders_2fa_setting() + self.renders_2fa_setting() def test_renders_2fa_setting_for_manager(self) -> None: manager_user = self.create_user("manager@example.com") @@ -32,7 +32,7 @@ def test_renders_2fa_setting_for_manager(self) -> None: self.login_as(manager_user) self.browser.get(self.path) self.load_organization_helper() - assert self.renders_2fa_setting() + self.renders_2fa_setting() def test_setting_2fa_without_2fa_enabled(self) -> None: self.browser.get(self.path) diff --git a/tests/acceptance/test_performance_issues.py b/tests/acceptance/test_performance_issues.py index e2dd7f494ddd..ac5318831b4d 100644 --- a/tests/acceptance/test_performance_issues.py +++ b/tests/acceptance/test_performance_issues.py @@ -40,6 +40,8 @@ def setUp(self) -> None: def create_sample_event(self, fixture: str, start_timestamp: float) -> dict[str, Any]: event = json.loads(self.load_fixture(f"events/issue_detection/{fixture}.json")) + if not isinstance(event, dict): + raise TypeError("Issue detection fixture is not a JSON object") for key in ["datetime", "location", "title"]: del event[key] diff --git a/tests/acceptance/test_proxy.py b/tests/acceptance/test_proxy.py index a1ec042cf8cd..6fa3117ee5b4 100644 --- a/tests/acceptance/test_proxy.py +++ b/tests/acceptance/test_proxy.py @@ -13,7 +13,6 @@ from sentry.models.organization import Organization from sentry.models.team import Team from sentry.silo.base import SiloMode, SingleProcessSiloModeState -from sentry.testutils.asserts import assert_status_code from sentry.testutils.cases import TransactionTestCase from sentry.testutils.cell import override_cells from sentry.testutils.factories import Factories @@ -43,7 +42,10 @@ class EndToEndAPIProxyTest(TransactionTestCase): def get_response(self, *args: str, **params: Any) -> HttpResponse | StreamingHttpResponse: url = reverse(self.endpoint, args=args) headers = params.pop("extra_headers", {}) - return getattr(self.client, self.method)(url, format="json", data=params, **headers) + response: HttpResponse | StreamingHttpResponse = getattr(self.client, self.method)( + url, format="json", data=params, **headers + ) + return response # https://storage.googleapis.com/rca-analysis-sentry/rca-reports/ci-rca-02db8025.html @pytest.mark.skip(reason="flaky") @@ -71,7 +73,7 @@ def test_through_api_gateway(self) -> None: ), ) - assert_status_code(resp, 201) + assert resp.status_code == 201 result = json.loads(close_streaming_response(resp)) team = Team.objects.get(id=result["id"]) assert team.idp_provisioned diff --git a/tests/acceptance/test_replay_details.py b/tests/acceptance/test_replay_details.py index 5340ea246c76..c36f07c5dac3 100644 --- a/tests/acceptance/test_replay_details.py +++ b/tests/acceptance/test_replay_details.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +from typing import Any, cast from sentry.replays.testutils import ( mock_replay, @@ -63,7 +64,10 @@ def setUp(self) -> None: ), ] for segment_id, segment in enumerate(segments): - self.store_replay_segments(replay_id, self.project.id, segment_id, segment) + # justification: replay segments are rrweb event lists, not the mapping the helper annotates. + self.store_replay_segments( + replay_id, self.project.id, segment_id, cast("dict[str, Any]", segment) + ) def test_console_tab(self) -> None: with self.feature(FEATURE_NAME): diff --git a/tests/apidocs/endpoints/events/test_group_events.py b/tests/apidocs/endpoints/events/test_group_events.py index 6be33af08e02..0731ccb2ff79 100644 --- a/tests/apidocs/endpoints/events/test_group_events.py +++ b/tests/apidocs/endpoints/events/test_group_events.py @@ -22,6 +22,7 @@ def setUp(self) -> None: for timestamp in last_release.values(): event = self.create_event("c", release="1.0a", timestamp=timestamp.isoformat()) + assert event.group is not None self.group_id = event.group.id self.login_as(user=self.user) diff --git a/tests/apidocs/endpoints/events/test_group_issue_details.py b/tests/apidocs/endpoints/events/test_group_issue_details.py index 0216388f2e9a..739042cbe2fe 100644 --- a/tests/apidocs/endpoints/events/test_group_issue_details.py +++ b/tests/apidocs/endpoints/events/test_group_issue_details.py @@ -24,6 +24,7 @@ def setUp(self) -> None: for timestamp in last_release.values(): event = self.create_event("c", release="1.0a", timestamp=timestamp.isoformat()) + assert event.group is not None self.url = f"/api/0/organizations/{self.organization.slug}/issues/{event.group.id}/" self.login_as(user=self.user) diff --git a/tests/apidocs/endpoints/integration_platform/test_group_integration_details.py b/tests/apidocs/endpoints/integration_platform/test_group_integration_details.py index 5e6854c8051f..60b443f8a519 100644 --- a/tests/apidocs/endpoints/integration_platform/test_group_integration_details.py +++ b/tests/apidocs/endpoints/integration_platform/test_group_integration_details.py @@ -23,6 +23,7 @@ def setUp(self) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert self.event.group is not None self.group = self.event.group self.integration = self.create_integration( organization=self.organization, diff --git a/tests/apidocs/endpoints/releases/test_organization_sessions.py b/tests/apidocs/endpoints/releases/test_organization_sessions.py index ac0151389573..fc63c18be046 100644 --- a/tests/apidocs/endpoints/releases/test_organization_sessions.py +++ b/tests/apidocs/endpoints/releases/test_organization_sessions.py @@ -1,3 +1,5 @@ +from typing import Any + import pytest from django.test.client import RequestFactory from django.urls import reverse @@ -23,7 +25,7 @@ def setUp(self) -> None: self.login_as(user=self.user) def test_get(self) -> None: - query = { + query: dict[str, Any] = { "project": [self.project.id], "statsPeriod": "30d", "field": ["sum(session)"], diff --git a/tests/apidocs/test_hooks.py b/tests/apidocs/test_hooks.py index d38d44b278a2..de45498fad41 100644 --- a/tests/apidocs/test_hooks.py +++ b/tests/apidocs/test_hooks.py @@ -32,7 +32,7 @@ @pytest.mark.django_db def test_investigation_schema_selects_only_private_frontend_routes() -> None: with patched_settings({"PREPROCESSING_HOOKS": []}): - endpoints = CustomEndpointEnumerator().get_api_endpoints() + endpoints = CustomEndpointEnumerator().collect_endpoints() selected = investigation_endpoints(endpoints) assert selected assert all( diff --git a/tests/apigw/test_proxy.py b/tests/apigw/test_proxy.py index b5487e9c8068..447b3c66189a 100644 --- a/tests/apigw/test_proxy.py +++ b/tests/apigw/test_proxy.py @@ -1,3 +1,5 @@ +from typing import Any, Callable, cast + import punkreq import pytest from emmett55 import current @@ -41,7 +43,8 @@ def test_proxied_url() -> None: ) try: - current.response = Response(None) + # justification: emmett_core does not annotate RSGI Response.__init__. + current.response = cast("Callable[[Any], Response]", Response)(None) with pytest.raises(HTTPResponse): build_proxied_url("http://test-host.internal:9000", "//1.2.3.4:1234") @@ -112,7 +115,8 @@ def test_adapt_response_headers_and_cookies() -> None: # bind a real emmett response to the current context, as the app would try: - current.response = Response(None) + # justification: emmett_core does not annotate RSGI Response.__init__. + current.response = cast("Callable[[Any], Response]", Response)(None) # the returned coroutine streams the body to the protocol; the # response adaptation we validate here already happened diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 19fa65fa0e14..2fca0ef3d2eb 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -123,7 +123,7 @@ def test_sso_redirect_url_external_removed(self) -> None: assert resp.json()["detail"]["extra"]["loginUrl"] == "/auth/login/foo/" - def _test_paths_with_status(self, status): + def _test_paths_with_status(self, status: int) -> None: for path in self.paths: resp = self.client.get(path) assert resp.status_code == status, (resp.status_code, resp.content) diff --git a/tests/integration/test_service.py b/tests/integration/test_service.py index 5216b1bcfbd1..1a26deac90b6 100644 --- a/tests/integration/test_service.py +++ b/tests/integration/test_service.py @@ -16,7 +16,7 @@ class IntegrationServiceTest(TestCase): jwt = "my_cool_jwt" - def generate_integration(self, metadata: dict[str, Any] | None = None): + def generate_integration(self, metadata: dict[str, Any] | None = None) -> Integration: integration = self.create_integration( organization=self.organization, provider="github", @@ -32,7 +32,7 @@ def generate_integration(self, metadata: dict[str, Any] | None = None): @responses.activate @mock.patch("sentry.integrations.github.client.get_jwt", return_value=jwt) - def test_refresh_expired_token(self, mock_jwt): + def test_refresh_expired_token(self, mock_jwt: mock.MagicMock) -> None: integration = self.generate_integration( metadata={ "access_token": "token_1", @@ -75,7 +75,7 @@ def test_refresh_expired_token(self, mock_jwt): @responses.activate @mock.patch("sentry.integrations.github.client.get_jwt", return_value=jwt) - def test_refresh_token_within_grace_period(self, mock_jwt): + def test_refresh_token_within_grace_period(self, mock_jwt: mock.MagicMock) -> None: integration = self.generate_integration( metadata={ "access_token": "token_1", @@ -114,7 +114,7 @@ def test_refresh_token_within_grace_period(self, mock_jwt): @responses.activate @mock.patch("sentry.integrations.github.client.get_jwt", return_value=jwt) - def test_no_refresh_token_outside_grace_period(self, mock_jwt): + def test_no_refresh_token_outside_grace_period(self, mock_jwt: mock.MagicMock) -> None: integration = self.generate_integration( metadata={ "access_token": "token_valid", @@ -138,7 +138,7 @@ def test_no_refresh_token_outside_grace_period(self, mock_jwt): @responses.activate @mock.patch("sentry.integrations.github.client.get_jwt", return_value=jwt) - def test_refresh_token_missing_expiration_time(self, mock_jwt): + def test_refresh_token_missing_expiration_time(self, mock_jwt: mock.MagicMock) -> None: integration = self.generate_integration( metadata={ "access_token": "token_no_expiry", @@ -176,7 +176,7 @@ def test_refresh_token_missing_expiration_time(self, mock_jwt): "pull_requests": "read", } - def test_missing_integration(self): + def test_missing_integration(self) -> None: rpc_integration = integration_service.refresh_github_access_token( integration_id=12345, organization_id=self.organization.id, @@ -184,7 +184,7 @@ def test_missing_integration(self): assert rpc_integration is None - def test_disabled_integration(self): + def test_disabled_integration(self) -> None: integration = self.generate_integration() with assume_test_silo_mode_of(Integration): integration.status = ObjectStatus.DISABLED @@ -197,7 +197,7 @@ def test_disabled_integration(self): assert rpc_integration is None - def test_missing_installation(self): + def test_missing_installation(self) -> None: # Generate a new integration with an installation on a different org. integration = self.create_integration( organization=self.create_organization(owner=self.create_user()), @@ -214,7 +214,9 @@ def test_missing_installation(self): @responses.activate @mock.patch("sentry.integrations.github.client.get_jwt", return_value=jwt) - def test_refresh_permissions_reads_without_minting_a_token(self, mock_jwt): + def test_refresh_permissions_reads_without_minting_a_token( + self, mock_jwt: mock.MagicMock + ) -> None: """The point of this call is the permissions, not the token. GitHub reports an installation's current permissions on the @@ -249,7 +251,7 @@ def test_refresh_permissions_reads_without_minting_a_token(self, mock_jwt): assert len(responses.calls) == 1 assert responses.calls[0].request.headers["Authorization"] == f"Bearer {self.jwt}" - def test_refresh_permissions_for_an_install_on_another_organization(self): + def test_refresh_permissions_for_an_install_on_another_organization(self) -> None: """An integration id alone is not enough to reach an installation. The org has to actually have it installed, or a caller anywhere in the @@ -269,7 +271,7 @@ def test_refresh_permissions_for_an_install_on_another_organization(self): is None ) - def test_refresh_permissions_for_an_unknown_integration(self): + def test_refresh_permissions_for_an_unknown_integration(self) -> None: integration = self.generate_integration() assert ( @@ -280,7 +282,7 @@ def test_refresh_permissions_for_an_unknown_integration(self): is None ) - def test_refresh_permissions_for_a_disabled_integration(self): + def test_refresh_permissions_for_a_disabled_integration(self) -> None: integration = self.generate_integration() with assume_test_silo_mode_of(Integration): integration.status = ObjectStatus.DISABLED diff --git a/tests/relay_integration/lang/java/test_plugin.py b/tests/relay_integration/lang/java/test_plugin.py index 4531486099e2..368a4e5abd34 100644 --- a/tests/relay_integration/lang/java/test_plugin.py +++ b/tests/relay_integration/lang/java/test_plugin.py @@ -1,10 +1,14 @@ import zipfile +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager from io import BytesIO +from typing import Any from uuid import uuid4 import pytest from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse +from pytest_django.live_server_helper import LiveServer from sentry.models.debugfile import ProjectDebugFile from sentry.models.files.file import File @@ -424,12 +428,16 @@ class AnotherInnerClass { @debug_files_test_both_backends class BasicResolvingIntegrationTest(RelayStoreHelper, TransactionTestCase): @pytest.fixture(autouse=True) - def initialize(self, set_sentry_option, live_server): + def initialize( + self, + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], + live_server: LiveServer, + ) -> Iterator[None]: with set_sentry_option("system.url-prefix", live_server.url): # Run test case yield - def upload_proguard_mapping(self, uuid, mapping_file_content): + def upload_proguard_mapping(self, uuid: str, mapping_file_content: bytes) -> None: url = reverse( "sentry-api-0-dsym-files", kwargs={ @@ -1082,7 +1090,7 @@ def test_error_on_resolving(self) -> None: assert error["mapping_uuid"] == "071207ac-b491-4a74-957c-2c94fd9594f2" assert error["type"] == "proguard_missing_lineno" - def upload_jvm_bundle(self, debug_id, source_files): + def upload_jvm_bundle(self, debug_id: str, source_files: dict[str, bytes]) -> None: files = {} for source_file in source_files: @@ -1602,7 +1610,7 @@ def test_source_lookup_with_proguard(self) -> None: @pytest.mark.skip(reason="flaky: #93949") @requires_symbolicator def test_invalid_exception(self) -> None: - event_data = { + event_data: dict[str, Any] = { "user": {"ip_address": "31.172.207.97"}, "extra": {}, "project": self.project.id, diff --git a/tests/relay_integration/lang/javascript/test_example.py b/tests/relay_integration/lang/javascript/test_example.py index 51bd1e3a565c..27ba3cc452a3 100644 --- a/tests/relay_integration/lang/javascript/test_example.py +++ b/tests/relay_integration/lang/javascript/test_example.py @@ -1,8 +1,13 @@ import os +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager import pytest +from pytest_django.live_server_helper import LiveServer from sentry.models.files.file import File +from sentry.models.project import Project +from sentry.models.projectkey import ProjectKey from sentry.models.release import Release from sentry.models.releasefile import ReleaseFile from sentry.testutils.helpers.datetime import before_now @@ -23,11 +28,11 @@ pytestmark = [requires_symbolicator, requires_kafka] -def get_fixture_path(name): +def get_fixture_path(name: str) -> str: return os.path.join(os.path.dirname(__file__), "fixtures/example", name) -def load_fixture(name): +def load_fixture(name: str) -> str: with open(get_fixture_path(name)) as f: return f.read() @@ -36,8 +41,13 @@ def load_fixture(name): class TestExample(RelayStoreHelper): @pytest.fixture(autouse=True) def initialize( - self, default_projectkey, default_project, request, set_sentry_option, live_server - ): + self, + default_projectkey: ProjectKey, + default_project: Project, + request: pytest.FixtureRequest, + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], + live_server: LiveServer, + ) -> Iterator[None]: self.project = default_project self.projectkey = default_projectkey self.project.update_option("sentry:scrape_javascript", False) diff --git a/tests/relay_integration/lang/javascript/test_plugin.py b/tests/relay_integration/lang/javascript/test_plugin.py index 1ddf6c1e61d8..c58004d3baa7 100644 --- a/tests/relay_integration/lang/javascript/test_plugin.py +++ b/tests/relay_integration/lang/javascript/test_plugin.py @@ -1,11 +1,15 @@ import os.path import zipfile from base64 import b64encode +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager from io import BytesIO +from typing import Any from uuid import uuid4 import pytest import responses +from pytest_django.live_server_helper import LiveServer from sentry.models.artifactbundle import ( ArtifactBundle, @@ -15,6 +19,8 @@ SourceFileType, ) from sentry.models.files.file import File +from sentry.models.project import Project +from sentry.models.projectkey import ProjectKey from sentry.models.release import Release from sentry.models.releasefile import ReleaseFile, update_artifact_index from sentry.testutils.helpers.datetime import before_now @@ -49,11 +55,11 @@ INVALID_BASE64_SOURCEMAP = "data:application/json;base64,A" -def get_fixture_path(name): +def get_fixture_path(name: str) -> str: return os.path.join(os.path.dirname(__file__), "fixtures", name) -def load_fixture(name): +def load_fixture(name: str) -> bytes: with open(get_fixture_path(name), "rb") as fp: return fp.read() @@ -63,7 +69,13 @@ def load_fixture(name): @thread_leak_allowlist(reason="sentry sdk background worker", issue=97042) class TestJavascriptIntegration(RelayStoreHelper): @pytest.fixture(autouse=True) - def initialize(self, default_projectkey, default_project, set_sentry_option, live_server): + def initialize( + self, + default_projectkey: ProjectKey, + default_project: Project, + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], + live_server: LiveServer, + ) -> Iterator[None]: self.project = default_project self.projectkey = default_projectkey self.organization = self.project.organization @@ -272,7 +284,7 @@ def test_nonhandled_frames_inapp_normalization(self) -> None: assert raw_frame_list[1].in_app assert raw_frame_list[2].in_app - def _create_source_files_and_sourcemaps(self, release): + def _create_source_files_and_sourcemaps(self, release: Release) -> None: for file in ["file.min.js", "file1.js", "file2.js", "file.sourcemap.js"]: with open(get_fixture_path(file), "rb") as f: f1 = File.objects.create( @@ -1075,12 +1087,12 @@ def test_expansion_via_distribution_release_artifacts(self) -> None: "\t\treturn multiply(add(a, b), a, b) / c;", ] - def _test_expansion_via_release_archive(self, link_sourcemaps: bool): + def _test_expansion_via_release_archive(self, link_sourcemaps: bool) -> None: project = self.project release = Release.objects.create(organization_id=project.organization_id, version="abc") release.add_project(project) - manifest = { + manifest: dict[str, Any] = { "org": self.organization.slug, "release": release.version, "files": { @@ -1283,7 +1295,7 @@ def test_node_processing(self) -> None: assert len(frame_list) == 6 - def assert_abs_path(abs_path): + def assert_abs_path(abs_path: str) -> None: # This makes the test assertion forward compatible with percent-encoded URLs # See https://github.com/getsentry/symbolicator/pull/1137 assert abs_path in ( @@ -1913,7 +1925,7 @@ def test_expansion_with_debug_id_not_found(self) -> None: release = Release.objects.create(organization_id=project.organization_id, version="abc") release.add_project(project) - manifest = { + manifest: dict[str, Any] = { "org": self.organization.slug, "release": release.version, "files": { @@ -2445,7 +2457,9 @@ def test_expansion_with_release_and_malformed_sourcemap(self) -> None: "url": "http://example.com/file.malformed.sourcemap.js", } - def generate_symbolicated_in_app_event_data(self, frames): + def generate_symbolicated_in_app_event_data( + self, frames: list[dict[str, Any]] + ) -> dict[str, Any]: return { "timestamp": self.min_ago, "message": "hello", diff --git a/tests/relay_integration/test_integration.py b/tests/relay_integration/test_integration.py index 1c19f5e89ac2..1821ccb82463 100644 --- a/tests/relay_integration/test_integration.py +++ b/tests/relay_integration/test_integration.py @@ -19,7 +19,7 @@ pytestmark = [requires_kafka] -def create_trusted_relay_signature(secret_key: SecretKey) -> str | bytes: +def create_trusted_relay_signature(secret_key: SecretKey) -> str: """ Mimics how the trusted relay signature is built so we can test communication. """ @@ -208,7 +208,7 @@ def test_transaction(self) -> None: # cases, which *did* have their silo mode changed. def test_project_config_compression(self) -> None: # Populate redis cache with compressed config: - invalidate_project_config(public_key=self.projectkey, trigger="test") + invalidate_project_config(public_key=self.projectkey.public_key, trigger="test") # Disable project config endpoint, to make sure Relay gets its data # from redis: diff --git a/tests/relay_integration/test_message_filters.py b/tests/relay_integration/test_message_filters.py index 102c4fec6d85..22756e1e94a8 100644 --- a/tests/relay_integration/test_message_filters.py +++ b/tests/relay_integration/test_message_filters.py @@ -1,5 +1,8 @@ +from typing import Any + from sentry.ingest.inbound_filters import ( _browser_extensions_filter, + _FilterSpec, _legacy_browsers_filter, _localhost_filter, _web_crawlers_filter, @@ -14,10 +17,10 @@ class FilterTests(RelayStoreHelper, TransactionTestCase): - def _get_message(self): + def _get_message(self) -> dict[str, Any]: return {} - def _set_filter_state(self, flt, state): + def _set_filter_state(self, flt: _FilterSpec, state: str) -> None: ProjectOption.objects.set_value(project=self.project, key=f"filters:{flt.id}", value=state) def test_should_not_filter_simple_messages(self) -> None: @@ -25,7 +28,7 @@ def test_should_not_filter_simple_messages(self) -> None: message = self._get_message() self.post_and_retrieve_event(message) - def _get_message_with_bad_ip(self): + def _get_message_with_bad_ip(self) -> Any: message = self._get_message() set_path(message, "user", "ip_address", value="127.0.0.1") return message @@ -41,7 +44,7 @@ def test_should_not_filter_bad_ip_addresses_when_disabled(self) -> None: message = self._get_message_with_bad_ip() self.post_and_retrieve_event(message) - def _get_message_with_bad_extension(self): + def _get_message_with_bad_extension(self) -> Any: message = self._get_message() set_path(message, "platform", value="javascript") set_path( @@ -62,7 +65,7 @@ def test_should_not_filter_browser_extensions_when_disabled(self) -> None: message = self._get_message_with_bad_extension() self.post_and_retrieve_event(message) - def _get_message_from_webcrawler(self): + def _get_message_from_webcrawler(self) -> Any: message = self._get_message() set_path( message, @@ -86,7 +89,7 @@ def test_should_not_filter_web_crawlers_when_disabled(self) -> None: message = self._get_message_from_webcrawler() self.post_and_retrieve_event(message) - def _get_message_from_legacy_browser(self): + def _get_message_from_legacy_browser(self) -> Any: ie_5_user_agent = ( "Mozilla/4.0 (compatible; MSIE 5.50; Windows NT; SiteKiosk 4.9; SiteCoach 1.0)" ) diff --git a/tests/relay_integration/test_sdk.py b/tests/relay_integration/test_sdk.py index e8b369c0b971..153d6e41e723 100644 --- a/tests/relay_integration/test_sdk.py +++ b/tests/relay_integration/test_sdk.py @@ -1,4 +1,6 @@ import uuid +from collections.abc import Callable, Iterator +from typing import Any from unittest import mock import pytest @@ -6,6 +8,7 @@ from django.test.utils import override_settings from sentry_sdk import Scope +from sentry.models.organization import Organization from sentry.models.project import Project from sentry.receivers import create_default_projects from sentry.services import eventstore @@ -21,14 +24,16 @@ @pytest.fixture -def scope(): +def scope() -> Iterator[Scope]: with sentry_sdk.isolation_scope() as scope: create_default_projects() yield scope @pytest.fixture -def post_event_with_sdk(settings, scope: Scope, relay_server, wait_for_ingest_consumer): +def post_event_with_sdk( + settings: Any, scope: Scope, relay_server: Any, wait_for_ingest_consumer: Callable[..., Any] +) -> Iterator[Callable[..., Any]]: with override_settings(SENTRY_ENDPOINT=relay_server["url"], SENTRY_PROJECT=1): internal_project_key = get_project_key() assert internal_project_key is not None @@ -39,7 +44,7 @@ def post_event_with_sdk(settings, scope: Scope, relay_server, wait_for_ingest_co wait_for_ingest_consumer = wait_for_ingest_consumer(settings) - def inner(*args, **kwargs): + def inner(*args: Any, **kwargs: Any) -> Any: kwargs.setdefault("scope", scope) event_id = client.capture_event(*args, **kwargs) assert event_id is not None @@ -56,7 +61,7 @@ def inner(*args, **kwargs): @no_silo_test @override_settings(SENTRY_PROJECT=1) @django_db_all -def test_simple(settings, post_event_with_sdk) -> None: +def test_simple(settings: Any, post_event_with_sdk: Callable[..., Any]) -> None: event = post_event_with_sdk({"message": "internal client test"}) assert event @@ -67,7 +72,7 @@ def test_simple(settings, post_event_with_sdk) -> None: @no_silo_test @override_settings(SENTRY_PROJECT=1, SENTRY_INGEST_CONSUMER_APM_SAMPLING=1.0) @django_db_all -def test_recursion_breaker(post_event_with_sdk) -> None: +def test_recursion_breaker(post_event_with_sdk: Callable[..., Any]) -> None: # If this test terminates at all then we avoided recursion. event_id = uuid.uuid4().hex with mock.patch( @@ -83,7 +88,7 @@ def test_recursion_breaker(post_event_with_sdk) -> None: @no_silo_test @django_db_all @override_settings(SENTRY_PROJECT=1) -def test_encoding(settings, post_event_with_sdk, scope: Scope) -> None: +def test_encoding(settings: Any, post_event_with_sdk: Callable[..., Any], scope: Scope) -> None: class NotJSONSerializable: pass @@ -98,7 +103,7 @@ class NotJSONSerializable: @no_silo_test @override_settings(SENTRY_PROJECT=1) @django_db_all -def test_bind_organization_context(default_organization, scope: Scope) -> None: +def test_bind_organization_context(default_organization: Organization, scope: Scope) -> None: bind_organization_context(default_organization) assert scope._tags["organization"] == default_organization.id @@ -112,8 +117,10 @@ def test_bind_organization_context(default_organization, scope: Scope) -> None: @no_silo_test @override_settings(SENTRY_PROJECT=1) @django_db_all -def test_bind_organization_context_with_callback(default_organization, scope: Scope) -> None: - def add_context(scope, organization, **kwargs): +def test_bind_organization_context_with_callback( + default_organization: Organization, scope: Scope +) -> None: + def add_context(scope: Any, organization: Organization, **kwargs: Any) -> None: scope.set_tag("organization.test", "1") with override_settings(SENTRY_ORGANIZATION_CONTEXT_HELPER=add_context): @@ -125,8 +132,10 @@ def add_context(scope, organization, **kwargs): @no_silo_test @override_settings(SENTRY_PROJECT=1) @django_db_all -def test_bind_organization_context_with_callback_error(default_organization, scope: Scope) -> None: - def add_context(scope, organization, **kwargs): +def test_bind_organization_context_with_callback_error( + default_organization: Organization, scope: Scope +) -> None: + def add_context(scope: Any, organization: Organization, **kwargs: Any) -> None: 1 / 0 with override_settings(SENTRY_ORGANIZATION_CONTEXT_HELPER=add_context): diff --git a/tests/sentry/analytics/test_base.py b/tests/sentry/analytics/test_base.py index 7d4cd20525fa..e86f5fea4b88 100644 --- a/tests/sentry/analytics/test_base.py +++ b/tests/sentry/analytics/test_base.py @@ -1,3 +1,5 @@ +from typing import Any + from sentry.analytics import Analytics from sentry.analytics.event import EventEnvelope from sentry.analytics.events.organization_created import OrganizationCreatedEvent @@ -5,12 +7,13 @@ class DummyAnalytics(Analytics): - def __init__(self): - self.events = [] - self.event_envelopes = [] + def __init__(self) -> None: + # justification: subclasses declare the event attributes under test. + self.events: list[Any] = [] + self.event_envelopes: list[EventEnvelope] = [] super().__init__() - def record_event_envelope(self, envelope: EventEnvelope): + def record_event_envelope(self, envelope: EventEnvelope) -> None: self.event_envelopes.append(envelope) self.events.append(envelope.event) diff --git a/tests/sentry/analytics/test_event.py b/tests/sentry/analytics/test_event.py index f3bef17de07b..5e658f7397ab 100644 --- a/tests/sentry/analytics/test_event.py +++ b/tests/sentry/analytics/test_event.py @@ -18,7 +18,7 @@ class DummyType: @eventclass("example") class ExampleEvent(Event): id: int - map: dict | DummyType + map: dict[str, object] | DummyType optional: bool | None = None diff --git a/tests/sentry/api/bases/test_organization.py b/tests/sentry/api/bases/test_organization.py index d0f481ca1cc9..4b12dc6b2322 100644 --- a/tests/sentry/api/bases/test_organization.py +++ b/tests/sentry/api/bases/test_organization.py @@ -1,11 +1,14 @@ -from datetime import timedelta +from collections.abc import Sequence +from datetime import datetime, timedelta from functools import cached_property +from typing import cast from unittest import mock import pytest from django.contrib.auth.models import AnonymousUser from django.contrib.sessions.backends.base import SessionBase from django.db.models import F +from django.http import HttpRequest from django.test import RequestFactory, override_settings from django.utils import timezone from rest_framework.exceptions import PermissionDenied @@ -15,6 +18,8 @@ from sentry.api.authentication import ViewerContextAuthentication from sentry.api.bases.organization import ( ControlSiloOrganizationEndpoint, + FilterParams, + FilterParamsDateNotNull, NoProjects, OrganizationAndStaffPermission, OrganizationEndpoint, @@ -38,9 +43,15 @@ from sentry.models.apikey import ApiKey from sentry.models.authidentity import AuthIdentity from sentry.models.authprovider import AuthProvider +from sentry.models.environment import Environment from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember +from sentry.models.project import Project from sentry.organizations.services.organization import organization_service +from sentry.organizations.services.organization.model import ( + RpcOrganization, + RpcUserOrganizationContext, +) from sentry.service_accounts.auth import SERVICE_ACCOUNT_TOKEN_KIND from sentry.service_accounts.principal import ServiceAccountPrincipal from sentry.silo.base import SiloMode @@ -48,6 +59,8 @@ from sentry.testutils.helpers.datetime import freeze_time from sentry.testutils.requests import drf_request_from_request from sentry.testutils.silo import assume_test_silo_mode +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.users.services.user.serial import serialize_rpc_user from sentry.users.services.user.service import user_service from sentry.utils.security.orgauthtoken_token import hash_token @@ -81,12 +94,12 @@ def setUp(self) -> None: def has_object_perm( self, - method, - obj, - auth=None, - user=None, - is_superuser=None, - is_staff=None, + method: str, + obj: Organization | RpcOrganization | RpcUserOrganizationContext, + auth: object = None, + user: User | RpcUser | None = None, + is_superuser: bool | None = None, + is_staff: bool | None = None, ) -> bool: result_with_org_rpc = None result_with_org_context_rpc = None @@ -120,9 +133,9 @@ def has_object_perm( class OrganizationPermissionTest(PermissionBaseTestCase): VIEWER_CONTEXT_SHARED_SECRET = "test-seer-api-shared-secret" - def org_require_2fa(self): + def org_require_2fa(self) -> None: self.org.update(flags=F("flags").bitor(Organization.flags.require_2fa)) - assert self.org.flags.require_2fa.is_set is True + assert self.org.flags.require_2fa def test_regular_user(self) -> None: user = self.create_user() @@ -397,7 +410,7 @@ def test_viewer_context_auth_bypasses_sso_gate(self) -> None: permission = self.permission_cls() permission.determine_access(request=drf_request, organization=self.org) - def _make_superuser_request(self, user): + def _make_superuser_request(self, user: User) -> HttpRequest: """Set up a superuser request, bypassing access form validation.""" with mock.patch.object(Superuser, "_needs_validation", return_value=False): request = self.make_request(user=user, is_superuser=True) @@ -504,39 +517,41 @@ def test_staff_passes_2FA(self) -> None: class BaseOrganizationEndpointTest(TestCase): @cached_property - def endpoint(self): + def endpoint(self) -> OrganizationEndpoint: return OrganizationEndpoint() @cached_property - def user(self): + def user(self) -> User: return self.create_user("tester@test.com") @cached_property - def member(self): + def member(self) -> User: return self.create_user("member@test.com") @cached_property - def owner(self): + def owner(self) -> User: return self.create_user("owner@test.com") @cached_property - def org(self): + def org(self) -> Organization: org = self.create_organization("test", self.owner) org.flags.allow_joinleave = False org.save() return org - def build_request(self, user=None, active_superuser=False, **params): - request = RequestFactory().get("/", params) + def build_request(self, **params: object) -> Request: + user = cast("User | None", params.pop("user", None)) + active_superuser = cast(bool, params.pop("active_superuser", False)) + request = RequestFactory().get("/", cast("dict[str, str]", params)) request.session = SessionBase() if active_superuser: - request.superuser = MockSuperUser() + setattr(request, "superuser", MockSuperUser()) if user is None: user = self.user request.user = user request.auth = None - request.access = from_request(drf_request_from_request(request), self.org) - return request + setattr(request, "access", from_request(drf_request_from_request(request), self.org)) + return cast(Request, request) class ControlSiloOrganizationEndpointTest(TestCase): @@ -545,7 +560,7 @@ def setUp(self) -> None: self.user = self.create_user() self.organization = self.create_organization(owner=self.user) - def build_request(self): + def build_request(self) -> Request: request = RequestFactory().get("/") request.session = SessionBase() request.user = self.user @@ -641,13 +656,13 @@ def setUp(self) -> None: def run_test( self, - expected_projects, - user=None, - project_ids=None, - include_all_accessible=False, - active_superuser=False, - ): - request_args = {} + expected_projects: Sequence[Project], + user: User | None = None, + project_ids: Sequence[int] | None = None, + include_all_accessible: bool = False, + active_superuser: bool = False, + ) -> None: + request_args: dict[str, object] = {} if project_ids: request_args["project"] = project_ids @@ -723,9 +738,9 @@ def test_ids_teams(self) -> None: ) def test_none_user(self) -> None: - request = RequestFactory().get("/") + request = cast(Request, RequestFactory().get("/")) request.session = SessionBase() - request.access = NoAccess() + setattr(request, "access", NoAccess()) request.auth = None result = self.endpoint.get_projects(request, self.org) assert [] == result @@ -735,7 +750,7 @@ def test_none_user(self) -> None: assert [] == result def test_all_accessible_sigil_value_no_open_join(self) -> None: - assert self.org.flags.allow_joinleave.number == 0, "precondition not met" + assert getattr(self.org.flags.allow_joinleave, "number") == 0, "precondition not met" self.create_team_membership(user=self.user, team=self.team_1) self.run_test([self.project_1], project_ids=[-1]) @@ -751,13 +766,15 @@ def test_all_accessible_sigil_value_allow_joinleave(self) -> None: @mock.patch( "sentry.api.bases.organization.OrganizationEndpoint._filter_projects_by_permissions" ) - def test_get_projects_no_slug_fallsback_to_ids(self, mock__filter_projects_by_permissions): + def test_get_projects_no_slug_fallsback_to_ids( + self, mock__filter_projects_by_permissions: mock.MagicMock + ) -> None: request = self.build_request(projectSlug=[""], project=[str(self.project_1.id)]) def side_effect( - projects, - **kwargs, - ): + projects: Sequence[Project], + **kwargs: object, + ) -> Sequence[Project]: return projects mock__filter_projects_by_permissions.side_effect = side_effect @@ -785,9 +802,9 @@ def test_get_projects_by_slugs( request = self.build_request(projectSlug=project_slugs) def side_effect( - projects, - **kwargs, - ): + projects: Sequence[Project], + **kwargs: object, + ) -> Sequence[Project]: return projects mock__filter_projects_by_permissions.side_effect = side_effect @@ -814,9 +831,9 @@ def test_get_projects_by_slugs_all( request = self.build_request(projectSlug=project_slugs) def side_effect( - projects, - **kwargs, - ): + projects: Sequence[Project], + **kwargs: object, + ) -> Sequence[Project]: return projects mock__filter_projects_by_permissions.side_effect = side_effect @@ -950,7 +967,7 @@ def test_release_permission_cache_key_uses_project_param_slugs_when_project_ids_ def build_service_account_request( self, organization: Organization | None = None, params: dict[str, str] | None = None - ): + ) -> Request: service_account = self.create_service_account( organization=organization or self.org, scope_list=["project:releases"], @@ -966,7 +983,7 @@ def build_service_account_request( scopes=["project:releases"], ) request.access = from_request(drf_request_from_request(request), self.org) - return request + return cast(Request, request) def test_service_account_gets_linked_projects(self) -> None: request = self.build_service_account_request() @@ -1059,8 +1076,10 @@ def setUp(self) -> None: self.env_1 = self.create_environment(project=self.project) self.env_2 = self.create_environment(project=self.project) - def run_test(self, expected_envs, env_names=None): - request_args = {} + def run_test( + self, expected_envs: Sequence[Environment], env_names: list[str] | None = None + ) -> None: + request_args: dict[str, object] = {} if env_names: request_args["environment"] = env_names result = self.endpoint.get_environments(self.build_request(**request_args), self.org) @@ -1090,21 +1109,21 @@ def setUp(self) -> None: def run_test( self, - expected_projects, - expected_envs=None, - expected_teams=None, - expected_start=None, - expected_end=None, - env_names=None, - user=None, - date_filter_optional=False, - project_ids=None, - start=None, - end=None, - stats_period=None, - active_superuser=False, - ): - request_args = {} + expected_projects: Sequence[Project], + expected_envs: Sequence[Environment] | None = None, + expected_teams: object | None = None, + expected_start: datetime | None = None, + expected_end: datetime | None = None, + env_names: list[str] | None = None, + user: User | None = None, + date_filter_optional: bool = False, + project_ids: Sequence[int] | None = None, + start: str | None = None, + end: str | None = None, + stats_period: str | None = None, + active_superuser: bool = False, + ) -> None: + request_args: dict[str, object] = {} if env_names: request_args["environment"] = env_names if project_ids: @@ -1115,11 +1134,12 @@ def run_test( if stats_period: request_args["statsPeriod"] = stats_period - result = self.endpoint.get_filter_params( - self.build_request(user=user, active_superuser=active_superuser, **request_args), - self.org, - date_filter_optional=date_filter_optional, - ) + request = self.build_request(user=user, active_superuser=active_superuser, **request_args) + result: FilterParams | FilterParamsDateNotNull + if date_filter_optional: + result = self.endpoint.get_filter_params(request, self.org, date_filter_optional=True) + else: + result = self.endpoint.get_filter_params(request, self.org) assert {p.id for p in expected_projects} == set(result["project_id"]) assert expected_start == result["start"] diff --git a/tests/sentry/api/bases/test_organization_events.py b/tests/sentry/api/bases/test_organization_events.py index fb4cf524686b..a52d67eef4fe 100644 --- a/tests/sentry/api/bases/test_organization_events.py +++ b/tests/sentry/api/bases/test_organization_events.py @@ -68,7 +68,7 @@ def test_legacy_meta_is_unchanged(self) -> None: class OrganizationEventsEndpointBaseGetDatasetTest(TestCase): - def resolve(self, dataset_label: str, **params: str): + def resolve(self, dataset_label: str, **params: str) -> object: request = drf_request_from_request( RequestFactory().get("/", {"dataset": dataset_label, **params}) ) diff --git a/tests/sentry/api/bases/test_project.py b/tests/sentry/api/bases/test_project.py index 6b920a23e111..e5b223d2bb43 100644 --- a/tests/sentry/api/bases/test_project.py +++ b/tests/sentry/api/bases/test_project.py @@ -11,6 +11,7 @@ ) from sentry.auth.access import from_request from sentry.db.models.fields.bounded import BoundedBigAutoField +from sentry.models.apikey import ApiKey from sentry.models.apitoken import ApiToken from sentry.models.project import Project from sentry.testutils.cases import TestCase @@ -36,7 +37,7 @@ def has_object_perm( self, method: str, obj: Project, - auth: ApiToken | None = None, + auth: ApiToken | ApiKey | None = None, user: User | None = None, is_superuser: bool | None = None, is_staff: bool | None = None, diff --git a/tests/sentry/api/bases/test_team.py b/tests/sentry/api/bases/test_team.py index f52c60c69a44..d360906ebfc1 100644 --- a/tests/sentry/api/bases/test_team.py +++ b/tests/sentry/api/bases/test_team.py @@ -4,7 +4,10 @@ from sentry.api.bases.team import TeamEndpoint, TeamPermission from sentry.auth.access import from_request +from sentry.models.apikey import ApiKey from sentry.models.apitoken import ApiToken +from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.models.team import Team from sentry.testutils.cases import TestCase from sentry.testutils.helpers import with_feature @@ -28,8 +31,8 @@ def setUp(self) -> None: def has_object_perm( self, method: str, - obj: Team, - auth: ApiToken | None = None, + obj: Team | Organization | Project, + auth: ApiKey | ApiToken | None = None, user: User | None = None, is_superuser: bool | None = None, ) -> bool: diff --git a/tests/sentry/api/endpoints/conftest.py b/tests/sentry/api/endpoints/conftest.py index 346e0eace487..1e04156ef9d9 100644 --- a/tests/sentry/api/endpoints/conftest.py +++ b/tests/sentry/api/endpoints/conftest.py @@ -1,8 +1,14 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING from uuid import uuid4 import pytest from sentry_relay.auth import PublicKey, SecretKey +if TYPE_CHECKING: + from sentry.models.relay import Relay + @pytest.fixture def key_pair() -> tuple[SecretKey, PublicKey]: @@ -27,7 +33,7 @@ def relay_id() -> str: @pytest.fixture -def relay(relay_id: str | int, public_key: PublicKey): +def relay(relay_id: str | int, public_key: PublicKey) -> Relay: from sentry.models.relay import Relay return Relay.objects.create(relay_id=relay_id, public_key=str(public_key), is_internal=True) diff --git a/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py b/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py index 572ebfc0c4ec..8fdfabd1db59 100644 --- a/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py +++ b/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py @@ -218,8 +218,8 @@ def test_get_multiple_matches(self, mock_get_trees_for_org: Any) -> None: assert response.data == expected_matches def test_get_no_installation(self) -> None: - config_data = { - "projectId": self.project.id, + config_data: dict[str, str] = { + "projectId": str(self.project.id), "stacktraceFilename": "stack/root/file.py", } with ( @@ -368,6 +368,7 @@ def test_post_no_installation(self) -> None: return_value=[{"name": "name", "identifier": "name", "external_id": "88888"}], ) def test_post_existing_code_mapping(self, mock_get_repos: MagicMock) -> None: + assert self.organization_integration is not None project_repo, _ = ProjectRepository.objects.get_or_create( project=self.project, repository=self.repo, diff --git a/tests/sentry/api/endpoints/test_accept_organization_invite.py b/tests/sentry/api/endpoints/test_accept_organization_invite.py index 01874fdd41d3..cea47988c67b 100644 --- a/tests/sentry/api/endpoints/test_accept_organization_invite.py +++ b/tests/sentry/api/endpoints/test_accept_organization_invite.py @@ -24,6 +24,7 @@ from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import assume_test_silo_mode_of, control_silo_test from sentry.types.cell import Cell +from sentry.users.models.user import User @control_silo_test @@ -34,7 +35,7 @@ def setUp(self) -> None: self.organization = self.create_organization(owner=self.create_user("foo@example.com")) self.user = self.create_user("bar@example.com") - def _get_paths(self, args): + def _get_paths(self, args: list[int | str]) -> tuple[str, str]: return ( reverse( "sentry-api-0-organization-accept-organization-invite", @@ -46,28 +47,32 @@ def _get_paths(self, args): ), ) - def _get_urls(self): + def _get_urls(self) -> tuple[str]: return ("sentry-api-0-organization-accept-organization-invite",) - def _get_path(self, url, args): + def _get_path(self, url: str, args: list[int | str]) -> str: return reverse(url, args=[self.organization.slug] + args) - def _require_2fa_for_organization(self): + def _invite_token(self, om: OrganizationMember) -> str: + assert om.token is not None + return om.token + + def _require_2fa_for_organization(self) -> None: with assume_test_silo_mode_of(Organization): self.organization.update(flags=F("flags").bitor(Organization.flags.require_2fa)) - assert self.organization.flags.require_2fa.is_set + assert self.organization.flags.require_2fa - def _assert_pending_invite_details_in_session(self, om): + def _assert_pending_invite_details_in_session(self, om: OrganizationMember) -> None: assert self.client.session["invite_token"] == om.token assert self.client.session["invite_member_id"] == om.id - def _assert_pending_invite_details_not_in_session(self, response): + def _assert_pending_invite_details_not_in_session(self, response: object) -> None: session_invite_token = self.client.session.get("invite_token", None) session_invite_member_id = self.client.session.get("invite_member_id", None) assert session_invite_token is None assert session_invite_member_id is None - def _enroll_user_in_2fa(self, user): + def _enroll_user_in_2fa(self, user: User) -> None: interface = TotpInterface() interface.enroll(user) assert user.has_2fa() @@ -93,7 +98,7 @@ def test_invalid_token(self) -> None: def test_invite_not_pending(self) -> None: user = self.create_user(email="test@gmail.com") om = Factories.create_member(token="abc", organization=self.organization, user=user) - for path in self._get_paths([om.id, om.token]): + for path in self._get_paths([om.id, self._invite_token(om)]): resp = self.client.get(path) assert resp.status_code == 400 @@ -104,7 +109,7 @@ def test_invite_unapproved(self) -> None: organization=self.organization, invite_status=InviteStatus.REQUESTED_TO_JOIN.value, ) - for path in self._get_paths([om.id, om.token]): + for path in self._get_paths([om.id, self._invite_token(om)]): resp = self.client.get(path) assert resp.status_code == 400 @@ -112,7 +117,7 @@ def test_needs_authentication(self) -> None: om = Factories.create_member( email="newuser@example.com", token="abc", organization=self.organization ) - for path in self._get_paths([om.id, om.token]): + for path in self._get_paths([om.id, self._invite_token(om)]): resp = self.client.get(path) assert resp.status_code == 200 assert resp.json()["needsAuthentication"] @@ -124,7 +129,7 @@ def test_not_needs_authentication(self) -> None: om = Factories.create_member( email="newuser@example.com", token="abc", organization=self.organization ) - for path in self._get_paths([om.id, om.token]): + for path in self._get_paths([om.id, self._invite_token(om)]): resp = self.client.get(path) assert resp.status_code == 200 assert not resp.json()["needsAuthentication"] @@ -133,7 +138,7 @@ def test_pending_invite_details_follow_the_current_invite(self) -> None: member = self.create_member( email="invitee@example.com", token="abc", organization=self.organization ) - path = self._get_paths([member.id, member.token])[0] + path = self._get_paths([member.id, self._invite_token(member)])[0] response = self.client.get(path) assert response.status_code == 200 @@ -142,7 +147,9 @@ def test_pending_invite_details_follow_the_current_invite(self) -> None: another_member = self.create_member( email="another-invitee@example.com", token="def", organization=self.organization ) - response = self.client.get(self._get_paths([another_member.id, another_member.token])[0]) + response = self.client.get( + self._get_paths([another_member.id, self._invite_token(another_member)])[0] + ) assert response.status_code == 200 self._assert_pending_invite_details_in_session(another_member) @@ -161,7 +168,7 @@ def test_invite_preserves_pending_mfa_on_reload(self) -> None: self.session["_next"] = "/settings/account/" self.save_session() - response = self.client.get(self._get_paths([member.id, member.token])[0]) + response = self.client.get(self._get_paths([member.id, self._invite_token(member)])[0]) assert response.status_code == 200 assert response.json()["needsAuthentication"] @@ -178,7 +185,7 @@ def test_user_needs_2fa(self) -> None: email="newuser@example.com", token="abc", organization=self.organization ) - for path in self._get_paths([om.id, om.token]): + for path in self._get_paths([om.id, self._invite_token(om)]): resp = self.client.get(path) assert resp.status_code == 200 assert resp.json()["needs2fa"] @@ -222,7 +229,7 @@ def test_multi_region_organizationmember_id(self) -> None: organization_id=self.organization.id, organizationmember_id=om.id ) - for path in self._get_paths([om.id, om.token]): + for path in self._get_paths([om.id, self._invite_token(om)]): resp = self.client.get(path) assert resp.status_code == 200 assert resp.json()["needs2fa"] @@ -239,7 +246,7 @@ def test_user_has_2fa(self) -> None: om = Factories.create_member( email="newuser@example.com", token="abc", organization=self.organization ) - for path in self._get_paths([om.id, om.token]): + for path in self._get_paths([om.id, self._invite_token(om)]): resp = self.client.get(path) assert resp.status_code == 200 assert not resp.json()["needs2fa"] @@ -253,7 +260,7 @@ def test_user_can_use_sso(self) -> None: om = Factories.create_member( email="newuser@example.com", token="abc", organization=self.organization ) - for path in self._get_paths([om.id, om.token]): + for path in self._get_paths([om.id, self._invite_token(om)]): resp = self.client.get(path) assert resp.status_code == 200 assert resp.json()["needsSso"] @@ -273,7 +280,7 @@ def test_can_accept_while_authenticated(self) -> None: token="abc", organization=self.organization, ) - path = self._get_path(url, [om.id, om.token]) + path = self._get_path(url, [om.id, self._invite_token(om)]) resp = self.client.post(path) assert resp.status_code == 204 @@ -297,6 +304,7 @@ def test_cannot_accept_expired(self) -> None: om = Factories.create_member( email="newuser@example.com", token="abc", organization=self.organization ) + assert om.token_expires_at is not None with ( assume_test_silo_mode_of(OrganizationMember), unguarded_write(using=router.db_for_write(OrganizationMember)), @@ -305,7 +313,7 @@ def test_cannot_accept_expired(self) -> None: token_expires_at=om.token_expires_at - timedelta(days=31) ) - for path in self._get_paths([om.id, om.token]): + for path in self._get_paths([om.id, self._invite_token(om)]): resp = self.client.post(path) assert resp.status_code == 400 @@ -324,7 +332,7 @@ def test_cannot_accept_unapproved_invite(self) -> None: organization=self.organization, invite_status=InviteStatus.REQUESTED_TO_JOIN.value, ) - for path in self._get_paths([om.id, om.token]): + for path in self._get_paths([om.id, self._invite_token(om)]): resp = self.client.post(path) assert resp.status_code == 400 @@ -347,7 +355,7 @@ def test_member_already_exists(self) -> None: token="abc", organization=self.organization, ) - path = self._get_path(url, [om.id, om.token]) + path = self._get_path(url, [om.id, self._invite_token(om)]) resp = self.client.post(path) assert resp.status_code == 204 @@ -364,7 +372,7 @@ def test_member_already_exists(self) -> None: ) self.assert_org_member_mapping(org_member=om2) with outbox_runner(): - path = self._get_path(url, [om2.id, om2.token]) + path = self._get_path(url, [om2.id, self._invite_token(om2)]) resp = self.client.post(path) assert resp.status_code == 400 with assume_test_silo_mode_of(OrganizationMember): @@ -389,7 +397,7 @@ def test_member_already_exists_with_member_id_from_another_organization(self) -> path = self._get_path( "sentry-api-0-organization-accept-organization-invite", - [other_om.id, other_om.token], + [other_om.id, self._invite_token(other_om)], ) resp = self.client.post(path) assert resp.status_code == 400 @@ -479,7 +487,7 @@ def test_can_accept_when_user_has_2fa(self) -> None: organization=self.organization, ) - path = self._get_path(url, [om.id, om.token]) + path = self._get_path(url, [om.id, self._invite_token(om)]) resp = self.client.post(path) assert resp.status_code == 204 @@ -508,7 +516,7 @@ def test_cannot_accept_when_user_needs_2fa(self) -> None: om = Factories.create_member( email="newuser@example.com", role="member", token="abc", organization=self.organization ) - for path in self._get_paths([om.id, om.token]): + for path in self._get_paths([om.id, self._invite_token(om)]): resp = self.client.post(path) assert resp.status_code == 400 @@ -531,7 +539,7 @@ def test_2fa_cookie_deleted_after_accept(self) -> None: token="abc", organization=self.organization, ) - path = self._get_path(url, [om.id, om.token]) + path = self._get_path(url, [om.id, self._invite_token(om)]) resp = self.client.get(path) assert resp.status_code == 200 self._assert_pending_invite_details_in_session(om) diff --git a/tests/sentry/api/endpoints/test_admin_project_configs.py b/tests/sentry/api/endpoints/test_admin_project_configs.py index 4fbf3fa9797d..d5a842373096 100644 --- a/tests/sentry/api/endpoints/test_admin_project_configs.py +++ b/tests/sentry/api/endpoints/test_admin_project_configs.py @@ -32,6 +32,7 @@ def setUp(self) -> None: self.p1_pk = self.create_project_key(self.proj1) self.p2_pk = self.create_project_key(self.proj2) + assert self.p1_pk.public_key is not None projectconfig_cache.backend.set_many( { @@ -160,6 +161,7 @@ def test_invalidate_project_config_cached_project(self) -> None: url = self.get_url() self.login_as(self.superuser, superuser=True) data = {"projectId": self.proj2.id} + assert self.p2_pk.public_key is not None projectconfig_cache.backend.set_many( { self.p2_pk.public_key: {"proj2": "config"}, @@ -172,6 +174,7 @@ def test_invalidate_project_config_cached_project_sets_correct_config(self) -> N url = self.get_url() self.login_as(self.superuser, superuser=True) data = {"projectId": self.proj2.id} + assert self.p2_pk.public_key is not None projectconfig_cache.backend.set_many( { self.p2_pk.public_key: {"proj2": "config"}, @@ -207,6 +210,8 @@ def test_invalidate_project_config_with_multiple_project_keys(self) -> None: second_key = test_project.key_set.create() # Set configs for both keys + assert first_key.public_key is not None + assert second_key.public_key is not None projectconfig_cache.backend.set_many( { first_key.public_key: {"test_proj": "config1"}, @@ -226,6 +231,7 @@ def test_get_caches_uncached_project_config(self) -> None: Tests that making a GET request for an uncached project results in its configuration being cached. """ + assert self.p2_pk.public_key is not None initial_cached_config = projectconfig_cache.backend.get(self.p2_pk.public_key) assert initial_cached_config is None diff --git a/tests/sentry/api/endpoints/test_assistant.py b/tests/sentry/api/endpoints/test_assistant.py index 11444407451b..f2a576a6cb74 100644 --- a/tests/sentry/api/endpoints/test_assistant.py +++ b/tests/sentry/api/endpoints/test_assistant.py @@ -13,7 +13,7 @@ class AssistantActivityTest(APITestCase): endpoint = "sentry-api-0-assistant" @cached_property - def guides(self): + def guides(self) -> dict[str, int]: return manager.all() def setUp(self) -> None: @@ -54,7 +54,7 @@ class AssistantActivityUpdateTest(APITestCase): method = "put" @cached_property - def guides(self): + def guides(self) -> dict[str, int]: return manager.all() def setUp(self) -> None: diff --git a/tests/sentry/api/endpoints/test_auth_integration.py b/tests/sentry/api/endpoints/test_auth_integration.py index 54a1a1325a78..05ebfcac4464 100644 --- a/tests/sentry/api/endpoints/test_auth_integration.py +++ b/tests/sentry/api/endpoints/test_auth_integration.py @@ -53,20 +53,25 @@ def assert_logged_out(self) -> None: assert response.status_code == 400 def login(self, password: str = "password") -> Response: - return self.client.post( + response: Response[Any] = self.client.post( reverse("sentry-api-0-auth-login"), data={"username": self.user.username, "password": password}, ) + return response def verify_2fa(self, method: str, **data: Any) -> Response: - return self.client.post( + response: Response[Any] = self.client.post( reverse("sentry-api-0-auth-2fa"), data={"method": method, **data}, content_type="application/json", ) + return response def activate_2fa(self, method: str) -> Response: - return self.client.post(reverse("sentry-api-0-auth-2fa-challenge"), data={"method": method}) + response: Response[Any] = self.client.post( + reverse("sentry-api-0-auth-2fa-challenge"), data={"method": method} + ) + return response def enroll_webauthn(self) -> WebAuthnTestCredential: private_key = ec.generate_private_key(ec.SECP256R1()) diff --git a/tests/sentry/api/endpoints/test_auth_login.py b/tests/sentry/api/endpoints/test_auth_login.py index 77ec81ee0c49..ba9edb162e31 100644 --- a/tests/sentry/api/endpoints/test_auth_login.py +++ b/tests/sentry/api/endpoints/test_auth_login.py @@ -1140,7 +1140,9 @@ def test_cancel_mfa_login_clears_webauthn_challenge(self) -> None: "sentry.auth.authenticators.U2fInterface.activate", return_value=ActivationChallengeResult(b"challenge"), ) - def test_activate_webauthn_challenge(self, activate, is_available) -> None: + def test_activate_webauthn_challenge( + self, activate: MagicMock, is_available: MagicMock + ) -> None: U2fInterface().enroll(self.user) login_response = self.get_response(username=self.user.username, password="admin") @@ -1170,7 +1172,9 @@ def test_activate_challenge_requires_csrf_token(self) -> None: @patch("sentry.auth.authenticators.U2fInterface.is_available", return_value=True) @patch("sentry.auth.authenticators.U2fInterface.validate_response", return_value=True) - def test_complete_webauthn_login(self, validate_response, is_available) -> None: + def test_complete_webauthn_login( + self, validate_response: MagicMock, is_available: MagicMock + ) -> None: U2fInterface().enroll(self.user) self.get_response(username=self.user.username, password="admin") webauthn_response = { @@ -1201,7 +1205,9 @@ def test_complete_webauthn_login(self, validate_response, is_available) -> None: ) @patch("sentry.auth.authenticators.U2fInterface.is_available", return_value=True) - def test_complete_webauthn_login_rejects_malformed_response(self, is_available) -> None: + def test_complete_webauthn_login_rejects_malformed_response( + self, is_available: MagicMock + ) -> None: U2fInterface().enroll(self.user) self.get_response(username=self.user.username, password="admin") session = self.client.session @@ -1227,7 +1233,9 @@ def test_complete_webauthn_login_rejects_malformed_response(self, is_available) assert "_auth_user_id" not in self.client.session @patch("sentry.auth.authenticators.U2fInterface.is_available", return_value=True) - def test_complete_webauthn_login_uses_camel_case_validation_errors(self, is_available) -> None: + def test_complete_webauthn_login_uses_camel_case_validation_errors( + self, is_available: MagicMock + ) -> None: U2fInterface().enroll(self.user) self.get_response(username=self.user.username, password="admin") @@ -1252,7 +1260,7 @@ def test_complete_webauthn_login_uses_camel_case_validation_errors(self, is_avai "sentry.auth.authenticators.SmsInterface.activate", return_value=ActivationMessageResult("Code sent", expires_in=45), ) - def test_activate_sms_challenge(self, activate, is_available) -> None: + def test_activate_sms_challenge(self, activate: MagicMock, is_available: MagicMock) -> None: interface = SmsInterface() interface.phone_number = "5555551212" interface.enroll(self.user) @@ -1275,7 +1283,7 @@ def test_activate_sms_challenge(self, activate, is_available) -> None: return_value=ActivationMessageResult("Unable to send code", type="error"), ) def test_activate_challenge_captures_provider_error( - self, activate, is_available, capture_message + self, activate: MagicMock, is_available: MagicMock, capture_message: MagicMock ) -> None: interface = SmsInterface() interface.phone_number = "5555551212" @@ -1299,7 +1307,7 @@ def test_activate_challenge_captures_provider_error( @patch("sentry.auth.authenticators.SmsInterface.is_available", return_value=True) @patch("sentry.auth.authenticators.SmsInterface.activate", return_value=None) def test_activate_challenge_captures_unexpected_result( - self, activate, is_available, capture_message + self, activate: MagicMock, is_available: MagicMock, capture_message: MagicMock ) -> None: interface = SmsInterface() interface.phone_number = "5555551212" @@ -1326,7 +1334,7 @@ def test_activate_challenge_captures_unexpected_result( return_value=ActivationMessageResult("Code sent"), ) def test_activate_challenge_captures_unsupported_result( - self, activate, is_available, capture_exception + self, activate: MagicMock, is_available: MagicMock, capture_exception: MagicMock ) -> None: interface = SmsInterface() interface.phone_number = "5555551212" @@ -1378,7 +1386,7 @@ def test_validate_recovery_token(self) -> None: response = self.client.get( reverse("sentry-api-0-auth-recovery-confirm"), - data={"userId": self.user.id, "token": password_hash.hash}, + data={"userId": str(self.user.id), "token": password_hash.hash}, ) assert response.status_code == 200 @@ -1391,7 +1399,7 @@ def test_validate_recovery_token(self) -> None: def test_validate_recovery_rejects_invalid_token(self) -> None: response = self.client.get( reverse("sentry-api-0-auth-recovery-confirm"), - data={"userId": self.user.id, "token": "invalid-token"}, + data={"userId": str(self.user.id), "token": "invalid-token"}, ) assert response.status_code == 200 @@ -1405,7 +1413,7 @@ def test_validate_recovery_rejects_expired_token(self) -> None: response = self.client.get( reverse("sentry-api-0-auth-recovery-confirm"), - data={"userId": self.user.id, "token": password_hash.hash}, + data={"userId": str(self.user.id), "token": password_hash.hash}, ) assert response.status_code == 200 @@ -1417,7 +1425,7 @@ def test_validate_recovery_rejects_another_users_token(self) -> None: response = self.client.get( reverse("sentry-api-0-auth-recovery-confirm"), - data={"userId": self.user.id, "token": password_hash.hash}, + data={"userId": str(self.user.id), "token": password_hash.hash}, ) assert response.status_code == 200 @@ -1429,7 +1437,7 @@ def test_validate_recovery_rejects_managed_user(self) -> None: response = self.client.get( reverse("sentry-api-0-auth-recovery-confirm"), - data={"userId": self.user.id, "token": password_hash.hash}, + data={"userId": str(self.user.id), "token": password_hash.hash}, ) assert response.status_code == 200 @@ -1441,7 +1449,7 @@ def test_validate_recovery_rejects_suspended_user(self) -> None: response = self.client.get( reverse("sentry-api-0-auth-recovery-confirm"), - data={"userId": self.user.id, "token": password_hash.hash}, + data={"userId": str(self.user.id), "token": password_hash.hash}, ) assert response.status_code == 200 @@ -1462,10 +1470,11 @@ def test_validate_recovery_rate_limited(self, is_limited: MagicMock) -> None: def request_recovery( self, user: str | None = None, client: APIClient | None = None ) -> Response: - return (client or self.client).post( + response: Response[Any] = (client or self.client).post( reverse("sentry-api-0-auth-recovery"), data={"user": user or self.user.email}, ) + return response def confirm_recovery( self, @@ -1473,10 +1482,11 @@ def confirm_recovery( password: str = "new-secure-password", client: APIClient | None = None, ) -> Response: - return (client or self.client).post( + response: Response[Any] = (client or self.client).post( reverse("sentry-api-0-auth-recovery-confirm"), - data={"userId": self.user.id, "token": token, "password": password}, + data={"userId": str(self.user.id), "token": token, "password": password}, ) + return response def test_request_recovery_requires_csrf_token(self) -> None: client = APIClient(enforce_csrf_checks=True) 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/endpoints/test_chunk_upload.py b/tests/sentry/api/endpoints/test_chunk_upload.py index d9756572d535..d478b2def77c 100644 --- a/tests/sentry/api/endpoints/test_chunk_upload.py +++ b/tests/sentry/api/endpoints/test_chunk_upload.py @@ -1,6 +1,7 @@ import gzip import math from hashlib import sha1 +from typing import TypedDict import pytest import zstandard @@ -8,6 +9,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.test import override_settings from django.urls import reverse +from rest_framework.views import APIView from sentry import options from sentry.api.endpoints.chunk import ( @@ -30,9 +32,13 @@ from sentry.testutils.silo import assume_test_silo_mode +class _LaunchpadAuthHeaders(TypedDict): + HTTP_AUTHORIZATION: str + + class ChunkUploadTest(APITestCase): @pytest.fixture(autouse=True) - def _restore_upload_url_options(self): + def _restore_upload_url_options(self) -> None: options.delete("system.upload-url-prefix") def setUp(self) -> None: @@ -41,7 +47,9 @@ def setUp(self) -> None: self.token = ApiToken.objects.create(user=self.user, scope_list=["project:write"]) self.url = reverse("sentry-api-0-chunk-upload", args=[self.organization.slug]) - def _get_launchpad_auth_headers(self, method="GET", data=b""): + def _get_launchpad_auth_headers( + self, method: str = "GET", data: bytes = b"" + ) -> _LaunchpadAuthHeaders: """Generate Launchpad RPC signature authentication headers.""" signature = generate_service_request_signature( self.url, data, ["test-secret-key"], "Launchpad" @@ -329,8 +337,8 @@ def test_upload_launchpad_auth_different_org(self) -> None: mock_request.successful_authenticator = LaunchpadRpcSignatureAuthentication() # This should return True for Launchpad auth - assert permission.has_permission(mock_request, None) - assert permission.has_object_permission(mock_request, None, other_org) + assert permission.has_permission(mock_request, APIView()) + assert permission.has_object_permission(mock_request, APIView(), other_org) def test_empty_upload(self) -> None: response = self.client.post( diff --git a/tests/sentry/api/endpoints/test_debug_files.py b/tests/sentry/api/endpoints/test_debug_files.py index c88d465b97fa..17b73148449a 100644 --- a/tests/sentry/api/endpoints/test_debug_files.py +++ b/tests/sentry/api/endpoints/test_debug_files.py @@ -6,7 +6,9 @@ import requests from django.core.files.uploadedfile import SimpleUploadedFile +from django.http import HttpResponse from django.urls import reverse +from rest_framework.response import Response from symbolic.debuginfo import Archive from sentry.models.debugfile import ( @@ -44,13 +46,13 @@ def setUp(self) -> None: ) self.login_as(user=self.user) - def _upload_proguard(self, url, uuid): + def _upload_proguard(self, url: str, uuid: str) -> Response: out = BytesIO() f = zipfile.ZipFile(out, "w") f.writestr("proguard/%s.txt" % uuid, PROGUARD_SOURCE) f.close() - return self.client.post( + response: Response = self.client.post( url, { "file": SimpleUploadedFile( @@ -59,8 +61,9 @@ def _upload_proguard(self, url, uuid): }, format="multipart", ) + return response - def _upload_wasm(self): + def _upload_wasm(self) -> Response: """Uploads a WASM fixture.""" out = BytesIO() @@ -71,7 +74,7 @@ def _upload_wasm(self): f.writestr("index.wasm", wasm.read()) f.close() - return self.client.post( + response: Response = self.client.post( self.url, { "file": SimpleUploadedFile( @@ -80,8 +83,9 @@ def _upload_wasm(self): }, format="multipart", ) + return response - def _upload_wasm_legacy(self): + def _upload_wasm_legacy(self) -> None: """ Uploads the same WASM fixture as `_upload_wasm`, but with its legacy code and debug IDs. @@ -105,18 +109,20 @@ def _upload_wasm_legacy(self): file.checksum = "foobar" create_dif_from_file(self.project, meta, file) - def _assert_successful_download(self, response, content: bytes, filename=None) -> None: + def _assert_successful_download( + self, response: HttpResponse, content: bytes, filename: str | None = None + ) -> None: if response.status_code == 302: location = response["Location"] # In dev/test the host may be rewritten to the Docker-internal `objectstore` hostname # (so Symbolicator can reach it); rewrite it back so this host-side test can follow it. location = location.replace("://objectstore:", "://127.0.0.1:") - response = requests.get(location) - assert response.status_code == 200, response.text - assert response.content == content + download = requests.get(location) + assert download.status_code == 200, download.text + assert download.content == content if filename is not None: assert ( - response.headers["Content-Disposition"] == f'attachment; filename="{filename}"' + download.headers["Content-Disposition"] == f'attachment; filename="{filename}"' ) else: assert response.status_code == 200, response.content @@ -242,7 +248,9 @@ def test_wasm_legacy_ids_old_upload(self) -> None: # The file that we find should have the legacy debug ID. assert response.data[0]["debugId"] == "10da03eb-5d63-d750-faab-0fb8e7cc12a1" - def _do_test_dsyms_by_debugid_and_codeid(self, query, files): + def _do_test_dsyms_by_debugid_and_codeid( + self, query: tuple[str, ...], files: list[tuple[bool, str, str]] + ) -> None: for _, debug_id, code_id in files: self.create_dif_file(debug_id=debug_id, code_id=code_id) @@ -527,7 +535,11 @@ def _upload_and_get_download_id(self) -> str: with self.feature({"organizations:objectstore-debugfiles-write": True}): response = self._upload_proguard(self.url, PROGUARD_UUID) assert response.status_code == 201, response.content - return self.client.get(self.url).data[0]["id"] + download_response: Response = self.client.get(self.url) + download_id = download_response.data[0]["id"] + if not isinstance(download_id, str): + raise TypeError("Debug file download has no id") + return download_id def test_internal_request_redirects_to_objectstore(self) -> None: download_id = self._upload_and_get_download_id() diff --git a/tests/sentry/api/endpoints/test_dif_assemble.py b/tests/sentry/api/endpoints/test_dif_assemble.py index df9b823df3df..c2830ef2a7c4 100644 --- a/tests/sentry/api/endpoints/test_dif_assemble.py +++ b/tests/sentry/api/endpoints/test_dif_assemble.py @@ -4,6 +4,7 @@ import pytest from django.core.files.base import ContentFile from django.urls import reverse +from rest_framework.response import Response from sentry.api.endpoints.debug_files import _clone_proguard_debug_file_for_reupload from sentry.models.apitoken import ApiToken @@ -406,8 +407,8 @@ def _assemble_source(self, checksum: str, chunks: list[str]) -> None: chunks=chunks, ) - def _clone_request(self, checksum: str, chunks: list[str]): - return self.client.post( + def _clone_request(self, checksum: str, chunks: list[str]) -> Response: + response: Response = self.client.post( self.url, data={ checksum: { @@ -417,6 +418,7 @@ def _clone_request(self, checksum: str, chunks: list[str]): }, HTTP_AUTHORIZATION=f"Bearer {self.token.token}", ) + return response def test_clone_file_backed_source_remains_file_backed(self) -> None: """A file-backed source produces a file-backed clone regardless of the active write flag.""" diff --git a/tests/sentry/api/endpoints/test_event_attachment_details.py b/tests/sentry/api/endpoints/test_event_attachment_details.py index 50a11719fd95..c8bce1c09e3c 100644 --- a/tests/sentry/api/endpoints/test_event_attachment_details.py +++ b/tests/sentry/api/endpoints/test_event_attachment_details.py @@ -45,6 +45,7 @@ def create_attachment( ) file = EventAttachment.putfile(self.project.id, attachment) + assert attachment.name is not None self.attachment = EventAttachment.objects.create( event_id=self.event.event_id, project_id=self.event.project_id, diff --git a/tests/sentry/api/endpoints/test_index.py b/tests/sentry/api/endpoints/test_index.py index 6b356e349b02..8cd005e6ce84 100644 --- a/tests/sentry/api/endpoints/test_index.py +++ b/tests/sentry/api/endpoints/test_index.py @@ -1,4 +1,5 @@ from django.urls import reverse +from rest_framework.test import APIClient from sentry.models.apikey import ApiKey from sentry.models.apitoken import ApiToken @@ -7,7 +8,7 @@ @control_silo_test -def test_anonymous(client) -> None: +def test_anonymous(client: APIClient) -> None: response = client.get(reverse("sentry-api-index")) assert response.status_code == 200 assert response.data["version"] == "0" diff --git a/tests/sentry/api/endpoints/test_oauth_authorize.py b/tests/sentry/api/endpoints/test_oauth_authorize.py index b41a605d8b20..830b0031516c 100644 --- a/tests/sentry/api/endpoints/test_oauth_authorize.py +++ b/tests/sentry/api/endpoints/test_oauth_authorize.py @@ -225,7 +225,10 @@ def setUp(self) -> None: def prepare(self, **query: str) -> str: response = self.get_success_response(method="get", **{**self.query, **query}) - return response.data["transactionId"] + transaction_id = response.data["transactionId"] + if not isinstance(transaction_id, str): + raise TypeError("OAuth authorize response carries no transaction id") + return transaction_id def test_approve_and_exchange_pkce_code(self) -> None: verifier = "v" * 43 diff --git a/tests/sentry/api/endpoints/test_onboarding.py b/tests/sentry/api/endpoints/test_onboarding.py index 571def633305..1c0b1b251c25 100644 --- a/tests/sentry/api/endpoints/test_onboarding.py +++ b/tests/sentry/api/endpoints/test_onboarding.py @@ -20,7 +20,7 @@ def test_skip_skippable_tasks(self) -> None: """ skippable_tasks = OrganizationOnboardingTask.SKIPPABLE_TASKS - def get_skipped_tasks(): + def get_skipped_tasks() -> set[int]: return set( OrganizationOnboardingTask.objects.filter( organization=self.organization, status=OnboardingTaskStatus.SKIPPED diff --git a/tests/sentry/api/endpoints/test_organization_agentic_onboarding.py b/tests/sentry/api/endpoints/test_organization_agentic_onboarding.py index 40df93b998a2..ab87a0d80c53 100644 --- a/tests/sentry/api/endpoints/test_organization_agentic_onboarding.py +++ b/tests/sentry/api/endpoints/test_organization_agentic_onboarding.py @@ -211,7 +211,7 @@ def _post_status(self, token: str) -> Response: "sentry.api.endpoints.organization_agentic_onboarding.get_onboarding_progress_service", return_value=self.service, ): - return self.client.post( + response: Response = self.client.post( path, { "schemaVersion": 1, @@ -220,6 +220,7 @@ def _post_status(self, token: str) -> Response: "status": "completed", }, ) + return response def test_status_rejects_backend_derived_bypassed_status(self) -> None: _, token = self.service.create_or_resume( diff --git a/tests/sentry/api/endpoints/test_organization_ai_conversation_details.py b/tests/sentry/api/endpoints/test_organization_ai_conversation_details.py index bb668fe1bb66..b828258c41a6 100644 --- a/tests/sentry/api/endpoints/test_organization_ai_conversation_details.py +++ b/tests/sentry/api/endpoints/test_organization_ai_conversation_details.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any from unittest.mock import MagicMock, call, patch from uuid import uuid4 @@ -10,6 +10,7 @@ from sentry.ai_monitoring.endpoints.organization_ai_conversation_details import ( PARENT_SPAN_ATTRIBUTES, OrganizationAIConversationDetailsEndpoint, + SpanRow, ) from sentry.ai_monitoring.transcript.builder import ( TranscriptContextError, @@ -19,7 +20,10 @@ from sentry.issues.grouptype import PerformanceFileIOMainThreadGroupType from sentry.issues.ingest import save_issue_occurrence from sentry.issues.issue_occurrence import IssueOccurrence +from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.search.events.types import SnubaParams +from sentry.services.eventstore.models import Event from sentry.snuba.spans_rpc import Spans from sentry.testutils.helpers import parse_link_header from sentry.testutils.helpers.datetime import before_now @@ -448,7 +452,12 @@ def test_agents_conversation_details_url(self) -> None: == f"/api/0/organizations/{self.organization.slug}/agents/conversations/{conversation_id}/" ) - def do_request(self, conversation_id, query=None, **kwargs): + def do_request( + self, + conversation_id: str, + query: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Any: query = query or {} return self.client.get( @@ -464,7 +473,9 @@ def do_request(self, conversation_id, query=None, **kwargs): **kwargs, ) - def _store_conversation_span(self, conversation_id, timestamp, project=None) -> None: + def _store_conversation_span( + self, conversation_id: str, timestamp: datetime, project: Project | None = None + ) -> None: """One minimal span, enough for the conversation to resolve to a project.""" self.store_ai_span( conversation_id=conversation_id, @@ -1292,7 +1303,13 @@ def test_timeout_returns_504(self) -> None: assert response.status_code == 504 - def _store_error_on_span(self, trace_id, span_id, timestamp, project=None): + def _store_error_on_span( + self, + trace_id: str, + span_id: str, + timestamp: datetime, + project: Project | None = None, + ) -> Event: project = project or self.project error_data = load_data("javascript", timestamp=timestamp) assert error_data is not None @@ -1986,7 +2003,7 @@ def test_transcript_annotates_only_emitted_rows_once(self) -> None: tool_name="another-lookup", ) - def annotate(spans, params, organization): + def annotate(spans: list[SpanRow], params: SnubaParams, organization: Organization) -> None: spans[0]["errors"] = [{"issue_id": 1}] with ( diff --git a/tests/sentry/api/endpoints/test_organization_ai_conversations.py b/tests/sentry/api/endpoints/test_organization_ai_conversations.py index bdab7cd53690..f851fef2eef9 100644 --- a/tests/sentry/api/endpoints/test_organization_ai_conversations.py +++ b/tests/sentry/api/endpoints/test_organization_ai_conversations.py @@ -1,5 +1,5 @@ -from datetime import timedelta -from typing import Any +from datetime import datetime, timedelta +from typing import Any, cast from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -12,6 +12,7 @@ OrganizationAIConversationsEndpoint, ) from sentry.ai_monitoring.serializers import OrganizationAIConversationsSerializer +from sentry.models.project import Project from sentry.search.eap.types import FieldsACL, SearchResolverConfig from sentry.search.events.types import SnubaParams from sentry.snuba.spans_rpc import Spans @@ -235,7 +236,7 @@ def test_agents_conversations_url(self) -> None: == f"/api/0/organizations/{self.organization.slug}/agents/conversations/" ) - def do_request(self, query=None, **kwargs): + def do_request(self, query: dict[str, Any] | None = None, **kwargs: Any) -> Any: query = query or {} return self.client.get( @@ -792,7 +793,7 @@ def test_single_conversation_single_trace(self) -> None: assert conversation["firstInput"] == "Hello, I need help" assert conversation["lastOutput"] == last_response_text - def _store_minimal_conversation(self, conversation_id: str, timestamp) -> None: + def _store_minimal_conversation(self, conversation_id: str, timestamp: datetime) -> None: self.store_ai_span( conversation_id=conversation_id, timestamp=timestamp, @@ -1575,7 +1576,8 @@ def _request_conversation(self, now: Any) -> dict[str, Any]: ) assert response.status_code == 200 assert len(response.data) == 1 - return response.data[0] + # justification: the conversations endpoint serializes one payload per row. + return cast("dict[str, Any]", response.data[0]) def test_agent_messages_populate_input_and_output(self) -> None: now = before_now(days=20).replace(microsecond=0) @@ -1858,7 +1860,9 @@ def test_tokens_only_counted_from_ai_client_spans(self) -> None: assert conversation["llmCalls"] == 1 assert conversation["flow"] == ["Test Agent"] - def _store_conversation_span(self, conversation_id, timestamp, project=None): + def _store_conversation_span( + self, conversation_id: str, timestamp: datetime, project: Project | None = None + ) -> None: """Store one minimal ai_client span so a conversation shows up in the list.""" self.store_ai_span( conversation_id=conversation_id, diff --git a/tests/sentry/api/endpoints/test_organization_ai_conversations_base.py b/tests/sentry/api/endpoints/test_organization_ai_conversations_base.py index f6f2d02384ac..e3ef3ebc82b3 100644 --- a/tests/sentry/api/endpoints/test_organization_ai_conversations_base.py +++ b/tests/sentry/api/endpoints/test_organization_ai_conversations_base.py @@ -1,3 +1,7 @@ +from datetime import datetime +from typing import Any + +from sentry.models.project import Project from sentry.testutils.cases import APITestCase, BaseSpansTestCase, SpanTestCase from sentry.utils import json @@ -19,53 +23,53 @@ def setUp(self) -> None: def store_ai_span( self, - conversation_id, - timestamp, - op="gen_ai.chat", - description=None, - status="ok", - operation_name=None, - operation_type=None, - tokens=None, - input_tokens=None, - output_tokens=None, - cache_read_tokens=None, - cache_write_tokens=None, - reasoning_tokens=None, - cost=None, - input_cost=None, - output_cost=None, - cache_read_cost=None, - cache_write_cost=None, - reasoning_cost=None, - request_model=None, - response_model=None, - trace_id=None, - agent_name=None, - messages=None, - response_text=None, - tool_name=None, - tool_result=None, - tool_result_size=None, - tool_output=None, - embeddings_input=None, - memory_store_id=None, - memory_query_text=None, - memory_record_id=None, - memory_record_count=None, - memory_records=None, - user_id=None, - user_email=None, - user_username=None, - user_ip=None, - origin=None, - input_messages=None, - output_messages=None, - system_instructions=None, - tool_definitions=None, - project=None, - store=True, - ): + conversation_id: str, + timestamp: datetime, + op: str = "gen_ai.chat", + description: str | None = None, + status: str = "ok", + operation_name: str | None = None, + operation_type: str | None = None, + tokens: int | None = None, + input_tokens: int | None = None, + output_tokens: int | None = None, + cache_read_tokens: int | None = None, + cache_write_tokens: int | None = None, + reasoning_tokens: int | None = None, + cost: float | None = None, + input_cost: float | None = None, + output_cost: float | None = None, + cache_read_cost: float | None = None, + cache_write_cost: float | None = None, + reasoning_cost: float | None = None, + request_model: str | None = None, + response_model: str | None = None, + trace_id: str | None = None, + agent_name: str | None = None, + messages: object | None = None, + response_text: str | None = None, + tool_name: str | None = None, + tool_result: str | None = None, + tool_result_size: int | None = None, + tool_output: str | None = None, + embeddings_input: object | None = None, + memory_store_id: str | None = None, + memory_query_text: str | None = None, + memory_record_id: str | None = None, + memory_record_count: int | None = None, + memory_records: object | None = None, + user_id: str | None = None, + user_email: str | None = None, + user_username: str | None = None, + user_ip: str | None = None, + origin: str | None = None, + input_messages: object | None = None, + output_messages: object | None = None, + system_instructions: object | None = None, + tool_definitions: object | None = None, + project: Project | None = None, + store: bool = True, + ) -> dict[str, Any]: """Create and store an AI span with the given attributes. Args: @@ -112,7 +116,7 @@ def store_ai_span( Returns: The created span object """ - span_data = { + span_data: dict[str, Any] = { # deprecated in favour of `ai_conversation_id` down below. "gen_ai.conversation.id": conversation_id, } diff --git a/tests/sentry/api/endpoints/test_organization_events_validate.py b/tests/sentry/api/endpoints/test_organization_events_validate.py index 34c3bb49ddf5..b07bdc030d9a 100644 --- a/tests/sentry/api/endpoints/test_organization_events_validate.py +++ b/tests/sentry/api/endpoints/test_organization_events_validate.py @@ -24,11 +24,12 @@ class OrganizationEventsValidateEndpointTest( def do_request(self, query: Any) -> Response: self.login_as(user=self.user) - return self.client.get( + response: Response[Any] = self.client.get( reverse(self.viewname, kwargs={"organization_id_or_slug": self.organization.slug}), query, format="json", ) + return response def test_no_projects(self) -> None: response = self.do_request({}) diff --git a/tests/sentry/api/endpoints/test_organization_explore_formulas.py b/tests/sentry/api/endpoints/test_organization_explore_formulas.py index 4069f0945536..a78c6511863f 100644 --- a/tests/sentry/api/endpoints/test_organization_explore_formulas.py +++ b/tests/sentry/api/endpoints/test_organization_explore_formulas.py @@ -515,14 +515,17 @@ def test_create_explore_formula(self) -> None: params = formula.variables.filter(kind=KindItemTypes.PARAM).order_by("order") assert len(params) == 3 assert params[0].name == data["params"][0]["name"] + assert params[0].param_type is not None assert ParamItemTypes.get_type_name(params[0].param_type) == data["params"][0]["type"] assert params[0].order == data["params"][0]["order"] assert params[0].value == data["params"][0]["value"] assert params[1].name == data["params"][1]["name"] + assert params[1].param_type is not None assert ParamItemTypes.get_type_name(params[1].param_type) == data["params"][1]["type"] assert params[1].order == data["params"][1]["order"] assert params[1].value == data["params"][1]["value"] assert params[2].name == data["params"][2]["name"] + assert params[2].param_type is not None assert ParamItemTypes.get_type_name(params[2].param_type) == data["params"][2]["type"] assert params[2].order == data["params"][2]["order"] assert params[2].value == data["params"][2]["value"] @@ -558,14 +561,17 @@ def test_create_explore_formula_with_upper_case(self) -> None: params = formula.variables.filter(kind=KindItemTypes.PARAM).order_by("order") assert len(params) == 3 assert params[0].name == data["params"][0]["name"] + assert params[0].param_type is not None assert ParamItemTypes.get_type_name(params[0].param_type) == data["params"][0]["type"] assert params[0].order == data["params"][0]["order"] assert params[0].value == data["params"][0]["value"] assert params[1].name == data["params"][1]["name"] + assert params[1].param_type is not None assert ParamItemTypes.get_type_name(params[1].param_type) == data["params"][1]["type"] assert params[1].order == data["params"][1]["order"] assert params[1].value == data["params"][1]["value"] assert params[2].name == data["params"][2]["name"] + assert params[2].param_type is not None assert ParamItemTypes.get_type_name(params[2].param_type) == data["params"][2]["type"] assert params[2].order == data["params"][2]["order"] assert params[2].value == data["params"][2]["value"] diff --git a/tests/sentry/api/endpoints/test_organization_fork.py b/tests/sentry/api/endpoints/test_organization_fork.py index 0610fd8fd322..dc0f6ec6f915 100644 --- a/tests/sentry/api/endpoints/test_organization_fork.py +++ b/tests/sentry/api/endpoints/test_organization_fork.py @@ -53,7 +53,7 @@ def test_good_simple( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.superuser, superuser=True) relocation_count = Relocation.objects.count() relocation_file_count = RelocationFile.objects.count() @@ -101,7 +101,7 @@ def test_good_simple_using_organization_id( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.superuser, superuser=True) relocation_count = Relocation.objects.count() relocation_file_count = RelocationFile.objects.count() @@ -148,7 +148,7 @@ def test_good_with_valid_autopause_option( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.superuser, superuser=True) response = self.get_success_response(self.existing_org.slug) @@ -188,7 +188,7 @@ def test_good_with_untriggered_autopause_option( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.superuser, superuser=True) response = self.get_success_response(self.existing_org.slug) @@ -224,7 +224,7 @@ def test_good_staff_when_feature_disabled( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.staff_user, staff=True) relocation_count = Relocation.objects.count() relocation_file_count = RelocationFile.objects.count() @@ -272,7 +272,7 @@ def test_good_superuser_when_feature_disabled( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.superuser, superuser=True) relocation_count = Relocation.objects.count() relocation_file_count = RelocationFile.objects.count() @@ -320,7 +320,7 @@ def test_bad_organization_not_found( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.superuser, superuser=True) relocation_count = Relocation.objects.count() relocation_file_count = RelocationFile.objects.count() @@ -341,7 +341,7 @@ def test_bad_organization_mapping_not_found( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.superuser, superuser=True) relocation_count = Relocation.objects.count() relocation_file_count = RelocationFile.objects.count() @@ -364,7 +364,7 @@ def test_bad_cannot_fork_deleted_organization( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.superuser, superuser=True) self.existing_org.status = OrganizationStatus.DELETION_IN_PROGRESS @@ -400,7 +400,7 @@ def test_bad_organization_in_forbidden_region( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.superuser, superuser=True) relocation_count = Relocation.objects.count() relocation_file_count = RelocationFile.objects.count() @@ -423,7 +423,7 @@ def test_bad_organization_already_in_region( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.superuser, superuser=True) relocation_count = Relocation.objects.count() relocation_file_count = RelocationFile.objects.count() @@ -450,8 +450,8 @@ def test_good_completed_relocation_for_same_organization( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - stat=stat, - ): + stat: Relocation.Status = stat, + ) -> None: self.login_as(user=self.superuser, superuser=True) Relocation.objects.create( creator_id=self.superuser.id, @@ -507,8 +507,8 @@ def test_bad_active_relocation_for_same_organization( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - stat=stat, - ): + stat: Relocation.Status = stat, + ) -> None: self.login_as(user=self.superuser, superuser=True) existing_relocation = Relocation.objects.create( creator_id=self.superuser.id, @@ -536,7 +536,7 @@ def test_good_no_throttle_for_staff( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.staff_user, staff=True) Relocation.objects.create( creator_id=self.superuser.id, @@ -587,7 +587,7 @@ def test_good_no_throttle_for_superuser( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.superuser, superuser=True) Relocation.objects.create( creator_id=self.superuser.id, @@ -637,7 +637,7 @@ def test_bad_without_superuser_or_staff( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.existing_org_owner, superuser=False, staff=False) relocation_count = Relocation.objects.count() relocation_file_count = RelocationFile.objects.count() @@ -654,7 +654,7 @@ def test_bad_superuser_not_active( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: self.login_as(user=self.superuser, superuser=False) relocation_count = Relocation.objects.count() relocation_file_count = RelocationFile.objects.count() @@ -672,7 +672,7 @@ def test_bad_no_auth( self, uploading_start_mock: Mock, analytics_record_mock: Mock, - ): + ) -> None: relocation_count = Relocation.objects.count() relocation_file_count = RelocationFile.objects.count() diff --git a/tests/sentry/api/endpoints/test_organization_insights_tree.py b/tests/sentry/api/endpoints/test_organization_insights_tree.py index 234f3cb93378..9ce89ca31575 100644 --- a/tests/sentry/api/endpoints/test_organization_insights_tree.py +++ b/tests/sentry/api/endpoints/test_organization_insights_tree.py @@ -98,11 +98,11 @@ def test_get_nextjs_function_data(self) -> None: self.url, data={ "statsPeriod": "14d", - "noPagination": True, + "noPagination": "true", "query": "span.op:function.nextjs", "mode": "aggregate", "field": ["span.description", "avg(span.duration)", "count(span.duration)"], - "project": self.project.id, + "project": str(self.project.id), "dataset": "spans", }, ) @@ -172,23 +172,23 @@ class DescriptionParsingTest(TestCase): _endpoint = OrganizationInsightsTreeEndpoint() - def _parse(self, desc): + def _parse(self, desc: str) -> tuple[object, object]: response = type("R", (), {"data": {"data": [{"span.description": desc}]}})() self._endpoint._separate_span_description_info(response) row = response.data["data"][0] return row["function.nextjs.component_type"], row["function.nextjs.path"] - def test_page_simple_route(self): + def test_page_simple_route(self) -> None: assert self._parse("Page Server Component (/dashboard)") == self._parse( 'resolve page server component "/dashboard"' ) - def test_page_nested_route(self): + def test_page_nested_route(self) -> None: assert self._parse("Page Server Component (/nested/route)") == self._parse( 'resolve page server component "/nested/route"' ) - def test_layout_simple_route(self): + def test_layout_simple_route(self) -> None: assert self._parse("Layout Server Component (/settings)") == self._parse( 'resolve layout server component "/settings"' ) diff --git a/tests/sentry/api/endpoints/test_organization_invite_request_details.py b/tests/sentry/api/endpoints/test_organization_invite_request_details.py index e825ea6f71b1..a03313130631 100644 --- a/tests/sentry/api/endpoints/test_organization_invite_request_details.py +++ b/tests/sentry/api/endpoints/test_organization_invite_request_details.py @@ -4,8 +4,10 @@ from sentry import audit_log from sentry.models.auditlogentry import AuditLogEntry from sentry.models.options.organization_option import OrganizationOption +from sentry.models.organization import Organization from sentry.models.organizationmember import InviteStatus, OrganizationMember from sentry.models.organizationmemberteam import OrganizationMemberTeam +from sentry.models.team import Team from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase from sentry.testutils.helpers import Feature @@ -18,23 +20,23 @@ class InviteRequestBase(APITestCase): endpoint = "sentry-api-0-organization-invite-request-detail" @cached_property - def org(self): + def org(self) -> Organization: return self.create_organization(owner=self.user) @cached_property - def team(self): + def team(self) -> Team: return self.create_team(organization=self.org) @cached_property - def member(self): + def member(self) -> OrganizationMember: return self.create_member(organization=self.org, user=self.create_user(), role="member") @cached_property - def manager(self): + def manager(self) -> OrganizationMember: return self.create_member(organization=self.org, user=self.create_user(), role="manager") @cached_property - def invite_request(self): + def invite_request(self) -> OrganizationMember: return self.create_member( email="test@example.com", organization=self.org, @@ -43,7 +45,7 @@ def invite_request(self): ) @cached_property - def request_to_join(self): + def request_to_join(self) -> OrganizationMember: return self.create_member( email="example@gmail.com", organization=self.org, diff --git a/tests/sentry/api/endpoints/test_organization_metrics.py b/tests/sentry/api/endpoints/test_organization_metrics.py index 8ac47387a7f8..516ad94daa2f 100644 --- a/tests/sentry/api/endpoints/test_organization_metrics.py +++ b/tests/sentry/api/endpoints/test_organization_metrics.py @@ -49,9 +49,10 @@ def send_request( self, organization: Organization, token: ApiToken, method: str, endpoint: str, *args: str ) -> HttpResponse: url = reverse(endpoint, args=(organization.slug,) + args) - return getattr(self.client, method)( + response: HttpResponse = getattr(self.client, method)( url, HTTP_AUTHORIZATION=f"Bearer {token.token}", format="json" ) + return response def test_access_with_wrong_permission_scopes(self) -> None: with assume_test_silo_mode(SiloMode.CONTROL): diff --git a/tests/sentry/api/endpoints/test_organization_on_demand_metrics_estimation_stats.py b/tests/sentry/api/endpoints/test_organization_on_demand_metrics_estimation_stats.py index cbf0b354a4c1..161b840f9521 100644 --- a/tests/sentry/api/endpoints/test_organization_on_demand_metrics_estimation_stats.py +++ b/tests/sentry/api/endpoints/test_organization_on_demand_metrics_estimation_stats.py @@ -1,6 +1,6 @@ -from datetime import timedelta +from datetime import datetime, timedelta from random import choice -from typing import cast +from typing import Any, cast import pytest from django.utils import timezone @@ -14,6 +14,7 @@ estimate_stats_quality, estimate_volume, ) +from sentry.services.eventstore.models import Event from sentry.testutils.cases import APITestCase, BaseMetricsLayerTestCase from sentry.testutils.helpers.datetime import freeze_time from sentry.utils.samples import load_data @@ -39,17 +40,17 @@ class OrganizationOnDemandMetricsEstimationStatsEndpointTest(APITestCase, BaseMe method = "GET" @property - def now(self): + def now(self) -> datetime: return MOCK_DATETIME - def _get_id(self, length): + def _get_id(self, length: int) -> str: return "".join([choice("abcde") for i in range(length)]) def create_transaction( self, - start_timestamp, - duration, - ): + start_timestamp: datetime, + duration: int, + ) -> Event: timestamp = start_timestamp + timedelta(milliseconds=duration) trace_id = self._get_id(32) @@ -182,7 +183,7 @@ def test_apdex(self) -> None: assert response.status_code == 200, response.content - def apdex(satisfactory, tolerable): + def apdex(satisfactory: int, tolerable: int) -> float: return (satisfactory + 0.5 * tolerable) / (satisfactory + tolerable) # --- check the results --- @@ -212,7 +213,9 @@ def apdex(satisfactory, tolerable): [[0, 1, 7], [8, 8, 8], [120, 120, 120], [0, 1 * 120 / 8, 7 * 120 / 8]], ], ) -def test_estimate_volume(indexed, base_indexed, metrics, expected) -> None: +def test_estimate_volume( + indexed: list[Any], base_indexed: list[Any], metrics: list[Any], expected: list[float] +) -> None: """ Tests volume estimation calculation """ @@ -256,7 +259,7 @@ def test_should_scale(metric: str, should_scale: bool) -> None: (100, StatsQualityEstimation.NO_INDEXED_DATA), ], ) -def test_estimate_stats_quality(zero_samples, expected_result) -> None: +def test_estimate_stats_quality(zero_samples: int, expected_result: StatsQualityEstimation) -> None: num_samples = 100 start_timestamp = 1_500_000_000 @@ -274,7 +277,7 @@ def test_estimate_stats_quality(zero_samples, expected_result) -> None: @pytest.mark.parametrize("zero_samples", [0, 1, 5, 9, 10]) -def test_count_non_zero_intervals(zero_samples) -> None: +def test_count_non_zero_intervals(zero_samples: int) -> None: num_samples = 10 start_timestamp = 1_500_000_000 diff --git a/tests/sentry/api/endpoints/test_organization_pipeline.py b/tests/sentry/api/endpoints/test_organization_pipeline.py index f38bad24e578..ffdf0de8c4ea 100644 --- a/tests/sentry/api/endpoints/test_organization_pipeline.py +++ b/tests/sentry/api/endpoints/test_organization_pipeline.py @@ -126,7 +126,7 @@ def finish_pipeline(self) -> HttpResponseBase: return HttpResponse("done") -class InitialDataSerializer(CamelSnakeSerializer): +class InitialDataSerializer(CamelSnakeSerializer[dict[str, object]]): external_id = CharField(required=False) @@ -191,8 +191,10 @@ def _get_pipeline_url(self, pipeline_name: str | None = None) -> str: ) def _init_pipeline_in_session( - self, pipeline_cls: type[Pipeline], provider_key: str = "dummy" - ) -> Pipeline: + self, + pipeline_cls: type[Pipeline[Never, PipelineSessionStore]], + provider_key: str = "dummy", + ) -> Pipeline[Never, PipelineSessionStore]: """Create and initialize a pipeline, storing it in the test client's session.""" with assume_test_silo_mode(SiloMode.CELL): rpc_org = serialize_rpc_organization(self.organization) diff --git a/tests/sentry/api/endpoints/test_organization_profiling_profiles.py b/tests/sentry/api/endpoints/test_organization_profiling_profiles.py index 3cb1647c04b2..fdcb5e0396f2 100644 --- a/tests/sentry/api/endpoints/test_organization_profiling_profiles.py +++ b/tests/sentry/api/endpoints/test_organization_profiling_profiles.py @@ -1,4 +1,5 @@ from datetime import UTC, datetime, timedelta +from typing import Any from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -8,6 +9,7 @@ from snuba_sdk import And, Column, Condition, Function, Op, Or from sentry.models.profilechunkattachment import ProfileChunkAttachment +from sentry.models.project import Project from sentry.profiles.flamegraph import FlamegraphExecutor from sentry.profiles.utils import proxy_profiling_service from sentry.snuba.dataset import Dataset @@ -29,7 +31,13 @@ def setUp(self) -> None: self.ten_mins_ago = before_now(minutes=10) self.hour_ago = before_now(hours=1).replace(minute=0, second=0, microsecond=0) - def do_request(self, query, features=None, compat=True, **kwargs): + def do_request( + self, + query: dict[str, Any], + features: dict[str, bool] | list[str] | None = None, + compat: bool = True, + **kwargs: Any, + ) -> Any: if features is None: features = self.features with self.feature(features): @@ -44,13 +52,13 @@ def do_request(self, query, features=None, compat=True, **kwargs): def store_transaction( self, - transaction=None, - profile_id=None, - profiler_id=None, - thread_id=None, - transaction_id=None, - project=None, - ): + transaction: str | None = None, + profile_id: str | None = None, + profiler_id: str | None = None, + thread_id: str | None = None, + transaction_id: str | None = None, + project: Project | None = None, + ) -> Any: data = load_data("transaction", timestamp=self.ten_mins_ago) if transaction is not None: @@ -314,10 +322,10 @@ def test_queries_profile_candidates_from_profiles(self) -> None: @patch("sentry.api.endpoints.organization_profiling_profiles.proxy_profiling_service") def test_queries_profile_candidates_from_functions_with_data( self, - mock_proxy_profiling_service, - mock_raw_snql_query, - mock_bulk_snuba_queries, - ): + mock_proxy_profiling_service: MagicMock, + mock_raw_snql_query: MagicMock, + mock_bulk_snuba_queries: MagicMock, + ) -> None: data = load_data("transaction", timestamp=self.hour_ago) data["transaction"] = "foo" profile_id = uuid4().hex @@ -418,10 +426,10 @@ def test_queries_profile_candidates_from_functions_with_data( @patch("sentry.api.endpoints.organization_profiling_profiles.proxy_profiling_service") def test_queries_profile_candidates_from_transactions_with_data( self, - mock_proxy_profiling_service, - mock_raw_snql_query, - mock_query_chunks_for_profilers, - ): + mock_proxy_profiling_service: MagicMock, + mock_raw_snql_query: MagicMock, + mock_query_chunks_for_profilers: MagicMock, + ) -> None: # this transaction has no transaction profile or continuous profile self.store_transaction(transaction="foo", project=self.project) @@ -533,7 +541,7 @@ def test_queries_profile_candidates_from_transactions_with_data( def test_queries_profile_candidates_from_profiles_with_continuous_profiles( self, - ): + ) -> None: profile_id = uuid4().hex span_with_transaction_profile = self.create_span( project=self.project, start_ts=self.ten_mins_ago, duration=1000 @@ -627,9 +635,9 @@ def test_queries_profile_candidates_from_profiles_with_continuous_profiles( @patch("sentry.api.endpoints.organization_profiling_profiles.proxy_profiling_service") def test_queries_profile_candidates_from_spans( self, - mock_proxy_profiling_service, - mock_bulk_snuba_queries, - ): + mock_proxy_profiling_service: MagicMock, + mock_bulk_snuba_queries: MagicMock, + ) -> None: # this span has a transaction profile profile_id = uuid4().hex @@ -755,7 +763,7 @@ def test_rejects_invalid_profiler_id(self, mock_get_chunk_ids: MagicMock) -> Non with self.feature(self.features): response = self.client.get( self.url, - {"project": [self.project.id], "profiler_id": "not-a-uuid"}, + {"project": [str(self.project.id)], "profiler_id": "not-a-uuid"}, ) assert response.status_code == 400 @@ -782,7 +790,7 @@ def test_proxies_to_profiling_service( self.client.get( self.url, { - "project": [self.project.id], + "project": [str(self.project.id)], "profiler_id": profiler_id, "statsPeriod": "1d", }, @@ -815,8 +823,11 @@ def setUp(self) -> None: self.profiler_id = uuid4().hex def create_attachment( - self, chunk_id, name="trace.perfetto", content_type="application/x-perfetto" - ): + self, + chunk_id: str, + name: str = "trace.perfetto", + content_type: str = "application/x-perfetto", + ) -> ProfileChunkAttachment: return ProfileChunkAttachment.objects.create( project_id=self.project.id, profiler_id=self.profiler_id, @@ -852,7 +863,7 @@ def test_rejects_invalid_profiler_id(self, mock_get_chunk_ids: MagicMock) -> Non with self.feature(self.features): response = self.client.get( self.url, - {"project": [self.project.id], "profiler_id": "not-a-uuid"}, + {"project": [str(self.project.id)], "profiler_id": "not-a-uuid"}, ) assert response.status_code == 400 @@ -873,7 +884,7 @@ def test_lists_attachments_for_resolved_chunks(self, mock_raw_snql_query: MagicM response = self.client.get( self.url, { - "project": [self.project.id], + "project": [str(self.project.id)], "profiler_id": self.profiler_id, "statsPeriod": "1d", }, @@ -905,7 +916,7 @@ def test_does_not_leak_other_project_attachments(self, mock_raw_snql_query: Magi response = self.client.get( self.url, { - "project": [self.project.id], + "project": [str(self.project.id)], "profiler_id": self.profiler_id, "statsPeriod": "1d", }, @@ -923,7 +934,7 @@ def test_chunk_id_param_short_circuits_snuba(self, mock_raw_snql_query: MagicMoc response = self.client.get( self.url, { - "project": [self.project.id], + "project": [str(self.project.id)], "profiler_id": self.profiler_id, "chunk_id": chunk_id, "statsPeriod": "1d", diff --git a/tests/sentry/api/endpoints/test_organization_project_keys.py b/tests/sentry/api/endpoints/test_organization_project_keys.py index c4e9d8824d6c..cc82ba1d5484 100644 --- a/tests/sentry/api/endpoints/test_organization_project_keys.py +++ b/tests/sentry/api/endpoints/test_organization_project_keys.py @@ -1,3 +1,6 @@ +from rest_framework.response import Response + +from sentry.models.project import Project from sentry.models.projectkey import ProjectKey, ProjectKeyStatus from sentry.testutils.cases import APITestCase @@ -11,10 +14,10 @@ def setUp(self) -> None: self.organization = self.create_organization(owner=self.user) self.login_as(user=self.user) - def _get_public_keys(self, response): + def _get_public_keys(self, response: Response) -> set[str]: return {item["public"] for item in response.data} - def _create_key(self, project): + def _create_key(self, project: Project) -> ProjectKey: return ProjectKey.objects.get_or_create(project=project)[0] def test_retrieving_all_keys(self) -> None: diff --git a/tests/sentry/api/endpoints/test_organization_recent_searches.py b/tests/sentry/api/endpoints/test_organization_recent_searches.py index a6df6e361290..c369ab6c06af 100644 --- a/tests/sentry/api/endpoints/test_organization_recent_searches.py +++ b/tests/sentry/api/endpoints/test_organization_recent_searches.py @@ -7,6 +7,7 @@ from django.utils import timezone from sentry.api.serializers import serialize +from sentry.models.organization import Organization from sentry.models.recentsearch import RecentSearch from sentry.models.search_common import SearchType from sentry.silo.base import SiloMode @@ -15,16 +16,19 @@ from sentry.testutils.helpers.features import with_feature from sentry.testutils.helpers.roles import granular_roles_granting from sentry.testutils.silo import assume_test_silo_mode +from sentry.users.models.user import User class RecentSearchesListTest(APITestCase): endpoint = "sentry-api-0-organization-recent-searches" @cached_property - def user(self): + def user(self) -> User: return self.create_user("test@test.com") - def check_results(self, expected, search_type, query=None): + def check_results( + self, expected: list[RecentSearch], search_type: SearchType, query: str | None = None + ) -> None: self.login_as(user=self.user) kwargs = {} if query: @@ -195,11 +199,11 @@ class RecentSearchesCreateTest(APITestCase): method = "post" @cached_property - def organization(self): + def organization(self) -> Organization: return self.create_organization() @cached_property - def user(self): + def user(self) -> User: user = self.create_user("test@test.com") self.create_team(members=[user], organization=self.organization) return user diff --git a/tests/sentry/api/endpoints/test_organization_relay_usage.py b/tests/sentry/api/endpoints/test_organization_relay_usage.py index 553ee739c741..c55ac4f6bafc 100644 --- a/tests/sentry/api/endpoints/test_organization_relay_usage.py +++ b/tests/sentry/api/endpoints/test_organization_relay_usage.py @@ -1,27 +1,37 @@ from datetime import datetime, timezone from functools import cached_property +from typing import TypedDict from django.urls import reverse from sentry.models.relay import RelayUsage from sentry.testutils.cases import APITestCase +from sentry.users.models.user import User + + +class RelayUsageData(TypedDict): + relay_id: str + public_key: str + version: str + first_seen: datetime + last_seen: datetime class OrganizationRelayHistoryTest(APITestCase): endpoint = "sentry-api-0-organization-relay-usage" @cached_property - def user(self): + def user(self) -> User: return self.create_user("test@test.com") - def _public_keys(self): + def _public_keys(self) -> list[str]: return [ "nDJl79SbEYH9-8NEJAI7ezrgYfolPW3Bnkg00k1zOfA", "AitWAgB-oHFywmKnUMRKMXcrsyPkESV-5gR-vsMqXgQ", "SMSesqan65THCV6M4qs4kBzPai60LzuDn-xNsvYpuP8", ] - def _history_fixture(self): + def _history_fixture(self) -> list[RelayUsageData]: pks = self._public_keys() return [ { @@ -58,7 +68,7 @@ def setUp(self) -> None: for relay_data in self._history_fixture(): RelayUsage.objects.create(**relay_data) - def _set_org_public_keys(self, public_keys): + def _set_org_public_keys(self, public_keys: list[str]) -> None: self.login_as(user=self.user) url = reverse("sentry-api-0-organization-details", args=[self.organization.slug]) diff --git a/tests/sentry/api/endpoints/test_organization_releases.py b/tests/sentry/api/endpoints/test_organization_releases.py index 993eadfd0dd1..6def0388d49c 100644 --- a/tests/sentry/api/endpoints/test_organization_releases.py +++ b/tests/sentry/api/endpoints/test_organization_releases.py @@ -6,6 +6,7 @@ import pytest from django.urls import reverse from django.utils import timezone +from rest_framework.response import Response from sentry.api.bases.organization import OrganizationReleasesBaseEndpoint from sentry.api.endpoints.organization_releases import ( @@ -26,6 +27,7 @@ from sentry.models.commitfilechange import CommitFileChange from sentry.models.environment import Environment from sentry.models.orgauthtoken import OrgAuthToken +from sentry.models.project import Project from sentry.models.release import Release from sentry.models.releasecommit import ReleaseCommit from sentry.models.releaseheadcommit import ReleaseHeadCommit @@ -60,7 +62,7 @@ class OrganizationReleaseListTest(APITestCase, BaseMetricsTestCase): endpoint = "sentry-api-0-organization-releases" - def assert_expected_versions(self, response, expected): + def assert_expected_versions(self, response: Response, expected: list[Release]) -> None: assert [item["version"] for item in response.data] == [e.version for e in expected] def test_simple(self) -> None: @@ -2408,7 +2410,11 @@ def test_simple(self, mock_fetch_commits: MagicMock) -> None: self.assert_commit(commits[2], "current-commit-id-3") head_commits = ReleaseHeadCommit.objects.all() - self.assert_head_commit(head_commits[0], "current-commit-id-3", release_id=release.id) + self.assert_head_commit( + head_commits[0], + "current-commit-id-3", + release_id=release.id, + ) refs_expected = [ { @@ -2459,7 +2465,11 @@ def test_simple_with_project_id(self, mock_fetch_commits: MagicMock) -> None: self.assert_commit(commits[2], "current-commit-id-3") head_commits = ReleaseHeadCommit.objects.all() - self.assert_head_commit(head_commits[0], "current-commit-id-3", release_id=release.id) + self.assert_head_commit( + head_commits[0], + "current-commit-id-3", + release_id=release.id, + ) refs_expected = [ { @@ -2515,7 +2525,11 @@ def test_head_commit(self, mock_fetch_commits: MagicMock) -> None: self.assert_commit(commits[2], "current-commit-id-3") head_commits = ReleaseHeadCommit.objects.all() - self.assert_head_commit(head_commits[0], "current-commit-id-3", release_id=release.id) + self.assert_head_commit( + head_commits[0], + "current-commit-id-3", + release_id=release.id, + ) refs_expected = [ { @@ -2607,12 +2621,12 @@ def setUp(self) -> None: self.env2 = env2 self.org = org - def make_environment(self, name, project): + def make_environment(self, name: str, project: Project) -> Environment: env = Environment.objects.create(organization_id=project.organization_id, name=name) env.add_project(project) return env - def assert_releases(self, response, releases): + def assert_releases(self, response: Response, releases: list[Release]) -> None: assert len(response.data) == len(releases) response_versions = sorted(r["version"] for r in response.data) @@ -2719,7 +2733,7 @@ def test_invalid_date_range(self) -> None: class OrganizationReleaseCreateCommitPatch(ReleaseCommitPatchTest): @cached_property - def url(self): + def url(self) -> str: return reverse( "sentry-api-0-organization-releases", kwargs={"organization_id_or_slug": self.org.slug} ) @@ -3160,7 +3174,7 @@ def test_api_token_same_organization_returns_projects(self) -> None: class DebounceUpdateReleaseHealthDataTest(TestCase): - def run_with_health_data(self, project, version="1.0"): + def run_with_health_data(self, project: Project, version: str = "1.0") -> None: with patch( "sentry.api.endpoints.organization_releases.release_health" ) as mock_release_health: diff --git a/tests/sentry/api/endpoints/test_organization_sampling_project_span_counts.py b/tests/sentry/api/endpoints/test_organization_sampling_project_span_counts.py index bf4a1982d7f5..334ae13d458e 100644 --- a/tests/sentry/api/endpoints/test_organization_sampling_project_span_counts.py +++ b/tests/sentry/api/endpoints/test_organization_sampling_project_span_counts.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from typing import Any +from typing import Any, cast from sentry.models.project import Project from sentry.testutils.cases import APITestCase, SnubaTestCase, SpanTestCase @@ -17,7 +17,7 @@ def setUp(self) -> None: self.root = self.create_project(organization=self.organization, teams=[self.team]) self.other = self.create_project(organization=self.organization, teams=[self.team]) - def store_span( + def store_root_span( self, project: Project, root: Project | None = None, @@ -50,7 +50,8 @@ def get_counts(self, **params: str) -> dict[str, Any]: response = self.get_success_response( self.organization.slug, qs_params={"statsPeriod": "24h", **params} ) - return response.data + # justification: the sampling counts endpoint serializes a JSON object. + return cast("dict[str, Any]", response.data) @staticmethod def row(root: Project, project: Project, totals: int) -> dict[str, Any]: @@ -71,11 +72,11 @@ def test_without_permission(self) -> None: ) def test_counts_received_spans_by_root_and_owning_project(self) -> None: - self.store_span(self.root, root=self.root) + self.store_root_span(self.root, root=self.root) # Sampled at 1/2, so it stands for 2 received spans. - self.store_span(self.root, root=self.root, sample_rate=0.5) - self.store_span(self.other, root=self.root) - self.store_span(self.other, root=self.other) + self.store_root_span(self.root, root=self.root, sample_rate=0.5) + self.store_root_span(self.other, root=self.root) + self.store_root_span(self.other, root=self.other) data = self.get_counts() @@ -89,8 +90,8 @@ def test_counts_received_spans_by_root_and_owning_project(self) -> None: assert data["end"] - data["start"] == timedelta(days=1) def test_span_without_dsc_project_counts_under_its_own_project(self) -> None: - self.store_span(self.other) - self.store_span(self.other, root=self.other) + self.store_root_span(self.other) + self.store_root_span(self.other, root=self.other) data = self.get_counts() @@ -98,16 +99,16 @@ def test_span_without_dsc_project_counts_under_its_own_project(self) -> None: def test_ignores_traces_rooted_outside_the_organization(self) -> None: foreign_root = self.create_project(organization=self.create_organization()) - self.store_span(self.other, root=foreign_root) - self.store_span(self.other, root=self.other) + self.store_root_span(self.other, root=foreign_root) + self.store_root_span(self.other, root=self.other) data = self.get_counts() assert data["data"] == [[self.row(self.other, self.other, 1)]] def test_stats_period_selects_spans_by_time(self) -> None: - self.store_span(self.root, root=self.root, start_ts=before_now(days=5)) - self.store_span(self.root, root=self.root) + self.store_root_span(self.root, root=self.root, start_ts=before_now(days=5)) + self.store_root_span(self.root, root=self.root) assert self.get_counts(statsPeriod="24h")["data"] == [[self.row(self.root, self.root, 1)]] @@ -117,8 +118,8 @@ def test_stats_period_selects_spans_by_time(self) -> None: def test_environment_filters_spans(self) -> None: self.create_environment(project=self.root, name="prod") - self.store_span(self.root, root=self.root, environment="prod") - self.store_span(self.root, root=self.root, environment="dev") + self.store_root_span(self.root, root=self.root, environment="prod") + self.store_root_span(self.root, root=self.root, environment="dev") data = self.get_counts(environment="prod") diff --git a/tests/sentry/api/endpoints/test_organization_traces.py b/tests/sentry/api/endpoints/test_organization_traces.py index a3ba9a1b4fde..e61bb58f3845 100644 --- a/tests/sentry/api/endpoints/test_organization_traces.py +++ b/tests/sentry/api/endpoints/test_organization_traces.py @@ -1,10 +1,13 @@ -from datetime import timedelta +from collections.abc import Sequence +from datetime import datetime, timedelta +from typing import Any from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest from django.urls import reverse from rest_framework.exceptions import ErrorDetail +from rest_framework.response import Response from sentry_protos.snuba.v1.endpoint_get_traces_pb2 import ( GetTracesResponse, TraceAttribute, @@ -12,6 +15,7 @@ from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey, AttributeValue from sentry.api.endpoints.organization_traces import process_breakdowns +from sentry.models.project import Project from sentry.snuba.referrer import Referrer from sentry.testutils.cases import APITestCase, BaseSpansTestCase from sentry.testutils.helpers import parse_link_header @@ -35,14 +39,14 @@ def setUp(self) -> None: def double_write_segment( self, *, - project, - trace_id, - transaction_id, - span_id, - timestamp, - duration, - **kwargs, - ): + project: Project, + trace_id: str, + transaction_id: str, + span_id: str, + timestamp: datetime, + duration: int, + **kwargs: Any, + ) -> None: kwargs.setdefault("measurements", {}) if "lcp" not in kwargs["measurements"]: kwargs["measurements"]["lcp"] = duration @@ -60,6 +64,7 @@ def double_write_segment( spans=[], event_id=transaction_id, ) + assert data is not None for measurement, value in kwargs.get("measurements", {}).items(): data["measurements"][measurement] = {"value": value} @@ -87,7 +92,9 @@ def double_write_segment( **kwargs, ) - def create_mock_traces(self): + def create_mock_traces( + self, + ) -> tuple[Project, Project, str, str, str, list[int], list[str]]: project_1 = self.create_project() project_2 = self.create_project() @@ -246,6 +253,7 @@ def create_mock_traces(self): ) error_data = load_data("javascript", timestamp=timestamps[0]) + assert error_data is not None error_data["contexts"]["trace"] = { "type": "trace", "trace_id": trace_id_1, @@ -264,7 +272,9 @@ def create_mock_traces(self): span_ids, ) - def do_request(self, query, features=None, **kwargs): + def do_request( + self, query: dict[str, Any], features: Sequence[str] | None = None, **kwargs: Any + ) -> Response: if features is None: features = [ "organizations:visibility-explore-view", @@ -273,7 +283,7 @@ def do_request(self, query, features=None, **kwargs): query["dataset"] = "spans" with self.feature(features): - return self.client.get( + response: Response[Any] = self.client.get( reverse( self.view, kwargs={"organization_id_or_slug": self.organization.slug}, @@ -282,6 +292,7 @@ def do_request(self, query, features=None, **kwargs): format="json", **kwargs, ) + return response def test_no_feature(self) -> None: response = self.do_request({}, features=[]) @@ -1046,7 +1057,7 @@ def test_sort_by_timestamp(self) -> None: span_ids, ) = self.create_mock_traces() - expected = [ + expected: list[dict[str, Any]] = [ { "trace": trace_id_1, "numErrors": 1, @@ -2454,7 +2465,9 @@ def test_span_name_as_name(self) -> None: ), ], ) -def test_process_breakdowns(data, traces_range, expected) -> None: +def test_process_breakdowns( + data: list[dict[str, Any]], traces_range: dict[str, Any], expected: object +) -> None: traces_range = { trace: { "start": trace_start, diff --git a/tests/sentry/api/endpoints/test_project_artifact_bundle_file_details.py b/tests/sentry/api/endpoints/test_project_artifact_bundle_file_details.py index 91db76d51c84..7f0fee8a8c88 100644 --- a/tests/sentry/api/endpoints/test_project_artifact_bundle_file_details.py +++ b/tests/sentry/api/endpoints/test_project_artifact_bundle_file_details.py @@ -1,6 +1,7 @@ import base64 import io import zipfile +from typing import Any from uuid import uuid4 import orjson @@ -15,8 +16,10 @@ class ProjectArtifactBundleFileDetailsEndpointTest(APITestCase): @staticmethod - def get_compressed_zip_file(artifact_name, files, type="artifact.bundle"): - def remove_and_return(dictionary, key): + def get_compressed_zip_file( + artifact_name: str, files: dict[str, Any], type: str = "artifact.bundle" + ) -> File: + def remove_and_return(dictionary: dict[str, Any], key: str) -> dict[str, Any]: dictionary.pop(key) return dictionary diff --git a/tests/sentry/api/endpoints/test_project_artifact_lookup.py b/tests/sentry/api/endpoints/test_project_artifact_lookup.py index 5a6149aae910..b09c945b0b93 100644 --- a/tests/sentry/api/endpoints/test_project_artifact_lookup.py +++ b/tests/sentry/api/endpoints/test_project_artifact_lookup.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone from hashlib import sha1 from io import BytesIO -from typing import Any +from typing import Any, cast from unittest import mock from uuid import uuid4 @@ -22,9 +22,15 @@ ReleaseArtifactBundle, SourceFileType, ) +from sentry.models.distribution import Distribution from sentry.models.files.file import File from sentry.models.files.fileblob import FileBlob -from sentry.models.releasefile import ReleaseFile, read_artifact_index, update_artifact_index +from sentry.models.project import Project +from sentry.models.releasefile import ( + ReleaseFile, + read_artifact_index, + update_artifact_index, +) from sentry.tasks.assemble import assemble_artifacts from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.datetime import freeze_time @@ -32,14 +38,19 @@ from sentry.testutils.helpers.response import close_streaming_response -def make_file(artifact_name, content, type="artifact.bundle", headers=None): +def make_file( + artifact_name: str, + content: bytes, + type: str = "artifact.bundle", + headers: dict[str, str] | None = None, +) -> File: file = File.objects.create(name=artifact_name, type=type, headers=(headers or {})) file.putfile(BytesIO(content)) return file -def make_compressed_zip_file(files): - def remove_and_return(dictionary, key): +def make_compressed_zip_file(files: dict[str, dict[str, Any]]) -> bytes: + def remove_and_return(dictionary: dict[str, Any], key: str) -> dict[str, Any]: dictionary.pop(key) return dictionary @@ -65,14 +76,16 @@ def remove_and_return(dictionary, key): return compressed.getvalue() -def upload_bundle(bundle_file, project, release=None, dist=None): +def upload_bundle( + bundle_file: bytes, project: Project, release: str | None = None, dist: str | None = None +) -> None: blob1 = FileBlob.from_file_with_organization(ContentFile(bundle_file), project.organization) total_checksum = sha1(bundle_file).hexdigest() return assemble_artifacts( org_id=project.organization.id, project_ids=[project.id], - version=release, + version=cast(str, release), # justification: assemble_artifacts treats None as no release dist=dist, checksum=total_checksum, chunks=[blob1.checksum], @@ -86,7 +99,12 @@ def assert_download_matches_file(self, url: str, file_contents: bytes) -> None: for chunk in response: assert file.read(len(chunk)) == chunk - def create_archive(self, fields, files, dist=None): + def create_archive( + self, + fields: dict[str, Any], + files: dict[str, Any], + dist: Distribution | None = None, + ) -> tuple[ReleaseFile, bytes]: manifest = dict( fields, files={filename: {"url": f"fake://{filename}"} for filename in files} ) @@ -102,7 +120,10 @@ def create_archive(self, fields, files, dist=None): file_.putfile(buffer) file_.update(timestamp=datetime(2021, 6, 11, 9, 13, 1, 317902, tzinfo=timezone.utc)) - return (update_artifact_index(self.release, dist, file_), buffer.getvalue()) + return ( + cast(ReleaseFile, update_artifact_index(self.release, dist, file_)), + buffer.getvalue(), + ) def lookup_with_legacy_calls(self, query: str) -> tuple[list[dict[str, Any]], int]: """ diff --git a/tests/sentry/api/endpoints/test_project_profiling_profile.py b/tests/sentry/api/endpoints/test_project_profiling_profile.py index c579ac1ed891..443f087b330b 100644 --- a/tests/sentry/api/endpoints/test_project_profiling_profile.py +++ b/tests/sentry/api/endpoints/test_project_profiling_profile.py @@ -40,7 +40,7 @@ def setUp(self) -> None: stored_id=uuid4().hex, ) - def get_url(self, attachment_id=None): + def get_url(self, attachment_id: int | None = None) -> str: return reverse( self.endpoint, args=( diff --git a/tests/sentry/api/endpoints/test_project_repo_path_parsing.py b/tests/sentry/api/endpoints/test_project_repo_path_parsing.py index 4d98846094ae..6ebe40c9faad 100644 --- a/tests/sentry/api/endpoints/test_project_repo_path_parsing.py +++ b/tests/sentry/api/endpoints/test_project_repo_path_parsing.py @@ -1,3 +1,5 @@ +from typing import Any + from django.urls import reverse from rest_framework.response import Response @@ -43,7 +45,7 @@ def make_post( }, ) - return self.client.post( + response: Response[Any] = self.client.post( url, data={ "sourceUrl": source_url, @@ -53,6 +55,7 @@ def make_post( "platform": platform, }, ) + return response class PathMappingSerializerTest(TestCase): diff --git a/tests/sentry/api/endpoints/test_prompts_activity.py b/tests/sentry/api/endpoints/test_prompts_activity.py index b0f7a25318ac..82cd9d76fbc4 100644 --- a/tests/sentry/api/endpoints/test_prompts_activity.py +++ b/tests/sentry/api/endpoints/test_prompts_activity.py @@ -1,4 +1,5 @@ from django.urls import reverse +from rest_framework.response import Response from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase @@ -133,8 +134,8 @@ def test_batched_invalid_feature(self) -> None: def test_invalid_project(self) -> None: # Invalid project id - data = { - "project_id": self.project.id, + data: dict[str, str | list[str]] = { + "project_id": str(self.project.id), "feature": "releases", } resp = self.client.get(self.path, data) @@ -155,8 +156,8 @@ def test_invalid_project(self) -> None: assert resp.data["detail"] == "Project does not belong to this organization" def test_dismiss(self) -> None: - data = { - "project_id": self.project.id, + data: dict[str, str | list[str]] = { + "project_id": str(self.project.id), "feature": "releases", } resp = self.client.get(self.path, data) @@ -191,8 +192,8 @@ def test_dismiss_str_id(self) -> None: ) assert resp.status_code == 201, resp.content - data = { - "project_id": self.project.id, + data: dict[str, str | list[str]] = { + "project_id": str(self.project.id), "feature": "releases", } resp = self.client.get(self.path, data) @@ -202,8 +203,8 @@ def test_dismiss_str_id(self) -> None: assert "dismissed_ts" in resp.data["data"] def test_snooze(self) -> None: - data = { - "project_id": self.project.id, + data: dict[str, str | list[str]] = { + "project_id": str(self.project.id), "feature": "releases", } resp = self.client.get(self.path, data) @@ -227,8 +228,8 @@ def test_snooze(self) -> None: assert "snoozed_ts" in resp.data["data"] def test_visible(self) -> None: - data = { - "project_id": self.project.id, + data: dict[str, str | list[str]] = { + "project_id": str(self.project.id), "feature": "releases", } resp = self.client.get(self.path, data) @@ -252,8 +253,8 @@ def test_visible(self) -> None: assert resp.data["data"].get("snoozed_ts") is None def test_visible_after_dismiss(self) -> None: - data = { - "project_id": self.project.id, + data: dict[str, str | list[str]] = { + "project_id": str(self.project.id), "feature": "releases", } resp = self.client.get(self.path, data) @@ -287,8 +288,8 @@ def test_visible_after_dismiss(self) -> None: assert resp.data["data"].get("snoozed_ts") is None def test_batched(self) -> None: - data = { - "project_id": self.project.id, + data: dict[str, str | list[str]] = { + "project_id": str(self.project.id), "feature": ["releases", "alert_stream"], } resp = self.client.get(self.path, data) @@ -368,8 +369,8 @@ def _use_token(self, scope_list: list[str]) -> None: token = self.create_user_auth_token(user=self.user, scope_list=scope_list) self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.token}") - def dismiss(self): - return self.client.put( + def dismiss(self) -> Response: + response: Response = self.client.put( self.path, { "organization_id": self.org.id, @@ -378,10 +379,13 @@ def dismiss(self): "status": "dismissed", }, ) + return response def test_read_scope_can_get(self) -> None: self._use_token(["user_preference:read"]) - resp = self.client.get(self.path, {"project_id": self.project.id, "feature": "releases"}) + resp = self.client.get( + self.path, {"project_id": str(self.project.id), "feature": "releases"} + ) assert resp.status_code == 200, resp.content def test_read_scope_cannot_dismiss(self) -> None: @@ -396,5 +400,7 @@ def test_write_scope_can_dismiss(self) -> None: def test_write_scope_cannot_get(self) -> None: # Write does not imply read. self._use_token(["user_preference:write"]) - resp = self.client.get(self.path, {"project_id": self.project.id, "feature": "releases"}) + resp = self.client.get( + self.path, {"project_id": str(self.project.id), "feature": "releases"} + ) assert resp.status_code == 403 diff --git a/tests/sentry/api/endpoints/test_relay_globalconfig_v3.py b/tests/sentry/api/endpoints/test_relay_globalconfig_v3.py index 53786bca5c69..b0d805133186 100644 --- a/tests/sentry/api/endpoints/test_relay_globalconfig_v3.py +++ b/tests/sentry/api/endpoints/test_relay_globalconfig_v3.py @@ -1,10 +1,13 @@ +from collections.abc import Callable from copy import deepcopy +from typing import Any from unittest.mock import patch import orjson import pytest from django.test import override_settings from django.urls import reverse +from sentry_relay.auth import SecretKey from sentry_relay.processing import normalize_global_config from sentry.relay.globalconfig import get_global_config @@ -13,8 +16,8 @@ @pytest.fixture -def call_endpoint(client, relay, private_key): - def inner(version, global_): +def call_endpoint(client: Any, relay: Any, private_key: SecretKey) -> Any: + def inner(version: str, global_: Any) -> Any: path = reverse("sentry-api-0-relay-projectconfigs") + f"?version={version}" body = {"global": True} if global_ else {} @@ -61,7 +64,7 @@ def inner(version, global_): "relay.upload-max-chunk.size": 123, } ) -def test_global_config(inbound_filters_custom_error_values) -> None: +def test_global_config(inbound_filters_custom_error_values: list[str]) -> None: # Nest the settings override inside `override_options`: the `settings` fixture # is torn down after the decorator exits, which leaks the options into later tests. with override_settings(SENTRY_INBOUND_FILTER_CUSTOM_VALUES=inbound_filters_custom_error_values): @@ -105,11 +108,11 @@ def _lowercase_glob_values(node: object) -> None: ) @django_db_all def test_return_global_config_on_right_version( - call_endpoint, - version, - request_global_config, - expect_global_config, -): + call_endpoint: Callable[..., tuple[dict[str, Any], int]], + version: int, + request_global_config: bool, + expect_global_config: bool, +) -> None: result, status_code = call_endpoint(version, request_global_config) assert status_code < 400 if not expect_global_config: diff --git a/tests/sentry/api/endpoints/test_relay_projectconfigs.py b/tests/sentry/api/endpoints/test_relay_projectconfigs.py index b515dee3337e..9f835f431e4f 100644 --- a/tests/sentry/api/endpoints/test_relay_projectconfigs.py +++ b/tests/sentry/api/endpoints/test_relay_projectconfigs.py @@ -1,18 +1,23 @@ from __future__ import annotations import re -from typing import Any -from unittest.mock import patch +from collections.abc import Callable, Iterator, Mapping +from contextlib import AbstractContextManager +from typing import Any, Protocol, cast +from unittest.mock import MagicMock, patch from uuid import uuid4 import orjson import pytest +from django.test import Client from django.urls import reverse -from sentry_relay.auth import generate_key_pair +from sentry_relay.auth import PublicKey, SecretKey, generate_key_pair from sentry import quotas from sentry.constants import DataCategory, ObjectStatus +from sentry.models.organization import Organization from sentry.models.project import Project +from sentry.models.projectkey import ProjectKey from sentry.models.relay import Relay from sentry.quotas.base import RETENTIONS_CONFIG_MAPPING, RetentionSettings from sentry.testutils.helpers import Feature @@ -22,7 +27,7 @@ _date_regex = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z$") -def _get_all_keys(config): +def _get_all_keys(config: Mapping[str, object]) -> Iterator[str]: for key in config: yield key if key == "breakdownsV2": @@ -30,49 +35,56 @@ def _get_all_keys(config): # e.g. span_ops continue if isinstance(config[key], dict): - for key in _get_all_keys(config[key]): - yield key + # justification: values come from decoded JSON and have no fixed key type. + yield from _get_all_keys(cast(Mapping[str, object], config[key])) -def assert_no_snakecase_key(config): +def assert_no_snakecase_key(config: Mapping[str, object]) -> None: assert not {x for x in _get_all_keys(config) if "-" in x or "_" in x} @pytest.fixture -def key_pair(): +def key_pair() -> tuple[SecretKey, PublicKey]: return generate_key_pair() @pytest.fixture -def public_key(key_pair): +def public_key(key_pair: tuple[SecretKey, PublicKey]) -> PublicKey: return key_pair[1] @pytest.fixture -def private_key(key_pair): +def private_key(key_pair: tuple[SecretKey, PublicKey]) -> SecretKey: return key_pair[0] @pytest.fixture -def relay_id(): +def relay_id() -> str: return str(uuid4()) @pytest.fixture -def relay(relay_id, public_key): +def relay(relay_id: str, public_key: PublicKey) -> Relay: return Relay.objects.create(relay_id=relay_id, public_key=str(public_key), is_internal=True) @pytest.fixture(autouse=True) -def setup_relay(default_project): +def setup_relay(default_project: Project) -> None: default_project.update_option("sentry:scrub_ip_address", True) +class CallEndpoint(Protocol): + def __call__(self, projects: list[int | str] | None = None) -> tuple[dict[str, Any], int]: ... + + @pytest.fixture -def call_endpoint(client, relay, private_key, default_project): +def call_endpoint( + client: Client, relay: Relay, private_key: SecretKey, default_project: Project +) -> CallEndpoint: + # justification: decoded JSON response body def inner( - projects=None, - ): + projects: list[int | str] | None = None, + ) -> tuple[dict[str, Any], int]: path = reverse("sentry-api-0-relay-projectconfigs") if projects is None: @@ -94,14 +106,14 @@ def inner( @pytest.fixture -def add_org_key(default_organization, relay): +def add_org_key(default_organization: Organization, relay: Relay) -> None: default_organization.update_option( "sentry:trusted-relays", [{"public_key": relay.public_key, "name": "main-relay"}] ) @pytest.fixture -def no_internal_networks(): +def no_internal_networks() -> Iterator[None]: """ Disable is_internal_ip functionality (make all requests appear to be from external networks) """ @@ -111,8 +123,10 @@ def no_internal_networks(): @django_db_all def test_internal_relays_should_receive_full_configs( - call_endpoint, default_project, default_projectkey -): + call_endpoint: CallEndpoint, + default_project: Project, + default_projectkey: ProjectKey, +) -> None: result, status_code = call_endpoint() assert status_code < 400 @@ -174,7 +188,7 @@ def test_internal_relays_should_receive_full_configs( @django_db_all -def test_parse_retentions(call_endpoint, default_project): +def test_parse_retentions(call_endpoint: CallEndpoint, default_project: Project) -> None: with patch.multiple( "sentry.quotas.backend", get_retentions=lambda x: { @@ -203,7 +217,9 @@ def test_parse_retentions(call_endpoint, default_project): @django_db_all -def test_parse_retentions_with_transactions(call_endpoint, default_project): +def test_parse_retentions_with_transactions( + call_endpoint: CallEndpoint, default_project: Project +) -> None: with patch.multiple( "sentry.quotas.backend", get_retentions=lambda x: { @@ -229,7 +245,7 @@ def test_parse_retentions_with_transactions(call_endpoint, default_project): @django_db_all -def test_parse_trimming(call_endpoint, default_project): +def test_parse_trimming(call_endpoint: CallEndpoint, default_project: Project) -> None: with patch.object( quotas.backend, "get_trimming_configs", @@ -245,7 +261,7 @@ def test_parse_trimming(call_endpoint, default_project): @django_db_all -def test_relays_dyamic_sampling(call_endpoint, default_project) -> None: +def test_relays_dyamic_sampling(call_endpoint: CallEndpoint, default_project: Project) -> None: """ Tests that dynamic sampling configuration set in project details are retrieved in relay configs """ @@ -277,8 +293,10 @@ def test_relays_dyamic_sampling(call_endpoint, default_project) -> None: @django_db_all def test_trusted_external_relays_should_not_be_able_to_request_full_configs( - add_org_key, call_endpoint, no_internal_networks -): + add_org_key: None, + call_endpoint: CallEndpoint, + no_internal_networks: None, +) -> None: result, status_code = call_endpoint() assert status_code == 403 @@ -286,8 +304,11 @@ def test_trusted_external_relays_should_not_be_able_to_request_full_configs( @django_db_all @patch("sentry.api.authentication.is_internal_relay") def test_external_relays_do_not_get_project_configuration( - is_internal_relay, call_endpoint, add_org_key, relay -): + is_internal_relay: MagicMock, + call_endpoint: CallEndpoint, + add_org_key: None, + relay: Relay, +) -> None: is_internal_relay.return_value = False result, status_code = call_endpoint() @@ -296,14 +317,15 @@ def test_external_relays_do_not_get_project_configuration( @django_db_all def test_untrusted_external_relays_should_not_receive_configs( - call_endpoint, no_internal_networks + call_endpoint: CallEndpoint, + no_internal_networks: None, ) -> None: result, status_code = call_endpoint() assert status_code == 403 @pytest.fixture -def projectconfig_cache_set(): +def projectconfig_cache_set() -> Iterator[list[dict[str, Any]]]: calls: list[dict[str, Any]] = [] with patch("sentry.relay.projectconfig_cache.backend.set_many", calls.append): yield calls @@ -311,8 +333,11 @@ def projectconfig_cache_set(): @django_db_all def test_relay_projectconfig_cache_full_config( - call_endpoint, default_project, projectconfig_cache_set, task_runner -): + call_endpoint: CallEndpoint, + default_project: Project, + projectconfig_cache_set: list[dict[str, Any]], + task_runner: Callable[[], AbstractContextManager[None]], +) -> None: """ When a relay fetches a full config, that config should end up in Redis. """ @@ -335,7 +360,11 @@ def test_relay_projectconfig_cache_full_config( @django_db_all -def test_relay_nonexistent_project(call_endpoint, projectconfig_cache_set, task_runner) -> None: +def test_relay_nonexistent_project( + call_endpoint: CallEndpoint, + projectconfig_cache_set: list[dict[str, Any]], + task_runner: Callable[[], AbstractContextManager[None]], +) -> None: wrong_id = max(p.id for p in Project.objects.all()) + 1 with task_runner(): @@ -350,8 +379,11 @@ def test_relay_nonexistent_project(call_endpoint, projectconfig_cache_set, task_ @django_db_all def test_relay_disabled_project( - call_endpoint, default_project, projectconfig_cache_set, task_runner -): + call_endpoint: CallEndpoint, + default_project: Project, + projectconfig_cache_set: list[dict[str, Any]], + task_runner: Callable[[], AbstractContextManager[None]], +) -> None: default_project.update(status=ObjectStatus.PENDING_DELETION) wrong_id = default_project.id @@ -367,7 +399,12 @@ def test_relay_disabled_project( @django_db_all -def test_health_check_filters(call_endpoint, add_org_key, relay, default_project) -> None: +def test_health_check_filters( + call_endpoint: CallEndpoint, + add_org_key: None, + relay: Relay, + default_project: Project, +) -> None: """ Test health check filter (aka ignoreTransactions) """ diff --git a/tests/sentry/api/endpoints/test_relay_projectconfigs_v2.py b/tests/sentry/api/endpoints/test_relay_projectconfigs_v2.py index 0dbcced583b6..d68919e32709 100644 --- a/tests/sentry/api/endpoints/test_relay_projectconfigs_v2.py +++ b/tests/sentry/api/endpoints/test_relay_projectconfigs_v2.py @@ -1,16 +1,23 @@ from __future__ import annotations import re -from typing import Any -from unittest.mock import patch +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager +from typing import Any, ContextManager, Protocol +from unittest.mock import MagicMock, patch import orjson import pytest +from django.test import Client from django.urls import reverse +from sentry_relay.auth import SecretKey from sentry import quotas from sentry.constants import ObjectStatus +from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.models.projectkey import ProjectKey, ProjectKeyStatus +from sentry.models.relay import Relay from sentry.quotas.base import RETENTIONS_CONFIG_MAPPING from sentry.testutils.helpers import Feature from sentry.testutils.pytest.fixtures import django_db_all @@ -20,14 +27,20 @@ _date_regex = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z$") +class CallEndpoint(Protocol): + def __call__(self, public_keys: list[str] | None = None) -> tuple[dict[str, Any], int]: ... + + @pytest.fixture(autouse=True) -def setup_relay(default_project): +def setup_relay(default_project: Project) -> None: default_project.update_option("sentry:scrub_ip_address", True) @pytest.fixture -def call_endpoint(client, relay, private_key, default_projectkey): - def inner(public_keys=None): +def call_endpoint( + client: Client, relay: Relay, private_key: SecretKey, default_projectkey: ProjectKey +) -> CallEndpoint: + def inner(public_keys: list[str] | None = None) -> tuple[dict[str, Any], int]: path = reverse("sentry-api-0-relay-projectconfigs") + "?version=2" if public_keys is None: @@ -49,14 +62,14 @@ def inner(public_keys=None): @pytest.fixture -def add_org_key(default_organization, relay): +def add_org_key(default_organization: Organization, relay: Relay) -> None: default_organization.update_option( "sentry:trusted-relays", [{"public_key": relay.public_key, "name": "main-relay"}] ) @pytest.fixture -def no_internal_networks(): +def no_internal_networks() -> Iterator[None]: """ Disable is_internal_ip functionality (make all requests appear to be from external networks) """ @@ -66,8 +79,8 @@ def no_internal_networks(): @django_db_all def test_internal_relays_should_receive_full_configs( - call_endpoint, default_project, default_projectkey -): + call_endpoint: CallEndpoint, default_project: Project, default_projectkey: ProjectKey +) -> None: result, status_code = call_endpoint() assert status_code < 400 @@ -130,7 +143,9 @@ def test_internal_relays_should_receive_full_configs( @django_db_all -def test_relays_dyamic_sampling(call_endpoint, default_projectkey) -> None: +def test_relays_dyamic_sampling( + call_endpoint: CallEndpoint, default_projectkey: ProjectKey +) -> None: """ Tests that dynamic sampling configuration set in project details are retrieved in relay configs """ @@ -166,8 +181,8 @@ def test_relays_dyamic_sampling(call_endpoint, default_projectkey) -> None: @django_db_all def test_trusted_external_relays_should_not_be_able_to_request_full_configs( - add_org_key, call_endpoint, no_internal_networks -): + add_org_key: None, call_endpoint: CallEndpoint, no_internal_networks: None +) -> None: result, status_code = call_endpoint() assert status_code == 403 @@ -175,8 +190,8 @@ def test_trusted_external_relays_should_not_be_able_to_request_full_configs( @django_db_all @patch("sentry.api.authentication.is_internal_relay") def test_external_relays_do_not_get_project_configuration( - is_internal_relay, call_endpoint, add_org_key, relay -): + is_internal_relay: MagicMock, call_endpoint: CallEndpoint, add_org_key: None, relay: Relay +) -> None: is_internal_relay.return_value = False result, status_code = call_endpoint() @@ -185,7 +200,7 @@ def test_external_relays_do_not_get_project_configuration( @django_db_all def test_untrusted_external_relays_should_not_receive_configs( - call_endpoint, no_internal_networks + call_endpoint: CallEndpoint, no_internal_networks: None ) -> None: result, status_code = call_endpoint() @@ -193,7 +208,7 @@ def test_untrusted_external_relays_should_not_receive_configs( @pytest.fixture -def projectconfig_cache_set(): +def projectconfig_cache_set() -> Iterator[list[dict[str, Any]]]: calls: list[dict[str, Any]] = [] with patch("sentry.relay.projectconfig_cache.backend.set_many", calls.append): yield calls @@ -201,8 +216,11 @@ def projectconfig_cache_set(): @django_db_all def test_relay_projectconfig_cache_full_config( - call_endpoint, default_projectkey, projectconfig_cache_set, task_runner -): + call_endpoint: CallEndpoint, + default_projectkey: ProjectKey, + projectconfig_cache_set: list[dict[str, Any]], + task_runner: Callable[[], ContextManager[None]], +) -> None: """ When a relay fetches a full config, that config should end up in Redis. """ @@ -225,7 +243,11 @@ def test_relay_projectconfig_cache_full_config( @django_db_all -def test_relay_nonexistent_project(call_endpoint, projectconfig_cache_set, task_runner) -> None: +def test_relay_nonexistent_project( + call_endpoint: CallEndpoint, + projectconfig_cache_set: list[dict[str, Any]], + task_runner: Callable[[], ContextManager[None]], +) -> None: wrong_public_key = ProjectKey.generate_api_key() with task_runner(): @@ -239,8 +261,11 @@ def test_relay_nonexistent_project(call_endpoint, projectconfig_cache_set, task_ @django_db_all def test_relay_disabled_project( - call_endpoint, default_project, projectconfig_cache_set, task_runner -): + call_endpoint: CallEndpoint, + default_project: Project, + projectconfig_cache_set: list[dict[str, Any]], + task_runner: Callable[[], ContextManager[None]], +) -> None: default_project.update(status=ObjectStatus.PENDING_DELETION) wrong_public_key = ProjectKey.generate_api_key() @@ -256,8 +281,11 @@ def test_relay_disabled_project( @django_db_all def test_relay_disabled_key( - call_endpoint, projectconfig_cache_set, task_runner, default_projectkey -): + call_endpoint: CallEndpoint, + projectconfig_cache_set: list[dict[str, Any]], + task_runner: Callable[[], ContextManager[None]], + default_projectkey: ProjectKey, +) -> None: default_projectkey.update(status=ProjectKeyStatus.INACTIVE) with task_runner(): @@ -271,7 +299,9 @@ def test_relay_disabled_key( @django_db_all -def test_session_metrics_extraction(call_endpoint, task_runner) -> None: +def test_session_metrics_extraction( + call_endpoint: CallEndpoint, task_runner: Callable[[], ContextManager[None]] +) -> None: with task_runner(): result, status_code = call_endpoint() assert status_code < 400 @@ -284,8 +314,11 @@ def test_session_metrics_extraction(call_endpoint, task_runner) -> None: @django_db_all @pytest.mark.parametrize("abnormal_mechanism_rollout", [0, 1]) def test_session_metrics_abnormal_mechanism_tag_extraction( - call_endpoint, task_runner, set_sentry_option, abnormal_mechanism_rollout -): + call_endpoint: CallEndpoint, + task_runner: Callable[[], ContextManager[None]], + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], + abnormal_mechanism_rollout: int, +) -> None: with set_sentry_option( "sentry-metrics.releasehealth.abnormal-mechanism-extraction-rate", abnormal_mechanism_rollout, diff --git a/tests/sentry/api/endpoints/test_relay_projectconfigs_v3.py b/tests/sentry/api/endpoints/test_relay_projectconfigs_v3.py index b94d47027736..50200cd56b21 100644 --- a/tests/sentry/api/endpoints/test_relay_projectconfigs_v3.py +++ b/tests/sentry/api/endpoints/test_relay_projectconfigs_v3.py @@ -1,26 +1,42 @@ -from unittest.mock import patch, sentinel +from collections.abc import Iterator +from typing import Any, Protocol +from unittest.mock import MagicMock, patch, sentinel import orjson import pytest +from django.test import Client from django.urls import reverse +from sentry_relay.auth import SecretKey from sentry.db.postgres.transactions import in_test_hide_transaction_boundary +from sentry.models.projectkey import ProjectKey +from sentry.models.relay import Relay from sentry.relay.config import ProjectConfig from sentry.tasks.relay import build_project_config from sentry.testutils.hybrid_cloud import simulated_transaction_watermarks from sentry.testutils.pytest.fixtures import django_db_all +class CallEndpoint(Protocol): + def __call__( + self, public_keys: list[str] | None = None, global_: bool = False + ) -> tuple[dict[str, Any], int]: ... + + @pytest.fixture(autouse=True) -def disable_auto_on_commit(): +def disable_auto_on_commit() -> Iterator[None]: simulated_transaction_watermarks.state["default"] = -1 with in_test_hide_transaction_boundary(): yield @pytest.fixture -def call_endpoint(client, relay, private_key, default_projectkey): - def inner(public_keys=None, global_=False): +def call_endpoint( + client: Client, relay: Relay, private_key: SecretKey, default_projectkey: ProjectKey +) -> CallEndpoint: + def inner( + public_keys: list[str] | None = None, global_: bool = False + ) -> tuple[dict[str, Any], int]: path = reverse("sentry-api-0-relay-projectconfigs") + "?version=3" if public_keys is None: @@ -45,7 +61,7 @@ def inner(public_keys=None, global_=False): @pytest.fixture -def projectconfig_cache_get_mock_config(): +def projectconfig_cache_get_mock_config() -> Iterator[None]: with patch( "sentry.relay.projectconfig_cache.backend.get", return_value={"is_mock_config": True}, @@ -54,8 +70,8 @@ def projectconfig_cache_get_mock_config(): @pytest.fixture -def single_mock_proj_cached(): - def cache_get(*args, **kwargs): +def single_mock_proj_cached() -> Iterator[None]: + def cache_get(*args: object, **kwargs: object) -> dict[str, object] | None: if args[0] == "must_exist": return {"is_mock_config": True} return None @@ -65,13 +81,13 @@ def cache_get(*args, **kwargs): @pytest.fixture -def projectconfig_debounced_cache(): +def projectconfig_debounced_cache() -> Iterator[None]: with patch("sentry.relay.projectconfig_debounce_cache.backend.is_debounced", return_value=True): yield @pytest.fixture -def project_config_get_mock(): +def project_config_get_mock() -> Iterator[None]: with patch( "sentry.relay.config.get_project_config", return_value=ProjectConfig(sentinel.mock_project, is_mock_config=True), @@ -81,8 +97,10 @@ def project_config_get_mock(): @django_db_all def test_return_full_config_if_in_cache( - call_endpoint, default_projectkey, projectconfig_cache_get_mock_config -): + call_endpoint: CallEndpoint, + default_projectkey: ProjectKey, + projectconfig_cache_get_mock_config: None, +) -> None: result, status_code = call_endpoint() assert status_code < 400 assert result == { @@ -93,9 +111,11 @@ def test_return_full_config_if_in_cache( @django_db_all def test_proj_in_cache_and_another_pending( - call_endpoint, default_projectkey, single_mock_proj_cached -): - result, status_code = call_endpoint(public_keys=["must_exist", default_projectkey.public_key]) + call_endpoint: CallEndpoint, default_projectkey: ProjectKey, single_mock_proj_cached: None +) -> None: + public_key = default_projectkey.public_key + assert public_key is not None + result, status_code = call_endpoint(public_keys=["must_exist", public_key]) assert status_code < 400 assert result == { "configs": {"must_exist": {"is_mock_config": True}}, @@ -106,10 +126,10 @@ def test_proj_in_cache_and_another_pending( @patch("sentry.tasks.relay.build_project_config.delay") @django_db_all def test_enqueue_task_if_config_not_cached_not_queued( - schedule_mock, - call_endpoint, - default_projectkey, -): + schedule_mock: MagicMock, + call_endpoint: CallEndpoint, + default_projectkey: ProjectKey, +) -> None: result, status_code = call_endpoint() assert status_code < 400 assert result == {"configs": {}, "pending": [default_projectkey.public_key]} @@ -119,11 +139,11 @@ def test_enqueue_task_if_config_not_cached_not_queued( @patch("sentry.tasks.relay.build_project_config.delay") @django_db_all def test_debounce_task_if_proj_config_not_cached_already_enqueued( - task_mock, - call_endpoint, - default_projectkey, - projectconfig_debounced_cache, -): + task_mock: MagicMock, + call_endpoint: CallEndpoint, + default_projectkey: ProjectKey, + projectconfig_debounced_cache: None, +) -> None: result, status_code = call_endpoint() assert status_code < 400 assert result == {"configs": {}, "pending": [default_projectkey.public_key]} @@ -133,10 +153,11 @@ def test_debounce_task_if_proj_config_not_cached_already_enqueued( @patch("sentry.relay.projectconfig_cache.backend.set_many") @django_db_all def test_task_writes_config_into_cache( - cache_set_many_mock, - default_projectkey, - project_config_get_mock, -): + cache_set_many_mock: MagicMock, + default_projectkey: ProjectKey, + project_config_get_mock: None, +) -> None: + assert default_projectkey.public_key is not None build_project_config( public_key=default_projectkey.public_key, update_reason="test", @@ -155,10 +176,10 @@ def test_task_writes_config_into_cache( ) @django_db_all def test_return_project_and_global_config( - call_endpoint, - default_projectkey, - projectconfig_cache_get_mock_config, -): + call_endpoint: CallEndpoint, + default_projectkey: ProjectKey, + projectconfig_cache_get_mock_config: None, +) -> None: result, status_code = call_endpoint(global_=True) assert status_code == 200 assert result == { diff --git a/tests/sentry/api/endpoints/test_relay_publickeys.py b/tests/sentry/api/endpoints/test_relay_publickeys.py index f88828cd4346..307dadbf566d 100644 --- a/tests/sentry/api/endpoints/test_relay_publickeys.py +++ b/tests/sentry/api/endpoints/test_relay_publickeys.py @@ -1,3 +1,5 @@ +from contextlib import AbstractContextManager +from typing import TypedDict, cast from unittest import mock from uuid import uuid4 @@ -10,7 +12,17 @@ from sentry.testutils.cases import APITestCase -def disable_internal_networks(): +class RelayInfo(TypedDict): + publicKey: str + internal: bool + + +class RelayPublicKeysResponse(TypedDict): + public_keys: dict[str, str | None] + relays: dict[str, RelayInfo | None] + + +def disable_internal_networks() -> AbstractContextManager[object]: return mock.patch.object(system, "INTERNAL_NETWORKS", ()) @@ -61,11 +73,11 @@ def test_get_project_config_internal(self) -> None: assert legacy_keys[self.non_existing_key] is None # check new results - relay_a_info = keys[self.relay_a.relay_id] + relay_a_info = cast(RelayInfo, keys[self.relay_a.relay_id]) assert relay_a_info["publicKey"] == self.relay_a.public_key assert not relay_a_info["internal"] - relay_b_info = keys[self.relay_b.relay_id] + relay_b_info = cast(RelayInfo, keys[self.relay_b.relay_id]) assert relay_b_info["publicKey"] == self.relay_b.public_key assert relay_b_info["internal"] @@ -84,17 +96,17 @@ def test_get_project_config_external(self) -> None: assert legacy_keys[self.non_existing_key] is None # check new results - relay_a_info = keys[self.relay_a.relay_id] + relay_a_info = cast(RelayInfo, keys[self.relay_a.relay_id]) assert relay_a_info["publicKey"] == self.relay_a.public_key assert not relay_a_info["internal"] - relay_b_info = keys[self.relay_b.relay_id] + relay_b_info = cast(RelayInfo, keys[self.relay_b.relay_id]) assert relay_b_info["publicKey"] == self.relay_b.public_key assert not relay_b_info["internal"] assert keys[self.non_existing_key] is None - def _call_endpoint(self, calling_relay): + def _call_endpoint(self, calling_relay: Relay) -> RelayPublicKeysResponse: raw_json, signature = self.private_key.pack( { "relay_ids": [ @@ -113,4 +125,5 @@ def _call_endpoint(self, calling_relay): HTTP_X_SENTRY_RELAY_SIGNATURE=signature, ) - return orjson.loads(resp.content) + # justification: the relay public keys endpoint returns its response payload. + return cast("RelayPublicKeysResponse", orjson.loads(resp.content)) diff --git a/tests/sentry/api/endpoints/test_rpc.py b/tests/sentry/api/endpoints/test_rpc.py index 2a2a4e0ee49b..77b664019f4d 100644 --- a/tests/sentry/api/endpoints/test_rpc.py +++ b/tests/sentry/api/endpoints/test_rpc.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from unittest.mock import patch @@ -7,6 +8,7 @@ from django.test import override_settings from django.urls import reverse from rest_framework.exceptions import ErrorDetail +from rest_framework.response import Response from sentry.hybridcloud.rpc.service import generate_request_signature from sentry.organizations.services.organization import RpcUserOrganizationContext @@ -32,10 +34,12 @@ def _get_path(service_name: str, method_name: str) -> str: kwargs={"service_name": service_name, "method_name": method_name}, ) - def auth_header(self, path: str, data: dict | str) -> str: - if isinstance(data, dict): - data = orjson.dumps(data).decode() - signature = generate_request_signature(path, data.encode()) + def auth_header(self, path: str, data: Mapping[str, object] | str) -> str: + if isinstance(data, str): + payload = data + else: + payload = orjson.dumps(data).decode() + signature = generate_request_signature(path, payload.encode()) return f"rpcsignature {signature}" @@ -44,8 +48,8 @@ def test_invalid_endpoint(self) -> None: response = self.client.post(path) assert response.status_code == 403 - def _send_post_request(self, path, data): - response = self.client.post( + def _send_post_request(self, path: str, data: Mapping[str, object] | str) -> Response: + response: Response = self.client.post( path, data=data, HTTP_AUTHORIZATION=self.auth_header(path, data) ) return response @@ -175,7 +179,7 @@ def test_viewer_context_propagated_from_meta(self) -> None: "sentry.hybridcloud.rpc.service", fromlist=["dispatch_to_local_service"] ).dispatch_to_local_service - def capturing_dispatch(*args, **kwargs): + def capturing_dispatch(*args: object, **kwargs: object) -> object: captured_contexts.append(get_viewer_context()) return original_dispatch(*args, **kwargs) @@ -214,7 +218,7 @@ def test_viewer_context_unknown_when_meta_empty(self) -> None: "sentry.hybridcloud.rpc.service", fromlist=["dispatch_to_local_service"] ).dispatch_to_local_service - def capturing_dispatch(*args, **kwargs): + def capturing_dispatch(*args: object, **kwargs: object) -> object: captured_contexts.append(get_viewer_context()) return original_dispatch(*args, **kwargs) @@ -244,7 +248,7 @@ def test_agent_viewer_context_roundtrip_through_meta(self) -> None: "sentry.hybridcloud.rpc.service", fromlist=["dispatch_to_local_service"] ).dispatch_to_local_service - def capturing_dispatch(*args, **kwargs): + def capturing_dispatch(*args: object, **kwargs: object) -> object: captured_contexts.append(get_viewer_context()) return original_dispatch(*args, **kwargs) diff --git a/tests/sentry/api/endpoints/test_source_map_debug.py b/tests/sentry/api/endpoints/test_source_map_debug.py index 443ccbf55555..ecd402e9daea 100644 --- a/tests/sentry/api/endpoints/test_source_map_debug.py +++ b/tests/sentry/api/endpoints/test_source_map_debug.py @@ -2,7 +2,7 @@ import zipfile from io import BytesIO -from typing import Any +from typing import Any, cast from unittest import mock import orjson @@ -15,6 +15,7 @@ MIN_JS_SDK_VERSION_FOR_DEBUG_IDS, ReleaseBundleUrls, SourceMapReleaseProcessResult, + _sourcemap_header, get_release_bundle_urls, report_unsuccessful_url_matches, ) @@ -34,6 +35,13 @@ from sentry.testutils.helpers.options import override_options from sentry.testutils.skips import requires_snuba + +def test_sourcemap_header_ignores_non_strings() -> None: + assert _sourcemap_header({"sourcemap": "foo"}) == "foo" + assert _sourcemap_header({"x-sourcemap": "bar"}) == "bar" + assert _sourcemap_header({"sourcemap": 1}) is None + + pytestmark = [requires_snuba] @@ -1574,7 +1582,11 @@ def get_release_process_result(self, event_id: str) -> dict[str, Any]: self.project.slug, event_id, ) - return resp.data["exceptions"][0]["frames"][0]["release_process"] + # justification: the event endpoint serializes the release process payload. + return cast( + "dict[str, Any]", + resp.data["exceptions"][0]["frames"][0]["release_process"], + ) @override_options({"sourcemaps.source-map-debug.url-match-max-index-rows": 10}) def test_frame_release_process_artifact_bundle_url_match_by_bundle_successful(self) -> None: diff --git a/tests/sentry/api/endpoints/test_user_subscriptions.py b/tests/sentry/api/endpoints/test_user_subscriptions.py index 0b5fda776fef..b2a43bf2ab2e 100644 --- a/tests/sentry/api/endpoints/test_user_subscriptions.py +++ b/tests/sentry/api/endpoints/test_user_subscriptions.py @@ -33,7 +33,9 @@ def test_get_subscriptions(self) -> None: def test_subscribe(self) -> None: self.get_success_response(self.user.id, listId="123", subscribed=True, status_code=204) - results = newsletter.backend.get_subscriptions(self.user)["subscriptions"] + subscriptions = newsletter.backend.get_subscriptions(self.user) + assert subscriptions is not None + results = subscriptions["subscriptions"] assert len(results) == 1 assert results[0].list_id == 123 assert results[0].subscribed @@ -48,7 +50,9 @@ def test_unverified_emails(self) -> None: def test_unsubscribe(self) -> None: self.get_success_response(self.user.id, listId="123", subscribed=False, status_code=204) - results = newsletter.backend.get_subscriptions(self.user)["subscriptions"] + subscriptions = newsletter.backend.get_subscriptions(self.user) + assert subscriptions is not None + results = subscriptions["subscriptions"] assert len(results) == 1 assert results[0].list_id == 123 assert not results[0].subscribed @@ -56,7 +60,9 @@ def test_unsubscribe(self) -> None: def test_default_subscription(self) -> None: self.get_success_response(self.user.id, method="post", subscribed=True, status_code=204) - results = newsletter.backend.get_subscriptions(self.user)["subscriptions"] + subscriptions = newsletter.backend.get_subscriptions(self.user) + assert subscriptions is not None + results = subscriptions["subscriptions"] assert len(results) == 1 assert results[0].list_id == newsletter.backend.get_default_list_id() assert results[0].subscribed diff --git a/tests/sentry/api/fields/test_serializedfile.py b/tests/sentry/api/fields/test_serializedfile.py index 949cf9695a7f..b6a3113d2208 100644 --- a/tests/sentry/api/fields/test_serializedfile.py +++ b/tests/sentry/api/fields/test_serializedfile.py @@ -1,6 +1,8 @@ import unittest from base64 import b64encode +from collections.abc import Sequence from io import BytesIO +from typing import cast import pytest from rest_framework import serializers @@ -14,9 +16,9 @@ def test_to_representation(self) -> None: field = SerializedFileField() assert field.to_representation(None) == "" - assert field.to_representation("") == "" + assert field.to_representation(cast(FileUpload | None, "")) == "" with pytest.raises(ValueError): - assert field.to_representation(1) + assert field.to_representation(cast(FileUpload | None, 1)) result = field.to_representation( FileUpload(name="filename.txt", content=BytesIO(b"hello world")) @@ -29,7 +31,7 @@ def test_to_internal_value(self) -> None: assert field.to_internal_value("") is None assert field.to_internal_value(None) is None with pytest.raises(serializers.ValidationError): - assert field.to_internal_value(True) + assert field.to_internal_value(cast(Sequence[str | bytes] | None, True)) result = field.to_internal_value(["filename.txt", b64encode(b"hello world")]) assert isinstance(result, FileUpload) diff --git a/tests/sentry/api/helpers/test_deprecation.py b/tests/sentry/api/helpers/test_deprecation.py index 405fd7110fcb..42fcc1470c24 100644 --- a/tests/sentry/api/helpers/test_deprecation.py +++ b/tests/sentry/api/helpers/test_deprecation.py @@ -4,6 +4,7 @@ from django.http.request import HttpRequest from django.http.response import HttpResponse from django.urls import ResolverMatch +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.status import HTTP_200_OK, HTTP_410_GONE @@ -30,26 +31,26 @@ class DummyEndpoint(Endpoint): permission_classes = () @deprecated(test_date, suggested_api=replacement_api) - def get(self, request): + def get(self, request: Request) -> Response: return Response({"ok": True}) - def head(self, request): + def head(self, request: Request) -> Response: return Response({"ok": True}) @deprecated(test_date, suggested_api=replacement_api, key="override") - def post(self, request): + def post(self, request: Request) -> Response: return Response({"ok": True}) @deprecated(test_date, url_names=["sentry-dummy-delete-deprecated"]) - def delete(self, request): + def delete(self, request: Request) -> Response: return Response({"ok": True}) @deprecated(test_date, suggested_api="sentry-api-0-organization-projects") - def put(self, request): + def put(self, request: Request) -> Response: return Response({"ok": True}) @deprecated(test_date, suggested_api="sentry-api-0-organization-workflow-details") - def patch(self, request): + def patch(self, request: Request) -> Response: return Response({"ok": True}) @@ -67,14 +68,14 @@ def assert_deprecation_metadata(self, request: HttpRequest, response: HttpRespon assert response["X-Sentry-Deprecation-Date"] == test_date.isoformat() assert response["X-Sentry-Replacement-Endpoint"] == replacement_api_resolved - def assert_not_deprecated(self, method): + def assert_not_deprecated(self, method: str) -> None: request = self.make_request(method=method) resp = dummy_endpoint(request) assert resp.status_code == HTTP_200_OK assert "X-Sentry-Deprecation-Date" not in resp assert "X-Sentry-Replacement-Endpoint" not in resp - def assert_allowed_request(self, method): + def assert_allowed_request(self, method: str) -> None: request = self.make_request(method=method) request.META["HTTP_ORIGIN"] = "http://example.com" resp = dummy_endpoint(request) @@ -82,7 +83,7 @@ def assert_allowed_request(self, method): assert resp.status_code == HTTP_200_OK self.assert_deprecation_metadata(request, resp) - def assert_denied_request(self, method): + def assert_denied_request(self, method: str) -> None: request = self.make_request(method=method) resp = dummy_endpoint(request) assert resp.status_code == HTTP_410_GONE @@ -344,7 +345,7 @@ class UnknownRouteEndpoint(Endpoint): permission_classes = () @deprecated(test_date, suggested_api="nonexistent-route-name") - def get(self, request): + def get(self, request: Request) -> Response: return Response({"ok": True}) endpoint = UnknownRouteEndpoint.as_view() diff --git a/tests/sentry/api/helpers/test_group_index.py b/tests/sentry/api/helpers/test_group_index.py index afcde900f44f..c7b1b21fa137 100644 --- a/tests/sentry/api/helpers/test_group_index.py +++ b/tests/sentry/api/helpers/test_group_index.py @@ -571,7 +571,7 @@ def test_ignoring_group_archived_until_condition_met(self, send_robust: Mock) -> @patch("sentry.signals.issue_unignored.send_robust") def test_unignoring_group(self, send_robust: Mock) -> None: - for data in [ + cases: list[dict[str, Any]] = [ { "group": self.create_group( status=GroupStatus.IGNORED, first_seen=datetime.now(UTC) - timedelta(days=8) @@ -593,7 +593,8 @@ def test_unignoring_group(self, send_robust: Mock) -> None: "request_data": {"status": "unresolved"}, "expected_substatus": GroupSubStatus.NEW, }, - ]: + ] + for data in cases: group = data["group"] http_request = self.make_request(user=self.user, method="GET") http_request.GET = QueryDict(query_string=f"id={group.id}") @@ -651,6 +652,7 @@ def test_ignore_with_substatus_archived_until_escalating(self, send_robust: Mock @patch("sentry.signals.issue_resolved.send_robust") def test_resolving_group_with_short_id(self, send_robust: Mock) -> None: group = self.create_group(status=GroupStatus.UNRESOLVED) + assert group.qualified_short_id is not None http_request = self.make_request( user=self.user, @@ -685,6 +687,7 @@ def test_get_group_list_filters_by_project_access(self) -> None: # User only has access to self.project, not project2 # When requesting group2 using qualified short ID, it should be filtered out + assert group2.qualified_short_id is not None group_list = get_group_list( self.organization.id, [self.project], [group2.qualified_short_id] ) @@ -693,12 +696,15 @@ def test_get_group_list_filters_by_project_access(self) -> None: assert group_list == [] # But when requesting a group from an accessible project, it should work + assert group1.qualified_short_id is not None group_list = get_group_list( self.organization.id, [self.project], [group1.qualified_short_id] ) assert group_list == [group1] # When user has access to both projects, both groups should be returned + assert group1.qualified_short_id is not None + assert group2.qualified_short_id is not None group_list = get_group_list( self.organization.id, [self.project, project2], diff --git a/tests/sentry/api/serializers/rest_framework/test_base.py b/tests/sentry/api/serializers/rest_framework/test_base.py index cefa3a11a69f..5d34beae5fcd 100644 --- a/tests/sentry/api/serializers/rest_framework/test_base.py +++ b/tests/sentry/api/serializers/rest_framework/test_base.py @@ -15,7 +15,7 @@ from sentry.testutils.cases import TestCase -class PersonSerializer(CamelSnakeSerializer): +class PersonSerializer(CamelSnakeSerializer[dict[str, object]]): name = serializers.CharField() works_at = serializers.CharField() @@ -42,7 +42,7 @@ def test_smuggling(self) -> None: PersonSerializer(data={"name": "Rick", "worksAt": "Sentry", "_name": "Chuck"}) -class ContentTypeSerializer(CamelSnakeModelSerializer): +class ContentTypeSerializer(CamelSnakeModelSerializer[ContentType]): class Meta: model = ContentType fields = ["app_label", "model"] diff --git a/tests/sentry/api/serializers/rest_framework/test_origin.py b/tests/sentry/api/serializers/rest_framework/test_origin.py index 4c71031efed9..7b5867a142c3 100644 --- a/tests/sentry/api/serializers/rest_framework/test_origin.py +++ b/tests/sentry/api/serializers/rest_framework/test_origin.py @@ -1,13 +1,23 @@ +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() +class BlankOriginSerializer(serializers.Serializer[object, OriginSerializerData]): + origin_field = OriginField(allow_blank=True) + + class OriginFieldTest(TestCase): def test_valid_origin(self) -> None: urls = ["https://www.foo.com", "*", "*.domain.com", "*:80", "localhost:8080"] @@ -20,3 +30,9 @@ def test_invalid_origin(self) -> None: for url in urls: serializer = DummySerializer(data={"origin_field": url}) assert serializer.is_valid() is False + + def test_blank_origin_is_preserved(self) -> None: + serializer = BlankOriginSerializer(data={"origin_field": ""}) + + assert serializer.is_valid() + assert serializer.validated_data["origin_field"] == "" diff --git a/tests/sentry/api/serializers/rest_framework/test_project.py b/tests/sentry/api/serializers/rest_framework/test_project.py index a2d36cfb9299..dacc6222e2a3 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_an_empty_list(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_activity.py b/tests/sentry/api/serializers/test_activity.py index 84d6564edc48..098d540294d5 100644 --- a/tests/sentry/api/serializers/test_activity.py +++ b/tests/sentry/api/serializers/test_activity.py @@ -208,7 +208,7 @@ def test_collapse_group_stats_in_activity_with_option(self) -> None: assert "firstSeen" not in serialized["data"]["source"] - def _create_note(self, mentions): + def _create_note(self, mentions: list[dict[str, object]]) -> Activity: group = self.create_group() return Activity.objects.create( project_id=group.project_id, 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..8139687fb34e 100644 --- a/tests/sentry/api/serializers/test_base.py +++ b/tests/sentry/api/serializers/test_base.py @@ -1,4 +1,7 @@ +from collections.abc import Mapping, Sequence + 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 @@ -7,26 +10,46 @@ class Foo: pass -class FooSerializer(Serializer): - def serialize(self, *args, **kwargs): +class FooSerializer(Serializer[str]): + def serialize(self, *args: object, **kwargs: object) -> str: return "lol" -class VariadicSerializer(Serializer): - def serialize(self, obj, attrs, user, **kw): +class VariadicSerializer(Serializer[dict[str, object]]): + def serialize( + self, + obj: object, + attrs: Mapping[object, object], + user: object, + **kw: object, + ) -> dict[str, object]: return {"kw": kw} -class FailingChildSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): +class FailingChildSerializer(Serializer[dict[str, object]]): + def serialize( + self, + obj: object, + attrs: Mapping[object, object], + user: object, + **kwargs: object, + ) -> dict[str, object]: raise Exception -class ParentSerializer(Serializer): - def get_attrs(self, item_list, user, **kwargs): +class ParentSerializer(Serializer[dict[str, object]]): + def get_attrs( + self, item_list: Sequence[object], user: object, **kwargs: object + ) -> dict[object, dict[str, object]]: return {item: {"child_data": Foo()} for item in item_list} - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: object, + attrs: Mapping[object, object], + user: object, + **kwargs: object, + ) -> dict[str, object]: return { "parent": "something", "child": serialize(attrs["child_data"], serializer=FailingChildSerializer()), @@ -78,3 +101,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_group.py b/tests/sentry/api/serializers/test_group.py index 79f726eafdce..40839c47926b 100644 --- a/tests/sentry/api/serializers/test_group.py +++ b/tests/sentry/api/serializers/test_group.py @@ -1,6 +1,6 @@ from datetime import timedelta from typing import Any -from unittest.mock import call, patch +from unittest.mock import MagicMock, call, patch from django.utils import timezone @@ -438,7 +438,7 @@ def test_seer_autofix_last_triggered_with_explorer(self) -> None: class GroupSerializerDerivedDataTest(TestCase): @patch("sentry.issues.derived.serialization.metrics.incr") - def test_derived_data_served_metric(self, mock_metrics_incr) -> None: + def test_derived_data_served_metric(self, mock_metrics_incr: MagicMock) -> None: fresh_group = self.create_group() self.create_group_derived_data( group=fresh_group, diff --git a/tests/sentry/api/serializers/test_organization.py b/tests/sentry/api/serializers/test_organization.py index f820ada02d36..20cc9221b9ef 100644 --- a/tests/sentry/api/serializers/test_organization.py +++ b/tests/sentry/api/serializers/test_organization.py @@ -1,4 +1,5 @@ import datetime +from typing import cast from unittest import mock from django.conf import settings @@ -11,11 +12,16 @@ OrganizationWithProjectsAndTeamsSerializer, serialize, ) +from sentry.api.serializers.models.organization import ( + OrganizationSerializerResponse, + OrganizationWithProjectsAndTeamsSerializerResponse, +) from sentry.auth import access from sentry.features.base import OrganizationFeature from sentry.models.deploy import Deploy from sentry.models.environment import Environment from sentry.models.options.organization_option import OrganizationOption +from sentry.models.organization import Organization from sentry.models.organizationonboardingtask import ( OnboardingTask, OnboardingTaskStatus, @@ -26,9 +32,36 @@ from sentry.testutils.cases import TestCase from sentry.testutils.helpers import with_feature from sentry.testutils.skips import requires_snuba +from sentry.users.models.user import User pytestmark = [requires_snuba] + +def _serialize_organization( + organization: Organization, + user: User, + serializer: OrganizationSerializer, + access: access.Access, +) -> OrganizationSerializerResponse: + # justification: OrganizationSerializer is typed with the summary response but returns the full one + return cast( + OrganizationSerializerResponse, serialize(organization, user, serializer, access=access) + ) + + +def _serialize_organization_with_projects_and_teams( + organization: Organization, + user: User, + serializer: OrganizationWithProjectsAndTeamsSerializer, + access: access.Access, +) -> OrganizationWithProjectsAndTeamsSerializerResponse: + # justification: the serializer is typed with the summary response but returns the full one + return cast( + OrganizationWithProjectsAndTeamsSerializerResponse, + serialize(organization, user, serializer, access=access), + ) + + granular_scopes = ["dashboard:read", "dashboard:write", "saved_query:read", "saved_query:write"] # Accepted by endpoints, but no role grants them yet. ungranted_scopes = ["user_preference:read", "user_preference:write"] @@ -123,7 +156,7 @@ def test_detailed(self) -> None: acc = access.from_user(user, organization) serializer = OrganizationSerializer() - result = serialize(organization, user, serializer, access=acc) + result = _serialize_organization(organization, user, serializer, acc) assert result["id"] == str(organization.id) assert result["role"] == "owner" @@ -140,7 +173,7 @@ def test_detailed_with_granular_permission_scopes(self) -> None: acc = access.from_user(user, organization) serializer = OrganizationSerializer() - result = serialize(organization, user, serializer, access=acc) + result = _serialize_organization(organization, user, serializer, acc) assert result["access"] == default_owner_scopes | set(granular_scopes) for role in result["orgRoleList"]: @@ -152,7 +185,7 @@ def test_granular_replay_permissions_without_option(self) -> None: acc = access.from_user(user, organization) serializer = OrganizationSerializer() - result = serialize(organization, user, serializer, access=acc) + result = _serialize_organization(organization, user, serializer, acc) assert result["hasGranularReplayPermissions"] is False assert result["replayAccessMembers"] == [] @@ -169,7 +202,7 @@ def test_granular_replay_permissions_with_option(self) -> None: ) serializer = OrganizationSerializer() - result = serialize(organization, user, serializer, access=acc) + result = _serialize_organization(organization, user, serializer, acc) assert result["hasGranularReplayPermissions"] is True assert result["replayAccessMembers"] == [] @@ -187,7 +220,7 @@ def test_replay_access_members_not_serialized_without_option(self) -> None: acc = access.from_user(user, organization) serializer = OrganizationSerializer() - result = serialize(organization, user, serializer, access=acc) + result = _serialize_organization(organization, user, serializer, acc) assert set(result["replayAccessMembers"]) == set() def test_replay_access_members_serialized_with_option_enabled(self) -> None: @@ -210,7 +243,7 @@ def test_replay_access_members_serialized_with_option_enabled(self) -> None: ) serializer = OrganizationSerializer() - result = serialize(organization, user, serializer, access=acc) + result = _serialize_organization(organization, user, serializer, acc) assert result["hasGranularReplayPermissions"] is True assert set(result["replayAccessMembers"]) == {member1.user_id, member2.user_id} @@ -220,7 +253,7 @@ def test_replay_access_members_empty_when_none_set(self) -> None: acc = access.from_user(user, organization) serializer = OrganizationSerializer() - result = serialize(organization, user, serializer, access=acc) + result = _serialize_organization(organization, user, serializer, acc) assert result["replayAccessMembers"] == [] @@ -230,7 +263,7 @@ def test_experiments_field_defaults_to_empty_dict(self) -> None: acc = access.from_user(user, organization) serializer = OrganizationSerializer() - result = serialize(organization, user, serializer, access=acc) + result = _serialize_organization(organization, user, serializer, acc) assert result["experiments"] == {} @@ -247,7 +280,7 @@ def test_experiments_field_populated_from_entity_handler(self) -> None: features.default_manager.add_entity_handler(handler) try: serializer = OrganizationSerializer() - result = serialize(organization, user, serializer, access=acc) + result = _serialize_organization(organization, user, serializer, acc) assert result["experiments"] == {"experiment-test": "active"} finally: @@ -261,7 +294,9 @@ def test_detailed_org_projs_teams(self) -> None: self.project acc = access.from_user(self.user, self.organization) serializer = OrganizationWithProjectsAndTeamsSerializer() - result = serialize(self.organization, self.user, serializer, access=acc) + result = _serialize_organization_with_projects_and_teams( + self.organization, self.user, serializer, acc + ) assert result["id"] == str(self.organization.id) assert result["role"] == "owner" @@ -294,7 +329,9 @@ def test_disable_last_deploys_killswitch(self) -> None: ) acc = access.from_user(self.user, self.organization) serializer = OrganizationWithProjectsAndTeamsSerializer() - result = serialize(self.organization, self.user, serializer, access=acc) + result = _serialize_organization_with_projects_and_teams( + self.organization, self.user, serializer, acc + ) assert result["projects"][0]["latestDeploys"] @@ -303,7 +340,9 @@ def test_disable_last_deploys_killswitch(self) -> None: ) options.set("api.organization.disable-last-deploys", opt_val) - result = serialize(self.organization, self.user, serializer, access=acc) + result = _serialize_organization_with_projects_and_teams( + self.organization, self.user, serializer, acc + ) assert not result["projects"][0].get("latestDeploys") opt_val = killswitches.validate_user_input("api.organization.disable-last-deploys", []) diff --git a/tests/sentry/api/serializers/test_organization_member.py b/tests/sentry/api/serializers/test_organization_member.py index d9f82d2bcf52..9291a00382b4 100644 --- a/tests/sentry/api/serializers/test_organization_member.py +++ b/tests/sentry/api/serializers/test_organization_member.py @@ -7,7 +7,7 @@ OrganizationMemberSCIMSerializer, OrganizationMemberWithTeamsSerializer, ) -from sentry.models.organizationmember import InviteStatus +from sentry.models.organizationmember import InviteStatus, OrganizationMember from sentry.testutils.cases import TestCase from sentry.testutils.silo import assume_test_silo_mode_of from sentry.users.models.user import User @@ -25,7 +25,7 @@ def setUp(self) -> None: self.project = self.create_project(teams=[self.team]) self.project_2 = self.create_project(teams=[self.team_2]) - def _get_org_members(self) -> list[User]: + def _get_org_members(self) -> list[OrganizationMember]: return list( self.org.member_set.filter(user_id__in=[self.owner_user.id, self.user_2.id]).order_by( "user_email" @@ -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/serializers/test_project.py b/tests/sentry/api/serializers/test_project.py index 1a4f6e315c6f..de5016400d05 100644 --- a/tests/sentry/api/serializers/test_project.py +++ b/tests/sentry/api/serializers/test_project.py @@ -1,7 +1,7 @@ import datetime from datetime import UTC, timedelta from functools import cached_property -from typing import Any +from typing import Any, cast from unittest import mock from django.conf import settings @@ -21,7 +21,7 @@ ) from sentry.app import env from sentry.conf.types.sentry_config import SentryMode -from sentry.features.base import ProjectFeature +from sentry.features.base import Feature, ProjectFeature from sentry.models.deploy import Deploy from sentry.models.environment import Environment, EnvironmentProject from sentry.models.options.project_option import ProjectOption @@ -232,26 +232,30 @@ def test_project_features(self, mock_features: mock.MagicMock) -> None: class EarlyAdopterFeatureHandler(features.BatchFeatureHandler): features = {early_flag} - def _check_for_batch(self, feature_name, organization, actor): + def _check_for_batch( + self, feature_name: str, organization: object, actor: object + ) -> bool: return organization == early_adopter - def batch_has(self, *a, **k): + def batch_has(self, *a: object, **k: object) -> None: raise NotImplementedError("unreachable") - def create_color_handler(color_flag, included_projects): + def create_color_handler( + color_flag: str, included_projects: list[Project] + ) -> features.FeatureHandler: class ProjectColorFeatureHandler(features.FeatureHandler): features = {color_flag} def has( self, - feature, - actor, + feature: Feature, + actor: object, skip_entity: bool | None = False, skip_experiment_exposure: bool = False, - ): - return feature.project in included_projects + ) -> bool: + return cast(ProjectFeature, feature).project in included_projects - def batch_has(self, *a, **k): + def batch_has(self, *a: object, **k: object) -> None: raise NotImplementedError("unreachable") return ProjectColorFeatureHandler() @@ -264,12 +268,12 @@ def batch_has(self, *a, **k): for handler in (EarlyAdopterFeatureHandler(), red_handler, blue_handler): test_features.add_handler(handler) - def api_form(flag): + def api_form(flag: str) -> str: return flag[len("projects:") :] flags_to_find = {api_form(f) for f in [early_flag, red_flag, blue_flag]} - def assert_has_features(project, expected_features): + def assert_has_features(project: Project, expected_features: list[str]) -> None: serialized = serialize(project) actual_features = {f for f in serialized["features"] if f in flags_to_find} assert actual_features == {api_form(f) for f in expected_features} @@ -712,8 +716,10 @@ def test_stats_with_transactions_no_spans(self) -> None: "sentry.api.serializers.models.project.release_health.backend.get_current_and_previous_crash_free_rates" ) def test_stats_with_sessions( - self, get_current_and_previous_crash_free_rates, check_has_health_data - ): + self, + get_current_and_previous_crash_free_rates: mock.MagicMock, + check_has_health_data: mock.MagicMock, + ) -> None: get_current_and_previous_crash_free_rates.return_value = { self.project.id: { "currentCrashFreeRate": 75.63453, @@ -737,8 +743,10 @@ def test_stats_with_sessions( "sentry.api.serializers.models.project.release_health.backend.get_current_and_previous_crash_free_rates" ) def test_stats_with_sessions_and_none_crash_free_rates( - self, get_current_and_previous_crash_free_rates, check_has_health_data - ): + self, + get_current_and_previous_crash_free_rates: mock.MagicMock, + check_has_health_data: mock.MagicMock, + ) -> None: """ Test that ensures if both `currentCrashFreeRate` and `previousCrashFreeRate` are None, then we need to make a call to `check_has_health_data` to know if we have health data in that @@ -903,11 +911,11 @@ def test_seer_scanner_automation_flag(self) -> None: class BulkFetchProjectLatestReleases(TestCase): @cached_property - def project(self): + def project(self) -> Project: return self.create_project(teams=[self.team], organization=self.organization) @cached_property - def other_project(self): + def other_project(self) -> Project: return self.create_project(teams=[self.team], organization=self.organization) def test_single_no_release(self) -> None: diff --git a/tests/sentry/api/serializers/test_projectcodeowners.py b/tests/sentry/api/serializers/test_projectcodeowners.py index 99129b142e64..32b86a49f84c 100644 --- a/tests/sentry/api/serializers/test_projectcodeowners.py +++ b/tests/sentry/api/serializers/test_projectcodeowners.py @@ -1,4 +1,5 @@ from sentry.api.serializers.models.projectcodeowners import ProjectCodeOwnersSerializer +from sentry.api.serializers.models.projectownership import OwnershipSchemaResponse from sentry.testutils.cases import TestCase @@ -28,7 +29,7 @@ def test_schema_response(self) -> None: self.user, ) - expected_schema = { + expected_schema: OwnershipSchemaResponse = { "$version": 1, "rules": [ { diff --git a/tests/sentry/api/serializers/test_release.py b/tests/sentry/api/serializers/test_release.py index 9270b60135b2..be2bad38fdc4 100644 --- a/tests/sentry/api/serializers/test_release.py +++ b/tests/sentry/api/serializers/test_release.py @@ -10,7 +10,11 @@ from sentry import tagstore from sentry.api.endpoints.organization_releases import ReleaseSerializerWithProjects from sentry.api.serializers import serialize -from sentry.api.serializers.models.release import GroupEventReleaseSerializer, get_users_for_authors +from sentry.api.serializers.models.release import ( + GroupEventReleaseSerializer, + _expose_health_data, + get_users_for_authors, +) from sentry.integrations.models.external_actor import ExternalActor from sentry.models.commit import Commit from sentry.models.commitauthor import CommitAuthor @@ -595,6 +599,22 @@ def test_with_none_new_groups(self) -> None: assert result["version"] == "0.1" assert result["newGroups"] == 0 # Should default to 0 when None + def test_none_new_groups_per_project_defaults_to_zero(self) -> None: + project = self.create_project() + + release = Release.objects.create( + organization_id=project.organization_id, + version="0.2", + ) + release.add_project(project) + + ReleaseProject.objects.filter(release=release, project=project).update(new_groups=None) + + result = serialize(release, user=self.user, project=project) + + assert result["newGroups"] == 0 + assert result["projects"][0]["newGroups"] == 0 # Should default to 0 when None + def test_new_groups_single_release(self) -> None: """ Test new groups counts for one release with multiple projects, each having different issue counts. @@ -1184,7 +1204,7 @@ def test_get_users_for_authors_multiple_emails(self) -> None: assert users[str(author.id)]["name"] == "Regular Sentry User" @patch("sentry.users.services.user.service.user_service.serialize_many") - def test_get_users_for_authors_user_dropped(self, mock_serialize) -> None: + def test_get_users_for_authors_user_dropped(self, mock_serialize: MagicMock) -> None: """Edge case: user ID is found but doesn't come back from serialize_many""" project = self.create_project() user = self.create_user(email="found@company.com", name="Found User") @@ -1263,3 +1283,34 @@ def test_external_actor_duplicate_external_name_prefers_most_recent(self) -> Non assert users[str(author.id)].get("id", "not present") == str(user2.id) assert users[str(author.id)]["email"] == "user2@company.com" assert users[str(author.id)]["name"] == "User 2" + + +class ExposeHealthDataTest(TestCase): + def test_none_and_empty_payload_returns_none(self) -> None: + assert _expose_health_data(None) is None + assert _expose_health_data({}) is None + + def test_missing_stats_defaults_to_empty_dict(self) -> None: + result = _expose_health_data( + { + "duration_p50": None, + "duration_p90": None, + "crash_free_users": None, + "crash_free_sessions": None, + "sessions_crashed": 0, + "sessions_errored": 0, + "total_users": 0, + "total_users_24h": 0, + "total_project_users_24h": 0, + "total_sessions": 0, + "total_sessions_24h": 0, + "total_project_sessions_24h": 0, + "adoption": None, + "sessions_adoption": None, + "has_health_data": False, + } + ) + + assert result is not None + assert result["stats"] == {} + assert result["sessionsCrashed"] == 0 diff --git a/tests/sentry/api/serializers/test_snuba.py b/tests/sentry/api/serializers/test_snuba.py index 760750f38306..861e6c40a934 100644 --- a/tests/sentry/api/serializers/test_snuba.py +++ b/tests/sentry/api/serializers/test_snuba.py @@ -1,5 +1,5 @@ import unittest -from datetime import timedelta +from datetime import datetime, timedelta from django.utils import timezone @@ -7,15 +7,24 @@ class ZeroFillTest(unittest.TestCase): - def run_test(self, filled_buckets, irregular_buckets, start, end, rollup, zerofilled_buckets): - filled_buckets = [(start + (rollup * bucket), val) for bucket, val in filled_buckets] - buckets = [(date.timestamp(), val) for date, val in filled_buckets + irregular_buckets] + def run_test( + self, + filled_buckets: list[tuple[int, list[int]]], + irregular_buckets: list[tuple[datetime, list[int]]], + start: datetime, + end: datetime, + rollup: timedelta, + zerofilled_buckets: list[int], + ) -> None: + filled = [(start + (rollup * bucket), val) for bucket, val in filled_buckets] + buckets = [(date.timestamp(), val) for date, val in filled] + buckets.extend((date.timestamp(), val) for date, val in irregular_buckets) sort_key = lambda row: row[0] buckets.sort(key=sort_key) - zerofilled_buckets = [ + zerofilled: list[tuple[float, list[int]]] = [ ((start + (rollup * bucket)).timestamp(), []) for bucket in zerofilled_buckets ] - expected = buckets + zerofilled_buckets + expected = buckets + zerofilled expected.sort(key=sort_key) assert zerofill(buckets, start, end, int(rollup.total_seconds())) == expected diff --git a/tests/sentry/api/test_api_pagination_check.py b/tests/sentry/api/test_api_pagination_check.py index 5cdaa64d0554..6f714ef4b0dd 100644 --- a/tests/sentry/api/test_api_pagination_check.py +++ b/tests/sentry/api/test_api_pagination_check.py @@ -12,7 +12,7 @@ class APIPaginationCheckTestCase(TestCase): def test_if_wrong_api_method_fails(self) -> None: class ExampleEndpoint(TestCase, Endpoint): - def __init__(self, *args, **kwargs): + def __init__(self, *args: str, **kwargs: str) -> None: super().__init__(*args, **kwargs) self.access = "read" @@ -20,7 +20,7 @@ def __init__(self, *args, **kwargs): def check_permissions(self, request: Request) -> None: pass - def get(self, request, *args, **kwargs): + def get(self, request: object, *args: object, **kwargs: object) -> Response: return Response(data=[]) # Test the endpoint, assert there is a MissingPaginationError @@ -32,7 +32,7 @@ def get(self, request, *args, **kwargs): def test_endpoint_in_allowlist(self) -> None: class GroupTagsEndpoint(TestCase, Endpoint): - def __init__(self, *args, **kwargs): + def __init__(self, *args: str, **kwargs: str) -> None: super().__init__(*args, **kwargs) self.access = "read" @@ -40,7 +40,7 @@ def __init__(self, *args, **kwargs): def check_permissions(self, request: Request) -> None: pass - def get(self, request, *args, **kwargs): + def get(self, request: object, *args: object, **kwargs: object) -> Response: return Response(data=[]) # Test the endpoint, assert there is no MissingPaginationError @@ -51,7 +51,7 @@ def get(self, request, *args, **kwargs): def test_empty_payload_with_pagination(self) -> None: class ExampleEndpoint(Endpoint): - def __init__(self, *args, **kwargs): + def __init__(self, *args: str, **kwargs: str) -> None: super().__init__(*args, **kwargs) self.access = "read" @@ -60,7 +60,7 @@ def check_permissions(self, request: Request) -> None: pass # call the pagination method - def get(self, request, *args, **kwargs): + def get(self, request: Request, *args: object, **kwargs: object) -> Response: return self.paginate( request=request, queryset=[], diff --git a/tests/sentry/api/test_authentication.py b/tests/sentry/api/test_authentication.py index db609b01226e..d16e23b48e4e 100644 --- a/tests/sentry/api/test_authentication.py +++ b/tests/sentry/api/test_authentication.py @@ -77,19 +77,25 @@ def setUp(self) -> None: self.sentry_app = self.create_sentry_app(name="foo", organization=self.org) - self.api_app = self.sentry_app.application + api_app = self.sentry_app.application + assert api_app is not None + assert api_app.client_secret is not None + self.api_app = api_app + self.client_secret = api_app.client_secret def test_authenticate(self) -> None: request = _drf_request( { "client_id": self.api_app.client_id, - "client_secret": self.api_app.client_secret, + "client_secret": self.client_secret, } ) - user, _ = self.auth.authenticate(request) + auth_result = self.auth.authenticate(request) + assert auth_result is not None + user, _ = auth_result - assert user.id == self.sentry_app.proxy_user.id + assert user.id == self.sentry_app.proxy_user_id def test_without_json_body(self) -> None: request = _drf_request() @@ -98,7 +104,7 @@ def test_without_json_body(self) -> None: self.auth.authenticate(request) def test_missing_client_id(self) -> None: - request = _drf_request({"client_secret": self.api_app.client_secret}) + request = _drf_request({"client_secret": self.client_secret}) with pytest.raises(AuthenticationFailed): self.auth.authenticate(request) @@ -113,7 +119,7 @@ def test_incorrect_client_id(self) -> None: request = _drf_request( { "client_id": "notit", - "client_secret": self.api_app.client_secret, + "client_secret": self.client_secret, } ) @@ -143,7 +149,11 @@ def setUp(self) -> None: self.installation = self.create_sentry_app_installation( organization=self.org, slug=self.sentry_app.slug, user=self.user ) - self.api_app = self.sentry_app.application + api_app = self.sentry_app.application + assert api_app is not None + assert api_app.client_secret is not None + self.api_app = api_app + self.client_secret = api_app.client_secret def _create_jwt(self, client_id: str, exp: datetime | None = None) -> str: """Helper to create a JWT token""" @@ -157,7 +167,7 @@ def _create_jwt(self, client_id: str, exp: datetime | None = None) -> str: "exp": int(exp.timestamp()), # Expiration "jti": str(uuid.uuid4()), # JWT ID (unique identifier) } - return jwt.encode(payload, self.api_app.client_secret, algorithm="HS256") + return jwt.encode(payload, self.client_secret, algorithm="HS256") def test_authenticate(self) -> None: token = self._create_jwt(self.api_app.client_id) @@ -165,8 +175,10 @@ def test_authenticate(self) -> None: request = _drf_request({"grant_type": GrantTypes.CLIENT_SECRET_JWT}, path=path) request.META["HTTP_AUTHORIZATION"] = f"Bearer {token}" - user, _ = self.auth.authenticate(request) - assert user.id == self.sentry_app.proxy_user.id + auth_result = self.auth.authenticate(request) + assert auth_result is not None + user, _ = auth_result + assert user.id == self.sentry_app.proxy_user_id def test_missing_installation(self) -> None: token = self._create_jwt(self.api_app.client_id) @@ -205,7 +217,7 @@ def test_missing_expiration(self) -> None: "iat": int(datetime.now(UTC).timestamp()), "jti": str(uuid.uuid4()), }, - self.api_app.client_secret, + self.client_secret, algorithm="HS256", ) path = f"/api/0/sentry-app-installations/{self.installation.uuid}/authorizations/" @@ -285,8 +297,10 @@ def test_jwt_cannot_be_used_twice(self) -> None: request.META["HTTP_AUTHORIZATION"] = f"Bearer {token}" # First request should succeed - user, _ = self.auth.authenticate(request) - assert user.id == self.sentry_app.proxy_user.id + auth_result = self.auth.authenticate(request) + assert auth_result is not None + user, _ = auth_result + assert user.id == self.sentry_app.proxy_user_id # Second request with the same token should fail due to cache request2 = _drf_request({"grant_type": GrantTypes.CLIENT_SECRET_JWT}, path=path) @@ -311,8 +325,10 @@ def test_jwt_cannot_be_used_for_another_installation(self) -> None: path = f"/api/0/sentry-app-installations/{self.installation.uuid}/authorizations/" request = _drf_request({"grant_type": GrantTypes.CLIENT_SECRET_JWT}, path=path) request.META["HTTP_AUTHORIZATION"] = f"Bearer {token}" - user, _ = self.auth.authenticate(request) - assert user.id == self.sentry_app.proxy_user.id + auth_result = self.auth.authenticate(request) + assert auth_result is not None + user, _ = auth_result + assert user.id == self.sentry_app.proxy_user_id other_path = f"/api/0/sentry-app-installations/{other_installation.uuid}/authorizations/" request2 = _drf_request({"grant_type": GrantTypes.CLIENT_SECRET_JWT}, path=other_path) @@ -327,8 +343,10 @@ def test_jwt_cannot_be_used_twice_before_expiration(self) -> None: token = self._create_jwt(self.api_app.client_id, exp=now + timedelta(minutes=4)) request = _drf_request({"grant_type": GrantTypes.CLIENT_SECRET_JWT}, path=path) request.META["HTTP_AUTHORIZATION"] = f"Bearer {token}" - user, _ = self.auth.authenticate(request) - assert user.id == self.sentry_app.proxy_user.id + auth_result = self.auth.authenticate(request) + assert auth_result is not None + user, _ = auth_result + assert user.id == self.sentry_app.proxy_user_id with freeze_time(now + timedelta(minutes=2)): request2 = _drf_request({"grant_type": GrantTypes.CLIENT_SECRET_JWT}, path=path) @@ -554,7 +572,7 @@ def test_no_match(self) -> None: @django_db_all @pytest.mark.parametrize("internal", [True, False]) -def test_registered_relay(internal) -> None: +def test_registered_relay(internal: bool) -> None: sk, pk = generate_key_pair() relay_id = str(uuid.uuid4()) @@ -587,7 +605,7 @@ def test_registered_relay(internal) -> None: @django_db_all @pytest.mark.parametrize("internal", [True, False]) -def test_statically_configured_relay(internal) -> None: +def test_statically_configured_relay(internal: bool) -> None: sk, pk = generate_key_pair() relay_id = str(uuid.uuid4()) @@ -632,7 +650,9 @@ def test_authenticate_success(self) -> None: signature = generate_request_signature(request.path_info, request.body) request.META["HTTP_AUTHORIZATION"] = f"rpcsignature {signature}" - user, token = self.auth.authenticate(request) + auth_result = self.auth.authenticate(request) + assert auth_result is not None + user, token = auth_result assert user.is_anonymous assert token == signature @@ -646,7 +666,9 @@ def test_authenticate_old_key_validates(self) -> None: # Update settings so that we have a new key with override_settings(RPC_SHARED_SECRET=["a-long-secret-key", "an-old-key"]): - user, token = self.auth.authenticate(request) + auth_result = self.auth.authenticate(request) + assert auth_result is not None + user, token = auth_result assert user.is_anonymous assert token == signature @@ -704,7 +726,9 @@ def test_authenticate_success(self) -> None: ) request.META["HTTP_AUTHORIZATION"] = f"rpcsignature {signature}" - user, token = self.auth.authenticate(request) + auth_result = self.auth.authenticate(request) + assert auth_result is not None + user, token = auth_result assert user.is_anonymous assert token == signature @@ -721,7 +745,9 @@ def test_authenticate_old_key_validates(self) -> None: ) request.META["HTTP_AUTHORIZATION"] = f"rpcsignature {signature}" - user, token = self.auth.authenticate(request) + auth_result = self.auth.authenticate(request) + assert auth_result is not None + user, token = auth_result assert user.is_anonymous assert token == signature @@ -751,7 +777,9 @@ class CustomPrefixAuth(HmacSignatureAuthentication): request.META["HTTP_AUTHORIZATION"] = f"rpcsignature {custom_signature}" with override_settings(TEST_SERVICE_RPC_SHARED_SECRET=["test-secret-key"]): - user, token = auth.authenticate(request) + auth_result = auth.authenticate(request) + assert auth_result is not None + user, token = auth_result assert user.is_anonymous assert token == custom_signature @@ -780,7 +808,9 @@ class UrlInSignatureAuth(HmacSignatureAuthentication): ) request.META["HTTP_AUTHORIZATION"] = f"rpcsignature {signature}" - user, token = auth.authenticate(request) + auth_result = auth.authenticate(request) + assert auth_result is not None + user, token = auth_result assert user.is_anonymous assert token == signature @@ -1037,6 +1067,7 @@ def test_api_tokens(self) -> None: ) with assume_test_silo_mode(SiloMode.CONTROL): at = app_install.api_token + assert at is not None with assume_test_silo_mode(SiloMode.CELL): atr = ApiTokenReplica.objects.get(apitoken_id=at.id) diff --git a/tests/sentry/api/test_base.py b/tests/sentry/api/test_base.py index bce5e2713eed..7c572bf3b5cd 100644 --- a/tests/sentry/api/test_base.py +++ b/tests/sentry/api/test_base.py @@ -1,6 +1,6 @@ from collections.abc import Mapping from datetime import datetime -from typing import Any +from typing import Any, cast from unittest import mock from unittest.mock import MagicMock @@ -38,14 +38,14 @@ # Though it looks weird to have a method outside a class, this isn't a mistake but rather # a mock for a method in Django REST Framework's `APIView` class -def reraise(self, e: Exception): +def reraise(self: Any, e: Exception) -> None: raise e class DummyEndpoint(Endpoint): permission_classes: tuple[type[BasePermission], ...] = () - def get(self, request): + def get(self, request: Request) -> Response: return Response({"ok": True}) @@ -76,7 +76,7 @@ class DummySecondDeclaredScopePermission(AllowAny): class DummyScopeCheckingEndpoint(Endpoint): permission_classes = (DummyDeclaredScopePermission, DummySecondDeclaredScopePermission) - def get(self, request): + def get(self, request: Request) -> Response: request.access.has_scope("org:read") request.access.has_scope("team:read") request.access.has_scope("project:write") @@ -104,10 +104,16 @@ class DummyErroringEndpoint(Endpoint): handler_context_arg: Mapping[str, Any] | None = None scope_arg: Scope | None = None - def get(self, request): + def get(self, request: Request) -> Response: raise self.error - def handle_exception_with_details(self, request, exc, handler_context=None, scope=None): + def handle_exception_with_details( + self, + request: Request, + exc: Exception, + handler_context: Mapping[str, Any] | None = None, + scope: Scope | None = None, + ) -> Response: return super().handle_exception_with_details( request, exc, self.handler_context_arg, self.scope_arg ) @@ -116,10 +122,10 @@ def handle_exception_with_details(self, request, exc, handler_context=None, scop class DummyPaginationEndpoint(Endpoint): permission_classes = () - def get(self, request): + def get(self, request: Request) -> Response: values = [x for x in range(0, 100)] - def data_fn(offset, limit): + def data_fn(offset: int, limit: int) -> list[int]: page_offset = offset * limit return values[page_offset : page_offset + limit] @@ -133,10 +139,10 @@ def data_fn(offset, limit): class DummyPaginationStreamingEndpoint(Endpoint): permission_classes = () - def get(self, request): + def get(self, request: Request) -> Response: values = [x for x in range(0, 100)] - def data_fn(offset, limit): + def data_fn(offset: int, limit: int) -> list[int]: page_offset = offset * limit return values[page_offset : page_offset + limit] @@ -144,13 +150,16 @@ def data_fn(offset, limit): request=request, paginator=GenericOffsetPaginator(data_fn), on_results=lambda results: iter(results), - response_cls=StreamingHttpResponse, + # justification: paginate supports streaming responses but is typed for DRF responses + response_cls=cast(type[Response], StreamingHttpResponse), response_kwargs={"content_type": "application/json"}, ) class SometimesFailingSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, obj: int, attrs: Mapping[Any, Any], user: Any, **kwargs: Any + ) -> Mapping[str, Any]: if obj % 2: raise Exception return {"id": obj} @@ -159,10 +168,10 @@ def serialize(self, obj, attrs, user, **kwargs): class DummyPaginationFailingSerializerEndpoint(Endpoint): permission_classes = () - def get(self, request): + def get(self, request: Request) -> Response: values = [x for x in range(0, 10)] - def data_fn(offset, limit): + def data_fn(offset: int, limit: int) -> list[int]: page_offset = offset * limit return values[page_offset : page_offset + limit] @@ -495,19 +504,19 @@ class EndpointHandleExceptionTest(APITestCase): @mock.patch("rest_framework.views.APIView.handle_exception", return_value=Response(status=500)) def test_handle_exception_when_super_returns_response( self, mock_super_handle_exception: MagicMock - ): + ) -> None: mock_endpoint = DummyErroringEndpoint.as_view(error=Exception("nope")) response = mock_endpoint(self.make_request(method="GET")) # The endpoint should pass along the response generated by `APIView.handle_exception_with_details` assert response == mock_super_handle_exception.return_value - @mock.patch("rest_framework.views.APIView.handle_exception", new=reraise) + @mock.patch("rest_framework.views.APIView.handle_exception", reraise) @mock.patch("sentry.api.base.capture_exception", return_value="1231201211212012") def test_handle_exception_when_super_reraises( self, mock_capture_exception: MagicMock, - ): + ) -> None: handler_context = {"api_request_URL": "http://dogs.are.great/"} scope = Scope() tags = {"maisey": "silly", "charlie": "goofy"} @@ -681,7 +690,7 @@ def test_custom_response_type(self) -> None: @all_silo_test(cells=create_test_cells("us", "eu")) class CustomerDomainTest(APITestCase): def test_resolve_region(self) -> None: - def request_with_subdomain(subdomain): + def request_with_subdomain(subdomain: str) -> bool: request = self.make_request(method="GET") request.subdomain = subdomain return subdomain_is_locality(request) @@ -692,14 +701,16 @@ def request_with_subdomain(subdomain): class EndpointSiloLimitTest(APITestCase): - def _test_active_on(self, endpoint_mode, active_mode, expect_to_be_active): + def _test_active_on( + self, endpoint_mode: SiloMode, active_mode: SiloMode, expect_to_be_active: bool + ) -> None: @EndpointSiloLimit(endpoint_mode) class DecoratedEndpoint(DummyEndpoint): pass class EndpointWithDecoratedMethod(DummyEndpoint): @EndpointSiloLimit(endpoint_mode) - def get(self, request): + def get(self, request: Request) -> Response: return super().get(request) with override_settings(SILO_MODE=active_mode): @@ -744,9 +755,11 @@ def test_internal_option(self) -> None: class FunctionSiloLimitTest(APITestCase): - def _test_active_on(self, endpoint_mode, active_mode, expect_to_be_active): + def _test_active_on( + self, endpoint_mode: SiloMode, active_mode: SiloMode, expect_to_be_active: bool + ) -> None: @FunctionSiloLimit(endpoint_mode) - def decorated_function(): + def decorated_function() -> None: pass with override_settings(SILO_MODE=active_mode): @@ -809,12 +822,14 @@ def test_access_property_set_before_convert_args(self) -> None: class AccessUsingEndpoint(Endpoint): permission_classes = () - def convert_args(self, request, *args, **kwargs): + def convert_args( + self, request: Request, *args: Any, **kwargs: Any + ) -> tuple[tuple[Any, ...], dict[str, Any]]: # This should not raise an AttributeError assert request.access is not None return (args, kwargs) - def get(self, request): + def get(self, request: Request) -> Response: return Response({"ok": True}) response = AccessUsingEndpoint.as_view()(self.request) diff --git a/tests/sentry/api/test_client.py b/tests/sentry/api/test_client.py index 819b8e43c360..3cfe2977d63d 100644 --- a/tests/sentry/api/test_client.py +++ b/tests/sentry/api/test_client.py @@ -1,4 +1,4 @@ -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch from django.http import JsonResponse @@ -15,7 +15,7 @@ class ClientParameterHandlingTest(TestCase): @patch("sentry.api.client.resolve") - def test_mixed_parameters_in_query_string(self, mock_resolve): + def test_mixed_parameters_in_query_string(self, mock_resolve: MagicMock) -> None: mock_view = Mock(return_value=JsonResponse({"success": True})) mock_resolve.return_value = (mock_view, (), {}) @@ -53,7 +53,7 @@ def test_mixed_parameters_in_query_string(self, mock_resolve): class ClientViewerContextTest(TestCase): @patch("sentry.api.client.resolve") - def test_view_caller_does_not_leak(self, mock_resolve): + def test_view_caller_does_not_leak(self, mock_resolve: MagicMock) -> None: mock_view = Mock(return_value=JsonResponse({})) mock_resolve.return_value = (mock_view, (), {}) outer = ViewerContext(actor=Actor(type=ActorType.UNKNOWN)) diff --git a/tests/sentry/api/test_client_kind.py b/tests/sentry/api/test_client_kind.py index 345d3b30f6fc..2fb44230d99d 100644 --- a/tests/sentry/api/test_client_kind.py +++ b/tests/sentry/api/test_client_kind.py @@ -1,5 +1,5 @@ from types import SimpleNamespace -from typing import Any +from typing import Any, cast from unittest import mock import pytest @@ -513,7 +513,8 @@ def setUp(self) -> None: def tags_for(self, url: str) -> list[Any]: with mock.patch("sentry.api.client_kind.sentry_sdk") as sdk: assert self.client.get(url).status_code == 200 - return sdk.set_tag.call_args_list + # justification: Mock records plain positional tuples. + return cast("list[Any]", sdk.set_tag.call_args_list) def test_an_organization_endpoint_records_the_caller(self) -> None: url = f"/api/0/organizations/{self.organization.slug}/" diff --git a/tests/sentry/api/test_data_secrecy.py b/tests/sentry/api/test_data_secrecy.py index c968b42cc72c..8b9d6334005b 100644 --- a/tests/sentry/api/test_data_secrecy.py +++ b/tests/sentry/api/test_data_secrecy.py @@ -1,11 +1,17 @@ +from typing import cast + import pytest +from django.http import HttpRequest +from rest_framework.request import Request from sentry.api.exceptions import DataSecrecyError from sentry.auth import access +from sentry.auth.access import Access from sentry.organizations.services.organization import organization_service from sentry.testutils.cases import APITestCase, TestCase from sentry.testutils.helpers import override_options from sentry.testutils.helpers.features import with_feature +from sentry.users.models.user import User class DataSecrecyTestCase(APITestCase): @@ -74,12 +80,12 @@ def setUp(self) -> None: self.organization.save() self.create_member(organization=self.organization, user=self.owner, role="owner") - def _build_impersonated_request(self, impersonated_user): + def _build_impersonated_request(self, impersonated_user: User) -> HttpRequest: request = self.make_request(user=impersonated_user) request.actual_user = self.superuser # type: ignore[attr-defined] return request - def _from_request_org_and_scopes(self, request): + def _from_request_org_and_scopes(self, request: HttpRequest) -> Access: rpc_user_org_context = organization_service.get_organization_by_id( id=self.organization.id, user_id=request.user.id ) @@ -128,7 +134,7 @@ def test_impersonated_blocked_via_from_request_db_path(self) -> None: request = self._build_impersonated_request(self.owner) with self.settings(SENTRY_SELF_HOSTED=False): with pytest.raises(DataSecrecyError): - access.from_request(request, self.organization) + access.from_request(cast(Request, request), self.organization) @with_feature("organizations:data-secrecy") def test_impersonated_allowed_via_from_request_db_path_no_data_secrecy(self) -> None: @@ -136,5 +142,5 @@ def test_impersonated_allowed_via_from_request_db_path_no_data_secrecy(self) -> self.organization.save() request = self._build_impersonated_request(self.owner) with self.settings(SENTRY_SELF_HOSTED=False): - result = access.from_request(request, self.organization) + result = access.from_request(cast(Request, request), self.organization) assert result is not None diff --git a/tests/sentry/api/test_event_search.py b/tests/sentry/api/test_event_search.py index 009f398b16a5..72093cc7a0d9 100644 --- a/tests/sentry/api/test_event_search.py +++ b/tests/sentry/api/test_event_search.py @@ -1,6 +1,8 @@ import datetime import os +from collections.abc import Collection, Sequence from datetime import timedelta +from typing import Any, cast from unittest.mock import MagicMock, patch import pytest @@ -41,16 +43,18 @@ abs_fixtures_path = os.path.join(MODULE_ROOT, os.pardir, os.pardir, fixture_path) -def register_fixture_tests(cls, skipped): +def register_fixture_tests(cls: type[object], skipped: Collection[str]) -> None: """ Registers test fixtures onto a class with a run_test_case method """ - def assign_test_case(name, tests): + def assign_test_case(name: str, tests: list[dict[str, Any]]) -> None: if name in skipped: return - def runner(self): + def runner( + self: Any, + ) -> None: # justification: fixture tests set dynamic attributes on the class. for case in tests: self.run_test_case(name, case) @@ -64,7 +68,9 @@ def runner(self): assign_test_case(name, tests) -def result_transformer(result): +def result_transformer( + result: list[dict[str, Any]], +) -> list[Any]: # justification: fixture tokens decode to dynamic JSON. """ This is used to translate the expected token results from the format used in the JSON test data (which is more close to the lossless frontend AST) to @@ -75,7 +81,7 @@ def result_transformer(result): done in the SearchVisitor. """ - def node_visitor(token): + def node_visitor(token: dict[str, Any]) -> Any: if token["type"] == "spaces": return None @@ -212,7 +218,7 @@ def node_visitor(token): } -def build_config(additional_config): +def build_config(additional_config: dict[str, object] | None) -> SearchConfig: if not additional_config: return default_config @@ -237,7 +243,7 @@ class ParseSearchQueryTest(SimpleTestCase): See the `assign_test_case` function. """ - def run_test_case(self, name, case): + def run_test_case(self, name: str, case: dict[str, Any]) -> None: expected = None expect_error = None @@ -1213,7 +1219,7 @@ def test_not_has_raises_when_disabled(self) -> None: (r"\*\*\*aaa\*\*\*", r"***aaa***"), ], ) -def test_search_value(raw, result) -> None: +def test_search_value(raw: str, result: str) -> None: search_value = SearchValue(raw) assert search_value.value == result @@ -1232,7 +1238,7 @@ def test_search_value(raw, result) -> None: "transaction.duration:<1.0", ], ) -def test_search_filter_to_query_string(query) -> None: +def test_search_filter_to_query_string(query: str) -> None: """ Does a round trip (from query string to tokens and back to query string) """ @@ -1254,12 +1260,14 @@ def test_search_filter_to_query_string(query) -> None: (datetime.datetime(2023, 10, 15, 11, 12, 13), "2023-10-15T11:12:13"), ], ) -def test_search_value_to_query_string(value, expected_query_string) -> None: +def test_search_value_to_query_string(value: object, expected_query_string: str) -> None: """ Test turning a QueryValue back to a string usable in a query string """ - search_value = SearchValue(value) + search_value = SearchValue( + cast("str | float | datetime.datetime | Sequence[float] | Sequence[str] | None", value) + ) actual = search_value.to_query_string() assert actual == expected_query_string @@ -1286,12 +1294,16 @@ def test_search_value_to_query_string(value, expected_query_string) -> None: pytest.param("Case*", "prefix", "case", id="prefix is lower cased"), ], ) -def test_search_value_classify_and_format_wildcard(value, expected_kind, expected_value) -> None: +def test_search_value_classify_and_format_wildcard( + value: object, expected_kind: str, expected_value: object +) -> None: """ Test classifying the wildcard type into one of prefix/suffix/infix/other and formatting the value according to the classification results. """ - search_value = SearchValue(value) + search_value = SearchValue( + cast("str | float | datetime.datetime | Sequence[float] | Sequence[str] | None", value) + ) kind, wildcard = search_value.classify_and_format_wildcard() assert (kind, wildcard) == (expected_kind, expected_value) @@ -1307,7 +1319,7 @@ def test_search_value_classify_and_format_wildcard(value, expected_kind, expecte pytest.param("c*o_m%p\\*lex", "c%o\\_m\\%p*lex", id="complex"), ], ) -def test_translate_wildcard_as_clickhouse_pattern(pattern, clickhouse) -> None: +def test_translate_wildcard_as_clickhouse_pattern(pattern: str, clickhouse: str) -> None: assert translate_wildcard_as_clickhouse_pattern(pattern) == clickhouse @@ -1319,7 +1331,7 @@ def test_translate_wildcard_as_clickhouse_pattern(pattern, clickhouse) -> None: pytest.param("\\_"), ], ) -def test_invalid_translate_wildcard_as_clickhouse_pattern(pattern) -> None: +def test_invalid_translate_wildcard_as_clickhouse_pattern(pattern: str) -> None: with pytest.raises(InvalidSearchQuery): assert translate_wildcard_as_clickhouse_pattern(pattern) @@ -1343,7 +1355,7 @@ def test_invalid_translate_wildcard_as_clickhouse_pattern(pattern) -> None: pytest.param("flags[foo:bar,number]:0", "flags[foo:bar,number]", "0"), ], ) -def test_handles_special_character_in_tags_and_flags(query, key, value) -> None: +def test_handles_special_character_in_tags_and_flags(query: str, key: str, value: str) -> None: parsed = parse_search_query(query) assert parsed == [SearchFilter(SearchKey(key), "=", SearchValue(value))] @@ -1367,7 +1379,7 @@ def test_handles_special_character_in_tags_and_flags(query, key, value) -> None: pytest.param("has:flags[foo:bar,number]", "flags[foo:bar,number]"), ], ) -def test_handles_has_tags_and_flags(query, key) -> None: +def test_handles_has_tags_and_flags(query: str, key: str) -> None: parsed = parse_search_query(query) assert parsed == [SearchFilter(SearchKey(key), "!=", SearchValue(""))] @@ -1382,7 +1394,7 @@ def test_handles_has_tags_and_flags(query, key) -> None: pytest.param("\\*test\\*", WILDCARD_OPERATOR_MAP["contains"], "*\\*test\\**"), ], ) -def test_gen_wildcard_value(value, wildcard_op, expected) -> None: +def test_gen_wildcard_value(value: str, wildcard_op: str, expected: str) -> None: assert gen_wildcard_value(value, wildcard_op) == expected @@ -1456,7 +1468,7 @@ def test_gen_wildcard_value(value, wildcard_op, expected) -> None: ), ], ) -def test_handles_contains_wildcard_op_translations(query, expected) -> None: +def test_handles_contains_wildcard_op_translations(query: str, expected: str) -> None: filters = parse_search_query(query) assert len(filters) == 1 assert isinstance(filters[0], SearchFilter) @@ -1534,7 +1546,7 @@ def test_handles_contains_wildcard_op_translations(query, expected) -> None: ), ], ) -def test_handles_starts_with_wildcard_op_translations(query, expected) -> None: +def test_handles_starts_with_wildcard_op_translations(query: str, expected: str) -> None: filters = parse_search_query(query) assert len(filters) == 1 assert isinstance(filters[0], SearchFilter) @@ -1561,7 +1573,7 @@ def test_handles_starts_with_wildcard_op_translations(query, expected) -> None: ], ) def test_parses_regex_op_without_rewriting_the_pattern( - query, expected_operator, expected_value + query: str, expected_operator: str, expected_value: str ) -> None: filters = parse_search_query(query, config=regex_config) assert len(filters) == 1 @@ -1581,7 +1593,7 @@ def test_parses_regex_op_without_rewriting_the_pattern( pytest.param(r"span.op://https?://example\.com///", id="embedded and trailing slashes"), ], ) -def test_round_trips_a_regex_op_through_to_query_string(query) -> None: +def test_round_trips_a_regex_op_through_to_query_string(query: str) -> None: filters = parse_search_query(query, config=regex_config) assert len(filters) == 1 assert isinstance(filters[0], SearchFilter) @@ -1603,7 +1615,7 @@ def test_round_trips_a_regex_op_through_to_query_string(query) -> None: ), ], ) -def test_rejects_an_invalid_regex_pattern(query, expected_message) -> None: +def test_rejects_an_invalid_regex_pattern(query: str, expected_message: str) -> None: with pytest.raises(InvalidSearchQuery) as err: parse_search_query(query, config=regex_config) assert str(err.value) == expected_message @@ -1880,7 +1892,7 @@ def test_parses_a_long_literal_that_starts_with_the_regex_delimiter(query: str) ), ], ) -def test_handles_ends_with_wildcard_op_translations(query, expected) -> None: +def test_handles_ends_with_wildcard_op_translations(query: str, expected: str) -> None: filters = parse_search_query(query) assert len(filters) == 1 assert isinstance(filters[0], SearchFilter) @@ -1964,7 +1976,7 @@ def test_handles_ends_with_wildcard_op_translations(query, expected) -> None: ), ], ) -def test_array_includes_filter(query, expected) -> None: +def test_array_includes_filter(query: str, expected: list[SearchFilter]) -> None: assert parse_search_query(query) == expected @@ -1974,7 +1986,7 @@ def test_array_includes_filter(query, expected) -> None: pytest.param("frame_filenames[*]:", id="rejects_empty_value"), ], ) -def test_array_includes_filter_rejects_invalid_input(query) -> None: +def test_array_includes_filter_rejects_invalid_input(query: str) -> None: with pytest.raises(InvalidSearchQuery): parse_search_query(query) @@ -2006,5 +2018,7 @@ def test_array_includes_filter_rejects_invalid_input(query) -> None: ), ], ) -def test_array_includes_filter_passes_through_comparison_operators(query, expected) -> None: +def test_array_includes_filter_passes_through_comparison_operators( + query: str, expected: list[SearchFilter] +) -> None: assert parse_search_query(query) == expected diff --git a/tests/sentry/api/test_handlers.py b/tests/sentry/api/test_handlers.py index e4e87c25b7e0..8719e7e90468 100644 --- a/tests/sentry/api/test_handlers.py +++ b/tests/sentry/api/test_handlers.py @@ -1,6 +1,7 @@ from django.test import override_settings from django.urls import re_path from rest_framework.permissions import AllowAny +from rest_framework.request import Request from sentry.api.base import Endpoint from sentry.api.utils import handle_query_errors @@ -14,15 +15,15 @@ class RateLimitedEndpoint(Endpoint): permission_classes = (AllowAny,) - def get(self, request): + def get(self, request: Request) -> None: raise RateLimitExceeded( "Rate limit exceeded. Please try your query with a smaller date range or fewer projects." ) - def post(self, request): + def post(self, request: Request) -> None: raise SnubaRPCRateLimitExceeded("Snuba is tired") - def put(self, request): + def put(self, request: Request) -> None: with handle_query_errors(): raise SnubaRPCUnavailable("Snuba is unavailable") diff --git a/tests/sentry/api/test_invite_helper.py b/tests/sentry/api/test_invite_helper.py index 9a7b4060ab86..96c735583a55 100644 --- a/tests/sentry/api/test_invite_helper.py +++ b/tests/sentry/api/test_invite_helper.py @@ -27,6 +27,8 @@ def setUp(self) -> None: organization=self.org, teams=[self.team], ) + assert self.member.token is not None + self.token = self.member.token self.request = HttpRequest() # Needed for audit logs self.request.META["REMOTE_ADDR"] = "127.0.0.1" @@ -34,7 +36,7 @@ def setUp(self) -> None: def test_from_session_with_deleted_member(self) -> None: self.request.session = SessionBase() - add_invite_details_to_session(self.request, self.member.id, self.member.token, self.org.id) + add_invite_details_to_session(self.request, self.member.id, self.token, self.org.id) self.member.delete() logger = Mock() @@ -45,7 +47,7 @@ def test_from_session_with_deleted_member(self) -> None: def test_from_session_with_rotated_token(self) -> None: self.request.session = SessionBase() - add_invite_details_to_session(self.request, self.member.id, self.member.token, self.org.id) + add_invite_details_to_session(self.request, self.member.id, self.token, self.org.id) self.member.update(token="rotatedtoken") helper = ApiInviteHelper.from_session(self.request) @@ -58,7 +60,7 @@ def test_from_session_with_rotated_token(self) -> None: def test_from_session_with_existing_member(self) -> None: self.request.session = SessionBase() - add_invite_details_to_session(self.request, self.member.id, self.member.token, self.org.id) + add_invite_details_to_session(self.request, self.member.id, self.token, self.org.id) existing_member = self.create_member(user=self.user, organization=self.org) logger = Mock() diff --git a/tests/sentry/api/test_organization_events.py b/tests/sentry/api/test_organization_events.py index 22347b35e030..a5860c6a6dca 100644 --- a/tests/sentry/api/test_organization_events.py +++ b/tests/sentry/api/test_organization_events.py @@ -1,6 +1,9 @@ +from collections.abc import Mapping +from typing import Any from unittest import mock from django.urls import reverse +from rest_framework.response import Response from sentry.search.events import constants from sentry.testutils.cases import APITestCase @@ -20,16 +23,24 @@ def setUp(self) -> None: self.ten_mins_ago_iso = self.ten_mins_ago.isoformat() self.features: dict[str, bool] = {} - def client_get(self, *args, **kwargs): - return self.client.get(*args, **kwargs) + def client_get( + self, *args: Any, **kwargs: Any + ) -> Response: # justification: variadic forwarding to the test client. + response: Response[Any] = self.client.get(*args, **kwargs) + return response - def reverse_url(self): + def reverse_url(self) -> str: return reverse( self.viewname, kwargs={"organization_id_or_slug": self.organization.slug}, ) - def do_request(self, query, features=None, **kwargs): + def do_request( + self, + query: Mapping[str, object], + features: dict[str, bool] | None = None, + **kwargs: object, + ) -> Response: if features is None: features = {"organizations:discover-basic": True} features.update(self.features) @@ -178,7 +189,9 @@ def test_valid_referrer(self, mock: mock.MagicMock) -> None: _, kwargs = mock.call_args self.assertEqual(kwargs["referrer"], "api.insights.transaction-summary") - def _api_token_request(self, query, features): + def _api_token_request( + self, query: Mapping[str, object], features: dict[str, bool] + ) -> Response: """Issue an external API-token request, which forces the referrer to `api.auth-token.events` (the "external API request" branch of the blocked-org warning).""" diff --git a/tests/sentry/api/test_paginator.py b/tests/sentry/api/test_paginator.py index fb63247b905f..97e7a906c507 100644 --- a/tests/sentry/api/test_paginator.py +++ b/tests/sentry/api/test_paginator.py @@ -1,5 +1,6 @@ import base64 from datetime import UTC, datetime, timedelta +from typing import Any from unittest import TestCase as SimpleTestCase import pytest @@ -331,6 +332,26 @@ def test_descending(self) -> None: assert result3.next assert not result3.prev + def test_prev_page_with_explicit_descending_queryset(self) -> None: + joined = timezone.now() + + res1 = self.create_user("foo@example.com", date_joined=joined) + res2 = self.create_user("bar@example.com", date_joined=joined + timedelta(seconds=1)) + res3 = self.create_user("baz@example.com", date_joined=joined + timedelta(seconds=2)) + + paginator = DateTimePaginator( + User.objects.order_by("-date_joined"), order_by="-date_joined" + ) + + result1 = paginator.get_result(limit=2, cursor=None) + assert list(result1) == [res3, res2] + + result2 = paginator.get_result(limit=2, cursor=result1.next) + assert list(result2) == [res1] + + result3 = paginator.get_result(limit=2, cursor=result2.prev) + assert list(result3) == [res3, res2] + def test_prev_descending_with_new(self) -> None: joined = timezone.now() @@ -602,7 +623,7 @@ def test_hits(self) -> None: class GenericOffsetPaginatorTest(SimpleTestCase): def test_simple(self) -> None: - def data_fn(offset=None, limit=None): + def data_fn(offset: int, limit: int) -> list[int]: return [i for i in range(offset, limit)] paginator = GenericOffsetPaginator(data_fn=data_fn) @@ -969,7 +990,10 @@ def test_no_duplicates_in_pagination(self) -> None: assert third.next.has_results is False -def dummy_snuba_request_method(limit, offset, org_id, proj_id, timestamp): +# justification: raw Snuba query response data is decoded JSON. +def dummy_snuba_request_method( + limit: int, offset: int, org_id: int, proj_id: int, timestamp: datetime +) -> list[Any]: referrer = "tests.sentry.api.test_paginator" query = Query( match=Entity("events"), @@ -989,7 +1013,10 @@ def dummy_snuba_request_method(limit, offset, org_id, proj_id, timestamp): query=query, tenant_ids={"referrer": referrer, "organization_id": org_id}, ) - return raw_snql_query(request, referrer)["data"] + rows = raw_snql_query(request, referrer)["data"] + if not isinstance(rows, list): + raise TypeError("Snuba returned no data array") + return rows class CallbackPaginatorTest(APITestCase, SnubaTestCase): @@ -1041,7 +1068,8 @@ class TestEAPPageTokenPaginator: cls = EAPPageTokenPaginator def test_first_page_empty(self) -> None: - def data_fn(limit, page_token): + # justification: paginator data payload is heterogeneous. + def data_fn(limit: int, page_token: PageToken | None) -> dict[str, Any]: return { "data": [], "page_token": PageToken(end_pagination=True), @@ -1054,7 +1082,8 @@ def data_fn(limit, page_token): assert page.next.has_results is False def test_first_page_all_data(self) -> None: - def data_fn(limit, page_token): + # justification: paginator data payload is heterogeneous. + def data_fn(limit: int, page_token: PageToken | None) -> dict[str, Any]: return { "data": [1, 2, 3], "page_token": PageToken(end_pagination=True), @@ -1069,7 +1098,8 @@ def data_fn(limit, page_token): def test_first_page_partial_data(self) -> None: expected_page_token = PageToken(filter_offset=TraceItemFilter(and_filter=AndFilter())) - def data_fn(limit, page_token): + # justification: paginator data payload is heterogeneous. + def data_fn(limit: int, page_token: PageToken | None) -> dict[str, Any]: if page_token is None: return { "data": [1, 2, 3], @@ -1088,7 +1118,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/api/test_path_params.py b/tests/sentry/api/test_path_params.py index 4251a8356541..81b38f19d977 100644 --- a/tests/sentry/api/test_path_params.py +++ b/tests/sentry/api/test_path_params.py @@ -1,5 +1,5 @@ import re -from collections.abc import Generator +from collections.abc import Generator, Sequence from typing import Any from django.urls import URLPattern, URLResolver @@ -13,7 +13,9 @@ def extract_slug_path_params(path: str) -> list[str]: return re.findall(r"<(\w+_slug)>", path) -def extract_all_url_patterns(urlpatterns, base: str = "") -> Generator[Any | tuple[str, Any], Any]: +def extract_all_url_patterns( + urlpatterns: Sequence[URLResolver | URLPattern], base: str = "" +) -> Generator[Any | tuple[str, Any], Any]: for pattern in urlpatterns: if isinstance(pattern, URLResolver): yield from extract_all_url_patterns(pattern.url_patterns, base + str(pattern.pattern)) diff --git a/tests/sentry/api/test_permissions.py b/tests/sentry/api/test_permissions.py index 280ce09d1556..74d849ab2c3b 100644 --- a/tests/sentry/api/test_permissions.py +++ b/tests/sentry/api/test_permissions.py @@ -1,5 +1,8 @@ -from unittest.mock import patch +from collections.abc import Sequence +from typing import cast +from unittest.mock import MagicMock, patch +from rest_framework.request import Request from rest_framework.views import APIView from sentry.api.bases.organization import OrganizationPermission @@ -17,9 +20,15 @@ ) from sentry.auth.services.auth import AuthenticatedToken from sentry.demo_mode.utils import READONLY_SCOPES +from sentry.models.apitoken import ApiToken from sentry.organizations.services.organization import organization_service +from sentry.organizations.services.organization.model import ( + RpcOrganizationMember, + RpcUserOrganizationContext, +) from sentry.testutils.cases import APITestCase, DRFPermissionTestCase from sentry.testutils.helpers.options import override_options +from sentry.users.models.user import User class PermissionsTest(DRFPermissionTestCase): @@ -124,7 +133,7 @@ def setUp(self) -> None: self.user = self.create_user() self.organization = self.create_organization(owner=self.user) - def _token_request(self, scopes, method): + def _token_request(self, scopes: Sequence[str], method: str) -> Request: token = self.create_user_auth_token(user=self.user, scope_list=list(scopes)) return self.make_request(user=self.user, auth=token, method=method) @@ -192,14 +201,14 @@ def setUp(self) -> None: self.user = self.create_user() self.organization = self.create_organization(owner=self.user) - def _token_request(self, scopes): + def _token_request(self, scopes: Sequence[str]) -> Request: # Requests authenticated with a token carry an AuthenticatedToken. token = AuthenticatedToken(kind="api_token", scopes=list(scopes), user_id=self.user.id) return self.make_request(user=self.user, auth=token, method="GET") @override_options({"auth.deprecated-scope-translation.enabled": True}) @patch("sentry.auth.deprecated_scope_translation.sentry_sdk.metrics") - def test_deprecated_scope_is_translated(self, mock_metrics) -> None: + def test_deprecated_scope_is_translated(self, mock_metrics: MagicMock) -> None: request = self._token_request(["org:read"]) assert self.permission.has_permission(request, APIView()) mock_metrics.count.assert_called_once_with( @@ -215,14 +224,16 @@ def test_deprecated_scope_not_translated_when_disabled(self) -> None: @override_options({"auth.deprecated-scope-translation.enabled": True}) @patch("sentry.auth.deprecated_scope_translation.sentry_sdk.metrics") - def test_granular_scope_does_not_record_translation(self, mock_metrics) -> None: + def test_granular_scope_does_not_record_translation(self, mock_metrics: MagicMock) -> None: request = self._token_request(["dashboard:read"]) assert self.permission.has_permission(request, APIView()) mock_metrics.count.assert_not_called() @override_options({"auth.deprecated-scope-translation.enabled": True}) @patch("sentry.auth.deprecated_scope_translation.sentry_sdk.metrics") - def test_translation_not_recorded_when_granular_scopes_already_held(self, mock_metrics) -> None: + def test_translation_not_recorded_when_granular_scopes_already_held( + self, mock_metrics: MagicMock + ) -> None: request = self._token_request( [ "org:read", @@ -254,7 +265,7 @@ def setUp(self) -> None: organization_id=self.organization.id, user_id=self.readonly_user.id ).get_scopes() - def _get_rpc_context(self, user): + def _get_rpc_context(self, user: User) -> RpcUserOrganizationContext: rpc_context = organization_service.get_organization_by_id( id=self.organization.id, user_id=user.id ) @@ -326,7 +337,9 @@ def test_determine_access_disabled(self) -> None: organization=readonly_rpc_context, ) - assert readonly_rpc_context.member.scopes == list(self.org_member_scopes) + assert cast(RpcOrganizationMember, readonly_rpc_context.member).scopes == list( + self.org_member_scopes + ) def test_determine_access(self) -> None: with override_options( @@ -344,7 +357,9 @@ def test_determine_access(self) -> None: organization=readonly_rpc_context, ) - assert readonly_rpc_context.member.scopes == sorted(READONLY_SCOPES) + assert cast(RpcOrganizationMember, readonly_rpc_context.member).scopes == sorted( + READONLY_SCOPES + ) def test_determine_access_no_demo_users(self) -> None: with override_options({"demo-mode.enabled": False, "demo-mode.users": []}): @@ -360,7 +375,9 @@ def test_determine_access_no_demo_users(self) -> None: organization=readonly_rpc_context, ) - assert readonly_rpc_context.member.scopes == list(self.org_member_scopes) + assert cast(RpcOrganizationMember, readonly_rpc_context.member).scopes == list( + self.org_member_scopes + ) class InsufficientScopeResponseTest(APITestCase): @@ -373,7 +390,7 @@ def setUp(self) -> None: super().setUp() self.organization = self.create_organization(owner=self.user) - def _token(self, scopes): + def _token(self, scopes: Sequence[str]) -> ApiToken: return self.create_user_auth_token(user=self.user, scope_list=list(scopes)) def test_under_scoped_token_put_returns_insufficient_scope_header(self) -> None: diff --git a/tests/sentry/apidocs/__init__.py b/tests/sentry/apidocs/__init__.py index f62a40c9616f..4548ed7f1438 100644 --- a/tests/sentry/apidocs/__init__.py +++ b/tests/sentry/apidocs/__init__.py @@ -1,9 +1,26 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Any, cast + from drf_spectacular.validation import validate_schema +if TYPE_CHECKING: + from django.http import HttpResponse + from django.urls import URLPattern, URLResolver + from rest_framework.views import APIView + from rest_framework.viewsets import ViewSetMixin + -def generate_schema(route, viewset=None, view=None, view_function=None, patterns=None): +def generate_schema( + route: str | None, + viewset: type[ViewSetMixin] | None = None, + view: type[APIView] | None = None, + view_function: Callable[..., HttpResponse] | None = None, + patterns: Sequence[URLPattern | URLResolver] | None = None, +) -> dict[str, Any]: """ - Taken from drf_spectacular tests + Taken from drf-spectacular tests https://github.com/tfranzel/drf-spectacular/blob/590a2f7f053fbe83446aa453cb4d4a3025410609/tests/__init__.py#L64 """ from django.urls import path @@ -12,18 +29,28 @@ def generate_schema(route, viewset=None, view=None, view_function=None, patterns from rest_framework.viewsets import ViewSetMixin if viewset: + assert route is not None assert issubclass(viewset, ViewSetMixin) router = routers.SimpleRouter() router.register(route, viewset, basename=route) patterns = router.urls elif view: + assert route is not None patterns = [path(route, view.as_view())] elif view_function: + assert route is not None patterns = [path(route, view_function)] else: assert route is None and isinstance(patterns, list) - generator = SchemaGenerator(patterns=patterns) - schema = generator.get_schema(request=None, public=True) - validate_schema(schema) # make sure generated schemas are always valid + assert patterns is not None + # justification: drf-spectacular does not annotate SchemaGenerator. + generator_cls = cast("Callable[..., SchemaGenerator]", SchemaGenerator) + generator = generator_cls(patterns=patterns) + get_schema = cast("Callable[..., dict[str, Any]]", generator.get_schema) + schema = get_schema(request=None, public=True) + assert schema is not None + # justification: drf-spectacular does not annotate validate_schema. + validate = cast("Callable[[dict[str, Any]], None]", validate_schema) + validate(schema) # make sure generated schemas are always valid return schema diff --git a/tests/sentry/apidocs/test_extensions.py b/tests/sentry/apidocs/test_extensions.py index 78b0f9da46e3..8f5d61f1853d 100644 --- a/tests/sentry/apidocs/test_extensions.py +++ b/tests/sentry/apidocs/test_extensions.py @@ -5,6 +5,7 @@ from typing import Any, Literal, TypedDict import pytest +from django.http import HttpRequest from drf_spectacular.openapi import AutoSchema from drf_spectacular.utils import extend_schema, extend_schema_serializer from openapi_schema_validator import OAS30Validator @@ -62,9 +63,15 @@ def serialize( raise NotImplementedError -class FailSerializer(Serializer): - def serialize(self, obj: Any, attrs: Mapping[Any, Any], user: Any, **kwargs: Any): - raise NotImplementedError +FailSerializer = type( + "FailSerializer", + (Serializer,), + { + "serialize": lambda self, obj, attrs, user, **kwargs: (_ for _ in ()).throw( + NotImplementedError() + ) + }, +) def test_sentry_response_serializer_extension() -> None: @@ -243,13 +250,15 @@ def test_investigation_delete_schema_requires_camel_case_versions() -> None: ), ], ) -def test_investigation_polymorphic_requests_match_validation(serializer, payload, valid) -> None: +def test_investigation_polymorphic_requests_match_validation( + serializer: type[serializers.Serializer], payload: dict[str, object], valid: bool +) -> None: class ExampleEndpoint(Endpoint): permission_classes = () schema = InvestigationSchema() @extend_schema(request=serializer, responses={204: None}) - def post(self, request): + def post(self, request: HttpRequest) -> None: pass schema = generate_schema("investigations/", view=ExampleEndpoint) diff --git a/tests/sentry/apidocs/test_omission_guard.py b/tests/sentry/apidocs/test_omission_guard.py index 2c0915aa68a2..efce589cad17 100644 --- a/tests/sentry/apidocs/test_omission_guard.py +++ b/tests/sentry/apidocs/test_omission_guard.py @@ -1,10 +1,11 @@ from __future__ import annotations from collections.abc import Callable, Sequence -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from unittest import mock import pytest +from django.http import HttpRequest from django.urls import path as url_path from drf_spectacular.settings import spectacular_settings from drf_spectacular.utils import OpenApiParameter, extend_schema @@ -54,11 +55,11 @@ class GuardedEndpoint(Endpoint): permission_classes = () @extend_schema(operation_id="guarded", parameters=[GuardedParams]) - def get(self, request): + def get(self, request: HttpRequest) -> None: pass @extend_schema(operation_id="guardedBody", request=GuardedBody) - def post(self, request): + def post(self, request: HttpRequest) -> None: pass @@ -66,7 +67,7 @@ class UnrelatedEndpoint(Endpoint): permission_classes = () @extend_schema(operation_id="unrelated", parameters=[UnrelatedParams]) - def get(self, request): + def get(self, request: HttpRequest) -> None: pass @@ -84,7 +85,7 @@ def _build(hooks: Sequence[Hook] = ()) -> dict[str, Any]: def _parameters(result: dict[str, Any], route: str) -> list[dict[str, Any]]: - return result["paths"][f"/{route}"]["get"]["parameters"] + return cast("list[dict[str, Any]]", result["paths"][f"/{route}"]["get"]["parameters"]) def _parameter(result: dict[str, Any], route: str, name: str) -> dict[str, Any]: @@ -185,7 +186,7 @@ class OnlyRequiredFieldOmittedEndpoint(Endpoint): permission_classes = () @extend_schema(operation_id="onlyRequiredOmitted", request=OnlyRequiredFieldOmittedBody) - def put(self, request): + def put(self, request: HttpRequest) -> None: pass @@ -229,7 +230,7 @@ class ExplicitEndpoint(Endpoint): operation_id="explicit", parameters=[OpenApiParameter("kind", str, enum=["public", "internal"])], ) - def get(self, request): + def get(self, request: HttpRequest) -> None: pass patterns = [url_path("explicit/", ExplicitEndpoint.as_view())] @@ -254,7 +255,7 @@ class DeprecatedNestedEndpoint(Endpoint): permission_classes = () @extend_schema(operation_id="deprecatedNested", request=DeprecatedNestedBody) - def post(self, request): + def post(self, request: HttpRequest) -> None: pass @@ -279,7 +280,7 @@ class GuardedTypedEndpoint(Endpoint): permission_classes = () @extend_schema(operation_id="typed", responses={200: GuardedTypedResponseSerializer}) - def get(self, request): + def get(self, request: HttpRequest) -> None: pass @@ -292,7 +293,8 @@ def _build_typed(hooks: Sequence[Hook] = ()) -> dict[str, Any]: def _typed_item(result: dict[str, Any], where: str) -> dict[str, Any]: """The GuardedItem object inside the response, as the owner or a list item.""" response = result["components"]["schemas"]["GuardedTypedResponse"]["properties"] - return response["owner"] if where == "owner" else response["items"]["items"] + value = response["owner"] if where == "owner" else response["items"]["items"] + return cast("dict[str, Any]", value) def test_a_typed_dict_omission_passes_the_guard_wherever_it_is_nested() -> None: @@ -353,7 +355,7 @@ class UnionEndpoint(Endpoint): permission_classes = () @extend_schema(operation_id="union", responses={200: UnionResponseSerializer}) - def get(self, request): + def get(self, request: HttpRequest) -> None: pass @@ -394,7 +396,7 @@ class ReplacedDefaultEndpoint(Endpoint): operation_id="replacedDefault", parameters=[ReplacedDefaultParams, OpenApiParameter("mode", str, description="Mode.")], ) - def get(self, request): + def get(self, request: HttpRequest) -> None: pass @@ -433,7 +435,7 @@ class CyclicEndpoint(Endpoint): permission_classes = () @extend_schema(operation_id="cyclic", responses={200: CyclicRuleSerializer}) - def get(self, request): + def get(self, request: HttpRequest) -> None: pass diff --git a/tests/sentry/apidocs/test_omission_paths.py b/tests/sentry/apidocs/test_omission_paths.py index 89a99c0be836..42b19523467e 100644 --- a/tests/sentry/apidocs/test_omission_paths.py +++ b/tests/sentry/apidocs/test_omission_paths.py @@ -4,6 +4,7 @@ from unittest import mock import pytest +from django.http import HttpRequest from django.urls import path as url_path from drf_spectacular.drainage import get_override from drf_spectacular.settings import spectacular_settings @@ -348,14 +349,14 @@ class DeclaringEndpoint(Endpoint): permission_classes = () @extend_schema(parameters=[DeclaringParams]) - def get(self, request): + def get(self, request: HttpRequest) -> None: pass class UnrelatedEndpoint(Endpoint): permission_classes = () @extend_schema(parameters=[UnrelatedParams]) - def get(self, request): + def get(self, request: HttpRequest) -> None: pass schema = _build(("declaring/", DeclaringEndpoint), ("unrelated/", UnrelatedEndpoint)) @@ -375,7 +376,7 @@ class DroppedFieldEndpoint(Endpoint): permission_classes = () @extend_schema(parameters=[DroppedFieldParams, OpenApiParameter("other", exclude=True)]) - def get(self, request): + def get(self, request: HttpRequest) -> None: pass schema = _build(("dropped/", DroppedFieldEndpoint)) @@ -406,7 +407,7 @@ class DescribedEndpoint(Endpoint): permission_classes = () @extend_schema(parameters=[DescribedParams]) - def get(self, request): + def get(self, request: HttpRequest) -> None: pass kind = _query(_build(("described/", DescribedEndpoint)), "described/")["kind"] @@ -425,7 +426,7 @@ class DefaultedEndpoint(Endpoint): permission_classes = () @extend_schema(parameters=[DefaultedParams]) - def get(self, request): + def get(self, request: HttpRequest) -> None: pass schema = _build(("defaulted/", DefaultedEndpoint)) @@ -444,7 +445,7 @@ class BodyEndpoint(Endpoint): permission_classes = () @extend_schema(request=WithheldBody) - def post(self, request): + def post(self, request: HttpRequest) -> None: pass schema = _build(("body/", BodyEndpoint)) @@ -462,7 +463,7 @@ class GhostEndpoint(Endpoint): permission_classes = () @extend_schema(parameters=[GhostParams]) - def get(self, request): + def get(self, request: HttpRequest) -> None: pass with pytest.raises(SentryApiBuildError) as exc: @@ -483,7 +484,7 @@ class OverriddenEndpoint(Endpoint): @extend_schema( parameters=[OverriddenParams, OpenApiParameter("kind", str, description="Kind.")] ) - def get(self, request): + def get(self, request: HttpRequest) -> None: pass kind = _query(_build(("overridden/", OverriddenEndpoint)), "overridden/")["kind"] diff --git a/tests/sentry/apidocs/test_schema.py b/tests/sentry/apidocs/test_schema.py index afaf1f2cc6f1..5216c4332837 100644 --- a/tests/sentry/apidocs/test_schema.py +++ b/tests/sentry/apidocs/test_schema.py @@ -1,4 +1,5 @@ import pytest +from django.http import HttpRequest from drf_spectacular.utils import extend_schema from sentry.api.base import Endpoint @@ -24,7 +25,7 @@ class ExampleEndpoint(Endpoint): permission_classes = () @extend_schema(operation_id=op_id) - def get(self, request, *args, **kwargs): + def get(self, request: HttpRequest, *args: object, **kwargs: object) -> None: pass schema = generate_schema("foo", view=ExampleEndpoint) @@ -35,7 +36,7 @@ def test_description() -> None: class ExampleEndpoint(Endpoint): permission_classes = () - def get(self, request, *args, **kwargs): + def get(self, request: HttpRequest, *args: object, **kwargs: object) -> None: """ Operation ID @@ -49,19 +50,19 @@ def get(self, request, *args, **kwargs): """ @extend_schema(operation_id="Ignore Docstring") - def post(self, request): + def post(self, request: HttpRequest) -> None: """ Autoschema Description Extended Lines """ - def put(self, request): + def put(self, request: HttpRequest) -> None: """ Autoschema Description """ # Should not result in an error when generating a schema - def delete(self, request): + def delete(self, request: HttpRequest) -> None: pass schema = generate_schema("foo", view=ExampleEndpoint) diff --git a/tests/sentry/attachments/test_base.py b/tests/sentry/attachments/test_base.py index f1a98540f8c3..74dc6d9804d4 100644 --- a/tests/sentry/attachments/test_base.py +++ b/tests/sentry/attachments/test_base.py @@ -11,23 +11,23 @@ class InMemoryCache: internal assertions to ensure correct use of `raw`. """ - def __init__(self): - self.data = {} + def __init__(self) -> None: + self.data: dict[str, object] = {} #: Used to check for consistent usage of `raw` param - self.raw_map = {} + self.raw_map: dict[str, bool] = {} - def get(self, key, raw=False): + def get(self, key: str, raw: bool = False) -> object | None: assert key not in self.raw_map or raw == self.raw_map[key] return copy.deepcopy(self.data.get(key)) - def set(self, key, value, timeout=None, raw=False): + def set(self, key: str, value: object, timeout: int | None = None, raw: bool = False) -> None: # Attachment chunks MUST be bytestrings. Josh please don't change this # to unicode. assert isinstance(value, bytes) or not raw assert key not in self.raw_map or raw == self.raw_map[key] self.data[key] = value - def delete(self, key): + def delete(self, key: str) -> None: del self.data[key] diff --git a/tests/sentry/audit_log/services/test_log.py b/tests/sentry/audit_log/services/test_log.py index c069e513f32e..ed952f520177 100644 --- a/tests/sentry/audit_log/services/test_log.py +++ b/tests/sentry/audit_log/services/test_log.py @@ -94,8 +94,8 @@ def test_audit_log_event_bad_target_user_id() -> None: with assume_test_silo_mode(SiloMode.CONTROL): log = AuditLogEntry.objects.get() - assert log.actor_id is None - assert log.target_user_id is None + assert not log.actor_id + assert not log.target_user_id @django_db_all diff --git a/tests/sentry/auth/providers/fly/test_client.py b/tests/sentry/auth/providers/fly/test_client.py index 3510331368a6..9813b21bb2bd 100644 --- a/tests/sentry/auth/providers/fly/test_client.py +++ b/tests/sentry/auth/providers/fly/test_client.py @@ -1,4 +1,5 @@ from unittest import mock +from unittest.mock import MagicMock import pytest import responses @@ -8,12 +9,12 @@ @pytest.fixture -def client(): +def client() -> FlyClient: return FlyClient("accessToken") @responses.activate -def test_request_sends_access_token(client) -> None: +def test_request_sends_access_token(client: FlyClient) -> None: responses.add(responses.GET, f"{ACCESS_TOKEN_URL}/", json={"status": "SUCCESS"}, status=200) client._request("/") @@ -22,6 +23,6 @@ def test_request_sends_access_token(client) -> None: @mock.patch.object(FlyClient, "_request") -def test_get_info(mock_request, client) -> None: +def test_get_info(mock_request: MagicMock, client: FlyClient) -> None: client.get_info() mock_request.assert_called_once_with("/info") diff --git a/tests/sentry/auth/providers/fly/test_provider.py b/tests/sentry/auth/providers/fly/test_provider.py index f47531e93b43..f013ced27ef8 100644 --- a/tests/sentry/auth/providers/fly/test_provider.py +++ b/tests/sentry/auth/providers/fly/test_provider.py @@ -1,7 +1,10 @@ +from typing import cast + import pytest from sentry.auth.exceptions import IdentityNotValid from sentry.auth.partnership_configs import ChannelName +from sentry.auth.providers.fly.provider import FlyOAuth2Provider from sentry.models.authidentity import AuthIdentity from sentry.models.authprovider import AuthProvider from sentry.testutils.cases import TestCase @@ -21,20 +24,20 @@ def test_refresh_identity_without_refresh_token(self) -> None: auth_provider=self.auth_provider, user=self.user, data={"access_token": "access_token"} ) - provider = self.auth_provider.get_provider() + provider = cast(FlyOAuth2Provider, self.auth_provider.get_provider()) with pytest.raises(IdentityNotValid): provider.refresh_identity(auth_identity) def test_build_config(self) -> None: - provider = self.auth_provider.get_provider() + provider = cast(FlyOAuth2Provider, self.auth_provider.get_provider()) resource = {"id": "nathans-org", "role": "member"} result = provider.build_config(resource=resource) assert result == {"org": {"id": "nathans-org"}} assert provider.is_partner == (self.auth_provider.provider == ChannelName.FLY_IO.value) def test_build_identity(self) -> None: - provider = self.auth_provider.get_provider() + provider = cast(FlyOAuth2Provider, self.auth_provider.get_provider()) data = { "access_token": "fo1_6xgeCrB8ew8vFQ86vdaakBSFTVDGCzOUvebUbvgPGhI", "token_type": "Bearer", diff --git a/tests/sentry/auth/providers/github/test_client.py b/tests/sentry/auth/providers/github/test_client.py index 93635ded0912..48470000b9a6 100644 --- a/tests/sentry/auth/providers/github/test_client.py +++ b/tests/sentry/auth/providers/github/test_client.py @@ -8,12 +8,12 @@ @pytest.fixture -def client(): +def client() -> GitHubClient: return GitHubClient("accessToken") @responses.activate -def test_request_sends_access_token(client) -> None: +def test_request_sends_access_token(client: GitHubClient) -> None: responses.add(responses.GET, f"https://{API_DOMAIN}/", json={"status": "SUCCESS"}, status=200) client._request("/") @@ -22,24 +22,24 @@ def test_request_sends_access_token(client) -> None: @mock.patch.object(GitHubClient, "_request") -def test_get_org_list(mock_request, client) -> None: +def test_get_org_list(mock_request: mock.MagicMock, client: GitHubClient) -> None: client.get_org_list() mock_request.assert_called_once_with("/user/orgs") @mock.patch.object(GitHubClient, "_request") -def test_get_user(mock_request, client) -> None: +def test_get_user(mock_request: mock.MagicMock, client: GitHubClient) -> None: client.get_user() mock_request.assert_called_once_with("/user") @mock.patch.object(GitHubClient, "_request") -def test_get_user_emails(mock_request, client) -> None: +def test_get_user_emails(mock_request: mock.MagicMock, client: GitHubClient) -> None: client.get_user_emails() mock_request.assert_called_once_with("/user/emails") @mock.patch.object(GitHubClient, "_request", return_value=[{"id": 1396951}]) -def test_is_org_member(mock_request, client) -> None: +def test_is_org_member(mock_request: mock.MagicMock, client: GitHubClient) -> None: got = client.is_org_member(1396951) assert got is True diff --git a/tests/sentry/auth/providers/github/test_views.py b/tests/sentry/auth/providers/github/test_views.py index 562916ede33b..9067ebabd188 100644 --- a/tests/sentry/auth/providers/github/test_views.py +++ b/tests/sentry/auth/providers/github/test_views.py @@ -1,3 +1,4 @@ +from typing import Any, cast from unittest import mock import pytest @@ -17,7 +18,7 @@ @pytest.mark.parametrize("email,expected_name", expected_data) -def test_get_name_from_email(email, expected_name) -> None: +def test_get_name_from_email(email: str, expected_name: str) -> None: assert _get_name_from_email(email) == expected_name @@ -35,12 +36,12 @@ def setUp(self) -> None: def _run( self, *, - user: dict, - emails: list[dict] | None = None, + user: dict[str, Any], + emails: list[dict[str, Any]] | None = None, emails_error: Exception | None = None, returning_user: bool = False, returning_user_active: bool = True, - ) -> dict: + ) -> dict[str, Any]: if returning_user: identity_user = ( self.user if returning_user_active else self.create_user(is_active=False) @@ -66,7 +67,8 @@ def _run( key, bound_user = pipeline.bind_state.call_args.args assert key == "user" - return bound_user + # justification: the bound pipeline user is a decoded attribute mapping. + return cast("dict[str, Any]", bound_user) def test_verified_primary_sets_email_verified(self) -> None: bound_user = self._run( diff --git a/tests/sentry/auth/providers/google/test_provider.py b/tests/sentry/auth/providers/google/test_provider.py index 5dbda8893d00..5d9156c8fa7a 100644 --- a/tests/sentry/auth/providers/google/test_provider.py +++ b/tests/sentry/auth/providers/google/test_provider.py @@ -1,7 +1,10 @@ +from typing import cast + import pytest from sentry.auth.exceptions import IdentityNotValid from sentry.auth.providers.google.constants import DATA_VERSION +from sentry.auth.providers.google.provider import GoogleOAuth2Provider from sentry.models.authidentity import AuthIdentity from sentry.models.authprovider import AuthProvider from sentry.testutils.cases import TestCase @@ -23,7 +26,7 @@ def test_refresh_identity_without_refresh_token(self) -> None: data={"access_token": "access_token"}, ) - provider = self.auth_provider_inst.get_provider() + provider = cast(GoogleOAuth2Provider, self.auth_provider_inst.get_provider()) with pytest.raises(IdentityNotValid): provider.refresh_identity(auth_identity) @@ -31,17 +34,17 @@ def test_refresh_identity_without_refresh_token(self) -> None: def test_handles_multiple_domains(self) -> None: self.auth_provider_inst.update(config={"domains": ["example.com"]}) - provider = self.auth_provider_inst.get_provider() + provider = cast(GoogleOAuth2Provider, self.auth_provider_inst.get_provider()) assert provider.domains == ["example.com"] def test_handles_legacy_single_domain(self) -> None: self.auth_provider_inst.update(config={"domain": "example.com"}) - provider = self.auth_provider_inst.get_provider() + provider = cast(GoogleOAuth2Provider, self.auth_provider_inst.get_provider()) assert provider.domains == ["example.com"] def test_build_config(self) -> None: - provider = self.auth_provider_inst.get_provider() + provider = cast(GoogleOAuth2Provider, self.auth_provider_inst.get_provider()) state = { "domain": "example.com", "user": { diff --git a/tests/sentry/auth/providers/test_oauth2.py b/tests/sentry/auth/providers/test_oauth2.py index 9a0dedc496d0..63c54be9d568 100644 --- a/tests/sentry/auth/providers/test_oauth2.py +++ b/tests/sentry/auth/providers/test_oauth2.py @@ -28,14 +28,14 @@ def get_refresh_token_url(self) -> str: def build_identity(self, state: Mapping[str, Any]) -> Mapping[str, Any]: raise NotImplementedError - def build_config(self, state): + def build_config(self, state: Any) -> dict[str, Any]: raise NotImplementedError @control_silo_test class OAuth2ProviderTest(TestCase): @cached_property - def auth_provider(self): + def auth_provider(self) -> AuthProvider: return AuthProvider.objects.create(provider="oauth2", organization_id=self.organization.id) def test_refresh_identity_without_refresh_token(self) -> None: diff --git a/tests/sentry/auth/test_access.py b/tests/sentry/auth/test_access.py index 2356b67dbd11..ae6b39fdeeb8 100644 --- a/tests/sentry/auth/test_access.py +++ b/tests/sentry/auth/test_access.py @@ -1,7 +1,10 @@ +from collections.abc import Iterable from datetime import timedelta +from typing import cast from unittest.mock import Mock, patch from django.contrib.auth.models import AnonymousUser +from django.http import HttpRequest from django.test import override_settings from django.utils import timezone from rest_framework.request import Request @@ -34,11 +37,11 @@ def silo_from_user( - user, - organization=None, - scopes=None, - is_superuser=False, - is_staff=False, + user: User | AnonymousUser, + organization: Organization | None = None, + scopes: Iterable[str] | None = None, + is_superuser: bool = False, + is_staff: bool = False, ) -> Access: rpc_user_org_context = None if organization: @@ -54,7 +57,11 @@ def silo_from_user( ) -def silo_from_request(request, organization: Organization | None = None, scopes=None) -> Access: +def silo_from_request( + request: HttpRequest, + organization: Organization | None = None, + scopes: Iterable[str] | None = None, +) -> Access: rpc_user_org_context = None if organization: rpc_user_org_context = organization_service.get_organization_by_id( @@ -66,26 +73,40 @@ def silo_from_request(request, organization: Organization | None = None, scopes= class AccessFactoryTestCase(TestCase): - def from_user(self, *args, **kwds): + def from_user( + self, + user: User | AnonymousUser, + organization: Organization | None = None, + scopes: Iterable[str] | None = None, + is_superuser: bool = False, + is_staff: bool = False, + ) -> Access: if SiloMode.get_current_mode() == SiloMode.MONOLITH: - return access.from_user(*args, **kwds) - return silo_from_user(*args, **kwds) - - def from_request(self, *args, **kwds): + return access.from_user(user, organization, scopes, is_superuser, is_staff) + return silo_from_user(user, organization, scopes, is_superuser, is_staff) + + def from_request( + self, + request: HttpRequest, + organization: Organization | None = None, + scopes: Iterable[str] | None = None, + ) -> Access: if SiloMode.get_current_mode() == SiloMode.MONOLITH: - return access.from_request(*args, **kwds) - return silo_from_request(*args, **kwds) + return access.from_request(cast(Request, request), organization, scopes) + return silo_from_request(request, organization, scopes) @assume_test_silo_mode(SiloMode.CONTROL) - def create_api_key(self, organization: Organization, **kwds): + def create_api_key(self, organization: Organization, **kwds: object) -> ApiKey: return ApiKey.objects.create(organization_id=organization.id, **kwds) @assume_test_silo_mode(SiloMode.CONTROL) - def create_auth_provider(self, organization: Organization, **kwds): + def create_auth_provider(self, organization: Organization, **kwds: object) -> AuthProvider: return AuthProvider.objects.create(organization_id=organization.id, **kwds) @assume_test_silo_mode(SiloMode.CONTROL) - def create_auth_identity(self, auth_provider: AuthProvider, user: User, **kwds): + def create_auth_identity( + self, auth_provider: AuthProvider, user: User, **kwds: object + ) -> AuthIdentity: return AuthIdentity.objects.create(auth_provider=auth_provider, user=user, **kwds) @@ -813,7 +834,10 @@ def test_with_valid_auth(self) -> None: self.create_member(user=user, organization=organization, role="admin", teams=[member_team]) request = self.make_request() - request.auth = self.create_api_key(organization=organization, allowed_origins="*") + request.auth = cast( + AuthenticatedToken, + self.create_api_key(organization=organization, allowed_origins="*"), + ) result = self.from_request(request, organization) assert result.team_ids_with_membership == frozenset({}) @@ -836,7 +860,10 @@ def test_with_invalid_auth(self) -> None: request = self.make_request() # Using an API key for another org should be invalid - request.auth = self.create_api_key(organization=other_organization, allowed_origins="*") + request.auth = cast( + AuthenticatedToken, + self.create_api_key(organization=other_organization, allowed_origins="*"), + ) result = self.from_request(request, organization) assert result == NoAccess() diff --git a/tests/sentry/auth/test_helper.py b/tests/sentry/auth/test_helper.py index 39d5d2c7f990..60ffc329adc7 100644 --- a/tests/sentry/auth/test_helper.py +++ b/tests/sentry/auth/test_helper.py @@ -1,13 +1,16 @@ from __future__ import annotations -from typing import TypedDict +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypedDict, cast from unittest import mock +from unittest.mock import MagicMock +from urllib.parse import urlencode import pytest from django.contrib.auth.models import AnonymousUser from django.core import mail from django.db import IntegrityError, models, router, transaction -from django.http import HttpResponseRedirect +from django.http import HttpRequest, HttpResponseRedirect, QueryDict from django.http.response import HttpResponseBase from django.test import Client, RequestFactory from django.urls import reverse @@ -52,8 +55,15 @@ PENDING_VERIFICATION_SESSION_KEY, ) +if TYPE_CHECKING: + from django.http.request import _ImmutableQueryDict -def _set_up_request(): + +def _post_data(data: Mapping[str, str]) -> _ImmutableQueryDict: + return QueryDict(urlencode(data)) + + +def _set_up_request() -> HttpRequest: request = RequestFactory().post("/auth/sso/") request.user = AnonymousUser() request.auth = None @@ -87,10 +97,10 @@ def setUp(self) -> None: self.state = AuthHelperSessionStore(self.request, "pipeline", ttl=PIPELINE_STATE_TTL) @property - def handler(self): + def handler(self) -> AuthIdentityHandler: return self._handler_with(self.identity) - def _handler_with(self, identity): + def _handler_with(self, identity: Mapping[str, Any]) -> AuthIdentityHandler: with assume_test_silo_mode(SiloMode.CELL): rpc_organization = serialize_rpc_organization(self.organization) return AuthIdentityHandler( @@ -101,7 +111,7 @@ def _handler_with(self, identity): identity, ) - def set_up_user(self): + def set_up_user(self) -> User: """Set up a persistent user and associate it to the request. If not called, default to having the request come from an @@ -112,7 +122,7 @@ def set_up_user(self): self.request.user = user return user - def set_up_user_identity(self): + def set_up_user_identity(self) -> tuple[User, AuthIdentity]: """Set up a persistent user who already has an auth identity.""" user = self.set_up_user() auth_identity = self.create_auth_identity( @@ -494,7 +504,7 @@ def test_existing_identity(self, mock_messages: mock.MagicMock) -> None: ) self.assert_org_member_mapping(org_member=org_member) - def _test_with_identity_belonging_to_another_user(self, request_user): + def _test_with_identity_belonging_to_another_user(self, request_user: User) -> None: other_user = self.create_user() # The user logs in with credentials from this other identity @@ -568,7 +578,7 @@ def test_raises_on_session_user_mismatch(self) -> None: @control_silo_test class HandleUnknownIdentityTest(AuthIdentityHandlerTest): - def _test_simple(self, mock_render, expected_template): + def _test_simple(self, mock_render: MagicMock, expected_template: str) -> dict[str, object]: redirect = self.handler.handle_unknown_identity(self.state) assert redirect is mock_render.return_value @@ -587,7 +597,8 @@ def _test_simple(self, mock_render, expected_template): assert context["provider"] == self.auth_provider_inst.get_provider().name assert context["identity_display_name"] == self.identity["name"] assert context["identity_identifier"] == self.email - return context + # justification: the rendered template context is a mapping. + return cast("dict[str, object]", context) @mock.patch("sentry.auth.helper.render_to_response") def test_unauthenticated(self, mock_render: mock.MagicMock) -> None: @@ -650,7 +661,9 @@ def test_automatic_migration( assert email == self.email assert identity_id == self.identity["id"] - assert context["existing_user"].id == existing_user.id + context_existing_user = context["existing_user"] + assert isinstance(context_existing_user, User) + assert context_existing_user.id == existing_user.id assert "login_form" in context @mock.patch("sentry.auth.helper.render_to_response") @@ -708,7 +721,7 @@ def test_new_user_duplicate_email_shows_error( self, mock_render: mock.MagicMock, mock_messages: mock.MagicMock ) -> None: self.create_user(email=self.email, is_test_user=False) - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) response = self.handler.handle_unknown_identity(self.state) assert response is mock_render.return_value @@ -731,7 +744,7 @@ def test_confirm_links_to_session_user_not_email_resolved_user( # Create the user whose email the IdP asserted self.create_user(email=self.email) - self.request.POST = {"op": "confirm"} + self.request.POST = _post_data({"op": "confirm"}) self.handler.handle_unknown_identity(self.state) auth_identity = AuthIdentity.objects.get( @@ -752,7 +765,7 @@ def test_confirm_links_to_session_user_not_secondary_email_user( other_user = self.create_user(email="primary@example.com") self.create_useremail(user=other_user, email=self.email, is_verified=True) - self.request.POST = {"op": "confirm"} + self.request.POST = _post_data({"op": "confirm"}) self.handler.handle_unknown_identity(self.state) auth_identity = AuthIdentity.objects.get( @@ -852,14 +865,14 @@ def test_idp_email_verified_does_not_auto_link_unauthenticated_user( with assume_test_silo_mode(SiloMode.CELL): self.create_member(user=existing_user, organization=self.organization, role="member") - identity: _Identity = { + identity: dict[str, object] = { "id": self.identity["id"], "email": self.email, "name": self.identity["name"], "data": {}, } + identity["email_verified"] = True handler = self._handler_with(identity) - handler.identity["email_verified"] = True response = handler.handle_unknown_identity(self.state) @@ -878,7 +891,7 @@ def test_confirm_unauthenticated_unverified_shows_merge_error( """op=confirm without authentication or email verification shows ERR_MERGE_FAILED.""" self.create_user(email=self.email) - self.request.POST = {"op": "confirm"} + self.request.POST = _post_data({"op": "confirm"}) response = self.handler.handle_unknown_identity(self.state) assert response is mock_render.return_value @@ -891,7 +904,7 @@ def test_confirm_unauthenticated_unverified_shows_merge_error( @mock.patch("sentry.auth.helper.messages") def test_newuser_creates_account_and_identity(self, mock_messages: mock.MagicMock) -> None: """op=newuser creates a new User and links the AuthIdentity to them.""" - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) self.handler.handle_unknown_identity(self.state) auth_identity = AuthIdentity.objects.get( @@ -912,7 +925,7 @@ def test_login_with_valid_credentials_returns_confirmation( mock_form.is_valid.return_value = True mock_form.get_user.return_value = existing_user - self.request.POST = {"op": "login"} + self.request.POST = _post_data({"op": "login"}) with mock.patch.object( type(self.handler), "_login_form", new_callable=lambda: property(lambda s: mock_form) ): @@ -932,7 +945,7 @@ def test_login_with_invalid_credentials_returns_confirmation( mock_form = mock.MagicMock() mock_form.is_valid.return_value = False - self.request.POST = {"op": "login", "username": self.email} + self.request.POST = _post_data({"op": "login", "username": self.email}) with mock.patch.object( type(self.handler), "_login_form", new_callable=lambda: property(lambda s: mock_form) ): @@ -971,7 +984,7 @@ def setUp(self) -> None: self.request = _set_up_request() self.request.session["auth_key"] = self.auth_key - def _test_pipeline(self, flow, referrer=None): + def _test_pipeline(self, flow: int, referrer: str | None = None) -> HttpResponseRedirect: initial_state = { "org_id": self.organization.id, "flow": flow, @@ -994,7 +1007,7 @@ def _test_pipeline(self, flow, referrer=None): next_step = helper.next_step() assert next_step.status_code == 302 - return next_step + return cast(HttpResponseRedirect, next_step) @mock.patch("sentry.auth.helper.messages") def test_login(self, mock_messages: mock.MagicMock) -> None: @@ -1270,7 +1283,7 @@ def setUp(self) -> None: self.request = _set_up_request() self.request.session["auth_key"] = self.auth_key - def _create_helper_with_state(self, provider_key=None): + def _create_helper_with_state(self, provider_key: str | None = None) -> AuthHelper: """Create an AuthHelper with initial state and optional provider key mismatch.""" initial_state = { "org_id": self.organization.id, @@ -1305,6 +1318,7 @@ def test_provider_mismatch_redirects_to_correct_sso( response = helper.finish_pipeline() # Should redirect to org SSO page + assert isinstance(response, HttpResponseRedirect) assert response.status_code == 302 assert f"/auth/login/{self.organization.slug}/" in response.url @@ -1451,7 +1465,7 @@ def test_setup_links_to_admin_when_email_matches_no_user( class InactiveUserIdentityTest(AuthIdentityHandlerTest): """Tests that inactive-user AuthIdentity always routes through handle_unknown_identity.""" - def _create_inactive_user_with_identity(self): + def _create_inactive_user_with_identity(self) -> tuple[User, AuthIdentity]: """Create an inactive user with an AuthIdentity matching self.identity.""" inactive_user = self.create_user(is_active=False) auth_identity = self.create_auth_identity( @@ -1502,7 +1516,7 @@ class HandleNewUserTransactionRollbackTest(AuthIdentityHandlerTest): def test_plain_newuser_rollback_shows_confirmation_instead_of_500( self, mock_messages: mock.MagicMock, mock_render: mock.MagicMock ) -> None: - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) with mock.patch("sentry.auth.helper.User.objects.create", side_effect=ValueError("boom")): response = self.handler.handle_unknown_identity(self.state) @@ -1516,7 +1530,7 @@ def test_plain_newuser_rollback_shows_confirmation_instead_of_500( @control_silo_test class SSOEmailVerificationRequiredTest(AuthIdentityHandlerTest, HybridCloudTestMixin): def test_flag_off_creates_user_immediately(self) -> None: - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) with override_options({"auth.email-verification-at-signup.sso-enabled": False}): self.handler.handle_unknown_identity(self.state) @@ -1539,7 +1553,7 @@ def test_flag_on_trust_check_passes_creates_user_with_verified_email( identity["email_verified"] = True # type: ignore[typeddict-unknown-key] handler = self._handler_with(identity) - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) with override_options({"auth.email-verification-at-signup.sso-enabled": True}): with mock.patch.object(handler, "provider") as mock_provider: mock_provider.key = "google" @@ -1557,7 +1571,7 @@ def test_confirm_op_does_not_trigger_verified_user_creation( self.state.regenerate({"flow": FLOW_LOGIN}) self.state.verified_email = self.email - self.request.POST = {"op": "confirm"} + self.request.POST = _post_data({"op": "confirm"}) self.handler.handle_unknown_identity(self.state) assert not User.objects.filter(email=self.email).exists() @@ -1571,7 +1585,7 @@ def test_login_op_does_not_trigger_verified_user_creation( self.state.regenerate({"flow": FLOW_LOGIN}) self.state.verified_email = self.email - self.request.POST = {"op": "login", "username": self.email} + self.request.POST = _post_data({"op": "login", "username": self.email}) self.handler.handle_unknown_identity(self.state) assert not User.objects.filter(email=self.email).exists() @@ -1581,7 +1595,7 @@ def test_resubmitting_after_verification_does_not_create_duplicate_user(self) -> self.state.regenerate({"flow": FLOW_LOGIN}) self.state.verified_email = self.email - self.request.POST = {} + self.request.POST = _post_data({}) self.handler.handle_unknown_identity(self.state) assert User.objects.filter(email=self.email).count() == 1 @@ -1601,7 +1615,7 @@ def test_resubmitting_after_verification_does_not_create_duplicate_user(self) -> def test_flag_on_untrusted_provider_sends_verification_email( self, mock_send: mock.MagicMock ) -> None: - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) with override_options({"auth.email-verification-at-signup.sso-enabled": True}): response = self.handler.handle_unknown_identity(self.state) @@ -1626,7 +1640,7 @@ def test_flag_on_untrusted_provider_sends_verification_email( def test_allowlist_forces_verification_even_without_sso_enabled( self, mock_send: mock.MagicMock ) -> None: - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) response = self.handler.handle_unknown_identity(self.state) assert isinstance(response, HttpResponseRedirect) @@ -1644,7 +1658,7 @@ def test_valid_invite_token_exempts_from_verification(self) -> None: self.request.session["invite_token"] = member.token self.save_session() - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) with override_options({"auth.email-verification-at-signup.sso-enabled": True}): with mock.patch("sentry.auth.helper.send_signup_verification_email") as mock_send: self.handler.handle_unknown_identity(self.state) @@ -1662,7 +1676,7 @@ def test_invite_token_email_mismatch_still_requires_verification(self) -> None: self.request.session["invite_token"] = member.token self.save_session() - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) with override_options({"auth.email-verification-at-signup.sso-enabled": True}): with mock.patch("sentry.auth.helper.send_signup_verification_email") as mock_send: response = self.handler.handle_unknown_identity(self.state) @@ -1676,7 +1690,7 @@ def test_invite_without_session_token_still_requires_verification(self) -> None: with assume_test_silo_mode(SiloMode.CELL): OrganizationMember.objects.create(organization=self.organization, email=self.email) - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) with override_options({"auth.email-verification-at-signup.sso-enabled": True}): with mock.patch("sentry.auth.helper.send_signup_verification_email") as mock_send: response = self.handler.handle_unknown_identity(self.state) @@ -1697,7 +1711,7 @@ def test_authenticated_user_without_org_membership_still_gets_invite_exemption(s self.save_session() self.set_up_user() - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) with override_options({"auth.email-verification-at-signup.sso-enabled": True}): with mock.patch("sentry.auth.helper.send_signup_verification_email") as mock_send: self.handler.handle_unknown_identity(self.state) @@ -1745,7 +1759,7 @@ def test_unapproved_invite_does_not_grant_verification_exemption(self) -> None: self.request.session["invite_token"] = member.token self.save_session() - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) with override_options({"auth.email-verification-at-signup.sso-enabled": True}): with mock.patch("sentry.auth.helper.send_signup_verification_email") as mock_send: response = self.handler.handle_unknown_identity(self.state) @@ -1758,7 +1772,7 @@ def test_unapproved_invite_does_not_grant_verification_exemption(self) -> None: def test_invite_check_failure_falls_back_to_requiring_verification( self, mock_send: mock.MagicMock ) -> None: - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) with override_options({"auth.email-verification-at-signup.sso-enabled": True}): with mock.patch( "sentry.auth.helper.ApiInviteHelper.from_session_or_email", @@ -1781,7 +1795,7 @@ def test_transaction_rollback_after_verification_allows_resend( self.state.verification_email_sent = True handler = self.handler - self.request.POST = {} + self.request.POST = _post_data({}) with mock.patch("sentry.auth.helper.User.objects.create", side_effect=ValueError("boom")): response = handler.handle_unknown_identity(self.state) @@ -1789,7 +1803,7 @@ def test_transaction_rollback_after_verification_allows_resend( assert getattr(self.state, "verification_email_sent", None) is False assert not User.objects.filter(email=self.email).exists() - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) with override_options({"auth.email-verification-at-signup.sso-enabled": True}): with mock.patch("sentry.auth.helper.send_signup_verification_email") as mock_send: handler.handle_unknown_identity(self.state) @@ -1804,13 +1818,13 @@ def test_retry_after_failed_verification_does_not_bypass_pipeline( self.state.verified_email = self.email handler = self.handler - self.request.POST = {} + self.request.POST = _post_data({}) with mock.patch("sentry.auth.helper.User.objects.create", side_effect=ValueError("boom")): handler.handle_unknown_identity(self.state) assert getattr(self.state, "verified_email", None) is None - self.request.POST = {} + self.request.POST = _post_data({}) handler.handle_unknown_identity(self.state) assert not User.objects.filter(email=self.email).exists() @@ -1824,7 +1838,7 @@ def test_membership_error_after_verification_does_not_reset_sent_marker( self.state.verification_email_sent = True handler = self.handler - self.request.POST = {} + self.request.POST = _post_data({}) with mock.patch.object( handler, "_handle_new_membership", side_effect=AuthIdentityUserMismatch() ): @@ -1838,21 +1852,23 @@ def test_membership_error_after_verification_does_not_reset_sent_marker( def test_flag_on_refreshes_pipeline_ttl(self, mock_send: mock.MagicMock) -> None: self.state.regenerate({"flow": FLOW_LOGIN}) client = self.state._client - client.expire(self.state.redis_key, 5) - assert client.ttl(self.state.redis_key) <= 5 + redis_key = self.state.redis_key + assert redis_key is not None + client.expire(redis_key, 5) + assert client.ttl(redis_key) <= 5 - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) with override_options({"auth.email-verification-at-signup.sso-enabled": True}): self.handler.handle_unknown_identity(self.state) - assert client.ttl(self.state.redis_key) > 5 + assert client.ttl(redis_key) > 5 @mock.patch("sentry.auth.helper.send_signup_verification_email") def test_resubmitting_confirmation_form_does_not_resend_email( self, mock_send: mock.MagicMock ) -> None: self.state.regenerate({"flow": FLOW_LOGIN}) - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) with override_options({"auth.email-verification-at-signup.sso-enabled": True}): for _ in range(3): @@ -1869,7 +1885,7 @@ def test_rate_limited_send_shows_error_instead_of_pending_page( ) -> None: mock_ratelimiter.backend.is_limited.return_value = True - self.request.POST = {"op": "newuser"} + self.request.POST = _post_data({"op": "newuser"}) with override_options({"auth.email-verification-at-signup.sso-enabled": True}): response = self.handler.handle_unknown_identity(self.state) diff --git a/tests/sentry/auth/test_idpmigration.py b/tests/sentry/auth/test_idpmigration.py index e363f2e3dd64..97320cf030a0 100644 --- a/tests/sentry/auth/test_idpmigration.py +++ b/tests/sentry/auth/test_idpmigration.py @@ -7,6 +7,7 @@ import sentry.auth.idpmigration as idpmigration from sentry.models.authprovider import AuthProvider from sentry.models.organizationmember import OrganizationMember +from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.silo.base import SiloMode from sentry.testutils.cases import TestCase from sentry.testutils.silo import assume_test_silo_mode, control_silo_test @@ -29,7 +30,11 @@ def test_send_one_time_account_confirm_link(self) -> None: with assume_test_silo_mode(SiloMode.CELL): om = OrganizationMember.objects.create(organization=self.org, user_id=self.user.id) link = idpmigration.send_one_time_account_confirm_link( - self.user, self.org, self.provider, self.email, self.IDENTITY_ID + self.user, + serialize_rpc_organization(self.org, include_projects=False, include_teams=False), + self.provider, + self.email, + self.IDENTITY_ID, ) assert re.match(r"auth:one-time-key:\w{32}", link.verification_key) @@ -44,7 +49,11 @@ def test_send_one_time_account_confirm_link(self) -> None: def test_send_without_org_membership(self) -> None: link = idpmigration.send_one_time_account_confirm_link( - self.user, self.org, self.provider, self.email, self.IDENTITY_ID + self.user, + serialize_rpc_organization(self.org, include_projects=False, include_teams=False), + self.provider, + self.email, + self.IDENTITY_ID, ) value = json.loads(cast(str, idpmigration._get_redis_client().get(link.verification_key))) @@ -61,7 +70,11 @@ def test_confirm_link_sent_only_to_verified_account_email(self) -> None: asserted = "other@example.com" with self.tasks(): idpmigration.send_one_time_account_confirm_link( - self.user, self.org, self.provider, asserted, self.IDENTITY_ID + self.user, + serialize_rpc_organization(self.org, include_projects=False, include_teams=False), + self.provider, + asserted, + self.IDENTITY_ID, ) verified = set(self.user.get_verified_emails().values_list("email", flat=True)) @@ -77,7 +90,11 @@ def test_confirm_link_falls_back_to_primary_without_verified_email(self) -> None asserted = "other@example.com" with self.tasks(): idpmigration.send_one_time_account_confirm_link( - self.user, self.org, self.provider, asserted, self.IDENTITY_ID + self.user, + serialize_rpc_organization(self.org, include_projects=False, include_teams=False), + self.provider, + asserted, + self.IDENTITY_ID, ) assert len(mail.outbox) == 1 (sent,) = mail.outbox @@ -106,7 +123,11 @@ def read(key: str) -> object: def test_verify_account(self) -> None: link = idpmigration.send_one_time_account_confirm_link( - self.user, self.org, self.provider, self.email, self.IDENTITY_ID + self.user, + serialize_rpc_organization(self.org, include_projects=False, include_teams=False), + self.provider, + self.email, + self.IDENTITY_ID, ) path = reverse( "sentry-idp-email-verification", @@ -120,7 +141,11 @@ def test_verify_account(self) -> None: def test_verify_account_wrong_key(self) -> None: idpmigration.send_one_time_account_confirm_link( - self.user, self.org, self.provider, self.email, self.IDENTITY_ID + self.user, + serialize_rpc_organization(self.org, include_projects=False, include_teams=False), + self.provider, + self.email, + self.IDENTITY_ID, ) path = reverse( "sentry-idp-email-verification", diff --git a/tests/sentry/auth/test_staff.py b/tests/sentry/auth/test_staff.py index db68bb275123..82132364ef9b 100644 --- a/tests/sentry/auth/test_staff.py +++ b/tests/sentry/auth/test_staff.py @@ -1,9 +1,13 @@ +from collections.abc import Iterator from contextlib import contextmanager from datetime import UTC, datetime, timedelta +from enum import Enum, auto +from typing import cast from unittest import mock from django.contrib.auth.models import AnonymousUser from django.core import signing +from django.http import HttpRequest from django.utils import timezone from sentry.auth import staff @@ -28,7 +32,16 @@ from sentry.users.models.user import User from sentry.utils.auth import mark_sso_complete -UNSET = object() + +class _Unset(Enum): + UNSET = auto() + + +class _StaffRequest(HttpRequest): + staff: Staff + + +UNSET = _Unset.UNSET BASETIME = datetime(2022, 3, 21, 0, 0, tzinfo=UTC) @@ -38,7 +51,7 @@ @contextmanager -def override_org_id(new_org_id: int): +def override_org_id(new_org_id: int) -> Iterator[None]: """ ORG_ID in staff.py is loaded from STAFF_ORG_ID in settings. This happens at the module level, but we cannot override module level variables using @@ -63,16 +76,16 @@ def setUp(self) -> None: def build_request( self, - cookie_token=UNSET, - session_token=UNSET, - expires=UNSET, - uid=UNSET, - session_data=True, - user=None, - ): + cookie_token: str | None | _Unset = UNSET, + session_token: str | _Unset = UNSET, + expires: datetime | _Unset = UNSET, + uid: int | _Unset = UNSET, + session_data: bool = True, + user: User | None = None, + ) -> _StaffRequest: if user is None: user = self.staff_user - request = self.make_request(user=user) + request = cast(_StaffRequest, self.make_request(user=user)) if cookie_token is not None: request.COOKIES[COOKIE_NAME] = signing.get_cookie_signer( salt=COOKIE_NAME + COOKIE_SALT @@ -295,7 +308,7 @@ def test_changed_user(self) -> None: def test_is_active_staff_sys_token(self) -> None: request = self.build_request() - request.auth = SystemToken() + setattr(request, "auth", SystemToken()) assert is_active_staff(request) def test_is_active_staff(self) -> None: @@ -313,5 +326,5 @@ def test_is_not_active_staff(self) -> None: @mock.patch.object(Staff, "is_active", return_value=True) def test_is_active_staff_from_request(self, mock_is_active: mock.MagicMock) -> None: request = self.build_request() - request.staff = None + setattr(request, "staff", None) assert is_active_staff(request) diff --git a/tests/sentry/auth/test_superuser.py b/tests/sentry/auth/test_superuser.py index 6385ae2c0b02..f75f1f70a5a8 100644 --- a/tests/sentry/auth/test_superuser.py +++ b/tests/sentry/auth/test_superuser.py @@ -1,4 +1,5 @@ from datetime import UTC, datetime, timedelta +from typing import cast from unittest import mock from unittest.mock import MagicMock, Mock, patch @@ -7,7 +8,9 @@ from django.core import signing from django.test import override_settings from django.utils import timezone +from rest_framework.request import Request +from sentry.auth.access import RpcBackedAccess from sentry.auth.services.auth.model import RpcAuthState, RpcMemberSsoState from sentry.auth.superuser import ( COOKIE_DOMAIN, @@ -31,6 +34,7 @@ from sentry.auth.system import SystemToken from sentry.middleware.placeholder import placeholder_get_response from sentry.middleware.superuser import SuperuserMiddleware +from sentry.models.organization import Organization from sentry.testutils.cases import TestCase from sentry.testutils.helpers.datetime import freeze_time from sentry.testutils.helpers.options import override_options @@ -41,6 +45,13 @@ UNSET = object() + +class SuperuserTestRequest(Request): + superuser: Superuser | None + access: RpcBackedAccess + organization: Organization + + BASETIME = datetime(2022, 3, 21, 0, 0, tzinfo=UTC) EXPIRE_TIME = timedelta(hours=4, minutes=1) @@ -61,32 +72,34 @@ def setUp(self) -> None: def build_request( self, - cookie_token=UNSET, - session_token=UNSET, - expires=UNSET, - idle_expires=UNSET, - uid=UNSET, - session_data=True, - user=None, - method="get", - ): + cookie_token: object = UNSET, + session_token: object = UNSET, + expires: object = UNSET, + idle_expires: object = UNSET, + uid: object = UNSET, + session_data: bool = True, + user: User | None = None, + method: str = "get", + ) -> SuperuserTestRequest: if user is None: user = self.superuser current_datetime = self.current_datetime - request = self.make_request(user=user, method=method) + request = cast(SuperuserTestRequest, self.make_request(user=user, method=method)) if cookie_token is not None: request.COOKIES[COOKIE_NAME] = signing.get_cookie_signer( salt=COOKIE_NAME + COOKIE_SALT - ).sign(self.default_token if cookie_token is UNSET else cookie_token) + ).sign(self.default_token if cookie_token is UNSET else cast(str, cookie_token)) if session_data: request.session[SESSION_KEY] = { "exp": ( - current_datetime + timedelta(hours=4) if expires is UNSET else expires + current_datetime + timedelta(hours=4) + if expires is UNSET + else cast(datetime, expires) ).strftime("%s"), "idl": ( current_datetime + timedelta(minutes=15) if idle_expires is UNSET - else idle_expires + else cast(datetime, idle_expires) ).strftime("%s"), "tok": self.default_token if session_token is UNSET else session_token, "uid": str(user.id) if uid is UNSET else uid, @@ -283,7 +296,7 @@ def test_su_access_no_request_user_missing_info(self, logger: MagicMock) -> None @override_settings(SENTRY_SELF_HOSTED=False, VALIDATE_SUPERUSER_ACCESS_CATEGORY_AND_REASON=True) def test_su_access_invalid_request_body( self, - ): + ) -> None: request = self.make_request(user=self.superuser, method="PUT") request._body = b'{"invalid" "json"}' @@ -330,6 +343,7 @@ def test_middleware_as_superuser(self) -> None: middleware = SuperuserMiddleware(placeholder_get_response) middleware.process_request(request) + assert request.superuser is not None assert request.superuser.is_active response = Mock() @@ -352,6 +366,7 @@ def test_middleware_as_superuser_without_session(self) -> None: middleware = SuperuserMiddleware(placeholder_get_response) middleware.process_request(request) + assert request.superuser is not None assert not request.superuser.is_active response = Mock() @@ -366,6 +381,7 @@ def test_middleware_as_non_superuser(self) -> None: middleware = SuperuserMiddleware(placeholder_get_response) middleware.process_request(request) + assert request.superuser is not None assert not request.superuser.is_active response = Mock() @@ -392,7 +408,7 @@ def test_changed_user(self) -> None: def test_is_active_superuser_sys_token(self) -> None: request = self.build_request() - request.auth = SystemToken() + setattr(request, "auth", SystemToken()) assert is_active_superuser(request) def test_is_active_superuser(self) -> None: diff --git a/tests/sentry/auth_v2/utils/test_session.py b/tests/sentry/auth_v2/utils/test_session.py index 5bdfe4ce0617..493723c9ce3f 100644 --- a/tests/sentry/auth_v2/utils/test_session.py +++ b/tests/sentry/auth_v2/utils/test_session.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import cast from unittest.mock import Mock from django.contrib.sessions.backends.base import SessionBase @@ -13,22 +14,22 @@ class MockSession(SessionBase): """Mock session class for testing.""" - def __init__(self): - self.data = {} + def __init__(self) -> None: + self.data: dict[str, object] = {} - def get(self, key, default=None): + def get(self, key: str, default: object = None) -> object: return self.data.get(key, default) - def __getitem__(self, key): + def __getitem__(self, key: str) -> object: return self.data[key] - def __setitem__(self, key, value): + def __setitem__(self, key: str, value: object) -> None: self.data[key] = value - def __contains__(self, key): + def __contains__(self, key: object) -> bool: return key in self.data - def get_expiry_date(self, **kwargs): + def get_expiry_date(self, **kwargs: object) -> datetime: return EXPIRY_DATE @@ -87,7 +88,7 @@ def setUp(self) -> None: self.request.session = MockSession() self.session_builder = SessionBuilder(self.request) - def create_mock_user(self, **kwargs): + def create_mock_user(self, **kwargs: bool) -> Mock: # Default attributes that should all evaluate to False attributes = { "has_verified_primary_email": True, @@ -115,7 +116,8 @@ def create_mock_user(self, **kwargs): mock_attributes[key] = value self.request.user = Mock(**mock_attributes) - return self.request.user + # justification: the request user was just replaced with a Mock above. + return cast("Mock", self.request.user) def test_initialize_does_not_override_flags(self) -> None: """ diff --git a/tests/sentry/autofix/test_webhooks.py b/tests/sentry/autofix/test_webhooks.py index 9d7846f8227e..9ae6e2b3746f 100644 --- a/tests/sentry/autofix/test_webhooks.py +++ b/tests/sentry/autofix/test_webhooks.py @@ -1,4 +1,4 @@ -from unittest.mock import call, patch +from unittest.mock import MagicMock, call, patch from django.conf import settings from django.test import override_settings @@ -48,11 +48,11 @@ def create_autofix_issue_data(self, group: Group) -> SeerAutofixIssueData: @patch("sentry.seer.autofix.webhooks.metrics.distribution") def test_opened( self, - mock_metrics_distribution, - mock_metrics_incr, - mock_analytics_record, - mock_get_agent_state_from_pr_id, - ): + mock_metrics_distribution: MagicMock, + mock_metrics_incr: MagicMock, + mock_analytics_record: MagicMock, + mock_get_agent_state_from_pr_id: MagicMock, + ) -> None: group = self.create_group(project=self.project) issue_data = self.create_autofix_issue_data(group) previous_date_updated = issue_data.date_updated @@ -120,8 +120,11 @@ def test_opened( @patch("sentry.seer.autofix.webhooks.analytics.record") @patch("sentry.seer.autofix.webhooks.metrics.incr") def test_opened_sentry_app( - self, mock_metrics_incr, mock_analytics_record, mock_get_agent_state_from_pr_id - ): + self, + mock_metrics_incr: MagicMock, + mock_analytics_record: MagicMock, + mock_get_agent_state_from_pr_id: MagicMock, + ) -> None: group = self.create_group(project=self.project) mock_get_agent_state_from_pr_id.return_value = SeerRunState( run_id=1, @@ -176,11 +179,11 @@ def test_opened_sentry_app( @patch("sentry.seer.autofix.webhooks.metrics.distribution") def test_closed( self, - mock_metrics_distribution, - mock_metrics_incr, - mock_analytics_record, - mock_get_agent_state_from_pr_id, - ): + mock_metrics_distribution: MagicMock, + mock_metrics_incr: MagicMock, + mock_analytics_record: MagicMock, + mock_get_agent_state_from_pr_id: MagicMock, + ) -> None: group = self.create_group(project=self.project) issue_data = self.create_autofix_issue_data(group) mock_get_agent_state_from_pr_id.return_value = SeerRunState( @@ -250,11 +253,11 @@ def test_closed( @patch("sentry.seer.autofix.webhooks.metrics.distribution") def test_merged( self, - mock_metrics_distribution, - mock_metrics_incr, - mock_analytics_record, - mock_get_agent_state_from_pr_id, - ): + mock_metrics_distribution: MagicMock, + mock_metrics_incr: MagicMock, + mock_analytics_record: MagicMock, + mock_get_agent_state_from_pr_id: MagicMock, + ) -> None: group = self.create_group(project=self.project) issue_data = self.create_autofix_issue_data(group) mock_get_agent_state_from_pr_id.return_value = SeerRunState( @@ -319,8 +322,11 @@ def test_merged( @patch("sentry.seer.autofix.webhooks.analytics.record") @patch("sentry.seer.autofix.webhooks.metrics.incr") def test_merges_with_existing_seer_created_attribution( - self, mock_metrics_incr, mock_analytics_record, mock_get_agent_state_from_pr_id - ): + self, + mock_metrics_incr: MagicMock, + mock_analytics_record: MagicMock, + mock_get_agent_state_from_pr_id: MagicMock, + ) -> None: """The seer.pr_created flow may have already written a SEER_DATA/SENTRY_APP row (via ``attribute_seer_created_pull_requests``) for a different group_id than what the live RPC lookup resolves here — the two writes must merge @@ -382,8 +388,11 @@ def test_merges_with_existing_seer_created_attribution( @patch("sentry.seer.autofix.webhooks.analytics.record") @patch("sentry.seer.autofix.webhooks.metrics.incr") def test_skips_attribution_when_pr_number_missing( - self, mock_metrics_incr, mock_analytics_record, mock_get_agent_state_from_pr_id - ): + self, + mock_metrics_incr: MagicMock, + mock_analytics_record: MagicMock, + mock_get_agent_state_from_pr_id: MagicMock, + ) -> None: group = self.create_group(project=self.project) mock_get_agent_state_from_pr_id.return_value = SeerRunState( run_id=1, @@ -435,11 +444,11 @@ def test_skips_attribution_when_pr_number_missing( @patch("sentry.seer.autofix.webhooks.metrics.incr") def test_attribution_failure_does_not_block_analytics( self, - mock_metrics_incr, - mock_analytics_record, - mock_get_agent_state_from_pr_id, - mock_record_attribution_signal, - ): + mock_metrics_incr: MagicMock, + mock_analytics_record: MagicMock, + mock_get_agent_state_from_pr_id: MagicMock, + mock_record_attribution_signal: MagicMock, + ) -> None: group = self.create_group(project=self.project) mock_get_agent_state_from_pr_id.return_value = SeerRunState( run_id=1, @@ -491,8 +500,11 @@ def test_attribution_failure_does_not_block_analytics( @patch("sentry.seer.autofix.webhooks.analytics.record") @patch("sentry.seer.autofix.webhooks.metrics.incr") def test_no_run( - self, mock_metrics_incr, mock_analytics_record, mock_get_agent_state_from_pr_id - ): + self, + mock_metrics_incr: MagicMock, + mock_analytics_record: MagicMock, + mock_get_agent_state_from_pr_id: MagicMock, + ) -> None: mock_get_agent_state_from_pr_id.return_value = None handle_github_pr_webhook_for_autofix( self.organization, @@ -522,8 +534,11 @@ def test_no_run( @patch("sentry.seer.autofix.webhooks.get_agent_state_from_pr_id") @patch("sentry.seer.autofix.webhooks.analytics.record") def test_run_without_group_is_skipped( - self, mock_analytics_record, mock_get_agent_state_from_pr_id, mock_capture_exception - ): + self, + mock_analytics_record: MagicMock, + mock_get_agent_state_from_pr_id: MagicMock, + mock_capture_exception: MagicMock, + ) -> None: mock_get_agent_state_from_pr_id.return_value = SeerRunState( run_id=1, blocks=[], status="completed", updated_at="2025-01-15T10:30:00Z", metadata={} ) @@ -546,8 +561,11 @@ def test_run_without_group_is_skipped( @patch("sentry.seer.autofix.webhooks.analytics.record") @patch("sentry.seer.autofix.webhooks.metrics.incr") def test_no_settings_github_app_id_set( - self, mock_metrics_incr, mock_analytics_record, mock_get_agent_state_from_pr_id - ): + self, + mock_metrics_incr: MagicMock, + mock_analytics_record: MagicMock, + mock_get_agent_state_from_pr_id: MagicMock, + ) -> None: mock_get_agent_state_from_pr_id.return_value = None handle_github_pr_webhook_for_autofix( self.organization, @@ -573,8 +591,11 @@ def test_no_settings_github_app_id_set( @patch("sentry.seer.autofix.webhooks.analytics.record") @patch("sentry.seer.autofix.webhooks.metrics.incr") def test_no_different_github_app( - self, mock_metrics_incr, mock_analytics_record, mock_get_agent_state_from_pr_id - ): + self, + mock_metrics_incr: MagicMock, + mock_analytics_record: MagicMock, + mock_get_agent_state_from_pr_id: MagicMock, + ) -> None: mock_get_agent_state_from_pr_id.return_value = None handle_github_pr_webhook_for_autofix( self.organization, diff --git a/tests/sentry/backup/__init__.py b/tests/sentry/backup/__init__.py index d3695b786fa0..e2cd926c89a3 100644 --- a/tests/sentry/backup/__init__.py +++ b/tests/sentry/backup/__init__.py @@ -110,7 +110,9 @@ def verify_models_in_output(expected_models: list[type[models.Model]], actual_js raise AssertionError(f"Model {name} has {num} mistakes: {mistakes}") -def expect_models(group: set[NormalizedModelName], *marking: type | Literal["__all__"]) -> Callable: +def expect_models( + group: set[NormalizedModelName], *marking: type | Literal["__all__"] +) -> Callable[[Callable[..., object]], Callable[..., object]]: """ A function that runs at module load time and marks all models that appear in a given test function. The first argument stores the tracked models in a global group, which we then check in @@ -131,9 +133,9 @@ def expect_models(group: set[NormalizedModelName], *marking: type | Literal["__a group.add(get_model_name(model)) target_models.add(model) - def decorator(func: Callable): + def decorator(func: Callable[..., object]) -> Callable[..., object]: @wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: object, **kwargs: object) -> object: return func(*args, target_models, **kwargs) return wrapper diff --git a/tests/sentry/backup/test_imports.py b/tests/sentry/backup/test_imports.py index a65040ee1881..d62921e64573 100644 --- a/tests/sentry/backup/test_imports.py +++ b/tests/sentry/backup/test_imports.py @@ -4,6 +4,7 @@ import os import tarfile import tempfile +from collections.abc import Callable from datetime import UTC, date, datetime from pathlib import Path from unittest.mock import MagicMock, patch @@ -104,7 +105,7 @@ class ImportTestCase(BackupTransactionTestCase): - def export_to_tmp_file_and_clear_database(self, tmp_dir) -> Path: + def export_to_tmp_file_and_clear_database(self, tmp_dir: str) -> Path: tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json") export_to_file(tmp_path, ExportScope.Global) clear_database() @@ -802,7 +803,7 @@ class ScopingTests(ImportTestCase): """ @staticmethod - def verify_model_inclusion(scope: ImportScope): + def verify_model_inclusion(scope: ImportScope) -> None: """ Ensure all in-scope models are included, and that no out-of-scope models are included. Additionally, we verify that each such model had an appropriate `*ImportChunk` written out @@ -929,7 +930,7 @@ class DatabaseResetTests(ImportTestCase): Ensure that database resets work as intended in different import scopes. """ - def import_empty_backup_file(self, import_fn): + def import_empty_backup_file(self, import_fn: Callable[..., object]) -> None: with tempfile.TemporaryDirectory() as tmp_dir: tmp_empty_file_path = tmp_dir + "empty_backup.json" with open(tmp_empty_file_path, "wb") as tmp_file: @@ -1016,7 +1017,7 @@ class DecryptionTests(ImportTestCase): """ @staticmethod - def encrypt_json_fixture(tmp_dir) -> tuple[Path, Path]: + def encrypt_json_fixture(tmp_dir: str) -> tuple[Path, Path]: good_file_path = get_fixture_path("backup", "fresh-install.json") (priv_key_pem, pub_key_pem) = generate_rsa_key_pair() @@ -1702,7 +1703,7 @@ def test_colliding_data_source(self, expected_models: list[type[Model]]) -> None @expect_models(COLLISION_TESTED, ControlOption, Option, Relay, RelayUsage, UserRole) def test_colliding_configs_overwrite_configs_enabled_in_config_scope( self, expected_models: list[type[Model]] - ): + ) -> None: owner = self.create_exhaustive_user("owner", is_admin=True) self.create_exhaustive_global_configs(owner) @@ -1798,7 +1799,7 @@ def test_colliding_configs_overwrite_configs_enabled_in_config_scope( @expect_models(COLLISION_TESTED, ControlOption, Option, Relay, RelayUsage, UserRole) def test_colliding_configs_overwrite_configs_disabled_in_config_scope( self, expected_models: list[type[Model]] - ): + ) -> None: owner = self.create_exhaustive_user("owner", is_admin=True) self.create_exhaustive_global_configs(owner) @@ -1890,7 +1891,7 @@ def test_colliding_configs_overwrite_configs_disabled_in_config_scope( @expect_models(COLLISION_TESTED, User, UserEmail) def test_colliding_user_with_merging_enabled_in_user_scope( self, expected_models: list[type[Model]] - ): + ) -> None: self.create_exhaustive_user(username="owner", email="importing@example.com") with tempfile.TemporaryDirectory() as tmp_dir: @@ -1933,7 +1934,7 @@ def test_colliding_user_with_merging_enabled_in_user_scope( @expect_models(COLLISION_TESTED, User, UserEmail) def test_colliding_user_with_merging_disabled_in_user_scope( self, expected_models: list[type[Model]] - ): + ) -> None: self.create_exhaustive_user(username="owner", email="importing@example.com") with tempfile.TemporaryDirectory() as tmp_dir: @@ -1977,7 +1978,7 @@ def test_colliding_user_with_merging_disabled_in_user_scope( @expect_models(COLLISION_TESTED, Organization, OrganizationMember, User, UserEmail) def test_colliding_user_with_merging_enabled_in_organization_scope( self, expected_models: list[type[Model]] - ): + ) -> None: owner = self.create_exhaustive_user(username="owner", email="importing@example.com") org = self.create_organization("some-org", owner=owner) old_org_membership = OrganizationMember.objects.get(organization=org) @@ -2063,7 +2064,7 @@ def test_colliding_user_with_merging_enabled_in_organization_scope( @expect_models(COLLISION_TESTED, Organization, OrganizationMember, User, UserEmail) def test_colliding_user_with_merging_disabled_in_organization_scope( self, expected_models: list[type[Model]] - ): + ) -> None: owner = self.create_exhaustive_user(username="owner", email="importing@example.com") org = self.create_organization("some-org", owner=owner) old_org_membership = OrganizationMember.objects.get(organization=org) @@ -2154,7 +2155,7 @@ def test_colliding_user_with_merging_disabled_in_organization_scope( @expect_models(COLLISION_TESTED, User, UserEmail, UserPermission) def test_colliding_user_with_merging_enabled_in_config_scope( self, expected_models: list[type[Model]] - ): + ) -> None: self.create_exhaustive_user(username="owner", email="importing@example.com", is_admin=True) with tempfile.TemporaryDirectory() as tmp_dir: @@ -2201,7 +2202,7 @@ def test_colliding_user_with_merging_enabled_in_config_scope( @expect_models(COLLISION_TESTED, User, UserEmail, UserPermission) def test_colliding_user_with_merging_disabled_in_config_scope( self, expected_models: list[type[Model]] - ): + ) -> None: self.create_exhaustive_user(username="owner", email="importing@example.com", is_admin=True) with tempfile.TemporaryDirectory() as tmp_dir: @@ -2248,7 +2249,7 @@ def test_colliding_user_with_merging_disabled_in_config_scope( @expect_models(COLLISION_TESTED, Organization, OrganizationMember, User, UserEmail) def test_colliding_user_email_unique_with_merging_disabled_in_organization_scope( self, expected_models: list[type[Model]] - ): + ) -> None: # Create an owner, and set email_unique as it isn't populated by factory methods. owner = self.create_exhaustive_user(username="owner", email="importing@example.com") owner.email_unique = owner.email @@ -2301,7 +2302,7 @@ def test_colliding_user_email_unique_with_merging_disabled_in_organization_scope @expect_models(COLLISION_TESTED, Organization, OrganizationMember, User, UserEmail) def test_colliding_user_email_unique_existing_user_no_merge( self, expected_models: list[type[Model]] - ): + ) -> None: # Create an owner, and ensure that email_unique is None owner = self.create_exhaustive_user(username="owner", email="importing@example.com") assert owner.email_unique is None diff --git a/tests/sentry/backup/test_rpc.py b/tests/sentry/backup/test_rpc.py index 7465e7b3b5ac..239a7c6eadf0 100644 --- a/tests/sentry/backup/test_rpc.py +++ b/tests/sentry/backup/test_rpc.py @@ -4,7 +4,7 @@ from functools import cached_property from typing import Any from unittest.mock import MagicMock, patch -from uuid import uuid4 +from uuid import UUID, uuid4 import orjson from django.db import models @@ -224,7 +224,7 @@ def wrapped_get_existing_import_chunk( with assume_test_silo_mode(SiloMode.CONTROL): import_chunk_count = ControlImportChunk.objects.count() ControlImportChunk.objects.create( - import_uuid=import_uuid, + import_uuid=UUID(import_uuid), model="sentry.controloption", min_ordinal=1, max_ordinal=1, diff --git a/tests/sentry/backup/test_sanitize.py b/tests/sentry/backup/test_sanitize.py index 515066bb563d..a53304b75c30 100644 --- a/tests/sentry/backup/test_sanitize.py +++ b/tests/sentry/backup/test_sanitize.py @@ -23,8 +23,6 @@ from sentry.backup.scopes import RelocationScope from sentry.db.models.base import DefaultFieldsModel from sentry.db.models.fields.jsonfield import JSONField -from sentry.db.models.fields.slug import SentrySlugField -from sentry.db.models.fields.uuid import UUIDField FAKE_EMAIL = "test@fake.com" FAKE_NAME = "Fake Name" @@ -52,15 +50,15 @@ class FakeSanitizableModel(DefaultFieldsModel): email = models.EmailField(null=True, max_length=75) name = models.CharField(null=True, max_length=64) - slug = SentrySlugField(null=True) + slug = models.SlugField(null=True) nickname = models.CharField(null=True, max_length=32) - text = SentrySlugField(null=True, max_length=128) + text = models.SlugField(null=True, max_length=128) json_dict = JSONField(null=True, default=dict) json_list = JSONField(null=True, default=list) ip_v4 = models.GenericIPAddressField(null=True) ip_v6 = models.GenericIPAddressField(null=True) url = models.URLField(null=True) - uuid = UUIDField(null=True) + uuid = models.UUIDField(null=True) class Meta: app_label = "test" diff --git a/tests/sentry/billing/platform/core/test_service.py b/tests/sentry/billing/platform/core/test_service.py index e09c8fe6a239..0de75acb23ca 100644 --- a/tests/sentry/billing/platform/core/test_service.py +++ b/tests/sentry/billing/platform/core/test_service.py @@ -1,6 +1,7 @@ from __future__ import annotations from unittest import mock +from unittest.mock import MagicMock import pytest from google.protobuf.wrappers_pb2 import Int32Value, StringValue @@ -60,7 +61,9 @@ def bad_return(self, request: StringValue) -> StringValue: @mock.patch("sentry.billing.platform.core.service.metrics") @mock.patch("sentry.billing.platform.core.service.logger") @mock.patch("sentry.billing.platform.core.service._should_log_trace", return_value=True) - def test_service_method_observability(self, mock_should_log, mock_logger, mock_metrics): + def test_service_method_observability( + self, mock_should_log: MagicMock, mock_logger: MagicMock, mock_metrics: MagicMock + ) -> None: """Service methods emit metrics and logs.""" class TestService(BillingService): @@ -89,7 +92,9 @@ def test_method(self, request: StringValue) -> StringValue: @mock.patch("sentry.billing.platform.core.service.metrics") @mock.patch("sentry.billing.platform.core.service.logger") - def test_service_method_skips_success_log_when_not_sampled(self, mock_logger, mock_metrics): + def test_service_method_skips_success_log_when_not_sampled( + self, mock_logger: MagicMock, mock_metrics: MagicMock + ) -> None: """Success logs are skipped when trace is not sampled.""" class TestService(BillingService): @@ -111,7 +116,7 @@ def test_method(self, request: StringValue) -> StringValue: mock_logger.info.assert_not_called() @mock.patch("sentry.billing.platform.core.service.metrics") - def test_service_method_error_handling(self, mock_metrics): + def test_service_method_error_handling(self, mock_metrics: MagicMock) -> None: """Service methods propagate exceptions and emit error metrics.""" class TestService(BillingService): @@ -157,17 +162,17 @@ class TestShouldLogTrace: """Tests for the _should_log_trace sampling logic.""" @mock.patch("sentry.billing.platform.core.service._get_trace_hash", return_value=None) - def test_returns_false_when_no_trace_id(self, mock_hash): + def test_returns_false_when_no_trace_id(self, mock_hash: MagicMock) -> None: with _propagate_sample_rate(1.0): assert _should_log_trace() is False @mock.patch("sentry.billing.platform.core.service._get_trace_hash", return_value=50) - def test_logs_when_hash_below_threshold(self, mock_hash): + def test_logs_when_hash_below_threshold(self, mock_hash: MagicMock) -> None: with _propagate_sample_rate(0.01): # threshold = 100 assert _should_log_trace() is True @mock.patch("sentry.billing.platform.core.service._get_trace_hash", return_value=500) - def test_skips_when_hash_above_threshold(self, mock_hash): + def test_skips_when_hash_above_threshold(self, mock_hash: MagicMock) -> None: with _propagate_sample_rate(0.01): # threshold = 100 assert _should_log_trace() is False @@ -177,7 +182,9 @@ class TestEffectiveSampleRatePropagation: @mock.patch("sentry.billing.platform.core.service._get_trace_hash", return_value=9999) @mock.patch("sentry.billing.platform.core.service.metrics") - def test_parent_rate_propagates_to_child(self, mock_metrics, mock_hash): + def test_parent_rate_propagates_to_child( + self, mock_metrics: MagicMock, mock_hash: MagicMock + ) -> None: """A child method inherits the parent's higher effective sample rate.""" captured_rates: list[float] = [] @@ -200,7 +207,9 @@ def child_method(self, request: StringValue) -> StringValue: @mock.patch("sentry.billing.platform.core.service._get_trace_hash", return_value=9999) @mock.patch("sentry.billing.platform.core.service.metrics") - def test_child_higher_rate_takes_precedence(self, mock_metrics, mock_hash): + def test_child_higher_rate_takes_precedence( + self, mock_metrics: MagicMock, mock_hash: MagicMock + ) -> None: """A child with a higher rate than the parent uses its own rate.""" captured_rates: list[float] = [] @@ -223,7 +232,9 @@ def child_method(self, request: StringValue) -> StringValue: @mock.patch("sentry.billing.platform.core.service._get_trace_hash", return_value=9999) @mock.patch("sentry.billing.platform.core.service.metrics") - def test_effective_rate_resets_after_method_returns(self, mock_metrics, mock_hash): + def test_effective_rate_resets_after_method_returns( + self, mock_metrics: MagicMock, mock_hash: MagicMock + ) -> None: """The effective rate is restored after a method returns, so siblings don't inherit each other's rates.""" captured_rates: list[float] = [] diff --git a/tests/sentry/billing/platform/services/quota/test_quota_config_mapping.py b/tests/sentry/billing/platform/services/quota/test_quota_config_mapping.py index 74311972a107..a6c01dc96de7 100644 --- a/tests/sentry/billing/platform/services/quota/test_quota_config_mapping.py +++ b/tests/sentry/billing/platform/services/quota/test_quota_config_mapping.py @@ -19,7 +19,7 @@ class TestProtoToSentryCategory: @pytest.mark.parametrize("proto_category, sentry_category", PROTO_TO_SENTRY_CATEGORY.items()) - def test_mapped_category(self, proto_category, sentry_category): + def test_mapped_category(self, proto_category: int, sentry_category: int) -> None: assert proto_to_sentry_category(proto_category) == sentry_category @pytest.mark.parametrize( @@ -30,12 +30,12 @@ def test_mapped_category(self, proto_category, sentry_category): 9999, ], ) - def test_unmapped_category_returns_negative_one(self, proto_category): + def test_unmapped_category_returns_negative_one(self, proto_category: int) -> None: assert proto_to_sentry_category(proto_category) == -1 class TestSentryToProtoQuotaConfig: - def test_basic_conversion(self): + def test_basic_conversion(self) -> None: quota = QuotaConfig( id="test_quota", categories=[DataCategory.ERROR, DataCategory.TRANSACTION], @@ -56,7 +56,7 @@ def test_basic_conversion(self): ProtoDataCategory.DATA_CATEGORY_TRANSACTION, } - def test_reject_all_quota(self): + def test_reject_all_quota(self) -> None: quota = QuotaConfig( id=None, categories=[DataCategory.ERROR], @@ -69,7 +69,7 @@ def test_reject_all_quota(self): assert proto.id == "" assert proto.limit == 0 - def test_scope_id_included(self): + def test_scope_id_included(self) -> None: quota = QuotaConfig( id="q", categories=[DataCategory.ERROR], @@ -84,7 +84,7 @@ def test_scope_id_included(self): class TestProtoToSentryQuotaConfig: - def test_basic_conversion(self): + def test_basic_conversion(self) -> None: proto = ProtoQuotaConfig( id="test_quota", categories=[ @@ -107,7 +107,7 @@ def test_basic_conversion(self): assert DataCategory.ERROR in result.categories assert DataCategory.TRANSACTION in result.categories - def test_unspecified_scope_defaults_to_organization(self): + def test_unspecified_scope_defaults_to_organization(self) -> None: proto = ProtoQuotaConfig( id="q", categories=[ProtoDataCategory.DATA_CATEGORY_ERROR], @@ -129,7 +129,7 @@ def test_unspecified_scope_defaults_to_organization(self): ProtoDataCategory.ValueType(9999), ], ) - def test_unknown_category_skipped(self, unmapped_value): + def test_unknown_category_skipped(self, unmapped_value: ProtoDataCategory.ValueType) -> None: """Skip unmapped categories even when their ints match Sentry categories.""" proto = ProtoQuotaConfig( id="q", @@ -156,7 +156,9 @@ def test_unknown_category_skipped(self, unmapped_value): ProtoDataCategory.ValueType(9999), ], ) - def test_all_categories_unmapped_returns_none(self, unmapped_value): + def test_all_categories_unmapped_returns_none( + self, unmapped_value: ProtoDataCategory.ValueType + ) -> None: """If all categories are unmapped, return None to avoid broadening quota.""" proto = ProtoQuotaConfig( id="q", @@ -171,7 +173,7 @@ def test_all_categories_unmapped_returns_none(self, unmapped_value): assert result is None @patch.dict(PROTO_TO_SENTRY_CATEGORY, {ProtoDataCategory.DATA_CATEGORY_ERROR: 9999}) - def test_invalid_mapped_category_skipped(self): + def test_invalid_mapped_category_skipped(self) -> None: proto = ProtoQuotaConfig( categories=[ ProtoDataCategory.DATA_CATEGORY_ERROR, @@ -186,7 +188,7 @@ def test_invalid_mapped_category_skipped(self): assert result is not None assert result.categories == {DataCategory.TRANSACTION} - def test_default_category_preserved(self): + def test_default_category_preserved(self) -> None: proto = ProtoQuotaConfig( categories=[ProtoDataCategory.DATA_CATEGORY_DEFAULT], limit=0, @@ -199,7 +201,7 @@ def test_default_category_preserved(self): assert result.categories == {DataCategory.DEFAULT} assert result.limit == 0 - def test_empty_categories_preserved(self): + def test_empty_categories_preserved(self) -> None: """Proto with no categories should map to empty (all-data quota), not None.""" proto = ProtoQuotaConfig( id="q", @@ -213,7 +215,7 @@ def test_empty_categories_preserved(self): assert result is not None assert len(result.categories) == 0 - def test_unknown_scope_falls_back_to_organization(self): + def test_unknown_scope_falls_back_to_organization(self) -> None: """Unknown scope values fall back to ORGANIZATION instead of raising.""" proto = ProtoQuotaConfig( id="q", @@ -228,7 +230,7 @@ def test_unknown_scope_falls_back_to_organization(self): assert result is not None assert result.scope == QuotaScope.ORGANIZATION - def test_project_scope(self): + def test_project_scope(self) -> None: proto = ProtoQuotaConfig( id="q", categories=[ProtoDataCategory.DATA_CATEGORY_ERROR], @@ -244,7 +246,7 @@ def test_project_scope(self): assert result.scope == QuotaScope.PROJECT assert result.scope_id == "42" - def test_key_scope(self): + def test_key_scope(self) -> None: proto = ProtoQuotaConfig( id="q", categories=[ProtoDataCategory.DATA_CATEGORY_ERROR], @@ -258,7 +260,7 @@ def test_key_scope(self): assert result is not None assert result.scope == QuotaScope.KEY - def test_reject_all_quota(self): + def test_reject_all_quota(self) -> None: proto = ProtoQuotaConfig( categories=[ProtoDataCategory.DATA_CATEGORY_ERROR], scope=ProtoQuotaScope.QUOTA_SCOPE_ORGANIZATION, @@ -273,7 +275,7 @@ def test_reject_all_quota(self): class TestRoundTrip: - def test_roundtrip_sentry_to_proto_to_sentry(self): + def test_roundtrip_sentry_to_proto_to_sentry(self) -> None: original = QuotaConfig( id="roundtrip", categories=[DataCategory.ERROR, DataCategory.SPAN], diff --git a/tests/sentry/billing/platform/services/usage/test_outcomes_query.py b/tests/sentry/billing/platform/services/usage/test_outcomes_query.py index ca0de81bdba0..d5f649d7f1f9 100644 --- a/tests/sentry/billing/platform/services/usage/test_outcomes_query.py +++ b/tests/sentry/billing/platform/services/usage/test_outcomes_query.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone -from unittest.mock import patch +from unittest.mock import MagicMock, patch from google.protobuf.timestamp_pb2 import Timestamp from sentry_protos.billing.v1.date_pb2 import Date @@ -20,6 +20,7 @@ _BILLABLE_OUTCOMES, _build_query, _build_response, + _OutcomeRow, _over_quota_condition, _total_function, query_outcomes_usage, @@ -27,6 +28,7 @@ from sentry.billing.platform.services.usage._project_outcomes_query import ( _build_project_query, _build_project_response, + _ProjectOutcomeRow, query_project_outcomes_usage, ) from sentry.utils.outcomes import Outcome @@ -41,6 +43,7 @@ def _make_timestamp(dt: datetime) -> Timestamp: def _make_row( *, time: str = "2025-03-15T00:00:00+00:00", + max_ts: str = "2025-03-15T11:00:00+00:00", category: int = 1, total: int = 0, accepted: int = 0, @@ -49,9 +52,10 @@ def _make_row( over_quota: int = 0, spike_protection: int = 0, dynamic_sampling: int = 0, -) -> dict: +) -> _OutcomeRow: return { "time": time, + "max_ts": max_ts, "category": category, "total": total, "accepted": accepted, @@ -64,12 +68,12 @@ def _make_row( class TestOverQuotaCondition: - def test_returns_and_function(self): + def test_returns_and_function(self) -> None: cond = _over_quota_condition() assert isinstance(cond, Function) assert cond.function == "and" - def test_outer_condition_checks_rate_limited(self): + def test_outer_condition_checks_rate_limited(self) -> None: cond = _over_quota_condition() outcome_check = cond.parameters[0] assert outcome_check.function == "equals" @@ -77,7 +81,7 @@ def test_outer_condition_checks_rate_limited(self): assert outcome_check.parameters[0].name == "outcome" assert outcome_check.parameters[1] == Outcome.RATE_LIMITED - def test_inner_or_includes_ends_with(self): + def test_inner_or_includes_ends_with(self) -> None: cond = _over_quota_condition() or_clause = cond.parameters[1] assert or_clause.function == "or" @@ -85,7 +89,7 @@ def test_inner_or_includes_ends_with(self): assert ends_with.function == "endsWith" assert ends_with.parameters[1] == "_usage_exceeded" - def test_inner_or_includes_usage_exceeded_and_grace_period(self): + def test_inner_or_includes_usage_exceeded_and_grace_period(self) -> None: cond = _over_quota_condition() inner_or = cond.parameters[1].parameters[1] assert inner_or.function == "or" @@ -95,13 +99,13 @@ def test_inner_or_includes_usage_exceeded_and_grace_period(self): class TestBuildResponse: - def test_build_response_empty(self): + def test_build_response_empty(self) -> None: response = _build_response([], None) assert isinstance(response, GetUsageResponse) assert list(response.days) == [] - def test_build_response_multi_day(self): + def test_build_response_multi_day(self) -> None: rows = [ _make_row(time="2025-03-17T00:00:00+00:00", accepted=50, total=50), _make_row(time="2025-03-15T00:00:00+00:00", accepted=100, total=100), @@ -114,7 +118,7 @@ def test_build_response_multi_day(self): assert response.days[1].date == Date(year=2025, month=3, day=16) assert response.days[2].date == Date(year=2025, month=3, day=17) - def test_build_response_multi_category(self): + def test_build_response_multi_category(self) -> None: rows = [ _make_row(time="2025-03-15T00:00:00+00:00", category=2, accepted=50, total=50), _make_row(time="2025-03-15T00:00:00+00:00", category=1, accepted=100, total=100), @@ -131,7 +135,7 @@ def test_build_response_multi_category(self): assert usage_by_cat[sentry_to_proto_category(2)] == 50 assert usage_by_cat[sentry_to_proto_category(9)] == 25 - def test_build_response_skips_unmapped_sentry_category(self): + def test_build_response_skips_unmapped_sentry_category(self) -> None: # Sentry SESSION=5 has no proto mapping; passing the int through would # collide with proto REPLAY=5 and clobber real replay data via the # response builder's overwrite. @@ -147,7 +151,7 @@ def test_build_response_skips_unmapped_sentry_category(self): assert day.usage[0].category == sentry_to_proto_category(7) assert day.usage[0].data.accepted == 42 - def test_build_response_preserves_overlapping_semantics(self): + def test_build_response_preserves_overlapping_semantics(self) -> None: """dropped >= over_quota + spike_protection — all values preserved as-is from query.""" rows = [ _make_row( @@ -168,7 +172,7 @@ def test_build_response_preserves_overlapping_semantics(self): assert data.spike_protection == 50 assert data.dropped >= data.over_quota + data.spike_protection - def test_build_response_all_fields(self): + def test_build_response_all_fields(self) -> None: rows = [ _make_row( time="2025-03-15T00:00:00+00:00", @@ -196,7 +200,7 @@ def test_build_response_all_fields(self): class TestBuildQuery: - def test_build_query_with_categories(self): + def test_build_query_with_categories(self) -> None: start = datetime(2025, 3, 1, tzinfo=timezone.utc) end = datetime(2025, 3, 31, tzinfo=timezone.utc) @@ -210,7 +214,7 @@ def test_build_query_with_categories(self): assert category_conditions[0].op == Op.IN assert category_conditions[0].rhs == [1, 2] - def test_build_query_no_categories(self): + def test_build_query_no_categories(self) -> None: start = datetime(2025, 3, 1, tzinfo=timezone.utc) end = datetime(2025, 3, 31, tzinfo=timezone.utc) @@ -222,7 +226,7 @@ def test_build_query_no_categories(self): ] assert len(category_conditions) == 0 - def test_build_query_basic_structure(self): + def test_build_query_basic_structure(self) -> None: start = datetime(2025, 3, 1, tzinfo=timezone.utc) end = datetime(2025, 3, 31, tzinfo=timezone.utc) @@ -237,7 +241,7 @@ def test_build_query_basic_structure(self): assert len(org_conditions) == 1 assert org_conditions[0].rhs == 42 - def test_build_query_groups_by_category_and_time_only(self): + def test_build_query_groups_by_category_and_time_only(self) -> None: start = datetime(2025, 3, 1, tzinfo=timezone.utc) end = datetime(2025, 3, 31, tzinfo=timezone.utc) @@ -246,7 +250,7 @@ def test_build_query_groups_by_category_and_time_only(self): groupby_names = [col.name for col in snuba_request.query.groupby] assert groupby_names == ["category", "time"] - def test_build_query_total_filters_billable_outcomes(self): + def test_build_query_total_filters_billable_outcomes(self) -> None: start = datetime(2025, 3, 1, tzinfo=timezone.utc) end = datetime(2025, 3, 31, tzinfo=timezone.utc) @@ -279,7 +283,7 @@ def test_build_query_total_filters_billable_outcomes(self): assert outcome_conditions[0].op == Op.IN assert set(outcome_conditions[0].rhs) == set(_BILLABLE_OUTCOMES) - def test_build_query_total_all_outcomes_when_none(self): + def test_build_query_total_all_outcomes_when_none(self) -> None: start = datetime(2025, 3, 1, tzinfo=timezone.utc) end = datetime(2025, 3, 31, tzinfo=timezone.utc) @@ -295,17 +299,17 @@ def test_build_query_total_all_outcomes_when_none(self): c for c in snuba_request.query.where if hasattr(c, "lhs") and c.lhs.name == "outcome" ] - def test_total_function_with_outcomes(self): + def test_total_function_with_outcomes(self) -> None: fn = _total_function(_BILLABLE_OUTCOMES) assert fn.function == "sumIf" assert fn.alias == "total" - def test_total_function_without_outcomes(self): + def test_total_function_without_outcomes(self) -> None: fn = _total_function(None) assert fn.function == "sum" assert fn.alias == "total" - def test_build_query_select_has_sumif_columns(self): + def test_build_query_select_has_sumif_columns(self) -> None: start = datetime(2025, 3, 1, tzinfo=timezone.utc) end = datetime(2025, 3, 31, tzinfo=timezone.utc) @@ -334,7 +338,7 @@ def test_build_query_select_has_sumif_columns(self): class TestQueryOutcomesUsage: @patch("sentry.billing.platform.services.usage._outcomes_query.raw_snql_query") - def test_query_uses_billing_referrer(self, mock_query): + def test_query_uses_billing_referrer(self, mock_query: MagicMock) -> None: mock_query.return_value = {"data": []} start = _make_timestamp(datetime(2025, 3, 1, tzinfo=timezone.utc)) @@ -348,7 +352,7 @@ def test_query_uses_billing_referrer(self, mock_query): assert kwargs["referrer"] == "billing.usage_service.clickhouse" @patch("sentry.billing.platform.services.usage._outcomes_query.raw_snql_query") - def test_end_date_shifted_plus_one_day(self, mock_query): + def test_end_date_shifted_plus_one_day(self, mock_query: MagicMock) -> None: """Proto end is inclusive (last included day). The query shifts +1 day for half-open.""" mock_query.return_value = {"data": []} @@ -371,7 +375,7 @@ def test_end_date_shifted_plus_one_day(self, mock_query): ) + timedelta(days=1) @patch("sentry.billing.platform.services.usage._outcomes_query.raw_snql_query") - def test_query_returns_response(self, mock_query): + def test_query_returns_response(self, mock_query: MagicMock) -> None: mock_query.return_value = { "data": [ { @@ -408,7 +412,7 @@ def _make_project_row( category: int = 1, accepted: int = 100, max_ts: str = "2025-03-15T11:00:00+00:00", -) -> dict: +) -> _ProjectOutcomeRow: return { "project_id": project_id, "time": time, @@ -425,7 +429,7 @@ def _make_project_row( class TestBuildProjectResponse: - def test_groups_usage_by_project_day_and_category(self): + def test_groups_usage_by_project_day_and_category(self) -> None: response = _build_project_response( [ _make_project_row(project_id=20, category=2, accepted=25), @@ -449,12 +453,12 @@ def test_groups_usage_by_project_day_and_category(self): ] assert response.projects[0].days[0].usage[0].data.accepted == 100 - def test_skips_unmapped_category(self): + def test_skips_unmapped_category(self) -> None: response = _build_project_response([_make_project_row(category=5)], None) assert list(response.projects) == [] - def test_sets_last_usage_timestamp(self): + def test_sets_last_usage_timestamp(self) -> None: last_usage_ts = datetime(2025, 3, 15, 11, tzinfo=timezone.utc) response = _build_project_response([], last_usage_ts) @@ -463,7 +467,7 @@ def test_sets_last_usage_timestamp(self): class TestBuildProjectQuery: - def test_adds_project_id_to_shared_query(self): + def test_adds_project_id_to_shared_query(self) -> None: start = datetime(2025, 3, 15, 6, tzinfo=timezone.utc) end = datetime(2025, 3, 16, 6, tzinfo=timezone.utc) request = _build_project_query(42, start, end, [1, 2]) @@ -493,7 +497,7 @@ def test_adds_project_id_to_shared_query(self): class TestQueryProjectOutcomesUsage: @patch("sentry.billing.platform.services.usage._project_outcomes_query.raw_snql_query") - def test_end_date_shifted_plus_one_day(self, mock_query): + def test_end_date_shifted_plus_one_day(self, mock_query: MagicMock) -> None: mock_query.return_value = {"data": []} start_dt = datetime(2025, 3, 15, tzinfo=timezone.utc) end_dt = datetime(2025, 3, 15, tzinfo=timezone.utc) @@ -518,8 +522,8 @@ def test_end_date_shifted_plus_one_day(self, mock_query): @patch("sentry.billing.platform.services.usage._project_outcomes_query.metrics") @patch("sentry.billing.platform.services.usage._project_outcomes_query.raw_snql_query") def test_warns_and_returns_response_when_query_reaches_row_limit( - self, mock_query, mock_metrics, mock_logger - ): + self, mock_query: MagicMock, mock_metrics: MagicMock, mock_logger: MagicMock + ) -> None: mock_query.return_value = {"data": [_make_project_row()] * 10000} request = GetUsageByProjectRequest( organization_id=1, @@ -539,7 +543,7 @@ def test_warns_and_returns_response_when_query_reaches_row_limit( ) @patch("sentry.billing.platform.services.usage._project_outcomes_query.raw_snql_query") - def test_request_queries_all_projects(self, mock_query): + def test_request_queries_all_projects(self, mock_query: MagicMock) -> None: mock_query.return_value = {"data": []} request = GetUsageByProjectRequest( organization_id=1, @@ -553,7 +557,7 @@ def test_request_queries_all_projects(self, mock_query): mock_query.assert_called_once() @patch("sentry.billing.platform.services.usage._project_outcomes_query.raw_snql_query") - def test_returns_latest_usage_timestamp(self, mock_query): + def test_returns_latest_usage_timestamp(self, mock_query: MagicMock) -> None: mock_query.return_value = { "data": [ _make_project_row(max_ts="2025-03-15T10:00:00+00:00"), diff --git a/tests/sentry/billing/platform/services/usage/test_service.py b/tests/sentry/billing/platform/services/usage/test_service.py index e394371ee960..145f9da82c29 100644 --- a/tests/sentry/billing/platform/services/usage/test_service.py +++ b/tests/sentry/billing/platform/services/usage/test_service.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone from unittest import mock +from unittest.mock import MagicMock import pytest from google.protobuf.timestamp_pb2 import Timestamp @@ -55,7 +56,7 @@ def test_get_usage_rejects_non_protobuf_input(self) -> None: service.get_usage("not a protobuf") # type: ignore[arg-type] @mock.patch("sentry.billing.platform.core.service.metrics") - def test_get_usage_emits_metrics(self, mock_metrics): + def test_get_usage_emits_metrics(self, mock_metrics: MagicMock) -> None: service = UsageService() request = GetUsageRequest(organization_id=1) service.get_usage(request) diff --git a/tests/sentry/buffer/test_redis.py b/tests/sentry/buffer/test_redis.py index 85cea2df0741..8d1fdd76b723 100644 --- a/tests/sentry/buffer/test_redis.py +++ b/tests/sentry/buffer/test_redis.py @@ -1,14 +1,18 @@ import copy import datetime import pickle -from typing import Any +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager +from typing import Any, cast from unittest import mock import pytest +import rb from django.utils import timezone +from sentry_redis_tools.clients import RedisCluster from sentry import options -from sentry.buffer.base import Buffer +from sentry.buffer.base import Buffer, BufferField from sentry.buffer.redis import RedisBuffer, _coerce_val, make_key from sentry.models.group import Group from sentry.models.project import Project @@ -21,8 +25,10 @@ from sentry.utils.services import Delegator -def _hgetall_decode_keys(client, key, is_redis_cluster): - ret = client.hgetall(key) +def _hgetall_decode_keys( + client: "RedisCluster[bytes] | rb.RoutingClient", key: str, is_redis_cluster: bool +) -> dict[Any, Any]: + ret: dict[Any, Any] = client.hgetall(key) if not is_redis_cluster: return {k.decode(): v for k, v in ret.items()} else: @@ -32,14 +38,18 @@ def _hgetall_decode_keys(client, key, is_redis_cluster): @pytest.mark.django_db class TestRedisBuffer: @pytest.fixture(params=["cluster", "blaster"]) - def buffer(self, set_sentry_option, request): + def buffer( + self, + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], + request: pytest.FixtureRequest, + ) -> Iterator[RedisBuffer]: value = copy.deepcopy(options.get("redis.clusters")) value["default"]["is_redis_cluster"] = request.param == "cluster" with set_sentry_option("redis.clusters", value): yield RedisBuffer() @pytest.fixture(autouse=True) - def setup_buffer(self, buffer): + def setup_buffer(self, buffer: RedisBuffer) -> None: self.buf = buffer def test_coerce_val_handles_foreignkeys(self) -> None: @@ -50,7 +60,7 @@ def test_coerce_val_handles_unicode(self) -> None: @mock.patch("sentry.buffer.redis.make_key", mock.Mock(return_value="foo")) @mock.patch("sentry.buffer.redis.process_incr") - def test_process_pending_one_batch(self, process_incr) -> None: + def test_process_pending_one_batch(self, process_incr: mock.MagicMock) -> None: self.buf.incr_batch_size = 5 client = get_cluster_routing_client(self.buf.cluster, self.buf.is_redis_cluster) client.zadd("b:p", {"foo": 1, "bar": 2}) @@ -64,7 +74,7 @@ def test_process_pending_one_batch(self, process_incr) -> None: @mock.patch("sentry.buffer.redis.make_key", mock.Mock(return_value="foo")) @mock.patch("sentry.buffer.redis.process_incr") - def test_process_pending_multiple_batches(self, process_incr) -> None: + def test_process_pending_multiple_batches(self, process_incr: mock.MagicMock) -> None: self.buf.incr_batch_size = 2 client = get_cluster_routing_client(self.buf.cluster, self.buf.is_redis_cluster) client.zadd("b:p", {"foo": 1, "bar": 2, "baz": 3}) @@ -79,7 +89,7 @@ def test_process_pending_multiple_batches(self, process_incr) -> None: @mock.patch("sentry.buffer.redis.make_key", mock.Mock(return_value="foo")) @mock.patch("sentry.buffer.base.Buffer.process") - def test_process_does_bubble_up_json(self, process) -> None: + def test_process_does_bubble_up_json(self, process: mock.MagicMock) -> None: client = get_cluster_routing_client(self.buf.cluster, self.buf.is_redis_cluster) client.hmset( @@ -104,7 +114,7 @@ def test_process_does_bubble_up_json(self, process) -> None: @mock.patch("sentry.buffer.redis.make_key", mock.Mock(return_value="foo")) @mock.patch("sentry.buffer.base.Buffer.process") - def test_process_does_bubble_up_pickle(self, process) -> None: + def test_process_does_bubble_up_pickle(self, process: mock.MagicMock) -> None: client = get_cluster_routing_client(self.buf.cluster, self.buf.is_redis_cluster) client.hmset( @@ -125,7 +135,9 @@ def test_process_does_bubble_up_pickle(self, process) -> None: @django_db_all @freeze_time() - def test_group_cache_updated(self, default_group, task_runner) -> None: + def test_group_cache_updated( + self, default_group: Group, task_runner: Callable[[], AbstractContextManager[None]] + ) -> None: # Make sure group is stored in the cache and keep track of times_seen at the time orig_times_seen = Group.objects.get_from_cache(id=default_group.id).times_seen times_seen_incr = 5 @@ -141,7 +153,7 @@ def test_group_cache_updated(self, default_group, task_runner) -> None: assert group.times_seen == orig_times_seen + times_seen_incr @django_db_all - def test_process_incr_through_delegator(self, default_group) -> None: + def test_process_incr_through_delegator(self, default_group: Group) -> None: delegator = Delegator( Buffer, {"redis": (self.buf, SynchronousExecutor())}, lambda *args: ["redis"] ) @@ -155,7 +167,7 @@ def test_get(self) -> None: model = mock.Mock() model.__name__ = "Mock" columns = ["times_seen"] - filters = {"pk": 1} + filters: dict[str, BufferField] = {"pk": 1} # If the value doesn't exist we just assume 0 assert self.buf.get(model, columns, filters=filters) == {"times_seen": 0} self.buf.incr(model, {"times_seen": 1}, filters) @@ -169,18 +181,22 @@ def test_incr_saves_to_redis(self) -> None: model = mock.Mock() model.__name__ = "Mock" columns = {"times_seen": 1} - filters = {"pk": 1, "datetime": now} + # justification: BufferField omits datetime, which the buffer still serializes. + filters = cast(dict[str, BufferField], {"pk": 1, "datetime": now}) key = make_key(model, filters=filters) self.buf.incr(model, columns, filters, extra={"foo": "bar", "datetime": now}) result = _hgetall_decode_keys(client, key, self.buf.is_redis_cluster) f = result.pop("f") + # justification: these decode raw JSON/pickle payloads read back from Redis. + load_values: Callable[[Any], Any] + load_value: Callable[[Any], Any] if self.buf.is_redis_cluster: - def load_values(x): + def load_values(x: Any) -> Any: return self.buf._load_values(json.loads(x)) - def load_value(x): + def load_value(x: Any) -> Any: return self.buf._load_value(json.loads(x)) else: @@ -237,7 +253,7 @@ def test_incr_bounds_pending_key(self) -> None: @mock.patch("sentry.buffer.redis.make_key", mock.Mock(return_value="foo")) @mock.patch("sentry.buffer.base.Buffer.process") - def test_process_uses_signal_only(self, process) -> None: + def test_process_uses_signal_only(self, process: mock.MagicMock) -> None: client = get_cluster_routing_client(self.buf.cluster, self.buf.is_redis_cluster) client.hmset( @@ -254,7 +270,9 @@ def test_process_uses_signal_only(self, process) -> None: @django_db_all @freeze_time() - def test_incr_uses_signal_only(self, default_group, task_runner) -> None: + def test_incr_uses_signal_only( + self, default_group: Group, task_runner: Callable[[], AbstractContextManager[None]] + ) -> None: # Make sure group is stored in the cache and keep track of times_seen at the time orig_times_seen = Group.objects.get_from_cache(id=default_group.id).times_seen times_seen_incr = 5 diff --git a/tests/sentry/cache/test_reconnectingmemcache.py b/tests/sentry/cache/test_reconnectingmemcache.py index 8b9db853b4b2..1e6e3e454354 100644 --- a/tests/sentry/cache/test_reconnectingmemcache.py +++ b/tests/sentry/cache/test_reconnectingmemcache.py @@ -7,7 +7,7 @@ from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor -def _make_cache(reconnect_age=300): +def _make_cache(reconnect_age: int = 300) -> ReconnectingMemcache: """Create a ReconnectingMemcache with a mocked pymemcache client class.""" with patch.object( ReconnectingMemcache, @@ -31,7 +31,7 @@ def test_each_thread_gets_own_client() -> None: results = [] - def check(): + def check() -> None: worker_client = cache._cache results.append(worker_client is parent_client) @@ -52,7 +52,7 @@ def test_context_propagating_executor_isolates_clients() -> None: results = [] - def check(): + def check() -> None: worker_client = cache._cache results.append(worker_client is parent_client) @@ -86,6 +86,7 @@ def test_reconnects_after_age() -> None: # Simulate time passing beyond reconnect_age state = cache._backend_var.get() + assert state is not None cache._backend_var.set(state._replace(created_at=time.time() - 11)) client2 = cache._cache @@ -102,7 +103,7 @@ def test_workers_in_same_context_run_get_own_clients() -> None: worker_clients = [] - def collect(): + def collect() -> None: worker_clients.append(id(cache._cache)) with ContextPropagatingThreadPoolExecutor(max_workers=3) as pool: diff --git a/tests/sentry/cache/test_redis.py b/tests/sentry/cache/test_redis.py index 8e4b4ebe8100..c5a773f7d3b1 100644 --- a/tests/sentry/cache/test_redis.py +++ b/tests/sentry/cache/test_redis.py @@ -1,7 +1,9 @@ +from collections.abc import Callable + import pytest from sentry import options -from sentry.cache.redis import RedisCache, RedisClusterCache, ValueTooLarge +from sentry.cache.redis import CommonRedisCache, RedisCache, RedisClusterCache, ValueTooLarge from sentry.exceptions import MissingTTL from sentry.testutils.helpers.options import override_options from sentry.testutils.pytest.xdist import get_redis_db @@ -17,7 +19,7 @@ @clients -def test_redis_cache_integration(make_client) -> None: +def test_redis_cache_integration(make_client: Callable[[], CommonRedisCache]) -> None: backend = make_client() backend.set("foo", {"foo": "bar"}, timeout=50) @@ -34,7 +36,9 @@ def test_redis_cache_integration(make_client) -> None: @clients -def test_set_without_a_timeout_is_rejected(make_client) -> None: +def test_set_without_a_timeout_is_rejected( + make_client: Callable[[], CommonRedisCache], +) -> None: backend = make_client() with pytest.raises(MissingTTL): @@ -47,7 +51,7 @@ def test_set_without_a_timeout_is_rejected(make_client) -> None: @clients -def test_raw_preserves_bytes(make_client) -> None: +def test_raw_preserves_bytes(make_client: Callable[[], CommonRedisCache]) -> None: backend = make_client() backend.set("k", b"\xa0\x12\xfe", timeout=50, raw=True) assert backend.get("k", raw=True) == b"\xa0\x12\xfe" @@ -69,8 +73,8 @@ def _assert_roundtrip(backend: RedisCache) -> None: @pytest.mark.parametrize( "cache_options", ({}, {"cluster_id": "default"}), ids=("implicit", "named") ) -def test_redis_cache_uses_redis_clusters(cache_options) -> None: - backend = RedisCache(**cache_options) +def test_redis_cache_uses_redis_clusters(cache_options: dict[str, str]) -> None: + backend = RedisCache(cluster_id=cache_options.get("cluster_id", "default")) assert backend._text_client is redis_clusters.get("default") assert backend._bytes_client is redis_clusters.get_binary("default") _assert_roundtrip(backend) diff --git a/tests/sentry/conduit/endpoints/test_organization_conduit_demo.py b/tests/sentry/conduit/endpoints/test_organization_conduit_demo.py index e1197850ef34..81de6fcfbfaa 100644 --- a/tests/sentry/conduit/endpoints/test_organization_conduit_demo.py +++ b/tests/sentry/conduit/endpoints/test_organization_conduit_demo.py @@ -123,7 +123,7 @@ def test_credentials_are_unique(self) -> None: ) @patch("sentry.conduit.endpoints.organization_conduit_demo.stream_demo_data") @with_feature("organizations:conduit-demo") - def test_post_queues_task(self, mock_task: MagicMock): + def test_post_queues_task(self, mock_task: MagicMock) -> None: self.get_success_response( self.organization.slug, method="POST", diff --git a/tests/sentry/conduit/test_tasks.py b/tests/sentry/conduit/test_tasks.py index 19ef6e999295..abe5f846a37a 100644 --- a/tests/sentry/conduit/test_tasks.py +++ b/tests/sentry/conduit/test_tasks.py @@ -1,7 +1,7 @@ import datetime import re import time -from unittest.mock import patch +from unittest.mock import MagicMock, patch from uuid import uuid4 import jwt as pyjwt @@ -260,7 +260,7 @@ class StreamDemoDataTest(TestCase): # Patch the module's own `time` reference. Patching `time.sleep` would also count the sleeps of # every other thread in the test process. @patch("sentry.conduit.tasks.time") - def test_stream_demo_data_sends_all_phases(self, mock_time): + def test_stream_demo_data_sends_all_phases(self, mock_time: MagicMock) -> None: """Test that stream_demo_data sends START, DELTA, and END phases.""" mock_time.time.side_effect = time.time org_id = 123 @@ -288,7 +288,9 @@ def test_stream_demo_data_sends_all_phases(self, mock_time): # `time.sleep`, which would replace sleep for every thread in the test process. @patch("sentry.utils.retries.time") @patch("sentry.conduit.tasks.time") - def test_stream_demo_data_stops_on_exhausted_retries(self, mock_time, mock_retries_time): + def test_stream_demo_data_stops_on_exhausted_retries( + self, mock_time: MagicMock, mock_retries_time: MagicMock + ) -> None: """Test that stream_demo_data stops streaming when a publish fails after max retries.""" mock_time.time.side_effect = time.time org_id = 123 diff --git a/tests/sentry/conf/types/test_imports.py b/tests/sentry/conf/types/test_imports.py index f6b4026b9257..661f66e9e00d 100644 --- a/tests/sentry/conf/types/test_imports.py +++ b/tests/sentry/conf/types/test_imports.py @@ -25,7 +25,7 @@ @pytest.mark.parametrize("modname", MODNAMES) -def test_module_does_not_import_sentry(modname) -> None: +def test_module_does_not_import_sentry(modname: str) -> None: prog = f"""\ import json import sys diff --git a/tests/sentry/consumers/test_dlq.py b/tests/sentry/consumers/test_dlq.py index 8cbcd885eb9c..dd3b68dd56e5 100644 --- a/tests/sentry/consumers/test_dlq.py +++ b/tests/sentry/consumers/test_dlq.py @@ -10,10 +10,13 @@ from arroyo.types import BrokerValue, Message, Partition, Topic from sentry.consumers.dlq import DlqStaleMessagesStrategyFactoryWrapper +from sentry.testutils.factories import Factories from sentry.testutils.pytest.fixtures import django_db_all -def make_message(payload: bytes, partition: Partition, offset: int, timestamp: datetime) -> Message: +def make_message( + payload: bytes, partition: Partition, offset: int, timestamp: datetime +) -> Message[KafkaPayload]: return Message( BrokerValue( KafkaPayload(None, payload, []), @@ -26,7 +29,7 @@ def make_message(payload: bytes, partition: Partition, offset: int, timestamp: d @pytest.mark.parametrize("stale_threshold_sec", [300]) @django_db_all -def test_dlq_stale_messages_timestamps(factories, stale_threshold_sec) -> None: +def test_dlq_stale_messages_timestamps(factories: Factories, stale_threshold_sec: int) -> None: organization = factories.create_organization() project = factories.create_project(organization=organization) diff --git a/tests/sentry/consumers/test_run.py b/tests/sentry/consumers/test_run.py index 1713e484dae4..1124c11702e3 100644 --- a/tests/sentry/consumers/test_run.py +++ b/tests/sentry/consumers/test_run.py @@ -1,7 +1,9 @@ +from collections.abc import Mapping from unittest.mock import patch import pytest from arroyo.processing.strategies.abstract import ProcessingStrategyFactory +from pytest_django.fixtures import SettingsWrapper from sentry import consumers from sentry.conf.types.kafka_definition import ConsumerDefinition, Topic @@ -10,7 +12,9 @@ @pytest.mark.parametrize("consumer_def", list(consumers.KAFKA_CONSUMERS.items())) -def test_all_importable(consumer_def, settings) -> None: +def test_all_importable( + consumer_def: tuple[str, ConsumerDefinition], settings: SettingsWrapper +) -> None: name: str defn: ConsumerDefinition name, defn = consumer_def @@ -23,7 +27,7 @@ def test_all_importable(consumer_def, settings) -> None: @pytest.mark.parametrize("consumer_def", list(consumers.KAFKA_CONSUMERS.items())) -def test_dlq(consumer_def) -> None: +def test_dlq(consumer_def: tuple[str, ConsumerDefinition]) -> None: post_process_forwarders = [ "post-process-forwarder-errors", "post-process-forwarder-transactions", @@ -49,7 +53,11 @@ def test_dlq(consumer_def) -> None: def test_commit_log_consumer_config_keyed_by_own_topic() -> None: topics_seen: list[Topic | None] = [] - def fake_cluster_options(cluster_name, override_params=None, topic=None): + def fake_cluster_options( + cluster_name: str, + override_params: Mapping[str, object] | None = None, + topic: Topic | None = None, + ) -> dict[str, str]: topics_seen.append(topic) return {"bootstrap.servers": "127.0.0.1:9092"} diff --git a/tests/sentry/core/endpoints/scim/test_scim_user_details.py b/tests/sentry/core/endpoints/scim/test_scim_user_details.py index e64f0515e738..e16844e317d6 100644 --- a/tests/sentry/core/endpoints/scim/test_scim_user_details.py +++ b/tests/sentry/core/endpoints/scim/test_scim_user_details.py @@ -1,4 +1,5 @@ import unittest +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -32,7 +33,7 @@ } -def generate_put_data(member: OrganizationMember, role: str = "") -> dict: +def generate_put_data(member: OrganizationMember, role: str = "") -> dict[str, Any]: put_data = CREATE_USER_POST_DATA.copy() put_data["userName"] = member.email put_data["sentryOrgRole"] = role @@ -389,6 +390,7 @@ def test_user_details_set_inactive(self) -> None: user=self.create_user(email="test.user@okta.local"), organization=self.organization ) with assume_test_silo_mode(SiloMode.CONTROL): + assert member.user_id is not None AuthIdentity.objects.create( user_id=member.user_id, auth_provider=self.auth_provider_inst, ident="test_ident" ) @@ -416,6 +418,7 @@ def test_user_details_cannot_set_partnership_member_inactive(self) -> None: flags=OrganizationMember.flags["partnership:restricted"], ) with assume_test_silo_mode(SiloMode.CONTROL): + assert member.user_id is not None AuthIdentity.objects.create( user_id=member.user_id, auth_provider=self.auth_provider_inst, ident="test_ident" ) @@ -432,6 +435,7 @@ def test_user_details_set_inactive_dict(self) -> None: user=self.create_user(email="test.user@okta.local"), organization=self.organization ) with assume_test_silo_mode(SiloMode.CONTROL): + assert member.user_id is not None AuthIdentity.objects.create( user_id=member.user_id, auth_provider=self.auth_provider_inst, ident="test_ident" ) @@ -457,6 +461,7 @@ def test_user_details_set_inactive_with_bool_string(self) -> None: user=self.create_user(email="test.user@okta.local"), organization=self.organization ) with assume_test_silo_mode(SiloMode.CONTROL): + assert member.user_id is not None AuthIdentity.objects.create( user_id=member.user_id, auth_provider=self.auth_provider_inst, ident="test_ident" ) @@ -482,6 +487,7 @@ def test_user_details_set_inactive_with_dict_bool_string(self) -> None: user=self.create_user(email="test.user@okta.local"), organization=self.organization ) with assume_test_silo_mode(SiloMode.CONTROL): + assert member.user_id is not None AuthIdentity.objects.create( user_id=member.user_id, auth_provider=self.auth_provider_inst, ident="test_ident" ) @@ -507,6 +513,7 @@ def test_invalid_patch_op(self) -> None: user=self.create_user(email="test.user@okta.local"), organization=self.organization ) with assume_test_silo_mode(SiloMode.CONTROL): + assert member.user_id is not None AuthIdentity.objects.create( user_id=member.user_id, auth_provider=self.auth_provider_inst, ident="test_ident" ) @@ -523,6 +530,7 @@ def test_invalid_patch_op_value(self) -> None: user=self.create_user(email="test.user@okta.local"), organization=self.organization ) with assume_test_silo_mode(SiloMode.CONTROL): + assert member.user_id is not None AuthIdentity.objects.create( user_id=member.user_id, auth_provider=self.auth_provider_inst, ident="test_ident" ) @@ -549,6 +557,7 @@ def test_user_details_patch_404(self) -> None: def test_delete_route(self) -> None: member = self.create_member(user=self.create_user(), organization=self.organization) with assume_test_silo_mode(SiloMode.CONTROL): + assert member.user_id is not None AuthIdentity.objects.create( user_id=member.user_id, auth_provider=self.auth_provider_inst, ident="test_ident" ) @@ -570,6 +579,7 @@ def test_cannot_delete_partnership_member(self) -> None: flags=OrganizationMember.flags["partnership:restricted"], ) with assume_test_silo_mode(SiloMode.CONTROL): + assert member.user_id is not None AuthIdentity.objects.create( user_id=member.user_id, auth_provider=self.auth_provider_inst, ident="test_ident" ) @@ -737,7 +747,7 @@ class SCIMMemberSaasPrivilegeRevocationTests(SCIMTestCase): @patch("sentry.core.endpoints.scim.members.user_service") def test_delete_superuser_in_saas_default_org_revokes_privileges_and_deletes_membership( - self, mock_user_service + self, mock_user_service: MagicMock ) -> None: """ In SaaS mode with default org, deleting a superuser should revoke @@ -747,6 +757,7 @@ def test_delete_superuser_in_saas_default_org_revokes_privileges_and_deletes_mem member = self.create_member(user=superuser, organization=self.organization) with assume_test_silo_mode(SiloMode.CONTROL): + assert member.user_id is not None AuthIdentity.objects.create( user_id=member.user_id, auth_provider=self.auth_provider_inst, ident="test_ident" ) @@ -776,7 +787,7 @@ def test_delete_superuser_in_saas_default_org_revokes_privileges_and_deletes_mem @patch("sentry.core.endpoints.scim.members.user_service") def test_delete_staff_in_saas_default_org_revokes_privileges_and_deletes_membership( - self, mock_user_service + self, mock_user_service: MagicMock ) -> None: """ In SaaS mode with default org, deleting a staff member should revoke @@ -786,6 +797,7 @@ def test_delete_staff_in_saas_default_org_revokes_privileges_and_deletes_members member = self.create_member(user=staff_user, organization=self.organization) with assume_test_silo_mode(SiloMode.CONTROL): + assert member.user_id is not None AuthIdentity.objects.create( user_id=member.user_id, auth_provider=self.auth_provider_inst, ident="test_ident" ) @@ -815,7 +827,7 @@ def test_delete_staff_in_saas_default_org_revokes_privileges_and_deletes_members @patch("sentry.core.endpoints.scim.members.user_service") def test_delete_user_with_both_privileges_in_saas_default_org_revokes_both_and_deletes_membership( - self, mock_user_service + self, mock_user_service: MagicMock ) -> None: """ In SaaS mode with default org, deleting a user with both superuser and @@ -825,6 +837,7 @@ def test_delete_user_with_both_privileges_in_saas_default_org_revokes_both_and_d member = self.create_member(user=admin_user, organization=self.organization) with assume_test_silo_mode(SiloMode.CONTROL): + assert member.user_id is not None AuthIdentity.objects.create( user_id=member.user_id, auth_provider=self.auth_provider_inst, ident="test_ident" ) @@ -870,7 +883,7 @@ def test_delete_regular_user_in_non_default_org_deletes_membership_only(self) -> @patch("sentry.core.endpoints.scim.members.user_service") def test_patch_inactive_superuser_in_saas_default_org_revokes_privileges_and_deletes_membership( - self, mock_user_service + self, mock_user_service: MagicMock ) -> None: """ In SaaS mode with default org, PATCH with active=false for a superuser @@ -880,6 +893,7 @@ def test_patch_inactive_superuser_in_saas_default_org_revokes_privileges_and_del member = self.create_member(user=superuser, organization=self.organization) with assume_test_silo_mode(SiloMode.CONTROL): + assert member.user_id is not None AuthIdentity.objects.create( user_id=member.user_id, auth_provider=self.auth_provider_inst, ident="test_ident" ) @@ -916,7 +930,7 @@ def test_patch_inactive_superuser_in_saas_default_org_revokes_privileges_and_del @patch("sentry.core.endpoints.scim.members.user_service") def test_patch_inactive_staff_in_saas_default_org_revokes_privileges_and_deletes_membership( - self, mock_user_service + self, mock_user_service: MagicMock ) -> None: """ In SaaS mode with default org, PATCH with active=false for a staff member @@ -926,6 +940,7 @@ def test_patch_inactive_staff_in_saas_default_org_revokes_privileges_and_deletes member = self.create_member(user=staff_user, organization=self.organization) with assume_test_silo_mode(SiloMode.CONTROL): + assert member.user_id is not None AuthIdentity.objects.create( user_id=member.user_id, auth_provider=self.auth_provider_inst, ident="test_ident" ) diff --git a/tests/sentry/core/endpoints/scim/test_scim_user_index.py b/tests/sentry/core/endpoints/scim/test_scim_user_index.py index 4993a0e982b3..25e57d5c4504 100644 --- a/tests/sentry/core/endpoints/scim/test_scim_user_index.py +++ b/tests/sentry/core/endpoints/scim/test_scim_user_index.py @@ -1,5 +1,6 @@ import unittest from datetime import timedelta +from typing import Any from unittest.mock import MagicMock, call, patch from django.urls import reverse @@ -16,7 +17,7 @@ from sentry.testutils.silo import all_silo_test, assume_test_silo_mode -def post_data(): +def post_data() -> dict[str, Any]: return { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": "test.user@okta.local", @@ -26,7 +27,7 @@ def post_data(): } -def merge_dictionaries(dict1, dict2): +def merge_dictionaries(dict1: dict[str, Any], dict2: dict[str, Any]) -> dict[str, Any]: return {**dict1, **dict2} diff --git a/tests/sentry/core/endpoints/test_organization_details.py b/tests/sentry/core/endpoints/test_organization_details.py index b78af02d0ef3..6a76733d12ae 100644 --- a/tests/sentry/core/endpoints/test_organization_details.py +++ b/tests/sentry/core/endpoints/test_organization_details.py @@ -3,12 +3,13 @@ import re from base64 import b64encode from datetime import UTC, datetime, timedelta -from typing import Any +from typing import Any, cast from unittest.mock import MagicMock, patch import orjson import pytest from django.core import mail +from django.core.mail import EmailMessage from django.db import router from django.test import override_settings from django.urls import reverse @@ -66,7 +67,7 @@ ] -def get_trusted_relay_value(organization): +def get_trusted_relay_value(organization: Any) -> Any: return list( OrganizationOption.objects.filter( organization=organization, @@ -84,7 +85,7 @@ def setUp(self) -> None: class MockAccess: - def has_scope(self, scope): + def has_scope(self, scope: str) -> bool: # For the "test_as_no_org_read_user" we need a set of scopes that allows GET on the # OrganizationDetailsEndpoint to allow high-level access, but without "org:read" scope # to cover that branch with test. The scope "org:write" is a good candidate for this. @@ -99,7 +100,7 @@ def has_scope(self, scope): @cell_silo_test(cells=cells, include_monolith_run=True) class OrganizationDetailsTest(OrganizationDetailsTestBase): @property - def now(self): + def now(self) -> datetime: return datetime.now().replace(microsecond=0) def test_simple(self) -> None: @@ -234,7 +235,9 @@ def test_as_superuser(self) -> None: assert len(response.data["projects"]) == 5 assert len(response.data["teams"]) == 1 - def get_onboard_tasks(self, tasks, task_type): + def get_onboard_tasks( + self, tasks: list[dict[str, Any]], task_type: str + ) -> list[dict[str, Any]]: return [task for task in tasks if task["task"] == task_type] def test_onboarding_tasks(self) -> None: @@ -1202,7 +1205,7 @@ def test_update_slug(self) -> None: def test_org_mapping_already_taken(self) -> None: self.create_organization(slug="taken") res = self.get_error_response(self.organization.slug, slug="taken", status_code=400) - assert res.json()["slug"] == ['The slug "taken" is already in use.'] + assert orjson.loads(res.content)["slug"] == ['The slug "taken" is already in use.'] def test_org_mapping_already_taken_org_in_other_cell(self) -> None: de_cell = cells[1] @@ -1214,7 +1217,9 @@ def test_org_mapping_already_taken_org_in_other_cell(self) -> None: Organization.objects.filter(id=conflict.id).delete() res = self.get_error_response(self.organization.slug, slug="taken", status_code=400) - assert res.json()["slug"] == ['The slug "taken" is in use by another organization.'] + assert orjson.loads(res.content)["slug"] == [ + 'The slug "taken" is in use by another organization.' + ] def test_target_sample_rate_feature(self) -> None: with self.feature("organizations:dynamic-sampling-custom"): @@ -1673,7 +1678,9 @@ def test_default_automated_run_stopping_point_can_be_set(self) -> None: assert response.data["defaultAutomatedRunStoppingPoint"] == choice @patch("sentry.seer.autofix.utils.is_seer_seat_based_tier_enabled", return_value=True) - def test_default_automated_run_stopping_point_rejects_invalid(self, mock_seat_based) -> None: + def test_default_automated_run_stopping_point_rejects_invalid( + self, mock_seat_based: MagicMock + ) -> None: for invalid in ("solution", "invalid_point"): with self.subTest(value=invalid): data = {"defaultAutomatedRunStoppingPoint": invalid} @@ -2234,18 +2241,18 @@ def setUp(self) -> None: with assume_test_silo_mode_of(Authenticator): assert self.has_2fa.has_2fa() - def assert_2fa_email_equal(self, outbox, expected): + def assert_2fa_email_equal(self, outbox: list[EmailMessage], expected: list[str]) -> None: invite_url_regex = re.compile(r"http://.*/accept/[^/]+/[0-9]+/[a-f0-9]+/") assert len(outbox) == len(expected) assert sorted(email.to[0] for email in outbox) == sorted(expected) for email in outbox: - assert invite_url_regex.search(email.body), ( + assert invite_url_regex.search(cast(str, email.body)), ( f"No invite URL found in 2FA invite email body to: {email.to}" ) def assert_has_correct_audit_log( self, acting_user: User, target_user: User, organization: Organization - ): + ) -> None: with outbox_runner(): pass @@ -2445,7 +2452,7 @@ def test_trusted_relays_option_serialization() -> None: @pytest.mark.parametrize("invalid_data", invalid_payloads) -def test_trusted_relay_serializer_validation(invalid_data) -> None: +def test_trusted_relay_serializer_validation(invalid_data: Any) -> None: """ Tests that the public key is validated """ diff --git a/tests/sentry/core/endpoints/test_organization_index.py b/tests/sentry/core/endpoints/test_organization_index.py index 110275cc7ee1..c775f9749146 100644 --- a/tests/sentry/core/endpoints/test_organization_index.py +++ b/tests/sentry/core/endpoints/test_organization_index.py @@ -833,9 +833,9 @@ def setUp(self) -> None: self.no_2fa_user = self.create_user() self.create_member(organization=self.org_2fa, user=self.no_2fa_user, role="member") - def assert_redirected_to_2fa(self): + def assert_redirected_to_2fa(self) -> None: response = self.get_success_response(self.org_2fa.slug, status_code=302) - assert self.path_2fa in response.url + assert self.path_2fa in response.headers["Location"] def test_preexisting_members_must_enable_2fa(self) -> None: self.login_as(self.no_2fa_user) @@ -876,7 +876,7 @@ class OrganizationIndexMemberLimitTest(APITestCase): # This is a react view, not an API endpoint endpoint = "sentry-organization-index" - def setup_user(self, is_superuser=False): + def setup_user(self, is_superuser: bool = False) -> None: self.organization = self.create_organization() self.user = self.create_user(is_superuser=is_superuser) self.create_member( @@ -890,7 +890,10 @@ def setup_user(self, is_superuser=False): def test_member_limit_redirect(self) -> None: self.setup_user() response = self.get_success_response(self.organization.slug, status_code=302) - assert f"/organizations/{self.organization.slug}/disabled-member/" in response.url + assert ( + f"/organizations/{self.organization.slug}/disabled-member/" + in response.headers["Location"] + ) def test_member_limit_superuser_no_redirect(self) -> None: self.setup_user(is_superuser=True) diff --git a/tests/sentry/core/endpoints/test_organization_member_details.py b/tests/sentry/core/endpoints/test_organization_member_details.py index 5ea2d88f01fc..7b9aac8f7648 100644 --- a/tests/sentry/core/endpoints/test_organization_member_details.py +++ b/tests/sentry/core/endpoints/test_organization_member_details.py @@ -836,8 +836,8 @@ def test_cannot_demote_team_member_to_role_where_team_roles_disabled( wraps=mock_organization_roles_get_factory(organization_roles.get), ) def test_can_demote_team_member_to_role_where_team_roles_disabled_with_team_removed( - self, mock_get - ): + self, mock_get: MagicMock + ) -> None: team = self.create_team(organization=self.organization, name="Team Foo") self.manager = self.create_user() @@ -1284,7 +1284,7 @@ def setUp(self) -> None: self.interface_id = totp.authenticator.id assert Authenticator.objects.filter(user=self.member).exists() - def assert_can_get_authenticators(self): + def assert_can_get_authenticators(self) -> None: path = reverse( "sentry-api-0-organization-member-details", args=[self.org.slug, self.member_om.id] ) @@ -1296,7 +1296,7 @@ def assert_can_get_authenticators(self): assert data["user"]["has2fa"] is True assert data["user"]["canReset2fa"] is True - def assert_cannot_get_authenticators(self): + def assert_cannot_get_authenticators(self) -> None: path = reverse( "sentry-api-0-organization-member-details", args=[self.org.slug, self.member_om.id] ) @@ -1308,7 +1308,7 @@ def assert_cannot_get_authenticators(self): assert "canReset2fa" not in data["user"] @assume_test_silo_mode(SiloMode.CONTROL) - def assert_can_remove_authenticators(self): + def assert_can_remove_authenticators(self) -> None: path = reverse( "sentry-api-0-user-authenticator-details", args=[self.member.id, self.interface_id] ) @@ -1317,7 +1317,7 @@ def assert_can_remove_authenticators(self): assert not Authenticator.objects.filter(user=self.member).exists() @assume_test_silo_mode(SiloMode.CONTROL) - def assert_cannot_remove_authenticators(self): + def assert_cannot_remove_authenticators(self) -> None: path = reverse( "sentry-api-0-user-authenticator-details", args=[self.member.id, self.interface_id] ) @@ -1399,7 +1399,7 @@ def test_cannot_reset_member_2fa__org_requires_2fa(self) -> None: TotpInterface().enroll(self.owner) self.org.update(flags=F("flags").bitor(Organization.flags.require_2fa)) - assert self.org.flags.require_2fa.is_set is True + assert self.org.flags.require_2fa self.assert_cannot_remove_authenticators() diff --git a/tests/sentry/core/endpoints/test_organization_member_index.py b/tests/sentry/core/endpoints/test_organization_member_index.py index 5c22da2dfc1c..56a47b4eb11c 100644 --- a/tests/sentry/core/endpoints/test_organization_member_index.py +++ b/tests/sentry/core/endpoints/test_organization_member_index.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from dataclasses import replace from unittest.mock import MagicMock, patch @@ -10,6 +11,7 @@ from sentry.models.organizationmember import InviteStatus, OrganizationMember from sentry.models.organizationmemberteam import OrganizationMemberTeam from sentry.roles import organization_roles +from sentry.roles.manager import OrganizationRole from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase, TestCase from sentry.testutils.helpers import Feature, with_feature @@ -21,8 +23,10 @@ from sentry.users.models.useremail import UserEmail -def mock_organization_roles_get_factory(original_organization_roles_get): - def wrapped_method(role): +def mock_organization_roles_get_factory( + original_organization_roles_get: Callable[[str], OrganizationRole], +) -> Callable[[str], OrganizationRole]: + def wrapped_method(role: str) -> OrganizationRole: # emulate the 'member' role not having team-level permissions role_obj = original_organization_roles_get(role) if role == "member": @@ -406,8 +410,8 @@ def test_2fa_enabled_query(self) -> None: # Two authenticators to ensure the user list is distinct with assume_test_silo_mode(SiloMode.CONTROL): - Authenticator.objects.create(user_id=member_2fa.user_id, type=1, config={}) - Authenticator.objects.create(user_id=member_2fa.user_id, type=2, config={}) + Authenticator.objects.create(user_id=user.id, type=1, config={}) + Authenticator.objects.create(user_id=user.id, type=2, config={}) response = self.get_success_response( self.organization.slug, qs_params={"query": "has2fa:true"} @@ -618,7 +622,7 @@ def test_does_not_include_placeholder_org_members(self) -> None: class OrganizationMemberPermissionRoleTest(OrganizationMemberListTestBase, HybridCloudTestMixin): method = "post" - def invite_all_helper(self, role): + def invite_all_helper(self, role: str) -> None: invite_roles = ["owner", "manager", "member"] user = self.create_user("user@localhost") @@ -661,16 +665,18 @@ def invite_all_helper(self, role): else: self.get_error_response(self.organization.slug, **data, status_code=400) - def invite_to_other_team_helper(self, role): + def invite_to_other_team_helper(self, role: str) -> None: user = self.create_user("inviter@localhost") self.create_member(user=user, organization=self.organization, role=role, teams=[self.team]) self.login_as(user=user) other_team = self.create_team(organization=self.organization, name="Moo Deng's Team") - def get_data(email: str, other_team_invite: bool = False, use_team_roles: bool = True): + def get_data( + email: str, other_team_invite: bool = False, use_team_roles: bool = True + ) -> dict[str, str | list[dict[str, str]] | list[str]]: team_slug = other_team.slug if other_team_invite else self.team.slug - data: dict[str, str | list] = { + data: dict[str, str | list[dict[str, str]] | list[str]] = { "email": f"{email}@localhost", "role": "member", } diff --git a/tests/sentry/core/endpoints/test_organization_member_team_details.py b/tests/sentry/core/endpoints/test_organization_member_team_details.py index 2377d21b9356..8d5998b09826 100644 --- a/tests/sentry/core/endpoints/test_organization_member_team_details.py +++ b/tests/sentry/core/endpoints/test_organization_member_team_details.py @@ -13,6 +13,7 @@ from sentry.models.organizationaccessrequest import OrganizationAccessRequest from sentry.models.organizationmember import OrganizationMember from sentry.models.organizationmemberteam import OrganizationMemberTeam +from sentry.models.team import Team from sentry.notifications.types import GroupSubscriptionReason from sentry.roles import organization_roles from sentry.testutils.asserts import assert_org_audit_log_exists @@ -29,64 +30,64 @@ class OrganizationMemberTeamTestBase(APITestCase): endpoint = "sentry-api-0-organization-member-team-details" @cached_property - def org(self): + def org(self) -> Organization: # open membership return self.create_organization(owner=self.user, flags=Organization.flags.allow_joinleave) @cached_property - def team(self): + def team(self) -> Team: return self.create_team(organization=self.org) @cached_property - def idp_team(self): + def idp_team(self) -> Team: return self.create_team(organization=self.org, idp_provisioned=True) @cached_property - def owner(self): + def owner(self) -> OrganizationMember: return OrganizationMember.objects.get(organization=self.org, user_id=self.user.id) @cached_property - def member(self): + def member(self) -> OrganizationMember: return self.create_member(organization=self.org, user=self.create_user(), role="member") @cached_property - def admin(self): + def admin(self) -> OrganizationMember: self.admin_user = self.create_user() return self.create_member(organization=self.org, user=self.admin_user, role="admin") @cached_property - def manager(self): + def manager(self) -> OrganizationMember: self.manager_user = self.create_user() return self.create_member(organization=self.org, user=self.manager_user, role="manager") @cached_property - def member_on_team(self): + def member_on_team(self) -> OrganizationMember: self.member_on_team_user = self.create_user() return self.create_member( organization=self.org, user=self.member_on_team_user, role="member", teams=[self.team] ) @cached_property - def admin_on_team(self): + def admin_on_team(self) -> OrganizationMember: self.admin_on_team_user = self.create_user() return self.create_member( organization=self.org, user=self.admin_on_team_user, role="admin", teams=[self.team] ) @cached_property - def manager_on_team(self): + def manager_on_team(self) -> OrganizationMember: return self.create_member( organization=self.org, user=self.create_user(), role="manager", teams=[self.team] ) @cached_property - def owner_on_team(self): + def owner_on_team(self) -> OrganizationMember: return self.create_member( organization=self.org, user=self.create_user(), role="owner", teams=[self.team] ) @cached_property - def team_admin(self): + def team_admin(self) -> OrganizationMember: self.team_admin_user = self.create_user() member = self.create_member(organization=self.org, user=self.team_admin_user, role="member") OrganizationMemberTeam.objects.create( @@ -307,7 +308,7 @@ def test_team_admin_can_add_member(self) -> None: class CreateWithClosedMembershipTest(CreateOrganizationMemberTeamTest): @cached_property - def org(self): + def org(self) -> Organization: # rerun create org member tests with closed membership return self.create_organization(owner=self.user, flags=0) diff --git a/tests/sentry/core/endpoints/test_organization_projects.py b/tests/sentry/core/endpoints/test_organization_projects.py index 686c12c47976..adc2c4c047f2 100644 --- a/tests/sentry/core/endpoints/test_organization_projects.py +++ b/tests/sentry/core/endpoints/test_organization_projects.py @@ -1,8 +1,11 @@ +from typing import Any + import pytest from django.test import override_settings from django.urls import reverse from sentry.models.apikey import ApiKey +from sentry.models.project import Project from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.features import with_feature @@ -17,7 +20,7 @@ class OrganizationProjectsTestBase(APITestCase): endpoint = "sentry-api-0-organization-projects" @staticmethod - def check_valid_response(response, expected_projects): + def check_valid_response(response: Any, expected_projects: list[Project]) -> None: assert [project.id for project in expected_projects] == [ int(project_resp["id"]) for project_resp in response.data ] diff --git a/tests/sentry/core/endpoints/test_organization_projects_create.py b/tests/sentry/core/endpoints/test_organization_projects_create.py index 0ef0d07fc315..db3021f95697 100644 --- a/tests/sentry/core/endpoints/test_organization_projects_create.py +++ b/tests/sentry/core/endpoints/test_organization_projects_create.py @@ -1,4 +1,5 @@ import re +from typing import Any from unittest import mock from unittest.mock import MagicMock, Mock, patch @@ -33,7 +34,7 @@ def setUp(self) -> None: self.email_username = fetch_slugifed_email_username(self.user.email) self.t1 = f"team-{self.email_username}" - def validate_team_with_suffix(self, team: Team): + def validate_team_with_suffix(self, team: Team) -> bool: pattern = rf"^{self.t1}-[a-z]{{3}}$" return bool(re.match(pattern, team.slug)) and bool(re.match(pattern, team.name)) @@ -263,7 +264,7 @@ def test_duplicate_team_post_suffixing(self) -> None: create_reference = Team.objects.create # Call create team with the same slug as the above request - def create_copy(*args, **kwargs): + def create_copy(*args: Any, **kwargs: Any) -> Any: kwargs["slug"] = self.t1 return create_reference(*args, **kwargs) @@ -286,7 +287,7 @@ def test_member_does_not_exist(self) -> None: # function then call the reference unless the organization matches the test case get_reference = OrganizationMember.objects.get - def get_callthrough(*args, **kwargs): + def get_callthrough(*args: Any, **kwargs: Any) -> Any: if self.organization in kwargs.values(): raise OrganizationMember.DoesNotExist return get_reference(*args, **kwargs) diff --git a/tests/sentry/core/endpoints/test_project_details.py b/tests/sentry/core/endpoints/test_project_details.py index 2ac18a10c131..fc9b8ed171d4 100644 --- a/tests/sentry/core/endpoints/test_project_details.py +++ b/tests/sentry/core/endpoints/test_project_details.py @@ -1,6 +1,7 @@ from __future__ import annotations from abc import ABC +from collections.abc import Sequence from datetime import datetime, timedelta, timezone from time import time from typing import Any @@ -31,6 +32,7 @@ from sentry.models.projectredirect import ProjectRedirect from sentry.models.projectteam import ProjectTeam from sentry.models.rule import Rule +from sentry.models.team import Team from sentry.silo.base import SiloMode from sentry.silo.safety import unguarded_write from sentry.testutils.cases import APITestCase @@ -40,7 +42,7 @@ from sentry.utils.slug import DEFAULT_SLUG_ERROR_MESSAGE -def first_symbol_source_id(sources_json): +def first_symbol_source_id(sources_json: bytes | str) -> Any: sources = orjson.loads(sources_json) return sources[0]["id"] @@ -1398,7 +1400,7 @@ def test_redacted_symbol_source_secrets_unknown_secret( "symbolSources": ["Hidden symbol source secret is missing a value"] } - def symbol_sources(self): + def symbol_sources(self) -> list[dict[str, Any]]: project = Project.objects.get(id=self.project.id) source1 = { "id": "honk", @@ -1544,11 +1546,11 @@ def setUp(self) -> None: self.create_project_rule(project=self.other_project, name="rule3") self.rules = Rule.objects.filter(project_id=self.other_project.id).order_by("label") - def assert_other_project_settings_not_changed(self): + def assert_other_project_settings_not_changed(self) -> None: # other_project should not have changed. This should check that. self.assert_settings_copied(self.other_project) - def assert_settings_copied(self, project): + def assert_settings_copied(self, project: Project) -> None: for key, value in self.options_dict.items(): assert project.get_option(key) == value @@ -1568,7 +1570,7 @@ def assert_settings_copied(self, project): for rule, other_rule in zip(rules, self.rules): assert rule.label == other_rule.label - def assert_settings_not_copied(self, project, teams=()): + def assert_settings_not_copied(self, project: Project, teams: Sequence[Team] = ()) -> None: for key in self.options_dict.keys(): assert project.get_option(key) is None @@ -1712,7 +1714,7 @@ def setUp(self) -> None: self.login_as(user=self.user) @mock.patch("sentry.db.pending_deletion.uuid4") - def _delete_project_and_assert_deleted(self, mock_uuid4_mixin): + def _delete_project_and_assert_deleted(self, mock_uuid4_mixin: mock.MagicMock) -> None: mock_uuid4_mixin.return_value = self.get_mock_uuid() with self.settings(SENTRY_PROJECT=0): @@ -1764,8 +1766,8 @@ def test_internal_project(self) -> None: "sentry.tasks.seer.delete_seer_grouping_records.call_seer_delete_project_grouping_records.apply_async" ) def test_delete_project_and_delete_grouping_records( - self, mock_call_seer_delete_project_grouping_records - ): + self, mock_call_seer_delete_project_grouping_records: mock.MagicMock + ) -> None: self.project.update_option("sentry:similarity_backfill_completed", int(time())) self._delete_project_and_assert_deleted() mock_call_seer_delete_project_grouping_records.assert_called_with(args=[self.project.id]) @@ -1818,7 +1820,7 @@ def setUp(self) -> None: self.login_as(user=self.user) self._apply_old_date_to_project_and_org() - def _apply_old_date_to_project_and_org(self): + def _apply_old_date_to_project_and_org(self) -> None: # We have to create the project and organization in the past, since we boost new orgs and projects to 100% # automatically. old_date = datetime.now(tz=timezone.utc) - timedelta( @@ -2006,7 +2008,7 @@ def test_dynamic_sampling_bias_deactivation(self) -> None: def test_put_dynamic_sampling_after_migrating_to_new_plan_default_biases_with_missing_flags( self, - ): + ) -> None: """ Test for case when a user is an old plan but tries to update dynamic sampling biases that is a feature of new plans diff --git a/tests/sentry/core/endpoints/test_project_index.py b/tests/sentry/core/endpoints/test_project_index.py index 21d6cfef855c..2e66ee9b6037 100644 --- a/tests/sentry/core/endpoints/test_project_index.py +++ b/tests/sentry/core/endpoints/test_project_index.py @@ -309,7 +309,7 @@ def test_deleted_token_with_internal_integration(self) -> None: status_code=status.HTTP_401_UNAUTHORIZED, ) - def get_installed_unpublished_sentry_app_access_token(self) -> ApiToken: + def get_installed_unpublished_sentry_app_access_token(self) -> str: self.project = self.create_project(organization=self.organization, teams=[self.team]) sentry_app = self.create_sentry_app( scopes=("project:read",), @@ -320,7 +320,9 @@ def get_installed_unpublished_sentry_app_access_token(self) -> ApiToken: installation = self.create_sentry_app_installation( slug=sentry_app.slug, organization=self.organization, user=self.user ) - return installation.api_token.token + api_token = installation.api_token + assert api_token is not None + return api_token.token def test_valid_with_public_integration(self) -> None: token = self.get_installed_unpublished_sentry_app_access_token() @@ -333,10 +335,10 @@ def test_valid_with_public_integration(self) -> None: @responses.activate def test_deleted_token_with_public_integration(self) -> None: - token = self.get_installed_unpublished_sentry_app_access_token() + token_str = self.get_installed_unpublished_sentry_app_access_token() with assume_test_silo_mode(SiloMode.CONTROL), outbox_runner(): - token = ApiToken.objects.get(token=token) + token = ApiToken.objects.get(token=token_str) token.delete() with self.tasks(): diff --git a/tests/sentry/core/endpoints/test_project_team_details.py b/tests/sentry/core/endpoints/test_project_team_details.py index f1ee6a422f81..ed5b5484cc78 100644 --- a/tests/sentry/core/endpoints/test_project_team_details.py +++ b/tests/sentry/core/endpoints/test_project_team_details.py @@ -131,7 +131,7 @@ def test_remove_team(self) -> None: ar1.refresh_from_db() ar2.refresh_from_db() - assert (r1.owner_team, ar1.team) == (None, None) + assert not r1.owner_team and not ar1.team assert r2.owner_team == ar2.team == team self.get_success_response( @@ -146,7 +146,7 @@ def test_remove_team(self) -> None: ar1.refresh_from_db() ar2.refresh_from_db() - assert (r1.owner_team, r2.owner_team, ar1.team, ar2.team) == (None, None, None, None) + assert not r1.owner_team and not r2.owner_team and not ar1.team and not ar2.team self.get_success_response( another_project.organization.slug, diff --git a/tests/sentry/core/endpoints/test_project_users.py b/tests/sentry/core/endpoints/test_project_users.py index 633695f07be6..5516171d7cfd 100644 --- a/tests/sentry/core/endpoints/test_project_users.py +++ b/tests/sentry/core/endpoints/test_project_users.py @@ -2,6 +2,7 @@ from unittest import mock from django.utils import timezone +from rest_framework.response import Response from sentry.analytics.events.eventuser_endpoint_request import EventUserEndpointRequest from sentry.testutils.cases import APITestCase, SnubaTestCase @@ -49,7 +50,7 @@ def setUp(self) -> None: ) self.euser2 = EventUser.from_event(self.event2) - def _assert_simple_response(self, response, mock_record): + def _assert_simple_response(self, response: Response, mock_record: mock.MagicMock) -> None: assert len(response.data) == 2 if self.euser1.id is None and self.euser2.id is None: assert list(map(lambda x: x["id"], response.data)) == [None, None] diff --git a/tests/sentry/core/endpoints/test_team_details.py b/tests/sentry/core/endpoints/test_team_details.py index d75acb09dab7..7f3c92f4f731 100644 --- a/tests/sentry/core/endpoints/test_team_details.py +++ b/tests/sentry/core/endpoints/test_team_details.py @@ -45,14 +45,14 @@ def assert_team_status( assert audit_log_event assert scheduled_deletion_exists - def assert_team_deleted(self, team_id): + def assert_team_deleted(self, team_id: int) -> None: """ Checks team status, membership in DeletedTeams table, org audit log, and to see that delete function has been called. """ self.assert_team_status(team_id, TeamStatus.PENDING_DELETION) - def assert_team_not_deleted(self, team_id): + def assert_team_not_deleted(self, team_id: int) -> None: """ Checks team status, membership in DeletedTeams table, org audit log, and to see that delete function has not been called. diff --git a/tests/sentry/core/endpoints/test_team_projects.py b/tests/sentry/core/endpoints/test_team_projects.py index b8f9eecf7487..77c602a3473f 100644 --- a/tests/sentry/core/endpoints/test_team_projects.py +++ b/tests/sentry/core/endpoints/test_team_projects.py @@ -1,3 +1,4 @@ +from typing import cast from unittest import TestCase, mock from unittest.mock import MagicMock, Mock, patch @@ -395,7 +396,7 @@ def test_default_inbound_filters(self) -> None: } assert javascript_filter_states["browser-extensions"] - assert set(javascript_filter_states["legacy-browsers"]) == { + assert set(cast(list[str], javascript_filter_states["legacy-browsers"])) == { "ie", "firefox", "chrome", @@ -651,7 +652,7 @@ def test_project_autofix_tuning_not_set_if_org_option_not_set_in_db(self) -> Non @patch("sentry.seer.similarity.utils.is_seer_seat_based_tier_enabled", return_value=True) def test_project_autofix_tuning_defaults_to_medium_with_seat_based_tier( - self, mock_seat_based_tier + self, mock_seat_based_tier: MagicMock ) -> None: # Ensure no org-level default is set self.organization.delete_option("sentry:default_autofix_automation_tuning") @@ -671,7 +672,7 @@ def test_project_autofix_tuning_defaults_to_medium_with_seat_based_tier( @patch("sentry.seer.similarity.utils.is_seer_seat_based_tier_enabled", return_value=True) def test_project_autofix_tuning_respects_explicit_off_even_with_seat_based_tier( - self, mock_seat_based_tier + self, mock_seat_based_tier: MagicMock ) -> None: # Org explicitly sets "off" - should be respected even with seat-based tier self.organization.update_option("sentry:default_autofix_automation_tuning", "off") @@ -691,7 +692,7 @@ def test_project_autofix_tuning_respects_explicit_off_even_with_seat_based_tier( @patch("sentry.seer.similarity.utils.is_seer_seat_based_tier_enabled", return_value=True) def test_project_autofix_tuning_seat_based_tier_overrides_non_off_org_default( - self, mock_seat_based_tier + self, mock_seat_based_tier: MagicMock ) -> None: # Org sets "high" but seat-based tier overrides to "medium" self.organization.update_option("sentry:default_autofix_automation_tuning", "high") diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py index debf9da0e400..b7adbee01718 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py @@ -1,12 +1,15 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta from typing import Any from unittest import mock from urllib.parse import parse_qs, urlsplit import pytest +from django.db.models import QuerySet from django.urls import reverse +from rest_framework.response import Response from sentry import audit_log from sentry.dashboards.endpoints.organization_dashboards import PrebuiltDashboardId @@ -96,7 +99,7 @@ def setUp(self) -> None: order=0, ) - def url(self, dashboard_id): + def url(self, dashboard_id: int | str) -> str: return reverse( "sentry-api-0-organization-dashboard-details", kwargs={ @@ -105,7 +108,7 @@ def url(self, dashboard_id): }, ) - def assert_serialized_dashboard(self, data, dashboard): + def assert_serialized_dashboard(self, data: Mapping[str, Any], dashboard: Dashboard) -> None: assert data["id"] == str(dashboard.id) assert data["title"] == dashboard.title assert data["createdBy"]["id"] == str(dashboard.created_by_id) @@ -903,6 +906,7 @@ def setUp(self) -> None: super().setUp() self.project = self.create_project() self.create_user_member_role() + Dashboard.objects.filter(id=self.dashboard.id).update(created_by_id=self.user.id) self.widget_3 = DashboardWidget.objects.create( dashboard=self.dashboard, order=2, @@ -919,18 +923,18 @@ def setUp(self) -> None: ) self.widget_ids = [self.widget_1.id, self.widget_2.id, self.widget_3.id, self.widget_4.id] - def get_widget_queries(self, widget): + def get_widget_queries(self, widget: DashboardWidget) -> QuerySet[DashboardWidgetQuery]: return DashboardWidgetQuery.objects.filter(widget=widget).order_by("order") - def assert_no_changes(self): + def assert_no_changes(self) -> None: self.assert_dashboard_and_widgets(self.widget_ids) - def assert_dashboard_and_widgets(self, widget_ids): + def assert_dashboard_and_widgets(self, widget_ids: Sequence[int]) -> None: assert Dashboard.objects.filter( organization=self.organization, id=self.dashboard.id ).exists() - widgets = self.get_widgets(self.dashboard) + widgets = self.get_widgets(self.dashboard.id) assert len(widgets) == len(list(widget_ids)) for widget, id in zip(widgets, widget_ids): @@ -1422,7 +1426,7 @@ def test_update_text_widget_description_exceeds_15000_chars(self) -> None: ) def test_add_widget_with_limit(self) -> None: - data = { + data: dict[str, Any] = { "title": "First dashboard", "widgets": [ { @@ -1795,7 +1799,7 @@ def test_add_widget_e2e_test_with_translation(self) -> None: assert translated_widget_query.orderby == "" def test_update_widget_title(self) -> None: - data = { + data: dict[str, Any] = { "title": "First dashboard", "widgets": [ {"id": str(self.widget_1.id), "title": "New title"}, @@ -1961,7 +1965,7 @@ def test_update_widget_invalid_orderby(self) -> None: def test_remove_widget_and_add_new(self) -> None: # Remove a widget from the middle of the set and put a new widget there - data = { + data: dict[str, Any] = { "title": "First dashboard", "widgets": [ {"id": str(self.widget_1.id)}, @@ -2118,6 +2122,49 @@ def test_update_widget_with_invalid_preferred_polarity(self) -> None: == "Must be '+', '-', or empty string." ) + def test_update_widget_with_invalid_thresholds(self) -> None: + cases: tuple[tuple[object, str], ...] = ( + ([], "Must be an object."), + ({"max_values": []}, "Must be an object."), + ( + {"max_values": {"max1": 100, "max3": 300}}, + "Invalid maximum key max3", + ), + ( + {"max_values": {"max1": "100", "max2": 200}}, + "Maximum values must be integers.", + ), + ( + {"max_values": {"max1": 100, "max2": 200}, "unit": 5}, + "Must be a string.", + ), + ) + for thresholds, message in cases: + with self.subTest(thresholds=thresholds): + data = { + "title": "Dashboard", + "widgets": [ + { + "id": str(self.widget_1.id), + "title": "Big Number with Invalid Thresholds", + "displayType": "big_number", + "thresholds": thresholds, + "queries": [ + { + "name": "", + "fields": ["count()"], + "columns": [], + "aggregates": ["count()"], + "conditions": "", + } + ], + }, + ], + } + response = self.do_request("put", self.url(self.dashboard.id), data=data) + assert response.status_code == 400, response.data + assert message in str(response.data) + def test_update_widget_with_invalid_threshold_time_window(self) -> None: data = { "title": "Dashboard", @@ -2285,7 +2332,7 @@ def test_update_migrated_spans_widget_reset_changed_reason(self) -> None: assert spans_widget.dataset_source == DashboardWidgetDatasetSourcesTypes.USER.value def test_remove_widgets(self) -> None: - data = { + data: dict[str, Any] = { "title": "First dashboard", "widgets": [ {"id": str(self.widget_1.id), "title": "New title"}, @@ -2843,7 +2890,7 @@ def test_update_dashboard_permissions_with_put(self) -> None: "permissions": {"isEditableByEveryone": "False"}, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.user.id) self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2860,7 +2907,7 @@ def test_update_dashboard_permissions_to_false(self) -> None: "permissions": {"isEditableByEveryone": "false"}, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.user.id) self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2880,7 +2927,7 @@ def test_update_dashboard_permissions_when_already_created(self) -> None: } assert permission.is_editable_by_everyone is True - user = User(id=self.dashboard.created_by_id) + user = User(id=self.user.id) self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -3060,7 +3107,7 @@ def test_update_dashboard_permissions_with_new_teams(self) -> None: }, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.user.id) self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -3096,7 +3143,7 @@ def test_update_teams_in_dashboard_permissions(self) -> None: }, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.user.id) self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -3127,7 +3174,7 @@ def test_update_dashboard_permissions_with_invalid_teams(self) -> None: }, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.user.id) self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -3154,7 +3201,7 @@ def test_update_dashboard_permissions_with_teams_from_different_org(self) -> Non self.create_environment(project=mock_project, name="mock_env") - user = User(id=self.dashboard.created_by_id) + user = User(id=self.user.id) self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -3200,7 +3247,7 @@ def test_select_everyone_in_dashboard_permissions_clears_all_teams(self) -> None }, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.user.id) self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -4874,7 +4921,7 @@ class OrganizationDashboardDetailsOnDemandTransactionLikeTest( class OrganizationDashboardVisitTest(OrganizationDashboardDetailsTestCase): - def url(self, dashboard_id): + def url(self, dashboard_id: int | str) -> str: return reverse( "sentry-api-0-organization-dashboard-visit", kwargs={ @@ -4922,7 +4969,7 @@ def setUp(self) -> None: # Both users have favorited the dashboard self.dashboard.favorited_by = [self.user_1.id, self.user_2.id] - def url(self, dashboard_id): + def url(self, dashboard_id: int | str) -> str: return reverse( "sentry-api-0-organization-dashboard-favorite", kwargs={ @@ -4982,9 +5029,9 @@ class OrganizationDashboardFavoriteReorderingTest(OrganizationDashboardDetailsTe features = ["organizations:dashboards-starred"] - def do_request(self, *args, **kwargs): + def do_request(self, method: str, url: str, data: dict[str, Any] | None = None) -> Response: with self.feature(self.features): - return super().do_request(*args, **kwargs) + return super().do_request(method, url, data) def setUp(self) -> None: super().setUp() @@ -4997,7 +5044,7 @@ def setUp(self) -> None: dashboard=self.dashboard, ) - def url(self, dashboard_id): + def url(self, dashboard_id: int | str) -> str: return reverse( "sentry-api-0-organization-dashboard-favorite", kwargs={ @@ -5058,7 +5105,7 @@ def test_unfavorite_dashboard(self) -> None: @with_feature("organizations:granular-permission-scopes") @granular_roles_granting("user_preference:read", "user_preference:write") class OrganizationDashboardFavoriteScopeTest(OrganizationDashboardDetailsTestCase): - def url(self, dashboard_id: int) -> str: + def url(self, dashboard_id: int | str) -> str: return reverse( "sentry-api-0-organization-dashboard-favorite", kwargs={ diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboard_revision_restore.py b/tests/sentry/dashboards/endpoints/test_organization_dashboard_revision_restore.py index 54997254011d..29ac3d6e72a0 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboard_revision_restore.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboard_revision_restore.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from django.urls import reverse from sentry import audit_log @@ -61,7 +63,7 @@ def _url(self, revision_id: int | str) -> str: ) def _create_revision( - self, snapshot: dict | None = None, title: str = "Dashboard 1" + self, snapshot: dict[str, Any] | None = None, title: str = "Dashboard 1" ) -> DashboardRevision: return DashboardRevision.objects.create( dashboard=self.dashboard, diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py b/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py index a69729570fc2..992234794bd2 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py @@ -25,7 +25,7 @@ class OrganizationDashboardWidgetDetailsTestCase(OrganizationDashboardWidgetTestCase): - def url(self): + def url(self) -> str: return reverse( "sentry-api-0-organization-dashboard-widget-details", kwargs={"organization_id_or_slug": self.organization.slug}, @@ -844,7 +844,7 @@ def test_dashboard_hits_max_specs(self, mock_max: mock.MagicMock) -> None: DashboardWidgetQueryOnDemand.objects.create( dashboard_widget_query=self.widget_1_data_1, spec_version=OnDemandMetricSpecVersioning.get_query_spec_version( - self.organization + self.organization.id ).version, spec_hashes=["abcd"], extraction_state="enabled:manual", @@ -943,7 +943,7 @@ def test_first_query_without_ondemand_but_second_with(self, mock_max: mock.Magic DashboardWidgetQueryOnDemand.objects.create( dashboard_widget_query=self.widget_1_data_1, spec_version=OnDemandMetricSpecVersioning.get_query_spec_version( - self.organization + self.organization.id ).version, spec_hashes=["abcd"], extraction_state="enabled:manual", @@ -1093,7 +1093,7 @@ def test_widget_cardinality(self) -> None: option_get = options.get - def mock_options(option_name): + def mock_options(option_name: str) -> object: if option_name == "on_demand.max_widget_cardinality.on_query_count": return 0 else: diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py index 549cebcb6a47..2218ed5b7774 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py @@ -1,8 +1,9 @@ from __future__ import annotations +from collections.abc import Mapping from datetime import datetime, timedelta from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch from django.db import connection from django.urls import reverse @@ -53,7 +54,7 @@ def setUp(self) -> None: interval="1d", ) - def assert_equal_dashboards(self, dashboard, data): + def assert_equal_dashboards(self, dashboard: Dashboard, data: Mapping[str, Any]) -> None: assert data["id"] == str(dashboard.id) assert data["title"] == dashboard.title assert data["createdBy"]["id"] == str(dashboard.created_by_id) @@ -1239,7 +1240,7 @@ def test_post_allows_short_widget_display_type_at_height_one(self) -> None: } def test_post_widget_with_camel_case_layout_keys_returns_camel_case(self) -> None: - data = { + data: dict[str, Any] = { "title": "Dashboard from Post", "widgets": [ { @@ -1591,7 +1592,7 @@ def test_post_dashboard_with_invalid_start_end_filter(self) -> None: assert response.status_code == 400 def test_add_widget_with_limit(self) -> None: - data = { + data: dict[str, Any] = { "title": "Dashboard from Post", "widgets": [ { @@ -2192,7 +2193,7 @@ def test_response_includes_project_ids(self) -> None: assert starred_dashboard["projects"] == [] @patch("sentry.quotas.backend.get_dashboard_limit") - def test_dashboard_limit_prevents_creation(self, mock_get_dashboard_limit) -> None: + def test_dashboard_limit_prevents_creation(self, mock_get_dashboard_limit: MagicMock) -> None: mock_get_dashboard_limit.return_value = 1 response = self.do_request("post", self.url, data={"title": "New Dashboard w/ Limit"}) assert response.status_code == 400 @@ -2203,7 +2204,9 @@ def test_dashboard_limit_prevents_creation(self, mock_get_dashboard_limit) -> No assert response.status_code == 201 @patch("sentry.quotas.backend.get_dashboard_limit") - def test_dashboard_limit_not_bypassed_by_burst_creates(self, mock_get_dashboard_limit) -> None: + def test_dashboard_limit_not_bypassed_by_burst_creates( + self, mock_get_dashboard_limit: MagicMock + ) -> None: Dashboard.objects.all().delete() mock_get_dashboard_limit.return_value = 2 @@ -2225,7 +2228,7 @@ def test_dashboard_limit_not_bypassed_by_burst_creates(self, mock_get_dashboard_ @patch("sentry.quotas.backend.get_dashboard_limit") def test_dashboard_limit_does_not_count_prebuilt_dashboards( - self, mock_get_dashboard_limit + self, mock_get_dashboard_limit: MagicMock ) -> None: mock_get_dashboard_limit.return_value = 2 @@ -2941,7 +2944,7 @@ def _single_widget_dashboard( @patch("sentry.search.events.builder.base.in_test_environment", return_value=False) def test_post_validation_succeeds_for_user_without_team_membership( - self, mock_in_test_environment + self, mock_in_test_environment: MagicMock ) -> None: """get_projects filters by team membership, so this resolves to [].""" assert self.project # lazy fixture; the org needs a project @@ -2961,7 +2964,7 @@ def test_post_validation_succeeds_for_user_without_team_membership( @patch("sentry.search.events.builder.base.in_test_environment", return_value=False) def test_post_creates_dashboard_for_teamless_member_of_open_membership_org( - self, mock_in_test_environment + self, mock_in_test_environment: MagicMock ) -> None: """Creation must work too, not just validation.""" org = self.create_organization(owner=self.user, flags=1) # allow_joinleave @@ -2981,7 +2984,7 @@ def test_post_creates_dashboard_for_teamless_member_of_open_membership_org( @patch("sentry.search.events.builder.base.in_test_environment", return_value=False) def test_post_validation_accepts_accessible_project_filters_for_teamless_member( - self, mock_in_test_environment + self, mock_in_test_environment: MagicMock ) -> None: org = self.create_organization(owner=self.user, flags=1) team = self.create_team(organization=org) @@ -3008,7 +3011,7 @@ def test_post_validation_accepts_accessible_project_filters_for_teamless_member( @patch("sentry.search.events.builder.base.in_test_environment", return_value=False) def test_post_validation_errors_when_no_project_is_accessible( - self, mock_in_test_environment + self, mock_in_test_environment: MagicMock ) -> None: """Without allow_joinleave a teamless member can reach no project.""" org = self.create_organization(owner=self.user, flags=0) # no allow_joinleave @@ -3033,7 +3036,7 @@ def test_post_validation_errors_when_no_project_is_accessible( @patch("sentry.search.events.builder.base.in_test_environment", return_value=False) def test_post_validation_does_not_error_for_organization_without_projects( - self, mock_in_test_environment + self, mock_in_test_environment: MagicMock ) -> None: """With no projects there is no fallback, so this hits the backstop.""" empty_org = self.create_organization(owner=self.user) diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py b/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py index a49d51723a60..0e57b2913940 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py @@ -1,5 +1,8 @@ +from typing import Any + from django.urls import reverse from rest_framework.exceptions import ErrorDetail +from rest_framework.response import Response from sentry.models.dashboard import DashboardFavoriteUser from sentry.silo.base import SiloMode @@ -10,9 +13,9 @@ class StarredDashboardTestCase(OrganizationDashboardWidgetTestCase): - def do_request(self, *args, **kwargs): + def do_request(self, method: str, url: str, data: dict[str, Any] | None = None) -> Response: with self.feature("organizations:dashboards-starred"): - return super().do_request(*args, **kwargs) + return super().do_request(method, url, data) class OrganizationDashboardsStarredTest(StarredDashboardTestCase): diff --git a/tests/sentry/dashboards/test_on_completion_hook.py b/tests/sentry/dashboards/test_on_completion_hook.py index d3f55640b211..c256753f3c2c 100644 --- a/tests/sentry/dashboards/test_on_completion_hook.py +++ b/tests/sentry/dashboards/test_on_completion_hook.py @@ -21,7 +21,7 @@ def _make_state( status: str = "completed", - artifact_data: dict | None = None, + artifact_data: dict[str, Any] | None = None, previous_blocks: list[MemoryBlock] | None = None, ) -> SeerRunState: blocks: list[MemoryBlock] = list(previous_blocks or []) diff --git a/tests/sentry/data/test_samples.py b/tests/sentry/data/test_samples.py index 991ada27957c..57229e369eef 100644 --- a/tests/sentry/data/test_samples.py +++ b/tests/sentry/data/test_samples.py @@ -7,6 +7,6 @@ @pytest.mark.parametrize("filename", os.listdir(os.path.join(DATA_ROOT, "samples"))) -def test_is_valid_json(filename) -> None: +def test_is_valid_json(filename: str) -> None: with open(os.path.join(DATA_ROOT, "samples", filename)) as f: json.loads(f.read()) diff --git a/tests/sentry/data_export/processors/test_issues_by_tag.py b/tests/sentry/data_export/processors/test_issues_by_tag.py index 522c4af2e297..1808bf4caade 100644 --- a/tests/sentry/data_export/processors/test_issues_by_tag.py +++ b/tests/sentry/data_export/processors/test_issues_by_tag.py @@ -40,6 +40,7 @@ def setUp(self) -> None: }, project_id=self.project.id, ) + assert self.event.group is not None self.group = self.event.group self.euser = EventUser( project_id=self.project.id, diff --git a/tests/sentry/data_export/test_tasks.py b/tests/sentry/data_export/test_tasks.py index b38c4a36d87f..6cec8173490f 100644 --- a/tests/sentry/data_export/test_tasks.py +++ b/tests/sentry/data_export/test_tasks.py @@ -875,7 +875,10 @@ def _post_explore_logs_jsonl_rich_field_export( content_type="application/json", ) assert response.status_code == 201, response.content - return json.loads(response.content) + payload = json.loads(response.content) + if not isinstance(payload, dict): + raise TypeError("Export create response is not a JSON object") + return payload def _assert_explore_logs_jsonl_export_create_payload( self, payload: dict[str, Any] diff --git a/tests/sentry/db/models/fields/bitfield/test_bitfield.py b/tests/sentry/db/models/fields/bitfield/test_bitfield.py index 68cda2e84da8..c9a691a5eeab 100644 --- a/tests/sentry/db/models/fields/bitfield/test_bitfield.py +++ b/tests/sentry/db/models/fields/bitfield/test_bitfield.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import pickle import unittest +from typing import TYPE_CHECKING, cast import pytest from django import forms @@ -25,7 +28,13 @@ class flags(TypedClassBitField): bitfield_db_column = "another_name" -class BitFieldTestModelForm(forms.ModelForm): +if TYPE_CHECKING: + _ModelForm = forms.ModelForm[BitFieldTestModel] +else: + _ModelForm = forms.ModelForm + + +class BitFieldTestModelForm(_ModelForm): class Meta: model = BitFieldTestModel exclude = () @@ -272,7 +281,7 @@ def test_update_with_handler(self) -> None: instance.flags.FLAG_1 = True BitFieldTestModel.objects.filter(pk=instance.pk).update( - flags=bitor(F("flags"), instance.flags) + flags=bitor(F("flags"), instance.flags.FLAG_1) ) instance = BitFieldTestModel.objects.get(pk=instance.pk) self.assertTrue(instance.flags.FLAG_1) @@ -327,7 +336,7 @@ def test_binary_capacity(self) -> None: pytest.raises(ValueError, BitField, flags=flags[: (MAX_COUNT + 1)]) def test_dictionary_init(self) -> None: - flags = { + flags: dict[object, str] = { 0: "zero", 1: "first", 10: "tenth", @@ -337,12 +346,18 @@ def test_dictionary_init(self) -> None: -100: "smallkey", } - bf = BitField(flags) + # justification: exercises BitField's handling of out-of-range and non-int keys. + bf = BitField(cast("dict[int, str]", flags)) self.assertEqual(bf.flags, ["zero", "first", "second", "", "", "", "", "", "", "", "tenth"]) - pytest.raises(ValueError, BitField, flags={}) - pytest.raises(ValueError, BitField, flags={"wrongkey": "wrongkey"}) - pytest.raises(ValueError, BitField, flags={"1": "non_int_key"}) + with pytest.raises(ValueError): + BitField(flags={}) + with pytest.raises(ValueError): + # justification: exercises BitField's validation of non-int keys. + BitField(flags=cast("dict[int, str]", {"wrongkey": "wrongkey"})) + with pytest.raises(ValueError): + # justification: exercises BitField's validation of numeric string keys. + BitField(flags=cast("dict[int, str]", {"1": "non_int_key"})) def test_defaults_as_key_names(self) -> None: field = BitFieldTestModelWithDefaultsAsKeyNames._meta.get_field("flags") diff --git a/tests/sentry/db/models/fields/encryption/conftest.py b/tests/sentry/db/models/fields/encryption/conftest.py index 37d9ba790cb3..44d6d63d2719 100644 --- a/tests/sentry/db/models/fields/encryption/conftest.py +++ b/tests/sentry/db/models/fields/encryption/conftest.py @@ -1,3 +1,5 @@ +from collections.abc import Generator + import pytest from cryptography.fernet import Fernet @@ -5,17 +7,17 @@ @pytest.fixture -def fernet_key(): +def fernet_key() -> bytes: return Fernet.generate_key() @pytest.fixture -def fernet_instance(fernet_key): +def fernet_instance(fernet_key: bytes) -> Fernet: return Fernet(fernet_key) @pytest.fixture -def fernet_keys_store(fernet_key): +def fernet_keys_store(fernet_key: bytes) -> Generator[tuple[str, bytes]]: """Single key for testing. Mocks the FernetKeyStore._keys attribute.""" key_id = "key_id_1" original_keys = FernetKeyStore._keys @@ -33,7 +35,7 @@ def fernet_keys_store(fernet_key): @pytest.fixture -def multi_fernet_keys_store(): +def multi_fernet_keys_store() -> Generator[dict[str, bytes]]: """Multiple keys for testing key rotation. Mocks the FernetKeyStore._keys attribute.""" key1 = Fernet.generate_key() key2 = Fernet.generate_key() diff --git a/tests/sentry/db/models/fields/encryption/test_base.py b/tests/sentry/db/models/fields/encryption/test_base.py index 5ff6f2748c9f..a5020b079f98 100644 --- a/tests/sentry/db/models/fields/encryption/test_base.py +++ b/tests/sentry/db/models/fields/encryption/test_base.py @@ -11,9 +11,13 @@ ENCRYPTION_METHODS = ("plaintext", "fernet") +def _encrypted_field() -> EncryptedField[object, object]: + return EncryptedField() + + def test_plaintext_encryption() -> None: with override_options({"database.encryption.method": "plaintext"}): - field = EncryptedField() + field = _encrypted_field() # Test encryption (should return marker:base64 encoded string) encrypted = field.get_prep_value("test value") @@ -42,7 +46,7 @@ def test_fernet_encryption_without_key() -> None: override_settings(DATABASE_ENCRYPTION_SETTINGS={"fernet_primary_key_id": "test_key"}), override_options({"database.encryption.method": "fernet"}), ): - field = EncryptedField() + field = _encrypted_field() with pytest.raises(ValueError, match="Fernet encryption keys are not loaded"): field.get_prep_value("test value") finally: @@ -50,13 +54,13 @@ def test_fernet_encryption_without_key() -> None: FernetKeyStore._is_loaded = original_is_loaded -def test_fernet_encryption_with_key(multi_fernet_keys_store): +def test_fernet_encryption_with_key(multi_fernet_keys_store: dict[str, bytes]) -> None: """Test Fernet encryption with a valid key using new format.""" with ( override_settings(DATABASE_ENCRYPTION_SETTINGS={"fernet_primary_key_id": "key_primary"}), override_options({"database.encryption.method": "fernet"}), ): - field = EncryptedField() + field = _encrypted_field() # Test encryption encrypted = field.get_prep_value("test value") @@ -87,13 +91,13 @@ def test_fernet_encryption_with_key(multi_fernet_keys_store): assert decrypted == b"test value" -def test_fernet_key_rotation(multi_fernet_keys_store): +def test_fernet_key_rotation(multi_fernet_keys_store: dict[str, bytes]) -> None: """Test that data encrypted with different keys can be decrypted.""" with ( override_settings(DATABASE_ENCRYPTION_SETTINGS={"fernet_primary_key_id": "key_primary"}), override_options({"database.encryption.method": "fernet"}), ): - field = EncryptedField() + field = _encrypted_field() # Encrypt some data encrypted_value = field.get_prep_value("test data") @@ -115,7 +119,7 @@ def test_fernet_key_rotation(multi_fernet_keys_store): assert decrypted_manual == b"second key data" -def test_fernet_format_without_key_id_rejected(fernet_keys_store): +def test_fernet_format_without_key_id_rejected(fernet_keys_store: tuple[str, bytes]) -> None: """Test that Fernet format without key_id is rejected.""" key_id, fernet_key = fernet_keys_store @@ -123,7 +127,7 @@ def test_fernet_format_without_key_id_rejected(fernet_keys_store): override_settings(DATABASE_ENCRYPTION_SETTINGS={"fernet_primary_key_id": key_id}), override_options({"database.encryption.method": "fernet"}), ): - field = EncryptedField() + field = _encrypted_field() # Create invalid format without key_id fernet_instance = Fernet(fernet_key) @@ -136,7 +140,7 @@ def test_fernet_format_without_key_id_rejected(fernet_keys_store): assert result == invalid_format -def test_encryption_method_switching(fernet_keys_store): +def test_encryption_method_switching(fernet_keys_store: tuple[str, bytes]) -> None: """Test that values can be decrypted after switching encryption methods.""" key_id, _fernet_key = fernet_keys_store @@ -145,12 +149,12 @@ def test_encryption_method_switching(fernet_keys_store): override_settings(DATABASE_ENCRYPTION_SETTINGS={"fernet_primary_key_id": key_id}), override_options({"database.encryption.method": "fernet"}), ): - field = EncryptedField() + field = _encrypted_field() encrypted_fernet = field.get_prep_value("fernet encrypted") # encrypt with plain text with override_options({"database.encryption.method": "plaintext"}): - field = EncryptedField() + field = _encrypted_field() encrypted_plain = field.get_prep_value("plain text value") # assert that both can be decrypted independently of the encryption method @@ -159,7 +163,7 @@ def test_encryption_method_switching(fernet_keys_store): override_settings(DATABASE_ENCRYPTION_SETTINGS={"fernet_primary_key_id": key_id}), override_options({"database.encryption.method": encryption_method}), ): - field = EncryptedField() + field = _encrypted_field() # Should decrypt fernet value decrypted_fernet = field.to_python(encrypted_fernet) @@ -170,7 +174,7 @@ def test_encryption_method_switching(fernet_keys_store): assert decrypted_plain == b"plain text value" -def test_fernet_non_utf_8_chars(fernet_keys_store): +def test_fernet_non_utf_8_chars(fernet_keys_store: tuple[str, bytes]) -> None: """Test that different encrypted field types work correctly.""" key_id, _fernet_key = fernet_keys_store @@ -178,7 +182,7 @@ def test_fernet_non_utf_8_chars(fernet_keys_store): override_settings(DATABASE_ENCRYPTION_SETTINGS={"fernet_primary_key_id": key_id}), override_options({"database.encryption.method": "fernet"}), ): - text_field = EncryptedField() + text_field = _encrypted_field() invalid_utf_8 = b"\xc0" encrypted_text = text_field.get_prep_value(invalid_utf_8) assert isinstance(encrypted_text, str) @@ -196,13 +200,13 @@ def test_fernet_non_utf_8_chars(fernet_keys_store): def test_keysets_not_implemented() -> None: """Test that keysets method raises NotImplementedError.""" with override_options({"database.encryption.method": "keysets"}): - field = EncryptedField() + field = _encrypted_field() with pytest.raises(NotImplementedError, match="Keysets encryption not yet implemented"): field.get_prep_value("test value") -def test_fernet_marker_handling(fernet_keys_store): +def test_fernet_marker_handling(fernet_keys_store: tuple[str, bytes]) -> None: """Test that the fernet marker is handled correctly.""" key_id, _fernet_key = fernet_keys_store @@ -210,7 +214,7 @@ def test_fernet_marker_handling(fernet_keys_store): override_settings(DATABASE_ENCRYPTION_SETTINGS={"fernet_primary_key_id": key_id}), override_options({"database.encryption.method": "fernet"}), ): - field = EncryptedField() + field = _encrypted_field() # Create a value with fernet encryption test_value = "test value" @@ -230,7 +234,7 @@ def test_fernet_marker_handling(fernet_keys_store): def test_data_without_marker() -> None: """Test handling of unencrypted data without method marker.""" with override_options({"database.encryption.method": "plaintext"}): - field = EncryptedField() + field = _encrypted_field() # Simulate unencrypted plain text data (no marker) plain_value = "unencrypted plain text" @@ -240,7 +244,7 @@ def test_data_without_marker() -> None: def test_to_python_conversion() -> None: """Test the to_python method.""" - field = EncryptedField() + field = _encrypted_field() # Test string assert field.to_python("test") == "test" @@ -254,7 +258,7 @@ def test_to_python_conversion() -> None: assert field.to_python(encrypted) == b"test bytes" -def test_non_utf8_data_handling(fernet_keys_store): +def test_non_utf8_data_handling(fernet_keys_store: tuple[str, bytes]) -> None: """Test handling of non-UTF8 data.""" key_id, _fernet_key = fernet_keys_store invalid_value = b"\xc0" # invalid UTF-8 char @@ -264,7 +268,7 @@ def test_non_utf8_data_handling(fernet_keys_store): override_settings(DATABASE_ENCRYPTION_SETTINGS={"fernet_primary_key_id": key_id}), override_options({"database.encryption.method": encryption_method}), ): - field = EncryptedField() + field = _encrypted_field() result = field.to_python(invalid_value) assert result == invalid_value @@ -283,7 +287,9 @@ def test_non_utf8_data_handling(fernet_keys_store): b"invalid utf-8: \xc0", ], ) -def test_encryption_decryption_roundtrip(encryption_method, test_value, fernet_keys_store): +def test_encryption_decryption_roundtrip( + encryption_method: str, test_value: object, fernet_keys_store: tuple[str, bytes] +) -> None: """Test that encryption and decryption work correctly in roundtrip.""" key_id, _fernet_key = fernet_keys_store @@ -291,7 +297,7 @@ def test_encryption_decryption_roundtrip(encryption_method, test_value, fernet_k override_settings(DATABASE_ENCRYPTION_SETTINGS={"fernet_primary_key_id": key_id}), override_options({"database.encryption.method": encryption_method}), ): - field = EncryptedField() + field = _encrypted_field() encrypted = field.get_prep_value(test_value) decrypted = field.to_python(encrypted) @@ -304,10 +310,10 @@ def test_encryption_decryption_roundtrip(encryption_method, test_value, fernet_k assert decrypted == test_value -def test_marker_format_consistency(fernet_keys_store): +def test_marker_format_consistency(fernet_keys_store: tuple[str, bytes]) -> None: """Test that the marker format is consistent across methods.""" key_id, _fernet_key = fernet_keys_store - field = EncryptedField() + field = _encrypted_field() with override_options({"database.encryption.method": "plaintext"}): encrypted = field.get_prep_value("test") @@ -323,7 +329,7 @@ def test_marker_format_consistency(fernet_keys_store): assert encrypted.startswith(f"{MARKER_FERNET}:") -def test_fernet_missing_key_decryption(fernet_keys_store): +def test_fernet_missing_key_decryption(fernet_keys_store: tuple[str, bytes]) -> None: """Test that decryption fails gracefully when key_id is not found.""" key_id, _fernet_key = fernet_keys_store @@ -331,7 +337,7 @@ def test_fernet_missing_key_decryption(fernet_keys_store): override_settings(DATABASE_ENCRYPTION_SETTINGS={"fernet_primary_key_id": key_id}), override_options({"database.encryption.method": "fernet"}), ): - field = EncryptedField() + field = _encrypted_field() # Try to decrypt data that was "encrypted" with a missing key fake_encrypted_data = base64.b64encode(b"fake data").decode("ascii") @@ -342,7 +348,7 @@ def test_fernet_missing_key_decryption(fernet_keys_store): assert result == formatted_value # Should return the original encrypted string -def test_fernet_format_with_plaintext_data(fernet_keys_store): +def test_fernet_format_with_plaintext_data(fernet_keys_store: tuple[str, bytes]) -> None: """Test that data in fernet format but containing plain text (not encrypted) falls back correctly.""" key_id, _fernet_key = fernet_keys_store @@ -350,7 +356,7 @@ def test_fernet_format_with_plaintext_data(fernet_keys_store): override_settings(DATABASE_ENCRYPTION_SETTINGS={"fernet_primary_key_id": key_id}), override_options({"database.encryption.method": "fernet"}), ): - field = EncryptedField() + field = _encrypted_field() # Create data that looks like fernet format but contains plain text instead of encrypted data # This could happen during migration from plain text to encrypted storage diff --git a/tests/sentry/db/models/fields/encryption/test_encrypted_char_field.py b/tests/sentry/db/models/fields/encryption/test_encrypted_char_field.py index 5d8a14280875..286376d15a5e 100644 --- a/tests/sentry/db/models/fields/encryption/test_encrypted_char_field.py +++ b/tests/sentry/db/models/fields/encryption/test_encrypted_char_field.py @@ -1,24 +1,35 @@ +from typing import TYPE_CHECKING from unittest.mock import patch import pytest from django.db import connection, models +from django.db.models import CharField from django.test import override_settings from sentry.db.models.fields.encryption import EncryptedCharField -from sentry.db.models.fields.encryption._base import MARKER_FERNET, MARKER_PLAINTEXT +from sentry.db.models.fields.encryption._base import MARKER_FERNET, MARKER_PLAINTEXT, EncryptedField from sentry.testutils.helpers import override_options +if TYPE_CHECKING: + + class _NullableEncryptedCharField( + EncryptedField[str | None, str | None], CharField[str | None, str | None] + ): ... + +else: + _NullableEncryptedCharField = EncryptedCharField + class EncryptedFieldModel(models.Model): id = models.AutoField(primary_key=True) - data = EncryptedCharField(null=True, blank=True) + data = _NullableEncryptedCharField(null=True, blank=True) class Meta: app_label = "fixtures" @pytest.mark.django_db -def test_encrypted_char_field_fernet_end_to_end(fernet_keys_store): +def test_encrypted_char_field_fernet_end_to_end(fernet_keys_store: tuple[str, bytes]) -> None: """Test complete save/retrieve cycle with EncryptedField.""" key_id, _fernet_key = fernet_keys_store @@ -120,7 +131,7 @@ def test_encrypted_char_field_metrics_on_encrypt() -> None: @pytest.mark.django_db -def test_encrypted_char_field_metrics_on_decrypt(fernet_keys_store): +def test_encrypted_char_field_metrics_on_decrypt(fernet_keys_store: tuple[str, bytes]) -> None: """Test that decryption sends metrics with correct tags including table_name.""" key_id, _fernet_key = fernet_keys_store diff --git a/tests/sentry/db/models/fields/encryption/test_encrypted_json_field.py b/tests/sentry/db/models/fields/encryption/test_encrypted_json_field.py index 3bad5de84bb0..d71deae6864b 100644 --- a/tests/sentry/db/models/fields/encryption/test_encrypted_json_field.py +++ b/tests/sentry/db/models/fields/encryption/test_encrypted_json_field.py @@ -28,7 +28,7 @@ def test_encrypted_json_field_plaintext_encryption() -> None: assert decrypted == test_data -def test_encrypted_json_field_fernet_encryption(fernet_keys_store): +def test_encrypted_json_field_fernet_encryption(fernet_keys_store: tuple[str, bytes]) -> None: """Test EncryptedJSONField encryption with Fernet method.""" key_id, _ = fernet_keys_store @@ -73,7 +73,9 @@ def test_encrypted_json_field_fernet_encryption(fernet_keys_store): [], ], ) -def test_encrypted_json_field_roundtrip(encryption_method, test_value, fernet_keys_store): +def test_encrypted_json_field_roundtrip( + encryption_method: str, test_value: object, fernet_keys_store: tuple[str, bytes] +) -> None: """Test that JSON encryption and decryption work correctly in roundtrip.""" key_id, _ = fernet_keys_store @@ -122,7 +124,7 @@ class Meta: @pytest.mark.django_db -def test_encrypted_json_field_fernet_end_to_end(fernet_keys_store): +def test_encrypted_json_field_fernet_end_to_end(fernet_keys_store: tuple[str, bytes]) -> None: """Test complete save/retrieve cycle with EncryptedJSONField and Fernet.""" key_id, _fernet_key = fernet_keys_store @@ -185,7 +187,7 @@ def test_encrypted_json_field_plaintext_end_to_end() -> None: @pytest.mark.django_db -def test_encrypted_json_field_migration_compatibility(fernet_keys_store): +def test_encrypted_json_field_migration_compatibility(fernet_keys_store: tuple[str, bytes]) -> None: """Test that EncryptedJSONField can read unencrypted legacy data.""" key_id, _fernet_key = fernet_keys_store @@ -227,7 +229,9 @@ def test_encrypted_json_field_migration_compatibility(fernet_keys_store): @pytest.mark.django_db -def test_encrypted_json_field_fake_encrypted_format_fallback(fernet_keys_store): +def test_encrypted_json_field_fake_encrypted_format_fallback( + fernet_keys_store: tuple[str, bytes], +) -> None: """ Test that EncryptedJSONField falls back gracefully when data has the encrypted wrapper structure but contains non-encrypted plain text values. diff --git a/tests/sentry/db/models/fields/encryption/test_encrypted_text_field.py b/tests/sentry/db/models/fields/encryption/test_encrypted_text_field.py index 3ca70a12ee76..5e02d9070a6c 100644 --- a/tests/sentry/db/models/fields/encryption/test_encrypted_text_field.py +++ b/tests/sentry/db/models/fields/encryption/test_encrypted_text_field.py @@ -17,7 +17,7 @@ class Meta: app_label = "fixtures" -def generate_secret(): +def generate_secret() -> str: """Callable default similar to ApiApplication.generate_token.""" return secrets.token_hex(nbytes=32) @@ -55,7 +55,7 @@ def test_encrypted_text_field_default_method() -> None: @pytest.mark.django_db -def test_encrypted_text_field_fernet_end_to_end(fernet_keys_store): +def test_encrypted_text_field_fernet_end_to_end(fernet_keys_store: tuple[str, bytes]) -> None: """Test complete save/retrieve cycle with EncryptedTextField using fernet.""" key_id, _fernet_key = fernet_keys_store @@ -217,7 +217,7 @@ def test_encrypted_text_field_callable_default_generates_unique_values() -> None @pytest.mark.django_db -def test_encrypted_text_field_default_with_fernet(fernet_keys_store): +def test_encrypted_text_field_default_with_fernet(fernet_keys_store: tuple[str, bytes]) -> None: """Test that default values work correctly with fernet encryption.""" key_id, _fernet_key = fernet_keys_store diff --git a/tests/sentry/db/models/fields/test_bounded.py b/tests/sentry/db/models/fields/test_bounded.py index 9eedad75277b..15efcb088d4f 100644 --- a/tests/sentry/db/models/fields/test_bounded.py +++ b/tests/sentry/db/models/fields/test_bounded.py @@ -15,7 +15,7 @@ def test_norm_int() -> None: @pytest.mark.parametrize("cls", (BoundedBigIntegerField, BoundedPositiveIntegerField)) -def test_big_int(cls) -> None: +def test_big_int(cls: type[BoundedIntegerField]) -> None: field = cls() with pytest.raises(AssertionError): field.get_prep_value(9223372036854775808) diff --git a/tests/sentry/db/models/manager/test_base_query_set.py b/tests/sentry/db/models/manager/test_base_query_set.py index 0fd54b638401..dc2ea4081d8f 100644 --- a/tests/sentry/db/models/manager/test_base_query_set.py +++ b/tests/sentry/db/models/manager/test_base_query_set.py @@ -1,4 +1,6 @@ +from collections.abc import Generator from contextlib import contextmanager +from typing import Any from unittest import mock import pytest @@ -9,7 +11,7 @@ @contextmanager -def catch_signal(signal): +def catch_signal(signal: Any) -> Generator[mock.Mock]: handler = mock.Mock() signal.connect(handler) yield handler diff --git a/tests/sentry/db/models/test_base.py b/tests/sentry/db/models/test_base.py index edc1b40592ab..e5182002f355 100644 --- a/tests/sentry/db/models/test_base.py +++ b/tests/sentry/db/models/test_base.py @@ -1,5 +1,7 @@ from unittest import TestCase +from django.db.models import Model + from sentry.db.models import DefaultFieldsModelExisting from sentry.integrations.models import Integration, RepositoryProjectPathConfig from sentry.models.groupsearchview import GroupSearchView @@ -27,7 +29,7 @@ class PreventDefaultFieldsModelExistingUseTest(TestCase): - def all_subclasses(self, cls): + def all_subclasses(self, cls: type[Model]) -> set[type[Model]]: return set(cls.__subclasses__()).union( [s for c in cls.__subclasses__() for s in self.all_subclasses(c)] ) diff --git a/tests/sentry/db/postgres/schema/safe_migrations/integration/__init__.py b/tests/sentry/db/postgres/schema/safe_migrations/integration/__init__.py index ad9bd6d5a781..eeb28bcb906c 100644 --- a/tests/sentry/db/postgres/schema/safe_migrations/integration/__init__.py +++ b/tests/sentry/db/postgres/schema/safe_migrations/integration/__init__.py @@ -1,7 +1,9 @@ +from collections.abc import Sequence + from django.core.management.commands.migrate import Command as MigrateCommand -def migrate(params=None): +def migrate(params: Sequence[str] | None = None) -> str | None: command = MigrateCommand() parser = command.create_parser("manage.py", "migrate") options = parser.parse_args(params or []) diff --git a/tests/sentry/db/postgres/schema/safe_migrations/integration/test_migrations.py b/tests/sentry/db/postgres/schema/safe_migrations/integration/test_migrations.py index 21e4a328d4ce..069a9804253f 100644 --- a/tests/sentry/db/postgres/schema/safe_migrations/integration/test_migrations.py +++ b/tests/sentry/db/postgres/schema/safe_migrations/integration/test_migrations.py @@ -34,11 +34,11 @@ class BaseSafeMigrationTest(TestCase): migrate_from: str migrate_to: str - def run_migration(self): + def run_migration(self) -> None: self._run_migration(self.app, self.migrate_from) self._run_migration(self.app, self.migrate_to) - def _run_migration(self, app, migration_name): + def _run_migration(self, app: str, migration_name: str) -> None: with override_settings( INSTALLED_APPS=(f"{self.BASE_PATH}.{self.app}",), MIGRATION_MODULES={} ): @@ -49,7 +49,7 @@ def _run_migration(self, app, migration_name): executor.loader.project_state(target).apps executor.migrate(target) - def sql_migrate(self, app, migration_name): + def sql_migrate(self, app: str, migration_name: str) -> str: with override_settings( INSTALLED_APPS=(f"{self.BASE_PATH}.{self.app}",), MIGRATION_MODULES={} ): @@ -425,7 +425,7 @@ def test(self) -> None: class ColExistsMixin: app = "" - def col_exists(self, col_name): + def col_exists(self, col_name: str) -> bool: with connection.cursor() as cursor: table_name = f"{self.app}_testtable" columns = connection.introspection.get_table_description(cursor, table_name) diff --git a/tests/sentry/db/test_pending_deletion.py b/tests/sentry/db/test_pending_deletion.py index 9da9af087944..aac80528db28 100644 --- a/tests/sentry/db/test_pending_deletion.py +++ b/tests/sentry/db/test_pending_deletion.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import patch from sentry.db.pending_deletion import build_pending_deletion_key @@ -16,7 +17,7 @@ def setUp(self) -> None: external_id="external_id", ) - def assert_organization_option(self, repo): + def assert_organization_option(self, repo: Any) -> None: option = OrganizationOption.objects.get( organization_id=repo.organization_id, key=build_pending_deletion_key(repo) ) diff --git a/tests/sentry/db/test_transactions.py b/tests/sentry/db/test_transactions.py index 51e27ed0ae4f..10a3a2c62982 100644 --- a/tests/sentry/db/test_transactions.py +++ b/tests/sentry/db/test_transactions.py @@ -82,7 +82,7 @@ def test_safe_transaction_boundaries(self) -> None: Factories.create_user() def test_in_test_assert_no_transaction(self) -> None: - def do_assertions(): + def do_assertions() -> None: in_test_assert_no_transaction("Not, in transaction, should not fail") with pytest.raises(AssertionError): @@ -98,7 +98,7 @@ def do_assertions(): do_assertions() def test_transaction_on_commit(self) -> None: - def do_assertions(): + def do_assertions() -> None: calls = [] transaction.on_commit(lambda: calls.append("a"), "default") diff --git a/tests/sentry/debug_files/objectstore_migration/test_utils.py b/tests/sentry/debug_files/objectstore_migration/test_utils.py index dec226f468e7..0c59e034554d 100644 --- a/tests/sentry/debug_files/objectstore_migration/test_utils.py +++ b/tests/sentry/debug_files/objectstore_migration/test_utils.py @@ -38,8 +38,8 @@ def test_migrates_file_and_deletes_legacy_file(self) -> None: migrate_debug_file(self.debug_file) self.debug_file.refresh_from_db() - assert self.debug_file.file_id is None - assert self.debug_file.storage_path is not None + assert not self.debug_file.file_id + assert self.debug_file.storage_path assert self.debug_file.file_size == len(b"debug-file-contents") assert self.debug_file.content_type == "application/x-mach-binary" assert self.debug_file.date_created is not None @@ -66,8 +66,8 @@ def test_migrates_file_with_empty_checksum(self) -> None: extra={"debug_file_id": self.debug_file.id, "file_id": file.id}, ) self.debug_file.refresh_from_db() - assert self.debug_file.file_id is None - assert self.debug_file.storage_path is not None + assert not self.debug_file.file_id + assert self.debug_file.storage_path assert self.debug_file.checksum == expected_checksum @requires_objectstore @@ -76,7 +76,8 @@ def test_migrates_file_with_missing_size(self) -> None: assert file is not None expected_size = file.size assert expected_size is not None - file.size = None + # justification: size is nullable in the schema even though the custom field is typed non-optional. + setattr(file, "size", None) file.save(update_fields=["size"]) with ( @@ -90,8 +91,8 @@ def test_migrates_file_with_missing_size(self) -> None: extra={"debug_file_id": self.debug_file.id, "file_id": file.id}, ) self.debug_file.refresh_from_db() - assert self.debug_file.file_id is None - assert self.debug_file.storage_path is not None + assert not self.debug_file.file_id + assert self.debug_file.storage_path assert self.debug_file.file_size == expected_size def test_integrity_failure_does_not_cut_over(self) -> None: @@ -158,7 +159,7 @@ def test_commit_noops_when_row_changed(self) -> None: commit(self.debug_file.id, metadata, source_file_id=source_file_id) self.debug_file.refresh_from_db() assert self.debug_file.storage_path == "already-migrated" - assert self.debug_file.file_id is None + assert not self.debug_file.file_id other_file = self.create_file( name="other.dSYM", @@ -196,6 +197,6 @@ def test_keeps_file_still_referenced_by_another_dif(self) -> None: self.debug_file.refresh_from_db() other.refresh_from_db() - assert self.debug_file.file_id is None + assert not self.debug_file.file_id assert other.file_id == file_id assert File.objects.filter(id=file_id).exists() diff --git a/tests/sentry/debug_files/test_artifact_bundles.py b/tests/sentry/debug_files/test_artifact_bundles.py index 1b2fc59d404b..ed4c5a43c576 100644 --- a/tests/sentry/debug_files/test_artifact_bundles.py +++ b/tests/sentry/debug_files/test_artifact_bundles.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from hashlib import sha1 from io import BytesIO +from typing import Literal, TypedDict from unittest.mock import patch from django.core.files.base import ContentFile @@ -38,8 +39,15 @@ from sentry.utils import json -def make_compressed_zip_file(files): - def remove_and_return(dictionary, key): +class _ArtifactFile(TypedDict, total=False): + url: str + type: str + content: bytes + headers: dict[str, str] + + +def make_compressed_zip_file(files: dict[str, _ArtifactFile]) -> bytes: + def remove_and_return(dictionary: _ArtifactFile, key: Literal["content"]) -> _ArtifactFile: dictionary.pop(key) return dictionary @@ -65,7 +73,12 @@ def remove_and_return(dictionary, key): return compressed.getvalue() -def upload_bundle(bundle_file, project, release=None, dist=None): +def upload_bundle( + bundle_file: bytes, + project: Project, + release: str, + dist: str | None = None, +) -> None: blob1 = FileBlob.from_file_with_organization(ContentFile(bundle_file), project.organization) total_checksum = sha1(bundle_file).hexdigest() @@ -79,7 +92,9 @@ def upload_bundle(bundle_file, project, release=None, dist=None): ) -def get_artifact_bundles(project, release_name="", dist_name=""): +def get_artifact_bundles( + project: Project, release_name: str = "", dist_name: str = "" +) -> list[ArtifactBundle]: return list( ArtifactBundle.objects.filter( organization_id=project.organization.id, @@ -90,7 +105,9 @@ def get_artifact_bundles(project, release_name="", dist_name=""): ) -def get_indexed_files(project, release_name="", dist_name="", distinct=False): +def get_indexed_files( + project: Project, release_name: str = "", dist_name: str = "", distinct: bool = False +) -> list[ArtifactBundleIndex]: query = ArtifactBundleIndex.objects.filter( organization_id=project.organization.id, artifact_bundle__projectartifactbundle__project_id=project.id, @@ -102,8 +119,17 @@ def get_indexed_files(project, release_name="", dist_name="", distinct=False): return list(query) +def test_normalize_debug_id_rejects_non_strings() -> None: + assert ArtifactBundleArchive.normalize_debug_id(None) is None + assert ArtifactBundleArchive.normalize_debug_id(123) is None + assert ( + ArtifactBundleArchive.normalize_debug_id("aaaaaaaa-0000-0000-0000-000000000000") + == "aaaaaaaa-0000-0000-0000-000000000000" + ) + + class ArtifactLookupTest(TestCase): - def clear_cache(self): + def clear_cache(self) -> None: redis_client = get_redis_cluster_for_artifact_bundles() redis_client.flushdb() @@ -370,7 +396,7 @@ class IndexUrlsInBundleTest(TestCase): debug_id = "eb6e60f1-65ff-4f6f-adff-f1bbeded627b" def index(self) -> list[str]: - files = { + files: dict[str, _ArtifactFile] = { "files/_/_/generated.js": { "url": f"~/{self.debug_id}-0.js", "type": "minified_source", @@ -437,7 +463,9 @@ def setUp(self) -> None: self.release_name = "1.0.0" self.dist_name = "dist1" - def create_bundle(self, date_added=None, date_last_modified=None): + def create_bundle( + self, date_added: datetime | None = None, date_last_modified: datetime | None = None + ) -> ArtifactBundle: if date_added is None: date_added = timezone.now() if date_last_modified is None: @@ -470,7 +498,9 @@ def create_bundle(self, date_added=None, date_last_modified=None): ) return artifact_bundle - def assert_results(self, results: set[tuple[int, datetime]], expected_bundle_ids: set[int]): + def assert_results( + self, results: set[tuple[int, datetime]], expected_bundle_ids: set[int] + ) -> None: assert {bundle_id for bundle_id, _ in results} == expected_bundle_ids def test_get_artifact_bundles_containing_url_exact_match(self) -> None: diff --git a/tests/sentry/deletions/test_debugfiles.py b/tests/sentry/deletions/test_debugfiles.py index 1c238d36e465..8b8d953710d1 100644 --- a/tests/sentry/deletions/test_debugfiles.py +++ b/tests/sentry/deletions/test_debugfiles.py @@ -11,6 +11,8 @@ class DeleteDebugFilesTest(TransactionTestCase, HybridCloudTestMixin): def test_simple(self) -> None: dif = self.create_dif_file() dif2 = self.create_dif_file() + assert dif.file is not None + assert dif2.file is not None # NOTE: Its great that I pass the object directly to schedule a deletion task. # What is this even testing? I could as well just call `.delete()` directly? diff --git a/tests/sentry/deletions/test_group.py b/tests/sentry/deletions/test_group.py index 9dae56cc48ea..9fc86f205d68 100644 --- a/tests/sentry/deletions/test_group.py +++ b/tests/sentry/deletions/test_group.py @@ -53,6 +53,7 @@ def _create_event_with_many_group_children(self) -> Event: data=self._generate_data(fingerprint="group1"), project_id=self.project.id, ) + assert event.group is not None UserReport.objects.create( group_id=event.group.id, project_id=event.project_id, name="With group id" ) @@ -137,7 +138,8 @@ def test_multiple_groups(self) -> None: ) assert not Group.objects.filter(id=event.group.id).exists() - assert not Group.objects.filter(id=other_event.group_id).exists() + assert other_event.group is not None + assert not Group.objects.filter(id=other_event.group.id).exists() assert not nodestore.backend.get(self._get_node_id(event)) assert not nodestore.backend.get(self._get_node_id(other_event)) @@ -150,7 +152,9 @@ def test_grouphistory_relation(self) -> None: project_id=self.project.id, ) other_group = other_event.group + assert other_group is not None group = self.event.group + assert group is not None history_one = self.create_group_history(group=group, status=GroupHistoryStatus.ONGOING) history_two = self.create_group_history( group=group, @@ -182,6 +186,7 @@ def test_cleanup(self, nodestore_delete_multi: mock.Mock) -> None: os.environ["_SENTRY_CLEANUP"] = "1" try: group = self.event.group + assert group is not None with self.tasks(): delete_groups_for_project( @@ -215,27 +220,28 @@ def test_delete_groups_delete_grouping_records_by_hash( data=self._generate_data(fingerprint="group3"), project_id=self.project.id, ) + assert other_event.group is not None hashes = [ grouphash.hash for grouphash in GroupHash.objects.filter( - project_id=self.project.id, group_id__in=[event.group.id, other_event.group_id] + project_id=self.project.id, group_id__in=[event.group.id, other_event.group.id] ) ] with self.tasks(): delete_groups_for_project( - object_ids=[event.group.id, other_event.group_id], + object_ids=[event.group.id, other_event.group.id], transaction_id=uuid4().hex, project_id=self.project.id, ) assert not Group.objects.filter(id=event.group.id).exists() - assert not Group.objects.filter(id=other_event.group_id).exists() + assert not Group.objects.filter(id=other_event.group.id).exists() assert not nodestore.backend.get(self._get_node_id(event)) assert not nodestore.backend.get(self._get_node_id(other_event)) - assert Group.objects.filter(id=keep_event.group_id).exists() + assert Group.objects.filter(id=keep_event.group.id).exists() assert nodestore.backend.get(self._get_node_id(keep_event)) assert mock_delete_seer_grouping_records_by_hash_apply_async.call_args[1] == { @@ -260,11 +266,14 @@ def test_invalid_group_type_handling( data={"timestamp": before_now(minutes=1).isoformat(), "fingerprint": ["invalid-group"]}, project_id=self.project.id, ).group + assert error_group is not None + assert invalid_group is not None keep_event = self.store_event( data={"timestamp": before_now(minutes=1).isoformat(), "fingerprint": ["keep-group"]}, project_id=self.project.id, ) keep_group = keep_event.group + assert keep_group is not None Group.objects.filter(id=invalid_group.id).update(type=10000000) @@ -306,6 +315,7 @@ def test_delete_grouphashes_and_metadata(self) -> None: }, project_id=self.project.id, ) + assert event_a.group_id is not None grouphash_a = GroupHash.objects.get(group_id=event_a.group_id) assert grouphash_a.metadata is not None assert grouphash_a.metadata.seer_matched_grouphash is None @@ -360,6 +370,7 @@ def test_update_group_hash_metadata_in_batches(self) -> None: num_additional_grouphashes = 5 # Create events with group hashes event_a = self.store_event(data={"fingerprint": ["a"]}, project_id=self.project.id) + assert event_a.group_id is not None grouphash_a = GroupHash.objects.get(group_id=event_a.group_id) # Create multiple events with different group hashes that reference grouphash_a @@ -424,6 +435,7 @@ def test_delete_project_group_hashes_specific_groups(self) -> None: assert GroupHash.objects.filter(project=self.project).count() == 2 + assert event_1.group is not None delete_project_group_hashes( project_id=self.project.id, group_ids_filter=[event_1.group.id], @@ -513,7 +525,7 @@ def select_rows( return results[0] if results else None @property - def tenant_ids(self) -> dict[str, str]: + def tenant_ids(self) -> dict[str, str | int]: return {"referrer": self.referrer, "organization_id": self.organization.id} @mock.patch("sentry.deletions.tasks.nodestore.bulk_snuba_queries") @@ -532,6 +544,7 @@ def test_simple_issue_platform(self, mock_bulk_snuba_queries: mock.Mock) -> None # Assertions after creation assert occurrence_event.id != event.event_id assert event.group_id != issue_platform_group.id + assert event.group is not None assert event.group.issue_category == GroupCategory.ERROR assert issue_platform_group.issue_category == GroupCategory.FEEDBACK assert issue_platform_group.type == FeedbackGroup.type_id @@ -559,7 +572,7 @@ def test_simple_issue_platform(self, mock_bulk_snuba_queries: mock.Mock) -> None ) # The original error event and group still exist - assert Group.objects.filter(id=event.group_id).exists() + assert Group.objects.filter(id=event.group.id).exists() assert nodestore.backend.get(event_node_id) assert self.select_error_events(self.project.id) == expected_error diff --git a/tests/sentry/deletions/test_organization.py b/tests/sentry/deletions/test_organization.py index d6044ad64b96..93d2f307f2fe 100644 --- a/tests/sentry/deletions/test_organization.py +++ b/tests/sentry/deletions/test_organization.py @@ -530,7 +530,7 @@ def test_all_projects_detector_cleanup(self) -> None: assert Detector.objects.filter(id=all_projects_detector.id).exists() assert Detector.objects.filter(id=project_detector.id).exists() - assert all_projects_detector.project_id is None + assert not all_projects_detector.project_id assert project_detector.project_id == project.id org.update(status=OrganizationStatus.PENDING_DELETION) diff --git a/tests/sentry/deletions/test_release.py b/tests/sentry/deletions/test_release.py index 627b826cd4b6..e99d12f5c757 100644 --- a/tests/sentry/deletions/test_release.py +++ b/tests/sentry/deletions/test_release.py @@ -56,5 +56,5 @@ def test_cascade_from_user(self) -> None: release1.refresh_from_db() release2.refresh_from_db() - assert release1.owner_id is None + assert not release1.owner_id assert release2.owner_id is not None diff --git a/tests/sentry/deletions/test_sentry_app.py b/tests/sentry/deletions/test_sentry_app.py index 44b0959840f0..b79a42f1c084 100644 --- a/tests/sentry/deletions/test_sentry_app.py +++ b/tests/sentry/deletions/test_sentry_app.py @@ -41,11 +41,13 @@ def test_hard_deletes_app_installations(self) -> None: def test_deletes_api_application(self) -> None: application = self.sentry_app.application + assert application is not None deletions.exec_sync(self.sentry_app) assert not ApiApplication.objects.filter(pk=application.id).exists() def test_deletes_proxy_user(self) -> None: proxy_user = self.sentry_app.proxy_user + assert proxy_user is not None deletions.exec_sync(self.sentry_app) assert not User.objects.filter(pk=proxy_user.id).exists() diff --git a/tests/sentry/deletions/test_team.py b/tests/sentry/deletions/test_team.py index 427f3f82ecec..a467f4ad5818 100644 --- a/tests/sentry/deletions/test_team.py +++ b/tests/sentry/deletions/test_team.py @@ -45,9 +45,9 @@ def test_alert_blanking(self) -> None: alert_rule.refresh_from_db() rule.refresh_from_db() - assert rule.owner_team_id is None, "Should be blank when team is deleted." - assert alert_rule.user_id is None, "Should be blank when team is deleted." - assert alert_rule.team_id is None, "Should be blank when team is deleted." + assert not rule.owner_team_id, "Should be blank when team is deleted." + assert not alert_rule.user_id, "Should be blank when team is deleted." + assert not alert_rule.team_id, "Should be blank when team is deleted." def test_monitor_blanking(self) -> None: team = self.create_team(name="test") diff --git a/tests/sentry/demo_mode/test_tasks.py b/tests/sentry/demo_mode/test_tasks.py index 96a5d01388b7..57e579b10439 100644 --- a/tests/sentry/demo_mode/test_tasks.py +++ b/tests/sentry/demo_mode/test_tasks.py @@ -280,8 +280,8 @@ def test_sync_objectstore_project_debug_files(self) -> None: debug_id=source_project_debug_file.debug_id, ) - assert target_project_debug_file.file_id is None - assert target_project_debug_file.storage_path is not None + assert not target_project_debug_file.file_id + assert target_project_debug_file.storage_path assert target_project_debug_file.storage_path != source_project_debug_file.storage_path assert target_project_debug_file.content_type == content_type assert target_project_debug_file.file_size == len(content) @@ -306,6 +306,7 @@ def test_sync_dual_written_project_debug_files(self) -> None: source_storage_path = get_session(UsecaseId.DEBUG_FILES, self.source_proj_foo).put( b"objectstore-backed-debug-file", compress="none" ) + assert source_project_debug_file.file is not None source_project_debug_file.update( storage_path=source_storage_path, content_type=source_project_debug_file.file.headers["Content-Type"], diff --git a/tests/sentry/digests/test_notifications.py b/tests/sentry/digests/test_notifications.py index 17bf150d74cf..9c2844421e62 100644 --- a/tests/sentry/digests/test_notifications.py +++ b/tests/sentry/digests/test_notifications.py @@ -55,6 +55,7 @@ def origin(self) -> NotificationOrigin: @property def group_mapping(self) -> dict[int, Group]: + assert self.event.group is not None return {self.event.group.id: self.event.group} @property diff --git a/tests/sentry/discover/test_arithmetic.py b/tests/sentry/discover/test_arithmetic.py index 4545b4cf04b7..058ecf811b31 100644 --- a/tests/sentry/discover/test_arithmetic.py +++ b/tests/sentry/discover/test_arithmetic.py @@ -50,7 +50,7 @@ def parse_arithmetic_must_be_operation(s: str) -> Operation: (1.2345, "÷", 6.7890), ], ) -def test_simple_arithmetic(a, op, b) -> None: +def test_simple_arithmetic(a: str | float, op: str, b: str | float) -> None: equation = f"{a}{op}{b}" result = parse_arithmetic_must_be_operation(equation) assert result.operator == op_map[op.strip()], equation @@ -79,7 +79,7 @@ def test_parse_arithmetic_returns_column() -> None: ("2", 2), ], ) -def test_resolve_arithmetic(inp, output) -> None: +def test_resolve_arithmetic(inp: str, output: float) -> None: parsed, _, _ = parse_arithmetic(inp) assert resolve_arithmetic(parsed) == output @@ -101,7 +101,9 @@ def test_resolve_arithmetic(inp, output) -> None: ("12", "÷", "34", "÷", "56"), ], ) -def test_homogenous_arithmetic(a, op1, b, op2, c) -> None: +def test_homogenous_arithmetic( + a: str | float, op1: str, b: str | float, op2: str, c: str | float +) -> None: """Test that literal order of ops is respected assuming we don't have to worry about BEDMAS""" equation = f"{a}{op1}{b}{op2}{c}" result = parse_arithmetic_must_be_operation(equation) @@ -198,7 +200,15 @@ def test_brackets_with_four_inner_terms() -> None: ("12", "÷", "34", "*", "56", "÷", "78"), ], ) -def test_homogenous_four_terms(a, op1, b, op2, c, op3, d) -> None: +def test_homogenous_four_terms( + a: str | float, + op1: str, + b: str | float, + op2: str, + c: str | float, + op3: str, + d: str | float, +) -> None: """This basically tests flatten in the ArithmeticVisitor flatten only kicks in when its a chain of the same operator type @@ -232,7 +242,7 @@ def test_max_operators() -> None: (3.1415, "+", "spans.resource"), ], ) -def test_field_values(a, op, b) -> None: +def test_field_values(a: str | float, op: str, b: str | float) -> None: for with_brackets in [False, True]: equation = f"{a}{op}{b}" if with_brackets: @@ -287,7 +297,7 @@ def test_field_values(a, op, b) -> None: ('count_if(`test:"blah blah"`,test, test)', "+", "sum_if(`test:\"blah'blah'blah\"`)"), ], ) -def test_function_values(lhs, op, rhs) -> None: +def test_function_values(lhs: str | int, op: str, rhs: str | int) -> None: for with_brackets in [False, True]: equation = f"{lhs}{op}{rhs}" if with_brackets: @@ -322,7 +332,7 @@ def test_function_values(lhs, op, rhs) -> None: "+", ], ) -def test_unparseable_arithmetic(equation) -> None: +def test_unparseable_arithmetic(equation: str) -> None: with pytest.raises(ArithmeticParseError): parse_arithmetic(equation) @@ -345,7 +355,7 @@ def test_unparseable_arithmetic(equation) -> None: "(measurements.lcp)", ], ) -def test_invalid_arithmetic(equation) -> None: +def test_invalid_arithmetic(equation: str) -> None: with pytest.raises(ArithmeticValidationError): parse_arithmetic(equation, validate_single_operator=True) @@ -362,7 +372,7 @@ def test_invalid_arithmetic(equation) -> None: "trace_status_rate(ok) + 0", ], ) -def test_span_definition_allowlist_keeps_prebuilt_dashboard_formulas(equation) -> None: +def test_span_definition_allowlist_keeps_prebuilt_dashboard_formulas(equation: str) -> None: result, _, functions = parse_arithmetic(equation, definitions=SPAN_DEFINITIONS) assert isinstance(result, Operation) assert len(functions) > 0 diff --git a/tests/sentry/discover/test_compare_tables.py b/tests/sentry/discover/test_compare_tables.py index d63c649e7d21..aed9f6ed3211 100644 --- a/tests/sentry/discover/test_compare_tables.py +++ b/tests/sentry/discover/test_compare_tables.py @@ -1,4 +1,5 @@ from datetime import UTC, datetime, timedelta +from typing import TypedDict, Unpack from uuid import uuid4 import pytest @@ -13,6 +14,7 @@ DashboardWidgetQuery, DashboardWidgetTypes, ) +from sentry.models.project import Project from sentry.testutils.cases import BaseMetricsLayerTestCase, BaseSpansTestCase, TestCase from sentry.utils.samples import load_data @@ -24,9 +26,16 @@ ] +class SegmentKwargs(TypedDict, total=False): + transaction: str + exclusive_time: int + measurements: dict[str, float] + tags: dict[str, str] + + class CompareTablesTestCase(BaseMetricsLayerTestCase, TestCase, BaseSpansTestCase): @property - def now(self): + def now(self) -> datetime: return datetime.now(UTC).replace(microsecond=0) def setUp(self) -> None: @@ -198,17 +207,17 @@ def setUp(self) -> None: def triple_write_segment( self, *, - project, - trace_id, - transaction_id, - span_id, - duration, + project: Project, + trace_id: str, + transaction_id: str, + span_id: str, + duration: int, days_before_now: int = 0, hours_before_now: int = 0, minutes_before_now: int = 0, seconds_before_now: int = 0, - **kwargs, - ): + **kwargs: Unpack[SegmentKwargs], + ) -> None: kwargs.setdefault("measurements", {}) if "lcp" not in kwargs["measurements"]: kwargs["measurements"]["lcp"] = duration diff --git a/tests/sentry/discover/test_dataset_split.py b/tests/sentry/discover/test_dataset_split.py index f932b9c41eb0..c445d64e0b22 100644 --- a/tests/sentry/discover/test_dataset_split.py +++ b/tests/sentry/discover/test_dataset_split.py @@ -734,7 +734,10 @@ def project(organization: Organization) -> Project: ) @django_db_all def test_dataset_split_decision_inferred_from_query( - query: str, selected_columns: list[str], expected_dataset: int | None, project: Project + query: str, + selected_columns: list[str], + expected_dataset: SplitDataset | None, + project: Project, ) -> None: snuba_dataclass = SnubaParams( start=datetime.now() - timedelta(days=1), diff --git a/tests/sentry/discover/translation/test_mep_to_eap.py b/tests/sentry/discover/translation/test_mep_to_eap.py index cef09e1d38ad..b1239edda6a3 100644 --- a/tests/sentry/discover/translation/test_mep_to_eap.py +++ b/tests/sentry/discover/translation/test_mep_to_eap.py @@ -1,6 +1,10 @@ import pytest -from sentry.discover.translation.mep_to_eap import QueryParts, translate_mep_to_eap +from sentry.discover.translation.mep_to_eap import ( + DroppedEquation, + QueryParts, + translate_mep_to_eap, +) @pytest.mark.parametrize( @@ -294,7 +298,7 @@ def test_mep_to_eap_simple_selected_columns( ], ) def test_mep_to_eap_simple_equations( - input: list[str], expected: list[str], dropped: list[dict[str, list[str]]] + input: list[str], expected: list[str], dropped: list[DroppedEquation] ) -> None: old = QueryParts( selected_columns=["id"], diff --git a/tests/sentry/dynamic_sampling/models/test_base.py b/tests/sentry/dynamic_sampling/models/test_base.py index b0340e69b43c..9e4f5067f592 100644 --- a/tests/sentry/dynamic_sampling/models/test_base.py +++ b/tests/sentry/dynamic_sampling/models/test_base.py @@ -8,11 +8,11 @@ @pytest.fixture -def full_rebalancing_model(): +def full_rebalancing_model() -> FullRebalancingModel: return FullRebalancingModel() -def test_run_with_exception(full_rebalancing_model) -> None: +def test_run_with_exception(full_rebalancing_model: FullRebalancingModel) -> None: with pytest.raises(InvalidModelInputError): full_rebalancing_model.run( FullRebalancingInput( diff --git a/tests/sentry/dynamic_sampling/models/test_projects_rebalancing.py b/tests/sentry/dynamic_sampling/models/test_projects_rebalancing.py index 5f95ff455f6e..b5bb532bcac5 100644 --- a/tests/sentry/dynamic_sampling/models/test_projects_rebalancing.py +++ b/tests/sentry/dynamic_sampling/models/test_projects_rebalancing.py @@ -10,17 +10,21 @@ @pytest.fixture -def projects_rebalancing_model(): +def projects_rebalancing_model() -> ProjectsRebalancingModel: return ProjectsRebalancingModel() -def test_adjust_sample_rates_org_with_no_projects(projects_rebalancing_model) -> None: +def test_adjust_sample_rates_org_with_no_projects( + projects_rebalancing_model: ProjectsRebalancingModel, +) -> None: assert ( projects_rebalancing_model.run(ProjectsRebalancingInput(classes=[], sample_rate=0.25)) == [] ) -def test_adjust_sample_rates_org_with_single_project(projects_rebalancing_model) -> None: +def test_adjust_sample_rates_org_with_single_project( + projects_rebalancing_model: ProjectsRebalancingModel, +) -> None: assert projects_rebalancing_model.run( ProjectsRebalancingInput( classes=[ @@ -34,7 +38,9 @@ def test_adjust_sample_rates_org_with_single_project(projects_rebalancing_model) ) == [RebalancedItem(id=1, count=10, new_sample_rate=0.4)] -def test_adjust_sample_rates_org_with_few_projects(projects_rebalancing_model) -> None: +def test_adjust_sample_rates_org_with_few_projects( + projects_rebalancing_model: ProjectsRebalancingModel, +) -> None: classes = [ RebalancedItem(id=1, count=9), RebalancedItem(id=2, count=7), @@ -76,7 +82,9 @@ def test_adjust_sample_rates_org_with_few_projects(projects_rebalancing_model) - ) -def test_adjust_sample_rates_org_with_many_projects(projects_rebalancing_model) -> None: +def test_adjust_sample_rates_org_with_many_projects( + projects_rebalancing_model: ProjectsRebalancingModel, +) -> None: """ This test checks how we calculate sample rates for org with 30 projects and make sure model doesn't generate negative sample rate @@ -133,7 +141,9 @@ def test_adjust_sample_rates_org_with_many_projects(projects_rebalancing_model) ) * p.new_sample_rate == pytest.approx(sample_rate) -def test_adjust_sample_rates_org_with_even_num_projects(projects_rebalancing_model) -> None: +def test_adjust_sample_rates_org_with_even_num_projects( + projects_rebalancing_model: ProjectsRebalancingModel, +) -> None: classes = [ RebalancedItem(id=1, count=8.0), RebalancedItem(id=2, count=7.0), @@ -169,7 +179,9 @@ def test_adjust_sample_rates_org_with_even_num_projects(projects_rebalancing_mod ) -def test_adjust_sample_rates_org_with_same_counts_projects(projects_rebalancing_model) -> None: +def test_adjust_sample_rates_org_with_same_counts_projects( + projects_rebalancing_model: ProjectsRebalancingModel, +) -> None: classes = [ RebalancedItem(id=1, count=9.0), RebalancedItem(id=2, count=6.0), @@ -211,7 +223,9 @@ def test_adjust_sample_rates_org_with_same_counts_projects(projects_rebalancing_ ) -def test_adjust_sample_rates_org_with_counts_projects(projects_rebalancing_model) -> None: +def test_adjust_sample_rates_org_with_counts_projects( + projects_rebalancing_model: ProjectsRebalancingModel, +) -> None: classes = [ RebalancedItem(id=1, count=2.0), RebalancedItem(id=2, count=10.0), diff --git a/tests/sentry/dynamic_sampling/models/test_transactions_rebalancing.py b/tests/sentry/dynamic_sampling/models/test_transactions_rebalancing.py index b7a1c76e2a83..5fc7a3f5af36 100644 --- a/tests/sentry/dynamic_sampling/models/test_transactions_rebalancing.py +++ b/tests/sentry/dynamic_sampling/models/test_transactions_rebalancing.py @@ -11,11 +11,11 @@ @pytest.fixture -def transactions_rebalancing_model(): +def transactions_rebalancing_model() -> TransactionsRebalancingModel: return TransactionsRebalancingModel() -def create_transaction_counts(big: int, med: int, small: int): +def create_transaction_counts(big: int, med: int, small: int) -> list[RebalancedItem]: big_t = [RebalancedItem(id=f"tb{i}", count=1000 + i) for i in range(big)] med_t = [RebalancedItem(id=f"tm{i}", count=100 + i) for i in range(med)] small_t = [RebalancedItem(id=f"ts{i}", count=1 + i) for i in range(small)] @@ -42,7 +42,9 @@ def create_transaction_counts(big: int, med: int, small: int): def get_num_sampled_elements( - transactions: list[RebalancedItem], trans_dict: Mapping[str, float], global_rate: float + transactions: list[RebalancedItem], + trans_dict: Mapping[str | int, float], + global_rate: float, ) -> float: num_transactions = 0.0 for transaction in transactions: @@ -59,8 +61,13 @@ def get_num_sampled_elements( @pytest.mark.parametrize("transactions", test_resample_cases) @pytest.mark.parametrize("idx_low,idx_high", excluded_transactions) def test_maintains_overall_sample_rate( - transactions_rebalancing_model, intensity, sample_rate, transactions, idx_low, idx_high -): + transactions_rebalancing_model: TransactionsRebalancingModel, + intensity: float, + sample_rate: float, + transactions: list[RebalancedItem], + idx_low: int, + idx_high: int | None, +) -> None: """ Tests that the overall sampling rate is maintained after applying new rates """ @@ -91,8 +98,12 @@ def test_maintains_overall_sample_rate( @pytest.mark.parametrize("transactions", test_resample_cases) @pytest.mark.parametrize("idx_low,idx_high", excluded_transactions) def test_explicit_elements_ideal_rate( - transactions_rebalancing_model, sample_rate, transactions, idx_low, idx_high -): + transactions_rebalancing_model: TransactionsRebalancingModel, + sample_rate: float, + transactions: list[RebalancedItem], + idx_low: int, + idx_high: int | None, +) -> None: """ Tests that the explicitly specified elements are sampled at their ideal rate. @@ -164,7 +175,7 @@ def _overall_kept( def test_high_class_count_crushes_dominant_transaction_without_floor( - transactions_rebalancing_model, + transactions_rebalancing_model: TransactionsRebalancingModel, ) -> None: """ Reproduces TET-2535: one transaction carrying half the project volume while competing against a @@ -191,7 +202,9 @@ def test_high_class_count_crushes_dominant_transaction_without_floor( assert implicit_rate / big_rate.new_sample_rate == pytest.approx(99999.0) -def test_min_sample_rate_floors_dominant_transaction(transactions_rebalancing_model) -> None: +def test_min_sample_rate_floors_dominant_transaction( + transactions_rebalancing_model: TransactionsRebalancingModel, +) -> None: """ Same scenario as above, but with the floor enabled: the dominant transaction's rate is floored so its extrapolation factor is capped at 1 / min_sample_rate, the cost is reclaimed from the @@ -220,7 +233,9 @@ def test_min_sample_rate_floors_dominant_transaction(transactions_rebalancing_mo ) -def test_min_sample_rate_with_many_explicit_transactions(transactions_rebalancing_model) -> None: +def test_min_sample_rate_with_many_explicit_transactions( + transactions_rebalancing_model: TransactionsRebalancingModel, +) -> None: """ Production-shaped case: 30 high-volume explicit transactions (60% of volume) competing with a ~50k-class tail. Without the floor every head transaction collapses to a 5e-5 rate (20,000x @@ -256,7 +271,9 @@ def test_min_sample_rate_with_many_explicit_transactions(transactions_rebalancin ) -def test_min_sample_rate_clamped_to_overall_rate(transactions_rebalancing_model) -> None: +def test_min_sample_rate_clamped_to_overall_rate( + transactions_rebalancing_model: TransactionsRebalancingModel, +) -> None: """ For a project whose overall rate is already below the configured floor, the floor is clamped to the overall rate, so a class is never sampled above the project rate and the budget cannot be @@ -281,7 +298,9 @@ def test_min_sample_rate_clamped_to_overall_rate(transactions_rebalancing_model) ) -def test_min_sample_rate_inert_when_rates_healthy(transactions_rebalancing_model) -> None: +def test_min_sample_rate_inert_when_rates_healthy( + transactions_rebalancing_model: TransactionsRebalancingModel, +) -> None: """When the natural rates already sit above the floor, enabling it changes nothing.""" classes = [RebalancedItem(id=f"big{i}", count=1000) for i in range(3)] @@ -308,7 +327,9 @@ def test_min_sample_rate_inert_when_rates_healthy(transactions_rebalancing_model assert floored_implicit == pytest.approx(base_implicit) -def test_min_sample_rate_out_of_range_is_invalid(transactions_rebalancing_model) -> None: +def test_min_sample_rate_out_of_range_is_invalid( + transactions_rebalancing_model: TransactionsRebalancingModel, +) -> None: with pytest.raises(InvalidModelInputError): _run( transactions_rebalancing_model, @@ -320,7 +341,9 @@ def test_min_sample_rate_out_of_range_is_invalid(transactions_rebalancing_model) ) -def test_total_num_classes_mismatch(transactions_rebalancing_model) -> None: +def test_total_num_classes_mismatch( + transactions_rebalancing_model: TransactionsRebalancingModel, +) -> None: """ Simple test case that checks that the model is resilient to cases where the reported total number of classes is less than the number of passed classes diff --git a/tests/sentry/dynamic_sampling/per_org/test_cache.py b/tests/sentry/dynamic_sampling/per_org/test_cache.py index 3672513df6f5..0c0af9f12313 100644 --- a/tests/sentry/dynamic_sampling/per_org/test_cache.py +++ b/tests/sentry/dynamic_sampling/per_org/test_cache.py @@ -1,6 +1,8 @@ from __future__ import annotations +from collections.abc import Callable from datetime import timedelta +from typing import cast from unittest.mock import DEFAULT, MagicMock, patch import pytest @@ -283,7 +285,9 @@ def test_project_sample_rates_renew_the_expiry_when_no_rate_moved(self) -> None: set_project_sample_rates(self.organization.id, items) - assert self.redis.pttl(cache_key) > 1000 + # justification: sentry_redis_tools clients do not annotate pttl. + pttl = cast("Callable[[str], int]", self.redis.pttl) + assert pttl(cache_key) > 1000 def test_transaction_sample_rates_round_trip(self) -> None: missing = self.create_project(organization=self.organization) diff --git a/tests/sentry/dynamic_sampling/per_org/test_calculations.py b/tests/sentry/dynamic_sampling/per_org/test_calculations.py index 8952330cbf13..aa276bd1e575 100644 --- a/tests/sentry/dynamic_sampling/per_org/test_calculations.py +++ b/tests/sentry/dynamic_sampling/per_org/test_calculations.py @@ -399,7 +399,9 @@ def test_extrapolate_monthly_volume_with_28_days() -> None: ], ) @freeze_time("2023-04-03 12:00:00") -def test_extrapolate_monthly_volume_with_30_days(volume, hours, expected_result) -> None: +def test_extrapolate_monthly_volume_with_30_days( + volume: int, hours: int, expected_result: int +) -> None: assert extrapolate_monthly_volume(volume=volume, hours=hours) == expected_result diff --git a/tests/sentry/dynamic_sampling/per_org/test_end_to_end.py b/tests/sentry/dynamic_sampling/per_org/test_end_to_end.py index ec2286e71978..ba828b3f3c54 100644 --- a/tests/sentry/dynamic_sampling/per_org/test_end_to_end.py +++ b/tests/sentry/dynamic_sampling/per_org/test_end_to_end.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from django.utils import timezone @@ -80,7 +80,9 @@ def rule(project: Project, rule_type: RuleType) -> PolymorphicRule: @with_feature("organizations:dynamic-sampling") @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) @patch("sentry.quotas.backend.get_blended_sample_rate", return_value=BLENDED_SAMPLE_RATE) - def test_stored_segments_end_up_as_project_rules(self, get_blended_sample_rate) -> None: + def test_stored_segments_end_up_as_project_rules( + self, get_blended_sample_rate: MagicMock + ) -> None: project_a = self.create_project_with_segments(9) project_b = self.create_project_with_segments(7) project_c = self.create_project_with_segments(3) diff --git a/tests/sentry/dynamic_sampling/per_org/test_feature_cache.py b/tests/sentry/dynamic_sampling/per_org/test_feature_cache.py index 1b51331d9cab..63023f5b57ad 100644 --- a/tests/sentry/dynamic_sampling/per_org/test_feature_cache.py +++ b/tests/sentry/dynamic_sampling/per_org/test_feature_cache.py @@ -11,7 +11,7 @@ get_orgs_with_dynamic_sampling, ) from sentry.dynamic_sampling.rules.utils import get_redis_client_for_ds -from sentry.models.organization import OrganizationStatus +from sentry.models.organization import Organization, OrganizationStatus from sentry.testutils.cases import TestCase FEATURE = "organizations:dynamic-sampling" @@ -22,7 +22,7 @@ def setUp(self) -> None: super().setUp() get_redis_client_for_ds().delete(ORGS_WITH_DYNAMIC_SAMPLING_CACHE_KEY) - def _org_with_project(self): + def _org_with_project(self) -> Organization: org = self.create_organization() self.create_project(organization=org) return org diff --git a/tests/sentry/dynamic_sampling/per_org/test_queries.py b/tests/sentry/dynamic_sampling/per_org/test_queries.py index 742d0d0b4353..f849fc101a40 100644 --- a/tests/sentry/dynamic_sampling/per_org/test_queries.py +++ b/tests/sentry/dynamic_sampling/per_org/test_queries.py @@ -24,6 +24,7 @@ run_eap_spans_table_query_in_chunks, ) from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.search.eap.constants import SAMPLING_MODE_HIGHEST_ACCURACY from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import SnubaParams @@ -503,7 +504,7 @@ def test_get_eap_transaction_volumes_counts_every_root_project(self) -> None: low_volume_project = self.create_project(organization=organization) timestamp = before_now(minutes=15) - def segment(originating_project, root_project): + def segment(originating_project: Project, root_project: Project) -> dict[str, Any]: return self.create_span( { "is_segment": True, @@ -589,7 +590,9 @@ def test_get_eap_transaction_volumes_caps_transactions_per_project(self) -> None other_project = self.create_project(organization=organization) timestamp = before_now(minutes=15) - def segment(transaction, root_project_id, project, offset): + def segment( + transaction: str, root_project_id: int, project: Project, offset: int + ) -> dict[str, Any]: return self.create_span( { "is_segment": True, @@ -643,7 +646,7 @@ def test_get_eap_transaction_volumes_reads_cap_from_option(self) -> None: project = self.create_project(organization=organization) timestamp = before_now(minutes=15) - def segment(transaction, offset): + def segment(transaction: str, offset: int) -> dict[str, Any]: return self.create_span( { "is_segment": True, @@ -736,7 +739,7 @@ def test_get_eap_transaction_volumes_project_over_cap_does_not_starve_other_proj quiet_project = self.create_project(organization=organization) timestamp = before_now(minutes=15) - def segment(transaction, project, offset): + def segment(transaction: str, project: Project, offset: int) -> dict[str, Any]: return self.create_span( { "is_segment": True, diff --git a/tests/sentry/dynamic_sampling/rules/biases/test_boost_environments_bias.py b/tests/sentry/dynamic_sampling/rules/biases/test_boost_environments_bias.py index e75ceb542b7d..ecce07acb135 100644 --- a/tests/sentry/dynamic_sampling/rules/biases/test_boost_environments_bias.py +++ b/tests/sentry/dynamic_sampling/rules/biases/test_boost_environments_bias.py @@ -1,10 +1,11 @@ from sentry.dynamic_sampling import ENVIRONMENT_GLOBS from sentry.dynamic_sampling.rules.biases.boost_environments_bias import BoostEnvironmentsBias +from sentry.models.project import Project from sentry.testutils.pytest.fixtures import django_db_all @django_db_all -def test_generate_bias_rules_v2(default_project) -> None: +def test_generate_bias_rules_v2(default_project: Project) -> None: rules = BoostEnvironmentsBias().generate_rules(project=default_project, base_sample_rate=0.1) assert rules == [ { diff --git a/tests/sentry/dynamic_sampling/rules/biases/test_boost_latest_releases_bias.py b/tests/sentry/dynamic_sampling/rules/biases/test_boost_latest_releases_bias.py index bf16364e3d30..63d7e6f8209a 100644 --- a/tests/sentry/dynamic_sampling/rules/biases/test_boost_latest_releases_bias.py +++ b/tests/sentry/dynamic_sampling/rules/biases/test_boost_latest_releases_bias.py @@ -1,10 +1,11 @@ from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch from django.utils import timezone from sentry.dynamic_sampling import LATEST_RELEASE_TTAS, ExtendedBoostedRelease, Platform from sentry.dynamic_sampling.rules.biases.boost_latest_releases_bias import BoostLatestReleasesBias +from sentry.models.project import Project from sentry.testutils.helpers.datetime import freeze_time from sentry.testutils.pytest.fixtures import django_db_all @@ -17,7 +18,7 @@ @patch( "sentry.dynamic_sampling.rules.biases.boost_latest_releases_bias.ProjectBoostedReleases.get_extended_boosted_releases" ) -def test_generate_bias_rules_v2(get_boosted_releases, default_project) -> None: +def test_generate_bias_rules_v2(get_boosted_releases: MagicMock, default_project: Project) -> None: now = timezone.now() platform = "python" default_project.update(platform=platform) @@ -89,7 +90,7 @@ def test_generate_bias_rules_v2(get_boosted_releases, default_project) -> None: "sentry.dynamic_sampling.rules.biases.boost_latest_releases_bias.ProjectBoostedReleases.get_extended_boosted_releases" ) def test_generate_bias_rules_with_no_boosted_releases( - get_boosted_releases, default_project + get_boosted_releases: MagicMock, default_project: Project ) -> None: default_project.update(platform="python") boosted_releases: list[ExtendedBoostedRelease] = [] diff --git a/tests/sentry/dynamic_sampling/rules/biases/test_boost_low_volume_transactions_bias.py b/tests/sentry/dynamic_sampling/rules/biases/test_boost_low_volume_transactions_bias.py index fe8e3159a603..d8714db690ee 100644 --- a/tests/sentry/dynamic_sampling/rules/biases/test_boost_low_volume_transactions_bias.py +++ b/tests/sentry/dynamic_sampling/rules/biases/test_boost_low_volume_transactions_bias.py @@ -1,12 +1,20 @@ +from collections.abc import Callable +from typing import cast from unittest.mock import MagicMock, patch from sentry.dynamic_sampling import RESERVED_IDS, RuleType from sentry.dynamic_sampling.rules.biases.boost_low_volume_transactions_bias import ( BoostLowVolumeTransactionsBias, ) +from sentry.models.project import Project -def _create_mocks(): +def _create_mocks() -> tuple[ + MagicMock, + Callable[[int, int, float], tuple[dict[str, float], float]], + dict[str, float], + float, +]: proj_id = 22 org_id = 23 @@ -20,7 +28,9 @@ def _create_mocks(): explicit_rates = {"t1": 0.1, "t2": 0.2} implicit_rate = 0.01 - def get_transaction_sample_rates(org_id, project_id, default_rate): + def get_transaction_sample_rates( + org_id: int, project_id: int, default_rate: float + ) -> tuple[dict[str, float], float]: if org_id == org.id and project_id == proj.id: return explicit_rates, implicit_rate return {}, default_rate @@ -49,7 +59,9 @@ def test_transaction_boost_known_projects(get_transaction_sample_rates: MagicMoc t1_factor = t1_rate / rate / implicit_factor t2_factor = t2_rate / rate / implicit_factor - rules = BoostLowVolumeTransactionsBias().generate_rules(project=project, base_sample_rate=rate) + rules = BoostLowVolumeTransactionsBias().generate_rules( + project=cast(Project, project), base_sample_rate=rate + ) expected = [ { "samplingValue": {"type": "factor", "value": t1_factor}, @@ -108,5 +120,7 @@ def test_transaction_boost_unknown_projects(get_transaction_sample_rates: MagicM rate = 0.2 get_transaction_sample_rates.return_value = ({}, rate) - rules = BoostLowVolumeTransactionsBias().generate_rules(project=project, base_sample_rate=rate) + rules = BoostLowVolumeTransactionsBias().generate_rules( + project=cast(Project, project), base_sample_rate=rate + ) assert rules == [] diff --git a/tests/sentry/dynamic_sampling/rules/biases/test_boost_replay_id_bias.py b/tests/sentry/dynamic_sampling/rules/biases/test_boost_replay_id_bias.py index 9611764d0860..aadbe1066948 100644 --- a/tests/sentry/dynamic_sampling/rules/biases/test_boost_replay_id_bias.py +++ b/tests/sentry/dynamic_sampling/rules/biases/test_boost_replay_id_bias.py @@ -1,9 +1,10 @@ from sentry.dynamic_sampling.rules.biases.boost_replay_id_bias import BoostReplayIdBias +from sentry.models.project import Project from sentry.testutils.pytest.fixtures import django_db_all @django_db_all -def test_generate_bias_rules_v2(default_project) -> None: +def test_generate_bias_rules_v2(default_project: Project) -> None: rules = BoostReplayIdBias().generate_rules(project=default_project, base_sample_rate=0.1) assert rules == [ { diff --git a/tests/sentry/dynamic_sampling/rules/biases/test_ignore_health_checks_bias.py b/tests/sentry/dynamic_sampling/rules/biases/test_ignore_health_checks_bias.py index 74e1faf9a1a2..6bebc82b6df9 100644 --- a/tests/sentry/dynamic_sampling/rules/biases/test_ignore_health_checks_bias.py +++ b/tests/sentry/dynamic_sampling/rules/biases/test_ignore_health_checks_bias.py @@ -6,11 +6,12 @@ from sentry.dynamic_sampling.rules.biases.ignore_health_checks_bias import ( IgnoreHealthChecksTraceBias, ) +from sentry.models.project import Project from sentry.testutils.pytest.fixtures import django_db_all @django_db_all -def test_generate_trace_bias_rules(default_project) -> None: +def test_generate_trace_bias_rules(default_project: Project) -> None: rules = IgnoreHealthChecksTraceBias().generate_rules( project=default_project, base_sample_rate=1.0 ) diff --git a/tests/sentry/dynamic_sampling/rules/biases/test_minimum_sample_rate_bias.py b/tests/sentry/dynamic_sampling/rules/biases/test_minimum_sample_rate_bias.py index 81d878bc2619..2d30f047e430 100644 --- a/tests/sentry/dynamic_sampling/rules/biases/test_minimum_sample_rate_bias.py +++ b/tests/sentry/dynamic_sampling/rules/biases/test_minimum_sample_rate_bias.py @@ -1,10 +1,11 @@ from sentry.dynamic_sampling.rules.biases.minimum_sample_rate_bias import MinimumSampleRateBias from sentry.dynamic_sampling.rules.utils import RESERVED_IDS, RuleType +from sentry.models.project import Project from sentry.testutils.pytest.fixtures import django_db_all @django_db_all -def test_generate_rules_with_different_sample_rates(default_project) -> None: +def test_generate_rules_with_different_sample_rates(default_project: Project) -> None: test_rates = [0.01, 0.05, 0.1, 0.5, 1.0] for base_sample_rate in test_rates: diff --git a/tests/sentry/dynamic_sampling/test_generate_rules.py b/tests/sentry/dynamic_sampling/test_generate_rules.py index 10da5f3b11c0..d47dc29187e9 100644 --- a/tests/sentry/dynamic_sampling/test_generate_rules.py +++ b/tests/sentry/dynamic_sampling/test_generate_rules.py @@ -1,5 +1,6 @@ import time from datetime import datetime, timedelta, timezone +from typing import cast from unittest.mock import MagicMock, patch import pytest @@ -18,7 +19,9 @@ RuleType, ) from sentry.dynamic_sampling.types import DynamicSamplingMode +from sentry.models.project import Project from sentry.models.projectteam import ProjectTeam +from sentry.models.team import Team from sentry.testutils.factories import Factories from sentry.testutils.helpers import Feature from sentry.testutils.helpers.datetime import freeze_time @@ -29,7 +32,7 @@ @pytest.fixture -def latest_release_only(default_old_project): +def latest_release_only(default_old_project: Project) -> None: """ This fixture is a hacky way of automatically changing the default project options to use only the latest release bias. @@ -45,14 +48,14 @@ def latest_release_only(default_old_project): @pytest.fixture -def default_old_project(default_project): +def default_old_project(default_project: Project) -> Project: """ A project created with an old_date. """ return _apply_old_date_to_project_and_org(default_project) -def _apply_old_date_to_project_and_org(project): +def _apply_old_date_to_project_and_org(project: Project) -> Project: """ Applies an old date to project and its corresponding org. An old date is determined as a date which is more than NEW_MODEL_THRESHOLD_IN_MINUTES minutes in the past. @@ -67,7 +70,7 @@ def _apply_old_date_to_project_and_org(project): return project -def _validate_rules(project): +def _validate_rules(project: Project) -> None: rules = generate_rules(project) # Generate boilerplate around minimal project config: @@ -91,7 +94,7 @@ def test_generate_rules_capture_exception( get_blended_sample_rate.return_value = None # since we mock get_blended_sample_rate function # no need to create real project in DB - fake_project = MagicMock() + fake_project = cast(Project, MagicMock()) # if blended rate is None that means dynamic sampling rate should be set to 1. rules = generate_rules(fake_project) assert rules[0]["samplingValue"]["value"] == 1.0 @@ -104,8 +107,8 @@ def test_generate_rules_capture_exception( @django_db_all @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_return_only_always_allowed_rules_if_sample_rate_is_100_and_other_rules_are_enabled( - get_blended_sample_rate, default_old_project -): + get_blended_sample_rate: MagicMock, default_old_project: Project +) -> None: get_blended_sample_rate.return_value = 1.0 # We also enable the recalibration to show it's not generated as part of the rules. @@ -129,8 +132,10 @@ def test_generate_rules_return_only_always_allowed_rules_if_sample_rate_is_100_a @patch("sentry.dynamic_sampling.rules.base.get_enabled_user_biases") @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_return_uniform_rules_with_rate( - get_blended_sample_rate, get_enabled_user_biases, default_old_project -): + get_blended_sample_rate: MagicMock, + get_enabled_user_biases: MagicMock, + default_old_project: Project, +) -> None: # it means no enabled user biases get_enabled_user_biases.return_value = {} get_blended_sample_rate.return_value = 0.1 @@ -153,8 +158,10 @@ def test_generate_rules_return_uniform_rules_with_rate( @patch("sentry.dynamic_sampling.rules.base.get_enabled_user_biases") @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_serve_the_blended_rate_when_the_cache_cannot_be_read( - get_blended_sample_rate, get_enabled_user_biases, default_old_project -): + get_blended_sample_rate: MagicMock, + get_enabled_user_biases: MagicMock, + default_old_project: Project, +) -> None: get_enabled_user_biases.return_value = {} get_blended_sample_rate.return_value = 0.1 store_per_org_project_sample_rate(default_old_project, 0.5) @@ -179,8 +186,8 @@ def test_generate_rules_serve_the_blended_rate_when_the_cache_cannot_be_read( @django_db_all @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_return_uniform_rules_and_env_rule( - get_blended_sample_rate, default_old_project -): + get_blended_sample_rate: MagicMock, default_old_project: Project +) -> None: get_blended_sample_rate.return_value = 0.1 store_per_org_project_sample_rate(default_old_project, 0.1) default_old_project.update_option( @@ -239,8 +246,8 @@ def test_generate_rules_return_uniform_rules_and_env_rule( @django_db_all @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_return_uniform_rule_with_100_rate_and_without_env_rule( - get_blended_sample_rate, default_old_project -): + get_blended_sample_rate: MagicMock, default_old_project: Project +) -> None: get_blended_sample_rate.return_value = 1.0 assert generate_rules(default_old_project) == [ @@ -269,14 +276,14 @@ def test_generate_rules_return_uniform_rule_with_100_rate_and_without_env_rule( ], ) def test_generate_rules_with_different_project_platforms( - get_blended_sample_rate, - apply_dynamic_factor, - version, - platform, - end, - default_project, - latest_release_only, -): + get_blended_sample_rate: MagicMock, + apply_dynamic_factor: MagicMock, + version: str, + platform: str | None, + end: str, + default_project: Project, + latest_release_only: None, +) -> None: default_old_project = _apply_old_date_to_project_and_org(default_project) get_blended_sample_rate.return_value = 0.1 @@ -332,8 +339,11 @@ def test_generate_rules_with_different_project_platforms( @patch("sentry.dynamic_sampling.rules.biases.boost_latest_releases_bias.apply_dynamic_factor") @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_return_uniform_rules_and_latest_release_rule( - get_blended_sample_rate, apply_dynamic_factor, default_project, latest_release_only -): + get_blended_sample_rate: MagicMock, + apply_dynamic_factor: MagicMock, + default_project: Project, + latest_release_only: None, +) -> None: default_old_project = _apply_old_date_to_project_and_org(default_project) get_blended_sample_rate.return_value = 0.1 @@ -414,8 +424,11 @@ def test_generate_rules_return_uniform_rules_and_latest_release_rule( @patch("sentry.dynamic_sampling.rules.biases.boost_latest_releases_bias.apply_dynamic_factor") @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_does_not_return_rule_with_deleted_release( - get_blended_sample_rate, apply_dynamic_factor, default_project, latest_release_only -): + get_blended_sample_rate: MagicMock, + apply_dynamic_factor: MagicMock, + default_project: Project, + latest_release_only: None, +) -> None: default_old_project = _apply_old_date_to_project_and_org(default_project) get_blended_sample_rate.return_value = 0.1 @@ -469,8 +482,8 @@ def test_generate_rules_does_not_return_rule_with_deleted_release( @django_db_all @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_return_uniform_rule_with_100_rate_and_without_latest_release_rule( - get_blended_sample_rate, default_old_project, latest_release_only -): + get_blended_sample_rate: MagicMock, default_old_project: Project, latest_release_only: None +) -> None: get_blended_sample_rate.return_value = 1.0 default_old_project.update(platform="python") @@ -489,8 +502,8 @@ def test_generate_rules_return_uniform_rule_with_100_rate_and_without_latest_rel @django_db_all @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_return_uniform_rule_with_non_existent_releases( - get_blended_sample_rate, default_old_project, latest_release_only -): + get_blended_sample_rate: MagicMock, default_old_project: Project, latest_release_only: None +) -> None: get_blended_sample_rate.return_value = 1.0 redis_client = get_redis_client_for_ds() @@ -513,7 +526,7 @@ def test_generate_rules_return_uniform_rule_with_non_existent_releases( @django_db_all @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_with_zero_base_sample_rate( - get_blended_sample_rate, default_old_project + get_blended_sample_rate: MagicMock, default_old_project: Project ) -> None: get_blended_sample_rate.return_value = 0.0 store_per_org_project_sample_rate(default_old_project, 0.0) @@ -538,8 +551,11 @@ def test_generate_rules_with_zero_base_sample_rate( "sentry.dynamic_sampling.rules.biases.boost_low_volume_transactions_bias.get_transaction_sample_rates" ) def test_generate_rules_return_uniform_rules_and_low_volume_transactions_rules( - get_transaction_sample_rates, get_blended_sample_rate, default_old_project, default_team -): + get_transaction_sample_rates: MagicMock, + get_blended_sample_rate: MagicMock, + default_old_project: Project, + default_team: Team, +) -> None: project_sample_rate = 0.1 t1_rate = 0.7 implicit_rate = 0.037 @@ -617,8 +633,11 @@ def test_generate_rules_return_uniform_rules_and_low_volume_transactions_rules( "sentry.dynamic_sampling.rules.biases.boost_low_volume_transactions_bias.get_transaction_sample_rates" ) def test_low_volume_transactions_rules_not_returned_when_inactive( - get_transaction_sample_rates, get_blended_sample_rate, default_old_project, default_team -): + get_transaction_sample_rates: MagicMock, + get_blended_sample_rate: MagicMock, + default_old_project: Project, + default_team: Team, +) -> None: get_blended_sample_rate.return_value = 0.1 store_per_org_project_sample_rate(default_old_project, 0.1) get_transaction_sample_rates.return_value = ( @@ -658,8 +677,8 @@ def test_low_volume_transactions_rules_not_returned_when_inactive( @freeze_time("2022-10-21T18:50:25Z") @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_return_uniform_rules_and_recalibrate_orgs_rule( - get_blended_sample_rate, default_project -): + get_blended_sample_rate: MagicMock, default_project: Project +) -> None: default_old_project = _apply_old_date_to_project_and_org(default_project) get_blended_sample_rate.return_value = 0.1 @@ -700,7 +719,7 @@ def test_generate_rules_return_uniform_rules_and_recalibrate_orgs_rule( @django_db_all @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_return_boost_replay_id( - get_blended_sample_rate, default_old_project + get_blended_sample_rate: MagicMock, default_old_project: Project ) -> None: get_blended_sample_rate.return_value = 0.5 store_per_org_project_sample_rate(default_old_project, 0.5) @@ -744,8 +763,8 @@ def test_generate_rules_return_boost_replay_id( @django_db_all @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_return_minimum_sample_rate_when_enabled( - get_blended_sample_rate, default_old_project -): + get_blended_sample_rate: MagicMock, default_old_project: Project +) -> None: get_blended_sample_rate.return_value = 0.3 store_per_org_project_sample_rate(default_old_project, 0.3) default_old_project.update_option( @@ -794,8 +813,8 @@ def test_generate_rules_return_minimum_sample_rate_when_enabled( @django_db_all @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_minimum_sample_rate_not_included_when_disabled( - get_blended_sample_rate, default_old_project -): + get_blended_sample_rate: MagicMock, default_old_project: Project +) -> None: get_blended_sample_rate.return_value = 0.3 store_per_org_project_sample_rate(default_old_project, 0.3) default_old_project.update_option( @@ -823,8 +842,8 @@ def test_generate_rules_minimum_sample_rate_not_included_when_disabled( @django_db_all @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_minimum_sample_rate_not_included_by_default( - get_blended_sample_rate, default_old_project -): + get_blended_sample_rate: MagicMock, default_old_project: Project +) -> None: get_blended_sample_rate.return_value = 0.3 store_per_org_project_sample_rate(default_old_project, 0.3) default_old_project.update_option( @@ -852,8 +871,8 @@ def test_generate_rules_minimum_sample_rate_not_included_by_default( @django_db_all @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_minimum_sample_rate_correct_order( - get_blended_sample_rate, default_old_project -): + get_blended_sample_rate: MagicMock, default_old_project: Project +) -> None: with Feature({"organizations:dynamic-sampling-minimum-sample-rate": True}): get_blended_sample_rate.return_value = 0.4 store_per_org_project_sample_rate(default_old_project, 0.4) @@ -898,8 +917,8 @@ def test_generate_rules_minimum_sample_rate_correct_order( @django_db_all @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_minimum_sample_rate_with_100_percent_sample_rate( - get_blended_sample_rate, default_old_project -): + get_blended_sample_rate: MagicMock, default_old_project: Project +) -> None: get_blended_sample_rate.return_value = 1.0 default_old_project.update_option( "sentry:dynamic_sampling_biases", @@ -928,8 +947,8 @@ def test_generate_rules_minimum_sample_rate_with_100_percent_sample_rate( @django_db_all @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_trace_health_checks_feature_enabled( - get_blended_sample_rate, default_old_project -): + get_blended_sample_rate: MagicMock, default_old_project: Project +) -> None: get_blended_sample_rate.return_value = 0.4 store_per_org_project_sample_rate(default_old_project, 0.4) default_old_project.update_option( @@ -959,8 +978,10 @@ def test_generate_rules_trace_health_checks_feature_enabled( @patch("sentry.dynamic_sampling.rules.base.get_enabled_user_biases") @patch("sentry.dynamic_sampling.rules.base.quotas.backend.get_blended_sample_rate") def test_generate_rules_project_mode( - get_blended_sample_rate, get_enabled_user_biases, default_old_project -): + get_blended_sample_rate: MagicMock, + get_enabled_user_biases: MagicMock, + default_old_project: Project, +) -> None: # Ensure only the project base rate is generated get_enabled_user_biases.return_value = {} diff --git a/tests/sentry/dynamic_sampling/test_utils.py b/tests/sentry/dynamic_sampling/test_utils.py index 004939fbc7d6..c31a27c0ac5a 100644 --- a/tests/sentry/dynamic_sampling/test_utils.py +++ b/tests/sentry/dynamic_sampling/test_utils.py @@ -22,12 +22,14 @@ (1.0, 4.0, 1.0), ], ) -def test_apply_dynamic_factor_with_valid_params(base_sample_rate, x, expected) -> None: +def test_apply_dynamic_factor_with_valid_params( + base_sample_rate: float, x: float, expected: float +) -> None: assert apply_dynamic_factor(base_sample_rate, x) == pytest.approx(expected) @pytest.mark.parametrize(["base_sample_rate", "x"], [(-0.1, 1.5), (1.1, 2.5), (0.5, 0)]) -def test_apply_dynamic_factor_with_invalid_params(base_sample_rate, x) -> None: +def test_apply_dynamic_factor_with_invalid_params(base_sample_rate: float, x: float) -> None: with pytest.raises(Exception): apply_dynamic_factor(base_sample_rate, x) diff --git a/tests/sentry/event_manager/grouping/test_assign_to_group.py b/tests/sentry/event_manager/grouping/test_assign_to_group.py index 57ac9fd92336..90d240fdb03b 100644 --- a/tests/sentry/event_manager/grouping/test_assign_to_group.py +++ b/tests/sentry/event_manager/grouping/test_assign_to_group.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterator from contextlib import contextmanager from time import time from typing import Any @@ -28,7 +29,9 @@ @contextmanager -def patch_grouping_helpers(return_values: dict[str, Any]): +def patch_grouping_helpers( + return_values: dict[str, Any], +) -> Iterator[dict[str, mock.MagicMock]]: wrapped_find_grouphash_with_group = capture_results(find_grouphash_with_group, return_values) wrapped_calculate_primary_hashes = capture_results( _calculate_primary_hashes_and_variants, return_values @@ -75,7 +78,7 @@ def set_grouping_configs( secondary_config: str | None, transition_expiry: float | None = None, in_transition: bool = False, -): +) -> None: project.update_option("sentry:grouping_config", primary_config) project.update_option("sentry:secondary_grouping_config", secondary_config) if in_transition: @@ -128,7 +131,7 @@ def get_results_from_saving_event( secondary_config: str, in_transition: bool, existing_group_id: int | None = None, -): +) -> dict[str, object]: # Whether or not these are assigned a value depends on the values of `in_transition` and # `existing_group_id`. Everything else we'll return will definitely get a value and therefore # doesn't need to be initialized. @@ -258,7 +261,7 @@ def get_results_from_saving_event( def test_new_group( in_transition: bool, default_project: Project, -): +) -> None: project = default_project event_data = {"message": "testing, testing, 123"} @@ -312,7 +315,7 @@ def test_new_group( def test_existing_group_no_new_hash( in_transition: bool, default_project: Project, -): +) -> None: project = default_project event_data = {"message": "testing, testing, 123"} @@ -375,7 +378,7 @@ def test_existing_group_new_hash_exists( secondary_hash_exists: bool, in_transition: bool, default_project: Project, -): +) -> None: project = default_project event_data = {"message": "testing, testing, 123"} diff --git a/tests/sentry/event_manager/grouping/test_group_creation_lock.py b/tests/sentry/event_manager/grouping/test_group_creation_lock.py index 82fc3db06bfd..980c1d9deb7c 100644 --- a/tests/sentry/event_manager/grouping/test_group_creation_lock.py +++ b/tests/sentry/event_manager/grouping/test_group_creation_lock.py @@ -1,11 +1,13 @@ import contextlib import time +from collections.abc import Iterator from threading import Thread from unittest.mock import patch import pytest from sentry.event_manager import GroupInfo, assign_event_to_group +from sentry.models.project import Project from sentry.services.eventstore.models import Event from sentry.testutils.pytest.fixtures import django_db_all @@ -15,7 +17,7 @@ class FakeTransactionModule: @staticmethod @contextlib.contextmanager - def atomic(*args, **kwds): + def atomic(*args: object, **kwds: object) -> Iterator[None]: yield @@ -55,7 +57,7 @@ def save_event(project_id: int, return_values: list[GroupInfo]) -> None: ], ids=(" lock_disabled: True ", " lock_disabled: False "), ) -def test_group_creation_race(default_project, lock_disabled) -> None: +def test_group_creation_race(default_project: Project, lock_disabled: bool) -> None: with contextlib.ExitStack() as ctx: if lock_disabled: # Disable transaction isolation just within event manager, but not in diff --git a/tests/sentry/event_manager/grouping/test_seer_grouping.py b/tests/sentry/event_manager/grouping/test_seer_grouping.py index e69753793555..656cae4046e3 100644 --- a/tests/sentry/event_manager/grouping/test_seer_grouping.py +++ b/tests/sentry/event_manager/grouping/test_seer_grouping.py @@ -5,8 +5,11 @@ from sentry.grouping.ingest.grouphash_metadata import create_or_update_grouphash_metadata_if_needed from sentry.grouping.ingest.seer import get_seer_similar_issues, should_call_seer_for_grouping +from sentry.grouping.variants import BaseVariant from sentry.models.grouphash import GroupHash +from sentry.models.project import Project from sentry.seer.similarity.types import GroupingVersion, SeerSimilarIssueData +from sentry.services.eventstore.models import Event from sentry.testutils.cases import TestCase from sentry.testutils.helpers.eventprocessing import save_new_event from sentry.testutils.pytest.mocking import capture_results @@ -115,13 +118,17 @@ def test_obeys_seer_similarity_flags(self) -> None: @patch("sentry.grouping.ingest.seer.should_call_seer_for_grouping", return_value=True) @patch("sentry.grouping.ingest.seer.get_seer_similar_issues", return_value=EMPTY_SEER_RESULTS) - def test_calls_seer_if_no_group_found(self, mock_get_seer_similar_issues: MagicMock, _) -> None: + def test_calls_seer_if_no_group_found( + self, mock_get_seer_similar_issues: MagicMock, _: MagicMock + ) -> None: save_new_event({"message": "Dogs are great!"}, self.project) assert mock_get_seer_similar_issues.call_count == 1 @patch("sentry.grouping.ingest.seer.should_call_seer_for_grouping", return_value=True) @patch("sentry.grouping.ingest.seer.get_seer_similar_issues", return_value=EMPTY_SEER_RESULTS) - def test_bypasses_seer_if_group_found(self, mock_get_seer_similar_issues: MagicMock, _) -> None: + def test_bypasses_seer_if_group_found( + self, mock_get_seer_similar_issues: MagicMock, _: MagicMock + ) -> None: existing_event = save_new_event({"message": "Dogs are great!"}, self.project) assert mock_get_seer_similar_issues.call_count == 1 @@ -316,8 +323,13 @@ def test_fills_in_missing_date_added(self, _: MagicMock) -> None: # create `GroupHash` and `GroupHashMetadata` records, and each event wins one of the races, # which results in the metadata not having a `date_added` value def race_condition_create_or_update_grouphash_metadata( - event, project, grouphash, created, grouping_config, variants - ): + event: Event, + project: Project, + grouphash: GroupHash, + created: bool, + grouping_config: str, + variants: dict[str, BaseVariant], + ) -> None: create_or_update_grouphash_metadata_if_needed( event, project, grouphash, created, grouping_config, variants ) diff --git a/tests/sentry/event_manager/interfaces/test_exception.py b/tests/sentry/event_manager/interfaces/test_exception.py index a4c5e412b030..d17ecefd7189 100644 --- a/tests/sentry/event_manager/interfaces/test_exception.py +++ b/tests/sentry/event_manager/interfaces/test_exception.py @@ -275,10 +275,14 @@ def test_context_with_two_exceptions_having_mechanism( def test_iteration() -> None: inst = Exception.to_python({"values": [None, {"type": "ValueError"}, None]}) + assert inst is not None assert len(inst) == 1 - assert inst[0].type == "ValueError" + first = inst.exceptions()[0] + assert first is not None + assert first.type == "ValueError" for exc in inst: + assert exc is not None assert exc.type == "ValueError" @@ -303,6 +307,8 @@ def test_get_api_meta_with_non_numeric_keys() -> None: } ) + assert inst is not None + # Test mix of valid single-digit, multi-digit, and invalid keys meta: dict[str, dict[str, dict[str, object]]] = { "values": { @@ -320,6 +326,7 @@ def test_get_api_meta_with_non_numeric_keys() -> None: } result = inst.get_api_meta(meta) + assert result is not None # Valid single-digit keys should be present assert "0" in result["values"] diff --git a/tests/sentry/event_manager/test_ensure_has_ip.py b/tests/sentry/event_manager/test_ensure_has_ip.py index 7e53c9202b46..956779f58215 100644 --- a/tests/sentry/event_manager/test_ensure_has_ip.py +++ b/tests/sentry/event_manager/test_ensure_has_ip.py @@ -1,8 +1,13 @@ +from collections.abc import MutableMapping +from typing import Any + from sentry.event_manager import EventManager from sentry.testutils.pytest.fixtures import django_db_all -def validate_and_normalize(report, client_ip=None): +def validate_and_normalize( + report: MutableMapping[str, Any], client_ip: str | None = None +) -> MutableMapping[str, Any]: manager = EventManager(report, client_ip=client_ip) manager.normalize() return manager.get_data() diff --git a/tests/sentry/event_manager/test_event_manager.py b/tests/sentry/event_manager/test_event_manager.py index d42fc3a0328e..c99062ec2b32 100644 --- a/tests/sentry/event_manager/test_event_manager.py +++ b/tests/sentry/event_manager/test_event_manager.py @@ -5,7 +5,7 @@ from collections.abc import Mapping from datetime import UTC, datetime, timedelta from time import time -from typing import Any +from typing import Any, cast from unittest import mock from unittest.mock import MagicMock, patch @@ -771,6 +771,7 @@ def test_regression_attributes_to_system(self) -> None: def test_regression_emits_snapshot(self, produce_snapshot: MagicMock) -> None: event = self.store_event(data={}, project_id=self.project.id) + assert event.group_id is not None group = Group.objects.get(id=event.group_id) group.update(status=GroupStatus.RESOLVED, substatus=None, active_at=before_now(days=1)) @@ -3009,8 +3010,12 @@ def test_category_match_group(self) -> None: @override_options({"performance.issues.all.problem-detection": 1.0}) @override_options({"performance.issues.n_plus_one_db.problem-creation": 1.0}) def test_perf_issue_creation(self) -> None: - event = self.create_performance_issue( - event_data=make_event(**get_event("n-plus-one-db/n-plus-one-in-django-index-view")) + # justification: performance issue fixtures emit GroupEvents though the fixture is typed as Event. + event = cast( + "GroupEvent", + self.create_performance_issue( + event_data=make_event(**get_event("n-plus-one-db/n-plus-one-in-django-index-view")) + ), ) data = event.data assert event.get_event_type() == "transaction" @@ -3067,7 +3072,6 @@ def test_perf_issue_creation(self) -> None: assert group.level == 40 assert group.issue_category == GroupCategory.DB_QUERY assert group.issue_type == PerformanceNPlusOneGroupType - assert isinstance(event, GroupEvent) assert event.occurrence assert event.occurrence.evidence_display == [ IssueEvidence( @@ -3221,9 +3225,13 @@ def test_perf_issue_creation_over_ignored_threshold(self) -> None: ) def test_perf_issue_slow_db_issue_is_created(self) -> None: def attempt_to_generate_slow_db_issue() -> GroupEvent: - return self.create_performance_issue( - event_data=make_event(**get_event("slow-db/slow-db-spans")), - issue_type=PerformanceSlowDBQueryGroupType, + # justification: performance issue fixtures emit GroupEvents though the fixture is typed as Event. + return cast( + "GroupEvent", + self.create_performance_issue( + event_data=make_event(**get_event("slow-db/slow-db-spans")), + issue_type=PerformanceSlowDBQueryGroupType, + ), ) last_event = attempt_to_generate_slow_db_issue() diff --git a/tests/sentry/event_manager/test_event_manager_grouping.py b/tests/sentry/event_manager/test_event_manager_grouping.py index 335046a8ace2..396d8a1fc077 100644 --- a/tests/sentry/event_manager/test_event_manager_grouping.py +++ b/tests/sentry/event_manager/test_event_manager_grouping.py @@ -655,7 +655,9 @@ def test_bug_regression_no_longer_breaks_titles(self) -> None: (None, None, None), ], ) -def test_get_updated_group_title(existing_title, incoming_title, expected_title) -> None: +def test_get_updated_group_title( + existing_title: str | None, incoming_title: str | None, expected_title: str | None +) -> None: existing_data = {"title": existing_title} if existing_title is not None else {} incoming_data = {"title": incoming_title} if incoming_title is not None else {} @@ -709,7 +711,7 @@ def test_records_avg_calculations_per_event_metrics(self, mock_metrics_incr: Mag @mock.patch("sentry.event_manager.metrics.incr") def test_adds_correct_tags_to_avg_calculations_per_event_metrics( self, mock_metrics_incr: MagicMock - ): + ) -> None: project = self.project in_transition_cases: list[Any] = [ @@ -765,7 +767,7 @@ def test_records_hash_comparison_metric( secondary_hashes: list[str], expected_tag: str, default_project: Project, -): +) -> None: project = default_project project.update_option("sentry:grouping_config", DEFAULT_GROUPING_CONFIG) project.update_option("sentry:secondary_grouping_config", NO_MSG_PARAM_CONFIG) diff --git a/tests/sentry/event_manager/test_normalization.py b/tests/sentry/event_manager/test_normalization.py index b3bd6e5efbb9..29f593d1cf28 100644 --- a/tests/sentry/event_manager/test_normalization.py +++ b/tests/sentry/event_manager/test_normalization.py @@ -1,5 +1,6 @@ import logging from datetime import datetime +from typing import Any import pytest from django.conf import settings @@ -8,7 +9,7 @@ from sentry.event_manager import EventManager -def make_event(**kwargs): +def make_event(**kwargs: Any) -> dict[str, Any]: result = { "event_id": "a" * 32, "message": "foo", @@ -46,7 +47,7 @@ def test_interface_is_relabeled() -> None: @pytest.mark.parametrize("user", ["missing", None, {}, {"ip_address": None}]) -def test_does_default_ip_address_to_user(user) -> None: +def test_does_default_ip_address_to_user(user: Any) -> None: event = { "request": {"url": "http://example.com", "env": {"REMOTE_ADDR": "127.0.0.1"}}, "platform": "javascript", @@ -187,7 +188,7 @@ def test_event_id_lowercase() -> None: @pytest.mark.parametrize("key", ["applecrashreport", "device", "repos", "query"]) -def test_deprecated_attrs(key) -> None: +def test_deprecated_attrs(key: str) -> None: event = make_event() event[key] = "some value" @@ -200,7 +201,7 @@ def test_deprecated_attrs(key) -> None: @pytest.mark.parametrize("environment", ["", None, "production"]) -def test_environment_tag_removed(environment) -> None: +def test_environment_tag_removed(environment: str | None) -> None: event = make_event() event["environment"] = environment event["tags"] = {"environment": "production"} diff --git a/tests/sentry/event_manager/test_validate_data.py b/tests/sentry/event_manager/test_validate_data.py index b550d9e90d4d..ccaed94594ea 100644 --- a/tests/sentry/event_manager/test_validate_data.py +++ b/tests/sentry/event_manager/test_validate_data.py @@ -1,4 +1,6 @@ +from collections.abc import MutableMapping from datetime import timedelta +from typing import Any import pytest from django.utils import timezone @@ -7,7 +9,7 @@ from sentry.event_manager import EventManager -def validate_and_normalize(data): +def validate_and_normalize(data: MutableMapping[str, Any]) -> MutableMapping[str, Any]: manager = EventManager(data) manager.normalize() return manager.get_data() diff --git a/tests/sentry/eventstream/test_eventstream.py b/tests/sentry/eventstream/test_eventstream.py index 5a0f323fc23a..357a83ee8b45 100644 --- a/tests/sentry/eventstream/test_eventstream.py +++ b/tests/sentry/eventstream/test_eventstream.py @@ -2,7 +2,7 @@ import threading import time import uuid -from collections.abc import Generator, MutableSequence +from collections.abc import Generator, MutableSequence, Sequence from datetime import datetime, timedelta from typing import Any, cast from unittest import mock @@ -295,7 +295,11 @@ def test_issueless(self, mock_eventstream_insert: MagicMock) -> None: end=now + timedelta(days=1), selected_columns=["event_id"], groupby=None, - filter_keys={"project_id": [self.project.id], "event_id": [event.event_id]}, + # justification: Snuba accepts string event ids despite the int-typed filter contract. + filter_keys=cast( + "dict[str, Sequence[int]]", + {"project_id": [self.project.id], "event_id": [event.event_id]}, + ), tenant_ids={"organization_id": 1, "referrer": "r"}, ) assert len(result["data"]) == 1 @@ -328,7 +332,11 @@ def test_multiple_groups(self, mock_eventstream_insert: MagicMock) -> None: end=now + timedelta(days=1), selected_columns=["event_id", "group_ids"], groupby=None, - filter_keys={"project_id": [self.project.id], "event_id": [event.event_id]}, + # justification: Snuba accepts string event ids despite the int-typed filter contract. + filter_keys=cast( + "dict[str, Sequence[int]]", + {"project_id": [self.project.id], "event_id": [event.event_id]}, + ), tenant_ids={"organization_id": 1, "referrer": "r"}, ) assert len(result["data"]) == 1 diff --git a/tests/sentry/explore/endpoints/test_explore_saved_query_detail.py b/tests/sentry/explore/endpoints/test_explore_saved_query_detail.py index c93d2142ecdd..a76ce4a9fb80 100644 --- a/tests/sentry/explore/endpoints/test_explore_saved_query_detail.py +++ b/tests/sentry/explore/endpoints/test_explore_saved_query_detail.py @@ -750,7 +750,7 @@ def setUp(self) -> None: self.query.set_projects(self.project_ids) - def url(self, id): + def url(self, id: str) -> str: return reverse( "sentry-api-0-explore-saved-query-visit", kwargs={"organization_id_or_slug": self.org.slug, "id": id}, @@ -762,7 +762,7 @@ def test_visit_query(self) -> None: assert self.query.visits == 1 with self.feature(self.feature_name): - response = self.client.post(self.url(self.query.id)) + response = self.client.post(self.url(str(self.query.id))) assert response.status_code == 204 @@ -776,7 +776,7 @@ def test_visit_query_no_access(self) -> None: assert self.query.visits == 1 with self.feature({self.feature_name: False}): - response = self.client.post(self.url(self.query.id)) + response = self.client.post(self.url(str(self.query.id))) assert response.status_code == 404 @@ -808,7 +808,7 @@ def test_visit_query_without_project_access(self) -> None: self.login_as(user=member_user) with self.feature(self.feature_name): - response = self.client.post(self.url(restricted_query.id)) + response = self.client.post(self.url(str(restricted_query.id))) assert response.status_code == 403, response.content diff --git a/tests/sentry/explore/migrations/test_0016_update_if_syntax_on_spans.py b/tests/sentry/explore/migrations/test_0016_update_if_syntax_on_spans.py index 6468d5fa03e4..45b29155bc23 100644 --- a/tests/sentry/explore/migrations/test_0016_update_if_syntax_on_spans.py +++ b/tests/sentry/explore/migrations/test_0016_update_if_syntax_on_spans.py @@ -1,3 +1,7 @@ +from typing import Any + +from django.db.migrations.state import StateApps + from sentry.models.project import Project from sentry.testutils.cases import TestMigrations @@ -7,18 +11,18 @@ class UpdateIfSyntaxOnSpansTest(TestMigrations): migrate_to = "0016_update_if_syntax_on_spans" app = "explore" - def setup_initial_state(self): + def setup_initial_state(self) -> None: # Preserve the project flags previously set by SnubaTestCase without # requiring Snuba for these PostgreSQL-only query rewrites. self.project = self.create_project( name="Bar", slug="bar", flags=Project.flags.has_transactions ) - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: ExploreSavedQuery = apps.get_model("explore", "ExploreSavedQuery") ExploreSavedQueryProject = apps.get_model("explore", "ExploreSavedQueryProject") - def create_query(*, name, dataset, query): + def create_query(*, name: str, dataset: int, query: dict[str, object]) -> Any: saved = ExploreSavedQuery.objects.create( organization_id=self.organization.id, name=name, @@ -209,7 +213,7 @@ def create_query(*, name, dataset, query): return super().setup_before_migration(apps) - def test_rewrites_saved_queries(self): + def test_rewrites_saved_queries(self) -> None: # All scenarios share the same before/after state. Run the migration once # and keep separate failure reports for each set of read-only assertions. with self.subTest(scenario="basic_aggregates_and_orderby"): @@ -221,7 +225,7 @@ def test_rewrites_saved_queries(self): with self.subTest(scenario="already_migrated_unchanged"): self._assert_already_migrated_unchanged() - def _assert_basic_aggregates_and_orderby(self): + def _assert_basic_aggregates_and_orderby(self) -> None: self.query_basic.refresh_from_db() assert self.query_basic.query == { "name": "Basic if combinators", @@ -277,7 +281,7 @@ def _assert_basic_aggregates_and_orderby(self): "interval": "1m", } - def _assert_equation_patterns(self): + def _assert_equation_patterns(self) -> None: self.query_equations.refresh_from_db() y_axes = [ field["yAxes"][0] for field in self.query_equations.query["query"][0]["aggregateField"] @@ -290,7 +294,7 @@ def _assert_equation_patterns(self): "equation|count_if(`span.description:a:b:c`) / count_if(`span.description:c:b:a`)", ] - def _assert_duration_unit_thresholds(self): + def _assert_duration_unit_thresholds(self) -> None: self.query_duration_units.refresh_from_db() y_axes = [ field["yAxes"][0] @@ -302,7 +306,7 @@ def _assert_duration_unit_thresholds(self): "equation|count_if(`span.duration:<7s`)/(count_if(`span.duration:>7s`)+count_if(`span.duration:<7s`))", ] - def _assert_already_migrated_unchanged(self): + def _assert_already_migrated_unchanged(self) -> None: self.query_already_migrated.refresh_from_db() y_axes = [ field["yAxes"][0] diff --git a/tests/sentry/explore/test_models.py b/tests/sentry/explore/test_models.py index 2fdff0edfbf8..993000880ade 100644 --- a/tests/sentry/explore/test_models.py +++ b/tests/sentry/explore/test_models.py @@ -55,7 +55,7 @@ def test_create_org_wide(self) -> None: item_type=TraceItemTypes.SPANS, attribute_type=TraceItemAttributeTypes.STRING, ) - assert description.project_id is None + assert not description.project_id assert description.examples == [] def test_unique_project_scoped(self) -> None: diff --git a/tests/sentry/explore/translation/test_alerts_translation.py b/tests/sentry/explore/translation/test_alerts_translation.py index 50351fc5cdf4..cc64b9fa60c0 100644 --- a/tests/sentry/explore/translation/test_alerts_translation.py +++ b/tests/sentry/explore/translation/test_alerts_translation.py @@ -1,5 +1,5 @@ from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch import orjson import pytest @@ -129,7 +129,7 @@ def test_snapshot_snuba_query_with_metrics_to_transactions_flag_incorrect_datase @with_feature("organizations:migrate-transaction-alerts-to-spans") @patch("sentry.snuba.tasks._create_rpc_in_snuba") - def test_translate_alert_rule_p95(self, mock_create_rpc) -> None: + def test_translate_alert_rule_p95(self, mock_create_rpc: MagicMock) -> None: mock_create_rpc.return_value = "test-subscription-id" snuba_query = create_snuba_query( @@ -198,7 +198,7 @@ def test_translate_alert_rule_p95(self, mock_create_rpc) -> None: @with_feature("organizations:migrate-transaction-alerts-to-spans") @patch("sentry.snuba.tasks._create_rpc_in_snuba") - def test_translate_alert_rule_apdex(self, mock_create_rpc) -> None: + def test_translate_alert_rule_apdex(self, mock_create_rpc: MagicMock) -> None: mock_create_rpc.return_value = "test-subscription-id" snuba_query = create_snuba_query( @@ -256,7 +256,7 @@ def test_translate_alert_rule_apdex(self, mock_create_rpc) -> None: @with_feature("organizations:migrate-transaction-alerts-to-spans") @patch("sentry.snuba.tasks._create_rpc_in_snuba") - def test_translate_alert_rule_apdex_float(self, mock_create_rpc) -> None: + def test_translate_alert_rule_apdex_float(self, mock_create_rpc: MagicMock) -> None: mock_create_rpc.return_value = "test-subscription-id" snuba_query = create_snuba_query( @@ -314,7 +314,7 @@ def test_translate_alert_rule_apdex_float(self, mock_create_rpc) -> None: @with_feature("organizations:migrate-transaction-alerts-to-spans") @patch("sentry.snuba.tasks._create_rpc_in_snuba") - def test_translate_alert_rule_empty_query(self, mock_create_rpc) -> None: + def test_translate_alert_rule_empty_query(self, mock_create_rpc: MagicMock) -> None: mock_create_rpc.return_value = "test-subscription-id" snuba_query = create_snuba_query( @@ -375,7 +375,7 @@ def test_translate_alert_rule_empty_query(self, mock_create_rpc) -> None: @patch("sentry.snuba.tasks._create_snql_in_snuba") @patch("sentry.snuba.tasks._create_rpc_in_snuba") def test_rollback_alert_rule_query( - self, mock_create_rpc, mock_create_snql, mock_delete + self, mock_create_rpc: MagicMock, mock_create_snql: MagicMock, mock_delete: MagicMock ) -> None: mock_create_rpc.return_value = "test-subscription-id" mock_create_snql.return_value = "rollback-subscription-id" @@ -527,11 +527,11 @@ def test_rollback_without_snapshot(self) -> None: ) def test_rollback_anomaly_detection_alert( self, - mock_seer_request, - mock_seer, - mock_create_rpc, - mock_create_snql, - mock_delete, + mock_seer_request: MagicMock, + mock_seer: MagicMock, + mock_create_rpc: MagicMock, + mock_create_snql: MagicMock, + mock_delete: MagicMock, ) -> None: from sentry.workflow_engine.models.data_condition import Condition @@ -628,7 +628,7 @@ def test_rollback_anomaly_detection_alert( @with_feature("organizations:migrate-transaction-alerts-to-spans") @patch("sentry.snuba.tasks._create_rpc_in_snuba") - def test_extrapolation_mode_sum_transactions(self, mock_create_rpc) -> None: + def test_extrapolation_mode_sum_transactions(self, mock_create_rpc: MagicMock) -> None: mock_create_rpc.return_value = "test-subscription-id" snuba_query = create_snuba_query( @@ -677,7 +677,9 @@ def test_extrapolation_mode_sum_transactions(self, mock_create_rpc) -> None: @with_feature("organizations:migrate-transaction-alerts-to-spans") @patch("sentry.snuba.tasks._create_rpc_in_snuba") - def test_extrapolation_mode_avg_custom_measurement_transactions(self, mock_create_rpc) -> None: + def test_extrapolation_mode_avg_custom_measurement_transactions( + self, mock_create_rpc: MagicMock + ) -> None: mock_create_rpc.return_value = "test-subscription-id" snuba_query = create_snuba_query( @@ -727,7 +729,7 @@ def test_extrapolation_mode_avg_custom_measurement_transactions(self, mock_creat @with_feature("organizations:migrate-transaction-alerts-to-spans") @patch("sentry.snuba.tasks._create_rpc_in_snuba") - def test_count_custom_measurement_transactions(self, mock_create_rpc) -> None: + def test_count_custom_measurement_transactions(self, mock_create_rpc: MagicMock) -> None: mock_create_rpc.return_value = "test-subscription-id" snuba_query = create_snuba_query( @@ -778,7 +780,7 @@ def test_count_custom_measurement_transactions(self, mock_create_rpc) -> None: @with_feature("organizations:migrate-transaction-alerts-to-spans") @patch("sentry.snuba.tasks._create_in_snuba") - def test_avg_custom_measurement_transactions(self, mock_create_in_snuba) -> None: + def test_avg_custom_measurement_transactions(self, mock_create_in_snuba: MagicMock) -> None: mock_create_in_snuba.return_value = "test-subscription-id" snuba_query = create_snuba_query( @@ -829,7 +831,7 @@ def test_avg_custom_measurement_transactions(self, mock_create_in_snuba) -> None @with_feature("organizations:migrate-transaction-alerts-to-spans") @patch("sentry.snuba.tasks._create_rpc_in_snuba") - def test_extrapolation_mode_count_if_transactions(self, mock_create_rpc) -> None: + def test_extrapolation_mode_count_if_transactions(self, mock_create_rpc: MagicMock) -> None: mock_create_rpc.return_value = "test-subscription-id" snuba_query = create_snuba_query( @@ -878,7 +880,7 @@ def test_extrapolation_mode_count_if_transactions(self, mock_create_rpc) -> None @with_feature("organizations:migrate-transaction-alerts-to-spans") @patch("sentry.snuba.tasks._create_rpc_in_snuba") - def test_extrapolation_mode_p50_transactions(self, mock_create_rpc) -> None: + def test_extrapolation_mode_p50_transactions(self, mock_create_rpc: MagicMock) -> None: mock_create_rpc.return_value = "test-subscription-id" snuba_query = create_snuba_query( @@ -927,7 +929,7 @@ def test_extrapolation_mode_p50_transactions(self, mock_create_rpc) -> None: @with_feature("organizations:migrate-transaction-alerts-to-spans") @patch("sentry.snuba.tasks._create_rpc_in_snuba") - def test_eap_query_with_valid_event_types(self, mock_create_rpc) -> None: + def test_eap_query_with_valid_event_types(self, mock_create_rpc: MagicMock) -> None: mock_create_rpc.return_value = "test-subscription-id" snuba_query = create_snuba_query( @@ -989,7 +991,7 @@ def test_eap_query_with_valid_event_types(self, mock_create_rpc) -> None: @with_feature("organizations:migrate-transaction-alerts-to-spans") @patch("sentry.snuba.tasks._create_rpc_in_snuba") - def test_eap_query_invalid_event_types(self, mock_create_rpc) -> None: + def test_eap_query_invalid_event_types(self, mock_create_rpc: MagicMock) -> None: mock_create_rpc.return_value = "test-subscription-id" snuba_query = create_snuba_query( @@ -1052,7 +1054,7 @@ def test_eap_query_invalid_event_types(self, mock_create_rpc) -> None: @with_feature("organizations:migrate-transaction-alerts-to-spans") @patch("sentry.snuba.tasks._create_rpc_in_snuba") - def test_rollback_skips_user_updated_query(self, mock_create_rpc) -> None: + def test_rollback_skips_user_updated_query(self, mock_create_rpc: MagicMock) -> None: mock_create_rpc.return_value = "test-subscription-id" snuba_query = create_snuba_query( @@ -1112,7 +1114,7 @@ def test_rollback_skips_user_updated_query(self, mock_create_rpc) -> None: @patch("sentry.snuba.tasks._create_snql_in_snuba") @patch("sentry.snuba.tasks._create_rpc_in_snuba") def test_bypass_flag_check_rollback( - self, mock_create_rpc, mock_create_snql, mock_delete + self, mock_create_rpc: MagicMock, mock_create_snql: MagicMock, mock_delete: MagicMock ) -> None: mock_create_rpc.return_value = "test-subscription-id" mock_create_snql.return_value = "rollback-subscription-id" diff --git a/tests/sentry/explore/translation/test_am1_metrics_to_transactions.py b/tests/sentry/explore/translation/test_am1_metrics_to_transactions.py index 9bdb28502d22..c1fd70faf19f 100644 --- a/tests/sentry/explore/translation/test_am1_metrics_to_transactions.py +++ b/tests/sentry/explore/translation/test_am1_metrics_to_transactions.py @@ -15,6 +15,8 @@ from sentry.snuba.subscriptions import create_snuba_query from sentry.testutils.cases import SnubaTestCase, TestCase from sentry.testutils.helpers.features import with_feature +from sentry.workflow_engine.models.data_source import DataSource +from sentry.workflow_engine.models.detector import Detector pytestmark = pytest.mark.sentry_metrics @@ -42,7 +44,9 @@ def _create_snuba_query( resolution=timedelta(minutes=1), ) - def _setup_detector(self, snuba_query: SnubaQuery): + def _setup_detector( + self, snuba_query: SnubaQuery + ) -> tuple[QuerySubscription, DataSource, Detector]: """Creates a QuerySubscription, DataSource, and Detector linked to the given snuba_query.""" query_subscription = QuerySubscription.objects.create( project=self.project, diff --git a/tests/sentry/explore/translation/test_discover_translation.py b/tests/sentry/explore/translation/test_discover_translation.py index 704aaeadfca8..20a89b258454 100644 --- a/tests/sentry/explore/translation/test_discover_translation.py +++ b/tests/sentry/explore/translation/test_discover_translation.py @@ -1,3 +1,5 @@ +from typing import Any + import pytest from sentry.discover.models import DiscoverSavedQuery, DiscoverSavedQueryTypes @@ -10,7 +12,13 @@ class DiscoverToExploreTranslationTest(TestCase): - def create_discover_query(self, name: str, query: dict, explore_query=None, is_homepage=False): + def create_discover_query( + self, + name: str, + query: dict[str, Any], + explore_query: ExploreSavedQuery | None = None, + is_homepage: bool = False, + ) -> DiscoverSavedQuery: discover_saved_query = DiscoverSavedQuery.objects.create( organization=self.org, created_by_id=self.user.id, diff --git a/tests/sentry/features/test_flagpole_context.py b/tests/sentry/features/test_flagpole_context.py index b72e9b35040f..8d35174df92f 100644 --- a/tests/sentry/features/test_flagpole_context.py +++ b/tests/sentry/features/test_flagpole_context.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, cast import pytest from django.contrib.auth.models import AnonymousUser @@ -12,6 +12,7 @@ ) from sentry.hybridcloud.services.organization_mapping import organization_mapping_service from sentry.models.organizationmapping import OrganizationMapping +from sentry.models.project import Project from sentry.organizations.services.organization import organization_service from sentry.service_accounts.principal import ServiceAccountPrincipal from sentry.silo.base import SiloMode @@ -112,7 +113,10 @@ def test_with_invalid_project_passed(self) -> None: project_context_transformer(SentryContextData(project=123)) # type: ignore[arg-type] with pytest.raises(InvalidContextDataException): - project_context_transformer(SentryContextData(project=self.create_organization())) + # justification: deliberately wrong project type to exercise runtime validation. + project_context_transformer( + SentryContextData(project=cast("Project", self.create_organization())) + ) def test_with_valid_project(self) -> None: project = self.create_project(slug="foobar", name="Foo Bar", platform="php") diff --git a/tests/sentry/features/test_manager.py b/tests/sentry/features/test_manager.py index d0825067bbfc..74a0608a6d58 100644 --- a/tests/sentry/features/test_manager.py +++ b/tests/sentry/features/test_manager.py @@ -1,4 +1,5 @@ -from typing import Any +from collections.abc import Sequence +from typing import Any, cast from unittest import mock from django.conf import settings @@ -13,11 +14,17 @@ ProjectFeature, SystemFeature, ) +from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.organizations.services.organization.model import RpcOrganization from sentry.testutils.cases import TestCase from sentry.testutils.helpers.options import override_options from sentry.users.models.user import User from sentry.users.services.user import RpcUser +FeatureEntity = Organization | RpcOrganization | User | RpcUser | AnonymousUser | None +FeatureActor = User | RpcUser | AnonymousUser | None + class MockBatchHandler(features.BatchFeatureHandler): features = {"auth:register", "organizations:feature", "projects:feature"} @@ -31,8 +38,15 @@ def has( ) -> bool: return True - def batch_has(self, feature_names, *args: Any, projects=None, organization=None, **kwargs: Any): - feature_results = { + def batch_has( + self, + feature_names: Sequence[str], + *args: Any, + projects: Sequence[Project] | None = None, + organization: Organization | RpcOrganization | None = None, + **kwargs: Any, + ) -> dict[str, dict[str, bool | None]] | None: + feature_results: dict[str, bool | None] = { feature_name: True for feature_name in feature_names if feature_name in self.features } @@ -44,10 +58,14 @@ def batch_has(self, feature_names, *args: Any, projects=None, organization=None, return {"unscoped": feature_results} - def _check_for_batch(self, feature_name, organization, actor): + def _check_for_batch( + self, feature_name: str, organization: FeatureEntity, actor: FeatureActor + ) -> bool | None: raise NotImplementedError - def batch_has_for_organizations(self, feature_name, organizations) -> dict[str, bool]: + def batch_has_for_organizations( + self, feature_name: str, organizations: Sequence[Organization | RpcOrganization] + ) -> dict[str, bool]: results: dict[str, bool] = {} for org in organizations: entity_key = f"organization:{org.id}" @@ -58,10 +76,40 @@ def batch_has_for_organizations(self, feature_name, organizations) -> dict[str, class MockUserBatchHandler(features.BatchFeatureHandler): features = {"users:feature"} - def _check_for_batch(self, feature_name, user, actor): - return user.name == "steve" + def _check_for_batch( + self, feature_name: str, user: FeatureEntity, actor: FeatureActor + ) -> bool | None: + return cast("User | RpcUser", user).name == "steve" + + def batch_has(self, *a: Any, **k: Any) -> dict[str, dict[str, bool | None]] | None: + raise NotImplementedError("unreachable") + - def batch_has(self, *a, **k): +class OrganizationBatchHandler(features.BatchFeatureHandler): + def __init__( + self, + flags: Sequence[str], + result: bool | None, + expected_org: Organization, + expected_actor: User, + ) -> None: + self.features = set(flags) + self.result = result + self.expected_org = expected_org + self.expected_actor = expected_actor + self.hit_counter = 0 + + def _check_for_batch( + self, feature_name: str, organization: FeatureEntity, actor: FeatureActor + ) -> bool | None: + assert feature_name in self.features + assert organization == self.expected_org + assert actor == self.expected_actor + + self.hit_counter += 1 + return self.result + + def batch_has(self, *a: Any, **k: Any) -> dict[str, dict[str, bool | None]] | None: raise NotImplementedError("unreachable") @@ -121,26 +169,27 @@ def test_handlers(self) -> None: class TestProjectHandler(features.FeatureHandler): features = {project_flag} - def __init__(self, true_set, false_set): + def __init__(self, true_set: Sequence[Project], false_set: Sequence[Project]) -> None: self.true_set = frozenset(true_set) self.false_set = frozenset(false_set) def has( self, - feature, - actor, + feature: Feature, + actor: FeatureActor, skip_entity: bool | None = False, skip_experiment_exposure: bool = False, - ): + ) -> bool | None: assert actor == test_user - if feature.project in self.true_set: + project = cast(ProjectFeature, feature).project + if project in self.true_set: return True - if feature.project in self.false_set: + if project in self.false_set: return False return None - def batch_has(self, *a, **k): + def batch_has(self, *a: Any, **k: Any) -> dict[str, dict[str, bool | None]] | None: raise NotImplementedError("unreachable") p1 = self.create_project() @@ -204,7 +253,8 @@ def test_entity_handler(self) -> None: # The entity_handler doesn't have a response for this feature either, so settings should be checked instead entity_handler.has.return_value = None with mock.patch.dict(settings.SENTRY_FEATURES, {"organizations:settings-feature": "test"}): - assert manager.has("organizations:settings-feature", test_org) == "test" + settings_result: object = manager.has("organizations:settings-feature", test_org) + assert settings_result == "test" assert len(entity_handler.mock_calls) == 2 def test_entity_handler_has_capture_error(self) -> None: @@ -231,25 +281,8 @@ def test_has_for_batch(self) -> None: projects = [self.create_project(organization=test_org) for i in range(5)] - def create_handler(flags, result): - class OrganizationTestHandler(features.BatchFeatureHandler): - features = set(flags) - - def __init__(self): - self.hit_counter = 0 - - def _check_for_batch(self, feature_name, organization, actor): - assert feature_name in self.features - assert organization == test_org - assert actor == test_user - - self.hit_counter += 1 - return result - - def batch_has(self, *a, **k): - raise NotImplementedError("unreachable") - - return OrganizationTestHandler() + def create_handler(flags: Sequence[str], result: bool | None) -> OrganizationBatchHandler: + return OrganizationBatchHandler(flags, result, test_org, test_user) yes_flag = "organizations:yes" no_flag = "organizations:no" @@ -588,17 +621,22 @@ class NoBatchOrgHandler(features.BatchFeatureHandler): def has( self, - feature, - actor, + feature: Feature, + actor: FeatureActor, skip_entity: bool | None = False, skip_experiment_exposure: bool = False, - ): + ) -> bool | None: return feature.name in self.features def batch_has( - self, feature_names, *args: Any, projects=None, organization=None, **kwargs: Any - ): - feature_results = { + self, + feature_names: Sequence[str], + *args: Any, + projects: Sequence[Project] | None = None, + organization: Organization | RpcOrganization | None = None, + **kwargs: Any, + ) -> dict[str, dict[str, bool | None]] | None: + feature_results: dict[str, bool | None] = { feature_name: True for feature_name in feature_names if feature_name in self.features @@ -612,7 +650,9 @@ def batch_has( return {"unscoped": feature_results} - def _check_for_batch(self, feature_name, organization, actor): + def _check_for_batch( + self, feature_name: str, organization: FeatureEntity, actor: FeatureActor + ) -> bool | None: raise NotImplementedError manager = features.FeatureManager() diff --git a/tests/sentry/feedback/__init__.py b/tests/sentry/feedback/__init__.py index ebafa3b7e6ca..a46802b70ce9 100644 --- a/tests/sentry/feedback/__init__.py +++ b/tests/sentry/feedback/__init__.py @@ -50,10 +50,10 @@ def mock_feedback_event( class MockSeerResponse: - def __init__(self, status: int, json_data: dict): + def __init__(self, status: int, json_data: object) -> None: self.status = status self.json_data = json_data self.data = json.dumps(json_data) - def json(self): + def json(self) -> object: return self.json_data diff --git a/tests/sentry/feedback/endpoints/test_error_page_embed.py b/tests/sentry/feedback/endpoints/test_error_page_embed.py index 188bf37f5daf..79d8f43ebfe7 100644 --- a/tests/sentry/feedback/endpoints/test_error_page_embed.py +++ b/tests/sentry/feedback/endpoints/test_error_page_embed.py @@ -1,6 +1,7 @@ import logging from collections.abc import Callable from unittest import mock +from unittest.mock import MagicMock from urllib.parse import quote, urlencode, urljoin, urlparse from uuid import uuid4 @@ -20,6 +21,7 @@ from sentry.models.project import Project from sentry.models.projectkey import ProjectKey from sentry.models.userreport import UserReport +from sentry.services.eventstore.models import Event from sentry.silo.base import SiloLimit, SiloMode from sentry.testutils.cases import TestCase from sentry.testutils.helpers.apigateway import ApiGatewayTestCase @@ -244,7 +246,7 @@ def setUp(self) -> None: ) self.environment.add_project(self.project) - def make_event(self, **kwargs): + def make_event(self, **kwargs: object) -> Event: min_ago = before_now(minutes=1).isoformat() result = { "event_id": "a" * 32, @@ -295,8 +297,8 @@ def test_calls_feedback_shim_if_ff_enabled( @mock.patch("sentry.feedback.usecases.ingest.create_feedback.produce_occurrence_to_kafka") def test_does_not_call_feedback_shim_no_event_if_ff_enabled( - self, mock_produce_occurrence_to_kafka - ): + self, mock_produce_occurrence_to_kafka: MagicMock + ) -> None: self.client.post( self.path, { @@ -413,7 +415,10 @@ def setUp(self) -> None: @property def app_host(self) -> str: - return urlparse(options.get("system.url-prefix")).netloc + host = urlparse(options.get("system.url-prefix")).netloc + if not isinstance(host, str) or not host: + raise TypeError("system.url-prefix has no hostname") + return host def _cell_dsn(self, project_key: ProjectKey, cell: Cell, port: int | None = None) -> str: """Build a cell-style ingest DSN (``o{org_id}.ingest.{cell}.{app_host}``).""" diff --git a/tests/sentry/feedback/endpoints/test_organization_user_reports.py b/tests/sentry/feedback/endpoints/test_organization_user_reports.py index 80f7b5eae0d4..dde18b067aa3 100644 --- a/tests/sentry/feedback/endpoints/test_organization_user_reports.py +++ b/tests/sentry/feedback/endpoints/test_organization_user_reports.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock, patch @@ -67,7 +68,7 @@ def setUp(self) -> None: date_added=datetime.now(UTC) - timedelta(days=7), ) - def run_test(self, expected, **params): + def run_test(self, expected: Sequence[UserReport], **params: object) -> None: response = self.get_response(self.project_1.organization.slug, **params) assert response.status_code == 200, response.content @@ -175,6 +176,7 @@ def test_event_retention(self, mock_get_event_retention: MagicMock) -> None: }, project_id=self.project_1.id, ) + assert old_event.group is not None UserReport.objects.create( project_id=self.project_1.id, event_id=old_event.event_id, diff --git a/tests/sentry/feedback/endpoints/test_project_user_reports.py b/tests/sentry/feedback/endpoints/test_project_user_reports.py index 5f6cd9810d56..8cffdce50706 100644 --- a/tests/sentry/feedback/endpoints/test_project_user_reports.py +++ b/tests/sentry/feedback/endpoints/test_project_user_reports.py @@ -4,9 +4,10 @@ from django.utils import timezone -from sentry.models.group import GroupStatus +from sentry.models.group import Group, GroupStatus from sentry.models.project import Project from sentry.models.userreport import UserReport +from sentry.services.eventstore.models import Event from sentry.testutils.cases import APITestCase, SnubaTestCase from sentry.testutils.helpers.datetime import before_now @@ -15,6 +16,11 @@ def _make_url(project: Project) -> str: return f"/api/0/projects/{project.organization.slug}/{project.slug}/user-feedback/" +def _group(event: Event) -> Group: + assert event.group is not None + return event.group + + class ProjectUserReportListTest(APITestCase, SnubaTestCase): def setUp(self) -> None: super().setUp() @@ -44,7 +50,7 @@ def setUp(self) -> None: name="Foo", email="foo@example.com", comments="Hello world", - group_id=self.event.group.id, + group_id=_group(self.event).id, ) self.report2 = UserReport.objects.create( project_id=self.project.id, @@ -52,7 +58,7 @@ def setUp(self) -> None: name="Foo", email="foo@example.com", comments="Hello world", - group_id=self.event.group.id, + group_id=_group(self.event).id, ) def test_simple(self) -> None: @@ -67,7 +73,7 @@ def test_simple(self) -> None: }, project_id=project.id, ) - group = event1.group + group = _group(event1) event2 = self.store_event( data={ "timestamp": timezone.now().isoformat(), @@ -76,7 +82,7 @@ def test_simple(self) -> None: }, project_id=project.id, ) - group2 = event2.group + group2 = _group(event2) group2.status = GroupStatus.RESOLVED group2.substatus = None group2.save() @@ -139,7 +145,7 @@ def test_all_reports(self) -> None: }, project_id=project.id, ) - group = event.group + group = _group(event) report_1 = UserReport.objects.create( project_id=project.id, event_id="a" * 32, @@ -226,7 +232,7 @@ def test_event_retention(self, mock_get_event_retention: MagicMock) -> None: project_id=self.project.id, event_id=old_event.event_id, environment_id=self.environment.id, - group_id=old_event.group.id, + group_id=_group(old_event).id, date_added=before_now(days=1), ) response = self.client.get(_make_url(self.project)) @@ -274,7 +280,7 @@ def test_simple(self) -> None: report = UserReport.objects.get(id=response.data["id"]) assert report.project_id == self.project.id - assert report.group_id == self.event.group.id + assert report.group_id == _group(self.event).id assert report.email == "foo@example.com" assert report.name == "Foo Bar" assert report.comments == "It broke!" @@ -321,7 +327,7 @@ def test_already_present(self) -> None: self.login_as(user=self.user) UserReport.objects.create( - group_id=self.event.group.id, + group_id=_group(self.event).id, project_id=self.project.id, event_id=self.event.event_id, name="foo", @@ -345,7 +351,7 @@ def test_already_present(self) -> None: report = UserReport.objects.get(id=response.data["id"]) assert report.project_id == self.project.id - assert report.group_id == self.event.group.id + assert report.group_id == _group(self.event).id assert report.email == "foo@example.com" assert report.name == "Foo Bar" assert report.comments == "It broke!" @@ -354,7 +360,7 @@ def test_already_present_after_deadline(self) -> None: self.login_as(user=self.user) UserReport.objects.create( - group_id=self.old_event.group.id, + group_id=_group(self.old_event).id, project_id=self.project.id, event_id=self.old_event.event_id, name="foo", @@ -446,7 +452,7 @@ def test_simple_shim_to_feedback(self, mock_produce_occurrence_to_kafka: MagicMo report = UserReport.objects.get(id=response.data["id"]) assert report.project_id == self.project.id - assert report.group_id == event_with_replay.group.id + assert report.group_id == _group(event_with_replay).id assert report.email == "foo@example.com" assert report.name == "Foo Bar" assert report.comments == "It broke!" @@ -473,8 +479,8 @@ def test_simple_shim_to_feedback(self, mock_produce_occurrence_to_kafka: MagicMo @patch("sentry.feedback.usecases.ingest.create_feedback.produce_occurrence_to_kafka") def test_simple_shim_to_feedback_no_event_should_not_call( - self, mock_produce_occurrence_to_kafka - ): + self, mock_produce_occurrence_to_kafka: MagicMock + ) -> None: self.login_as(user=self.user) url = _make_url(self.project) diff --git a/tests/sentry/feedback/lib/test_feedback_utils.py b/tests/sentry/feedback/lib/test_feedback_utils.py index 49a9bb0808da..859c3bab39c9 100644 --- a/tests/sentry/feedback/lib/test_feedback_utils.py +++ b/tests/sentry/feedback/lib/test_feedback_utils.py @@ -1,9 +1,16 @@ +from collections.abc import Callable +from contextlib import AbstractContextManager + from sentry.feedback.lib.utils import is_in_feedback_denylist +from sentry.models.project import Project from sentry.testutils.pytest.fixtures import django_db_all @django_db_all -def test_denylist(set_sentry_option, default_project) -> None: +def test_denylist( + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], + default_project: Project, +) -> None: with set_sentry_option( "feedback.organizations.slug-denylist", [default_project.organization.slug] ): @@ -11,6 +18,9 @@ def test_denylist(set_sentry_option, default_project) -> None: @django_db_all -def test_denylist_not_in_list(set_sentry_option, default_project) -> None: +def test_denylist_not_in_list( + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], + default_project: Project, +) -> None: with set_sentry_option("feedback.organizations.slug-denylist", ["not-in-list"]): assert is_in_feedback_denylist(default_project.organization) is False diff --git a/tests/sentry/feedback/tasks/test_update_user_reports.py b/tests/sentry/feedback/tasks/test_update_user_reports.py index 3ce518be7b6a..640cde111e4f 100644 --- a/tests/sentry/feedback/tasks/test_update_user_reports.py +++ b/tests/sentry/feedback/tasks/test_update_user_reports.py @@ -5,6 +5,7 @@ from sentry.feedback.tasks.update_user_reports import update_user_reports from sentry.models.userreport import UserReport +from sentry.services.eventstore.models import Event from sentry.testutils.cases import TestCase from sentry.testutils.skips import requires_snuba from sentry.utils.dates import epoch @@ -15,7 +16,7 @@ class UpdateUserReportTest(TestCase): def create_event_and_report( self, project_id: int, event_dt: datetime | None = None, report_dt: datetime | None = None - ): + ) -> tuple[Event, UserReport]: event_dt = event_dt or timezone.now() report_dt = report_dt or timezone.now() event = self.store_event(data={"timestamp": event_dt.isoformat()}, project_id=project_id) @@ -170,8 +171,8 @@ def test_calls_feedback_shim_if_ff_enabled( @patch("sentry.feedback.usecases.ingest.create_feedback.produce_occurrence_to_kafka") def test_does_not_call_feedback_shim_if_environment_is_set( - self, mock_produce_occurrence_to_kafka - ): + self, mock_produce_occurrence_to_kafka: MagicMock + ) -> None: project = self.create_project() event1 = self.store_event( data={ diff --git a/tests/sentry/feedback/usecases/ingest/test_create_feedback.py b/tests/sentry/feedback/usecases/ingest/test_create_feedback.py index 00dfcd847523..0f3f4d6e2c7a 100644 --- a/tests/sentry/feedback/usecases/ingest/test_create_feedback.py +++ b/tests/sentry/feedback/usecases/ingest/test_create_feedback.py @@ -1,8 +1,10 @@ from __future__ import annotations +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager from datetime import UTC, datetime from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -18,6 +20,7 @@ MAX_AI_LABELS_JSON_LENGTH, ) from sentry.models.group import Group, GroupStatus +from sentry.models.project import Project from sentry.signals import first_feedback_received, first_new_feedback_received from sentry.testutils.pytest.fixtures import django_db_all from sentry.types.group import GroupSubStatus @@ -26,7 +29,7 @@ @pytest.fixture(autouse=True) -def mock_has_seer_access(): +def mock_has_seer_access() -> Iterator[MagicMock]: """ Auto mocks `has_seer_access` so it returns false by default. To enable, request the fixture and set mock_has_seer_access.return_value = True @@ -229,7 +232,7 @@ def test_corrected_still_works() -> None: @pytest.mark.parametrize("environment", ("missing", None, "", "my-environment")) -def test_fix_for_issue_platform_environment(environment) -> None: +def test_fix_for_issue_platform_environment(environment: str | None) -> None: event = mock_feedback_event(1) if environment == "missing": event.pop("environment", "") @@ -244,7 +247,9 @@ def test_fix_for_issue_platform_environment(environment) -> None: @django_db_all -def test_create_feedback_filters_unreal(default_project, mock_produce_occurrence_to_kafka) -> None: +def test_create_feedback_filters_unreal( + default_project: Project, mock_produce_occurrence_to_kafka: MagicMock +) -> None: event = { "project_id": 1, "request": { @@ -283,7 +288,9 @@ def test_create_feedback_filters_unreal(default_project, mock_produce_occurrence @django_db_all -def test_create_feedback_filters_empty(default_project, mock_produce_occurrence_to_kafka) -> None: +def test_create_feedback_filters_empty( + default_project: Project, mock_produce_occurrence_to_kafka: MagicMock +) -> None: event = { "project_id": 1, "request": { @@ -357,8 +364,8 @@ def test_create_feedback_filters_empty(default_project, mock_produce_occurrence_ @django_db_all def test_create_feedback_filters_no_contexts_or_message( - default_project, mock_produce_occurrence_to_kafka -): + default_project: Project, mock_produce_occurrence_to_kafka: MagicMock +) -> None: event_no_context = { "project_id": 1, "request": { @@ -468,15 +475,15 @@ def test_create_feedback_filters_no_contexts_or_message( @patch("sentry.feedback.usecases.ingest.create_feedback.spam_detection_enabled") @patch("sentry.feedback.usecases.ingest.create_feedback.is_spam_seer") def test_create_feedback_spam_detection_kafka_and_evidence( - mock_is_spam_seer, - mock_spam_detection_enabled, - default_project, - mock_produce_occurrence_to_kafka, - input_message, - enabled, - expected_result, - expected_evidence_display, -): + mock_is_spam_seer: MagicMock, + mock_spam_detection_enabled: MagicMock, + default_project: Project, + mock_produce_occurrence_to_kafka: MagicMock, + input_message: str, + enabled: object, + expected_result: object, + expected_evidence_display: object, +) -> None: mock_spam_detection_enabled.return_value = enabled if enabled: mock_is_spam_seer.return_value = expected_result @@ -517,7 +524,7 @@ def test_create_feedback_spam_detection_kafka_and_evidence( @patch("sentry.feedback.usecases.ingest.create_feedback.spam_detection_enabled", return_value=True) @patch("sentry.feedback.usecases.ingest.create_feedback.is_spam_seer", return_value=True) def test_create_feedback_spam_detection_set_status_ignored( - mock_is_spam_seer, mock_spam_detection_enabled, default_project + mock_is_spam_seer: MagicMock, mock_spam_detection_enabled: MagicMock, default_project: Project ) -> None: event = mock_feedback_event(default_project.id, message="This is definitely spam") @@ -530,8 +537,8 @@ def test_create_feedback_spam_detection_set_status_ignored( @django_db_all def test_create_feedback_evidence_associated_event_id( - default_project, mock_produce_occurrence_to_kafka -): + default_project: Project, mock_produce_occurrence_to_kafka: MagicMock +) -> None: event = { "project_id": default_project.id, "request": { @@ -582,8 +589,8 @@ def test_create_feedback_evidence_associated_event_id( @django_db_all def test_create_feedback_filters_invalid_associated_event_id( - default_project, mock_produce_occurrence_to_kafka -): + default_project: Project, mock_produce_occurrence_to_kafka: MagicMock +) -> None: event = { "project_id": default_project.id, "request": { @@ -624,7 +631,9 @@ def test_create_feedback_filters_invalid_associated_event_id( @django_db_all -def test_create_feedback_tags(default_project, mock_produce_occurrence_to_kafka) -> None: +def test_create_feedback_tags( + default_project: Project, mock_produce_occurrence_to_kafka: MagicMock +) -> None: """We want to surface these tags in the UI. We also use user.email for alert conditions.""" event = mock_feedback_event(default_project.id) event["user"]["email"] = "josh.ferge@sentry.io" @@ -658,8 +667,8 @@ def test_create_feedback_tags(default_project, mock_produce_occurrence_to_kafka) @django_db_all def test_create_feedback_tags_no_associated_event_id( - default_project, mock_produce_occurrence_to_kafka -): + default_project: Project, mock_produce_occurrence_to_kafka: MagicMock +) -> None: event = mock_feedback_event(default_project.id, dt=datetime.now(UTC)) create_feedback_issue(event, default_project, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE) @@ -674,7 +683,7 @@ def test_create_feedback_tags_no_associated_event_id( @django_db_all def test_create_feedback_tags_skips_email_if_empty( - default_project, mock_produce_occurrence_to_kafka + default_project: Project, mock_produce_occurrence_to_kafka: MagicMock ) -> None: event = mock_feedback_event(default_project.id) event["user"].pop("email", None) @@ -692,13 +701,13 @@ def test_create_feedback_tags_skips_email_if_empty( @patch("sentry.feedback.usecases.ingest.create_feedback.spam_detection_enabled") @patch("sentry.feedback.usecases.ingest.create_feedback.is_spam_seer", return_value=False) def test_create_feedback_filters_large_message( - mock_is_spam_seer, - mock_spam_detection_enabled, - default_project, - mock_produce_occurrence_to_kafka, - spam_enabled, - set_sentry_option, -): + mock_is_spam_seer: MagicMock, + mock_spam_detection_enabled: MagicMock, + default_project: Project, + mock_produce_occurrence_to_kafka: MagicMock, + spam_enabled: object, + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], +) -> None: """Large messages are filtered before spam detection and producing to kafka.""" mock_spam_detection_enabled.return_value = spam_enabled @@ -712,7 +721,9 @@ def test_create_feedback_filters_large_message( @django_db_all -def test_create_feedback_evidence_source(default_project, mock_produce_occurrence_to_kafka) -> None: +def test_create_feedback_evidence_source( + default_project: Project, mock_produce_occurrence_to_kafka: MagicMock +) -> None: """We need this evidence field in post process, to determine if we should send alerts.""" event = mock_feedback_event(default_project.id) source = FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE @@ -724,7 +735,9 @@ def test_create_feedback_evidence_source(default_project, mock_produce_occurrenc @django_db_all -def test_create_feedback_release(default_project, mock_produce_occurrence_to_kafka) -> None: +def test_create_feedback_release( + default_project: Project, mock_produce_occurrence_to_kafka: MagicMock +) -> None: event = mock_feedback_event(default_project.id) create_feedback_issue(event, default_project, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE) @@ -735,7 +748,7 @@ def test_create_feedback_release(default_project, mock_produce_occurrence_to_kaf @django_db_all -def test_create_feedback_issue_updates_project_flag(default_project) -> None: +def test_create_feedback_issue_updates_project_flag(default_project: Project) -> None: event = mock_feedback_event(default_project.id, dt=datetime.now(UTC)) with ( @@ -762,9 +775,9 @@ def test_create_feedback_issue_updates_project_flag(default_project) -> None: @django_db_all @patch("sentry.feedback.usecases.ingest.create_feedback.get_feedback_title") def test_create_feedback_issue_title( - mock_get_feedback_title, - default_project, - mock_produce_occurrence_to_kafka, + mock_get_feedback_title: MagicMock, + default_project: Project, + mock_produce_occurrence_to_kafka: MagicMock, ) -> None: """Test that create_feedback_issue uses the formatted feedback message title when seer access is disabled.""" long_message = "This is a very long feedback message that describes multiple issues with the application including performance problems, UI bugs, and various other concerns that users are experiencing" @@ -800,10 +813,10 @@ def test_create_feedback_issue_title( @django_db_all @patch("sentry.feedback.usecases.ingest.create_feedback.get_feedback_title") def test_create_feedback_issue_title_from_seer( - mock_get_feedback_title, - default_project, - mock_produce_occurrence_to_kafka, - mock_has_seer_access, + mock_get_feedback_title: MagicMock, + default_project: Project, + mock_produce_occurrence_to_kafka: MagicMock, + mock_has_seer_access: MagicMock, ) -> None: """Test that create_feedback_issue uses the generated title from Seer.""" mock_has_seer_access.return_value = True @@ -830,10 +843,10 @@ def test_create_feedback_issue_title_from_seer( @django_db_all @patch("sentry.feedback.usecases.title_generation.make_title_generation_request") def test_create_feedback_issue_title_does_not_throw( - mock_make_signed_seer_api_request, - default_project, - mock_produce_occurrence_to_kafka, - mock_has_seer_access, + mock_make_signed_seer_api_request: MagicMock, + default_project: Project, + mock_produce_occurrence_to_kafka: MagicMock, + mock_has_seer_access: MagicMock, ) -> None: """Test that the title falls back to message-based title if Seer call fails with network error.""" mock_has_seer_access.return_value = True @@ -847,10 +860,10 @@ def test_create_feedback_issue_title_does_not_throw( @django_db_all @patch("sentry.feedback.usecases.title_generation.make_title_generation_request") def test_create_feedback_issue_title_from_seer_skips_if_spam( - mock_make_signed_seer_api_request, - default_project, - mock_produce_occurrence_to_kafka, - mock_has_seer_access, + mock_make_signed_seer_api_request: MagicMock, + default_project: Project, + mock_produce_occurrence_to_kafka: MagicMock, + mock_has_seer_access: MagicMock, ) -> None: """Test title generation endpoint is not called if marked as spam.""" mock_has_seer_access.return_value = True @@ -869,7 +882,9 @@ def test_create_feedback_issue_title_from_seer_skips_if_spam( @django_db_all def test_create_feedback_adds_ai_labels( - default_project, mock_produce_occurrence_to_kafka, mock_has_seer_access + default_project: Project, + mock_produce_occurrence_to_kafka: MagicMock, + mock_has_seer_access: MagicMock, ) -> None: """Test that create_feedback_issue adds AI labels to tags when label generation succeeds.""" mock_has_seer_access.return_value = True @@ -877,7 +892,7 @@ def test_create_feedback_adds_ai_labels( event["contexts"]["feedback"]["message"] = "The login button is broken and the UI is slow" # This assumes that the maximum number of labels allowed is greater than 3 - def mock_generate_labels(*args, **kwargs): + def mock_generate_labels(*args: object, **kwargs: object) -> list[str]: return ["User Interface", "Authentication", "Performance"] with patch( @@ -903,7 +918,9 @@ def mock_generate_labels(*args, **kwargs): @django_db_all def test_create_feedback_handles_label_generation_errors( - default_project, mock_produce_occurrence_to_kafka, mock_has_seer_access + default_project: Project, + mock_produce_occurrence_to_kafka: MagicMock, + mock_has_seer_access: MagicMock, ) -> None: """Test that create_feedback_issue continues to work even when generate_labels raises an error.""" mock_has_seer_access.return_value = True @@ -911,7 +928,7 @@ def test_create_feedback_handles_label_generation_errors( event["contexts"]["feedback"]["message"] = "This is a valid feedback message" # Mock generate_labels to raise an exception - def mock_generate_labels(*args, **kwargs): + def mock_generate_labels(*args: object, **kwargs: object) -> list[str]: raise Exception("Label generation failed") with patch( @@ -934,7 +951,9 @@ def mock_generate_labels(*args, **kwargs): @django_db_all def test_create_feedback_truncates_ai_labels_max_list_length( - default_project, mock_produce_occurrence_to_kafka, mock_has_seer_access + default_project: Project, + mock_produce_occurrence_to_kafka: MagicMock, + mock_has_seer_access: MagicMock, ) -> None: """Test that create_feedback_issue truncates AI labels when more than MAX_AI_LABELS are returned. If the list of labels is longer than MAX_AI_LABELS_JSON_LENGTH characters, the list is truncated in this test to match the intended behaviour.""" mock_has_seer_access.return_value = True @@ -945,7 +964,7 @@ def test_create_feedback_truncates_ai_labels_max_list_length( # Mock generate_labels to return more than MAX_AI_LABELS labels # The labels should be sorted alphabetically, so don't store numbers, instead use letters - def mock_generate_labels(*args, **kwargs): + def mock_generate_labels(*args: object, **kwargs: object) -> list[str]: return [f"{alphabet[i]}" for i in range(MAX_AI_LABELS + 5)] with patch( @@ -985,7 +1004,9 @@ def mock_generate_labels(*args, **kwargs): @django_db_all def test_create_feedback_truncates_ai_labels_max_json_length( - default_project, mock_produce_occurrence_to_kafka, mock_has_seer_access + default_project: Project, + mock_produce_occurrence_to_kafka: MagicMock, + mock_has_seer_access: MagicMock, ) -> None: """Test that create_feedback_issue truncates AI labels when the serialized list of labels is longer than MAX_AI_LABELS_JSON_LENGTH characters.""" mock_has_seer_access.return_value = True @@ -994,7 +1015,7 @@ def test_create_feedback_truncates_ai_labels_max_json_length( event["contexts"]["feedback"]["message"] = "This is a very complex feedback with many issues" # The serialized list of labels should be longer than MAX_AI_LABELS_JSON_LENGTH characters, so we should only take the first item - def mock_generate_labels(*args, **kwargs): + def mock_generate_labels(*args: object, **kwargs: object) -> list[str]: return ["a" * (MAX_AI_LABELS_JSON_LENGTH - 50)] * 100 with patch( diff --git a/tests/sentry/feedback/usecases/ingest/test_save_event_feedback.py b/tests/sentry/feedback/usecases/ingest/test_save_event_feedback.py index 5c053d1c5b28..30230111ede0 100644 --- a/tests/sentry/feedback/usecases/ingest/test_save_event_feedback.py +++ b/tests/sentry/feedback/usecases/ingest/test_save_event_feedback.py @@ -1,12 +1,15 @@ import uuid +from collections.abc import Iterator from datetime import UTC, datetime from unittest import mock +from unittest.mock import MagicMock import pytest from sentry.feedback.lib.utils import FeedbackCreationSource from sentry.feedback.usecases.ingest.save_event_feedback import save_event_feedback from sentry.models.environment import Environment, EnvironmentProject +from sentry.models.project import Project from sentry.models.userreport import UserReport from sentry.testutils.factories import Factories from sentry.testutils.pytest.fixtures import django_db_all @@ -14,7 +17,7 @@ @pytest.fixture -def mock_create_feedback_issue(): +def mock_create_feedback_issue() -> Iterator[MagicMock]: with mock.patch( "sentry.feedback.usecases.ingest.save_event_feedback.create_feedback_issue" ) as m: @@ -23,7 +26,7 @@ def mock_create_feedback_issue(): @django_db_all def test_save_event_feedback_no_associated_event( - default_project, mock_create_feedback_issue + default_project: Project, mock_create_feedback_issue: MagicMock ) -> None: event_data = mock_feedback_event(default_project.id) mock_create_feedback_issue.return_value = None @@ -42,8 +45,8 @@ def test_save_event_feedback_no_associated_event( ["number", "iso"], ) def test_save_event_feedback_with_associated_event( - default_project, mock_create_feedback_issue, timestamp_format: str -): + default_project: Project, mock_create_feedback_issue: MagicMock, timestamp_format: str +) -> None: environment = Factories.create_environment(default_project, name="production") event_id = uuid.uuid4().hex assoc_event = Factories.store_event( @@ -85,9 +88,9 @@ def test_save_event_feedback_with_associated_event( @django_db_all def test_save_event_feedback_with_unprocessed_associated_event( - default_project, - mock_create_feedback_issue, -): + default_project: Project, + mock_create_feedback_issue: MagicMock, +) -> None: environment = Factories.create_environment(default_project, name="production") event_data = mock_feedback_event(default_project.id) @@ -118,7 +121,7 @@ def test_save_event_feedback_with_unprocessed_associated_event( @django_db_all def test_save_event_feedback_with_missing_fields( - default_project, mock_create_feedback_issue + default_project: Project, mock_create_feedback_issue: MagicMock ) -> None: environment = Factories.create_environment(default_project, name="production") @@ -153,7 +156,7 @@ def test_save_event_feedback_with_missing_fields( @django_db_all def test_save_event_feedback_creates_missing_environment( - default_project, mock_create_feedback_issue + default_project: Project, mock_create_feedback_issue: MagicMock ) -> None: event_data = mock_feedback_event(default_project.id) event_data["contexts"]["feedback"]["associated_event_id"] = "abcd" * 8 diff --git a/tests/sentry/feedback/usecases/ingest/test_shim_to_feedback.py b/tests/sentry/feedback/usecases/ingest/test_shim_to_feedback.py index 84d9a8ca33aa..e5cc6ecf5cd7 100644 --- a/tests/sentry/feedback/usecases/ingest/test_shim_to_feedback.py +++ b/tests/sentry/feedback/usecases/ingest/test_shim_to_feedback.py @@ -1,3 +1,7 @@ +from unittest.mock import MagicMock + +from sentry.models.project import Project + """ shim_to_feedback unit tests. Integration testing is done in test_create_feedback, test_project_user_reports, test_post_process, and test_update_user_reports. """ @@ -16,8 +20,8 @@ @pytest.mark.parametrize("use_username", (False, True)) @django_db_all def test_shim_to_feedback_event_user_used_if_missing( - default_project, mock_produce_occurrence_to_kafka, use_username -): + default_project: Project, mock_produce_occurrence_to_kafka: MagicMock, use_username: object +) -> None: """Uses the error event's user context if user info is missing from the report.""" report_dict = { "comments": "Shim this", @@ -52,8 +56,8 @@ def test_shim_to_feedback_event_user_used_if_missing( @pytest.mark.parametrize("use_username", (False, True)) @django_db_all def test_shim_to_feedback_event_user_does_not_override_report( - default_project, mock_produce_occurrence_to_kafka, use_username -): + default_project: Project, mock_produce_occurrence_to_kafka: MagicMock, use_username: object +) -> None: """The report's user info should take precedence over the event.""" report_dict = { "name": "Andrew", @@ -88,7 +92,9 @@ def test_shim_to_feedback_event_user_does_not_override_report( @django_db_all -def test_shim_to_feedback_no_user_info(default_project, mock_produce_occurrence_to_kafka) -> None: +def test_shim_to_feedback_no_user_info( + default_project: Project, mock_produce_occurrence_to_kafka: MagicMock +) -> None: """User fields default to "" if not present.""" report_dict = { "comments": "Shim this", @@ -116,7 +122,7 @@ def test_shim_to_feedback_no_user_info(default_project, mock_produce_occurrence_ @django_db_all -def test_shim_to_feedback_fails_if_required_fields_missing(default_project) -> None: +def test_shim_to_feedback_fails_if_required_fields_missing(default_project: Project) -> None: # Email and comments are required to shim. Tests key errors are handled. report_dict = { "name": "andrew", diff --git a/tests/sentry/feedback/usecases/ingest/test_userreport.py b/tests/sentry/feedback/usecases/ingest/test_userreport.py index af87ce5c6e3e..e84b7639adcc 100644 --- a/tests/sentry/feedback/usecases/ingest/test_userreport.py +++ b/tests/sentry/feedback/usecases/ingest/test_userreport.py @@ -1,3 +1,6 @@ +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager +from typing import cast from unittest import mock import pytest @@ -6,12 +9,14 @@ from sentry.feedback.lib.types import UserReportDict from sentry.feedback.lib.utils import UNREAL_FEEDBACK_UNATTENDED_MESSAGE, FeedbackCreationSource from sentry.feedback.usecases.ingest.userreport import save_userreport, validate_user_report +from sentry.models.project import Project from sentry.models.userreport import UserReport +from sentry.services.eventstore.models import Event from sentry.testutils.factories import Factories from sentry.testutils.pytest.fixtures import django_db_all -def _mock_event(project_id: int, environment: str): +def _mock_event(project_id: int, environment: str) -> Event: return Factories.store_event( data={"timestamp": timezone.now().isoformat(), "environment": environment}, project_id=project_id, @@ -29,7 +34,7 @@ def mock_report_dict() -> UserReportDict: @pytest.fixture -def skip_denylist(): +def skip_denylist() -> Iterator[None]: with mock.patch( "sentry.feedback.usecases.ingest.userreport.is_in_feedback_denylist", return_value=False ): @@ -37,7 +42,7 @@ def skip_denylist(): @pytest.fixture -def skip_filters(): +def skip_filters() -> Iterator[None]: with mock.patch( "sentry.feedback.usecases.ingest.userreport.validate_user_report", return_value=(False, None, None), @@ -46,7 +51,7 @@ def skip_filters(): @pytest.fixture -def skip_eventstore(): +def skip_eventstore() -> Iterator[None]: with mock.patch("sentry.services.eventstore.backend.get_event_by_id", return_value=None): yield @@ -58,8 +63,10 @@ def skip_eventstore(): @django_db_all @pytest.mark.parametrize("field", ["comments", "event_id"]) -def test_validator_should_filter_missing_required_field(field, mock_report_dict) -> None: - del mock_report_dict[field] +def test_validator_should_filter_missing_required_field( + field: str, mock_report_dict: UserReportDict +) -> None: + del cast(dict[str, object], mock_report_dict)[field] should_filter, tag, reason = validate_user_report(mock_report_dict, 1) assert should_filter is True @@ -68,7 +75,9 @@ def test_validator_should_filter_missing_required_field(field, mock_report_dict) @django_db_all -def test_validator_should_filter_unreal_unattended_message_with_option(set_sentry_option) -> None: +def test_validator_should_filter_unreal_unattended_message_with_option( + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], +) -> None: with set_sentry_option("feedback.filter_garbage_messages", True): should_filter, tag, reason = validate_user_report( { @@ -86,7 +95,7 @@ def test_validator_should_filter_unreal_unattended_message_with_option(set_sentr @django_db_all def test_validator_should_not_filter_unreal_unattended_message_without_option( - set_sentry_option, + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], ) -> None: with set_sentry_option("feedback.filter_garbage_messages", False): should_filter, tag, reason = validate_user_report( @@ -104,7 +113,9 @@ def test_validator_should_not_filter_unreal_unattended_message_without_option( @django_db_all -def test_validator_should_filter_empty_message_with_option(set_sentry_option) -> None: +def test_validator_should_filter_empty_message_with_option( + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], +) -> None: with set_sentry_option("feedback.filter_garbage_messages", True): should_filter, tag, reason = validate_user_report( { @@ -121,7 +132,9 @@ def test_validator_should_filter_empty_message_with_option(set_sentry_option) -> @django_db_all -def test_validator_should_not_filter_empty_message_without_option(set_sentry_option) -> None: +def test_validator_should_not_filter_empty_message_without_option( + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], +) -> None: with set_sentry_option("feedback.filter_garbage_messages", False): should_filter, tag, reason = validate_user_report( { @@ -204,8 +217,12 @@ def test_validator_strips_comments_whitespace() -> None: @django_db_all def test_save_user_report_basic( - default_project, skip_denylist, skip_filters, skip_eventstore, mock_report_dict -): + default_project: Project, + skip_denylist: object, + skip_filters: object, + skip_eventstore: object, + mock_report_dict: UserReportDict, +) -> None: result = save_userreport( default_project, mock_report_dict, FeedbackCreationSource.USER_REPORT_ENVELOPE ) @@ -221,7 +238,9 @@ def test_save_user_report_basic( @django_db_all -def test_save_user_report_filters_denylist(default_project, skip_filters, mock_report_dict) -> None: +def test_save_user_report_filters_denylist( + default_project: Project, skip_filters: object, mock_report_dict: UserReportDict +) -> None: with mock.patch( "sentry.feedback.usecases.ingest.userreport.is_in_feedback_denylist", return_value=True ): @@ -235,9 +254,13 @@ def test_save_user_report_filters_denylist(default_project, skip_filters, mock_r @django_db_all @pytest.mark.parametrize("field", ["comments", "event_id"]) def test_save_user_report_filters_missing_required_field( - default_project, skip_denylist, skip_eventstore, mock_report_dict, field -): - del mock_report_dict[field] + default_project: Project, + skip_denylist: object, + skip_eventstore: object, + mock_report_dict: UserReportDict, + field: str, +) -> None: + del cast(dict[str, object], mock_report_dict)[field] result = save_userreport( default_project, mock_report_dict, FeedbackCreationSource.USER_REPORT_ENVELOPE @@ -248,7 +271,7 @@ def test_save_user_report_filters_missing_required_field( @django_db_all def test_save_user_report_missing_name_and_email( - default_project, skip_denylist, skip_eventstore + default_project: Project, skip_denylist: object, skip_eventstore: object ) -> None: report: UserReportDict = { "event_id": "a49558bf9bd94e2da4c9c3dc1b5b95f7", @@ -267,13 +290,17 @@ def test_save_user_report_missing_name_and_email( @django_db_all @pytest.mark.parametrize("field", ["name", "email", "comments"]) def test_save_user_report_filters_too_large_fields( - default_project, skip_denylist, skip_eventstore, mock_report_dict, field -): + default_project: Project, + skip_denylist: object, + skip_eventstore: object, + mock_report_dict: UserReportDict, + field: str, +) -> None: max_length = UserReport._meta.get_field(field).max_length # type: ignore[union-attr] if not max_length: assert False, f"Missing max_length for UserReport {field} field!" - mock_report_dict[field] = "a" * (max_length + 1) + cast(dict[str, object], mock_report_dict)[field] = "a" * (max_length + 1) result = save_userreport( default_project, mock_report_dict, FeedbackCreationSource.USER_REPORT_ENVELOPE @@ -284,8 +311,11 @@ def test_save_user_report_filters_too_large_fields( @django_db_all def test_save_user_report_shims_if_event_found( - default_project, skip_denylist, skip_filters, mock_report_dict -): + default_project: Project, + skip_denylist: object, + skip_filters: object, + mock_report_dict: UserReportDict, +) -> None: event = _mock_event(default_project.id, "prod") with ( mock.patch("sentry.services.eventstore.backend.get_event_by_id", return_value=event), @@ -303,8 +333,11 @@ def test_save_user_report_shims_if_event_found( @django_db_all def test_save_user_report_does_not_shim_if_event_found_but_source_is_new_feedback( - default_project, skip_denylist, skip_filters, mock_report_dict -): + default_project: Project, + skip_denylist: object, + skip_filters: object, + mock_report_dict: UserReportDict, +) -> None: event = _mock_event(default_project.id, "prod") with ( mock.patch("sentry.services.eventstore.backend.get_event_by_id", return_value=event), diff --git a/tests/sentry/feedback/usecases/test_label_generation.py b/tests/sentry/feedback/usecases/test_label_generation.py index 839034a4dea6..4105a7dc9257 100644 --- a/tests/sentry/feedback/usecases/test_label_generation.py +++ b/tests/sentry/feedback/usecases/test_label_generation.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest import requests @@ -8,7 +8,7 @@ @patch("sentry.feedback.usecases.label_generation.make_label_generation_request") -def test_generate_labels_success_response(mock_make_seer_request) -> None: +def test_generate_labels_success_response(mock_make_seer_request: MagicMock) -> None: mock_response = MockSeerResponse( 200, {"data": {"labels": ["User Interface", "Navigation", "Right Sidebar"]}}, @@ -31,7 +31,7 @@ def test_generate_labels_success_response(mock_make_seer_request) -> None: @patch("sentry.feedback.usecases.label_generation.make_label_generation_request") -def test_generate_labels_failed_response(mock_make_seer_request) -> None: +def test_generate_labels_failed_response(mock_make_seer_request: MagicMock) -> None: mock_response = MockSeerResponse( 500, {"error": "Internal Server Error"}, @@ -53,7 +53,7 @@ def test_generate_labels_failed_response(mock_make_seer_request) -> None: @patch("sentry.feedback.usecases.label_generation.make_label_generation_request") -def test_generate_labels_network_error(mock_make_seer_request) -> None: +def test_generate_labels_network_error(mock_make_seer_request: MagicMock) -> None: mock_make_seer_request.side_effect = requests.exceptions.Timeout("Request timed out") with pytest.raises(requests.exceptions.Timeout, match="Request timed out"): diff --git a/tests/sentry/feedback/usecases/test_spam_detection.py b/tests/sentry/feedback/usecases/test_spam_detection.py index 985bedebb14f..9b64b36282ed 100644 --- a/tests/sentry/feedback/usecases/test_spam_detection.py +++ b/tests/sentry/feedback/usecases/test_spam_detection.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -8,7 +8,7 @@ @pytest.mark.parametrize("response_is_spam", [True, False]) @patch("sentry.feedback.usecases.spam_detection.make_spam_detection_request") -def test_is_spam_seer_success(mock_make_seer_request, response_is_spam): +def test_is_spam_seer_success(mock_make_seer_request: MagicMock, response_is_spam: object) -> None: mock_make_seer_request.return_value = MockSeerResponse(200, {"is_spam": response_is_spam}) if response_is_spam: assert is_spam_seer("Test feedback message", 1) is True @@ -18,7 +18,7 @@ def test_is_spam_seer_success(mock_make_seer_request, response_is_spam): @patch("sentry.feedback.usecases.spam_detection.make_spam_detection_request") -def test_is_spam_seer_exception(mock_make_seer_request): +def test_is_spam_seer_exception(mock_make_seer_request: MagicMock) -> None: mock_make_seer_request.side_effect = Exception("Network error") assert is_spam_seer("Test feedback message", 1) is None mock_make_seer_request.assert_called_once() @@ -26,7 +26,7 @@ def test_is_spam_seer_exception(mock_make_seer_request): @pytest.mark.parametrize("status_code", [400, 401, 403, 404, 429, 500, 502, 503, 504]) @patch("sentry.feedback.usecases.spam_detection.make_spam_detection_request") -def test_is_spam_seer_http_error(mock_make_seer_request, status_code): +def test_is_spam_seer_http_error(mock_make_seer_request: MagicMock, status_code: int) -> None: mock_make_seer_request.return_value = MockSeerResponse(status_code, {}) assert is_spam_seer("Test feedback message", 1) is None mock_make_seer_request.assert_called_once() @@ -44,7 +44,9 @@ def test_is_spam_seer_http_error(mock_make_seer_request, status_code): ], ) @patch("sentry.feedback.usecases.spam_detection.make_spam_detection_request") -def test_is_spam_seer_invalid_response(mock_make_seer_request, invalid_response): +def test_is_spam_seer_invalid_response( + mock_make_seer_request: MagicMock, invalid_response: object +) -> None: mock_make_seer_request.return_value = MockSeerResponse(200, invalid_response) assert is_spam_seer("Test feedback message", 1) is None mock_make_seer_request.assert_called_once() diff --git a/tests/sentry/feedback/usecases/test_title_generation.py b/tests/sentry/feedback/usecases/test_title_generation.py index afbc8eaeb441..04de38a90d67 100644 --- a/tests/sentry/feedback/usecases/test_title_generation.py +++ b/tests/sentry/feedback/usecases/test_title_generation.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -43,8 +43,8 @@ ) @patch("sentry.feedback.usecases.title_generation.make_title_generation_request") def test_get_feedback_title_from_seer_invalid_response( - mock_make_seer_request, status_code, json_data -): + mock_make_seer_request: MagicMock, status_code: int, json_data: object +) -> None: """Test the get_feedback_title_from_seer function with various invalid responses.""" mock_response = MockSeerResponse(status_code, json_data) mock_make_seer_request.return_value = mock_response @@ -53,7 +53,7 @@ def test_get_feedback_title_from_seer_invalid_response( @patch("sentry.feedback.usecases.title_generation.make_title_generation_request") -def test_get_feedback_title_from_seer_http_error(mock_make_seer_request): +def test_get_feedback_title_from_seer_http_error(mock_make_seer_request: MagicMock) -> None: """Test the get_feedback_title_from_seer function with HTTP error response.""" mock_response = MockSeerResponse(500, {}) mock_make_seer_request.return_value = mock_response @@ -62,7 +62,7 @@ def test_get_feedback_title_from_seer_http_error(mock_make_seer_request): @patch("sentry.feedback.usecases.title_generation.make_title_generation_request") -def test_get_feedback_title_from_seer_exception(mock_make_seer_request): +def test_get_feedback_title_from_seer_exception(mock_make_seer_request: MagicMock) -> None: """Test the get_feedback_title_from_seer function with exception during API call.""" mock_make_seer_request.side_effect = Exception("Network error") assert get_feedback_title_from_seer("Login button broken", 123) is None @@ -70,7 +70,7 @@ def test_get_feedback_title_from_seer_exception(mock_make_seer_request): @patch("sentry.feedback.usecases.title_generation.make_title_generation_request") -def test_get_feedback_title_from_seer_success(mock_make_seer_request): +def test_get_feedback_title_from_seer_success(mock_make_seer_request: MagicMock) -> None: """Test the get_feedback_title_from_seer function with successful response.""" mock_response = MockSeerResponse(200, {"title": "Login Button Issue"}) mock_make_seer_request.return_value = mock_response diff --git a/tests/sentry/grouping/__init__.py b/tests/sentry/grouping/__init__.py index 6e66e1b5055f..e32e65f64184 100644 --- a/tests/sentry/grouping/__init__.py +++ b/tests/sentry/grouping/__init__.py @@ -316,7 +316,10 @@ def __init__(self, filename: str) -> None: @cached_property def data(self) -> MutableMapping[str, Any]: with open(path.join(FINGERPRINT_INPUTS_DIR, self.filename)) as f: - return json.load(f) + data = json.load(f) + if not isinstance(data, dict): + raise TypeError(f"{self.filename} does not contain a JSON object") + return data def create_event(self) -> tuple[FingerprintingConfig, Event]: config = FingerprintingConfig.from_json( diff --git a/tests/sentry/grouping/test_components.py b/tests/sentry/grouping/test_components.py index cb9f7e61a1a6..14a09b7cb5b4 100644 --- a/tests/sentry/grouping/test_components.py +++ b/tests/sentry/grouping/test_components.py @@ -1,4 +1,5 @@ from collections import Counter +from collections.abc import Callable from typing import Any import pytest @@ -13,10 +14,16 @@ StacktraceGroupingComponent, ThreadsGroupingComponent, ) +from sentry.grouping.variants import BaseVariant, ComponentVariant from sentry.services.eventstore.models import Event from sentry.testutils.cases import TestCase +def component_variant(variant: BaseVariant) -> ComponentVariant: + assert isinstance(variant, ComponentVariant) + return variant + + def find_given_child_component[T]( parent_component: BaseGroupingComponent[Any], child_component_type: type[T] ) -> T: @@ -87,7 +94,9 @@ def test_primitive_wrappers_wrap_at_most_one_value(self) -> None: FunctionGroupingComponent(values=["playFetch", "rollOver"]) def test_component_wrappers_can_wrap_multiple_values(self) -> None: - get_frame = lambda: FrameGroupingComponent(in_app=True, values=[]) + get_frame: Callable[[], FrameGroupingComponent] = lambda: FrameGroupingComponent( + in_app=True, values=[] + ) # Any number of values is fine StacktraceGroupingComponent(values=[]) @@ -106,7 +115,7 @@ def test_frame_components_record_in_app(self) -> None: variants = self.event.get_grouping_variants(normalize_stacktraces=True) for variant_name in ["app", "system"]: - exception_component = variants[variant_name].root_component.values[0] + exception_component = component_variant(variants[variant_name]).root_component.values[0] assert isinstance(exception_component, ExceptionGroupingComponent) stacktrace_component = find_given_child_component( exception_component, StacktraceGroupingComponent @@ -140,7 +149,7 @@ def test_exception_enhancements_apply_during_grouping(self) -> None: variants = self.event.get_grouping_variants(normalize_stacktraces=True) for variant_name in ["app", "system"]: - exception_component = variants[variant_name].root_component.values[0] + exception_component = component_variant(variants[variant_name]).root_component.values[0] assert isinstance(exception_component, ExceptionGroupingComponent) stacktrace_component = find_given_child_component( exception_component, StacktraceGroupingComponent @@ -160,8 +169,8 @@ def test_stacktrace_component_tallies_frame_types_simple(self) -> None: # `normalize_stacktraces=True` forces the custom stacktrace enhancements to run variants = self.event.get_grouping_variants(normalize_stacktraces=True) - system_exception_component = variants["system"].root_component.values[0] - app_exception_component = variants["app"].root_component.values[0] + system_exception_component = component_variant(variants["system"]).root_component.values[0] + app_exception_component = component_variant(variants["app"]).root_component.values[0] assert isinstance(app_exception_component, ExceptionGroupingComponent) assert isinstance(system_exception_component, ExceptionGroupingComponent) @@ -197,8 +206,8 @@ def test_stacktrace_component_tallies_frame_types_not_all_types_present(self) -> # `normalize_stacktraces=True` forces the custom stacktrace enhancements to run variants = self.event.get_grouping_variants(normalize_stacktraces=True) - system_exception_component = variants["system"].root_component.values[0] - app_exception_component = variants["app"].root_component.values[0] + system_exception_component = component_variant(variants["system"]).root_component.values[0] + app_exception_component = component_variant(variants["app"]).root_component.values[0] assert isinstance(app_exception_component, ExceptionGroupingComponent) assert isinstance(system_exception_component, ExceptionGroupingComponent) @@ -237,8 +246,8 @@ def test_exception_component_uses_stacktrace_frame_counts(self) -> None: # `normalize_stacktraces=True` forces the custom stacktrace enhancements to run variants = self.event.get_grouping_variants(normalize_stacktraces=True) - system_exception_component = variants["system"].root_component.values[0] - app_exception_component = variants["app"].root_component.values[0] + system_exception_component = component_variant(variants["system"]).root_component.values[0] + app_exception_component = component_variant(variants["app"]).root_component.values[0] assert isinstance(app_exception_component, ExceptionGroupingComponent) assert isinstance(system_exception_component, ExceptionGroupingComponent) @@ -278,8 +287,8 @@ def test_threads_component_uses_stacktrace_frame_counts(self) -> None: # `normalize_stacktraces=True` forces the custom stacktrace enhancements to run variants = self.event.get_grouping_variants(normalize_stacktraces=True) - app_threads_component = variants["app"].root_component.values[0] - system_threads_component = variants["system"].root_component.values[0] + app_threads_component = component_variant(variants["app"]).root_component.values[0] + system_threads_component = component_variant(variants["system"]).root_component.values[0] assert isinstance(app_threads_component, ThreadsGroupingComponent) assert isinstance(system_threads_component, ThreadsGroupingComponent) @@ -330,8 +339,12 @@ def test_chained_exception_component_sums_stacktrace_frame_counts(self) -> None: # `normalize_stacktraces=True` forces the custom stacktrace enhancements to run variants = self.event.get_grouping_variants(normalize_stacktraces=True) - app_chained_exception_component = variants["app"].root_component.values[0] - system_chained_exception_component = variants["system"].root_component.values[0] + app_chained_exception_component = component_variant(variants["app"]).root_component.values[ + 0 + ] + system_chained_exception_component = component_variant( + variants["system"] + ).root_component.values[0] assert isinstance(app_chained_exception_component, ChainedExceptionGroupingComponent) assert isinstance(system_chained_exception_component, ChainedExceptionGroupingComponent) @@ -371,7 +384,7 @@ def test_chained_exception_component_sums_stacktrace_frame_counts(self) -> None: ) def test_get_subcomponent(self) -> None: - root_component = self.event.get_grouping_variants()["app"].root_component + root_component = component_variant(self.event.get_grouping_variants()["app"]).root_component # When `recursive` isn't specified, it should find direct children but not grandchildren exception_component = root_component.get_subcomponent("exception") @@ -410,7 +423,7 @@ def test_configs_put_exception_subcomponents_in_expected_order(self) -> None: self.project.update_option("sentry:grouping_config", WINTER_2023_GROUPING_CONFIG) variants = self.event.get_grouping_variants() - exception_component = variants["app"].root_component.values[0] + exception_component = component_variant(variants["app"]).root_component.values[0] assert isinstance(exception_component, ExceptionGroupingComponent) assert [subcomponent.id for subcomponent in exception_component.values] == [ "stacktrace", @@ -420,7 +433,7 @@ def test_configs_put_exception_subcomponents_in_expected_order(self) -> None: self.project.update_option("sentry:grouping_config", FALL_2025_GROUPING_CONFIG) variants = self.event.get_grouping_variants() - exception_component = variants["app"].root_component.values[0] + exception_component = component_variant(variants["app"]).root_component.values[0] assert isinstance(exception_component, ExceptionGroupingComponent) assert [subcomponent.id for subcomponent in exception_component.values] == [ "type", diff --git a/tests/sentry/grouping/test_strategies.py b/tests/sentry/grouping/test_strategies.py index 5e592b701b89..460de7c62e55 100644 --- a/tests/sentry/grouping/test_strategies.py +++ b/tests/sentry/grouping/test_strategies.py @@ -5,8 +5,16 @@ from sentry.grouping.component import StacktraceGroupingComponent from sentry.grouping.context import GroupingContext from sentry.grouping.strategies.base import create_strategy_configuration_class +from sentry.grouping.variants import BaseVariant, ComponentVariant from sentry.services.eventstore.models import Event from sentry.testutils.cases import TestCase + + +def component_variant(variant: BaseVariant) -> ComponentVariant: + assert isinstance(variant, ComponentVariant) + return variant + + from sentry.testutils.helpers.eventprocessing import save_new_event @@ -183,8 +191,8 @@ def test_stacktrace_contribution_values_with_in_app_frames(self) -> None: self.project.update_option("sentry:grouping_enhancements", "function:dog_walker +app") variants = self.event.get_grouping_variants(normalize_stacktraces=True) - app_stacktrace_component = variants["app"].root_component.values[0] - system_stacktrace_component = variants["system"].root_component.values[0] + app_stacktrace_component = component_variant(variants["app"]).root_component.values[0] + system_stacktrace_component = component_variant(variants["system"]).root_component.values[0] assert isinstance(app_stacktrace_component, StacktraceGroupingComponent) assert isinstance(system_stacktrace_component, StacktraceGroupingComponent) @@ -216,8 +224,8 @@ def test_stacktrace_contribution_values_no_in_app_frames(self) -> None: self.project.update_option("sentry:grouping_enhancements", "function:* -app") variants = self.event.get_grouping_variants(normalize_stacktraces=True) - app_stacktrace_component = variants["app"].root_component.values[0] - system_stacktrace_component = variants["system"].root_component.values[0] + app_stacktrace_component = component_variant(variants["app"]).root_component.values[0] + system_stacktrace_component = component_variant(variants["system"]).root_component.values[0] assert isinstance(app_stacktrace_component, StacktraceGroupingComponent) assert isinstance(system_stacktrace_component, StacktraceGroupingComponent) @@ -244,8 +252,8 @@ def test_stacktrace_contribution_values_no_contributing_in_app_frames(self) -> N ) variants = self.event.get_grouping_variants(normalize_stacktraces=True) - app_stacktrace_component = variants["app"].root_component.values[0] - system_stacktrace_component = variants["system"].root_component.values[0] + app_stacktrace_component = component_variant(variants["app"]).root_component.values[0] + system_stacktrace_component = component_variant(variants["system"]).root_component.values[0] assert isinstance(app_stacktrace_component, StacktraceGroupingComponent) assert isinstance(system_stacktrace_component, StacktraceGroupingComponent) @@ -277,8 +285,8 @@ def test_stacktrace_contribution_values_no_contributing_frames(self) -> None: self.project.update_option("sentry:grouping_enhancements", "function:* +app -group") variants = self.event.get_grouping_variants(normalize_stacktraces=True) - app_stacktrace_component = variants["app"].root_component.values[0] - system_stacktrace_component = variants["system"].root_component.values[0] + app_stacktrace_component = component_variant(variants["app"]).root_component.values[0] + system_stacktrace_component = component_variant(variants["system"]).root_component.values[0] assert isinstance(app_stacktrace_component, StacktraceGroupingComponent) assert isinstance(system_stacktrace_component, StacktraceGroupingComponent) diff --git a/tests/sentry/hybridcloud/apigateway/test_apigateway.py b/tests/sentry/hybridcloud/apigateway/test_apigateway.py index 174f0368e93e..32fbe34aab3e 100644 --- a/tests/sentry/hybridcloud/apigateway/test_apigateway.py +++ b/tests/sentry/hybridcloud/apigateway/test_apigateway.py @@ -19,7 +19,7 @@ class ApiGatewayTest(ApiGatewayTestCase): @responses.activate def test_simple(self) -> None: - query_params = dict(foo="test", bar=["one", "two"]) + query_params: dict[str, str | list[str]] = dict(foo="test", bar=["one", "two"]) headers = dict(example="this") responses.add_callback( responses.GET, diff --git a/tests/sentry/hybridcloud/models/test_outbox.py b/tests/sentry/hybridcloud/models/test_outbox.py index fa97bceab76b..4d257b093681 100644 --- a/tests/sentry/hybridcloud/models/test_outbox.py +++ b/tests/sentry/hybridcloud/models/test_outbox.py @@ -778,9 +778,10 @@ def test_catches_random_database_errors(self, mock_process: Mock) -> None: # drain outboxes pass + outbox_category = OutboxCategory(Organization(id=10).outbox_for_update().category) assert ( str(e.value) - == f"Failed to process Outbox, {Organization(id=10).outbox_for_update().category.name} due to database error" + == f"Failed to process Outbox, {outbox_category.name} due to database error" ) assert CellOutbox.objects.count() == 1 diff --git a/tests/sentry/hybridcloud/rpc/test_caching.py b/tests/sentry/hybridcloud/rpc/test_caching.py index 4459476362f0..bbb10fbf98cb 100644 --- a/tests/sentry/hybridcloud/rpc/test_caching.py +++ b/tests/sentry/hybridcloud/rpc/test_caching.py @@ -331,11 +331,11 @@ def get_org(org_id: int) -> RpcOrganizationSummary | None: org.update(name=org.name + " updated") # Does not include updates - for org in old: - next_org = get_org(org.id) - assert next_org == org + for cached_org in old: + next_org = get_org(cached_org.id) + assert next_org == cached_org - control_caching_service.clear_key(key=get_org.key_from(org.id)) + control_caching_service.clear_key(key=get_org.key_from(cached_org.id)) cached_orgs = [get_org.get_one(o.id) for o in orgs] for o, cached in zip(orgs, cached_orgs): @@ -561,13 +561,13 @@ def get_org_members(organization_id: int) -> list[RpcOrganizationMember]: # Read from cache - does not include updates made to db after_update_wrapped = get_org_members(org.id) - for member in after_update_wrapped: - assert member.role == "owner" + for cached_member in after_update_wrapped: + assert cached_member.role == "owner" # DB read is updated after_update_direct = get_org_members.cb(org.id) - for member in after_update_direct: - assert member.role == "member" + for direct_member in after_update_direct: + assert direct_member.role == "member" # Clear cache simulating outbox logic control_caching_service.clear_key(key=get_org_members.key_from(org.id)) diff --git a/tests/sentry/hybridcloud/tasks/test_deliver_webhooks.py b/tests/sentry/hybridcloud/tasks/test_deliver_webhooks.py index 881afc01f696..58f010cf4b7e 100644 --- a/tests/sentry/hybridcloud/tasks/test_deliver_webhooks.py +++ b/tests/sentry/hybridcloud/tasks/test_deliver_webhooks.py @@ -1,7 +1,7 @@ import threading import time from datetime import UTC, datetime, timedelta -from typing import Any +from typing import Any, cast from unittest.mock import ANY, MagicMock, patch import pytest @@ -636,7 +636,11 @@ def failing_cache(self, method: str) -> MagicMock: return double def carryover(self) -> list[dict[str, Any]] | None: - return cache.get(deliver_webhooks.CARRYOVER_CACHE_KEY) + # justification: the carryover cache key only stores webhook payload lists. + return cast( + "list[dict[str, Any]] | None", + cache.get(deliver_webhooks.CARRYOVER_CACHE_KEY), + ) def dispatched_ids(self, mock_drain: MagicMock) -> list[int]: return [call.kwargs["payload_id"] for call in mock_drain.delay.call_args_list] diff --git a/tests/sentry/hybridcloud/test_organizationmapping.py b/tests/sentry/hybridcloud/test_organizationmapping.py index 8256a14bccc8..cce0b5fa469d 100644 --- a/tests/sentry/hybridcloud/test_organizationmapping.py +++ b/tests/sentry/hybridcloud/test_organizationmapping.py @@ -205,7 +205,8 @@ def test_upsert__reject_when_no_slug_reservation_found(self) -> None: class OrganizationMappingReplicationTest(TransactionTestCase): def test_replicates_all_flags(self) -> None: self.organization = self.create_organization(slug="santry", cell="us") - self.organization.flags = 255 # all flags set + # justification: bitfield flags accept a raw integer mask though the descriptor is typed as its flag class. + setattr(self.organization, "flags", 255) # all flags set organization_mapping_service.upsert( organization_id=self.organization.id, update=update_organization_mapping_from_instance( diff --git a/tests/sentry/hybridcloud/test_organizationmembermapping.py b/tests/sentry/hybridcloud/test_organizationmembermapping.py index c2aada1156ba..daca6f8ac866 100644 --- a/tests/sentry/hybridcloud/test_organizationmembermapping.py +++ b/tests/sentry/hybridcloud/test_organizationmembermapping.py @@ -31,7 +31,7 @@ def test_upsert_stale_user_id(self) -> None: omm = OrganizationMemberMapping.objects.get( organization_id=self.organization.id, organizationmember_id=111111 ) - assert omm.user_id is None + assert not omm.user_id assert omm.inviter_id == self.user.id def test_upsert_stale_inviter_id(self) -> None: diff --git a/tests/sentry/identity/datadog/test_provider.py b/tests/sentry/identity/datadog/test_provider.py index 4a373118152f..e0d337bacc73 100644 --- a/tests/sentry/identity/datadog/test_provider.py +++ b/tests/sentry/identity/datadog/test_provider.py @@ -2,8 +2,9 @@ import base64 import hashlib +from collections.abc import Mapping from functools import cached_property -from typing import Any +from typing import Any, cast from unittest.mock import MagicMock, patch from urllib.parse import parse_qs, parse_qsl, urlparse @@ -12,6 +13,7 @@ import responses from django.contrib.messages.storage.fallback import FallbackStorage from django.contrib.sessions.backends.base import SessionBase +from django.http import HttpRequest from django.test import Client, RequestFactory from requests import ConnectionError, HTTPError from requests.exceptions import SSLError @@ -28,6 +30,7 @@ ) from sentry.identity.pipeline import IdentityPipeline from sentry.identity.providers.dummy import DummyProvider +from sentry.pipeline.views.base import PipelineView from sentry.testutils.asserts import assert_failure_metric from sentry.testutils.cases import TestCase from sentry.testutils.silo import control_silo_test @@ -203,7 +206,7 @@ def tearDown(self) -> None: sentry.identity.unregister(DummyProvider) @cached_property - def view(self): + def view(self) -> DatadogOAuth2LoginView: return DatadogOAuth2LoginView(authorize_url=AUTHORIZE_URL, scope="read", resource=RESOURCE) def _make_pipeline(self, dcr_client_id: str = "dcr-client-123") -> IdentityPipeline: @@ -292,7 +295,7 @@ def tearDown(self) -> None: sentry.identity.unregister(DummyProvider) @cached_property - def view(self): + def view(self) -> DatadogOAuth2CallbackView: return DatadogOAuth2CallbackView(access_token_url=TOKEN_URL) def _make_pipeline( @@ -355,14 +358,14 @@ class DatadogTestProvider(DummyProvider): name = "Datadog Test" key = "datadog-test" - def get_pipeline_views(self): + def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]: return [ DatadogOAuth2DCRView(register_url=REGISTER_URL), DatadogOAuth2LoginView(authorize_url=AUTHORIZE_URL, scope="read", resource=RESOURCE), DatadogOAuth2CallbackView(access_token_url=TOKEN_URL), ] - def build_identity(self, state): + def build_identity(self, state: Mapping[str, object]) -> Mapping[str, object]: return {"id": "test", "data": state.get("data", {})} @@ -381,7 +384,7 @@ def tearDown(self) -> None: super().tearDown() sentry.identity.unregister(DatadogTestProvider) - def _make_request(self, **query_params): + def _make_request(self, **query_params: str) -> HttpRequest: request = RequestFactory().get("/", data=query_params) request.session = SessionBase() request.user = self.user @@ -423,7 +426,7 @@ def test_pipeline_views(self, mock_record: MagicMock) -> None: # Login + Callback views: Simulate the OAuth redirect and advance to Callback. oauth_state = pipeline.fetch_state("state") - pipeline.request = self._make_request(code="auth-code", state=oauth_state) + pipeline.request = self._make_request(code="auth-code", state=cast(str, oauth_state)) result = pipeline.current_step() # Pipeline completed successfully with a redirect. diff --git a/tests/sentry/identity/gcp/test_provider.py b/tests/sentry/identity/gcp/test_provider.py index 6a666db5d8e8..3f3ca5480605 100644 --- a/tests/sentry/identity/gcp/test_provider.py +++ b/tests/sentry/identity/gcp/test_provider.py @@ -1,8 +1,9 @@ from __future__ import annotations import base64 +from collections.abc import Mapping from functools import cached_property -from typing import Any +from typing import Any, cast from unittest.mock import MagicMock, patch from urllib.parse import parse_qs, parse_qsl, urlparse @@ -10,6 +11,7 @@ import responses from django.contrib.messages.storage.fallback import FallbackStorage from django.contrib.sessions.backends.base import SessionBase +from django.http import HttpRequest from django.test import Client, RequestFactory import sentry.identity @@ -22,6 +24,7 @@ from sentry.identity.pipeline import IdentityPipeline from sentry.identity.providers.dummy import DummyProvider from sentry.integrations.gcp.utils import GCP_MCP_URLS +from sentry.pipeline.views.base import PipelineView from sentry.testutils.cases import TestCase from sentry.testutils.silo import control_silo_test from sentry.users.models.identity import IdentityProvider @@ -52,7 +55,7 @@ def tearDown(self) -> None: sentry.identity.unregister(DummyProvider) @cached_property - def view(self): + def view(self) -> GCPOAuth2LoginView: return GCPOAuth2LoginView( authorize_url=AUTHORIZE_URL, client_id="test-client-id", @@ -197,7 +200,7 @@ class GCPTestProvider(DummyProvider): name = "GCP Test" key = "gcp-test" - def get_pipeline_views(self): + def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]: return [ GCPOAuth2LoginView( authorize_url=AUTHORIZE_URL, @@ -211,7 +214,7 @@ def get_pipeline_views(self): ), ] - def build_identity(self, state): + def build_identity(self, state: Mapping[str, object]) -> Mapping[str, object]: return {"id": "test", "data": state.get("data", {})} @@ -228,7 +231,7 @@ def tearDown(self) -> None: super().tearDown() sentry.identity.unregister(GCPTestProvider) - def _make_request(self, **query_params): + def _make_request(self, **query_params: str) -> HttpRequest: request = RequestFactory().get("/", data=query_params) request.session = SessionBase() request.user = self.user @@ -271,7 +274,7 @@ def test_full_pipeline(self, _mock_record: MagicMock) -> None: # Callback view: simulate OAuth redirect back with code oauth_state = pipeline.fetch_state("state") - pipeline.request = self._make_request(code="auth-code", state=oauth_state) + pipeline.request = self._make_request(code="auth-code", state=cast(str, oauth_state)) result = pipeline.current_step() assert result.status_code == 302 diff --git a/tests/sentry/identity/github/test_provider.py b/tests/sentry/identity/github/test_provider.py index 4407ce774a2d..c7fcff78ced9 100644 --- a/tests/sentry/identity/github/test_provider.py +++ b/tests/sentry/identity/github/test_provider.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -75,7 +76,7 @@ def test_unexpected_shape_returns_none(self) -> None: ), ], ) -def test_get_verified_primary_email(emails: list, expected: str | None) -> None: +def test_get_verified_primary_email(emails: list[Any], expected: str | None) -> None: assert get_verified_primary_email(emails) == expected diff --git a/tests/sentry/identity/test_oauth2.py b/tests/sentry/identity/test_oauth2.py index 94a51ce71248..db1a2a360d65 100644 --- a/tests/sentry/identity/test_oauth2.py +++ b/tests/sentry/identity/test_oauth2.py @@ -2,13 +2,14 @@ from collections import namedtuple from functools import cached_property -from typing import Any, cast +from typing import Any, NoReturn, cast from unittest import TestCase from unittest.mock import MagicMock, patch from urllib.parse import parse_qs, parse_qsl, urlparse import responses from django.test import Client, RequestFactory +from requests import PreparedRequest from requests.exceptions import ConnectionError, SSLError import sentry.identity @@ -39,7 +40,7 @@ def tearDown(self) -> None: sentry.identity.unregister(DummyProvider) @cached_property - def view(self): + def view(self) -> OAuth2CallbackView: return OAuth2CallbackView( access_token_url="https://example.org/oauth/token", client_id=123456, @@ -98,7 +99,7 @@ def test_exchange_token_success_customer_domains(self, mock_record: MagicMock) - @responses.activate def test_exchange_token_ssl_error(self, mock_record: MagicMock) -> None: - def ssl_error(request): + def ssl_error(request: PreparedRequest) -> NoReturn: raise SSLError("Could not build connection") responses.add_callback( @@ -116,7 +117,7 @@ def ssl_error(request): @responses.activate def test_connection_error(self, mock_record: MagicMock) -> None: - def connection_error(request): + def connection_error(request: PreparedRequest) -> NoReturn: raise ConnectionError("Name or service not known") responses.add_callback( @@ -177,7 +178,7 @@ def tearDown(self) -> None: sentry.identity.unregister(DummyProvider) @cached_property - def view(self): + def view(self) -> OAuth2LoginView: return OAuth2LoginView( authorize_url="https://example.org/oauth2/authorize", client_id=123456, @@ -246,7 +247,7 @@ def step(self) -> OAuth2ApiStep: ) def test_returns_oauth_url(self) -> None: - ctx = cast(Pipeline, _FakePipelineContext(signature="abc123")) + ctx = cast(Pipeline[Any, Any], _FakePipelineContext(signature="abc123")) request = RequestFactory().get("/") data = self.step.get_step_data(ctx, request) @@ -300,7 +301,7 @@ def test_exchange_token_success(self) -> None: "https://example.org/oauth/token", json={"access_token": "a-fake-token"}, ) - ctx = cast(Pipeline, _FakePipelineContext(signature="valid-state")) + ctx = cast(Pipeline[Any, Any], _FakePipelineContext(signature="valid-state")) result = self.step.handle_post( {"code": "auth-code", "state": "valid-state"}, ctx, self.request ) @@ -316,7 +317,7 @@ def test_exchange_token_success(self) -> None: assert data["client_secret"] == "secret-value" def test_invalid_state(self) -> None: - ctx = cast(Pipeline, _FakePipelineContext(signature="correct-state")) + ctx = cast(Pipeline[Any, Any], _FakePipelineContext(signature="correct-state")) result = self.step.handle_post( {"code": "auth-code", "state": "wrong-state"}, ctx, self.request ) @@ -326,13 +327,13 @@ def test_invalid_state(self) -> None: @responses.activate def test_ssl_error(self) -> None: - def ssl_error(request): + def ssl_error(request: PreparedRequest) -> NoReturn: raise SSLError("Could not build connection") responses.add_callback( responses.POST, "https://example.org/oauth/token", callback=ssl_error ) - ctx = cast(Pipeline, _FakePipelineContext(signature="valid-state")) + ctx = cast(Pipeline[Any, Any], _FakePipelineContext(signature="valid-state")) result = self.step.handle_post( {"code": "auth-code", "state": "valid-state"}, ctx, self.request ) @@ -342,13 +343,13 @@ def ssl_error(request): @responses.activate def test_connection_error(self) -> None: - def connection_error(request): + def connection_error(request: PreparedRequest) -> NoReturn: raise ConnectionError("Name or service not known") responses.add_callback( responses.POST, "https://example.org/oauth/token", callback=connection_error ) - ctx = cast(Pipeline, _FakePipelineContext(signature="valid-state")) + ctx = cast(Pipeline[Any, Any], _FakePipelineContext(signature="valid-state")) result = self.step.handle_post( {"code": "auth-code", "state": "valid-state"}, ctx, self.request ) @@ -359,7 +360,7 @@ def connection_error(request): @responses.activate def test_empty_response_body(self) -> None: responses.add(responses.POST, "https://example.org/oauth/token", body="") - ctx = cast(Pipeline, _FakePipelineContext(signature="valid-state")) + ctx = cast(Pipeline[Any, Any], _FakePipelineContext(signature="valid-state")) result = self.step.handle_post( {"code": "auth-code", "state": "valid-state"}, ctx, self.request ) @@ -375,7 +376,7 @@ def test_api_error_401(self) -> None: json={"error": "unauthorized"}, status=401, ) - ctx = cast(Pipeline, _FakePipelineContext(signature="valid-state")) + ctx = cast(Pipeline[Any, Any], _FakePipelineContext(signature="valid-state")) result = self.step.handle_post( {"code": "auth-code", "state": "valid-state"}, ctx, self.request ) diff --git a/tests/sentry/incidents/endpoints/serializers/test_alert_rule.py b/tests/sentry/incidents/endpoints/serializers/test_alert_rule.py index 418e849f5242..71311b124ef4 100644 --- a/tests/sentry/incidents/endpoints/serializers/test_alert_rule.py +++ b/tests/sentry/incidents/endpoints/serializers/test_alert_rule.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from sentry.incidents.models.alert_rule import AlertRule @@ -11,8 +12,12 @@ class BaseAlertRuleSerializerTest: def assert_alert_rule_serialized( - self, alert_rule, result, skip_dates=False, resolve_threshold=NOT_SET - ): + self, + alert_rule: AlertRule, + result: Mapping[str, object], + skip_dates: bool = False, + resolve_threshold: object = NOT_SET, + ) -> None: alert_rule_projects = sorted( slug for slug in AlertRule.objects.filter(id=alert_rule.id).values_list( @@ -35,11 +40,12 @@ def assert_alert_rule_serialized( assert result["resolution"] == alert_rule.snuba_query.resolution / 60 assert result["thresholdPeriod"] == alert_rule.threshold_period assert result["projects"] == alert_rule_projects - if alert_rule.created_by_id: - created_by = user_service.get_user(user_id=alert_rule.created_by_id) + created_by_id: int | None = getattr(alert_rule, "created_by_id", None) + if created_by_id: + created_by = user_service.get_user(user_id=created_by_id) assert created_by is not None assert result["createdBy"] == { - "id": alert_rule.created_by_id, + "id": created_by_id, "name": created_by.get_display_name(), "email": created_by.email, } @@ -65,11 +71,9 @@ def assert_alert_rule_serialized( else: assert result["comparisonDelta"] is None - if alert_rule.snuba_query.extrapolation_mode is not None: - assert ( - result["extrapolationMode"] - == ExtrapolationMode(alert_rule.snuba_query.extrapolation_mode).name.lower() - ) + extrapolation_mode: int | None = alert_rule.snuba_query.extrapolation_mode + if extrapolation_mode is not None: + assert result["extrapolationMode"] == ExtrapolationMode(extrapolation_mode).name.lower() else: assert result.get("extrapolationMode") is None diff --git a/tests/sentry/incidents/endpoints/test_organization_detector_anomaly_data.py b/tests/sentry/incidents/endpoints/test_organization_detector_anomaly_data.py index 522f0b5cfa27..f8bf1e362bab 100644 --- a/tests/sentry/incidents/endpoints/test_organization_detector_anomaly_data.py +++ b/tests/sentry/incidents/endpoints/test_organization_detector_anomaly_data.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from sentry.incidents.grouptype import MetricIssue from sentry.snuba.subscriptions import create_snuba_subscription @@ -74,7 +74,7 @@ def test_no_subscription_found(self) -> None: @patch( "sentry.workflow_engine.endpoints.organization_detector_anomaly_data.get_anomaly_threshold_data_from_seer" ) - def test_seer_error(self, mock_get_data): + def test_seer_error(self, mock_get_data: MagicMock) -> None: mock_get_data.return_value = None response = self.get_error_response( @@ -89,7 +89,7 @@ def test_seer_error(self, mock_get_data): @patch( "sentry.workflow_engine.endpoints.organization_detector_anomaly_data.get_anomaly_threshold_data_from_seer" ) - def test_successful_fetch(self, mock_get_data): + def test_successful_fetch(self, mock_get_data: MagicMock) -> None: mock_data = [ { "external_alert_id": 24, diff --git a/tests/sentry/incidents/endpoints/validators/test_validators.py b/tests/sentry/incidents/endpoints/validators/test_validators.py index 06f84b5e4be5..c7470c526cde 100644 --- a/tests/sentry/incidents/endpoints/validators/test_validators.py +++ b/tests/sentry/incidents/endpoints/validators/test_validators.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, cast from unittest import mock import orjson @@ -239,7 +239,8 @@ def create_static_detector(self) -> Detector: assert condition.comparison == 100 assert condition.condition_result == DetectorPriorityLevel.HIGH - return static_detector + # justification: the validator persists and returns a Detector. + return cast("Detector", static_detector) def create_dynamic_detector(self) -> Detector: validator = MetricIssueDetectorValidator( @@ -269,14 +270,15 @@ def create_dynamic_detector(self) -> Detector: } assert condition.condition_result == DetectorPriorityLevel.HIGH - return detector + # justification: the validator persists and returns a Detector. + return cast("Detector", detector) def get_snuba_query(self, detector: Detector) -> SnubaQuery: data_source = DataSource.objects.get(detector=detector) query_sub = QuerySubscription.objects.get(id=data_source.source_id) return query_sub.snuba_query - def assert_validated(self, detector): + def assert_validated(self, detector: Detector) -> None: detector = Detector.objects.get(id=detector.id) assert detector.name == "Test Detector" assert detector.type == MetricIssue.slug @@ -309,7 +311,9 @@ class TestMetricAlertsCreateDetectorValidator(TestMetricAlertsDetectorValidator) @mock.patch("sentry.incidents.metric_issue_detector.schedule_update_project_config") @mock.patch("sentry.workflow_engine.endpoints.validators.base.detector.create_audit_entry") def test_create_with_valid_data( - self, mock_audit: mock.MagicMock, mock_schedule_update_project_config + self, + mock_audit: mock.MagicMock, + mock_schedule_update_project_config: mock.MagicMock, ) -> None: detector = self.create_static_detector() diff --git a/tests/sentry/incidents/serializers/test_workflow_engine_base.py b/tests/sentry/incidents/serializers/test_workflow_engine_base.py index b07db6678a8f..55130d7e1439 100644 --- a/tests/sentry/incidents/serializers/test_workflow_engine_base.py +++ b/tests/sentry/incidents/serializers/test_workflow_engine_base.py @@ -1,4 +1,5 @@ -from typing import Any +from collections.abc import Mapping +from typing import Any, cast from django.utils import timezone @@ -63,7 +64,8 @@ def create_metric_trigger( condition = self.create_data_condition( condition_group=detector.workflow_condition_group, type=Condition.GREATER, - comparison=threshold, + # justification: DataCondition.comparison is a JSON field where floats are valid values. + comparison=cast("str | int | Mapping[str, object]", threshold), condition_result=priority, ) action_group = self.create_data_condition_group(organization=self.organization) diff --git a/tests/sentry/incidents/serializers/test_workflow_engine_data_condition.py b/tests/sentry/incidents/serializers/test_workflow_engine_data_condition.py index 723c790a74b3..02c2cee0f601 100644 --- a/tests/sentry/incidents/serializers/test_workflow_engine_data_condition.py +++ b/tests/sentry/incidents/serializers/test_workflow_engine_data_condition.py @@ -1,3 +1,6 @@ +from collections.abc import Mapping +from typing import cast + from sentry.api.serializers import serialize from sentry.incidents.endpoints.serializers.utils import get_fake_id_from_object_id from sentry.incidents.endpoints.serializers.workflow_engine_data_condition import ( @@ -18,6 +21,13 @@ ) from sentry.workflow_engine.models.data_condition import Condition from sentry.workflow_engine.types import DetectorPriorityLevel + + +def _float_comparison(value: float) -> str | int | Mapping[str, object]: + # justification: DataCondition.comparison is a JSON field where floats are valid values. + return cast("str | int | Mapping[str, object]", value) + + from tests.sentry.incidents.serializers.test_workflow_engine_base import ( TestWorkflowEngineSerializer, ) @@ -328,8 +338,8 @@ def test_single_group_with_resolve_condition(self) -> None: self.create_data_condition( condition_group=dcg, condition_result=DetectorPriorityLevel.OK, - comparison=5.0, - type="gte", + comparison=_float_comparison(5.0), + type=Condition.GREATER_OR_EQUAL, ) assert get_resolve_thresholds([dcg]) == {dcg.id: 5.0} @@ -339,8 +349,8 @@ def test_single_group_without_resolve_condition(self) -> None: self.create_data_condition( condition_group=dcg, condition_result=DetectorPriorityLevel.HIGH, - comparison=100.0, - type="gte", + comparison=_float_comparison(100.0), + type=Condition.GREATER_OR_EQUAL, ) assert get_resolve_thresholds([dcg]) == {} @@ -353,20 +363,20 @@ def test_multiple_groups_mixed(self) -> None: self.create_data_condition( condition_group=dcg1, condition_result=DetectorPriorityLevel.OK, - comparison=10.0, - type="gte", + comparison=_float_comparison(10.0), + type=Condition.GREATER_OR_EQUAL, ) self.create_data_condition( condition_group=dcg2, condition_result=DetectorPriorityLevel.HIGH, - comparison=200.0, - type="gte", + comparison=_float_comparison(200.0), + type=Condition.GREATER_OR_EQUAL, ) self.create_data_condition( condition_group=dcg3, condition_result=DetectorPriorityLevel.OK, - comparison=25.0, - type="gte", + comparison=_float_comparison(25.0), + type=Condition.GREATER_OR_EQUAL, ) assert get_resolve_thresholds([dcg1, dcg2, dcg3]) == { diff --git a/tests/sentry/incidents/subscription_processor/test_subscription_processor_aci.py b/tests/sentry/incidents/subscription_processor/test_subscription_processor_aci.py index 70b76bc5afd1..725a75b28f7d 100644 --- a/tests/sentry/incidents/subscription_processor/test_subscription_processor_aci.py +++ b/tests/sentry/incidents/subscription_processor/test_subscription_processor_aci.py @@ -1,7 +1,9 @@ import copy import math +from collections.abc import Iterator from datetime import timedelta from functools import cached_property +from typing import cast from unittest.mock import MagicMock, call, patch from uuid import uuid4 @@ -74,7 +76,10 @@ def test_process_update_returns_false_when_downgraded(self, mock_is_enabled: Mag ) with self.capture_on_commit_callbacks(execute=True): - assert SubscriptionProcessor.process(self.sub, message) is False + assert ( + SubscriptionProcessor.process(self.sub, cast(QuerySubscriptionUpdate, message)) + is False + ) mock_is_enabled.assert_called_once() @patch("sentry.incidents.subscription_processor.metrics") @@ -174,7 +179,7 @@ def test_multiple_triggers_reversed(self) -> None: class ProcessUpdateComparisonAlertTest(ProcessUpdateBaseClass): @cached_property - def comparison_detector_above(self): + def comparison_detector_above(self) -> Detector: self.detector.config.update({"comparison_delta": 60 * 60}) self.detector.save() self.update_threshold(self.detector, DetectorPriorityLevel.HIGH, 150) @@ -184,7 +189,7 @@ def comparison_detector_above(self): return self.detector @cached_property - def comparison_detector_below(self): + def comparison_detector_below(self) -> Detector: self.detector.config.update({"comparison_delta": 60 * 60}) self.detector.save() DataCondition.objects.filter( @@ -196,7 +201,7 @@ def comparison_detector_below(self): return self.detector @patch("sentry.incidents.utils.process_update_helpers.metrics") - def test_comparison_alert_above(self, helper_metrics): + def test_comparison_alert_above(self, helper_metrics: MagicMock) -> None: detector = self.comparison_detector_above comparison_delta = timedelta(seconds=detector.config["comparison_delta"]) self.send_update(self.critical_threshold + 1, timedelta(minutes=-10)) @@ -245,7 +250,7 @@ def test_comparison_alert_above(self, helper_metrics): assert self.get_detector_state(detector) == DetectorPriorityLevel.OK @patch("sentry.incidents.utils.process_update_helpers.metrics") - def test_comparison_alert_below(self, helper_metrics): + def test_comparison_alert_below(self, helper_metrics: MagicMock) -> None: detector = self.comparison_detector_below comparison_delta = timedelta(seconds=detector.config["comparison_delta"]) self.send_update(self.critical_threshold - 1, timedelta(minutes=-10)) @@ -295,7 +300,7 @@ def test_comparison_alert_below(self, helper_metrics): assert self.get_detector_state(detector) == DetectorPriorityLevel.OK @patch("sentry.incidents.utils.process_update_helpers.metrics") - def test_comparison_alert_eap(self, helper_metrics): + def test_comparison_alert_eap(self, helper_metrics: MagicMock) -> None: detector = self.comparison_detector_above self.snuba_query.update( aggregate="count(span.duration)", @@ -358,7 +363,7 @@ def test_comparison_alert_eap(self, helper_metrics): assert self.get_detector_state(detector) == DetectorPriorityLevel.OK @patch("sentry.incidents.utils.process_update_helpers.metrics") - def test_is_unresolved_comparison_query(self, helper_metrics): + def test_is_unresolved_comparison_query(self, helper_metrics: MagicMock) -> None: """ Test that uses the ErrorsQueryBuilder (because of the specific query) """ @@ -423,7 +428,7 @@ def test_is_unresolved_comparison_query(self, helper_metrics): assert self.get_detector_state(detector) == DetectorPriorityLevel.OK @patch("sentry.incidents.utils.process_update_helpers.metrics") - def test_is_unresolved_different_aggregate(self, helper_metrics): + def test_is_unresolved_different_aggregate(self, helper_metrics: MagicMock) -> None: detector = self.comparison_detector_above comparison_delta = timedelta(seconds=detector.config["comparison_delta"]) snuba_query = self.get_snuba_query(detector) @@ -570,12 +575,12 @@ def test_upsampled_count_detector_and_resolve(self) -> None: class MetricsCrashRateDetectorProcessUpdateTest(ProcessUpdateBaseClass, BaseMetricsTestCase): @pytest.fixture(autouse=True) - def _setup_metrics_patcher(self): + def _setup_metrics_patcher(self) -> Iterator[None]: with patch("sentry.snuba.entity_subscription.metrics") as self.entity_subscription_metrics: yield @cached_property - def crash_rate_detector(self): + def crash_rate_detector(self) -> Detector: detector = self.metric_detector DataCondition.objects.filter(condition_group=detector.workflow_condition_group).delete() self.set_up_data_conditions( @@ -606,14 +611,17 @@ def setUp(self) -> None: ) ) - def send_crash_rate_detector_update(self, value, subscription, time_delta=None, count=EMPTY): + def send_crash_rate_detector_update( + self, + value: float | None, + subscription: QuerySubscription, + time_delta: timedelta | None = None, + count: object = EMPTY, + ) -> None: if time_delta is None: time_delta = timedelta() - if time_delta is not None: - timestamp = timezone.now() + time_delta - else: - timestamp = timezone.now() + timestamp = timezone.now() + time_delta timestamp = timestamp.replace(microsecond=0) with ( @@ -626,14 +634,14 @@ def send_crash_rate_detector_update(self, value, subscription, time_delta=None, if count is EMPTY: numerator, denominator = value.as_integer_ratio() else: - denominator = count + denominator = cast(int, count) numerator = int(value * denominator) SubscriptionProcessor.process( subscription, { "entity": "entity", "subscription_id": ( - subscription.subscription_id if subscription else uuid4().hex + cast(str, subscription.subscription_id) if subscription else uuid4().hex ), "values": { "data": [ @@ -679,8 +687,8 @@ def test_crash_rate_detector_for_sessions_with_auto_resolve_critical(self) -> No "sentry.seer.anomaly_detection.get_anomaly_data.SEER_ANOMALY_DETECTION_CONNECTION_POOL.urlopen" ) def test_dynamic_crash_rate_detector_for_sessions_with_auto_resolve_critical( - self, mock_seer_request - ): + self, mock_seer_request: MagicMock + ) -> None: """ Test that ensures that a dynamic critical monitor is triggered when `crash_free_percentage` falls below the critical threshold and then is resolved once `crash_free_percentage` goes above @@ -879,8 +887,8 @@ def test_crash_rate_detector_for_sessions_with_no_sessions_data( @patch("sentry.incidents.utils.process_update_helpers.CRASH_RATE_ALERT_MINIMUM_THRESHOLD", 30) @patch("sentry.incidents.utils.process_update_helpers.metrics") def test_crash_rate_detector_when_session_count_is_lower_than_minimum_threshold( - self, helper_metrics - ): + self, helper_metrics: MagicMock + ) -> None: # Send Critical Update update_value = (1 - self.critical_threshold / 100) + 0.05 self.send_crash_rate_detector_update( @@ -945,8 +953,8 @@ def test_multiple_threshold_trigger_is_reset_when_no_sessions_data( @patch("sentry.incidents.utils.process_update_helpers.CRASH_RATE_ALERT_MINIMUM_THRESHOLD", 30) @patch("sentry.incidents.utils.process_update_helpers.metrics") def test_multiple_threshold_trigger_is_reset_when_count_is_lower_than_min_threshold( - self, helper_metrics - ): + self, helper_metrics: MagicMock + ) -> None: update_value = (1 - self.critical_threshold / 100) + 0.05 subscription = self.sub @@ -977,14 +985,14 @@ def test_multiple_threshold_trigger_is_reset_when_count_is_lower_than_min_thresh @patch("sentry.incidents.utils.process_update_helpers.metrics") def test_ensure_case_when_no_metrics_index_not_found_is_handled_gracefully( - self, helper_metrics - ): + self, helper_metrics: MagicMock + ) -> None: with self.feature("organizations:performance-view"): SubscriptionProcessor.process( self.sub, { "entity": "entity", - "subscription_id": self.sub.subscription_id, + "subscription_id": cast(str, self.sub.subscription_id), "values": { # 1001 is a random int that doesn't map to anything in the indexer "data": [ diff --git a/tests/sentry/incidents/subscription_processor/test_subscription_processor_base.py b/tests/sentry/incidents/subscription_processor/test_subscription_processor_base.py index d5937afe610e..23d7668b405c 100644 --- a/tests/sentry/incidents/subscription_processor/test_subscription_processor_base.py +++ b/tests/sentry/incidents/subscription_processor/test_subscription_processor_base.py @@ -1,6 +1,8 @@ -from datetime import timedelta +from collections.abc import Iterator +from datetime import datetime, timedelta from functools import cached_property from random import randint +from typing import TypedDict, cast from unittest import mock from uuid import uuid4 @@ -13,7 +15,10 @@ store_detector_last_update, ) from sentry.incidents.utils.constants import INCIDENTS_SNUBA_SUBSCRIPTION_TYPE -from sentry.incidents.utils.types import DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION +from sentry.incidents.utils.types import ( + DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION, + QuerySubscriptionUpdate, +) from sentry.snuba.dataset import Dataset from sentry.snuba.models import QuerySubscription, SnubaQuery, SnubaQueryEventType from sentry.snuba.subscriptions import create_snuba_query, create_snuba_subscription @@ -27,10 +32,19 @@ EMPTY = object() +class _SubscriptionUpdateMessage(TypedDict): + subscription_id: str | None + values: dict[str, list[dict[str, object]]] + timestamp: datetime + interval: int + partition: int + offset: int + + @freeze_time() class ProcessUpdateBaseClass(TestCase, SpanTestCase, SnubaTestCase): @pytest.fixture(autouse=True) - def _setup_metrics_patch(self): + def _setup_metrics_patch(self) -> Iterator[None]: with mock.patch("sentry.incidents.subscription_processor.metrics") as self.metrics: yield @@ -45,7 +59,7 @@ def tearDown(self) -> None: self._run_tasks.__exit__(None, None, None) @cached_property - def sub(self): + def sub(self) -> QuerySubscription: data_source = self.metric_detector.data_sources.first() assert data_source is not None subscription_id = int(data_source.source_id) @@ -96,7 +110,7 @@ def set_up_data_conditions( critical_threshold: int, warning_threshold: int | None = None, resolve_threshold: int | None = None, - ): + ) -> None: if resolve_threshold is None: resolve_threshold = ( critical_threshold if warning_threshold is None else warning_threshold @@ -132,30 +146,30 @@ def metric_detector(self) -> Detector: return self.create_detector_data_source_and_data_conditions() @cached_property - def critical_threshold(self): + def critical_threshold(self) -> int | float: critical_detector_trigger = DataCondition.objects.get( condition_group=self.metric_detector.workflow_condition_group, condition_result=DetectorPriorityLevel.HIGH, ) - return critical_detector_trigger.comparison + return float(critical_detector_trigger.comparison) @cached_property - def warning_threshold(self): + def warning_threshold(self) -> int | float: warning_detector_trigger = DataCondition.objects.get( condition_group=self.metric_detector.workflow_condition_group, condition_result=DetectorPriorityLevel.MEDIUM, ) - return warning_detector_trigger.comparison + return float(warning_detector_trigger.comparison) @cached_property - def resolve_threshold(self): + def resolve_threshold(self) -> int | float: resolve_detector_trigger = DataCondition.objects.get( condition_group=self.metric_detector.workflow_condition_group, condition_result=DetectorPriorityLevel.OK, ) - return resolve_detector_trigger.comparison + return float(resolve_detector_trigger.comparison) - def get_snuba_query(self, detector: Detector): + def get_snuba_query(self, detector: Detector) -> SnubaQuery: data_source_detector = DataSourceDetector.objects.get(detector=detector) data_source = DataSource.objects.get(id=data_source_detector.data_source.id) query_subscription = QuerySubscription.objects.get(id=data_source.source_id) @@ -172,7 +186,12 @@ def update_threshold( detector_trigger.comparison = new_threshold detector_trigger.save() - def build_subscription_update(self, subscription, time_delta=None, value=EMPTY): + def build_subscription_update( + self, + subscription: QuerySubscription | None, + time_delta: timedelta | None = None, + value: object = EMPTY, + ) -> _SubscriptionUpdateMessage: if time_delta is not None: timestamp = timezone.now() + time_delta else: @@ -193,7 +212,12 @@ def build_subscription_update(self, subscription, time_delta=None, value=EMPTY): "offset": 1, } - def send_update(self, value, time_delta=None, subscription=None): + def send_update( + self, + value: object, + time_delta: timedelta | None = None, + subscription: QuerySubscription | None = None, + ) -> bool: if time_delta is None: time_delta = timedelta() if subscription is None: @@ -208,7 +232,9 @@ def send_update(self, value, time_delta=None, subscription=None): ), self.capture_on_commit_callbacks(execute=True), ): - return SubscriptionProcessor.process(subscription, message) + return SubscriptionProcessor.process( + subscription, cast(QuerySubscriptionUpdate, message) + ) def get_detector_state(self, detector: Detector) -> int: detector_state = DetectorState.objects.get(detector=detector) @@ -233,7 +259,9 @@ def test_uses_stored_last_update_value(self) -> None: ), self.capture_on_commit_callbacks(execute=True), ): - result = SubscriptionProcessor.process(self.sub, old_update_message) + result = SubscriptionProcessor.process( + self.sub, cast(QuerySubscriptionUpdate, old_update_message) + ) assert result is False @@ -268,6 +296,8 @@ def test_no_detector_returns_false_without_exception(self) -> None: ), self.capture_on_commit_callbacks(execute=True), ): - result = SubscriptionProcessor.process(subscription_without_detector, message) + result = SubscriptionProcessor.process( + subscription_without_detector, cast(QuerySubscriptionUpdate, message) + ) assert result is False diff --git a/tests/sentry/incidents/test_charts.py b/tests/sentry/incidents/test_charts.py index 863b1f7ff43d..7de1b5b17d6b 100644 --- a/tests/sentry/incidents/test_charts.py +++ b/tests/sentry/incidents/test_charts.py @@ -222,6 +222,7 @@ def test_get_open_periods_from_detector(self) -> None: assert opened_activity_resp["id"] == str(opened_gopa.id) assert opened_activity_resp["type"] == OpenPeriodActivityType(opened_gopa.type).to_str() + assert group.priority is not None assert opened_activity_resp["value"] == PriorityLevel(group.priority).to_str() assert opened_activity_resp["dateCreated"] == opened_gopa.date_added @@ -284,6 +285,7 @@ def test_use_open_period_serializer(self) -> None: activities = chart_data[0]["activities"] assert activities[0]["id"] == str(opened_gopa.id) assert activities[0]["type"] == OpenPeriodActivityType(opened_gopa.type).to_str() + assert group.priority is not None assert activities[0]["value"] == PriorityLevel(group.priority).to_str() @freeze_time(frozen_time) @@ -321,4 +323,5 @@ def test_use_open_period_serializer_with_offset(self) -> None: activities = chart_data[0]["activities"] assert activities[0]["id"] == str(opened_gopa.id) assert activities[0]["type"] == OpenPeriodActivityType(opened_gopa.type).to_str() + assert group.priority is not None assert activities[0]["value"] == PriorityLevel(group.priority).to_str() diff --git a/tests/sentry/incidents/test_metric_issue_detector_handler.py b/tests/sentry/incidents/test_metric_issue_detector_handler.py index cd6ee4e9a0aa..914642568aa4 100644 --- a/tests/sentry/incidents/test_metric_issue_detector_handler.py +++ b/tests/sentry/incidents/test_metric_issue_detector_handler.py @@ -30,7 +30,7 @@ def generate_evidence_data( value: int, detector_trigger: DataCondition, extra_trigger: DataCondition | None = None, - ): + ) -> dict[str, object]: self.query_subscription.refresh_from_db() conditions = [ @@ -87,7 +87,10 @@ def generate_evidence_data( return evidence_data def verify_issue_occurrence( - self, occurrence: IssueOccurrence, evidence_data: dict, detector_trigger: DataCondition + self, + occurrence: IssueOccurrence, + evidence_data: dict[str, object], + detector_trigger: DataCondition, ) -> None: assert occurrence is not None assert occurrence.issue_title == self.detector.name diff --git a/tests/sentry/incidents/test_metric_issue_post_process.py b/tests/sentry/incidents/test_metric_issue_post_process.py index 26e5bc5fc7ac..96ba620291b8 100644 --- a/tests/sentry/incidents/test_metric_issue_post_process.py +++ b/tests/sentry/incidents/test_metric_issue_post_process.py @@ -10,7 +10,7 @@ from sentry.tasks.post_process import post_process_group from sentry.types.activity import ActivityType from sentry.types.group import GroupSubStatus -from sentry.workflow_engine.models import Detector +from sentry.workflow_engine.models import Action, Detector from sentry.workflow_engine.models.data_condition import Condition from sentry.workflow_engine.types import DetectorPriorityLevel from tests.sentry.incidents.utils.test_metric_issue_base import BaseMetricIssueTest @@ -23,7 +23,7 @@ def setUp(self) -> None: super().setUp() self.critical_action, self.warning_action = self.create_metric_issue_workflow(self.detector) - def create_metric_issue_workflow(self, detector: Detector): + def create_metric_issue_workflow(self, detector: Detector) -> tuple[Action, Action]: # create the canonical workflow for a metric issue workflow = self.create_workflow() self.create_detector_workflow(detector=detector, workflow=workflow) @@ -86,7 +86,7 @@ def create_metric_issue_workflow(self, detector: Detector): warning_action, ) - def call_post_process_group(self, occurrence): + def call_post_process_group(self, occurrence: IssueOccurrence) -> None: stored_occurrence = IssueOccurrence.fetch(occurrence.id, occurrence.project_id) assert stored_occurrence event = eventstore.backend.get_event_by_id( @@ -105,7 +105,7 @@ def call_post_process_group(self, occurrence): eventstream_type=EventStreamEventType.Generic.value, ) - def get_group(self, occurrence): + def get_group(self, occurrence: IssueOccurrence) -> Group: stored_occurrence = IssueOccurrence.fetch(occurrence.id, occurrence.project_id) assert stored_occurrence event = eventstore.backend.get_event_by_id( diff --git a/tests/sentry/incidents/utils/test_format_duration.py b/tests/sentry/incidents/utils/test_format_duration.py index 1a53d5f2f438..4f77145915c0 100644 --- a/tests/sentry/incidents/utils/test_format_duration.py +++ b/tests/sentry/incidents/utils/test_format_duration.py @@ -21,5 +21,5 @@ @pytest.mark.parametrize("minutes, expected_str", TEST_CASES) -def test_format_duration(minutes, expected_str) -> None: +def test_format_duration(minutes: int, expected_str: str) -> None: assert expected_str == format_duration_idiomatic(minutes) diff --git a/tests/sentry/incidents/utils/test_metric_issue_base.py b/tests/sentry/incidents/utils/test_metric_issue_base.py index 47a508b9ab36..797c8f8020f0 100644 --- a/tests/sentry/incidents/utils/test_metric_issue_base.py +++ b/tests/sentry/incidents/utils/test_metric_issue_base.py @@ -91,7 +91,7 @@ def create_subscription_packet( ) def process_packet_and_return_result( - self, data_packet: DataPacket + self, data_packet: DataPacket[ProcessedSubscriptionUpdate] ) -> IssueOccurrence | StatusChangeMessage | None: results = process_data_packet(data_packet, DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION) if not results: diff --git a/tests/sentry/incidents/utils/test_process_update_helpers.py b/tests/sentry/incidents/utils/test_process_update_helpers.py index 57f98a729f9c..68c7284980b7 100644 --- a/tests/sentry/incidents/utils/test_process_update_helpers.py +++ b/tests/sentry/incidents/utils/test_process_update_helpers.py @@ -74,7 +74,7 @@ def test_missing_issue_ids_returns_none_gracefully(self) -> None: class GetAggregationValueHelperTest(TestCase): - def _update(self, value): + def _update(self, value: object) -> QuerySubscriptionUpdate: from sentry.incidents.utils.types import QuerySubscriptionUpdate update: QuerySubscriptionUpdate = { diff --git a/tests/sentry/incidents/utils/test_subscription_limits.py b/tests/sentry/incidents/utils/test_subscription_limits.py index 084eb8f8fcc6..b45a473fa4df 100644 --- a/tests/sentry/incidents/utils/test_subscription_limits.py +++ b/tests/sentry/incidents/utils/test_subscription_limits.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from contextlib import contextmanager from unittest.mock import MagicMock, patch @@ -10,7 +11,7 @@ class TestIsMetricSubscriptionAllowed: org = MagicMock(spec=Organization) @contextmanager - def fake_features(self, enabled: set[str]): + def fake_features(self, enabled: set[str]) -> Iterator[None]: with patch("sentry.incidents.utils.subscription_limits.features") as mock_features: mock_features.has.side_effect = lambda name, *a, **kw: name in enabled yield diff --git a/tests/sentry/ingest/ingest_consumer/test_attachment_event.py b/tests/sentry/ingest/ingest_consumer/test_attachment_event.py index dcf0f15e6e7f..4dc6ef29b1a0 100644 --- a/tests/sentry/ingest/ingest_consumer/test_attachment_event.py +++ b/tests/sentry/ingest/ingest_consumer/test_attachment_event.py @@ -7,6 +7,7 @@ import pytest from arroyo.backends.kafka import KafkaPayload from arroyo.types import BrokerValue, Message, Partition, Topic +from django.core.cache.backends.base import BaseCache from sentry.attachments import MissingAttachmentChunks, attachment_cache from sentry.ingest.consumer.attachment_event import decode_and_process_chunks @@ -65,7 +66,7 @@ def load_chunk(event_id: str) -> bytes: ids=["normal_mode", "recovery_not_stuck", "recovery_stuck_event", "recovery_stuck_transaction"], ) def test_decode_and_process_chunks_restores_chunks_only_for_stuck_events( - django_cache, + django_cache: BaseCache, reprocess_only_stuck_events: bool, stuck_in: str | None, expect_stored: bool, diff --git a/tests/sentry/ingest/ingest_consumer/test_dlq.py b/tests/sentry/ingest/ingest_consumer/test_dlq.py index 8884cdd19b47..0d45610758db 100644 --- a/tests/sentry/ingest/ingest_consumer/test_dlq.py +++ b/tests/sentry/ingest/ingest_consumer/test_dlq.py @@ -13,10 +13,11 @@ from sentry.event_manager import EventManager from sentry.ingest.consumer.factory import IngestStrategyFactory from sentry.ingest.types import ConsumerType +from sentry.testutils.factories import Factories from sentry.testutils.pytest.fixtures import django_db_all -def make_message(payload: bytes, partition: Partition, offset: int) -> Message: +def make_message(payload: bytes, partition: Partition, offset: int) -> Message[KafkaPayload]: return Message( BrokerValue( KafkaPayload(None, payload, []), @@ -36,7 +37,9 @@ def make_message(payload: bytes, partition: Partition, offset: int) -> Message: ], ) @django_db_all -def test_dlq_invalid_messages(factories, topic_name: str, consumer_type: ConsumerType) -> None: +def test_dlq_invalid_messages( + factories: Factories, topic_name: str, consumer_type: ConsumerType +) -> None: # Test is for all consumers that share the IngestStrategyFactory # Feedback test is located in feedback/consumers organization = factories.create_organization() diff --git a/tests/sentry/ingest/ingest_consumer/test_ingest_consumer_kafka.py b/tests/sentry/ingest/ingest_consumer/test_ingest_consumer_kafka.py index f1c778f2ae28..e1f4524e178e 100644 --- a/tests/sentry/ingest/ingest_consumer/test_ingest_consumer_kafka.py +++ b/tests/sentry/ingest/ingest_consumer/test_ingest_consumer_kafka.py @@ -2,20 +2,25 @@ import random import time import uuid +from collections.abc import Callable +from typing import ContextManager import msgpack import orjson import pytest +from confluent_kafka import Producer from django.conf import settings from sentry import nodestore from sentry.conf.types.kafka_definition import Topic from sentry.consumers import get_stream_processor from sentry.event_manager import EventManager +from sentry.models.project import Project from sentry.services import eventstore from sentry.services.eventstore.models import Event from sentry.services.eventstore.processing import event_processing_store from sentry.testutils.pytest.fixtures import django_db_all +from sentry.testutils.pytest.kafka import _KafkaAdminWrapper from sentry.testutils.skips import requires_kafka, requires_snuba from sentry.testutils.thread_leaks.pytest import thread_leak_allowlist from sentry.utils.batching_kafka_consumer import create_topics @@ -38,12 +43,14 @@ @pytest.fixture -def get_test_message(default_project): +def get_test_message( + default_project: Project, +) -> Callable[[str, Project], tuple[bytes, str]]: """ creates a test message to be inserted in a kafka queue """ - def inner(type, project=default_project): + def inner(type: str, project: Project = default_project) -> tuple[bytes, str]: # the event id should be 32 digits event_id = uuid.uuid4().hex message_text = f"some message {event_id}" @@ -82,13 +89,13 @@ def random_group_id() -> str: @django_db_all(transaction=True) def test_ingest_consumer_reads_from_topic_and_calls_task( - task_runner, - kafka_producer, - kafka_admin, - default_project, - get_test_message, - random_group_id, -): + task_runner: Callable[[], ContextManager[None]], + kafka_producer: Callable[[object], Producer], + kafka_admin: Callable[[object], _KafkaAdminWrapper], + default_project: Project, + get_test_message: Callable[[str, Project], tuple[bytes, str]], + random_group_id: str, +) -> None: topic = Topic.INGEST_EVENTS topic_event_name = get_topic_definition(topic)["real_topic_name"] @@ -98,7 +105,7 @@ def test_ingest_consumer_reads_from_topic_and_calls_task( create_topics("default", [topic_event_name]) - message, event_id = get_test_message(type="event") + message, event_id = get_test_message("event", default_project) producer.produce(topic_event_name, message) consumer = get_stream_processor( @@ -114,28 +121,29 @@ def test_ingest_consumer_reads_from_topic_and_calls_task( with task_runner(): i = 0 while i < MAX_POLL_ITERATIONS: - message = eventstore.backend.get_event_by_id(default_project.id, event_id) + stored_event = eventstore.backend.get_event_by_id(default_project.id, event_id) - if message: + if stored_event: break consumer._run_once() i += 1 # check that we got the messages - assert message.data["event_id"] == event_id - assert message.data["extra"]["the_id"] == event_id + assert stored_event is not None + assert stored_event.data["event_id"] == event_id + assert stored_event.data["extra"]["the_id"] == event_id @django_db_all(transaction=True) def test_ingest_consumer_gets_event_unstuck( - task_runner, - kafka_producer, - kafka_admin, - default_project, - get_test_message, - random_group_id, -): + task_runner: Callable[[], ContextManager[None]], + kafka_producer: Callable[[object], Producer], + kafka_admin: Callable[[object], _KafkaAdminWrapper], + default_project: Project, + get_test_message: Callable[[str, Project], tuple[bytes, str]], + random_group_id: str, +) -> None: topic = Topic.INGEST_EVENTS topic_event_name = get_topic_definition(topic)["real_topic_name"] @@ -145,10 +153,10 @@ def test_ingest_consumer_gets_event_unstuck( create_topics("default", [topic_event_name]) - message1, event_id1 = get_test_message(type="event") + message1, event_id1 = get_test_message("event", default_project) producer.produce(topic_event_name, message1) - message2, event_id2 = get_test_message(type="event") + message2, event_id2 = get_test_message("event", default_project) producer.produce(topic_event_name, message2) # an event is "stuck" when it is in the processing store, so lets fake that: @@ -191,13 +199,13 @@ def test_ingest_consumer_gets_event_unstuck( @django_db_all(transaction=True) def test_ingest_consumer_reprocesses_events_not_in_nodestore( - task_runner, - kafka_producer, - kafka_admin, - default_project, - get_test_message, - random_group_id, -): + task_runner: Callable[[], ContextManager[None]], + kafka_producer: Callable[[object], Producer], + kafka_admin: Callable[[object], _KafkaAdminWrapper], + default_project: Project, + get_test_message: Callable[[str, Project], tuple[bytes, str]], + random_group_id: str, +) -> None: topic = Topic.INGEST_EVENTS topic_event_name = get_topic_definition(topic)["real_topic_name"] @@ -207,10 +215,10 @@ def test_ingest_consumer_reprocesses_events_not_in_nodestore( create_topics("default", [topic_event_name]) - message1, event_id1 = get_test_message(type="event") + message1, event_id1 = get_test_message("event", default_project) producer.produce(topic_event_name, message1) - message2, event_id2 = get_test_message(type="event") + message2, event_id2 = get_test_message("event", default_project) producer.produce(topic_event_name, message2) # the first event already made it to `nodestore`, so lets fake that: diff --git a/tests/sentry/ingest/ingest_consumer/test_ingest_consumer_processing.py b/tests/sentry/ingest/ingest_consumer/test_ingest_consumer_processing.py index 5bd7019e1fda..5239ea7ab091 100644 --- a/tests/sentry/ingest/ingest_consumer/test_ingest_consumer_processing.py +++ b/tests/sentry/ingest/ingest_consumer/test_ingest_consumer_processing.py @@ -4,9 +4,11 @@ import time import uuid import zipfile +from collections.abc import Callable, Generator +from contextlib import AbstractContextManager from io import BytesIO -from typing import Any -from unittest.mock import patch +from typing import Any, ContextManager +from unittest.mock import MagicMock, patch import msgpack import orjson @@ -16,7 +18,9 @@ from arroyo.backends.local.storages.memory import MemoryMessageStorage from arroyo.types import Partition, Topic from django.conf import settings +from django.core.cache.backends.base import BaseCache from django.utils import timezone +from pytest_django.live_server_helper import LiveServer from sentry.attachments import ( CachedAttachment, @@ -42,6 +46,7 @@ from sentry.lang.native.utils import STORE_CRASH_REPORTS_ALL from sentry.models.debugfile import create_files_from_dif_zip from sentry.models.eventattachment import V2_PREFIX, EventAttachment, PendingEventAttachment +from sentry.models.project import Project from sentry.models.userreport import UserReport from sentry.objectstore import UsecaseId, get_session from sentry.services import eventstore @@ -50,7 +55,7 @@ transaction_processing_store, ) from sentry.tasks.store import save_event_attachments -from sentry.testutils.factories import get_fixture_path +from sentry.testutils.factories import Factories, get_fixture_path from sentry.testutils.helpers.features import Feature from sentry.testutils.helpers.options import override_options from sentry.testutils.helpers.usage_accountant import usage_accountant_backend @@ -74,29 +79,29 @@ """ -def get_normalized_event(data, project): +def get_normalized_event(data: dict[str, Any], project: Project) -> dict[str, Any]: mgr = EventManager(data, project=project) mgr.normalize() return dict(mgr.get_data()) @pytest.fixture -def save_event_transaction(): +def save_event_transaction() -> Generator[MagicMock]: with patch("sentry.ingest.consumer.processors.save_event_transaction") as mck: yield mck @pytest.fixture -def save_event_feedback(): +def save_event_feedback() -> Generator[MagicMock]: with patch("sentry.ingest.consumer.processors.save_event_feedback") as mck: yield mck @pytest.fixture -def preprocess_event(): +def preprocess_event() -> Generator[list[dict[str, object]]]: calls = [] - def inner(**kwargs): + def inner(**kwargs: object) -> None: calls.append(kwargs) with patch("sentry.ingest.consumer.processors.preprocess_event", inner): @@ -108,12 +113,12 @@ def inner(**kwargs): "rate,disable_store,expected_writes", [(0.0, True, 1), (1.0, False, 1), (1.0, True, 0)] ) def test_ingest_conditions_working_payload_write( - default_project, - preprocess_event, - rate, - disable_store, - expected_writes, -): + default_project: Project, + preprocess_event: list[dict[str, object]], + rate: float, + disable_store: bool, + expected_writes: int, +) -> None: data = get_normalized_event({"message": "inline event"}, default_project) data["type"] = "error" with ( @@ -142,7 +147,9 @@ def test_ingest_conditions_working_payload_write( @django_db_all -def test_ingest_inline_only_event_saves_without_working_payload(default_project, task_runner): +def test_ingest_inline_only_event_saves_without_working_payload( + default_project: Project, task_runner: Callable[[], ContextManager[None]] +) -> None: payload = get_normalized_event({"message": "inline event"}, default_project) event_id = payload["event_id"] with ( @@ -173,7 +180,11 @@ def test_ingest_inline_only_event_saves_without_working_payload(default_project, @django_db_all -def test_deduplication_works(default_project, task_runner, preprocess_event) -> None: +def test_deduplication_works( + default_project: Project, + task_runner: Callable[[], ContextManager[None]], + preprocess_event: list[dict[str, object]], +) -> None: payload = get_normalized_event({"message": "hello world"}, default_project) event_id = payload["event_id"] project_id = default_project.id @@ -204,7 +215,9 @@ def test_deduplication_works(default_project, task_runner, preprocess_event) -> @django_db_all -def test_process_event_from_kafka(default_project, preprocess_event) -> None: +def test_process_event_from_kafka( + default_project: Project, preprocess_event: list[dict[str, object]] +) -> None: payload = get_normalized_event({"message": "hello world"}, default_project) event_id = payload["event_id"] project_id = default_project.id @@ -236,7 +249,7 @@ def test_process_event_from_kafka(default_project, preprocess_event) -> None: @django_db_all def test_process_event_from_kafka_inlines_preprocess_save_event_with_option( - default_project, preprocess_event + default_project: Project, preprocess_event: list[dict[str, object]] ) -> None: payload = get_normalized_event({"message": "hello world"}, default_project) event_id = payload["event_id"] @@ -271,9 +284,9 @@ def test_process_event_from_kafka_inlines_preprocess_save_event_with_option( @django_db_all def test_process_event_from_kafka_transaction_spawns_save_event_transaction_by_default( - default_project, - preprocess_event, - save_event_transaction, + default_project: Project, + preprocess_event: list[dict[str, object]], + save_event_transaction: MagicMock, ) -> None: project_id = default_project.id now = datetime.datetime.now() @@ -323,9 +336,9 @@ def test_process_event_from_kafka_transaction_spawns_save_event_transaction_by_d @django_db_all def test_process_event_from_kafka_transaction_saves_inline_with_option( - default_project, - preprocess_event, - save_event_transaction, + default_project: Project, + preprocess_event: list[dict[str, object]], + save_event_transaction: MagicMock, ) -> None: project_id = default_project.id now = datetime.datetime.now() @@ -378,12 +391,12 @@ def test_process_event_from_kafka_transaction_saves_inline_with_option( @pytest.mark.parametrize("disable_store,expected_writes", [(False, 1), (True, 0)]) @pytest.mark.parametrize("save_synchronously", (False, True)) def test_inline_transaction( - default_project, - save_event_transaction, - disable_store, - expected_writes, - save_synchronously, -): + default_project: Project, + save_event_transaction: MagicMock, + disable_store: bool, + expected_writes: int, + save_synchronously: bool, +) -> None: data = get_normalized_event( { "type": "transaction", @@ -426,11 +439,11 @@ def test_inline_transaction( @django_db_all def test_transactions_spawn_save_event_transaction( - default_project, - task_runner, - preprocess_event, - save_event_transaction, -): + default_project: Project, + task_runner: Callable[[], ContextManager[None]], + preprocess_event: list[dict[str, object]], + save_event_transaction: MagicMock, +) -> None: project_id = default_project.id now = datetime.datetime.now() event = { @@ -475,7 +488,7 @@ def test_transactions_spawn_save_event_transaction( @django_db_all -def test_accountant_transaction(default_project) -> None: +def test_accountant_transaction(default_project: Project) -> None: storage: MemoryMessageStorage[KafkaPayload] = MemoryMessageStorage() broker = LocalBroker(storage) topic = Topic("shared-resources-usage") @@ -521,9 +534,9 @@ def test_accountant_transaction(default_project) -> None: msg1 = broker.consume(Partition(topic, 0), 0) assert msg1 is not None - payload = msg1.payload - assert payload is not None - formatted = loads(payload.value.decode("utf-8")) + consumed_payload = msg1.payload + assert consumed_payload is not None + formatted = loads(consumed_payload.value.decode("utf-8")) assert formatted["shared_resource_id"] == settings.EVENT_PROCESSING_STORE assert formatted["app_feature"] == "transactions" assert formatted["usage_unit"] == "bytes" @@ -532,8 +545,11 @@ def test_accountant_transaction(default_project) -> None: @django_db_all def test_feedbacks_spawn_save_event_feedback( - default_project, task_runner, preprocess_event, save_event_feedback -): + default_project: Project, + task_runner: Callable[[], ContextManager[None]], + preprocess_event: list[dict[str, object]], + save_event_feedback: MagicMock, +) -> None: with patch("sentry.features.has", return_value=True): project_id = default_project.id now = datetime.datetime.now() @@ -584,7 +600,12 @@ def test_feedbacks_spawn_save_event_feedback( @django_db_all @pytest.mark.parametrize("missing_chunks", (True, False)) -def test_with_attachments(default_project, task_runner, missing_chunks, django_cache) -> None: +def test_with_attachments( + default_project: Project, + task_runner: Callable[[], ContextManager[None]], + missing_chunks: bool, + django_cache: BaseCache, +) -> None: retention_days = 66 with patch("sentry.features.has", return_value=True): @@ -658,7 +679,10 @@ def test_with_attachments(default_project, task_runner, missing_chunks, django_c @django_db_all @pytest.mark.parametrize("cache_key", (None, "", "e:working-event")) def test_save_cached_attachments_with_optional_event_cache_key( - default_project, task_runner, django_cache, cache_key + default_project: Project, + task_runner: Callable[[], ContextManager[None]], + django_cache: BaseCache, + cache_key: str | None, ) -> None: payload = get_normalized_event({"message": "hello world"}, default_project) event_id = payload["event_id"] @@ -715,7 +739,12 @@ class TestDeobfuscateViewHierarchy: @django_db_all @requires_symbolicator @thread_leak_allowlist(reason="django dev server", issue=97036) - def test_deobfuscate_view_hierarchy(self, default_project, task_runner, live_server) -> None: + def test_deobfuscate_view_hierarchy( + self, + default_project: Project, + task_runner: Callable[[], ContextManager[None]], + live_server: LiveServer, + ) -> None: with override_options({"system.url-prefix": live_server.url}): do_process_view_hierarchy(default_project, task_runner) @@ -724,7 +753,10 @@ def test_deobfuscate_view_hierarchy(self, default_project, task_runner, live_ser @requires_symbolicator @thread_leak_allowlist(reason="django dev server", issue=97036) def test_deobfuscate_view_hierarchy_with_objectstore_attachment( - self, default_project, task_runner, live_server + self, + default_project: Project, + task_runner: Callable[[], ContextManager[None]], + live_server: LiveServer, ) -> None: with override_options({"system.url-prefix": live_server.url}): # this stores the attachment during processing in objectstore: @@ -739,7 +771,10 @@ class TestSymbolicateFlamegraph: @requires_symbolicator @thread_leak_allowlist(reason="django dev server", issue=97036) def test_symbolicate_flamegraph_attachment( - self, default_project, task_runner, live_server + self, + default_project: Project, + task_runner: Callable[[], ContextManager[None]], + live_server: LiveServer, ) -> None: do_process_flamegraph(default_project, task_runner, live_server) @@ -748,12 +783,20 @@ def test_symbolicate_flamegraph_attachment( @requires_symbolicator @thread_leak_allowlist(reason="django dev server", issue=97036) def test_symbolicate_stored_flamegraph_attachment( - self, default_project, task_runner, live_server + self, + default_project: Project, + task_runner: Callable[[], ContextManager[None]], + live_server: LiveServer, ) -> None: do_process_flamegraph(default_project, task_runner, live_server, use_objectstore=True) -def do_process_flamegraph(default_project, task_runner, live_server, use_objectstore=False): +def do_process_flamegraph( + default_project: Project, + task_runner: Callable[[], ContextManager[None]], + live_server: LiveServer, + use_objectstore: bool = False, +) -> None: default_project.update_option("sentry:builtin_symbol_sources", []) event = get_normalized_event( {"platform": "other", "message": "Flamegraph symbolication"}, default_project @@ -841,7 +884,9 @@ def do_process_flamegraph(default_project, task_runner, live_server, use_objects assert orjson.loads(stored.payload.read()) == processed -def do_process_view_hierarchy(project, task_runner, use_objectstore=False): +def do_process_view_hierarchy( + project: Project, task_runner: Callable[[], ContextManager[None]], use_objectstore: bool = False +) -> None: payload = get_normalized_event( { "message": "hello world", @@ -933,7 +978,10 @@ def do_process_view_hierarchy(project, task_runner, use_objectstore=False): @requires_symbolicator @thread_leak_allowlist(reason="django dev server", issue=97036) def test_process_stored_attachment( - default_project, task_runner, set_sentry_option, live_server + default_project: Project, + task_runner: Callable[[], ContextManager[None]], + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], + live_server: LiveServer, ) -> None: with set_sentry_option("system.url-prefix", live_server.url): payload = get_normalized_event( @@ -1019,8 +1067,13 @@ def test_process_stored_attachment( ) @pytest.mark.parametrize("with_group", [True, False], ids=["with_group", "without_group"]) def test_individual_attachments( - default_project, factories, feature_enabled, attachment, with_group, django_cache -): + default_project: Project, + factories: Factories, + feature_enabled: bool, + attachment: tuple[bytes | list[bytes], str, str], + with_group: bool, + django_cache: BaseCache, +) -> None: retention_days = 66 with patch("sentry.features.has", return_value=feature_enabled): @@ -1034,6 +1087,7 @@ def test_individual_attachments( data={"event_id": event_id, "message": "existence is pain"}, project_id=project_id ) + assert event.group is not None group_id = event.group.id assert group_id, "this test requires a group to work" @@ -1081,21 +1135,24 @@ def test_individual_attachments( if not feature_enabled: assert not attachments else: - (attachment,) = attachments - assert attachment.name == "foo.txt" + (stored_attachment,) = attachments + assert stored_attachment.name == "foo.txt" if with_group: - assert attachment.group_id == group_id - assert attachment.content_type == content_type + assert isinstance(stored_attachment, EventAttachment) + assert stored_attachment.group_id == group_id + assert stored_attachment.content_type == content_type - with attachment.getfile() as file_contents: + with stored_attachment.getfile() as file_contents: assert file_contents.read() == expected_content - delta = attachment.final_expiry_date() - (now + datetime.timedelta(days=retention_days)) + delta = stored_attachment.final_expiry_date() - ( + now + datetime.timedelta(days=retention_days) + ) assert abs(delta.total_seconds()) < 3600 @django_db_all -def test_userreport(django_cache, default_project) -> None: +def test_userreport(django_cache: BaseCache, default_project: Project) -> None: """ Test that user_report-type kafka messages end up in a user report being persisted. We additionally test some logic around upserting data in @@ -1133,7 +1190,9 @@ def test_userreport(django_cache, default_project) -> None: @django_db_all -def test_individual_attachment_before_event(django_cache, default_project, factories) -> None: +def test_individual_attachment_before_event( + django_cache: BaseCache, default_project: Project, factories: Factories +) -> None: """ An attachment that is ingested before its event is parked in `PendingEventAttachment` and promoted once the event arrives. @@ -1221,7 +1280,9 @@ def test_individual_attachment_before_event(django_cache, default_project, facto @django_db_all -def test_individual_attachment_before_transaction(django_cache, default_project) -> None: +def test_individual_attachment_before_transaction( + django_cache: BaseCache, default_project: Project +) -> None: """ An attachment can also be parked ahead of a *transaction*. @@ -1321,7 +1382,7 @@ def test_individual_attachment_before_transaction(django_cache, default_project) @django_db_all -def test_userreport_reverse_order(django_cache, default_project) -> None: +def test_userreport_reverse_order(django_cache: BaseCache, default_project: Project) -> None: """ Test that ingesting a userreport before the event works. This is relevant for unreal crashes where the userreport is processed immediately in the @@ -1364,7 +1425,9 @@ def test_userreport_reverse_order(django_cache, default_project) -> None: @django_db_all -def test_individual_attachments_missing_chunks(default_project, factories) -> None: +def test_individual_attachments_missing_chunks( + default_project: Project, factories: Factories +) -> None: with patch("sentry.features.has", return_value=True): event_id = "515539018c9b4260a6f999572f1661ee" attachment_id = "ca90fb45-6dd9-40a0-a18f-8693aa621abb" @@ -1392,7 +1455,7 @@ def test_individual_attachments_missing_chunks(default_project, factories) -> No @django_db_all -def test_collect_span_metrics(default_project) -> None: +def test_collect_span_metrics(default_project: Project) -> None: with Feature({"organizations:dynamic-sampling": True, "organization:am3-tier": True}): with patch("sentry.ingest.consumer.processors.metrics") as mock_metrics: assert mock_metrics.incr.call_count == 0 diff --git a/tests/sentry/ingest/ingest_consumer/test_ingest_consumer_transactions.py b/tests/sentry/ingest/ingest_consumer/test_ingest_consumer_transactions.py index f00e548da213..d4a3a5dd9b8b 100644 --- a/tests/sentry/ingest/ingest_consumer/test_ingest_consumer_transactions.py +++ b/tests/sentry/ingest/ingest_consumer/test_ingest_consumer_transactions.py @@ -2,17 +2,22 @@ import random import time import uuid +from collections.abc import Callable +from typing import ContextManager import msgpack import orjson import pytest +from confluent_kafka import Producer from django.conf import settings from sentry.conf.types.kafka_definition import Topic from sentry.consumers import get_stream_processor from sentry.event_manager import EventManager +from sentry.models.project import Project from sentry.services import eventstore from sentry.testutils.pytest.fixtures import django_db_all +from sentry.testutils.pytest.kafka import _KafkaAdminWrapper from sentry.testutils.skips import requires_kafka, requires_snuba from sentry.testutils.thread_leaks.pytest import thread_leak_allowlist from sentry.utils.batching_kafka_consumer import create_topics @@ -24,7 +29,7 @@ MAX_POLL_ITERATIONS = 100 -def get_test_message(project): +def get_test_message(project: Project) -> tuple[bytes, str]: """ creates a test message to be inserted in a kafka queue """ @@ -73,12 +78,12 @@ def random_group_id() -> str: @django_db_all(transaction=True) @thread_leak_allowlist(reason="ingest consumers", issue=97037) def test_ingest_consumer_reads_from_topic_and_saves_event( - task_runner, - kafka_producer, - kafka_admin, - default_project, - random_group_id, -): + task_runner: Callable[[], ContextManager[None]], + kafka_producer: Callable[[object], Producer], + kafka_admin: Callable[[object], _KafkaAdminWrapper], + default_project: Project, + random_group_id: str, +) -> None: topic = Topic.INGEST_TRANSACTIONS topic_event_name = get_topic_definition(topic)["real_topic_name"] diff --git a/tests/sentry/ingest/test_inbound_filters.py b/tests/sentry/ingest/test_inbound_filters.py index 5c1a6fda4544..a0a649460b63 100644 --- a/tests/sentry/ingest/test_inbound_filters.py +++ b/tests/sentry/ingest/test_inbound_filters.py @@ -19,6 +19,7 @@ ) from sentry.models.custominboundfilter import LegacyFilter from sentry.models.project import Project +from sentry.testutils.factories import Factories from sentry.testutils.pytest.fixtures import django_db_all from sentry.utils import json @@ -203,7 +204,8 @@ def test_global_generic_filters_omit_custom_error_without_values() -> None: @django_db_all @pytest.mark.parametrize("inbound_filters_custom_error_values", [[], CUSTOM_PATTERNS]) def test_shared_filter_references( - default_project: Project, inbound_filters_custom_error_values + default_project: Project, + inbound_filters_custom_error_values: list[tuple[str | None, str | None]], ) -> None: with override_settings(SENTRY_INBOUND_FILTER_CUSTOM_VALUES=inbound_filters_custom_error_values): generic_filters = get_generic_filters(default_project, InboundFilterFeatures()) @@ -277,7 +279,7 @@ def assert_relay_accepts_condition(condition: object) -> None: validate_rule_condition(json.dumps(condition)) -def error_message_rule_condition(values: list[str]) -> dict: +def error_message_rule_condition(values: list[str]) -> dict[str, object]: return { "op": "or", "inner": [ @@ -297,7 +299,7 @@ def error_message_rule_condition(values: list[str]) -> dict: } -def error_type_rule_condition(values: list[str]) -> dict: +def error_type_rule_condition(values: list[str]) -> dict[str, object]: return { "op": "any", "name": "event.exception.values", @@ -313,7 +315,7 @@ def error_type_rule_condition(values: list[str]) -> dict: ] -def release_rule_condition(values: list[str]) -> dict: +def release_rule_condition(values: list[str]) -> dict[str, object]: """The catch-all shape: the release field of every data type, combined with OR.""" return { "op": "or", @@ -321,7 +323,7 @@ def release_rule_condition(values: list[str]) -> dict: } -def release_version_rule_condition(comparator: str, release: str) -> dict: +def release_version_rule_condition(comparator: str, release: str) -> dict[str, object]: """The catch-all shape of a version comparison.""" return { "op": "or", @@ -652,7 +654,11 @@ def release_version_rule_condition(comparator: str, release: str) -> dict: ], ) def test_custom_inbound_filter_condition_translation( - default_project, factories, data_type, conditions, expected_condition + default_project: Project, + factories: Factories, + data_type: str, + conditions: list[dict[str, object]], + expected_condition: dict[str, object], ) -> None: custom_filter = factories.create_project_custom_inbound_filter( default_project, data_type=data_type, conditions=conditions @@ -668,7 +674,9 @@ def test_custom_inbound_filter_condition_translation( @django_db_all -def test_custom_inbound_filter_row_becomes_relay_config(default_project, factories) -> None: +def test_custom_inbound_filter_row_becomes_relay_config( + default_project: Project, factories: Factories +) -> None: # Golden test for the whole path: a stored filter row in, the exact generic filters # config Relay receives out. Spelled out literally so a change in the emitted JSON # has to be made deliberately here. @@ -748,7 +756,9 @@ def test_custom_inbound_filter_row_becomes_relay_config(default_project, factori @django_db_all -def test_custom_inbound_filter_skips_inactive_filters(default_project, factories) -> None: +def test_custom_inbound_filter_skips_inactive_filters( + default_project: Project, factories: Factories +) -> None: factories.create_project_custom_inbound_filter( default_project, active=False, @@ -759,7 +769,9 @@ def test_custom_inbound_filter_skips_inactive_filters(default_project, factories @django_db_all -def test_custom_inbound_filter_skips_untranslatable_filters(default_project, factories) -> None: +def test_custom_inbound_filter_skips_untranslatable_filters( + default_project: Project, factories: Factories +) -> None: # A condition that cannot be translated must disable the whole filter: conditions # are combined with AND, so dropping just the broken condition would filter more # data than the user configured. @@ -837,7 +849,9 @@ def test_custom_inbound_filter_skips_untranslatable_filters(default_project, fac @django_db_all -def test_custom_inbound_filters_are_ordered_by_id(default_project, factories) -> None: +def test_custom_inbound_filters_are_ordered_by_id( + default_project: Project, factories: Factories +) -> None: created = [ factories.create_project_custom_inbound_filter( default_project, @@ -854,7 +868,9 @@ def test_custom_inbound_filters_are_ordered_by_id(default_project, factories) -> @django_db_all @patch("sentry.ingest.inbound_filters.MAX_FILTERS_PER_PROJECT", 2) -def test_custom_inbound_filters_stop_at_the_project_cap(default_project, factories) -> None: +def test_custom_inbound_filters_stop_at_the_project_cap( + default_project: Project, factories: Factories +) -> None: # The API refuses to create a filter past the cap, so rows past it exist only if they # were made some other way. Relay still gets at most the cap, oldest first. factories.create_project_custom_inbound_filter( @@ -925,7 +941,10 @@ def test_custom_inbound_filters_stop_at_the_project_cap(default_project, factori ], ) def test_get_generic_filters_gates_each_source_on_its_feature( - default_project, factories, filter_features, expected_ids + default_project: Project, + factories: Factories, + filter_features: InboundFilterFeatures, + expected_ids: list[str], ) -> None: for builtin_filter_id, _ in ACTIVE_GENERIC_FILTERS: default_project.update_option(f"filters:{builtin_filter_id}", "0") @@ -950,7 +969,9 @@ def test_get_generic_filters_gates_each_source_on_its_feature( @django_db_all -def test_get_generic_filters_omits_gated_sources_without_configuration(default_project) -> None: +def test_get_generic_filters_omits_gated_sources_without_configuration( + default_project: Project, +) -> None: for builtin_filter_id, _ in ACTIVE_GENERIC_FILTERS: default_project.update_option(f"filters:{builtin_filter_id}", "0") @@ -961,7 +982,9 @@ def test_get_generic_filters_omits_gated_sources_without_configuration(default_p @django_db_all -def test_ip_denylist_becomes_a_generic_filter_with_the_native_reason(default_project) -> None: +def test_ip_denylist_becomes_a_generic_filter_with_the_native_reason( + default_project: Project, +) -> None: for builtin_filter_id, _ in ACTIVE_GENERIC_FILTERS: default_project.update_option(f"filters:{builtin_filter_id}", "0") default_project.update_option("sentry:blacklisted_ips", ["10.0.0.0/8", "2001:db8::1"]) diff --git a/tests/sentry/ingest/test_legacy_filter_lists.py b/tests/sentry/ingest/test_legacy_filter_lists.py index 43f4c3e5d7fe..d02127bc2281 100644 --- a/tests/sentry/ingest/test_legacy_filter_lists.py +++ b/tests/sentry/ingest/test_legacy_filter_lists.py @@ -8,11 +8,14 @@ set_list, ) from sentry.models.custominboundfilter import CustomInboundFilter, LegacyFilter +from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.testutils.factories import Factories from sentry.testutils.helpers.features import with_feature from sentry.testutils.pytest.fixtures import django_db_all -def rows_of(project) -> list[dict]: +def rows_of(project: Project) -> list[dict[str, object]]: return [ { "name": f.name, @@ -26,7 +29,7 @@ def rows_of(project) -> list[dict]: @django_db_all -def test_set_list_writes_the_option_and_one_row_with_every_line(default_project) -> None: +def test_set_list_writes_the_option_and_one_row_with_every_line(default_project: Project) -> None: lines = ["1.*", "# block staging until the fix ships", "1.*", "3.*"] set_list(default_project, FilterTypes.RELEASES, lines) @@ -44,7 +47,7 @@ def test_set_list_writes_the_option_and_one_row_with_every_line(default_project) @django_db_all def test_set_list_updates_the_lines_and_keeps_the_users_name_and_active_flag( - default_project, factories + default_project: Project, factories: Factories ) -> None: row = factories.create_project_custom_inbound_filter( default_project, @@ -72,7 +75,7 @@ def test_set_list_updates_the_lines_and_keeps_the_users_name_and_active_flag( @django_db_all -def test_set_list_removes_the_row_for_an_empty_list(default_project) -> None: +def test_set_list_removes_the_row_for_an_empty_list(default_project: Project) -> None: set_list(default_project, FilterTypes.RELEASES, ["1.*"]) set_list(default_project, FilterTypes.RELEASES, []) @@ -81,13 +84,15 @@ def test_set_list_removes_the_row_for_an_empty_list(default_project) -> None: @django_db_all -def test_served_legacy_filters_is_empty_without_the_feature(default_organization) -> None: +def test_served_legacy_filters_is_empty_without_the_feature( + default_organization: Organization, +) -> None: assert served_legacy_filters(default_organization) == [] @django_db_all @with_feature("organizations:inbound-filters-v2") -def test_the_feature_serves_every_list_of_the_org(default_organization) -> None: +def test_the_feature_serves_every_list_of_the_org(default_organization: Organization) -> None: assert served_legacy_filters(default_organization) == [ "release-version", "error-message", @@ -98,7 +103,7 @@ def test_the_feature_serves_every_list_of_the_org(default_organization) -> None: @django_db_all @with_feature("organizations:inbound-filters-v2") -def test_v2_reads_the_row(default_project, factories) -> None: +def test_v2_reads_the_row(default_project: Project, factories: Factories) -> None: default_project.update_option("sentry:releases", ["stale"]) factories.create_project_custom_inbound_filter( default_project, @@ -120,7 +125,9 @@ def test_v2_reads_the_row(default_project, factories) -> None: @django_db_all -def test_without_the_feature_reads_the_option(default_project, factories) -> None: +def test_without_the_feature_reads_the_option( + default_project: Project, factories: Factories +) -> None: default_project.update_option("sentry:releases", ["from the option"]) factories.create_project_custom_inbound_filter( default_project, @@ -136,7 +143,7 @@ def test_without_the_feature_reads_the_option(default_project, factories) -> Non @django_db_all @with_feature("organizations:inbound-filters-v2") -def test_v2_reads_an_empty_list_without_a_row(default_project) -> None: +def test_v2_reads_an_empty_list_without_a_row(default_project: Project) -> None: default_project.update_option("sentry:releases", ["stale"]) assert get_lines(default_project, FilterTypes.RELEASES) == [] @@ -146,7 +153,9 @@ def test_v2_reads_an_empty_list_without_a_row(default_project) -> None: @django_db_all @with_feature("organizations:inbound-filters-v2") -def test_v2_applies_nothing_while_the_row_is_switched_off(default_project, factories) -> None: +def test_v2_applies_nothing_while_the_row_is_switched_off( + default_project: Project, factories: Factories +) -> None: factories.create_project_custom_inbound_filter( default_project, active=False, diff --git a/tests/sentry/ingest/test_transaction_clusterer.py b/tests/sentry/ingest/test_transaction_clusterer.py index 7fdb5ef3eba5..9048f79e3e2d 100644 --- a/tests/sentry/ingest/test_transaction_clusterer.py +++ b/tests/sentry/ingest/test_transaction_clusterer.py @@ -1,4 +1,5 @@ from unittest import mock +from unittest.mock import MagicMock import pytest @@ -198,7 +199,12 @@ def test_distribution() -> None: ], ) def test_record_transactions( - mocked_record, default_organization, source, txname, tags, expected + mocked_record: MagicMock, + default_organization: Organization, + source: str | None, + txname: str | None, + tags: list[list[str]], + expected: int, ) -> None: project = Project(id=111, name="project", organization_id=default_organization.id) record_transaction_name( @@ -252,7 +258,7 @@ def test_max_rule_threshold_merge_composite_store(default_project: mock.MagicMoc @django_db_all -def test_save_rules(default_project) -> None: +def test_save_rules(default_project: Project) -> None: project = default_project project_rules = get_rules(ClustererNamespace.TRANSACTIONS, project) @@ -286,8 +292,10 @@ def test_save_rules(default_project) -> None: ) @django_db_all @freeze_time("2000-01-01 01:00:00") -def test_run_clusterer_task(cluster_projects_delay, default_organization) -> None: - def _add_mock_data(proj, number): +def test_run_clusterer_task( + cluster_projects_delay: MagicMock, default_organization: Organization +) -> None: + def _add_mock_data(proj: Project, number: int) -> None: for i in range(0, number): _record_sample(ClustererNamespace.TRANSACTIONS, proj, f"/user/tx-{proj.name}-{i}") _record_sample(ClustererNamespace.TRANSACTIONS, proj, f"/org/tx-{proj.name}-{i}") @@ -366,8 +374,10 @@ def _add_mock_data(proj, number): @mock.patch("sentry.ingest.transaction_clusterer.tasks.cluster_projects.delay") @django_db_all @freeze_time("2000-01-01 01:00:00") -def test_run_clusterer_spawn_cluster_projects(cluster_projects_delay, default_organization) -> None: - def _add_mock_data(proj, number): +def test_run_clusterer_spawn_cluster_projects( + cluster_projects_delay: MagicMock, default_organization: Organization +) -> None: + def _add_mock_data(proj: Project, number: int) -> None: for i in range(0, number): _record_sample(ClustererNamespace.TRANSACTIONS, proj, f"/user/tx-{proj.name}-{i}") _record_sample(ClustererNamespace.TRANSACTIONS, proj, f"/org/tx-{proj.name}-{i}") @@ -388,7 +398,9 @@ def _add_mock_data(proj, number): @mock.patch("sentry.ingest.transaction_clusterer.tasks.MERGE_THRESHOLD", 2) @mock.patch("sentry.ingest.transaction_clusterer.rules.update_rules") @django_db_all -def test_clusterer_only_runs_when_enough_transactions(mock_update_rules, default_project) -> None: +def test_clusterer_only_runs_when_enough_transactions( + mock_update_rules: MagicMock, default_project: Project +) -> None: project = default_project assert get_rules(ClustererNamespace.TRANSACTIONS, project) == {} @@ -413,7 +425,9 @@ def test_clusterer_only_runs_when_enough_transactions(mock_update_rules, default @mock.patch("sentry.ingest.transaction_clusterer.tasks.MERGE_THRESHOLD", 2) @mock.patch("sentry.ingest.transaction_clusterer.rules.update_rules") @django_db_all -def test_clusterer_skips_deleted_projects(mock_update_rules, default_project) -> None: +def test_clusterer_skips_deleted_projects( + mock_update_rules: MagicMock, default_project: Project +) -> None: project = default_project assert get_rules(ClustererNamespace.TRANSACTIONS, project) == {} @@ -433,8 +447,8 @@ def test_get_deleted_project() -> None: @django_db_all -def test_transaction_clusterer_generates_rules(default_project) -> None: - def _get_projconfig_tx_rules(project: Project): +def test_transaction_clusterer_generates_rules(default_project: Project) -> None: + def _get_projconfig_tx_rules(project: Project) -> object: return get_project_config(project).to_dict()["config"].get("txNameRules") feature = "organizations:transaction-name-normalize" @@ -471,7 +485,9 @@ def _get_projconfig_tx_rules(project: Project): wraps=cluster_projects, # call immediately ) @django_db_all -def test_transaction_clusterer_bumps_rules(_, default_organization) -> None: +def test_transaction_clusterer_bumps_rules( + _: MagicMock, default_organization: Organization +) -> None: project1 = Project(id=123, name="project1", organization_id=default_organization.id) project1.save() @@ -521,7 +537,7 @@ def test_transaction_clusterer_bumps_rules(_, default_organization) -> None: wraps=cluster_projects, # call immediately ) @django_db_all -def test_dont_store_inexisting_rules(_, default_organization) -> None: +def test_dont_store_inexisting_rules(_: MagicMock, default_organization: Organization) -> None: rogue_transaction = { "transaction": "/transaction/for/rogue/*/rule", "transaction_info": {"source": "sanitized"}, @@ -558,7 +574,7 @@ def test_dont_store_inexisting_rules(_, default_organization) -> None: @django_db_all -def test_stale_rules_arent_saved(default_project) -> None: +def test_stale_rules_arent_saved(default_project: Project) -> None: assert len(get_sorted_rules(ClustererNamespace.TRANSACTIONS, default_project)) == 0 with freeze_time("2000-01-01 01:00:00"): @@ -662,7 +678,7 @@ def test_update_rule_missing_rule_does_not_create_key(namespace: ClustererNamesp @django_db_all -def test_stored_invalid_rules_dropped_on_update(default_project) -> None: +def test_stored_invalid_rules_dropped_on_update(default_project: Project) -> None: """ Validate that invalid rules are removed from storage even if they already exist there. Updates before and after the removal don't impact the outcome. 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/bases/test_integration.py b/tests/sentry/integrations/api/bases/test_integration.py index bda6829a097d..81beda601e90 100644 --- a/tests/sentry/integrations/api/bases/test_integration.py +++ b/tests/sentry/integrations/api/bases/test_integration.py @@ -17,7 +17,7 @@ class IntegrationEndpointTest(TestCase): @patch("sentry_sdk.capture_exception") def test_handle_rest_framework_exception( self, mock_capture_exception: MagicMock, mock_stderror_write: MagicMock - ): + ) -> None: exc = APIException("There was a problem!") exc.status_code = 400 # not possible to set in init request = Request(HttpRequest()) @@ -35,7 +35,7 @@ def test_handle_rest_framework_exception( @patch("sentry.integrations.api.bases.integration.capture_exception") def test_handle_exception_503( self, mock_capture_exception: MagicMock, mock_stderror_write: MagicMock - ): + ) -> None: try: raise ApiError("This is an error", code=503) except ApiError as exc: @@ -56,7 +56,7 @@ def test_handle_exception_503( @patch("sentry.api.base.capture_exception") def test_handle_exception_stdlib( self, mock_capture_exception: MagicMock, mock_stderror_write: MagicMock - ): + ) -> None: try: raise ValueError("This is an error") except ValueError as exc: diff --git a/tests/sentry/integrations/api/endpoints/test_data_forwarding.py b/tests/sentry/integrations/api/endpoints/test_data_forwarding.py index a547515df4e8..74ce88f55c79 100644 --- a/tests/sentry/integrations/api/endpoints/test_data_forwarding.py +++ b/tests/sentry/integrations/api/endpoints/test_data_forwarding.py @@ -1,4 +1,5 @@ from django.urls import reverse +from rest_framework.response import Response from sentry.integrations.models.data_forwarder import DataForwarder from sentry.integrations.types import DataForwarderProviderSlug @@ -14,7 +15,7 @@ def setUp(self) -> None: super().setUp() self.login_as(user=self.user) - def get_response(self, *args, **kwargs): + def get_response(self, *args: object, **kwargs: object) -> Response: """ Override get_response to always add the required feature flag. """ diff --git a/tests/sentry/integrations/api/endpoints/test_data_forwarding_details.py b/tests/sentry/integrations/api/endpoints/test_data_forwarding_details.py index 1e6a4e7ec160..afdbf71f086b 100644 --- a/tests/sentry/integrations/api/endpoints/test_data_forwarding_details.py +++ b/tests/sentry/integrations/api/endpoints/test_data_forwarding_details.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta, timezone from django.urls import reverse +from rest_framework.response import Response from sentry.integrations.models.data_forwarder import DataForwarder from sentry.integrations.models.data_forwarder_project import DataForwarderProject @@ -18,7 +19,7 @@ def setUp(self) -> None: super().setUp() self.login_as(user=self.user) - def get_response(self, *args, **kwargs): + def get_response(self, *args: object, **kwargs: object) -> Response: """ Override get_response to always add the required feature flag. """ @@ -69,7 +70,7 @@ def test_update_data_forwarder(self) -> None: assert not response.data["isEnabled"] assert response.data["enrollNewProjects"] - data_forwarder.refresh_from_db() + data_forwarder = DataForwarder.objects.get(id=data_forwarder.id) assert data_forwarder.config == {"write_key": "new_key"} assert not data_forwarder.is_enabled assert data_forwarder.enroll_new_projects diff --git a/tests/sentry/integrations/api/endpoints/test_external_team_details.py b/tests/sentry/integrations/api/endpoints/test_external_team_details.py index e395e807ef14..616e53b1d83e 100644 --- a/tests/sentry/integrations/api/endpoints/test_external_team_details.py +++ b/tests/sentry/integrations/api/endpoints/test_external_team_details.py @@ -57,7 +57,6 @@ def test_ignore_teamid(self) -> None: self.external_team.refresh_from_db() assert self.external_team.team_id == self.team.id - @with_feature(["organizations:team-roles", "organizations:integrations-codeowners"]) def _assert_other_team_rejected(self, method: str, status_code: int) -> None: other_team = self.create_team(organization=self.organization) other_external_team = self.create_external_team(other_team, external_name="@org/other") @@ -92,9 +91,11 @@ def _assert_other_team_rejected(self, method: str, status_code: int) -> None: externalName="@org/changed", ) + @with_feature(["organizations:team-roles", "organizations:integrations-codeowners"]) def test_update_another_teams_external_team(self) -> None: self._assert_other_team_rejected("put", status_code=403) + @with_feature(["organizations:team-roles", "organizations:integrations-codeowners"]) def test_delete_another_teams_external_team(self) -> None: self._assert_other_team_rejected("delete", status_code=404) diff --git a/tests/sentry/integrations/api/endpoints/test_external_user.py b/tests/sentry/integrations/api/endpoints/test_external_user.py index 760560a69435..905b5f5aba3d 100644 --- a/tests/sentry/integrations/api/endpoints/test_external_user.py +++ b/tests/sentry/integrations/api/endpoints/test_external_user.py @@ -14,8 +14,9 @@ def setUp(self) -> None: self.organization, self.user, provider="github", name="GitHub", external_id="github:1" ) + self.external_name = "@NisanthanNanthakumar" self.data = { - "externalName": "@NisanthanNanthakumar", + "externalName": self.external_name, "provider": "github", "userId": self.user.id, "integrationId": self.integration.id, @@ -108,7 +109,7 @@ def test_multiple_case_insensitive_associations_for_same_user(self) -> None: self.user, self.organization, self.integration, - external_name=self.data["externalName"].lower(), + external_name=self.external_name.lower(), ) with self.feature({"organizations:integrations-codeowners": True}): diff --git a/tests/sentry/integrations/api/endpoints/test_integration_proxy.py b/tests/sentry/integrations/api/endpoints/test_integration_proxy.py index 8d3b103524ba..85ea34493959 100644 --- a/tests/sentry/integrations/api/endpoints/test_integration_proxy.py +++ b/tests/sentry/integrations/api/endpoints/test_integration_proxy.py @@ -1,10 +1,11 @@ from __future__ import annotations -from collections.abc import Generator +from collections.abc import Generator, Iterator from typing import Any, TypedDict, Unpack from unittest.mock import MagicMock, Mock, patch import pytest +from django.http import HttpRequest from django.http.request import HttpHeaders from django.test import RequestFactory, override_settings from requests import Response @@ -60,9 +61,9 @@ def create_request_headers( secret: str, signature_path: str, integration_id: int | None = None, - request_body=b"", - base_url="https://example.com/api", -): + request_body: bytes = b"", + base_url: str = "https://example.com/api", +) -> SiloHttpHeaders: signature = encode_subnet_signature( secret=secret, base_url=base_url, @@ -130,7 +131,7 @@ def assert_metric_count( mock_metrics: MagicMock, metric_prefix: str = "hybrid_cloud.integration_proxy", kwargs_to_match: dict[str, Any] | None = None, - ): + ) -> None: metric_name = f"{metric_prefix}.{metric_name}" logged_metrics = {call.args[0] for call in mock_metrics.call_args_list} @@ -159,7 +160,7 @@ def assert_failure_metric_count( count: int, mock_metrics: MagicMock, tags: Tags | None = None, - ): + ) -> None: metric_name = "hybrid_cloud.integration_proxy.proxy_failure" # Most failure types are raised before the integration resolves, so the sentinel is the # common case; tests for failures raised after resolution pass the real provider. @@ -481,7 +482,7 @@ def test_proxy_request_with_unresolvable_integration( assert_count_of_metric(mock_record_event, EventLifecycleOutcome.STARTED, 1) assert_count_of_metric(mock_record_event, EventLifecycleOutcome.FAILURE, 1) - def raise_exception(self, exc_type: type[Exception], *args, **kwargs): + def raise_exception(self, exc_type: type[Exception], *args: object, **kwargs: object) -> None: raise exc_type(*args) @override_settings(SENTRY_SUBNET_SECRET=SENTRY_SUBNET_SECRET, SILO_MODE=SiloMode.CONTROL) @@ -965,7 +966,7 @@ def test_proxy_stream_interrupted( first_chunk = b"partial-" - def iter_then_raise(chunk_size): + def iter_then_raise(chunk_size: int | None) -> Iterator[bytes]: yield first_chunk raise ChunkedEncodingError("connection reset") @@ -1054,7 +1055,7 @@ def setUp(self) -> None: integration_id=self.integration.id ) - def build_request(self, **extra_headers: Unpack[SiloHttpHeaders]): + def build_request(self, **extra_headers: Unpack[SiloHttpHeaders]) -> HttpRequest: """ Build a request with a valid subnet signature over the default proxy path, letting individual tests override single headers to exercise each validation failure. diff --git a/tests/sentry/integrations/api/endpoints/test_organization_code_mapping_details.py b/tests/sentry/integrations/api/endpoints/test_organization_code_mapping_details.py index 823c5025aa23..b6d0164ec128 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_code_mapping_details.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_code_mapping_details.py @@ -1,5 +1,6 @@ from django.urls import reverse from rest_framework import status +from rest_framework.response import Response from sentry.api.serializers import serialize from sentry.integrations.api.endpoints.organization_code_mappings import ( @@ -61,13 +62,14 @@ def setUp(self) -> None: args=[self.org.slug, self.config.id], ) - def make_put(self, data): + def make_put(self, data: dict[str, object]) -> Response: # reconstruct the original object config_data = serialize(self.config, self.user) - return self.client.put( + response: Response = self.client.put( self.url, {**config_data, **data, "repositoryId": self.repo.id}, ) + return response def test_non_project_member_permissions(self) -> None: non_member = self.create_user() diff --git a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py index 435f5247fd68..a34a5d450ca4 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py @@ -1,4 +1,5 @@ from django.urls import reverse +from rest_framework.response import Response from sentry.integrations.api.endpoints.organization_code_mappings import ( BRANCH_NAME_ERROR_MESSAGE, @@ -42,7 +43,7 @@ def setUp(self) -> None: args=[self.organization.slug], ) - def make_post(self, data=None): + def make_post(self, data: dict[str, object] | None = None) -> Response: config_data = { "repositoryId": self.repo1.id, "projectId": self.project1.id, @@ -53,7 +54,8 @@ def make_post(self, data=None): } if data: config_data.update(data) - return self.client.post(self.url, data=config_data, format="json") + response: Response = self.client.post(self.url, data=config_data, format="json") + return response def test_basic_get_with_integrationId(self) -> None: path_config1 = self.create_code_mapping( diff --git a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py index 16c5c6d5180f..227e985b8d51 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py @@ -1,7 +1,9 @@ +from typing import Any from unittest import mock from django.db import IntegrityError from django.urls import reverse +from rest_framework.response import Response from sentry.integrations.api.endpoints.organization_code_mappings import ( INVALID_SOURCE_ROOT_ERROR_MESSAGE, @@ -37,8 +39,9 @@ def setUp(self) -> None: args=[self.organization.slug], ) - def make_post(self, data=None, **kwargs): - payload = { + # justification: forwarded to APIClient.post as extra request options. + def make_post(self, data: dict[str, object] | None = None, **kwargs: Any) -> Response: + payload: dict[str, object] = { "project": self.project1.slug, "repository": self.repo1.name, "defaultBranch": "main", @@ -51,7 +54,8 @@ def make_post(self, data=None, **kwargs): } if data: payload.update(data) - return self.client.post(self.url, data=payload, format="json", **kwargs) + response: Response[Any] = self.client.post(self.url, data=payload, format="json", **kwargs) + return response def test_create_single_mapping(self) -> None: response = self.make_post() @@ -570,7 +574,10 @@ def test_partial_failure_returns_207(self) -> None: original_save = RepositoryProjectPathConfig.save call_count = 0 - def failing_save(self_inner, *args, **kwargs): + # justification: forwarded verbatim to Model.save. + def failing_save( + self_inner: RepositoryProjectPathConfig, *args: Any, **kwargs: Any + ) -> None: nonlocal call_count call_count += 1 if call_count == 2: diff --git a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py index 619391f2d9c2..04ed73e7981f 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py @@ -1,4 +1,5 @@ import contextlib +from collections.abc import Iterator, Sequence from unittest.mock import MagicMock, Mock, patch from sentry.testutils.cases import APITestCase @@ -14,7 +15,7 @@ def setUp(self) -> None: self.login_as(self.user) self._setup_mock_integration() - def _setup_mock_integration(self): + def _setup_mock_integration(self) -> None: """Set up mock integration and related objects for testing.""" # Create a GitHub integration to use as our test integration self.integration = self.create_integration( @@ -37,7 +38,9 @@ def _setup_mock_integration(self): ) self.rpc_org_integration = org_integration - def mock_integration_service_calls(self, integrations=None): + def mock_integration_service_calls( + self, integrations: Sequence[object] | None = None + ) -> contextlib.AbstractContextManager[None]: """Helper to mock integration service calls for GET endpoint.""" from unittest.mock import patch @@ -46,7 +49,7 @@ def mock_integration_service_calls(self, integrations=None): integrations = integrations or [] @contextlib.contextmanager - def mock_context(): + def mock_context() -> Iterator[None]: with ( patch.object( integration_service, @@ -142,7 +145,7 @@ def test_with_mock_integration(self) -> None: assert integration_data["provider"] == "test_provider" @patch("sentry.integrations.services.integration.integration_service.get_integrations") - def test_returns_coding_agent_integrations(self, mock_get_integrations): + def test_returns_coding_agent_integrations(self, mock_get_integrations: MagicMock) -> None: """Test GET endpoint returns coding agent integrations.""" mock_integration = Mock() mock_integration.id = self.integration.id @@ -163,7 +166,7 @@ def test_returns_coding_agent_integrations(self, mock_get_integrations): assert integration_data["provider"] == "github" @patch("sentry.integrations.services.integration.integration_service.get_integrations") - def test_handles_integration_processing_error(self, mock_get_integrations): + def test_handles_integration_processing_error(self, mock_get_integrations: MagicMock) -> None: """Test GET endpoint handles empty integrations gracefully.""" mock_get_integrations.return_value = [] @@ -173,7 +176,7 @@ def test_handles_integration_processing_error(self, mock_get_integrations): assert len(response.data["integrations"]) == 0 @patch("sentry.integrations.services.integration.integration_service.get_integrations") - def test_handles_service_error(self, mock_get_integrations): + def test_handles_service_error(self, mock_get_integrations: MagicMock) -> None: """Test GET endpoint handles organization integrations service errors.""" # Mock service to raise an exception mock_get_integrations.side_effect = Exception("Service unavailable") @@ -185,7 +188,9 @@ def test_handles_service_error(self, mock_get_integrations): "sentry.integrations.services.integration.integration_service.get_organization_integrations" ) @patch("sentry.integrations.services.integration.integration_service.get_integration") - def test_handles_integration_not_found(self, mock_get_integration, mock_get_org_integrations): + def test_handles_integration_not_found( + self, mock_get_integration: MagicMock, mock_get_org_integrations: MagicMock + ) -> None: """Test GET endpoint handles case where integration is not found.""" # Mock organization integrations but integration lookup returns None mock_get_org_integrations.return_value = [self.rpc_org_integration] @@ -197,14 +202,14 @@ def test_handles_integration_not_found(self, mock_get_integration, mock_get_org_ assert "integrations" in response.data assert len(response.data["integrations"]) == 0 - def _mock_github_copilot_integration(self): + def _mock_github_copilot_integration(self) -> Mock: mock = Mock() mock.id = 99 mock.name = "GitHub Copilot" mock.provider = "github_copilot" return mock - def test_github_copilot_shown_when_installed(self): + def test_github_copilot_shown_when_installed(self) -> None: """Test GET endpoint shows GitHub Copilot when installed.""" copilot = self._mock_github_copilot_integration() with self.mock_integration_service_calls(integrations=[copilot]): @@ -218,7 +223,7 @@ def test_github_copilot_shown_when_installed(self): assert integrations[0]["requires_identity"] is True assert integrations[0]["has_identity"] is False - def test_github_copilot_not_shown_when_not_installed(self): + def test_github_copilot_not_shown_when_not_installed(self) -> None: """Test GET endpoint does not show GitHub Copilot when integration is not installed.""" with self.mock_integration_service_calls(integrations=[]): response = self.get_success_response(self.organization.slug) @@ -227,7 +232,9 @@ def test_github_copilot_not_shown_when_not_installed(self): @patch( "sentry.integrations.api.endpoints.organization_coding_agents.github_copilot_identity_service" ) - def test_github_copilot_has_identity_true_when_authenticated(self, mock_identity_service): + def test_github_copilot_has_identity_true_when_authenticated( + self, mock_identity_service: MagicMock + ) -> None: """Test GET endpoint returns has_identity: True when user has GitHub Copilot OAuth token.""" mock_identity_service.get_access_token_for_user.return_value = "mock-access-token" copilot = self._mock_github_copilot_integration() @@ -246,7 +253,9 @@ def test_github_copilot_has_identity_true_when_authenticated(self, mock_identity @patch( "sentry.integrations.api.endpoints.organization_coding_agents.github_copilot_identity_service" ) - def test_github_copilot_has_identity_false_when_not_authenticated(self, mock_identity_service): + def test_github_copilot_has_identity_false_when_not_authenticated( + self, mock_identity_service: MagicMock + ) -> None: """Test GET endpoint returns has_identity: False when user doesn't have GitHub Copilot OAuth token.""" mock_identity_service.get_access_token_for_user.return_value = None copilot = self._mock_github_copilot_integration() @@ -265,7 +274,9 @@ def test_github_copilot_has_identity_false_when_not_authenticated(self, mock_ide @patch( "sentry.integrations.api.endpoints.organization_coding_agents.github_copilot_identity_service" ) - def test_github_copilot_handles_rpc_exception_gracefully(self, mock_identity_service): + def test_github_copilot_handles_rpc_exception_gracefully( + self, mock_identity_service: MagicMock + ) -> None: """Test GET endpoint handles RPC exceptions gracefully when checking GitHub Copilot identity.""" from sentry.hybridcloud.rpc.service import RpcRemoteException diff --git a/tests/sentry/integrations/api/endpoints/test_organization_integration_channel_validate.py b/tests/sentry/integrations/api/endpoints/test_organization_integration_channel_validate.py index 6b8c33f621a7..e0ad7140ff26 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_integration_channel_validate.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_integration_channel_validate.py @@ -1,8 +1,9 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import MagicMock, patch from django.core.exceptions import ValidationError +from rest_framework.response import Response from sentry.shared_integrations.exceptions import ApiError from sentry.testutils.cases import APITestCase @@ -17,7 +18,7 @@ def setUp(self) -> None: super().setUp() self.login_as(self.user) - def _get_response(self, integration_id, channel): + def _get_response(self, integration_id: int, channel: str) -> Response: return self.get_success_response(self.organization.slug, integration_id, channel=channel) @@ -35,7 +36,7 @@ def setUp(self) -> None: @patch( "sentry.integrations.api.endpoints.organization_integration_channel_validate.get_channel_id" ) - def test_channel_validation(self, mock_get_channel_id): + def test_channel_validation(self, mock_get_channel_id: MagicMock) -> None: cases = [("#general", "C123", True), ("#missing", None, False)] for channel_name, channel_id, expected in cases: @@ -59,7 +60,7 @@ def setUp(self) -> None: @patch( "sentry.integrations.api.endpoints.organization_integration_channel_validate.msteams_find_channel_id" ) - def test_channel_validation(self, mock_find): + def test_channel_validation(self, mock_find: MagicMock) -> None: cases = [("General", "19:abc@thread.tacv2", True), ("Missing", None, False)] for channel_name, channel_id, expected in cases: @@ -82,7 +83,7 @@ def setUp(self) -> None: @patch( "sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_validate_channel_id" ) - def test_discord_valid_numeric_id(self, mock_validate): + def test_discord_valid_numeric_id(self, mock_validate: MagicMock) -> None: mock_validate.return_value = "123" resp = self._get_response(self.integration.id, "123") assert resp.data == {"valid": True} @@ -92,7 +93,7 @@ def test_discord_valid_numeric_id(self, mock_validate): "sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_validate_channel_id", side_effect=ValidationError("bad"), ) - def test_discord_invalid_plain_name(self, _mock_validate): + def test_discord_invalid_plain_name(self, _mock_validate: object) -> None: resp = self._get_response(self.integration.id, "alerts") assert resp.data == {"valid": False} @@ -102,7 +103,7 @@ def test_discord_invalid_plain_name(self, _mock_validate): @patch( "sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_get_channel_id_from_url" ) - def test_discord_valid_url(self, mock_parse, mock_validate): + def test_discord_valid_url(self, mock_parse: MagicMock, mock_validate: MagicMock) -> None: mock_parse.return_value = "123" mock_validate.return_value = object() resp = self._get_response(self.integration.id, "https://discord.com/channels/123/123") @@ -114,7 +115,7 @@ def test_discord_valid_url(self, mock_parse, mock_validate): "sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_get_channel_id_from_url", side_effect=ValidationError("bad"), ) - def test_discord_invalid_url_parse(self, _mock_parse): + def test_discord_invalid_url_parse(self, _mock_parse: object) -> None: resp = self._get_response(self.integration.id, "https://discord.com/channels/bad") assert resp.data == {"valid": False} @@ -122,7 +123,7 @@ def test_discord_invalid_url_parse(self, _mock_parse): "sentry.integrations.api.endpoints.organization_integration_channel_validate.discord_validate_channel_id", side_effect=ApiError("rate limited", code=429), ) - def test_discord_validate_api_error(self, _mock_validate): + def test_discord_validate_api_error(self, _mock_validate: object) -> None: resp = self._get_response(self.integration.id, "123") assert resp.data == {"valid": False} diff --git a/tests/sentry/integrations/api/endpoints/test_organization_integration_channels.py b/tests/sentry/integrations/api/endpoints/test_organization_integration_channels.py index a03512932096..a0988b6f2cfc 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_integration_channels.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_integration_channels.py @@ -1,5 +1,5 @@ from typing import cast -from unittest.mock import patch +from unittest.mock import MagicMock, patch from sentry.testutils.cases import APITestCase from sentry.testutils.silo import control_silo_test @@ -30,7 +30,7 @@ def setUp(self) -> None: ) @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_list") - def test_slack_channels_list(self, mock_conversations_list): + def test_slack_channels_list(self, mock_conversations_list: MagicMock) -> None: channels = [ {"id": "C123", "name": "general", "is_private": False}, {"id": "C124", "name": "random", "is_private": True}, @@ -53,7 +53,7 @@ def test_slack_channels_list(self, mock_conversations_list): } @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_list") - def test_large_slack_channel_list(self, mock_conversations_list): + def test_large_slack_channel_list(self, mock_conversations_list: MagicMock) -> None: mock_channels = [ {"id": f"C{i:04}", "name": f"channel-{i}", "is_private": i % 2 == 0} for i in range(1, 1001) @@ -83,7 +83,7 @@ def setUp(self) -> None: ) @patch("sentry.integrations.discord.client.DiscordClient.get") - def test_discord_channels_list(self, mock_get): + def test_discord_channels_list(self, mock_get: MagicMock) -> None: mock_channels = [ {"id": "123456", "name": "general", "type": 0}, {"id": "789012", "name": "announcements", "type": 5}, @@ -129,7 +129,7 @@ def setUp(self) -> None: ) @patch("sentry.integrations.msteams.client.MsTeamsClient.get") - def test_msteams_channels_list(self, mock_get): + def test_msteams_channels_list(self, mock_get: MagicMock) -> None: # The Bot Framework returns `id`, `name` and `type` per channel. The # General channel has no name, and its id is the team id. mock_get.return_value = { diff --git a/tests/sentry/integrations/api/endpoints/test_organization_integration_details.py b/tests/sentry/integrations/api/endpoints/test_organization_integration_details.py index 60acb7748ef5..b62fafd82e50 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_integration_details.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_integration_details.py @@ -370,11 +370,12 @@ def test_serialize_organizationintegration_with_create_issue_config_for_jira(sel assert data["configData"] is None assert data["externalId"] == self.integration.external_id assert data["organizationId"] == self.organization.id + organization_integration = self.organization_integration + assert organization_integration is not None assert ( - data["organizationIntegrationStatus"] - == self.organization_integration.get_status_display() + data["organizationIntegrationStatus"] == organization_integration.get_status_display() ) - assert data["gracePeriodEnd"] == self.organization_integration.grace_period_end + assert data["gracePeriodEnd"] == organization_integration.grace_period_end jira_request_urls = [call.request.url for call in responses.calls] assert len(jira_request_urls) == 2 diff --git a/tests/sentry/integrations/api/endpoints/test_organization_integration_direct_enable.py b/tests/sentry/integrations/api/endpoints/test_organization_integration_direct_enable.py index eabf1502ca3c..024f81192ed1 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_integration_direct_enable.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_integration_direct_enable.py @@ -9,11 +9,11 @@ class OrganizationIntegrationDirectEnableTest(APITestCase): endpoint = "sentry-api-0-organization-integration-direct-enable" method = "post" - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(self.user) - def test_enables_github_copilot(self): + def test_enables_github_copilot(self) -> None: response = self.get_success_response(self.organization.slug, "github_copilot") integration = Integration.objects.get(provider="github_copilot") @@ -25,20 +25,20 @@ def test_enables_github_copilot(self): assert response.data["id"] == str(integration.id) assert response.data["organizationId"] == self.organization.id - def test_returns_404_for_unknown_provider(self): + def test_returns_404_for_unknown_provider(self) -> None: self.get_error_response(self.organization.slug, "nonexistent_provider", status_code=404) - def test_returns_400_for_provider_without_direct_enable(self): + def test_returns_400_for_provider_without_direct_enable(self) -> None: # cursor has no directEnable aspect self.get_error_response(self.organization.slug, "cursor", status_code=400) - def test_prevents_duplicate_installation(self): + def test_prevents_duplicate_installation(self) -> None: self.get_success_response(self.organization.slug, "github_copilot") self.get_error_response(self.organization.slug, "github_copilot", status_code=400) assert Integration.objects.filter(provider="github_copilot").count() == 1 - def test_requires_org_write_permission(self): + def test_requires_org_write_permission(self) -> None: member = self.create_user() self.create_member(organization=self.organization, user=member, role="member") self.login_as(member) diff --git a/tests/sentry/integrations/api/endpoints/test_organization_integration_issues.py b/tests/sentry/integrations/api/endpoints/test_organization_integration_issues.py index a72e055d50aa..1f582ba64f07 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_integration_issues.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_integration_issues.py @@ -9,7 +9,7 @@ def setUp(self) -> None: self.login_as(self.user) self.organization = self.create_organization(owner=self.user) - def get_path(self, integration_id): + def get_path(self, integration_id: int) -> str: return ( f"/api/0/organizations/{self.organization.slug}/integrations/{integration_id}/issues/" ) 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..d5ddeb02a6c8 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 @@ -1,8 +1,10 @@ +from typing import Any from unittest.mock import MagicMock, patch import responses from django.test import override_settings from responses import matchers +from rest_framework.response import Response from sentry.integrations.aws_lambda.integration import AwsLambdaIntegration from sentry.models.projectkey import ProjectKey @@ -37,14 +39,21 @@ def setUp(self) -> None: self.org_integration.save() self.login_as(self.user) - def get_response(self, **kwargs): + def get_response(self, **kwargs: str) -> Response: return super().get_response(self.organization.slug, self.integration.id, **kwargs) @property - def sentry_dsn(self): - return ProjectKey.get_default(project=self.project).get_dsn(public=True) - - def set_up_response_mocks(self, get_function_response, update_function_configuration_kwargs): + def sentry_dsn(self) -> str: + project_key = ProjectKey.get_default(project=self.project) + assert project_key is not None + return project_key.get_dsn(public=True) + + # justification: AWS SDK responses are decoded JSON. + def set_up_response_mocks( + self, + get_function_response: dict[str, Any], + update_function_configuration_kwargs: dict[str, Any], + ) -> None: responses.add( responses.POST, "http://controlserver/api/0/internal/integration-proxy/", @@ -619,8 +628,8 @@ def test_update_python_version( @patch.object(AwsLambdaIntegration, "get_serialized_lambda_function") @patch("sentry.integrations.aws_lambda.integration.gen_aws_client") def test_enable_python_layer_on_already_enabled( - self, mock_gen_aws_client, mock_get_serialized_lambda_function - ): + self, mock_gen_aws_client: MagicMock, mock_get_serialized_lambda_function: MagicMock + ) -> None: """ Test that ensures that if sentry-sdk is already enabled, then re-enabling it should not override the env variables since it could be diff --git a/tests/sentry/integrations/api/endpoints/test_organization_pull_request_files.py b/tests/sentry/integrations/api/endpoints/test_organization_pull_request_files.py index 274c36015dad..7a628fb03bee 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_pull_request_files.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_pull_request_files.py @@ -1,4 +1,5 @@ from unittest import mock +from unittest.mock import MagicMock from scm.errors import ( ProviderNotFound, @@ -13,17 +14,24 @@ _MAKE_SCM = "sentry.integrations.api.endpoints.organization_pull_request_files.make_scm" -def _page(files, next_cursor=""): +def _page(files: list[dict[str, str | None]], next_cursor: str = "") -> dict[str, object]: return {"data": files, "meta": {"next_cursor": next_cursor}} class _FakeScm: - def __init__(self, pages=None, error=None): + def __init__( + self, pages: list[dict[str, object]] | None = None, error: SCMError | None = None + ) -> None: self._pages = list(pages or []) self._error = error self.calls: list[str] = [] - def get_pull_request_files(self, pull_request_id, pagination=None, request_options=None): + def get_pull_request_files( + self, + pull_request_id: str, + pagination: dict[str, object] | None = None, + request_options: dict[str, object] | None = None, + ) -> dict[str, object]: self.calls.append(pull_request_id) if self._error is not None: raise self._error @@ -49,13 +57,18 @@ def setUp(self) -> None: key="42", ) - def _set_scm(self, mock_make_scm, pages=None, error=None): + def _set_scm( + self, + mock_make_scm: MagicMock, + pages: list[dict[str, object]] | None = None, + error: SCMError | None = None, + ) -> _FakeScm: scm = _FakeScm(pages=pages, error=error) mock_make_scm.return_value = scm return scm @mock.patch(_MAKE_SCM) - def test_returns_files_with_patches(self, mock_make_scm): + def test_returns_files_with_patches(self, mock_make_scm: MagicMock) -> None: scm = self._set_scm( mock_make_scm, pages=[_page([{"filename": "src/foo.py", "patch": "@@ -1 +1 @@\n-old\n+new"}])], @@ -70,13 +83,13 @@ def test_returns_files_with_patches(self, mock_make_scm): assert scm.calls[0] == "42" @mock.patch(_MAKE_SCM) - def test_binary_file_patch_is_null(self, mock_make_scm): + def test_binary_file_patch_is_null(self, mock_make_scm: MagicMock) -> None: self._set_scm(mock_make_scm, pages=[_page([{"filename": "image.png", "patch": None}])]) response = self.get_success_response(self.organization.slug, self.pull_request.id) assert response.data == {"files": [{"path": "image.png", "patch": None}]} @mock.patch(_MAKE_SCM) - def test_paginates_across_pages(self, mock_make_scm): + def test_paginates_across_pages(self, mock_make_scm: MagicMock) -> None: self._set_scm( mock_make_scm, pages=[ @@ -87,7 +100,7 @@ def test_paginates_across_pages(self, mock_make_scm): response = self.get_success_response(self.organization.slug, self.pull_request.id) assert [f["path"] for f in response.data["files"]] == ["a.py", "b.py"] - def test_pull_request_in_other_org_returns_404(self): + def test_pull_request_in_other_org_returns_404(self) -> None: other_org = self.create_organization(owner=self.create_user()) other_repo = self.create_repo( project=self.create_project(organization=other_org), @@ -102,40 +115,42 @@ def test_pull_request_in_other_org_returns_404(self): ) self.get_error_response(self.organization.slug, other_pull_request.id, status_code=404) - def test_nonexistent_pull_request_returns_404(self): + def test_nonexistent_pull_request_returns_404(self) -> None: self.get_error_response(self.organization.slug, 999999, status_code=404) - def test_non_numeric_pull_request_id_returns_404(self): + def test_non_numeric_pull_request_id_returns_404(self) -> None: self.get_error_response(self.organization.slug, "not-a-number", status_code=404) @mock.patch(_MAKE_SCM) - def test_inactive_repository_returns_404(self, mock_make_scm): + def test_inactive_repository_returns_404(self, mock_make_scm: MagicMock) -> None: mock_make_scm.side_effect = RepositoryInactive() self.get_error_response(self.organization.slug, self.pull_request.id, status_code=404) @mock.patch(_MAKE_SCM) - def test_unsupported_provider_returns_empty(self, mock_make_scm): + def test_unsupported_provider_returns_empty(self, mock_make_scm: MagicMock) -> None: mock_make_scm.side_effect = ProviderNotFound() response = self.get_success_response(self.organization.slug, self.pull_request.id) assert response.data == {"files": []} @mock.patch(_MAKE_SCM) - def test_provider_without_files_capability_returns_empty(self, mock_make_scm): + def test_provider_without_files_capability_returns_empty( + self, mock_make_scm: MagicMock + ) -> None: mock_make_scm.return_value = object() response = self.get_success_response(self.organization.slug, self.pull_request.id) assert response.data == {"files": []} @mock.patch(_MAKE_SCM) - def test_provider_error_returns_502(self, mock_make_scm): + def test_provider_error_returns_502(self, mock_make_scm: MagicMock) -> None: self._set_scm(mock_make_scm, error=SCMError("boom")) self.get_error_response(self.organization.slug, self.pull_request.id, status_code=502) @mock.patch(_MAKE_SCM) - def test_pull_request_missing_on_provider_returns_404(self, mock_make_scm): + def test_pull_request_missing_on_provider_returns_404(self, mock_make_scm: MagicMock) -> None: self._set_scm(mock_make_scm, error=ResourceNotFound()) self.get_error_response(self.organization.slug, self.pull_request.id, status_code=404) @mock.patch(_MAKE_SCM) - def test_provider_rate_limited_returns_429(self, mock_make_scm): + def test_provider_rate_limited_returns_429(self, mock_make_scm: MagicMock) -> None: self._set_scm(mock_make_scm, error=RateLimitExceeded()) self.get_error_response(self.organization.slug, self.pull_request.id, status_code=429) diff --git a/tests/sentry/integrations/api/endpoints/test_organization_repositories.py b/tests/sentry/integrations/api/endpoints/test_organization_repositories.py index dc5558480639..5fc611cb2ed6 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_repositories.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_repositories.py @@ -382,7 +382,7 @@ def test_existing_repo(self, mock_build_repository_config: MagicMock) -> None: name="getsentry/sentry", status=0, external_id="my_external_id", - integration_id="2", + integration_id=2, provider="integrations:example", url="https://github.com/getsentry/sentry", ) diff --git a/tests/sentry/integrations/api/endpoints/test_organization_repository_details.py b/tests/sentry/integrations/api/endpoints/test_organization_repository_details.py index b2c745587ddd..e66b8df633c8 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_repository_details.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_repository_details.py @@ -2,6 +2,7 @@ from django.urls import reverse from django.utils import timezone +from rest_framework.response import Response from sentry.constants import ObjectStatus from sentry.db.pending_deletion import build_pending_deletion_key @@ -103,7 +104,9 @@ def test_get_repository_expand_settings_no_settings_exist(self) -> None: class OrganizationRepositoryDeleteTest(APITestCase): - def assert_rename_pending_delete(self, response, repo, external_id=None): + def assert_rename_pending_delete( + self, response: Response, repo: Repository, external_id: str | None = None + ) -> None: assert response.data["status"] == "pending_deletion" assert response.data["name"] == "example" # name displayed matches what the user expects diff --git a/tests/sentry/integrations/api/endpoints/test_user_organizationintegration.py b/tests/sentry/integrations/api/endpoints/test_user_organizationintegration.py index 3d991cec2823..f591b5c4d5b5 100644 --- a/tests/sentry/integrations/api/endpoints/test_user_organizationintegration.py +++ b/tests/sentry/integrations/api/endpoints/test_user_organizationintegration.py @@ -2,6 +2,7 @@ import orjson +from sentry.roles.manager import OrganizationRole from sentry.testutils.cases import APITestCase from sentry.testutils.silo import control_silo_test @@ -26,13 +27,13 @@ class MockOrganizationRoles: {"id": "alice", "name": "Alice", "desc": "In Wonderland"}, ] - def __init__(self): + def __init__(self) -> None: from sentry.roles.manager import RoleManager self.default_manager = RoleManager(self.TEST_ORG_ROLES, self.TEST_TEAM_ROLES) self.organization_roles = self.default_manager.organization_roles - def get(self, x): + def get(self, x: str) -> OrganizationRole: return self.organization_roles.get(x) 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/integrations/bitbucket/test_installed.py b/tests/sentry/integrations/bitbucket/test_installed.py index 6895372d7dee..b985bc411fec 100644 --- a/tests/sentry/integrations/bitbucket/test_installed.py +++ b/tests/sentry/integrations/bitbucket/test_installed.py @@ -95,7 +95,8 @@ def test_default_permissions(self) -> None: def install(self, data: dict[str, Any]) -> Response: with mock.patch.object(BitbucketApiClient, "get_workspace_hooks"): - return self.client.post(self.path, data=data) + response: Response[Any] = self.client.post(self.path, data=data) + return response def test_installed_with_public_key(self) -> None: response = self.install(self.team_data_from_bitbucket) diff --git a/tests/sentry/integrations/bitbucket/test_integration.py b/tests/sentry/integrations/bitbucket/test_integration.py index bddf2670fd26..298852d34013 100644 --- a/tests/sentry/integrations/bitbucket/test_integration.py +++ b/tests/sentry/integrations/bitbucket/test_integration.py @@ -8,7 +8,10 @@ import responses from django.urls import reverse -from sentry.integrations.bitbucket.integration import BitbucketIntegrationProvider +from sentry.integrations.bitbucket.integration import ( + BitbucketIntegration, + BitbucketIntegrationProvider, +) from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.pipeline import IntegrationPipeline @@ -57,6 +60,7 @@ def test_get_repositories_with_uuid(self) -> None: json={"values": [{"full_name": "sentryuser/stuf", "uuid": "{abc-001}"}]}, ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) result = installation.get_repositories() assert result == [ {"identifier": "sentryuser/stuf", "name": "sentryuser/stuf", "external_id": "{abc-001}"} @@ -89,6 +93,7 @@ def test_get_repositories_multiple_pages(self) -> None: ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) result = installation.get_repositories() assert result == [ {"identifier": "sentryuser/repo-1", "name": "sentryuser/repo-1", "external_id": "{r1}"}, @@ -117,6 +122,7 @@ def test_get_repositories_respects_page_limit(self) -> None: ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) result = installation.get_repositories(page_number_limit=1) assert result == [ {"identifier": "sentryuser/repo-1", "name": "sentryuser/repo-1", "external_id": "{r1}"}, @@ -142,6 +148,7 @@ def test_get_repositories_zero_page_limit_returns_first_page(self) -> None: ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) result = installation.get_repositories(page_number_limit=0) assert result == [ {"identifier": "sentryuser/repo-1", "name": "sentryuser/repo-1", "external_id": "{r1}"}, @@ -159,6 +166,7 @@ def test_get_repositories_clamps_excessive_page_limit(self) -> None: ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) client = installation.get_client() result = installation.get_repositories(page_number_limit=client.page_number_limit + 100) assert result == [ @@ -198,6 +206,7 @@ def test_get_repositories_exact_match(self) -> None: ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) result = installation.get_repositories("stuf") assert result == [ { @@ -294,6 +303,7 @@ def test_get_repositories_no_exact_match(self) -> None: ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) result = installation.get_repositories("stu") assert result == [ { @@ -356,6 +366,7 @@ def test_get_repositories_no_exact_match(self) -> None: @responses.activate def test_source_url_matches(self) -> None: installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) test_cases = [ ( @@ -378,6 +389,7 @@ def test_source_url_matches(self) -> None: @responses.activate def test_extract_branch_from_source_url(self) -> None: installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) integration = Integration.objects.get(provider=self.provider.key) with assume_test_silo_mode(SiloMode.CELL): @@ -397,6 +409,7 @@ def test_extract_branch_from_source_url(self) -> None: @responses.activate def test_extract_source_path_from_source_url(self) -> None: installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) integration = Integration.objects.get(provider=self.provider.key) with assume_test_silo_mode(SiloMode.CELL): @@ -419,6 +432,7 @@ def test_extract_source_path_from_source_url(self) -> None: @responses.activate def test_extract_source_path_from_source_url_strips_query_params(self) -> None: installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) integration = Integration.objects.get(provider=self.provider.key) with assume_test_silo_mode(SiloMode.CELL): @@ -451,6 +465,7 @@ def test_get_repository_choices_halt_lifecycle( json={"error": {"message": "No workspace with identifier sentryuser"}}, ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) with pytest.raises( IntegrationError, match="Unable to retrieve repositories. Please try again later." ): @@ -471,6 +486,7 @@ def test_get_repository_choices_failure_lifecycle( json={"error": {"message": "Some other error, sentry is responsible"}}, ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) with pytest.raises( IntegrationError, match="Unable to retrieve repositories. Please try again later." ): diff --git a/tests/sentry/integrations/bitbucket/test_issues.py b/tests/sentry/integrations/bitbucket/test_issues.py index fe58b915c1d6..2051433bf4fd 100644 --- a/tests/sentry/integrations/bitbucket/test_issues.py +++ b/tests/sentry/integrations/bitbucket/test_issues.py @@ -2,6 +2,7 @@ import pytest import responses +from sentry.integrations.bitbucket.integration import BitbucketIntegration from sentry.integrations.bitbucket.issues import BITBUCKET_MAX_TITLE_LENGTH, ISSUE_TYPES, PRIORITIES from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.services.integration import integration_service @@ -46,6 +47,7 @@ def setUp(self) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event.group is not None self.group = event.group self.repo_choices = [ ("myaccount/repo1", "myaccount/repo1"), @@ -57,6 +59,7 @@ def build_autocomplete_url(self) -> str: def test_issue_url(self) -> None: installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) assert installation.get_issue_link_data( "https://bitbucket.org/myaccount/myrepo/issues/3/issue-title" ) == {"repo": "myaccount/myrepo", "externalIssue": "3"} @@ -75,9 +78,9 @@ def test_link_issue(self) -> None: data = {"repo": repo, "externalIssue": issue_id, "comment": "hello"} - assert self.integration.get_installation(self.organization.id).get_issue( - issue_id, data=data - ) == { + installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) + assert installation.get_issue(issue_id, data=data) == { "key": issue_id, "description": "This is the description", "title": "hello", @@ -102,9 +105,9 @@ def test_after_link_issue(self) -> None: key="%s#%d" % (repo, issue_id), ) - self.integration.get_installation(external_issue.organization_id).after_link_issue( - external_issue, data=comment - ) + installation = self.integration.get_installation(external_issue.organization_id) + assert isinstance(installation, BitbucketIntegration) + installation.after_link_issue(external_issue, data=comment) request = responses.calls[0].request assert responses.calls[0].response.status_code == 201 @@ -131,6 +134,7 @@ def test_default_repo_link_fields(self) -> None: }, ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) fields = installation.get_link_issue_config(self.group) repo_field = [field for field in fields if field["name"] == "repo"][0] assert repo_field["default"] == "myaccount/repo1" @@ -155,6 +159,7 @@ def test_default_repo_create_fields(self) -> None: }, ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) fields = installation.get_create_issue_config(self.group, self.user) for field in fields: if field["name"] == "repo": @@ -174,6 +179,7 @@ def test_default_repo_link_fields_no_repos(self) -> None: ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) fields = installation.get_link_issue_config(self.group) repo_field = [field for field in fields if field["name"] == "repo"][0] assert repo_field["default"] == "" @@ -191,6 +197,7 @@ def test_default_repo_create_fields_no_repos(self) -> None: ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) fields = installation.get_create_issue_config(self.group, self.user) repo_field = [field for field in fields if field["name"] == "repo"][0] assert repo_field["default"] == "" @@ -210,6 +217,7 @@ def test_get_create_issue_config(self) -> None: ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) assert installation.get_create_issue_config(self.group, self.user) == [ { "name": "repo", @@ -267,6 +275,7 @@ def test_get_create_issue_config_with_long_title(self) -> None: }, ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) event = self.store_event( data={ "event_id": "a" * 32, @@ -304,6 +313,7 @@ def test_get_create_issue_config_without_group(self) -> None: ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) config = installation.get_create_issue_config(None, self.user, params={}) [repo_field, assignee_field, label_field] = config assert repo_field["name"] == "repo" @@ -326,6 +336,7 @@ def test_get_link_issue_config(self) -> None: }, ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) assert installation.get_link_issue_config(self.group) == [ { "name": "repo", @@ -368,6 +379,7 @@ def test_create_issue(self) -> None: json={"id": id, "title": title, "content": {"html": content}}, ) installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, BitbucketIntegration) result = installation.create_issue( {"id": id, "title": title, "description": content, "repo": repo} ) diff --git a/tests/sentry/integrations/bitbucket/test_repository.py b/tests/sentry/integrations/bitbucket/test_repository.py index c16d27740269..85f604972651 100644 --- a/tests/sentry/integrations/bitbucket/test_repository.py +++ b/tests/sentry/integrations/bitbucket/test_repository.py @@ -1,9 +1,11 @@ import datetime +from collections.abc import Mapping, MutableMapping from datetime import timezone from functools import cached_property import pytest import responses +from requests import PreparedRequest from fixtures.bitbucket import COMMIT_DIFF_PATCH, COMPARE_COMMITS_EXAMPLE, REPO from sentry.constants import ObjectStatus @@ -44,7 +46,7 @@ def setUp(self) -> None: ) @cached_property - def provider(self): + def provider(self) -> BitbucketRepositoryProvider: return BitbucketRepositoryProvider("bitbucket") def test_get_client(self) -> None: @@ -86,7 +88,7 @@ def test_compare_commits(self) -> None: def _on_create_repository_discards_hook_when_repository_disabled( self, delete_status: int ) -> None: - def create(request): + def create(request: PreparedRequest) -> tuple[int, dict[str, str], str]: Repository.objects.filter(id=self.repo.id).update(status=ObjectStatus.DISABLED) return 201, {}, '{"uuid": "hook-uuid"}' @@ -122,7 +124,7 @@ def test_on_create_repository_discards_hook_when_repository_disabled_and_cleanup @responses.activate def test_on_create_repository_preserves_concurrent_changes(self) -> None: - def create(request): + def create(request: PreparedRequest) -> tuple[int, dict[str, str], str]: Repository.objects.filter(id=self.repo.id).update(name="renamed") return 201, {}, '{"uuid": "hook-uuid"}' @@ -179,11 +181,12 @@ def test_build_repository_config(self) -> None: }, ) integration.add_organization(organization) - data = { + data: MutableMapping[str, object] = { "provider": "integrations:bitbucket", "identifier": full_repo_name, "installation": integration.id, } + rpc_organization = serialize_rpc_organization(organization) data = self.provider.get_repository_data(organization, data) assert data == { "provider": "integrations:bitbucket", @@ -192,9 +195,9 @@ def test_build_repository_config(self) -> None: "external_id": REPO["uuid"], "name": full_repo_name, } - data = self.provider.build_repository_config(organization, data) + repository_config = self.provider.build_repository_config(rpc_organization, data) - assert data == { + assert repository_config == { "name": full_repo_name, "external_id": REPO["uuid"], "url": "https://bitbucket.org/laurynsentry/helloworld", @@ -242,7 +245,7 @@ def setUp(self) -> None: ) self.default_repository_config = {"full_name": "getsentry/example-repo", "id": "123"} - def add_create_repository_responses(self, repository_config): + def add_create_repository_responses(self, repository_config: Mapping[str, object]) -> None: responses.add( responses.GET, f"{self.base_url}/2.0/repositories/{self.repo.name}", diff --git a/tests/sentry/integrations/bitbucket/test_webhook.py b/tests/sentry/integrations/bitbucket/test_webhook.py index 607528b5cdc7..74a29722262c 100644 --- a/tests/sentry/integrations/bitbucket/test_webhook.py +++ b/tests/sentry/integrations/bitbucket/test_webhook.py @@ -4,6 +4,8 @@ from typing import Any from unittest.mock import MagicMock, patch +from rest_framework.response import Response + from fixtures.bitbucket import PUSH_EVENT_EXAMPLE from sentry.integrations.bitbucket.webhook import PROVIDER_NAME, is_valid_signature from sentry.models.commit import Commit @@ -213,7 +215,7 @@ def setUp(self) -> None: self.create_repository(integration_id=integration.id) - def send_signed_webhook(self): + def send_signed_webhook(self) -> Response: return self.get_response( self.organization_id, raw_data=PUSH_EVENT_EXAMPLE, diff --git a/tests/sentry/integrations/bitbucket_server/test_integration.py b/tests/sentry/integrations/bitbucket_server/test_integration.py index 141415789586..e7add7ce8b42 100644 --- a/tests/sentry/integrations/bitbucket_server/test_integration.py +++ b/tests/sentry/integrations/bitbucket_server/test_integration.py @@ -1,5 +1,5 @@ from functools import cached_property -from typing import Any +from typing import Any, cast from unittest.mock import MagicMock, patch import responses @@ -7,7 +7,10 @@ from requests.exceptions import ReadTimeout from fixtures.bitbucket_server import EXAMPLE_PRIVATE_KEY -from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegrationProvider +from sentry.integrations.bitbucket_server.integration import ( + BitbucketServerIntegration, + BitbucketServerIntegrationProvider, +) from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.pipeline import IntegrationPipeline @@ -25,7 +28,7 @@ class BitbucketServerIntegrationTest(IntegrationTestCase): @cached_property @assume_test_silo_mode(SiloMode.CONTROL) - def integration(self): + def integration(self) -> Integration: integration = Integration.objects.create( provider=self.provider.key, name="Bitbucket Server", @@ -39,7 +42,10 @@ def integration(self): return integration def test_source_url_matches(self) -> None: - installation = self.integration.get_installation(self.organization.id) + installation = cast( + BitbucketServerIntegration, + self.integration.get_installation(self.organization.id), + ) test_cases = [ ( @@ -68,7 +74,10 @@ def test_source_url_matches(self) -> None: assert installation.source_url_matches(source_url) == matches def test_format_source_url(self) -> None: - installation = self.integration.get_installation(self.organization.id) + installation = cast( + BitbucketServerIntegration, + self.integration.get_installation(self.organization.id), + ) with assume_test_silo_mode(SiloMode.CELL): repo = Repository.objects.create( @@ -95,7 +104,10 @@ def test_format_source_url(self) -> None: ) def test_extract_branch_from_source_url(self) -> None: - installation = self.integration.get_installation(self.organization.id) + installation = cast( + BitbucketServerIntegration, + self.integration.get_installation(self.organization.id), + ) with assume_test_silo_mode(SiloMode.CELL): repo = Repository.objects.create( @@ -126,7 +138,10 @@ def test_extract_branch_from_source_url(self) -> None: ) def test_extract_source_path_from_source_url(self) -> None: - installation = self.integration.get_installation(self.organization.id) + installation = cast( + BitbucketServerIntegration, + self.integration.get_installation(self.organization.id), + ) with assume_test_silo_mode(SiloMode.CELL): repo = Repository.objects.create( diff --git a/tests/sentry/integrations/bitbucket_server/test_repository.py b/tests/sentry/integrations/bitbucket_server/test_repository.py index 3138128d63b3..529bf98c57d7 100644 --- a/tests/sentry/integrations/bitbucket_server/test_repository.py +++ b/tests/sentry/integrations/bitbucket_server/test_repository.py @@ -5,6 +5,7 @@ import orjson import pytest import responses +from requests import PreparedRequest from fixtures.bitbucket_server import ( COMMIT_CHANGELIST_EXAMPLE, @@ -298,7 +299,7 @@ def _on_create_repository_discards_hook_when_repository_disabled( integration_id=self.integration.id, ) - def create(request): + def create(request: PreparedRequest) -> tuple[int, dict[str, str], str]: Repository.objects.filter(id=repo.id).update(status=ObjectStatus.DISABLED) return 201, {}, '{"id": 79}' @@ -353,7 +354,7 @@ def test_build_repository_config(self) -> None: status=201, ) - organization = self.organization + organization = serialize_rpc_organization(self.organization) integration = self.integration data = { @@ -361,8 +362,8 @@ def test_build_repository_config(self) -> None: "identifier": project + "/" + repo, "installation": integration.id, } - data = self.provider.get_repository_data(organization, data) - assert data == { + repository_config = self.provider.get_repository_data(organization, data) + assert repository_config == { "provider": "integrations:bitbucket_server", "repo": repo, "project": project, @@ -401,5 +402,5 @@ def test_repository_external_slug(self) -> None: def test_get_repository_data_no_installation_id(self) -> None: with pytest.raises(IntegrationError) as e: - self.provider.get_repository_data(self.organization, {}) + self.provider.get_repository_data(serialize_rpc_organization(self.organization), {}) assert "requires an integration id" in str(e.value) diff --git a/tests/sentry/integrations/bitbucket_server/test_tasks.py b/tests/sentry/integrations/bitbucket_server/test_tasks.py index cb33fc517f70..0af27738b66f 100644 --- a/tests/sentry/integrations/bitbucket_server/test_tasks.py +++ b/tests/sentry/integrations/bitbucket_server/test_tasks.py @@ -42,11 +42,11 @@ def setUp(self) -> None: ) self.bitbucket_client = MagicMock() self.bitbucket_client.get_repo.return_value = {"id": 987} - self.integration = MagicMock( + self.mock_integration = MagicMock( id=self.integration_id, provider="bitbucket_server", ) - self.integration.get_installation.return_value.get_client.return_value = ( + self.mock_integration.get_installation.return_value.get_client.return_value = ( self.bitbucket_client ) @@ -60,7 +60,7 @@ def setUp(self) -> None: ).start() self.get_integration = patch( "sentry.integrations.bitbucket_server.tasks.integration_service.get_integration", - return_value=self.integration, + return_value=self.mock_integration, ).start() self.incr = patch("sentry.integrations.bitbucket_server.tasks.metrics.incr").start() self.addCleanup(patch.stopall) @@ -92,7 +92,7 @@ def test_completed_repair_does_not_call_bitbucket(self) -> None: self.run_task() - self.integration.get_installation.assert_not_called() + self.mock_integration.get_installation.assert_not_called() self.assert_outcome("already_repaired") def test_updates_existing_hook_and_removes_pending_flag(self) -> None: @@ -172,7 +172,7 @@ def test_missing_webhook_id_stops_without_repair(self) -> None: self.run_task() token_hex.assert_not_called() - self.integration.get_installation.assert_not_called() + self.mock_integration.get_installation.assert_not_called() self.update_config.assert_not_called() self.assert_outcome("missing_webhook") diff --git a/tests/sentry/integrations/claude_code/test_integration.py b/tests/sentry/integrations/claude_code/test_integration.py index 7acdc6d28b0a..bf2d5ff67a2b 100644 --- a/tests/sentry/integrations/claude_code/test_integration.py +++ b/tests/sentry/integrations/claude_code/test_integration.py @@ -6,7 +6,7 @@ import hashlib from collections.abc import Mapping -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from unittest.mock import MagicMock, patch import pytest @@ -26,10 +26,17 @@ from sentry.testutils.cases import APITestCase, IntegrationTestCase from sentry.testutils.silo import assume_test_silo_mode, control_silo_test +if TYPE_CHECKING: + from sentry.integrations.coding_agent.models import CodingAgentLaunchRequest + MOCK_GET_CLIENT_CLASS = "sentry.integrations.claude_code.integration._get_client_class" -def _mock_client_class(validate_return=True, validate_side_effect=None, **client_attrs): +def _mock_client_class( + validate_return: bool = True, + validate_side_effect: object = None, + **client_attrs: object, +) -> tuple[MagicMock, MagicMock]: """Create a mock client class whose instances have validate_api_key and optional attributes.""" mock_client = MagicMock() mock_client.model = "claude-opus-4-6" @@ -47,8 +54,8 @@ def _mock_client_class(validate_return=True, validate_side_effect=None, **client class ClaudeCodeIntegrationTest(IntegrationTestCase): provider = ClaudeCodeAgentIntegrationProvider - def _make_metadata(self, **overrides): - defaults = { + def _make_metadata(self, **overrides: object) -> dict[str, object]: + defaults: dict[str, object] = { "api_key": "sk-ant-test-api-key-123", "domain_name": "anthropic.com", } @@ -64,7 +71,7 @@ def _expected_external_id(self) -> str: digest = hashlib.sha256(f"{PROVIDER_KEY}:{self.organization.id}".encode()).hexdigest() return digest[:32] - def _create_installation(self, **metadata_overrides) -> ClaudeCodeAgentIntegration: + def _create_installation(self, **metadata_overrides: object) -> ClaudeCodeAgentIntegration: """Helper to create an integration and return its installation.""" integration = self.create_integration( organization=self.organization, @@ -459,7 +466,7 @@ def test_uninstall_concurrent_vault_write_during_archive_is_preserved(self) -> N ) mock_cls, mock_client = _mock_client_class() - def archive_with_concurrent_write(vault_id): + def archive_with_concurrent_write(vault_id: str) -> None: with assume_test_silo_mode(SiloMode.CONTROL): fresh = Integration.objects.get(id=installation.model.id) fresh.metadata["installation_vault_ids"]["99"] = "vault_new" @@ -478,13 +485,13 @@ def archive_with_concurrent_write(vault_id): def _setup_launch( self, - environment_id=None, - client_environment_id=None, - agent_id=None, - agent_version=None, - client_agent_id=None, - client_agent_version=None, - ): + environment_id: str | None = None, + client_environment_id: str | None = None, + agent_id: str | None = None, + agent_version: int | None = None, + client_agent_id: str | None = None, + client_agent_version: int | None = None, + ) -> tuple[ClaudeCodeAgentIntegration, MagicMock, MagicMock, CodingAgentLaunchRequest]: """Set up mocks for launch tests. Returns (installation, mock_client, mock_cls, request).""" from sentry.integrations.coding_agent.models import CodingAgentLaunchRequest from sentry.seer.autofix.constants import CodingAgentStatus diff --git a/tests/sentry/integrations/cursor/test_integration.py b/tests/sentry/integrations/cursor/test_integration.py index 7099dd06d4ab..4a0cbd044079 100644 --- a/tests/sentry/integrations/cursor/test_integration.py +++ b/tests/sentry/integrations/cursor/test_integration.py @@ -26,11 +26,11 @@ @pytest.fixture -def provider(): +def provider() -> CursorAgentIntegrationProvider: return CursorAgentIntegrationProvider() -def test_build_integration_stores_metadata(provider): +def test_build_integration_stores_metadata(provider: CursorAgentIntegrationProvider) -> None: fake_uuid = UUID("11111111-2222-3333-4444-555555555555") with ( patch("sentry.integrations.cursor.integration.uuid.uuid4", return_value=fake_uuid), @@ -51,7 +51,7 @@ def test_build_integration_stores_metadata(provider): assert metadata["webhook_secret"] == "hook-secret" -def test_build_integration_with_user_api_key(provider): +def test_build_integration_with_user_api_key(provider: CursorAgentIntegrationProvider) -> None: """Test that build_integration uses /v1/me metadata when available""" fake_uuid = UUID("22222222-3333-4444-5555-666666666666") @@ -73,7 +73,7 @@ def test_build_integration_with_user_api_key(provider): assert integration_data["metadata"]["user_email"] == "developer@example.com" -def test_build_integration_with_service_account(provider): +def test_build_integration_with_service_account(provider: CursorAgentIntegrationProvider) -> None: """Service account keys (e.g. `grok_` keys) have a key name but no user email""" fake_uuid = UUID("22222222-3333-4444-5555-666666666666") @@ -93,7 +93,7 @@ def test_build_integration_with_service_account(provider): assert integration_data["metadata"]["user_email"] is None -def test_build_integration_raises_on_api_error(provider): +def test_build_integration_raises_on_api_error(provider: CursorAgentIntegrationProvider) -> None: """Test that build_integration raises IntegrationConfigurationError on API failure""" fake_uuid = UUID("33333333-4444-5555-6666-777777777777") @@ -112,7 +112,9 @@ def test_build_integration_raises_on_api_error(provider): provider.build_integration(state={"config": {"api_key": "cursor-api"}}) -def test_build_integration_raises_on_invalid_api_key(provider): +def test_build_integration_raises_on_invalid_api_key( + provider: CursorAgentIntegrationProvider, +) -> None: """Test that build_integration raises IntegrationConfigurationError with 401/403 status""" fake_uuid = UUID("44444444-5555-6666-7777-888888888888") @@ -131,7 +133,9 @@ def test_build_integration_raises_on_invalid_api_key(provider): provider.build_integration(state={"config": {"api_key": "invalid-api-key"}}) -def test_build_integration_stores_api_key_and_webhook_secret(provider): +def test_build_integration_stores_api_key_and_webhook_secret( + provider: CursorAgentIntegrationProvider, +) -> None: """Test that build_integration stores both API key and webhook secret""" with patch( "sentry.integrations.cursor.client.CursorAgentClient.verify_api_key", @@ -210,7 +214,7 @@ def test_get_client(self) -> None: assert client.base_url == "https://api.cursor.com" @patch("sentry.integrations.cursor.client.CursorAgentClient.post") - def test_launch(self, mock_post): + def test_launch(self, mock_post: MagicMock) -> None: from datetime import datetime from sentry.integrations.coding_agent.models import CodingAgentLaunchRequest @@ -279,7 +283,9 @@ def test_launch(self, mock_post): "sentry.integrations.cursor.client.CursorAgentClient.verify_api_key", return_value=SERVICE_ACCOUNT_METADATA, ) - def test_update_organization_config_persists_api_key_and_clears_org_config(self, mock_verify): + def test_update_organization_config_persists_api_key_and_clears_org_config( + self, mock_verify: MagicMock + ) -> None: integration = self.create_integration( organization=self.organization, provider="cursor", @@ -330,7 +336,9 @@ def test_update_organization_config_missing_api_key_raises(self) -> None: installation.update_organization_config({"api_key": ""}) @patch("sentry.integrations.cursor.client.CursorAgentClient.verify_api_key") - def test_update_organization_config_sets_custom_display_name(self, mock_verify): + def test_update_organization_config_sets_custom_display_name( + self, mock_verify: MagicMock + ) -> None: mock_verify.return_value = CursorApiKeyMetadata( apiKeyName="Production Key", createdAt="2024-01-15T10:30:00Z", @@ -363,7 +371,9 @@ def test_update_organization_config_sets_custom_display_name(self, mock_verify): assert integration.metadata["api_key"] == "old_key" @patch("sentry.integrations.cursor.client.CursorAgentClient.verify_api_key") - def test_update_organization_config_preserves_custom_name_on_key_rotation(self, mock_verify): + def test_update_organization_config_preserves_custom_name_on_key_rotation( + self, mock_verify: MagicMock + ) -> None: mock_verify.return_value = CursorApiKeyMetadata( apiKeyName="Rotated Key", createdAt="2024-01-15T10:30:00Z", @@ -395,7 +405,9 @@ def test_update_organization_config_preserves_custom_name_on_key_rotation(self, assert integration.metadata["api_key_name"] == "Rotated Key" @patch("sentry.integrations.cursor.client.CursorAgentClient.verify_api_key") - def test_update_organization_config_clearing_display_name_reverts_to_default(self, mock_verify): + def test_update_organization_config_clearing_display_name_reverts_to_default( + self, mock_verify: MagicMock + ) -> None: integration = self.create_integration( organization=self.organization, provider="cursor", @@ -462,7 +474,9 @@ def test_get_organization_config_includes_display_name_field(self) -> None: assert fields["display_name"]["placeholder"] == "Cursor Cloud Agent - Production Key" @patch("sentry.integrations.cursor.client.CursorAgentClient.verify_api_key") - def test_update_organization_config_raises_on_invalid_key(self, mock_get_metadata): + def test_update_organization_config_raises_on_invalid_key( + self, mock_get_metadata: MagicMock + ) -> None: mock_get_metadata.side_effect = ApiError("Unauthorized", 401) integration = self.create_integration( @@ -486,7 +500,9 @@ def test_update_organization_config_raises_on_invalid_key(self, mock_get_metadat assert integration.metadata["api_key"] == "old_key" @patch("sentry.integrations.cursor.client.CursorAgentClient.verify_api_key") - def test_update_organization_config_raises_on_api_error(self, mock_get_metadata): + def test_update_organization_config_raises_on_api_error( + self, mock_get_metadata: MagicMock + ) -> None: mock_get_metadata.side_effect = ApiError("API Error", 500) integration = self.create_integration( @@ -537,7 +553,7 @@ def test_property_getters(self) -> None: "sentry.integrations.cursor.client.CursorAgentClient.verify_api_key", return_value=SERVICE_ACCOUNT_METADATA, ) - def test_build_integration_creates_unique_installations(self, mock_verify): + def test_build_integration_creates_unique_installations(self, mock_verify: MagicMock) -> None: """Test that each call to build_integration creates a unique integration""" state: Mapping[str, Any] = {"config": {"api_key": "test_api_key_123"}} diff --git a/tests/sentry/integrations/cursor/test_webhook.py b/tests/sentry/integrations/cursor/test_webhook.py index 0c302d39c134..051f1e5de99c 100644 --- a/tests/sentry/integrations/cursor/test_webhook.py +++ b/tests/sentry/integrations/cursor/test_webhook.py @@ -1,12 +1,13 @@ import hashlib import hmac -from typing import Any -from unittest.mock import patch +from typing import Any, cast +from unittest.mock import MagicMock, patch import orjson import pytest from django.urls import reverse from rest_framework.exceptions import MethodNotAllowed +from rest_framework.response import Response from sentry.models.pullrequest import ( PullRequestAttribution, @@ -47,10 +48,13 @@ def _signed_headers(self, body: bytes, secret: str | None = None) -> dict[str, s signature = hmac.new(used_secret.encode("utf-8"), body, hashlib.sha256).hexdigest() return {"HTTP_X_WEBHOOK_SIGNATURE": f"sha256={signature}"} - def _post_with_headers(self, body: bytes, headers: dict[str, str]): + def _post_with_headers(self, body: bytes, headers: dict[str, str]) -> Response: # mypy: The DRF APIClient stubs can misinterpret **extra headers as a positional arg. client: Any = self.client - return client.post(self._url(), data=body, content_type="application/json", **headers) + response: Response[Any] = client.post( + self._url(), data=body, content_type="application/json", **headers + ) + return response def _build_status_payload( self, @@ -80,10 +84,11 @@ def _skip_log_extra(self, mock_logger: Any) -> dict[str, Any]: if c.args and c.args[0] == "cursor_webhook.attribution_skipped" ] assert len(calls) == 1 - return calls[0].kwargs["extra"] + # justification: Mock records the kwargs the logger was called with. + return cast("dict[str, Any]", calls[0].kwargs["extra"]) @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_happy_path_finished(self, mock_update_state): + def test_happy_path_finished(self, mock_update_state: MagicMock) -> None: mock_update_state.return_value = CodingAgentSyncResult( known_to_seer=True, run_id=None, group_id=None ) @@ -108,7 +113,9 @@ def test_happy_path_finished(self, mock_update_state): assert result.branch_name == "cursor/fix-bug-1234" @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_pr_number_parsed_past_trailing_url_segments(self, mock_update_state): + def test_pr_number_parsed_past_trailing_url_segments( + self, mock_update_state: MagicMock + ) -> None: for pr_url in [ "https://github.com/testorg/testrepo/pull/649", "https://github.com/testorg/testrepo/pull/649/files", @@ -127,7 +134,7 @@ def test_pr_number_parsed_past_trailing_url_segments(self, mock_update_state): assert result.pr_url == pr_url, pr_url @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_pr_number_withheld_when_not_completed(self, mock_update_state): + def test_pr_number_withheld_when_not_completed(self, mock_update_state: MagicMock) -> None: # Only a completed agent has a result to point at, so an errored one reports neither. body = orjson.dumps(self._build_status_payload(status="ERROR")) headers = self._signed_headers(body) @@ -140,7 +147,7 @@ def test_pr_number_withheld_when_not_completed(self, mock_update_state): assert result.pr_number is None @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_branch_name_absent_is_none(self, mock_update_state): + def test_branch_name_absent_is_none(self, mock_update_state: MagicMock) -> None: payload = self._build_status_payload(status="FINISHED", branch_name=None) body = orjson.dumps(payload) headers = self._signed_headers(body) @@ -151,7 +158,7 @@ def test_branch_name_absent_is_none(self, mock_update_state): assert mock_update_state.call_args[1]["result"].branch_name is None @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_finished_records_pr_attribution(self, mock_update_state): + def test_finished_records_pr_attribution(self, mock_update_state: MagicMock) -> None: mock_update_state.return_value = CodingAgentSyncResult( known_to_seer=True, run_id=123, group_id=456 ) @@ -177,7 +184,9 @@ def test_finished_records_pr_attribution(self, mock_update_state): assert attribution.signal_details["group_ids"] == [456] @patch("sentry.seer.autofix.coding_agent_handoffs.update_coding_agent_state") - def test_finished_updates_seer_run_coding_agent_handoff(self, mock_update_state): + def test_finished_updates_seer_run_coding_agent_handoff( + self, mock_update_state: MagicMock + ) -> None: # Mocked one level deeper than the other tests in this file (Seer's own RPC call, # rather than sync_coding_agent_status itself) so the real Sentry-side DB write # inside sync_coding_agent_status still runs and can be asserted on here. @@ -203,7 +212,7 @@ def test_finished_updates_seer_run_coding_agent_handoff(self, mock_update_state) assert pull_requests[0].key == "1" @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_unknown_agent_records_no_attribution(self, mock_update_state): + def test_unknown_agent_records_no_attribution(self, mock_update_state: MagicMock) -> None: # Seer returns False (e.g. 404) for agent_ids it doesn't know about — # these are Cursor sessions not delegated by Seer, and must not be attributed. mock_update_state.return_value = CodingAgentSyncResult( @@ -220,7 +229,7 @@ def test_unknown_agent_records_no_attribution(self, mock_update_state): assert not PullRequestAttribution.objects.exists() @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_error_status_records_no_attribution(self, mock_update_state): + def test_error_status_records_no_attribution(self, mock_update_state: MagicMock) -> None: self.create_repo(self.project, name="testorg/testrepo", provider="integrations:github") body = orjson.dumps(self._build_status_payload(status="ERROR", pr_url=None)) headers = self._signed_headers(body) @@ -232,7 +241,9 @@ def test_error_status_records_no_attribution(self, mock_update_state): assert not PullRequestAttribution.objects.exists() @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_finished_without_pr_url_records_no_attribution(self, mock_update_state): + def test_finished_without_pr_url_records_no_attribution( + self, mock_update_state: MagicMock + ) -> None: # Isolates the ``and pr_url`` half of the attribution guard: a completed # agent that produced no PR must not be attributed. self.create_repo(self.project, name="testorg/testrepo", provider="integrations:github") @@ -247,7 +258,9 @@ def test_finished_without_pr_url_records_no_attribution(self, mock_update_state) @patch("sentry.integrations.cursor.webhooks.handler.logger") @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_completed_without_attribution_logs_skip_reason(self, mock_update_state, mock_logger): + def test_completed_without_attribution_logs_skip_reason( + self, mock_update_state: MagicMock, mock_logger: MagicMock + ) -> None: # A completed agent that we don't attribute must leave a breadcrumb naming the # gate that blocked it, otherwise a missing SEER_DELEGATED_CURSOR row is invisible. self.create_repo(self.project, name="testorg/testrepo", provider="integrations:github") @@ -276,7 +289,9 @@ def test_completed_without_attribution_logs_skip_reason(self, mock_update_state, assert extra["has_pr_url"] is False @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_finished_records_no_attribution_when_flag_disabled(self, mock_update_state): + def test_finished_records_no_attribution_when_flag_disabled( + self, mock_update_state: MagicMock + ) -> None: self.create_repo(self.project, name="testorg/testrepo", provider="integrations:github") body = orjson.dumps(self._build_status_payload(status="FINISHED")) headers = self._signed_headers(body) @@ -314,7 +329,7 @@ def test_invalid_signature(self) -> None: "sentry.integrations.cursor.webhooks.handler.CursorWebhookEndpoint._get_cursor_integration_secret", return_value=None, ) - def test_no_webhook_secret_set(self, _mock_secret): + def test_no_webhook_secret_set(self, _mock_secret: MagicMock) -> None: payload = self._build_status_payload() body = orjson.dumps(payload) # Provide any signature header so we hit secret lookup path @@ -323,7 +338,7 @@ def test_no_webhook_secret_set(self, _mock_secret): assert response.status_code == 403 @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_error_status_maps_to_failed(self, mock_update_state): + def test_error_status_maps_to_failed(self, mock_update_state: MagicMock) -> None: payload = self._build_status_payload(status="ERROR", pr_url=None) body = orjson.dumps(payload) headers = self._signed_headers(body) @@ -337,7 +352,7 @@ def test_error_status_maps_to_failed(self, mock_update_state): assert kwargs["result"].pr_url is None @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_unknown_status_logs_and_defaults_to_failed(self, mock_update_state): + def test_unknown_status_logs_and_defaults_to_failed(self, mock_update_state: MagicMock) -> None: payload = self._build_status_payload(status="WEIRD") body = orjson.dumps(payload) headers = self._signed_headers(body) @@ -362,7 +377,7 @@ def test_missing_agent_id_or_status(self) -> None: assert resp.status_code == 204 @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_repo_variants_and_validation(self, mock_update_state): + def test_repo_variants_and_validation(self, mock_update_state: MagicMock) -> None: # Missing repo payload = self._build_status_payload() payload["source"].pop("repository") @@ -406,7 +421,7 @@ def test_repo_variants_and_validation(self, mock_update_state): assert mock_update_state.call_count == 1 @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_invalid_agent_url_is_dropped(self, mock_update_state): + def test_invalid_agent_url_is_dropped(self, mock_update_state: MagicMock) -> None: mock_update_state.return_value = CodingAgentSyncResult( known_to_seer=True, run_id=None, group_id=None ) @@ -432,7 +447,7 @@ def test_invalid_agent_url_is_dropped(self, mock_update_state): assert mock_update_state.call_args[1]["agent_url"] is None, bad_url @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_invalid_pr_url_is_dropped(self, mock_update_state): + def test_invalid_pr_url_is_dropped(self, mock_update_state: MagicMock) -> None: # Anything that isn't a PR URL in the reported repo is nulled out, so no attribution # fires. mock_update_state.return_value = CodingAgentSyncResult( @@ -464,7 +479,7 @@ def test_invalid_pr_url_is_dropped(self, mock_update_state): assert not PullRequestAttribution.objects.exists(), bad_url @patch("sentry.integrations.cursor.webhooks.handler.sync_coding_agent_status") - def test_signature_without_prefix(self, mock_update_state): + def test_signature_without_prefix(self, mock_update_state: MagicMock) -> None: payload = self._build_status_payload(status="FINISHED") body = orjson.dumps(payload) secret = self.integration.metadata["webhook_secret"] diff --git a/tests/sentry/integrations/cursor_origin/test_pipeline.py b/tests/sentry/integrations/cursor_origin/test_pipeline.py index e15ec96213f9..162bb5002ca4 100644 --- a/tests/sentry/integrations/cursor_origin/test_pipeline.py +++ b/tests/sentry/integrations/cursor_origin/test_pipeline.py @@ -1,7 +1,7 @@ from __future__ import annotations import time -from typing import Any +from typing import Any, cast from unittest import mock from urllib.parse import urlencode @@ -315,7 +315,8 @@ def _setup(self, **params: str) -> HttpResponseBase: self.options({"cursor-origin-app.id": APP_ID}), mock.patch(KEYS, return_value=[self.public]), ): - return self.client.get(f"{self.setup_path}?{urlencode(params)}") + response: HttpResponseBase = self.client.get(f"{self.setup_path}?{urlencode(params)}") + return response def _org_picker(self, receipt: str) -> str: return reverse( @@ -386,7 +387,8 @@ def _validated(self, data: dict[str, str]) -> dict[str, str]: ): serializer = ExternalInstallSerializer(data=data) serializer.is_valid(raise_exception=True) - return serializer.validated_data + # justification: DRF returns the validated attribute mapping. + return cast("dict[str, str]", serializer.validated_data) def test_binds_only_the_installation_the_receipt_names(self) -> None: receipt = _receipt(self.private, state=None) diff --git a/tests/sentry/integrations/cursor_origin/test_repository.py b/tests/sentry/integrations/cursor_origin/test_repository.py index 7273c5b42f90..b92d09f69e05 100644 --- a/tests/sentry/integrations/cursor_origin/test_repository.py +++ b/tests/sentry/integrations/cursor_origin/test_repository.py @@ -77,7 +77,12 @@ def test_carries_the_integration_id_to_the_config(self) -> None: assert self._repository_data()["integration_id"] == self.integration.id def test_the_config_is_built_from_what_the_picker_produced(self) -> None: - config = self.provider.build_repository_config(self.organization, self._repository_data()) + config = self.provider.build_repository_config( + serialize_rpc_organization( + self.organization, include_projects=False, include_teams=False + ), + self._repository_data(), + ) assert config == { "name": REPO, @@ -150,7 +155,9 @@ def test_a_reinstall_adopts_the_old_installation_s_repository(self) -> None: "default_branch": "main", "integration_id": self.integration.id, }, - serialize_rpc_organization(self.organization), + serialize_rpc_organization( + self.organization, include_projects=False, include_teams=False + ), ) assert repo.id == stranded.id diff --git a/tests/sentry/integrations/cursor_origin/test_webhook.py b/tests/sentry/integrations/cursor_origin/test_webhook.py index d096dc8df62f..d04957066af8 100644 --- a/tests/sentry/integrations/cursor_origin/test_webhook.py +++ b/tests/sentry/integrations/cursor_origin/test_webhook.py @@ -104,7 +104,7 @@ def _post( "webhook-event-type": event_type, }, ) - return response.status_code + return int(response.status_code) def test_an_uninstall_disables_the_integration(self) -> None: integration = self.create_integration( @@ -372,7 +372,7 @@ def _post(self, timestamp: str) -> int: "webhook-event-type": "installation.deleted", }, ) - return response.status_code + return int(response.status_code) def test_a_delivery_held_in_the_mailbox_is_still_accepted(self) -> None: """The window is checked at the edge; a queue wait must not lose the delivery.""" diff --git a/tests/sentry/integrations/data_forwarding/amazon_sqs/test_forwarder.py b/tests/sentry/integrations/data_forwarding/amazon_sqs/test_forwarder.py index 7112c716ad3d..441cc1afefaf 100644 --- a/tests/sentry/integrations/data_forwarding/amazon_sqs/test_forwarder.py +++ b/tests/sentry/integrations/data_forwarding/amazon_sqs/test_forwarder.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch import orjson from botocore.exceptions import ClientError @@ -36,7 +36,7 @@ def setUp(self) -> None: self.forwarder = AmazonSQSForwarder() @patch("boto3.client") - def test_simple_notification(self, mock_client): + def test_simple_notification(self, mock_client: MagicMock) -> None: event = self.store_event( data={ "exception": {"type": "ValueError", "value": "foo bar"}, @@ -61,7 +61,7 @@ def test_simple_notification(self, mock_client): ) @patch("boto3.client") - def test_token_error(self, mock_client): + def test_token_error(self, mock_client: MagicMock) -> None: mock_client.return_value.send_message.side_effect = ClientError( {"Error": {"Code": "InvalidClientTokenId", "Message": "Invalid token"}}, "SendMessage", @@ -75,7 +75,7 @@ def test_token_error(self, mock_client): self.forwarder.post_process(event, self.data_forwarder_project) @patch("boto3.client") - def test_message_group_error(self, mock_client): + def test_message_group_error(self, mock_client: MagicMock) -> None: mock_client.return_value.send_message.side_effect = ClientError( { "Error": { @@ -94,7 +94,7 @@ def test_message_group_error(self, mock_client): self.forwarder.post_process(event, self.data_forwarder_project) @patch("boto3.client") - def test_pass_message_group_id(self, mock_client): + def test_pass_message_group_id(self, mock_client: MagicMock) -> None: self.data_forwarder.config["message_group_id"] = "my_group" self.data_forwarder.save() @@ -113,7 +113,7 @@ def test_pass_message_group_id(self, mock_client): assert "MessageDeduplicationId" in call_args @patch("boto3.client") - def test_use_s3_bucket(self, mock_client): + def test_use_s3_bucket(self, mock_client: MagicMock) -> None: self.data_forwarder.config["s3_bucket"] = "my_bucket" self.data_forwarder.save() @@ -160,7 +160,7 @@ def test_get_task_payload(self) -> None: @patch("boto3.client") @override_options({"data-forwarding.task-rollout-rate": 1.0}) - def test_forward_event_from_task(self, mock_client) -> None: + def test_forward_event_from_task(self, mock_client: MagicMock) -> None: self.data_forwarder.config["message_group_id"] = "my_group" self.data_forwarder.config["s3_bucket"] = "my_bucket" self.data_forwarder.save() diff --git a/tests/sentry/integrations/data_forwarding/test_base.py b/tests/sentry/integrations/data_forwarding/test_base.py index 298e49361cce..e6c3e72e8ef7 100644 --- a/tests/sentry/integrations/data_forwarding/test_base.py +++ b/tests/sentry/integrations/data_forwarding/test_base.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch import responses @@ -34,7 +34,7 @@ def setUp(self) -> None: @responses.activate @override_options({"data-forwarding.task-rollout-rate": 1.0}) @patch("sentry.integrations.data_forwarding.tasks.forward_event") - def test_post_process_rollout_dispatches_to_task(self, mock_forward_event) -> None: + def test_post_process_rollout_dispatches_to_task(self, mock_forward_event: MagicMock) -> None: responses.add(responses.POST, "https://api.segment.io/v1/track") self.forwarder.post_process(self.event, self.data_forwarder_project) mock_forward_event.delay.assert_called_once() diff --git a/tests/sentry/integrations/discord/test_integration.py b/tests/sentry/integrations/discord/test_integration.py index 68c0236e6a1a..6e5b4e477cc0 100644 --- a/tests/sentry/integrations/discord/test_integration.py +++ b/tests/sentry/integrations/discord/test_integration.py @@ -30,6 +30,7 @@ NotificationProviderKey, NotificationTargetResourceType, ) +from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.shared_integrations.exceptions import ( ApiError, IntegrationConfigurationError, @@ -235,7 +236,9 @@ def test_post_install(self, mock_set_application_command: mock.MagicMock) -> Non ) provider.post_install( - integration=self.integration, organization=self.organization, extra={} + integration=self.integration, + organization=serialize_rpc_organization(self.organization), + extra={}, ) assert mock_set_application_command.call_count == 3 # one for each command @@ -246,7 +249,9 @@ def test_post_install_missing_credentials( provider = self.provider() provider.application_id = None provider.post_install( - integration=self.integration, organization=self.organization, extra={} + integration=self.integration, + organization=serialize_rpc_organization(self.organization), + extra={}, ) assert mock_set_application_command.call_count == 0 @@ -268,7 +273,9 @@ def test_set_commands_failure(self) -> None: ) with pytest.raises(ApiError): provider.post_install( - integration=self.integration, organization=self.organization, extra={} + integration=self.integration, + organization=serialize_rpc_organization(self.organization), + extra={}, ) @responses.activate @@ -283,7 +290,9 @@ def test_get_commands_failure(self) -> None: ) with pytest.raises(ApiError): provider.post_install( - integration=self.integration, organization=self.organization, extra={} + integration=self.integration, + organization=serialize_rpc_organization(self.organization), + extra={}, ) def test_build_integration_invalid_guild_id(self) -> None: @@ -376,7 +385,10 @@ def _advance_step(self, data: dict[str, Any]) -> Any: return self.client.post(self._get_pipeline_url(), data=data, format="json") def _get_pipeline_signature(self, resp: Any) -> str: - return resp.data["data"]["oauthUrl"].split("state=")[1].split("&")[0] + oauth_url = resp.data["data"]["oauthUrl"] + if not isinstance(oauth_url, str): + raise TypeError("Discord pipeline response carries no OAuth URL") + return oauth_url.split("state=")[1].split("&")[0] @responses.activate def test_initialize_pipeline(self) -> None: diff --git a/tests/sentry/integrations/discord/test_issue_alert.py b/tests/sentry/integrations/discord/test_issue_alert.py index a74592367298..86304819a0e0 100644 --- a/tests/sentry/integrations/discord/test_issue_alert.py +++ b/tests/sentry/integrations/discord/test_issue_alert.py @@ -50,7 +50,7 @@ def setUp(self) -> None: self.identity = self.create_identity( user=self.user, identity_provider=self.provider, external_id=self.discord_user_id ) - self.event = self.store_event( + event = self.store_event( data={ "event_id": "a" * 32, "message": "Event message", @@ -58,6 +58,8 @@ def setUp(self) -> None: }, project_id=self.project.id, ) + assert event.group is not None + self.group_event = event.for_group(event.group) self.tags = "environment, user" self.rule = self.get_rule( data={ @@ -77,7 +79,7 @@ def setUp(self) -> None: @mock.patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def assert_lifecycle_metrics(self, mock_record_event: mock.MagicMock) -> None: notification_uuid = str(uuid4()) - self.rule.after(self.event, notification_uuid=notification_uuid) + self.rule.after(self.group_event, notification_uuid=notification_uuid) assert_slo_metric(mock_record_event) @@ -88,7 +90,7 @@ def assert_lifecycle_metrics(self, mock_record_event: mock.MagicMock) -> None: def assert_lifecycle_metrics_failure( self, mock_record_event: mock.MagicMock, mock_send_message: mock.MagicMock ) -> None: - self.rule.after(self.event) + self.rule.after(self.group_event) assert_slo_metric(mock_record_event, EventLifecycleOutcome.FAILURE) @mock.patch( @@ -99,7 +101,7 @@ def assert_lifecycle_metrics_failure( def assert_lifecycle_metrics_halt_for_rate_limit( self, mock_record_event: mock.MagicMock, mock_send_message: mock.MagicMock ) -> None: - self.rule.after(self.event) + self.rule.after(self.group_event) assert_slo_metric(mock_record_event, EventLifecycleOutcome.HALTED) @mock.patch( @@ -110,7 +112,7 @@ def assert_lifecycle_metrics_halt_for_rate_limit( def assert_lifecycle_metrics_halt_for_missing_access( self, mock_record_event: mock.MagicMock, mock_send_message: mock.MagicMock ) -> None: - self.rule.after(self.event) + self.rule.after(self.group_event) assert_slo_metric(mock_record_event, EventLifecycleOutcome.HALTED) @mock.patch( @@ -121,40 +123,46 @@ def assert_lifecycle_metrics_halt_for_missing_access( def assert_lifecycle_metrics_failure_for_other_api_error( self, mock_record_event: mock.MagicMock, mock_send_message: mock.MagicMock ) -> None: - self.rule.after(self.event) + self.rule.after(self.group_event) assert_slo_metric(mock_record_event, EventLifecycleOutcome.FAILURE) @responses.activate @mock.patch("sentry.analytics.record") def test_basic(self, mock_record: mock.MagicMock) -> None: notification_uuid = str(uuid4()) - results = list(self.rule.after(self.event, notification_uuid=notification_uuid)) + results = list(self.rule.after(self.group_event, notification_uuid=notification_uuid)) assert len(results) == 1 - results[0].callback(self.event, futures=[]) + results[0].callback(self.group_event, futures=[]) body = responses.calls[0].request.body data = orjson.loads(body) embed = data["embeds"][0] assert embed == { - "title": build_attachment_title(self.event.group), + "title": build_attachment_title(self.group_event.group), "color": LEVEL_TO_COLOR["error"], - "footer": {"text": build_footer(self.event.group, self.event.project, "{text}", None)}, + "footer": { + "text": build_footer( + self.group_event.group, self.group_event.project, "{text}", None + ) + }, "fields": [], - "timestamp": self.event.timestamp, + "timestamp": self.group_event.timestamp, } buttons = data["components"][0]["components"] assert ( - buttons[0]["custom_id"] == f"{DiscordComponentCustomIds.RESOLVE}:{self.event.group.id}" + buttons[0]["custom_id"] + == f"{DiscordComponentCustomIds.RESOLVE}:{self.group_event.group.id}" ) assert ( - buttons[1]["custom_id"] == f"{DiscordComponentCustomIds.ARCHIVE}:{self.event.group.id}" + buttons[1]["custom_id"] + == f"{DiscordComponentCustomIds.ARCHIVE}:{self.group_event.group.id}" ) assert ( buttons[2]["custom_id"] - == f"{DiscordComponentCustomIds.ASSIGN_DIALOG}:{self.event.group.id}" + == f"{DiscordComponentCustomIds.ASSIGN_DIALOG}:{self.group_event.group.id}" ) assert_any_analytics_event( mock_record, @@ -162,7 +170,7 @@ def test_basic(self, mock_record: mock.MagicMock) -> None: category="issue_alert", organization_id=self.organization.id, project_id=self.project.id, - group_id=self.event.group_id, + group_id=self.group_event.group_id, notification_uuid=notification_uuid, alert_id=None, ), @@ -188,10 +196,10 @@ def test_has_releases(self) -> None: ) release.add_project(self.project) - results = list(self.rule.after(self.event)) + results = list(self.rule.after(self.group_event)) assert len(results) == 1 - results[0].callback(self.event, futures=[]) + results[0].callback(self.group_event, futures=[]) body = responses.calls[0].request.body data = orjson.loads(body) @@ -199,14 +207,15 @@ def test_has_releases(self) -> None: buttons = data["components"][0]["components"] assert ( buttons[0]["custom_id"] - == f"{DiscordComponentCustomIds.RESOLVE_DIALOG}:{self.event.group.id}" + == f"{DiscordComponentCustomIds.RESOLVE_DIALOG}:{self.group_event.group.id}" ) assert ( - buttons[1]["custom_id"] == f"{DiscordComponentCustomIds.ARCHIVE}:{self.event.group.id}" + buttons[1]["custom_id"] + == f"{DiscordComponentCustomIds.ARCHIVE}:{self.group_event.group.id}" ) assert ( buttons[2]["custom_id"] - == f"{DiscordComponentCustomIds.ASSIGN_DIALOG}:{self.event.group.id}" + == f"{DiscordComponentCustomIds.ASSIGN_DIALOG}:{self.group_event.group.id}" ) @responses.activate @@ -215,10 +224,10 @@ def test_has_releases(self) -> None: return_value=GroupStatus.RESOLVED, ) def test_resolved(self, mock_get_status: mock.MagicMock) -> None: - results = list(self.rule.after(self.event)) + results = list(self.rule.after(self.group_event)) assert len(results) == 1 - results[0].callback(self.event, futures=[]) + results[0].callback(self.group_event, futures=[]) body = responses.calls[0].request.body data = orjson.loads(body) @@ -226,14 +235,15 @@ def test_resolved(self, mock_get_status: mock.MagicMock) -> None: buttons = data["components"][0]["components"] assert ( buttons[0]["custom_id"] - == f"{DiscordComponentCustomIds.UNRESOLVE}:{self.event.group.id}" + == f"{DiscordComponentCustomIds.UNRESOLVE}:{self.group_event.group.id}" ) assert ( - buttons[1]["custom_id"] == f"{DiscordComponentCustomIds.ARCHIVE}:{self.event.group.id}" + buttons[1]["custom_id"] + == f"{DiscordComponentCustomIds.ARCHIVE}:{self.group_event.group.id}" ) assert ( buttons[2]["custom_id"] - == f"{DiscordComponentCustomIds.ASSIGN_DIALOG}:{self.event.group.id}" + == f"{DiscordComponentCustomIds.ASSIGN_DIALOG}:{self.group_event.group.id}" ) @responses.activate @@ -242,39 +252,40 @@ def test_resolved(self, mock_get_status: mock.MagicMock) -> None: return_value=GroupStatus.IGNORED, ) def test_ignored(self, mock_get_status: mock.MagicMock) -> None: - results = list(self.rule.after(self.event)) + results = list(self.rule.after(self.group_event)) assert len(results) == 1 - results[0].callback(self.event, futures=[]) + results[0].callback(self.group_event, futures=[]) body = responses.calls[0].request.body data = orjson.loads(body) buttons = data["components"][0]["components"] assert ( - buttons[0]["custom_id"] == f"{DiscordComponentCustomIds.RESOLVE}:{self.event.group.id}" + buttons[0]["custom_id"] + == f"{DiscordComponentCustomIds.RESOLVE}:{self.group_event.group.id}" ) assert ( buttons[1]["custom_id"] - == f"{DiscordComponentCustomIds.MARK_ONGOING}:{self.event.group.id}" + == f"{DiscordComponentCustomIds.MARK_ONGOING}:{self.group_event.group.id}" ) assert ( buttons[2]["custom_id"] - == f"{DiscordComponentCustomIds.ASSIGN_DIALOG}:{self.event.group.id}" + == f"{DiscordComponentCustomIds.ASSIGN_DIALOG}:{self.group_event.group.id}" ) @responses.activate def test_feature_flag_disabled(self) -> None: - results = list(self.rule.after(self.event)) + results = list(self.rule.after(self.group_event)) assert len(results) == 1 - results[0].callback(self.event, futures=[]) + results[0].callback(self.group_event, futures=[]) responses.assert_call_count(f"{MESSAGE_URL.format(channel_id=self.channel_id)}", 0) @responses.activate def test_integration_removed(self) -> None: integration_service.delete_integration(integration_id=self.discord_integration.id) - results = list(self.rule.after(self.event)) + results = list(self.rule.after(self.group_event)) assert len(results) == 0 @responses.activate diff --git a/tests/sentry/integrations/discord/webhooks/test_message_component.py b/tests/sentry/integrations/discord/webhooks/test_message_component.py index 117ecc7e2dbf..14ef4c0a6165 100644 --- a/tests/sentry/integrations/discord/webhooks/test_message_component.py +++ b/tests/sentry/integrations/discord/webhooks/test_message_component.py @@ -64,7 +64,7 @@ def send_interaction(self, data: Any | None = None, member: Any | None = None) - if member is None: member = {"user": {"id": self.discord_user_id}} - return self.client.post( + response: Response[Any] = self.client.post( path=WEBHOOK_URL, data={ "type": DiscordRequestTypes.MESSAGE_COMPONENT, @@ -77,9 +77,13 @@ def send_interaction(self, data: Any | None = None, member: Any | None = None) - HTTP_X_SIGNATURE_ED25519="signature", HTTP_X_SIGNATURE_TIMESTAMP="timestamp", ) + return response def get_message_content(self, response: Any) -> str: - return response.json()["data"]["content"] + content = response.json()["data"]["content"] + if not isinstance(content, str): + raise TypeError("Discord message content is not a string") + return content def get_message_components(self, response: Any) -> Any: return response.json()["data"]["components"] diff --git a/tests/sentry/integrations/example/test_integration.py b/tests/sentry/integrations/example/test_integration.py index 0fb3461feec7..2bf39e5596e3 100644 --- a/tests/sentry/integrations/example/test_integration.py +++ b/tests/sentry/integrations/example/test_integration.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch import responses from django.urls import reverse @@ -17,7 +17,7 @@ class ExampleIntegrationTest(APITestCase): endpoint = "sentry-api-0-organization-pipeline" - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(self.user) @@ -77,7 +77,7 @@ def test_name_required(self) -> None: @patch( "sentry.api.endpoints.organization_pipeline.initialize_integration_pipeline", ) - def test_initialize_via_api(self, mock_init) -> None: + def test_initialize_via_api(self, mock_init: MagicMock) -> None: pipeline = self._init_pipeline_in_session() pipeline.set_api_mode() mock_init.return_value = pipeline diff --git a/tests/sentry/integrations/gcp/test_integration.py b/tests/sentry/integrations/gcp/test_integration.py index 5a9fc3581233..6828d2e3a24e 100644 --- a/tests/sentry/integrations/gcp/test_integration.py +++ b/tests/sentry/integrations/gcp/test_integration.py @@ -24,6 +24,7 @@ ) from sentry.integrations.models.gcp_service_account import GcpServiceAccount from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.pipeline.types import PipelineStepAction, PipelineStepResult from sentry.shared_integrations.exceptions import IntegrationConfigurationError, IntegrationError from sentry.testutils.cases import TestCase @@ -453,7 +454,13 @@ def test_service_results_survive_installation_and_config_reload(self) -> None: name="Google Cloud Platform", ) - self.provider.post_install(integration, self.organization, extra=built["post_install_data"]) + self.provider.post_install( + integration, + serialize_rpc_organization( + self.organization, include_projects=False, include_teams=False + ), + extra=built["post_install_data"], + ) installation = integration.get_installation(organization_id=self.organization.id) assert isinstance(installation, GcpIntegration) @@ -563,7 +570,9 @@ def test_post_install_stores_config(self) -> None: self.provider.post_install( integration, - self.organization, + serialize_rpc_organization( + self.organization, include_projects=False, include_teams=False + ), extra={ "sentry_sa_email": _SA_EMAIL, "customer_sa_email": _CUSTOMER_SA, @@ -598,7 +607,9 @@ def test_post_install_stores_verification_status(self) -> None: self.provider.post_install( integration, - self.organization, + serialize_rpc_organization( + self.organization, include_projects=False, include_teams=False + ), extra={ "sentry_sa_email": _SA_EMAIL, "customer_sa_email": _CUSTOMER_SA, @@ -643,7 +654,9 @@ def test_post_install_records_error_when_verification_missing(self) -> None: self.provider.post_install( integration, - self.organization, + serialize_rpc_organization( + self.organization, include_projects=False, include_teams=False + ), extra={ "sentry_sa_email": _SA_EMAIL, "customer_sa_email": _CUSTOMER_SA, diff --git a/tests/sentry/integrations/github/tasks/test_link_all_repos.py b/tests/sentry/integrations/github/tasks/test_link_all_repos.py index 36ce4e50d2bd..3505ecce59af 100644 --- a/tests/sentry/integrations/github/tasks/test_link_all_repos.py +++ b/tests/sentry/integrations/github/tasks/test_link_all_repos.py @@ -24,7 +24,7 @@ class LinkAllReposTestCase(IntegrationTestCase): base_url = "https://api.github.com" key = "github" - def _add_responses(self): + def _add_responses(self) -> None: responses.add( responses.GET, self.base_url + "/installation/repositories?per_page=100", diff --git a/tests/sentry/integrations/github/tasks/test_pr_comment.py b/tests/sentry/integrations/github/tasks/test_pr_comment.py index 30b580042188..a33759ed8e91 100644 --- a/tests/sentry/integrations/github/tasks/test_pr_comment.py +++ b/tests/sentry/integrations/github/tasks/test_pr_comment.py @@ -23,6 +23,7 @@ PullRequestCommit, ) from sentry.models.repository import Repository +from sentry.services.eventstore.models import Event from sentry.shared_integrations.exceptions import ApiError from sentry.tasks.commit_context import DEBOUNCE_PR_COMMENT_CACHE_KEY from sentry.testutils.cases import IntegrationTestCase, SnubaTestCase @@ -30,11 +31,17 @@ from sentry.testutils.helpers.integrations import get_installation_of_type from sentry.testutils.silo import assume_test_silo_mode_of from sentry.testutils.skips import requires_snuba +from sentry.users.models.user import User from sentry.utils.cache import cache pytestmark = [requires_snuba] +def event_group_id(event: Event) -> int: + assert event.group is not None + return event.group.id + + class GithubCommentTestCase(IntegrationTestCase): provider = GitHubIntegrationProvider base_url = "https://api.github.com" @@ -91,10 +98,10 @@ def setUp(self) -> None: self.fingerprint = 1 patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1").start() - def add_commit_to_repo(self, repo, user, project): + def add_commit_to_repo(self, repo: Repository, user: User, project: Project) -> Commit: if user not in self.user_to_commit_author_map: self.user_to_commit_author_map[user] = self.create_commit_author( - project=repo.project, user=user + project=project, user=user ) commit = self.create_commit( project=project, @@ -106,7 +113,7 @@ def add_commit_to_repo(self, repo, user, project): self.commit_sha += 1 return commit - def add_pr_to_commit(self, commit: Commit, date_added=None): + def add_pr_to_commit(self, commit: Commit, date_added: datetime | None = None) -> PullRequest: if date_added is None: date_added = before_now(minutes=1) pr = PullRequest.objects.create( @@ -123,11 +130,11 @@ def add_pr_to_commit(self, commit: Commit, date_added=None): self.add_branch_commit_to_pr(commit, pr) return pr - def add_branch_commit_to_pr(self, commit: Commit, pr: PullRequest): + def add_branch_commit_to_pr(self, commit: Commit, pr: PullRequest) -> PullRequestCommit: pr_commit = PullRequestCommit.objects.create(pull_request=pr, commit=commit) return pr_commit - def add_groupowner_to_commit(self, commit: Commit, project, user): + def add_groupowner_to_commit(self, commit: Commit, project: Project, user: User) -> GroupOwner: event = self.store_event( data={ "message": f"issue {self.fingerprint}", @@ -148,7 +155,7 @@ def add_groupowner_to_commit(self, commit: Commit, project, user): ) return groupowner - def create_pr_issues(self, gh_repo=None): + def create_pr_issues(self, gh_repo: Repository | None = None) -> PullRequest: if gh_repo is None: gh_repo = self.gh_repo @@ -212,45 +219,49 @@ def test_multiple_commits(self) -> None: class TestTop5IssuesByCount(GithubCommentTestCase, SnubaTestCase): def test_simple(self) -> None: - group1 = [ + group1_events = [ self.store_event( {"fingerprint": ["group-1"], "timestamp": before_now(days=1).isoformat()}, project_id=self.project.id, ) for _ in range(3) - ][0].group.id - group2 = [ + ] + group1 = event_group_id(group1_events[0]) + group2_events = [ self.store_event( {"fingerprint": ["group-2"], "timestamp": before_now(days=1).isoformat()}, project_id=self.project.id, ) for _ in range(6) - ][0].group.id - group3 = [ + ] + group2 = event_group_id(group2_events[0]) + group3_events = [ self.store_event( {"fingerprint": ["group-3"], "timestamp": before_now(days=1).isoformat()}, project_id=self.project.id, ) for _ in range(4) - ][0].group.id + ] + group3 = event_group_id(group3_events[0]) res = self.pr_comment_workflow.get_top_5_issues_by_count( [group1, group2, group3], self.project ) assert [issue["group_id"] for issue in res] == [group2, group3, group1] def test_over_5_issues(self) -> None: - issue_ids = [ + events = [ self.store_event( {"fingerprint": [f"group-{idx}"], "timestamp": before_now(days=1).isoformat()}, project_id=self.project.id, - ).group.id + ) for idx in range(6) ] + issue_ids = [event_group_id(event) for event in events] res = self.pr_comment_workflow.get_top_5_issues_by_count(issue_ids, self.project) assert len(res) == 5 def test_ignore_info_level_issues(self) -> None: - group1 = [ + group1_events = [ self.store_event( { "fingerprint": ["group-1"], @@ -260,15 +271,17 @@ def test_ignore_info_level_issues(self) -> None: project_id=self.project.id, ) for _ in range(3) - ][0].group.id - group2 = [ + ] + group1 = event_group_id(group1_events[0]) + group2_events = [ self.store_event( {"fingerprint": ["group-2"], "timestamp": before_now(days=1).isoformat()}, project_id=self.project.id, ) for _ in range(6) - ][0].group.id - group3 = [ + ] + group2 = event_group_id(group2_events[0]) + group3_events = [ self.store_event( { "fingerprint": ["group-3"], @@ -278,14 +291,15 @@ def test_ignore_info_level_issues(self) -> None: project_id=self.project.id, ) for _ in range(4) - ][0].group.id + ] + group3 = event_group_id(group3_events[0]) res = self.pr_comment_workflow.get_top_5_issues_by_count( [group1, group2, group3], self.project ) assert [issue["group_id"] for issue in res] == [group2] def test_do_not_ignore_other_issues(self) -> None: - group1 = [ + group1_events = [ self.store_event( { "fingerprint": ["group-1"], @@ -295,8 +309,9 @@ def test_do_not_ignore_other_issues(self) -> None: project_id=self.project.id, ) for _ in range(3) - ][0].group.id - group2 = [ + ] + group1 = event_group_id(group1_events[0]) + group2_events = [ self.store_event( { "fingerprint": ["group-2"], @@ -306,8 +321,9 @@ def test_do_not_ignore_other_issues(self) -> None: project_id=self.project.id, ) for _ in range(6) - ][0].group.id - group3 = [ + ] + group2 = event_group_id(group2_events[0]) + group3_events = [ self.store_event( { "fingerprint": ["group-3"], @@ -317,7 +333,8 @@ def test_do_not_ignore_other_issues(self) -> None: project_id=self.project.id, ) for _ in range(4) - ][0].group.id + ] + group3 = event_group_id(group3_events[0]) res = self.pr_comment_workflow.get_top_5_issues_by_count( [group1, group2, group3], self.project ) @@ -536,8 +553,12 @@ def test_comment_workflow_api_error( @patch("sentry.integrations.source_code_management.tasks.metrics") @patch("sentry.models.Organization.objects") def test_comment_workflow_missing_org( - self, mock_repository, mock_metrics, mock_issues, mock_issue_query - ): + self, + mock_repository: MagicMock, + mock_metrics: MagicMock, + mock_issues: MagicMock, + mock_issue_query: MagicMock, + ) -> None: # Organization.DoesNotExist should trigger the cache to release the key cache.set(self.cache_key, True, timedelta(minutes=5).total_seconds()) @@ -594,8 +615,12 @@ def test_comment_workflow_missing_project( @patch("sentry.integrations.github.integration.GitHubPRCommentWorkflow.get_comment_body") @patch("sentry.integrations.source_code_management.tasks.metrics") def test_comment_workflow_missing_repo( - self, mock_metrics, mock_get_comment_body, mock_repository, mock_issues - ): + self, + mock_metrics: MagicMock, + mock_get_comment_body: MagicMock, + mock_repository: MagicMock, + mock_issues: MagicMock, + ) -> None: # Repository.DoesNotExist should trigger the cache to release the key cache.set(self.cache_key, True, timedelta(minutes=5).total_seconds()) @@ -619,8 +644,8 @@ def test_comment_workflow_missing_repo( @patch("sentry.integrations.github.integration.GitHubPRCommentWorkflow.get_comment_body") @patch("sentry.integrations.source_code_management.tasks.metrics") def test_comment_workflow_missing_integration( - self, mock_metrics, mock_get_comment_body, mock_issues - ): + self, mock_metrics: MagicMock, mock_get_comment_body: MagicMock, mock_issues: MagicMock + ) -> None: # missing integration should trigger the cache to release the key cache.set(self.cache_key, True, timedelta(minutes=5).total_seconds()) diff --git a/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py b/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py index 87f52486e462..31937b1b40f2 100644 --- a/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py +++ b/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py @@ -6,6 +6,7 @@ from sentry.integrations.github.tasks.sync_repos_on_install_change import ( sync_repos_on_install_change, ) +from sentry.integrations.github.webhook_types import GitHubInstallationRepo from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.models.auditlogentry import AuditLogEntry from sentry.models.commit import Commit @@ -22,13 +23,13 @@ class SyncReposOnInstallChangeTestCase(IntegrationTestCase): base_url = "https://api.github.com" key = "github" - def _make_repos_added(self): + def _make_repos_added(self) -> list[GitHubInstallationRepo]: return [ {"id": 1, "full_name": "getsentry/sentry", "private": False}, {"id": 2, "full_name": "getsentry/snuba", "private": False}, ] - def _make_repos_removed(self): + def _make_repos_removed(self) -> list[GitHubInstallationRepo]: return [ {"id": 3, "full_name": "getsentry/old-repo", "private": False}, ] diff --git a/tests/sentry/integrations/github/test_check_payloads.py b/tests/sentry/integrations/github/test_check_payloads.py index 84b82b60d060..ca59699e7bab 100644 --- a/tests/sentry/integrations/github/test_check_payloads.py +++ b/tests/sentry/integrations/github/test_check_payloads.py @@ -1,3 +1,5 @@ +from typing import Any + import pytest from sentry.integrations.github.check_payloads import ( @@ -52,7 +54,7 @@ def test_unplaceable_is_not_ours(self, base_repo_id: object, repo_id: object) -> class TestReferencesOwnRepoPullRequest: - def _event(self, refs: object, repo_id: object = 123) -> dict: + def _event(self, refs: object, repo_id: object = 123) -> dict[str, Any]: return {"repository": {"id": repo_id}, "check_run": {"pull_requests": refs}} def test_true_for_an_own_repo_entry(self) -> None: diff --git a/tests/sentry/integrations/github/test_client.py b/tests/sentry/integrations/github/test_client.py index ee171a518986..61a78e855819 100644 --- a/tests/sentry/integrations/github/test_client.py +++ b/tests/sentry/integrations/github/test_client.py @@ -1,15 +1,16 @@ import re from dataclasses import asdict from datetime import UTC, datetime, timedelta -from typing import Any +from typing import Any, cast from unittest import mock +from unittest.mock import MagicMock import orjson import pytest import responses from django.test import override_settings from django.utils import timezone -from requests import Request +from requests import PreparedRequest, Request from responses import matchers from sentry.constants import ObjectStatus @@ -67,7 +68,7 @@ class GitHubApiClientTest(TestCase): @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") - def setUp(self, get_jwt): + def setUp(self, get_jwt: MagicMock) -> None: ten_days = timezone.now() + timedelta(days=10) self.integration = self.create_integration( organization=self.organization, @@ -231,7 +232,7 @@ def test_core_capacity_is_recorded_against_the_core_resource(self) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_check_file(self, get_jwt) -> None: + def test_check_file(self, get_jwt: MagicMock) -> None: path = "src/sentry/integrations/github/client.py" version = "master" url = f"https://api.github.com/repos/{self.repo.name}/contents/{path}?ref={version}" @@ -248,7 +249,7 @@ def test_check_file(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_check_file_tracks_check_file_endpoint(self, get_jwt) -> None: + def test_check_file_tracks_check_file_endpoint(self, get_jwt: MagicMock) -> None: with mock.patch.object(self.github_client, "head_cached") as mock_head_cached: self.github_client.check_file(self.repo, "README.md", "master") @@ -260,7 +261,7 @@ def test_check_file_tracks_check_file_endpoint(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_check_no_file(self, get_jwt) -> None: + def test_check_no_file(self, get_jwt: MagicMock) -> None: path = "src/santry/integrations/github/client.py" version = "master" url = f"https://api.github.com/repos/{self.repo.name}/contents/{path}?ref={version}" @@ -274,7 +275,7 @@ def test_check_no_file(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @mock.patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @responses.activate - def test_get_stacktrace_link(self, mock_record, get_jwt) -> None: + def test_get_stacktrace_link(self, mock_record: MagicMock, get_jwt: MagicMock) -> None: path = "/src/sentry/integrations/github/client.py" version = "master" url = "https://api.github.com/repos/{}/contents/{}?ref={}".format( @@ -303,7 +304,7 @@ def test_get_stacktrace_link(self, mock_record, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_compare_commits_tracks_compare_endpoint(self, get_jwt) -> None: + def test_compare_commits_tracks_compare_endpoint(self, get_jwt: MagicMock) -> None: with mock.patch.object( self.github_client, "_get_with_pagination" ) as mock_get_with_pagination: @@ -317,8 +318,9 @@ def test_compare_commits_tracks_compare_endpoint(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_last_commits_tracks_get_commits_endpoint(self, get_jwt) -> None: + def test_get_last_commits_tracks_get_commits_endpoint(self, get_jwt: MagicMock) -> None: with mock.patch.object(self.github_client, "get_cached") as mock_get_cached: + mock_get_cached.return_value = [] self.github_client.get_last_commits(self.repo.name, "abc") mock_get_cached.assert_called_once_with( @@ -329,7 +331,7 @@ def test_get_last_commits_tracks_get_commits_endpoint(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_commit_tracks_get_commit_endpoint(self, get_jwt) -> None: + def test_get_commit_tracks_get_commit_endpoint(self, get_jwt: MagicMock) -> None: with mock.patch.object(self.github_client, "get_cached") as mock_get_cached: self.github_client.get_commit(self.repo.name, "abc") @@ -340,32 +342,32 @@ def test_get_commit_tracks_get_commit_endpoint(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_with_pagination(self, get_jwt) -> None: + def test_get_with_pagination(self, get_jwt: MagicMock) -> None: url = f"https://api.github.com/repos/{self.repo.name}/assignees?per_page={self.github_client.page_size}" responses.add( method=responses.GET, url=url, - json={"text": 200}, + json=[], headers={"link": f'<{url}&page=2>; rel="next", <{url}&page=4>; rel="last"'}, ) # For simplicity, we're skipping the `first` and `prev` links from the following responses responses.add( method=responses.GET, url=f"{url}&page=2", - json={"text": 200}, + json=[], headers={"link": f'<{url}&page=3>; rel="next", <{url}&page=4>; rel="last"'}, ) responses.add( method=responses.GET, url=f"{url}&page=3", - json={"text": 200}, + json=[], headers={"link": f'<{url}&page=4>; rel="next", <{url}&page=4>; rel="last"'}, ) responses.add( method=responses.GET, url=f"{url}&page=4", - json={"text": 200}, + json=[], # To help understanding, the last page only contains the `first` and `prev` links # The code only cares about the `next` value which is not included here headers={"link": f'<{url}&page=1>; rel="first", <{url}&page=3>; rel="prev"'}, @@ -376,18 +378,18 @@ def test_get_with_pagination(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_with_pagination_only_one_page(self, get_jwt) -> None: + def test_get_with_pagination_only_one_page(self, get_jwt: MagicMock) -> None: url = f"https://api.github.com/repos/{self.repo.name}/assignees?per_page={self.github_client.page_size}" # No link in the headers because there are no more pages - responses.add(method=responses.GET, url=url, json={}, headers={}) + responses.add(method=responses.GET, url=url, json=[], headers={}) self.github_client._get_with_pagination(f"/repos/{self.repo.name}/assignees") assert len(responses.calls) == 1 assert responses.calls[0].response.status_code == 200 @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_with_pagination_parallel_uses_last_link(self, get_jwt) -> None: + def test_get_with_pagination_parallel_uses_last_link(self, get_jwt: MagicMock) -> None: # When Github advertises the last page, the remaining pages are fetched # by page number (concurrently) rather than by following each page's # `next` link. The later pages omit their `next` link to prove the @@ -412,7 +414,9 @@ def test_get_with_pagination_parallel_uses_last_link(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_with_pagination_parallel_preserves_path_query_params(self, get_jwt) -> None: + def test_get_with_pagination_parallel_preserves_path_query_params( + self, get_jwt: MagicMock + ) -> None: # A query string embedded in ``path`` must ride along on every page the # parallel fan-out fetches, not just the first. ``fetch_page`` reuses # ``path``, so pages 2..N keep the original query params alongside the @@ -447,7 +451,9 @@ def test_get_with_pagination_parallel_preserves_path_query_params(self, get_jwt) @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_with_pagination_falls_back_to_serial_without_last_link(self, get_jwt) -> None: + def test_get_with_pagination_falls_back_to_serial_without_last_link( + self, get_jwt: MagicMock + ) -> None: # Even with parallel requested, cursor-style pagination provides `next` # links but no `rel="last"`, so arbitrary page jumps are unavailable and # we fall back to following the links serially. @@ -475,7 +481,7 @@ def test_get_with_pagination_falls_back_to_serial_without_last_link(self, get_jw @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_with_pagination_raises_when_truncated(self, get_jwt) -> None: + def test_get_with_pagination_raises_when_truncated(self, get_jwt: MagicMock) -> None: # Github advertises five pages but the caller caps at three: only the # first three pages are fetched and the partial result is attached. url = f"https://api.github.com/repos/{self.repo.name}/assignees?per_page={self.github_client.page_size}" @@ -501,7 +507,7 @@ def test_get_with_pagination_raises_when_truncated(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_with_pagination_truncates_without_raising(self, get_jwt) -> None: + def test_get_with_pagination_truncates_without_raising(self, get_jwt: MagicMock) -> None: # Same cap, but without raise_on_page_limit the partial result (the # pages fetched up to the cap) is returned silently. url = f"https://api.github.com/repos/{self.repo.name}/assignees?per_page={self.github_client.page_size}" @@ -525,7 +531,9 @@ def test_get_with_pagination_truncates_without_raising(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_with_pagination_serial_by_default_ignores_last_link(self, get_jwt) -> None: + def test_get_with_pagination_serial_by_default_ignores_last_link( + self, get_jwt: MagicMock + ) -> None: # parallel defaults to False: even though Github advertises rel="last", # the serial walk follows `next` links only. Page 2 omits its `next` # link, so the walk stops there and never fetches the advertised page 3. @@ -548,7 +556,7 @@ def test_get_with_pagination_serial_by_default_ignores_last_link(self, get_jwt) @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_get_with_pagination_parallel_records_success_metric( - self, get_jwt, mock_metrics + self, get_jwt: MagicMock, mock_metrics: MagicMock ) -> None: # A successful parallel fan-out records its width (total pages) and a # success outcome so we can watch the fast path in production. @@ -577,7 +585,7 @@ def test_get_with_pagination_parallel_records_success_metric( @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_get_with_pagination_parallel_records_rate_limited_metric( - self, get_jwt, mock_metrics + self, get_jwt: MagicMock, mock_metrics: MagicMock ) -> None: # A 429 from any page in the fan-out surfaces as ApiRateLimitedError and # is attributed to the parallel path, so we can tell whether the extra @@ -609,7 +617,7 @@ def test_get_with_pagination_parallel_records_rate_limited_metric( @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_get_with_pagination_parallel_403_rate_limit_records_rate_limited_metric( - self, get_jwt, mock_metrics + self, get_jwt: MagicMock, mock_metrics: MagicMock ) -> None: # Github also signals a tripped secondary rate limit with a 403 whose # body names the limit, not only a 429, so that outcome is attributed to @@ -646,7 +654,7 @@ def test_get_with_pagination_parallel_403_rate_limit_records_rate_limited_metric @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_get_with_pagination_parallel_403_permission_does_not_record_rate_limited( - self, get_jwt, mock_metrics + self, get_jwt: MagicMock, mock_metrics: MagicMock ) -> None: # A plain permission 403 (no rate-limit message) is not a rate limit, so # it re-raises without recording either outcome. @@ -684,7 +692,9 @@ def test_get_with_pagination_parallel_403_permission_does_not_record_rate_limite @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @mock.patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @responses.activate - def test_get_codeowner_file(self, mock_record, mock_jwt, mock_check_file) -> None: + def test_get_codeowner_file( + self, mock_record: MagicMock, mock_jwt: MagicMock, mock_check_file: MagicMock + ) -> None: self.config = self.create_code_mapping( repo=self.repo, project=self.project, @@ -710,7 +720,7 @@ def test_get_codeowner_file(self, mock_record, mock_jwt, mock_check_file) -> Non @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_file_codeowners_raises_on_json_error_response(self, mock_jwt) -> None: + def test_get_file_codeowners_raises_on_json_error_response(self, mock_jwt: MagicMock) -> None: responses.add( method=responses.GET, url=f"https://api.github.com/repos/{self.repo.name}/contents/CODEOWNERS", @@ -731,7 +741,7 @@ def test_get_file_codeowners_raises_on_json_error_response(self, mock_jwt) -> No @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_get_codeowner_file_returns_none_on_error_response( - self, mock_jwt, mock_check_file + self, mock_jwt: MagicMock, mock_check_file: MagicMock ) -> None: self.config = self.create_code_mapping( repo=self.repo, @@ -872,7 +882,7 @@ def test_get_cached_repo_files_raises_403_with_not_found_body(self) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_update_comment(self, get_jwt) -> None: + def test_update_comment(self, get_jwt: MagicMock) -> None: responses.add( method=responses.POST, url=f"https://api.github.com/repos/{self.repo.name}/issues/1/comments", @@ -915,7 +925,7 @@ def test_update_comment(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_update_pr_comment(self, get_jwt) -> None: + def test_update_pr_comment(self, get_jwt: MagicMock) -> None: responses.add( method=responses.POST, url=f"https://api.github.com/repos/{self.repo.name}/issues/1/comments", @@ -955,7 +965,7 @@ def test_update_pr_comment(self, get_jwt) -> None: self.github_client.update_pr_comment( repo=self.repo, pr=PullRequest(key="1"), - pr_comment=PullRequestComment(external_id="1"), + pr_comment=PullRequestComment(external_id=1), data={"body": "world"}, ) assert responses.calls[1].response.status_code == 200 @@ -963,7 +973,7 @@ def test_update_pr_comment(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_comment_reactions(self, get_jwt) -> None: + def test_get_comment_reactions(self, get_jwt: MagicMock) -> None: reaction_list = [ {"id": 1, "content": "hooray", "user": {"login": "octocat", "id": 1}}, {"id": 2, "content": "+1", "user": {"login": "hubot", "id": 2}}, @@ -980,7 +990,7 @@ def test_get_comment_reactions(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_comment_reactions_empty(self, get_jwt) -> None: + def test_get_comment_reactions_empty(self, get_jwt: MagicMock) -> None: responses.add( responses.GET, f"https://api.github.com/repos/{self.repo.name}/issues/comments/2/reactions?per_page=100", @@ -993,7 +1003,7 @@ def test_get_comment_reactions_empty(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_create_comment_reaction(self, get_jwt) -> None: + def test_create_comment_reaction(self, get_jwt: MagicMock) -> None: response_data = { "id": 1, "node_id": "MDg6UmVhY3Rpb24x", @@ -1016,7 +1026,7 @@ def test_create_comment_reaction(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_issue_reactions(self, get_jwt) -> None: + def test_get_issue_reactions(self, get_jwt: MagicMock) -> None: responses.add( method=responses.GET, url=f"https://api.github.com/repos/{self.repo.name}/issues/42/reactions?per_page={self.github_client.page_size}", @@ -1031,7 +1041,7 @@ def test_get_issue_reactions(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_create_issue_reaction(self, get_jwt) -> None: + def test_create_issue_reaction(self, get_jwt: MagicMock) -> None: response_data = { "id": 1, "node_id": "MDg6UmVhY3Rpb24x", @@ -1052,7 +1062,7 @@ def test_create_issue_reaction(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_delete_issue_reaction(self, get_jwt) -> None: + def test_delete_issue_reaction(self, get_jwt: MagicMock) -> None: responses.add( responses.DELETE, f"https://api.github.com/repos/{self.repo.name}/issues/42/reactions/123", @@ -1066,7 +1076,7 @@ def test_delete_issue_reaction(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_merge_commit_sha_from_commit(self, get_jwt) -> None: + def test_get_merge_commit_sha_from_commit(self, get_jwt: MagicMock) -> None: merge_commit_sha = "jkl123" pull_requests = [{"merge_commit_sha": merge_commit_sha, "state": "closed"}] commit_sha = "asdf" @@ -1081,7 +1091,7 @@ def test_get_merge_commit_sha_from_commit(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_merge_commit_sha_from_commit_open_pr(self, get_jwt) -> None: + def test_get_merge_commit_sha_from_commit_open_pr(self, get_jwt: MagicMock) -> None: merge_commit_sha = "jkl123" pull_requests = [{"merge_commit_sha": merge_commit_sha, "state": "open"}] commit_sha = "asdf" @@ -1096,7 +1106,7 @@ def test_get_merge_commit_sha_from_commit_open_pr(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_pull_request(self, get_jwt) -> None: + def test_get_pull_request(self, get_jwt: MagicMock) -> None: pull_number = 42 pr_data = { "number": pull_number, @@ -1137,7 +1147,7 @@ def add_graphql_response(self, json: dict[str, Any]) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_search_issue_assignees_missing_repository(self, get_jwt) -> None: + def test_search_issue_assignees_missing_repository(self, get_jwt: MagicMock) -> None: self.add_graphql_response({"data": {"repository": None}}) with pytest.raises(ApiError, match="Invalid GitHub GraphQL response"): @@ -1145,7 +1155,7 @@ def test_search_issue_assignees_missing_repository(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_search_issue_labels_query_error(self, get_jwt) -> None: + def test_search_issue_labels_query_error(self, get_jwt: MagicMock) -> None: self.add_graphql_response( { "errors": [ @@ -1162,7 +1172,7 @@ def test_search_issue_labels_query_error(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_search_issue_field_rate_limited(self, get_jwt) -> None: + def test_search_issue_field_rate_limited(self, get_jwt: MagicMock) -> None: self.add_graphql_response({"errors": [{"type": "RATE_LIMITED"}]}) with pytest.raises(ApiRateLimitedError): @@ -1170,7 +1180,7 @@ def test_search_issue_field_rate_limited(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_pull_request_status(self, get_jwt) -> None: + def test_get_pull_request_status(self, get_jwt: MagicMock) -> None: self.add_graphql_response( { "data": { @@ -1203,7 +1213,7 @@ def test_get_pull_request_status(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_pull_request_statuses_batches_cache_misses(self, get_jwt) -> None: + def test_get_pull_request_statuses_batches_cache_misses(self, get_jwt: MagicMock) -> None: self.add_graphql_response( { "data": { @@ -1253,7 +1263,7 @@ def test_get_pull_request_statuses_batches_cache_misses(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_pull_request_statuses_chunks_cache_misses(self, get_jwt) -> None: + def test_get_pull_request_statuses_chunks_cache_misses(self, get_jwt: MagicMock) -> None: # A batch larger than the chunk size is fetched over several sequential # queries so no single query exceeds GitHub's ~10s server-side timeout. self.add_graphql_response( @@ -1315,7 +1325,7 @@ def test_get_pull_request_statuses_chunks_cache_misses(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_pull_request_status_caches_the_response(self, get_jwt) -> None: + def test_get_pull_request_status_caches_the_response(self, get_jwt: MagicMock) -> None: # One provider request per pull request per window: the endpoint fetches for # every linked pull request, so a repeat view must not re-query GitHub. self.add_graphql_response( @@ -1373,7 +1383,7 @@ def test_pull_request_status_cache_key_versions_the_result_shape(self) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_pull_request_status_query_errors(self, get_jwt) -> None: + def test_get_pull_request_status_query_errors(self, get_jwt: MagicMock) -> None: self.add_graphql_response( {"data": None, "errors": [{"message": "Field 'statusCheckRollup' doesn't exist"}]} ) @@ -1383,7 +1393,7 @@ def test_get_pull_request_status_query_errors(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_pull_request_status_rate_limited(self, get_jwt) -> None: + def test_get_pull_request_status_rate_limited(self, get_jwt: MagicMock) -> None: self.add_graphql_response({"data": None, "errors": [{"type": "RATE_LIMITED"}]}) with pytest.raises(ApiRateLimitedError): @@ -1437,7 +1447,7 @@ def setUp(self) -> None: @responses.activate @mock.patch("sentry.integrations.github.client.get_jwt", return_value=jwt) - def test__refresh_access_token(self, mock_jwt) -> None: + def test__refresh_access_token(self, mock_jwt: MagicMock) -> None: assert self.integration.metadata == { "access_token": None, "expires_at": None, @@ -1448,10 +1458,9 @@ def test__refresh_access_token(self, mock_jwt) -> None: assert mock_jwt.called self.integration.refresh_from_db() - assert ( - self.integration.metadata.pop("last_refresh_at") - == self.integration.debug_data["last_refresh_at"] - ) + debug_data = self.integration.debug_data + assert debug_data is not None + assert self.integration.metadata.pop("last_refresh_at") == debug_data["last_refresh_at"] assert self.integration.metadata == { "access_token": self.access_token, "expires_at": self.expires_at.rstrip("Z"), @@ -1466,7 +1475,7 @@ def test__refresh_access_token(self, mock_jwt) -> None: @responses.activate @mock.patch("sentry.integrations.github.client.get_jwt", return_value=jwt) - def test__get_token(self, mock_jwt) -> None: + def test__get_token(self, mock_jwt: MagicMock) -> None: access_token_request = Request( url=f"{self.gh_client.base_url}/repos/test-repo/issues" ).prepare() @@ -1501,7 +1510,7 @@ def test__get_token(self, mock_jwt) -> None: @responses.activate @mock.patch("sentry.integrations.github.client.get_jwt", return_value=jwt) - def test_get_access_token(self, _) -> None: + def test_get_access_token(self, _: MagicMock) -> None: self.gh_client.integration.metadata["access_token"] = "access_token_1" self.gh_client.integration.metadata["expires_at"] = "3000-01-01T00:00:00Z" self.gh_client.integration.metadata["permissions"] = { @@ -1526,7 +1535,7 @@ def test_get_access_token(self, _) -> None: @responses.activate @mock.patch("sentry.integrations.github.client.GithubProxyClient._get_token", return_value=None) - def test_authorize_request_invalid(self, mock_get_invalid_token) -> None: + def test_authorize_request_invalid(self, mock_get_invalid_token: MagicMock) -> None: request = Request(url=f"{self.gh_client.base_url}/repos/test-repo/issues").prepare() self.gh_client.integration = None @@ -1540,7 +1549,7 @@ def test_authorize_request_invalid(self, mock_get_invalid_token) -> None: @responses.activate @mock.patch("sentry.integrations.github.client.get_jwt", return_value=jwt) - def test_authorize_request_valid(self, mock_jwt) -> None: + def test_authorize_request_valid(self, mock_jwt: MagicMock) -> None: access_token_request = Request( url=f"{self.gh_client.base_url}/repos/test-repo/issues" ).prepare() @@ -1559,7 +1568,7 @@ def test_authorize_request_valid(self, mock_jwt) -> None: # Following requests should just add headers self.gh_client.authorize_request(prepared_request=access_token_request) - assert not mock_jwt.called + assert not bool(mock_jwt.called) assert access_token_request.headers["Accept"] == GITHUB_API_ACCEPT_HEADER assert self.access_token in access_token_request.headers["Authorization"] @@ -1573,12 +1582,12 @@ def test_authorize_request_valid(self, mock_jwt) -> None: @mock.patch( "sentry.integrations.github.client.GithubProxyClient._get_token", return_value=access_token ) - def test_integration_proxy_is_active(self, mock_get_token) -> None: + def test_integration_proxy_is_active(self, mock_get_token: MagicMock) -> None: class GithubProxyTestClient(GitHubApiClient): _use_proxy_url_for_tests = True - def assert_proxy_request(self, request, is_proxy=True): - assert (PROXY_BASE_PATH in request.url) == is_proxy + def assert_proxy_request(self, request: PreparedRequest, is_proxy: bool = True) -> None: + assert (PROXY_BASE_PATH in cast("str", request.url)) == is_proxy assert (PROXY_OI_HEADER in request.headers) == is_proxy assert (PROXY_SIGNATURE_HEADER in request.headers) == is_proxy # The following GitHub headers don't appear in proxied requests @@ -1637,7 +1646,7 @@ def assert_proxy_request(self, request, is_proxy=True): class GitHubCommitContextClientTest(TestCase): @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") - def setUp(self, get_jwt): + def setUp(self, get_jwt: MagicMock) -> None: ten_days = timezone.now() + timedelta(days=10) self.integration = self.create_integration( organization=self.organization, @@ -1660,7 +1669,7 @@ def setUp(self, get_jwt): @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_create_check_run(self, get_jwt) -> None: + def test_create_check_run(self, get_jwt: MagicMock) -> None: repo_name = "getsentry/sentry" check_data = { "name": "sentry/ci", @@ -1695,7 +1704,7 @@ def test_create_check_run(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_check_run(self, get_jwt) -> None: + def test_get_check_run(self, get_jwt: MagicMock) -> None: repo_name = "getsentry/sentry" check_run_id = 123456 @@ -1723,7 +1732,7 @@ def test_get_check_run(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_check_runs(self, get_jwt) -> None: + def test_get_check_runs(self, get_jwt: MagicMock) -> None: repo_name = "getsentry/sentry" sha = "abc123" @@ -1765,7 +1774,7 @@ def test_get_check_runs(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_create_check_run_error(self, get_jwt) -> None: + def test_create_check_run_error(self, get_jwt: MagicMock) -> None: repo_name = "getsentry/sentry" check_data = {"name": "sentry/ci", "head_sha": "abc123"} @@ -1782,7 +1791,7 @@ def test_create_check_run_error(self, get_jwt) -> None: class GitHubClientFileBlameBase(TestCase): @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") - def setUp(self, get_jwt): + def setUp(self, get_jwt: MagicMock) -> None: integration = self.create_integration( organization=self.organization, provider="github", @@ -1847,7 +1856,7 @@ def setUp(self, get_jwt): class GitHubClientFileBlameIntegrationDisableTest(TestCase): @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") - def setUp(self, get_jwt): + def setUp(self, get_jwt: MagicMock) -> None: ten_days = timezone.now() + timedelta(days=10) self.integration = self.create_integration( organization=self.organization, @@ -1890,7 +1899,7 @@ def setUp(self) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_blame_for_files_same_repo(self, get_jwt) -> None: + def test_get_blame_for_files_same_repo(self, get_jwt: MagicMock) -> None: """ When all files are in the same repo, only one repository object should be queried and files blames within the repo should be deduped @@ -1980,7 +1989,7 @@ def test_get_blame_for_files_same_repo(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_blame_for_files_different_repos(self, get_jwt) -> None: + def test_get_blame_for_files_different_repos(self, get_jwt: MagicMock) -> None: """ When files are in different repos, multiple repository objects should be queried. Files within the same repo and branch should be deduped. @@ -2098,7 +2107,7 @@ def test_get_blame_for_files_different_repos(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_blame_for_files_different_refs(self, get_jwt) -> None: + def test_get_blame_for_files_different_refs(self, get_jwt: MagicMock) -> None: """ When files are in the same repo but different branches, query multiple ref objects. Files should still be deduped. @@ -2195,7 +2204,7 @@ def test_get_blame_for_files_different_refs(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_trim_file_path_for_query(self, get_jwt) -> None: + def test_trim_file_path_for_query(self, get_jwt: MagicMock) -> None: """ When file path has hanging forward slashes, trims them for the request. The GitHub GraphQL API will return empty responses otherwise. @@ -2350,7 +2359,7 @@ def setUp(self) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_blame_for_files_full_response(self, get_jwt) -> None: + def test_get_blame_for_files_full_response(self, get_jwt: MagicMock) -> None: """ Tests that the correct commits are selected from the blame range when a full response is returned. """ @@ -2405,7 +2414,7 @@ def test_get_blame_for_files_full_response(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_cached_blame_for_files_full_response(self, get_jwt) -> None: + def test_get_cached_blame_for_files_full_response(self, get_jwt: MagicMock) -> None: """ Tests that the cached commits are returned with full response """ @@ -2488,7 +2497,7 @@ def test_get_cached_blame_for_files_full_response(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_blame_for_files_response_partial_data(self, get_jwt) -> None: + def test_get_blame_for_files_response_partial_data(self, get_jwt: MagicMock) -> None: """ Tests that commits are still returned when some data is missing from the response """ @@ -2576,7 +2585,9 @@ def test_get_blame_for_files_response_partial_data(self, get_jwt) -> None: @mock.patch("sentry.integrations.github.client.logger.warning") @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_get_blame_for_files_invalid_commit(self, get_jwt, mock_logger_warning) -> None: + def test_get_blame_for_files_invalid_commit( + self, get_jwt: MagicMock, mock_logger_warning: MagicMock + ) -> None: """ Tests commits that have invalid data are skipped and logged """ @@ -2720,7 +2731,7 @@ def setUp(self) -> None: @mock.patch("sentry.integrations.github.client.logger.warning") @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_rate_limit_exceeded(self, get_jwt, mock_logger_warning) -> None: + def test_rate_limit_exceeded(self, get_jwt: MagicMock, mock_logger_warning: MagicMock) -> None: with pytest.raises(ApiRateLimitedError): self.github_client.get_blame_for_files([self.file], extra={}) mock_logger_warning.assert_called_with( @@ -2736,7 +2747,7 @@ def test_rate_limit_exceeded(self, get_jwt, mock_logger_warning) -> None: @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate - def test_no_rate_limiting(self, get_jwt) -> None: + def test_no_rate_limiting(self, get_jwt: MagicMock) -> None: """ Tests that no error is thrown when GitHub isn't enforcing rate limits """ diff --git a/tests/sentry/integrations/github/test_identity_external_actor.py b/tests/sentry/integrations/github/test_identity_external_actor.py index c88556629776..64a1ad1e92ab 100644 --- a/tests/sentry/integrations/github/test_identity_external_actor.py +++ b/tests/sentry/integrations/github/test_identity_external_actor.py @@ -1,3 +1,4 @@ +from typing import Any from unittest import mock from sentry.constants import ObjectStatus @@ -11,6 +12,7 @@ ExternalProviders, IntegrationProviderSlug, ) +from sentry.models.organization import Organization from sentry.testutils.cases import TestCase from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import no_silo_test @@ -25,7 +27,7 @@ class EnsureExternalActorsForGithubIdentityTest(TestCase): github_id = "583231" external_name = "@octocat" - def setUp(self): + def setUp(self) -> None: super().setUp() self.user = self.create_user() self.organization = self.create_organization(owner=self.create_user()) @@ -33,7 +35,7 @@ def setUp(self): self.create_member(user=self.user, organization=self.organization) assert not ExternalActor.objects.exists() - def _github_integration(self, organization=None, **kwargs): + def _github_integration(self, organization: Organization | None = None, **kwargs: Any) -> Any: return self.create_integration( organization=organization or self.organization, provider=IntegrationProviderSlug.GITHUB.value, @@ -41,10 +43,10 @@ def _github_integration(self, organization=None, **kwargs): **kwargs, ) - def _external_actors(self, **filters): + def _external_actors(self, **filters: Any) -> Any: return ExternalActor.objects.filter(**filters) - def test_creates_external_actor_for_github_integrated_org(self): + def test_creates_external_actor_for_github_integrated_org(self) -> None: integration = self._github_integration() ensure_external_actors_for_github_identity( @@ -60,7 +62,7 @@ def test_creates_external_actor_for_github_integrated_org(self): assert actor.integration_id == integration.id assert actor.source == ExternalActorSource.IDENTITY.value - def test_null_github_id_yields_null_external_id(self): + def test_null_github_id_yields_null_external_id(self) -> None: self._github_integration() ensure_external_actors_for_github_identity( @@ -71,7 +73,7 @@ def test_null_github_id_yields_null_external_id(self): assert actor.external_name == self.external_name assert actor.external_id is None - def test_idempotent(self): + def test_idempotent(self) -> None: self._github_integration() for _ in range(2): @@ -81,7 +83,7 @@ def test_idempotent(self): assert self._external_actors(user_id=self.user.id).count() == 1 - def test_skips_org_without_github_integration(self): + def test_skips_org_without_github_integration(self) -> None: # Org membership exists but no GitHub integration is installed. ensure_external_actors_for_github_identity( user_id=self.user.id, github_login=self.github_login, github_id=self.github_id @@ -89,7 +91,7 @@ def test_skips_org_without_github_integration(self): assert not self._external_actors(user_id=self.user.id).exists() - def test_skips_org_user_is_not_a_member_of(self): + def test_skips_org_user_is_not_a_member_of(self) -> None: other_org = self.create_organization(owner=self.create_user()) self._github_integration(organization=other_org) @@ -99,7 +101,7 @@ def test_skips_org_user_is_not_a_member_of(self): assert not self._external_actors(organization_id=other_org.id).exists() - def test_skips_disabled_github_integration(self): + def test_skips_disabled_github_integration(self) -> None: self._github_integration(oi_params={"status": ObjectStatus.DISABLED}) ensure_external_actors_for_github_identity( @@ -108,7 +110,7 @@ def test_skips_disabled_github_integration(self): assert not self._external_actors(user_id=self.user.id).exists() - def test_only_maps_github_provider_integrations(self): + def test_only_maps_github_provider_integrations(self) -> None: # A non-GitHub integration in the same org must not produce a mapping. self.create_integration( organization=self.organization, @@ -122,7 +124,7 @@ def test_only_maps_github_provider_integrations(self): assert not self._external_actors(user_id=self.user.id).exists() - def test_fans_out_across_multiple_github_orgs(self): + def test_fans_out_across_multiple_github_orgs(self) -> None: second_org = self.create_organization(owner=self.create_user()) with outbox_runner(): self.create_member(user=self.user, organization=second_org) @@ -135,7 +137,7 @@ def test_fans_out_across_multiple_github_orgs(self): assert self._external_actors(user_id=self.user.id).count() == 2 - def test_missing_login_is_noop(self): + def test_missing_login_is_noop(self) -> None: self._github_integration() ensure_external_actors_for_github_identity( @@ -144,7 +146,7 @@ def test_missing_login_is_noop(self): assert not self._external_actors(user_id=self.user.id).exists() - def test_user_with_no_memberships_is_noop(self): + def test_user_with_no_memberships_is_noop(self) -> None: loner = self.create_user() ensure_external_actors_for_github_identity( @@ -153,7 +155,7 @@ def test_user_with_no_memberships_is_noop(self): assert not self._external_actors(user_id=loner.id).exists() - def test_rpc_failure_is_swallowed_best_effort(self): + def test_rpc_failure_is_swallowed_best_effort(self) -> None: self._github_integration() with mock.patch( @@ -170,7 +172,7 @@ def test_rpc_failure_is_swallowed_best_effort(self): @no_silo_test class GitHubIdentityProviderPostLinkTest(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.user = self.create_user() self.organization = self.create_organization(owner=self.create_user()) @@ -182,7 +184,7 @@ def setUp(self): external_id="github:1", ) - def test_post_link_identity_creates_external_actor(self): + def test_post_link_identity_creates_external_actor(self) -> None: provider = GitHubIdentityProvider() provider.post_link_identity( @@ -196,7 +198,7 @@ def test_post_link_identity_creates_external_actor(self): assert actor.external_id == "583231" assert actor.provider == ExternalProviders.GITHUB.value - def test_post_link_identity_without_github_id_sets_null_external_id(self): + def test_post_link_identity_without_github_id_sets_null_external_id(self) -> None: provider = GitHubIdentityProvider() # A payload without a numeric "id" still yields a mapping keyed on the login. diff --git a/tests/sentry/integrations/github/test_integration.py b/tests/sentry/integrations/github/test_integration.py index 9640a324a743..60fc0c41f4d8 100644 --- a/tests/sentry/integrations/github/test_integration.py +++ b/tests/sentry/integrations/github/test_integration.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable, Iterator from dataclasses import asdict from datetime import datetime, timezone from typing import Any @@ -39,6 +40,7 @@ from sentry.integrations.source_code_management.repo_trees import RepoAndBranch, RepoTree from sentry.integrations.types import ExternalProviders from sentry.models.repository import Repository +from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.shared_integrations.exceptions import ApiError from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase, IntegrationTestCase @@ -116,14 +118,14 @@ def _setup_assignee_sync_test( external_id: str = "octocat", issue_key: str = "Test-Organization/foo#123", create_external_user: bool = True, - ) -> tuple: + ) -> tuple[Any, ...]: """ Common setup for assignee sync tests. Returns: tuple: (user, installation, external_issue, integration, group) """ - user = serialize_rpc_user(self.create_user(email=user_email)) + user = self.create_user(email=user_email) self.assert_setup_flow() integration = Integration.objects.get(provider=self.provider.key) @@ -157,19 +159,19 @@ def _setup_assignee_sync_test( key=issue_key, ) - return user, installation, external_issue, integration, group + return serialize_rpc_user(user), installation, external_issue, integration, group @pytest.fixture(autouse=True) - def stub_get_jwt(self): + def stub_get_jwt(self) -> Iterator[None]: with mock.patch.object(client, "get_jwt", return_value="jwt_token_1"): yield @pytest.fixture(autouse=True) - def stub_get_jwt_function(self): + def stub_get_jwt_function(self) -> Iterator[None]: with mock.patch("sentry.integrations.github.utils.get_jwt", return_value="jwt_token_1"): yield - def _stub_github(self): + def _stub_github(self) -> Any: """This stubs the calls related to a Github App""" self.gh_org = "Test-Organization" pp = 1 @@ -343,7 +345,7 @@ def gen_link(page: int, text: str) -> str: status=values["status_code"], ) - def _setup_with_existing_installations(self): + def _setup_with_existing_installations(self) -> None: self.installation_info = { "installations": [ { @@ -370,7 +372,7 @@ def _setup_with_existing_installations(self): json=self.installation_info, ) - def assert_setup_flow(self): + def assert_setup_flow(self) -> Any: """Create the Integration and OrganizationIntegration directly via ORM, matching what the pipeline would have produced.""" integration = Integration.objects.create( @@ -421,7 +423,9 @@ def test_plugin_migration(self) -> None: with self.tasks(): GitHubIntegrationProvider().post_install( integration=integration, - organization=self.organization, + organization=serialize_rpc_organization( + self.organization, include_projects=False, include_teams=False + ), extra={"app_id": self.app_id}, ) @@ -714,8 +718,12 @@ def test_get_message_from_error(self) -> None: ) def set_rate_limit( - self, remaining=MINIMUM_REQUESTS + 100, limit=5000, json_body=None, status=200 - ): + self, + remaining: int = MINIMUM_REQUESTS + 100, + limit: int = 5000, + json_body: dict[str, Any] | None = None, + status: int = 200, + ) -> None: """Helper class to set the rate limit. A status code different than 200 requires a json_body """ @@ -724,11 +732,11 @@ def set_rate_limit( if status != 200 else { "resources": { - "core": {"limit": limit, "remaining": remaining, "used": "foo", "reset": 123}, + "core": {"limit": limit, "remaining": remaining, "used": 1, "reset": 123}, "graphql": { "limit": limit, "remaining": remaining, - "used": "foo", + "used": 1, "reset": 123, }, } @@ -749,7 +757,7 @@ def get_installation_helper(self) -> GitHubIntegration: ) return installation - def _expected_trees(self, repo_info_list=None): + def _expected_trees(self, repo_info_list: list[Any] | None = None) -> Any: result = {} repo_ids = {"xyz": "1234567", "foo": "1296269", "bar": "9876574", "baz": "1276555"} # bar (409 empty repo) returns an empty RepoTree since we cache the result @@ -766,7 +774,7 @@ def _expected_trees(self, repo_info_list=None): ) return result - def _expected_cached_repos(self): + def _expected_cached_repos(self) -> Any: return [ { "full_name": f"{self.gh_org}/xyz", @@ -794,7 +802,7 @@ def test_get_trees_for_org_works(self) -> None: self.set_rate_limit() expected_trees = self._expected_trees() repos_key = f"githubtrees:repositories:{self.organization.id}" - repo_key = lambda x: f"github:repo:Test-Organization/{x}:source-code" + repo_key: Callable[[str], str] = lambda x: f"github:repo:Test-Organization/{x}:source-code" # Check that the cache is clear assert cache.get(repos_key) is None assert cache.get(repo_key("foo")) is None @@ -1523,6 +1531,7 @@ def test_sync_status_outbound_resolved(self) -> None: """Test syncing resolved status to GitHub (close issue).""" installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubIntegration) external_issue = self.create_integration_external_issue( group=self.group, @@ -1566,6 +1575,7 @@ def test_sync_status_outbound_unresolved(self) -> None: """Test syncing unresolved status to GitHub (reopen issue).""" installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubIntegration) external_issue = self.create_integration_external_issue( group=self.group, @@ -1609,6 +1619,7 @@ def test_sync_status_outbound_unchanged(self) -> None: """Test that no update is made when status is already in desired state.""" installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubIntegration) external_issue = self.create_integration_external_issue( group=self.group, integration=self.integration, @@ -1645,6 +1656,7 @@ def test_sync_status_outbound_no_external_project(self) -> None: """Test that sync_status_outbound returns early if no external project mapping exists.""" installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubIntegration) # Create external issue without project mapping with assume_test_silo_mode(SiloMode.CELL): @@ -1668,6 +1680,7 @@ def test_sync_status_outbound_api_error_on_get(self) -> None: from sentry.shared_integrations.exceptions import IntegrationError installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubIntegration) external_issue = self.create_integration_external_issue( group=self.group, @@ -1703,6 +1716,7 @@ def test_sync_status_outbound_api_error_on_update(self) -> None: from sentry.shared_integrations.exceptions import IntegrationError installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubIntegration) external_issue = self.create_integration_external_issue( group=self.group, @@ -1743,6 +1757,7 @@ def test_create_comment(self) -> None: self.user.name = "Sentry Admin" self.user.save() installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubIntegration) group_note = mock.Mock() comment = "hello world\nThis is a comment.\n\n\n Glad it's quoted" @@ -1756,6 +1771,7 @@ def test_create_comment(self) -> None: def test_update_comment(self) -> None: installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubIntegration) group_note = mock.Mock() comment = "hello world\nThis is a comment.\n\n\n I've changed it" @@ -1867,12 +1883,12 @@ def tearDown(self) -> None: super().tearDown() @pytest.fixture(autouse=True) - def stub_get_jwt(self): + def stub_get_jwt(self) -> Iterator[None]: with mock.patch.object(client, "get_jwt", return_value="jwt_token_1"): yield @pytest.fixture(autouse=True) - def stub_get_jwt_function(self): + def stub_get_jwt_function(self) -> Iterator[None]: with mock.patch("sentry.integrations.github.utils.get_jwt", return_value="jwt_token_1"): yield @@ -1985,7 +2001,9 @@ def _advance_step(self, data: dict[str, Any]) -> Any: def _get_pipeline_signature(self, init_resp: Any) -> str: """Extract the pipeline signature (OAuth state) from an initialize response.""" - return init_resp.data["data"]["oauthUrl"].split("state=")[1].split("&")[0] + oauth_url = init_resp.data["data"]["oauthUrl"] + assert isinstance(oauth_url, str) + return oauth_url.split("state=")[1].split("&")[0] def _stub_user_installations(self, installations: list[dict[str, Any]] | None = None) -> None: """Stub the GitHub /user/installations endpoint.""" diff --git a/tests/sentry/integrations/github/test_issues.py b/tests/sentry/integrations/github/test_issues.py index d855a0cab0fb..935def5dbb88 100644 --- a/tests/sentry/integrations/github/test_issues.py +++ b/tests/sentry/integrations/github/test_issues.py @@ -1,5 +1,5 @@ import datetime -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence from functools import cached_property from typing import Any, cast from unittest import mock @@ -59,7 +59,7 @@ def setUp(self) -> None: self.install = cast(GitHubIntegration, install) @fixture(autouse=True) - def stub_get_jwt(self): + def stub_get_jwt(self) -> Iterator[None]: with patch.object(client, "get_jwt", return_value="jwt_token_1"): yield @@ -132,7 +132,7 @@ def test_get_create_issue_config_prefetches_options_without_group(self) -> None: class GitHubIssueBasicTest(TestCase, PerformanceIssueTestCase, IntegratedApiTestCase): @cached_property - def request(self): + def request(self) -> Any: return RequestFactory() def setUp(self) -> None: @@ -147,7 +147,6 @@ def setUp(self) -> None: install = self.integration.get_installation(self.organization.id) self.install = cast(GitHubIntegration, install) self.min_ago = before_now(minutes=1).isoformat() - self.repo = "getsentry/sentry" def _generate_pagination_responses( self, api_url: str, data: Sequence[Mapping[str, str]], per_page_limit: int @@ -169,7 +168,7 @@ def _generate_pagination_responses( ) @fixture(autouse=True) - def stub_get_jwt(self): + def stub_get_jwt(self) -> Iterator[None]: with patch.object(client, "get_jwt", return_value="jwt_token_1"): yield @@ -760,11 +759,13 @@ def test_linked_issue_comment(self) -> None: assert issue_event.group is not None resp = self.install.get_link_issue_config(group=issue_event.group, **data) # assert comment wording for linked issue is correct + assert isinstance(resp[2]["default"], str) assert "Sentry Issue" in resp[2]["default"] # link a feedback issue resp = self.install.get_link_issue_config(group=feedback_issue, **data) # assert comment wording for linked feedback is correct + assert isinstance(resp[2]["default"], str) assert "Sentry Feedback" in resp[2]["default"] # ensure linked comment is a string @@ -772,7 +773,7 @@ def test_linked_issue_comment(self) -> None: assert isinstance(resp[0]["default"], str) @responses.activate - def after_link_issue(self): + def after_link_issue(self) -> None: responses.add( responses.POST, "https://api.github.com/repos/getsentry/sentry/issues/321/comments", diff --git a/tests/sentry/integrations/github/test_multi_platform_detection.py b/tests/sentry/integrations/github/test_multi_platform_detection.py index db654408b93b..ae0e1ab3b394 100644 --- a/tests/sentry/integrations/github/test_multi_platform_detection.py +++ b/tests/sentry/integrations/github/test_multi_platform_detection.py @@ -459,7 +459,7 @@ def _make_client( client = mock.MagicMock() client.get_languages.return_value = languages - def get_side_effect(path: str, params: dict | None = None) -> Any: + def get_side_effect(path: str, params: dict[str, Any] | None = None) -> Any: if "/git/trees/" in path: return {"tree": tree, "truncated": truncated} raise ApiError("Not Found", code=404) @@ -679,7 +679,7 @@ def test_isolated_content_read_failure_does_not_abort_detection(self) -> None: client = mock.MagicMock() client.get_languages.return_value = {"Python": 80_000} - def get_side_effect(path: str, params: dict | None = None) -> Any: + def get_side_effect(path: str, params: dict[str, Any] | None = None) -> Any: if "/git/trees/" in path: return {"tree": tree, "truncated": False} raise ApiError("Not Found", code=404) diff --git a/tests/sentry/integrations/github/test_post_install.py b/tests/sentry/integrations/github/test_post_install.py index 18b596f8c30e..2df441865e45 100644 --- a/tests/sentry/integrations/github/test_post_install.py +++ b/tests/sentry/integrations/github/test_post_install.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, call, patch from sentry.integrations.github.integration import GitHubIntegrationProvider +from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.testutils.cases import IntegrationTestCase from sentry.testutils.silo import control_silo_test @@ -24,10 +25,10 @@ def setUp(self) -> None: @patch("sentry.integrations.github.tasks.link_all_repos.link_all_repos.apply_async") def test_post_install_migrates_existing_repos( self, - mock_link_repos, - mock_migrate_repo, - mock_get_repositories, - ): + mock_link_repos: MagicMock, + mock_migrate_repo: MagicMock, + mock_get_repositories: MagicMock, + ) -> None: mock_repo1 = MagicMock() mock_repo1.id = 1 mock_repo2 = MagicMock() @@ -36,7 +37,11 @@ def test_post_install_migrates_existing_repos( provider = GitHubIntegrationProvider() provider.post_install( - integration=self.integration, organization=self.organization, extra={"app_id": "app_1"} + integration=self.integration, + organization=serialize_rpc_organization( + self.organization, include_projects=False, include_teams=False + ), + extra={"app_id": "app_1"}, ) assert mock_migrate_repo.call_count == 2 diff --git a/tests/sentry/integrations/github/test_repository.py b/tests/sentry/integrations/github/test_repository.py index 84c454070060..7e32a50cc69f 100644 --- a/tests/sentry/integrations/github/test_repository.py +++ b/tests/sentry/integrations/github/test_repository.py @@ -18,6 +18,7 @@ from sentry.integrations.models.integration import Integration from sentry.models.pullrequest import PullRequest from sentry.models.repository import Repository +from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.shared_integrations.exceptions import IntegrationError from sentry.silo.base import SiloMode from sentry.testutils.asserts import assert_commit_shape @@ -94,7 +95,10 @@ def test_build_repository_config(self) -> None: "external_id": "654321", "integration_id": integration.id, } - repo_config_data = self.provider.build_repository_config(organization, data) + repo_config_data = self.provider.build_repository_config( + serialize_rpc_organization(organization, include_projects=False, include_teams=False), + data, + ) assert repo_config_data == { "config": {"name": "getsentry/example-repo"}, "external_id": "654321", diff --git a/tests/sentry/integrations/github/test_ticket_action.py b/tests/sentry/integrations/github/test_ticket_action.py index 5fed284e4f24..1747f8a8772e 100644 --- a/tests/sentry/integrations/github/test_ticket_action.py +++ b/tests/sentry/integrations/github/test_ticket_action.py @@ -1,3 +1,5 @@ +from collections.abc import Iterator +from typing import Any from unittest.mock import patch import pytest @@ -8,10 +10,12 @@ from sentry.integrations.github.actions.create_ticket import GitHubCreateTicketAction from sentry.integrations.github.integration import GitHubIntegration from sentry.integrations.models.external_issue import ExternalIssue +from sentry.integrations.services.integration.serial import serialize_integration from sentry.issues.action_log import SYSTEM_ACTOR, ActionSource, action_context_scope from sentry.issues.action_log.types import CreateExternalIssueAction from sentry.models.activity import Activity from sentry.models.repository import Repository +from sentry.models.rule import Rule from sentry.services.eventstore.models import GroupEvent from sentry.silo.base import SiloMode from sentry.testutils.cases import RuleTestCase @@ -28,7 +32,7 @@ class GitHubTicketRulesTestCase(RuleTestCase, BaseAPITestCase): rule_cls = GitHubCreateTicketAction - repo = "foo/bar" + repo_name = "foo/bar" assignee = "sentry_user" labels = ["bug", "invalid"] issue_num = 1 @@ -60,11 +64,11 @@ def setUp(self) -> None: ) @pytest.fixture(autouse=True) - def stub_get_jwt(self): + def stub_get_jwt(self) -> Iterator[None]: with patch.object(client, "get_jwt", return_value="jwt_token_1"): yield - def trigger(self, event, rule_object): + def trigger(self, event: GroupEvent, rule_object: Rule) -> Any: action = rule_object.data.get("actions", ())[0] context = create_notification_action_context(rule_object) action_inst = self.get_rule(data=action, context=context) @@ -74,10 +78,10 @@ def trigger(self, event, rule_object): rule_future = RuleFuture(context=context, kwargs=results[0].kwargs) return results[0].callback(event, futures=[rule_future]) - def get_key(self, event: GroupEvent): - return ExternalIssue.objects.get_linked_issues(event, self.integration).values_list( - "key", flat=True - )[0] + def get_key(self, event: GroupEvent) -> Any: + return ExternalIssue.objects.get_linked_issues( + event, serialize_integration(self.integration) + ).values_list("key", flat=True)[0] @responses.activate() def test_ticket_rules(self) -> None: @@ -87,7 +91,7 @@ def test_ticket_rules(self) -> None: with assume_test_silo_mode(SiloMode.CELL): Repository.objects.create( - name=self.repo, + name=self.repo_name, provider="integrations:github", organization_id=self.organization.id, integration_id=self.integration.id, @@ -127,7 +131,7 @@ def test_ticket_rules(self) -> None: "id": "sentry.integrations.github.notify_action.GitHubCreateTicketAction", "integration": self.integration.id, "dynamic_form_fields": [{"random": "garbage"}], - "repo": self.repo, + "repo": self.repo_name, "assignee": self.assignee, "labels": self.labels, } @@ -153,7 +157,7 @@ def test_ticket_rules(self) -> None: # assert ticket created in DB key = self.get_key(event) - assert key == f"{self.repo}#{self.issue_num}" + assert key == f"{self.repo_name}#{self.issue_num}" external_issue = ExternalIssue.objects.get(key=key) external_issue_count = len(ExternalIssue.objects.filter(key=key)) assert external_issue_count == 1 @@ -173,7 +177,7 @@ def test_ticket_rules(self) -> None: # assert ticket created in GitHub data = self.installation.get_issue( - key, data={"repo": self.repo, "externalIssue": self.issue_num} + key, data={"repo": self.repo_name, "externalIssue": self.issue_num} ) assert sample_description in data["description"] diff --git a/tests/sentry/integrations/github/test_utils.py b/tests/sentry/integrations/github/test_utils.py index 720b0d3488a2..977c989b88d0 100644 --- a/tests/sentry/integrations/github/test_utils.py +++ b/tests/sentry/integrations/github/test_utils.py @@ -42,7 +42,9 @@ ), ], ) -def test_parse_github_blob_url(repo_url, source_url, expected_branch, expected_path): +def test_parse_github_blob_url( + repo_url: str, source_url: str, expected_branch: str, expected_path: str +) -> None: branch, path = parse_github_blob_url(repo_url, source_url) assert branch == expected_branch assert path == expected_path diff --git a/tests/sentry/integrations/github/test_webhook.py b/tests/sentry/integrations/github/test_webhook.py index e7a145b9427d..41f04c00833d 100644 --- a/tests/sentry/integrations/github/test_webhook.py +++ b/tests/sentry/integrations/github/test_webhook.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, timezone -from typing import cast +from typing import Any, cast from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -506,7 +506,7 @@ def _post(self) -> int: HTTP_X_HUB_SIGNATURE_256=f"sha256={sig256}", HTTP_X_GITHUB_DELIVERY=str(uuid4()), ) - return response.status_code + return int(response.status_code) def _add_refresh_response(self) -> None: responses.add( @@ -630,7 +630,12 @@ def setUp(self) -> None: self.secret = "b3002c3e321d4b7880360d397db2ccfd" options.set("github-app.webhook-secret", self.secret) - def _make_event(self, action="added", repos_added=None, repos_removed=None): + def _make_event( + self, + action: str = "added", + repos_added: list[dict[str, Any]] | None = None, + repos_removed: list[dict[str, Any]] | None = None, + ) -> Any: return json.dumps( { "action": action, @@ -920,7 +925,7 @@ def setUp(self) -> None: self.secret = "b3002c3e321d4b7880360d397db2ccfd" options.set("github-app.webhook-secret", self.secret) - def _create_integration_and_send_push_event(self): + def _create_integration_and_send_push_event(self) -> None: future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) with assume_test_silo_mode(SiloMode.CONTROL): integration = self.create_integration( @@ -943,7 +948,7 @@ def _create_integration_and_send_push_event(self): assert response.status_code == 204 - def _send_push_event(self, body: str): + def _send_push_event(self, body: str) -> Any: sig1 = GitHubIntegrationsWebhookEndpoint.compute_signature( "sha1", body.encode("utf-8"), self.secret ) @@ -960,7 +965,7 @@ def _send_push_event(self, body: str): HTTP_X_GITHUB_DELIVERY=str(uuid4()), ) - def _setup_github_integration_and_repo(self): + def _setup_github_integration_and_repo(self) -> Any: Repository.objects.create( organization_id=self.organization.id, external_id="35129377", @@ -1222,13 +1227,13 @@ def test_viewer_context_set_during_handler(self, mock_record: MagicMock) -> None name="baxterthehacker/repo", ) - captured_contexts: list = [] + captured_contexts: list[Any] = [] from sentry.integrations.github.webhook import PushEventWebhook original_handle = PushEventWebhook._handle - def capturing_handle(self_handler, **kwargs): + def capturing_handle(self_handler: Any, **kwargs: Any) -> Any: captured_contexts.append(get_viewer_context()) return original_handle(self_handler, **kwargs) @@ -1509,7 +1514,7 @@ def _create_integration_and_repo(self) -> Repository: integration_id=integration.id, ) - def _create_integration_and_send_pull_request_opened_event(self): + def _create_integration_and_send_pull_request_opened_event(self) -> Any: future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) with assume_test_silo_mode(SiloMode.CONTROL): integration = self.create_integration( @@ -2319,7 +2324,7 @@ def test_pr_lifecycle_activities_are_attributed_to_acting_user( ) assert merged_activity.user_id == self.user.id - def assert_group_link(self, group, pr): + def assert_group_link(self, group: Group, pr: PullRequest) -> None: link = GroupLink.objects.get() assert link.group_id == group.id assert link.linked_id == pr.id @@ -2457,7 +2462,7 @@ def _linked_group_for_assignee_sync(self) -> Group: ) return group - def _create_github_member(self, username: str): + def _create_github_member(self, username: str) -> Any: member = self.create_user(email=f"{username}@example.com") self.create_member(organization=self.organization, user=member, teams=[self.team]) self.create_external_user( @@ -2469,8 +2474,10 @@ def _create_github_member(self, username: str): ) return member - def _assigned_event(self, login: str, updated_at: str) -> dict: + def _assigned_event(self, login: str, updated_at: str) -> dict[str, Any]: event = json.loads(ISSUES_ASSIGNED_EVENT_EXAMPLE) + if not isinstance(event, dict): + raise TypeError("Issues assigned event is not a JSON object") event["issue"]["updated_at"] = updated_at event["issue"]["assignees"] = [{"login": login}] return event @@ -2562,7 +2569,7 @@ def _get_signature_sha256(self, body: str) -> str: ) return f"sha256={sig}" - def _post_issues_event(self, event: dict) -> None: + def _post_issues_event(self, event: dict[str, Any]) -> None: body = json.dumps(event) response = self.client.post( path=self.url, diff --git a/tests/sentry/integrations/github_copilot/test_integration.py b/tests/sentry/integrations/github_copilot/test_integration.py index a122f026577c..6a1472551437 100644 --- a/tests/sentry/integrations/github_copilot/test_integration.py +++ b/tests/sentry/integrations/github_copilot/test_integration.py @@ -3,29 +3,29 @@ class GithubCopilotIntegrationProviderTest(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.provider = GithubCopilotIntegrationProvider() - def test_key(self): + def test_key(self) -> None: assert self.provider.key == "github_copilot" - def test_requires_feature_flag_false(self): + def test_requires_feature_flag_false(self) -> None: assert self.provider.requires_feature_flag is False - def test_build_integration(self): + def test_build_integration(self) -> None: data = self.provider.build_integration({}) assert data["name"] == "GitHub Copilot" assert "external_id" in data assert data["metadata"] == {} - def test_build_integration_generates_unique_external_ids(self): + def test_build_integration_generates_unique_external_ids(self) -> None: data1 = self.provider.build_integration({}) data2 = self.provider.build_integration({}) assert data1["external_id"] != data2["external_id"] - def test_direct_enable_aspect(self): + def test_direct_enable_aspect(self) -> None: assert self.provider.metadata.aspects.get("directEnable") is True - def test_allow_multiple_false(self): + def test_allow_multiple_false(self) -> None: assert self.provider.allow_multiple is False diff --git a/tests/sentry/integrations/github_enterprise/test_integration.py b/tests/sentry/integrations/github_enterprise/test_integration.py index 0d793dc989af..6b9fa3714aa1 100644 --- a/tests/sentry/integrations/github_enterprise/test_integration.py +++ b/tests/sentry/integrations/github_enterprise/test_integration.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterator from dataclasses import asdict from datetime import datetime, timezone from typing import Any @@ -18,6 +19,7 @@ _api_base_url, get_user_info, ) +from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.pipeline import IntegrationPipeline @@ -26,6 +28,7 @@ FileBlameInfo, SourceLineInfo, ) +from sentry.models.group import Group from sentry.models.repository import Repository from sentry.shared_integrations.exceptions import IntegrationError from sentry.silo.base import SiloMode @@ -34,6 +37,7 @@ from sentry.testutils.helpers.integrations import get_installation_of_type from sentry.testutils.helpers.options import override_options from sentry.testutils.silo import assume_test_silo_mode, control_silo_test +from sentry.users.services.user.model import RpcUser class ApiBaseUrlTest(TestCase): @@ -78,19 +82,19 @@ class GitHubEnterpriseIntegrationTest(IntegrationTestCase): provider = GitHubEnterpriseIntegrationProvider @pytest.fixture(autouse=True) - def stub_get_jwt(self): + def stub_get_jwt(self) -> Iterator[None]: from sentry.integrations import github with mock.patch.object(github.client, "get_jwt", return_value="jwt_token_1"): yield @pytest.fixture(autouse=True) - def stub_get_jwt_function(self): + def stub_get_jwt_function(self) -> Iterator[None]: with mock.patch("sentry.integrations.github.utils.get_jwt", return_value="jwt_token_1"): yield @pytest.fixture(autouse=True) - def stub_get_jwt_enterprise(self): + def stub_get_jwt_enterprise(self) -> Iterator[None]: with mock.patch( "sentry.integrations.github_enterprise.integration.get_jwt", return_value="jwt_token_1" ): @@ -144,7 +148,7 @@ def _setup_assignee_sync_test( external_id: str = "octocat", issue_key: str = "Test-Organization/foo#123", create_external_user: bool = True, - ) -> tuple: + ) -> tuple[RpcUser, GitHubEnterpriseIntegration, ExternalIssue, Integration, Group]: """ Common setup for assignee sync tests. @@ -154,7 +158,7 @@ def _setup_assignee_sync_test( from sentry.integrations.types import ExternalProviders from sentry.users.services.user.serial import serialize_rpc_user - user = serialize_rpc_user(self.create_user(email=user_email)) + user = self.create_user(email=user_email) self.integration.metadata.update( { @@ -187,7 +191,7 @@ def _setup_assignee_sync_test( key=issue_key, ) - return user, installation, external_issue, self.integration, group + return serialize_rpc_user(user), installation, external_issue, self.integration, group @patch("sentry.integrations.github_enterprise.integration.get_jwt", return_value="jwt_token_1") @patch("sentry.integrations.github_enterprise.client.get_jwt", return_value="jwt_token_1") @@ -757,6 +761,7 @@ def test_sync_status_outbound_resolved(self) -> None: """Test syncing resolved status to GitHub (close issue).""" installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubEnterpriseIntegration) external_issue = self.create_integration_external_issue( group=self.group, @@ -800,6 +805,7 @@ def test_sync_status_outbound_unresolved(self) -> None: """Test syncing unresolved status to GitHub (reopen issue).""" installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubEnterpriseIntegration) external_issue = self.create_integration_external_issue( group=self.group, @@ -843,6 +849,7 @@ def test_sync_status_outbound_unchanged(self) -> None: """Test that no update is made when status is already in desired state.""" installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubEnterpriseIntegration) external_issue = self.create_integration_external_issue( group=self.group, integration=self.integration, @@ -879,6 +886,7 @@ def test_sync_status_outbound_no_external_project(self) -> None: """Test that sync_status_outbound returns early if no external project mapping exists.""" installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubEnterpriseIntegration) # Create external issue without project mapping with assume_test_silo_mode(SiloMode.CELL): @@ -902,6 +910,7 @@ def test_sync_status_outbound_api_error_on_get(self) -> None: from sentry.shared_integrations.exceptions import IntegrationError installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubEnterpriseIntegration) external_issue = self.create_integration_external_issue( group=self.group, @@ -937,6 +946,7 @@ def test_sync_status_outbound_api_error_on_update(self) -> None: from sentry.shared_integrations.exceptions import IntegrationError installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubEnterpriseIntegration) external_issue = self.create_integration_external_issue( group=self.group, @@ -977,6 +987,7 @@ def test_create_comment(self) -> None: self.user.name = "Sentry Admin" self.user.save() installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubEnterpriseIntegration) group_note = mock.Mock() comment = "hello world\nThis is a comment.\n\n\n Glad it's quoted" @@ -990,6 +1001,7 @@ def test_create_comment(self) -> None: def test_update_comment(self) -> None: installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, GitHubEnterpriseIntegration) group_note = mock.Mock() comment = "hello world\nThis is a comment.\n\n\n I've changed it" @@ -1108,7 +1120,10 @@ def _submit_config(self, **overrides: Any) -> Any: return self._advance_step(data) def _get_pipeline_signature(self, resp: Any) -> str: - return resp.data["data"]["oauthUrl"].split("state=")[1].split("&")[0] + oauth_url = resp.data["data"]["oauthUrl"] + if not isinstance(oauth_url, str): + raise TypeError("GitHub Enterprise pipeline response carries no OAuth URL") + return oauth_url.split("state=")[1].split("&")[0] def _stub_ghe_oauth(self) -> None: responses.add( diff --git a/tests/sentry/integrations/github_enterprise/test_issues.py b/tests/sentry/integrations/github_enterprise/test_issues.py index 4510370841e3..026971cd0d5e 100644 --- a/tests/sentry/integrations/github_enterprise/test_issues.py +++ b/tests/sentry/integrations/github_enterprise/test_issues.py @@ -19,7 +19,7 @@ class GitHubEnterpriseIssueBasicTest(TestCase, IntegratedApiTestCase): @cached_property - def request(self): + def request(self) -> RequestFactory: return RequestFactory() _IP_ADDRESS = "35.232.149.196" @@ -211,7 +211,7 @@ def test_link_issue(self, mock_get_jwt: MagicMock) -> None: @responses.activate @patch("sentry.integrations.github_enterprise.client.get_jwt", return_value="jwt_token_1") - def after_link_issue(self, mock_get_jwt): + def after_link_issue(self, mock_get_jwt: MagicMock) -> None: responses.add( responses.POST, f"https://{self._IP_ADDRESS}/api/v3/installations/installation_id/access_tokens", diff --git a/tests/sentry/integrations/github_enterprise/test_repository.py b/tests/sentry/integrations/github_enterprise/test_repository.py index 347c24e16807..4484581e20a0 100644 --- a/tests/sentry/integrations/github_enterprise/test_repository.py +++ b/tests/sentry/integrations/github_enterprise/test_repository.py @@ -3,6 +3,7 @@ import responses from sentry.integrations.github_enterprise.repository import GitHubEnterpriseRepositoryProvider +from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.silo.base import SiloMode from sentry.testutils.cases import TestCase from sentry.testutils.silo import assume_test_silo_mode @@ -38,7 +39,9 @@ def test_build_repository_config(self) -> None: "external_id": "654321", "integration_id": self.integration.id, } - rc_data = self.provider.build_repository_config(organization, data) + rc_data = self.provider.build_repository_config( + serialize_rpc_organization(organization), data + ) assert rc_data == { "config": {"name": "getsentry/example-repo"}, "external_id": "654321", diff --git a/tests/sentry/integrations/github_enterprise/test_ticket_action.py b/tests/sentry/integrations/github_enterprise/test_ticket_action.py index 816688d39c33..9ac3b97281dc 100644 --- a/tests/sentry/integrations/github_enterprise/test_ticket_action.py +++ b/tests/sentry/integrations/github_enterprise/test_ticket_action.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from unittest.mock import patch import pytest @@ -10,8 +11,10 @@ ) from sentry.integrations.github_enterprise.integration import GitHubEnterpriseIntegration from sentry.integrations.models.external_issue import ExternalIssue +from sentry.integrations.services.integration.serial import serialize_integration from sentry.models.activity import Activity from sentry.models.repository import Repository +from sentry.models.rule import Rule from sentry.rules import rules from sentry.services.eventstore.models import GroupEvent from sentry.silo.base import SiloMode @@ -34,7 +37,7 @@ class GitHubEnterpriseEnterpriseTicketRulesTestCase(RuleTestCase, BaseAPITestCase): rule_cls = GitHubEnterpriseCreateTicketAction - repo = "foo/bar" + repo_name = "foo/bar" assignee = "sentry_user" labels = ["bug", "invalid"] issue_num = 1 @@ -71,11 +74,11 @@ def setUp(self) -> None: ) @pytest.fixture(autouse=True) - def stub_get_jwt(self): + def stub_get_jwt(self) -> Iterator[None]: with patch.object(client, "get_jwt", return_value="jwt_token_1"): yield - def trigger(self, event, rule_object): + def trigger(self, event: GroupEvent, rule_object: Rule) -> object: action = rule_object.data.get("actions", ())[0] context = create_notification_action_context(rule_object) action_inst = self.get_rule(data=action, context=context) @@ -85,10 +88,10 @@ def trigger(self, event, rule_object): rule_future = RuleFuture(context=context, kwargs=results[0].kwargs) return results[0].callback(event, futures=[rule_future]) - def get_key(self, event: GroupEvent): - return ExternalIssue.objects.get_linked_issues(event, self.integration).values_list( - "key", flat=True - )[0] + def get_key(self, event: GroupEvent) -> str: + return ExternalIssue.objects.get_linked_issues( + event, serialize_integration(self.integration) + ).values_list("key", flat=True)[0] @responses.activate() def test_ticket_rules(self) -> None: @@ -98,7 +101,7 @@ def test_ticket_rules(self) -> None: with assume_test_silo_mode(SiloMode.CELL): Repository.objects.create( - name=self.repo, + name=self.repo_name, provider="integrations:github_enterprise", organization_id=self.organization.id, integration_id=self.integration.id, @@ -138,7 +141,7 @@ def test_ticket_rules(self) -> None: "id": "sentry.integrations.github_enterprise.notify_action.GitHubEnterpriseCreateTicketAction", "integration": self.integration.id, "dynamic_form_fields": [{"random": "garbage"}], - "repo": self.repo, + "repo": self.repo_name, "assignee": self.assignee, "labels": self.labels, } @@ -171,7 +174,7 @@ def test_ticket_rules(self) -> None: # assert ticket created in GitHub Enterprise data = self.installation.get_issue( - key, data={"repo": self.repo, "externalIssue": self.issue_num} + key, data={"repo": self.repo_name, "externalIssue": self.issue_num} ) assert sample_description in data["description"] diff --git a/tests/sentry/integrations/github_enterprise/test_webhooks.py b/tests/sentry/integrations/github_enterprise/test_webhooks.py index 3ce278aface2..543906d61075 100644 --- a/tests/sentry/integrations/github_enterprise/test_webhooks.py +++ b/tests/sentry/integrations/github_enterprise/test_webhooks.py @@ -436,8 +436,12 @@ def test_simple( @patch("sentry.integrations.github.webhook.PushEventWebhook.__call__") @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def test_webhook_error_metric( - self, mock_record, mock_event, mock_get_installation_metadata, mock_get_jwt - ): + self, + mock_record: MagicMock, + mock_event: MagicMock, + mock_get_installation_metadata: MagicMock, + mock_get_jwt: MagicMock, + ) -> None: responses.add( responses.POST, "https://35.232.149.196/extensions/github-enterprise/webhook/", diff --git a/tests/sentry/integrations/gitlab/tasks/test_pr_comment.py b/tests/sentry/integrations/gitlab/tasks/test_pr_comment.py index 7d986b3cb160..40c6a1fac9e7 100644 --- a/tests/sentry/integrations/gitlab/tasks/test_pr_comment.py +++ b/tests/sentry/integrations/gitlab/tasks/test_pr_comment.py @@ -13,22 +13,31 @@ from sentry.models.commit import Commit from sentry.models.group import Group from sentry.models.groupowner import GroupOwner, GroupOwnerType +from sentry.models.project import Project from sentry.models.pullrequest import ( CommentType, PullRequest, PullRequestComment, PullRequestCommit, ) +from sentry.models.repository import Repository +from sentry.services.eventstore.models import Event from sentry.shared_integrations.exceptions import ApiError from sentry.tasks.commit_context import DEBOUNCE_PR_COMMENT_CACHE_KEY from sentry.testutils.cases import SnubaTestCase from sentry.testutils.helpers.datetime import before_now, freeze_time from sentry.testutils.helpers.integrations import get_installation_of_type from sentry.testutils.silo import assume_test_silo_mode_of +from sentry.users.models.user import User from sentry.utils import json from sentry.utils.cache import cache +def event_group_id(event: Event) -> int: + assert event.group is not None + return event.group.id + + class GitlabCommentTestCase(GitLabTestCase): def setUp(self) -> None: super().setUp() @@ -61,10 +70,10 @@ def setUp(self) -> None: self.commit_sha = 1 self.fingerprint = 1 - def add_commit_to_repo(self, repo, user, project): + def add_commit_to_repo(self, repo: Repository, user: User, project: Project) -> Commit: if user not in self.user_to_commit_author_map: self.user_to_commit_author_map[user] = self.create_commit_author( - project=repo.project, user=user + project=project, user=user ) commit = self.create_commit( project=project, @@ -76,7 +85,7 @@ def add_commit_to_repo(self, repo, user, project): self.commit_sha += 1 return commit - def add_pr_to_commit(self, commit: Commit, date_added=None): + def add_pr_to_commit(self, commit: Commit, date_added: datetime | None = None) -> PullRequest: if date_added is None: date_added = before_now(minutes=1) pr = PullRequest.objects.create( @@ -93,11 +102,11 @@ def add_pr_to_commit(self, commit: Commit, date_added=None): self.add_branch_commit_to_pr(commit, pr) return pr - def add_branch_commit_to_pr(self, commit: Commit, pr: PullRequest): + def add_branch_commit_to_pr(self, commit: Commit, pr: PullRequest) -> PullRequestCommit: pr_commit = PullRequestCommit.objects.create(pull_request=pr, commit=commit) return pr_commit - def add_groupowner_to_commit(self, commit: Commit, project, user): + def add_groupowner_to_commit(self, commit: Commit, project: Project, user: User) -> GroupOwner: event = self.store_event( data={ "message": f"issue {self.fingerprint}", @@ -118,7 +127,7 @@ def add_groupowner_to_commit(self, commit: Commit, project, user): ) return groupowner - def create_pr_issues(self, repo=None): + def create_pr_issues(self, repo: Repository | None = None) -> PullRequest: if repo is None: repo = self.repo @@ -182,45 +191,49 @@ def test_multiple_commits(self) -> None: class TestTop5IssuesByCount(SnubaTestCase, GitlabCommentTestCase): def test_simple(self) -> None: - group1 = [ + group1_events = [ self.store_event( {"fingerprint": ["group-1"], "timestamp": before_now(days=1).isoformat()}, project_id=self.project.id, ) for _ in range(3) - ][0].group.id - group2 = [ + ] + group1 = event_group_id(group1_events[0]) + group2_events = [ self.store_event( {"fingerprint": ["group-2"], "timestamp": before_now(days=1).isoformat()}, project_id=self.project.id, ) for _ in range(6) - ][0].group.id - group3 = [ + ] + group2 = event_group_id(group2_events[0]) + group3_events = [ self.store_event( {"fingerprint": ["group-3"], "timestamp": before_now(days=1).isoformat()}, project_id=self.project.id, ) for _ in range(4) - ][0].group.id + ] + group3 = event_group_id(group3_events[0]) res = self.pr_comment_workflow.get_top_5_issues_by_count( [group1, group2, group3], self.project ) assert [issue["group_id"] for issue in res] == [group2, group3, group1] def test_over_5_issues(self) -> None: - issue_ids = [ + events = [ self.store_event( {"fingerprint": [f"group-{idx}"], "timestamp": before_now(days=1).isoformat()}, project_id=self.project.id, - ).group.id + ) for idx in range(6) ] + issue_ids = [event_group_id(event) for event in events] res = self.pr_comment_workflow.get_top_5_issues_by_count(issue_ids, self.project) assert len(res) == 5 def test_ignore_info_level_issues(self) -> None: - group1 = [ + group1_events = [ self.store_event( { "fingerprint": ["group-1"], @@ -230,15 +243,17 @@ def test_ignore_info_level_issues(self) -> None: project_id=self.project.id, ) for _ in range(3) - ][0].group.id - group2 = [ + ] + group1 = event_group_id(group1_events[0]) + group2_events = [ self.store_event( {"fingerprint": ["group-2"], "timestamp": before_now(days=1).isoformat()}, project_id=self.project.id, ) for _ in range(6) - ][0].group.id - group3 = [ + ] + group2 = event_group_id(group2_events[0]) + group3_events = [ self.store_event( { "fingerprint": ["group-3"], @@ -248,14 +263,15 @@ def test_ignore_info_level_issues(self) -> None: project_id=self.project.id, ) for _ in range(4) - ][0].group.id + ] + group3 = event_group_id(group3_events[0]) res = self.pr_comment_workflow.get_top_5_issues_by_count( [group1, group2, group3], self.project ) assert [issue["group_id"] for issue in res] == [group2] def test_do_not_ignore_other_issues(self) -> None: - group1 = [ + group1_events = [ self.store_event( { "fingerprint": ["group-1"], @@ -265,8 +281,9 @@ def test_do_not_ignore_other_issues(self) -> None: project_id=self.project.id, ) for _ in range(3) - ][0].group.id - group2 = [ + ] + group1 = event_group_id(group1_events[0]) + group2_events = [ self.store_event( { "fingerprint": ["group-2"], @@ -276,8 +293,9 @@ def test_do_not_ignore_other_issues(self) -> None: project_id=self.project.id, ) for _ in range(6) - ][0].group.id - group3 = [ + ] + group2 = event_group_id(group2_events[0]) + group3_events = [ self.store_event( { "fingerprint": ["group-3"], @@ -287,7 +305,8 @@ def test_do_not_ignore_other_issues(self) -> None: project_id=self.project.id, ) for _ in range(4) - ][0].group.id + ] + group3 = event_group_id(group3_events[0]) res = self.pr_comment_workflow.get_top_5_issues_by_count( [group1, group2, group3], self.project ) diff --git a/tests/sentry/integrations/gitlab/tasks/test_update_all_project_webhooks.py b/tests/sentry/integrations/gitlab/tasks/test_update_all_project_webhooks.py index 21bccff3bb5c..063214a61488 100644 --- a/tests/sentry/integrations/gitlab/tasks/test_update_all_project_webhooks.py +++ b/tests/sentry/integrations/gitlab/tasks/test_update_all_project_webhooks.py @@ -1,10 +1,12 @@ -from unittest.mock import call, patch +from collections.abc import Callable +from unittest.mock import MagicMock, call, patch import pytest import responses from redis.exceptions import ConnectionError as RedisConnectionError from redis.exceptions import RedisError from redis.exceptions import TimeoutError as RedisTimeoutError +from requests import PreparedRequest from fixtures.gitlab import GitLabTestCase from sentry.constants import ObjectStatus @@ -25,40 +27,49 @@ class UpdateAllProjectWebhooksTest(GitLabTestCase): """Tests for the main orchestration task that spawns individual webhook update tasks""" @patch("sentry.integrations.gitlab.tasks.update_project_webhook.delay") - def test_debounce_has_ttl_and_force_bypasses_it(self, mock_delay): - kwargs = {"integration_id": self.integration.id, "organization_id": self.organization.id} - org_integration = integration_service.get_organization_integration(**kwargs) + def test_debounce_has_ttl_and_force_bypasses_it(self, mock_delay: MagicMock) -> None: + integration_id = self.integration.id + organization_id = self.organization.id + org_integration = integration_service.get_organization_integration( + integration_id=integration_id, organization_id=organization_id + ) assert org_integration is not None config = org_integration.config.copy() - update_all_project_webhooks(**kwargs) + update_all_project_webhooks(integration_id=integration_id, organization_id=organization_id) assert mock_delay.call_count == 3 - update_all_project_webhooks(**kwargs) + update_all_project_webhooks(integration_id=integration_id, organization_id=organization_id) assert mock_delay.call_count == 3 - update_all_project_webhooks(**kwargs, force=True) + update_all_project_webhooks( + integration_id=integration_id, organization_id=organization_id, force=True + ) assert mock_delay.call_count == 6 - update_all_project_webhooks(**kwargs) + update_all_project_webhooks(integration_id=integration_id, organization_id=organization_id) assert mock_delay.call_count == 6 key = f"gitlab:webhook-reconcile:{self.organization.id}:{self.integration.id}" redis = redis_clusters.get("default") assert 0 < redis.ttl(key) <= 600 redis.delete(key) - update_all_project_webhooks(**kwargs) + update_all_project_webhooks(integration_id=integration_id, organization_id=organization_id) assert mock_delay.call_count == 9 - refreshed = integration_service.get_organization_integration(**kwargs) + refreshed = integration_service.get_organization_integration( + integration_id=integration_id, organization_id=organization_id + ) assert refreshed is not None assert refreshed.config == config - def test_redis_connection_error_does_not_block_reconciliation(self): + def test_redis_connection_error_does_not_block_reconciliation(self) -> None: self.assert_reconciliation_without_debounce(RedisConnectionError("Redis unavailable")) - def test_redis_timeout_does_not_block_forced_reconciliation(self): + def test_redis_timeout_does_not_block_forced_reconciliation(self) -> None: self.assert_reconciliation_without_debounce( RedisTimeoutError("Redis timed out"), force=True ) - def assert_reconciliation_without_debounce(self, error: RedisError, force: bool = False): + def assert_reconciliation_without_debounce( + self, error: RedisError, force: bool = False + ) -> None: with ( patch("sentry.integrations.gitlab.tasks.redis_clusters") as clusters, patch("sentry.integrations.gitlab.tasks.logger.exception") as log_exception, @@ -78,7 +89,7 @@ def assert_reconciliation_without_debounce(self, error: RedisError, force: bool extra={"integration_id": self.integration.id, "organization_id": self.organization.id}, ) - def test_fanout_failure_still_raises_when_debounce_is_unavailable(self): + def test_fanout_failure_still_raises_when_debounce_is_unavailable(self) -> None: with ( patch("sentry.integrations.gitlab.tasks.redis_clusters") as clusters, patch("sentry.integrations.gitlab.tasks.logger.exception"), @@ -92,7 +103,7 @@ def test_fanout_failure_still_raises_when_debounce_is_unavailable(self): update_all_project_webhooks(self.integration.id, self.organization.id) @patch("sentry.integrations.gitlab.tasks.update_project_webhook.delay") - def test_failed_fanout_can_retry_immediately(self, mock_delay): + def test_failed_fanout_can_retry_immediately(self, mock_delay: MagicMock) -> None: mock_delay.side_effect = RuntimeError("Task broker unavailable") with pytest.raises(RuntimeError, match="Task broker unavailable"): update_all_project_webhooks(self.integration.id, self.organization.id) @@ -102,7 +113,7 @@ def test_failed_fanout_can_retry_immediately(self, mock_delay): assert mock_delay.call_count == 3 @patch("sentry.integrations.gitlab.tasks.update_project_webhook.delay") - def test_debounced_run_does_not_load_repositories(self, mock_delay): + def test_debounced_run_does_not_load_repositories(self, mock_delay: MagicMock) -> None: update_all_project_webhooks(self.integration.id, self.organization.id) mock_delay.reset_mock() with patch( @@ -113,7 +124,7 @@ def test_debounced_run_does_not_load_repositories(self, mock_delay): mock_delay.assert_not_called() @patch("sentry.integrations.gitlab.tasks.update_project_webhook.delay") - def test_repository_lookup_failure_can_retry_immediately(self, mock_delay): + def test_repository_lookup_failure_can_retry_immediately(self, mock_delay: MagicMock) -> None: with patch( "sentry.integrations.gitlab.tasks.repository_service.get_repositories", side_effect=RuntimeError("Repository service unavailable"), @@ -123,11 +134,11 @@ def test_repository_lookup_failure_can_retry_immediately(self, mock_delay): update_all_project_webhooks(self.integration.id, self.organization.id) assert mock_delay.call_count == 3 - def test_failed_run_does_not_clear_a_newer_debounce(self): + def test_failed_run_does_not_clear_a_newer_debounce(self) -> None: key = f"gitlab:webhook-reconcile:{self.organization.id}:{self.integration.id}" redis = redis_clusters.get("default") - def fail_after_debounce_was_replaced(*args): + def fail_after_debounce_was_replaced(*args: object) -> None: # Simulate a newer run claiming the key after this run's TTL elapsed. redis.set(key, "newer-run", ex=600) raise RuntimeError("Task broker unavailable") @@ -141,7 +152,7 @@ def fail_after_debounce_was_replaced(*args): assert redis.get(key) == "newer-run" @patch("sentry.integrations.gitlab.tasks.update_project_webhook.delay") - def test_forced_failure_preserves_existing_debounce(self, mock_delay): + def test_forced_failure_preserves_existing_debounce(self, mock_delay: MagicMock) -> None: update_all_project_webhooks(self.integration.id, self.organization.id) key = f"gitlab:webhook-reconcile:{self.organization.id}:{self.integration.id}" redis = redis_clusters.get("default") @@ -151,7 +162,7 @@ def test_forced_failure_preserves_existing_debounce(self, mock_delay): update_all_project_webhooks(self.integration.id, self.organization.id, force=True) assert redis.get(key) == token - def test_debounce_cleanup_failure_preserves_original_error(self): + def test_debounce_cleanup_failure_preserves_original_error(self) -> None: with ( patch( "sentry.integrations.gitlab.tasks.update_project_webhook.delay", @@ -171,7 +182,9 @@ def test_debounce_cleanup_failure_preserves_original_error(self): ) @patch("sentry.integrations.gitlab.tasks.update_project_webhook.delay") - def test_debounce_is_scoped_to_organization_and_integration(self, mock_delay): + def test_debounce_is_scoped_to_organization_and_integration( + self, mock_delay: MagicMock + ) -> None: other_org = self.create_organization() self.create_organization_integration( organization_id=other_org.id, integration=self.integration @@ -234,7 +247,9 @@ def setUp(self) -> None: @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.integrations.gitlab.tasks.update_project_webhook.delay") - def test_task_spawns_individual_tasks(self, mock_delay, mock_record_event): + def test_task_spawns_individual_tasks( + self, mock_delay: MagicMock, mock_record_event: MagicMock + ) -> None: """Test that the task spawns individual tasks for each repository""" update_all_project_webhooks( integration_id=self.integration.id, @@ -258,7 +273,9 @@ def test_task_spawns_individual_tasks(self, mock_delay, mock_record_event): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.integrations.gitlab.tasks.update_project_webhook.delay") - def test_task_handles_integration_not_found(self, mock_delay, mock_record_event): + def test_task_handles_integration_not_found( + self, mock_delay: MagicMock, mock_record_event: MagicMock + ) -> None: """Test that the task handles missing integration gracefully""" # Should not raise exception and no tasks should be spawned update_all_project_webhooks( @@ -271,7 +288,9 @@ def test_task_handles_integration_not_found(self, mock_delay, mock_record_event) assert mock_record_event.call_count == 0 @patch("sentry.integrations.gitlab.tasks.update_project_webhook.delay") - def test_task_only_updates_active_repositories_for_installing_organization(self, mock_delay): + def test_task_only_updates_active_repositories_for_installing_organization( + self, mock_delay: MagicMock + ) -> None: other_org = self.create_organization() other_integration = self.create_provider_integration(provider="gitlab", external_id="other") with assume_test_silo_mode(SiloMode.CELL): @@ -291,7 +310,9 @@ def test_task_only_updates_active_repositories_for_installing_organization(self, @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.integrations.gitlab.tasks.update_project_webhook.delay") - def test_task_handles_no_repositories(self, mock_delay, mock_record_event): + def test_task_handles_no_repositories( + self, mock_delay: MagicMock, mock_record_event: MagicMock + ) -> None: """Test that the task handles case with no repositories and records halt metric""" # Delete all repositories with assume_test_silo_mode(SiloMode.CELL): @@ -315,7 +336,9 @@ def test_task_handles_no_repositories(self, mock_delay, mock_record_event): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.integrations.gitlab.tasks.update_project_webhook.delay") - def test_task_handles_org_integration_not_found(self, mock_delay, mock_record_event): + def test_task_handles_org_integration_not_found( + self, mock_delay: MagicMock, mock_record_event: MagicMock + ) -> None: """Test that the task handles missing org integration""" with assume_test_silo_mode(SiloMode.CONTROL): # Delete org integration @@ -341,7 +364,9 @@ def test_task_handles_org_integration_not_found(self, mock_delay, mock_record_ev @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.integrations.gitlab.tasks.update_project_webhook.delay") - def test_task_skips_repositories_missing_webhook_config(self, mock_delay, mock_record_event): + def test_task_skips_repositories_missing_webhook_config( + self, mock_delay: MagicMock, mock_record_event: MagicMock + ) -> None: """Test that the task spawns tasks for all repos - filtering happens in individual tasks""" with assume_test_silo_mode(SiloMode.CELL): # Repository missing webhook_id @@ -383,7 +408,9 @@ def test_task_skips_repositories_missing_webhook_config(self, mock_delay, mock_r @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.integrations.gitlab.tasks.update_project_webhook.delay") - def test_task_only_processes_active_repositories(self, mock_delay, mock_record_event): + def test_task_only_processes_active_repositories( + self, mock_delay: MagicMock, mock_record_event: MagicMock + ) -> None: """Test that only active repositories are processed""" with assume_test_silo_mode(SiloMode.CELL): # Mark one repository as inactive @@ -426,7 +453,7 @@ def setUp(self) -> None: @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @responses.activate - def test_task_updates_webhook(self, mock_record_event): + def test_task_updates_webhook(self, mock_record_event: MagicMock) -> None: """Test that the task successfully updates a webhook""" responses.add( responses.PUT, @@ -451,7 +478,7 @@ def test_task_updates_webhook(self, mock_record_event): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @responses.activate - def test_task_handles_integration_not_found(self, mock_record_event): + def test_task_handles_integration_not_found(self, mock_record_event: MagicMock) -> None: """Test that the task handles missing integration gracefully""" update_project_webhook( integration_id=99999, @@ -467,7 +494,7 @@ def test_task_handles_integration_not_found(self, mock_record_event): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @responses.activate - def test_task_handles_repository_not_found(self, mock_record_event): + def test_task_handles_repository_not_found(self, mock_record_event: MagicMock) -> None: """Test that the task handles missing repository gracefully""" update_project_webhook( integration_id=self.integration.id, @@ -488,7 +515,7 @@ def test_task_handles_repository_not_found(self, mock_record_event): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @responses.activate - def test_task_handles_inactive_repository(self, mock_record_event): + def test_task_handles_inactive_repository(self, mock_record_event: MagicMock) -> None: """Test that the task handles inactive repositories""" with assume_test_silo_mode(SiloMode.CELL): self.repo.status = ObjectStatus.DISABLED @@ -513,7 +540,7 @@ def test_task_handles_inactive_repository(self, mock_record_event): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @responses.activate - def test_task_handles_missing_project_id(self, mock_record_event): + def test_task_handles_missing_project_id(self, mock_record_event: MagicMock) -> None: """Test that the task handles repositories without webhook configuration""" with assume_test_silo_mode(SiloMode.CELL): self.repo.config = {"webhook_id": "webhook-1"} @@ -538,7 +565,7 @@ def test_task_handles_missing_project_id(self, mock_record_event): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @responses.activate - def test_task_retries_on_failure(self, mock_record_event): + def test_task_retries_on_failure(self, mock_record_event: MagicMock) -> None: """Test that the task retries on API failures""" responses.add( responses.PUT, @@ -563,7 +590,7 @@ def test_task_retries_on_failure(self, mock_record_event): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @responses.activate - def test_task_handles_auth_errors(self, mock_record_event): + def test_task_handles_auth_errors(self, mock_record_event: MagicMock) -> None: """Test that the task retries on API failures""" responses.add( responses.PUT, @@ -587,7 +614,7 @@ def test_task_handles_auth_errors(self, mock_record_event): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @responses.activate - def test_task_handles_not_found_errors(self, mock_record_event): + def test_task_handles_not_found_errors(self, mock_record_event: MagicMock) -> None: """Test that the task retries on API failures""" responses.add( responses.PUT, @@ -616,7 +643,7 @@ def test_task_handles_not_found_errors(self, mock_record_event): assert_slo_metric(mock_record_event, event_outcome=EventLifecycleOutcome.FAILURE) @responses.activate - def test_task_creates_missing_webhook(self): + def test_task_creates_missing_webhook(self) -> None: self.repo.update(config={"project_id": "101", "path": "test-group/repo"}) responses.add( responses.POST, @@ -633,7 +660,7 @@ def test_task_creates_missing_webhook(self): assert [call.request.method for call in responses.calls] == ["POST"] @responses.activate - def test_task_recreates_stale_webhook(self): + def test_task_recreates_stale_webhook(self) -> None: responses.add( responses.PUT, "https://example.gitlab.com/api/v4/projects/101/hooks/webhook-1", @@ -650,7 +677,7 @@ def test_task_recreates_stale_webhook(self): assert [call.request.method for call in responses.calls] == ["PUT", "POST"] @responses.activate - def test_task_replaces_disabled_webhook(self): + def test_task_replaces_disabled_webhook(self) -> None: responses.add( responses.PUT, "https://example.gitlab.com/api/v4/projects/101/hooks/webhook-1", @@ -671,10 +698,12 @@ def test_task_replaces_disabled_webhook(self): assert self.repo.config["webhook_id"] == 100 assert [call.request.method for call in responses.calls] == ["PUT", "DELETE", "POST"] - def _create_hook_while(self, change_repository, delete_status=204): + def _create_hook_while( + self, change_repository: Callable[[], object], delete_status: int = 204 + ) -> None: """Register a hook create that changes the repository mid-request, as a concurrent writer would.""" - def create(request): + def create(request: PreparedRequest) -> tuple[int, dict[str, str], str]: change_repository() return 201, {}, '{"id": 100}' @@ -689,7 +718,7 @@ def create(request): ) @responses.activate - def test_task_preserves_config_written_during_repair(self): + def test_task_preserves_config_written_during_repair(self) -> None: self._create_hook_while( lambda: Repository.objects.filter(id=self.repo.id).update( name="renamed", config={**self.repo.config, "sync_comments": True} @@ -708,7 +737,9 @@ def test_task_preserves_config_written_during_repair(self): @responses.activate @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_task_discards_hook_when_repository_disabled_during_repair(self, record_event): + def test_task_discards_hook_when_repository_disabled_during_repair( + self, record_event: MagicMock + ) -> None: self._create_hook_while( lambda: Repository.objects.filter(id=self.repo.id).update(status=ObjectStatus.DISABLED) ) @@ -721,7 +752,7 @@ def test_task_discards_hook_when_repository_disabled_during_repair(self, record_ @responses.activate @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_task_halts_when_discarding_the_hook_fails(self, record_event): + def test_task_halts_when_discarding_the_hook_fails(self, record_event: MagicMock) -> None: for delete_status in (404, 500): responses.reset() record_event.reset_mock() @@ -737,7 +768,7 @@ def test_task_halts_when_discarding_the_hook_fails(self, record_event): Repository.objects.filter(id=self.repo.id).update(status=ObjectStatus.ACTIVE) @responses.activate - def test_task_discards_hook_when_repository_moved_during_repair(self): + def test_task_discards_hook_when_repository_moved_during_repair(self) -> None: other = self.create_integration( organization=self.organization, external_id="other", provider="gitlab" ) @@ -751,7 +782,7 @@ def test_task_discards_hook_when_repository_moved_during_repair(self): assert [call.request.method for call in responses.calls] == ["POST", "DELETE"] @responses.activate - def test_task_discards_hook_when_repaired_concurrently(self): + def test_task_discards_hook_when_repaired_concurrently(self) -> None: self._create_hook_while( lambda: Repository.objects.filter(id=self.repo.id).update( config={**self.repo.config, "webhook_id": 99} @@ -765,7 +796,7 @@ def test_task_discards_hook_when_repaired_concurrently(self): @responses.activate @patch("sentry.integrations.gitlab.client.metrics.incr") @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_create_forbidden_is_terminal(self, record_event, incr): + def test_create_forbidden_is_terminal(self, record_event: MagicMock, incr: MagicMock) -> None: self.repo.update(config={"project_id": "101"}) responses.add( responses.POST, @@ -781,7 +812,7 @@ def test_create_forbidden_is_terminal(self, record_event, incr): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @responses.activate - def test_task_updates_webhook_with_correct_data(self, mock_record_event): + def test_task_updates_webhook_with_correct_data(self, mock_record_event: MagicMock) -> None: """Test that webhook update includes correct event subscriptions""" responses.add( responses.PUT, diff --git a/tests/sentry/integrations/gitlab/test_client.py b/tests/sentry/integrations/gitlab/test_client.py index 632a424f4a2b..b2eda8387791 100644 --- a/tests/sentry/integrations/gitlab/test_client.py +++ b/tests/sentry/integrations/gitlab/test_client.py @@ -1,9 +1,10 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import asdict from datetime import datetime, timedelta, timezone from time import time -from typing import Any +from typing import Any, cast from unittest import mock from urllib.parse import quote @@ -33,6 +34,7 @@ ApiRateLimitedError, ApiRetryError, ) +from sentry.shared_integrations.response import MappingApiResponse from sentry.testutils.helpers.datetime import freeze_time from sentry.testutils.silo import control_silo_test from sentry.users.models.identity import Identity @@ -72,7 +74,11 @@ def store_identity_data(self, **data: Any) -> None: identity.save() def get_stored_identity_data(self) -> dict[str, Any]: - return Identity.objects.get(id=self.gitlab_client.identity.id).data + # justification: Identity.data stores the provider payload as a JSON object. + return cast( + "dict[str, Any]", + Identity.objects.get(id=self.gitlab_client.identity.id).data, + ) @control_silo_test @@ -282,7 +288,7 @@ def setUp(self) -> None: def tearDown(self) -> None: responses.reset() - def make_users_request(self): + def make_users_request(self) -> MappingApiResponse: return self.gitlab_client.get_user() def add_user_responses_accepting_only_the_fresh_token(self) -> None: @@ -300,7 +306,7 @@ def add_user_responses_accepting_only_the_fresh_token(self) -> None: match=[matchers.header_matcher({"Authorization": "Bearer fresh-token"})], ) - def add_refresh_auth(self, success=True): + def add_refresh_auth(self, success: bool = True) -> None: responses.add( responses.POST, self.refresh_url, @@ -308,7 +314,7 @@ def add_refresh_auth(self, success=True): json=self.refresh_response if success else {}, ) - def add_get_user_response(self, success): + def add_get_user_response(self, success: bool) -> None: responses.add( responses.GET, self.request_url, @@ -316,23 +322,23 @@ def add_get_user_response(self, success): status=200 if success else 401, ) - def assert_response_call(self, call, url, status): + def assert_response_call(self, call: responses.Call, url: str, status: int) -> None: assert call.request.url == url assert call.response.status_code == status - def assert_data(self, data, expected_data): + def assert_data(self, data: Mapping[str, Any], expected_data: Mapping[str, Any]) -> None: assert data["access_token"] == expected_data["access_token"] assert data["refresh_token"] == expected_data["refresh_token"] assert data["created_at"] == expected_data["created_at"] - def assert_request_failed_refresh(self): + def assert_request_failed_refresh(self) -> None: responses_calls = responses.calls assert len(responses_calls) == 2 self.assert_response_call(responses_calls[0], self.request_url, 401) self.assert_response_call(responses_calls[1], self.refresh_url, 401) - def assert_request_with_refresh(self): + def assert_request_with_refresh(self) -> None: responses_calls = responses.calls assert len(responses_calls) == 3 @@ -342,14 +348,14 @@ def assert_request_with_refresh(self): assert orjson.loads(responses_calls[2].response.text) == self.request_data - def assert_identity_was_refreshed(self): + def assert_identity_was_refreshed(self) -> None: data = self.gitlab_client.identity.data self.assert_data(data, self.refresh_response) data = Identity.objects.get(id=self.gitlab_client.identity.id).data self.assert_data(data, self.refresh_response) - def assert_identity_was_not_refreshed(self): + def assert_identity_was_not_refreshed(self) -> None: data = self.gitlab_client.identity.data self.assert_data(data, self.original_identity_data) @@ -708,7 +714,7 @@ def setUp(self) -> None: ), ) - def set_up_success_responses(self): + def set_up_success_responses(self) -> None: responses.add( responses.GET, url=self.make_blame_request(self.file_1), @@ -731,7 +737,7 @@ def set_up_success_responses(self): def make_blame_request(self, file: SourceLineInfo) -> str: return f"https://example.gitlab.com/api/v4/projects/{self.gitlab_id}/repository/files/{quote(file.path.strip('/'), safe='')}/blame?ref={file.ref}&range[start]={file.lineno}&range[end]={file.lineno}" - def make_blame_response(self, **kwargs) -> list[GitLabFileBlameResponseItem]: + def make_blame_response(self, **kwargs: Any) -> list[GitLabFileBlameResponseItem]: return [ GitLabFileBlameResponseItem( lines=[], diff --git a/tests/sentry/integrations/gitlab/test_integration.py b/tests/sentry/integrations/gitlab/test_integration.py index cc68fe5829b3..deba8871b712 100644 --- a/tests/sentry/integrations/gitlab/test_integration.py +++ b/tests/sentry/integrations/gitlab/test_integration.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from unittest.mock import Mock, patch import orjson @@ -9,6 +9,7 @@ from django.core.cache import cache from django.test import override_settings from django.urls import reverse +from requests import PreparedRequest from fixtures.gitlab import GET_COMMIT_RESPONSE, GitLabTestCase from sentry.constants import ObjectStatus @@ -18,6 +19,7 @@ from sentry.integrations.gitlab.metrics import GitLabWebhookUpdateHaltReason from sentry.integrations.gitlab.tasks import update_all_project_webhooks from sentry.integrations.gitlab.utils import build_integration_external_id +from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.models.integration import Integration from sentry.integrations.models.integration_external_project import IntegrationExternalProject from sentry.integrations.models.organization_integration import OrganizationIntegration @@ -25,6 +27,7 @@ from sentry.integrations.services.integration import integration_service from sentry.integrations.source_code_management.sync_repos import sync_repos_for_org from sentry.integrations.types import ExternalProviders +from sentry.models.group import Group from sentry.models.organization import Organization from sentry.models.repository import Repository from sentry.shared_integrations.exceptions import ( @@ -39,13 +42,15 @@ from sentry.testutils.helpers.integrations import get_installation_of_type from sentry.testutils.silo import assume_test_silo_mode, control_silo_test from sentry.users.models.identity import Identity +from sentry.users.services.user import RpcUser from sentry.users.services.user.serial import serialize_rpc_user from sentry.utils import json from tests.sentry.integrations.test_helpers import add_control_silo_proxy_response -def assert_proxy_request(request, is_proxy=True): - assert (PROXY_BASE_PATH in request.url) == is_proxy +def assert_proxy_request(request: PreparedRequest, is_proxy: bool = True) -> None: + url = cast(str, request.url) + assert (PROXY_BASE_PATH in url) == is_proxy assert (PROXY_OI_HEADER in request.headers) == is_proxy assert (PROXY_SIGNATURE_HEADER in request.headers) == is_proxy # The following Gitlab headers don't appear in proxied requests @@ -280,7 +285,7 @@ def _setup_assignee_sync_test( external_id: str | None = None, issue_key: str = "example.gitlab.com/group-x:cool-group/sentry#45", create_external_user: bool = True, - ) -> tuple: + ) -> tuple[RpcUser, GitlabIntegration, ExternalIssue, Integration, Group]: """ Common setup for assignee sync tests. @@ -288,7 +293,7 @@ def _setup_assignee_sync_test( tuple: (user, installation, external_issue, integration, group) """ - user = serialize_rpc_user(self.create_user(email=user_email)) + user = self.create_user(email=user_email) integration = Integration.objects.get(provider=self.provider) installation = get_installation_of_type( @@ -313,7 +318,7 @@ def _setup_assignee_sync_test( key=issue_key, ) - return user, installation, external_issue, integration, group + return serialize_rpc_user(user), installation, external_issue, integration, group @responses.activate @with_feature("organizations:integrations-gitlab-project-management") @@ -674,7 +679,7 @@ def test_every_config_save_schedules_webhook_reconciliation(self) -> None: def test_legacy_version_does_not_gate_webhook_reconciliation(self) -> None: self.assert_config_schedules_reconciliation({"gitlab_webhook_version": 2}) - def assert_config_schedules_reconciliation(self, config) -> None: + def assert_config_schedules_reconciliation(self, config: dict[str, Any]) -> None: integration = Integration.objects.get(provider=self.provider) org_integration = OrganizationIntegration.objects.get( integration=integration, organization_id=self.organization.id @@ -1237,7 +1242,10 @@ def _submit_config(self, organization: Organization | None = None, **overrides: return self._advance_step(data, organization) def _get_pipeline_signature(self, resp: Any) -> str: - return resp.data["data"]["oauthUrl"].split("state=")[1].split("&")[0] + oauth_url = resp.data["data"]["oauthUrl"] + if not isinstance(oauth_url, str): + raise TypeError("GitLab pipeline response carries no OAuth URL") + return oauth_url.split("state=")[1].split("&")[0] def _run_pipeline(self, organization: Organization | None = None, **overrides: Any) -> Any: self._stub_gitlab_oauth() @@ -1794,6 +1802,7 @@ def _assert_reinstall_rejects_deletion(self, status: int, *, in_progress: bool) in_progress=in_progress, ) metadata = integration.metadata + assert organization_integration.default_auth_id is not None identity = Identity.objects.get(id=organization_integration.default_auth_id) identity_data = identity.data diff --git a/tests/sentry/integrations/gitlab/test_issues.py b/tests/sentry/integrations/gitlab/test_issues.py index 9dfc187242ee..890d01ed8b40 100644 --- a/tests/sentry/integrations/gitlab/test_issues.py +++ b/tests/sentry/integrations/gitlab/test_issues.py @@ -28,8 +28,14 @@ def setUp(self) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event.group is not None self.group = event.group + def latest_event_title(self) -> str: + event = self.group.get_latest_event() + assert event is not None + return event.title + def test_make_external_key(self) -> None: project_name = "getsentry/sentry" issue_iid = "7" @@ -142,7 +148,7 @@ def test_get_create_issue_config(self) -> None: { "name": "title", "label": "Title", - "default": self.group.get_latest_event().title, + "default": self.latest_event_title(), "type": "string", "required": True, }, @@ -351,7 +357,7 @@ def test_create_issue_default_project_in_group_api_call(self) -> None: { "name": "title", "label": "Title", - "default": self.group.get_latest_event().title, + "default": self.latest_event_title(), "type": "string", "required": True, }, @@ -434,7 +440,7 @@ def test_create_issue_default_project_not_in_api_call(self) -> None: { "name": "title", "label": "Title", - "default": self.group.get_latest_event().title, + "default": self.latest_event_title(), "type": "string", "required": True, }, @@ -479,7 +485,7 @@ def test_create_issue_no_projects(self) -> None: { "name": "title", "label": "Title", - "default": self.group.get_latest_event().title, + "default": self.latest_event_title(), "type": "string", "required": True, }, @@ -498,7 +504,7 @@ def test_after_link_issue(self) -> None: responses.add( responses.POST, "https://example.gitlab.com/api/v4/projects/2/issues/321/notes", - json=[], + json={}, ) data = {"externalIssue": "2#321", "comment": "This is not good."} external_issue = ExternalIssue.objects.create( diff --git a/tests/sentry/integrations/gitlab/test_repository.py b/tests/sentry/integrations/gitlab/test_repository.py index 76922142286c..2c407c780f41 100644 --- a/tests/sentry/integrations/gitlab/test_repository.py +++ b/tests/sentry/integrations/gitlab/test_repository.py @@ -1,9 +1,12 @@ +from collections.abc import Mapping from functools import cached_property -from unittest.mock import patch +from typing import Any, cast +from unittest.mock import MagicMock, patch import orjson import pytest import responses +from requests import PreparedRequest from fixtures.gitlab import COMMIT_DIFF_RESPONSE, COMMIT_LIST_RESPONSE, COMPARE_RESPONSE from sentry.constants import ObjectStatus @@ -60,14 +63,14 @@ def setUp(self) -> None: self.gitlab_id = 123 @cached_property - def provider(self): + def provider(self) -> GitlabRepositoryProvider: return GitlabRepositoryProvider("gitlab") def tearDown(self) -> None: super().tearDown() responses.reset() - def add_create_repository_responses(self, repository_config): + def add_create_repository_responses(self, repository_config: Mapping[str, Any]) -> None: responses.add( responses.GET, "https://example.gitlab.com/api/v4/projects/%s" % self.gitlab_id, @@ -80,10 +83,12 @@ def add_create_repository_responses(self, repository_config): ) @assume_test_silo_mode(SiloMode.CELL) - def get_repository(self, **kwargs) -> Repository: + def get_repository(self, **kwargs: Any) -> Repository: return Repository.objects.get(**kwargs) - def assert_repository(self, repository_config, organization_id=None): + def assert_repository( + self, repository_config: dict[str, Any], organization_id: int | None = None + ) -> None: instance = self.integration.metadata["instance"] external_id = "{}:{}".format(instance, repository_config["id"]) @@ -104,7 +109,7 @@ def assert_repository(self, repository_config, organization_id=None): @responses.activate @patch("sentry.integrations.gitlab.client.metrics.incr") - def test_create_repository(self, incr) -> None: + def test_create_repository(self, incr: MagicMock) -> None: response = self.create_repository(self.default_repository_config, self.integration.id) assert response.status_code == 201 self.assert_repository(self.default_repository_config) @@ -112,8 +117,8 @@ def test_create_repository(self, incr) -> None: @responses.activate def test_create_repository_verify_payload(self) -> None: - def request_callback(request): - payload = orjson.loads(request.body) + def request_callback(request: PreparedRequest) -> tuple[int, dict[str, str], str]: + payload = orjson.loads(cast(bytes, request.body)) assert "url" in payload assert payload["push_events"] assert payload["merge_requests_events"] @@ -168,8 +173,8 @@ def test_on_create_repository_relink_updates_webhook(self) -> None: response = self.create_repository(self.default_repository_config, self.integration.id) responses.reset() - def request_callback(request): - payload = orjson.loads(request.body) + def request_callback(request: PreparedRequest) -> tuple[int, dict[str, str], str]: + payload = orjson.loads(cast(bytes, request.body)) assert "url" in payload assert payload["push_events"] assert payload["merge_requests_events"] @@ -230,7 +235,7 @@ def _on_create_repository_relink_discards_hook_when_repository_disabled( repo = self.get_repository(pk=response.data["id"]) responses.reset() - def create(request): + def create(request: PreparedRequest) -> tuple[int, dict[str, str], str]: with assume_test_silo_mode(SiloMode.CELL): Repository.objects.filter(id=repo.id).update(status=ObjectStatus.DISABLED) return 201, {}, '{"id": 100}' diff --git a/tests/sentry/integrations/gitlab/test_search.py b/tests/sentry/integrations/gitlab/test_search.py index fd6407d3b933..25a5942e1d16 100644 --- a/tests/sentry/integrations/gitlab/test_search.py +++ b/tests/sentry/integrations/gitlab/test_search.py @@ -1,9 +1,11 @@ +from typing import cast from unittest.mock import MagicMock, patch from urllib.parse import parse_qs import orjson import responses from django.urls import reverse +from requests import PreparedRequest from fixtures.gitlab import GitLabTestCase from sentry.integrations.models.organization_integration import OrganizationIntegration @@ -124,8 +126,8 @@ def test_finds_project_results_with_pagination(self) -> None: "path_with_namespace": "getsentry2/sentry2", } - def request_callback(request): - query = parse_qs(request.url.split("?")[1]) + def request_callback(request: PreparedRequest) -> tuple[int, dict[str, str], str]: + query = parse_qs(cast(str, request.url).split("?")[1]) # allow for 220 responses if int(query["page"][0]) >= 3: projects = [project_a, project_b] * 10 diff --git a/tests/sentry/integrations/gitlab/test_webhook.py b/tests/sentry/integrations/gitlab/test_webhook.py index 0570b1122197..468435f31b08 100644 --- a/tests/sentry/integrations/gitlab/test_webhook.py +++ b/tests/sentry/integrations/gitlab/test_webhook.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +from typing import Any from unittest.mock import MagicMock, patch import orjson @@ -33,6 +34,7 @@ from sentry.testutils.asserts import assert_failure_metric, assert_success_metric from sentry.testutils.silo import assume_test_silo_mode, assume_test_silo_mode_of from sentry.types.activity import ActivityType +from sentry.users.models.user import User ISSUE_KEY = "example.gitlab.com/group-x:cool-group/sentry#23" @@ -85,7 +87,7 @@ def assert_pull_request(self, pull: PullRequest, author: CommitAuthor) -> None: assert pull.head_commit_sha == "ba3e0d8ff79c80d5b0bbb4f3e2e343e0aaa662b7" assert pull.draft is False - def assert_group_link(self, group, pull): + def assert_group_link(self, group: Group, pull: PullRequest) -> None: link = GroupLink.objects.get() assert link.group_id == group.id assert link.linked_type == GroupLink.LinkedType.pull_request @@ -781,7 +783,7 @@ def test_merge_event_edit_after_merge_preserves_terminal_timestamps(self) -> Non assert pull.merged_at == merged_at assert pull.closed_at == closed_at - def _post_merge_event(self, payload: dict) -> None: + def _post_merge_event(self, payload: dict[str, Any]) -> None: response = self.client.post( self.url, data=orjson.dumps(payload), @@ -1002,7 +1004,7 @@ def test_issue_unassigned(self, mock_sync: MagicMock) -> None: "organizations:integrations-gitlab-project-management", ] - def _post_issue_event(self, event: dict) -> None: + def _post_issue_event(self, event: dict[str, Any]) -> None: response = self.client.post( self.url, data=orjson.dumps(event), @@ -1019,7 +1021,7 @@ def _linked_group_for_assignee_sync(self) -> Group: ).update(config={"sync_reverse_assignment": True}) return self.link_issue() - def _create_gitlab_member(self, username: str, external_id: int): + def _create_gitlab_member(self, username: str, external_id: int) -> User: member = self.create_user(email=f"{username}@example.com") self.create_member(organization=self.organization, user=member, teams=[self.team]) self.create_external_user( @@ -1032,8 +1034,10 @@ def _create_gitlab_member(self, username: str, external_id: int): ) return member - def _assigned_event(self, username: str, external_id: int, updated_at: str) -> dict: + def _assigned_event(self, username: str, external_id: int, updated_at: str) -> dict[str, Any]: event = orjson.loads(ISSUE_ASSIGNED_EVENT) + if not isinstance(event, dict): + raise TypeError("Issue assigned event is not a JSON object") event["object_attributes"]["updated_at"] = updated_at event["assignees"] = [{"id": external_id, "username": username}] return event diff --git a/tests/sentry/integrations/grafana/test_integration.py b/tests/sentry/integrations/grafana/test_integration.py index 99042361523d..efdd7c45b677 100644 --- a/tests/sentry/integrations/grafana/test_integration.py +++ b/tests/sentry/integrations/grafana/test_integration.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, cast import pytest import responses @@ -65,9 +65,13 @@ def _installation(self, integration: Integration) -> GrafanaIntegration: return installation def _org_integration_config(self, integration: Integration) -> dict[str, Any]: - return OrganizationIntegration.objects.get( - organization_id=self.organization.id, integration_id=integration.id - ).config + # justification: OrganizationIntegration.config stores a JSON object. + return cast( + "dict[str, Any]", + OrganizationIntegration.objects.get( + organization_id=self.organization.id, integration_id=integration.id + ).config, + ) @responses.activate def test_build_integration_stores_token_in_metadata_and_url_in_post_install_config( diff --git a/tests/sentry/integrations/jira/models/test_jira_schema.py b/tests/sentry/integrations/jira/models/test_jira_schema.py index 07418dafc5e2..78be038d11c6 100644 --- a/tests/sentry/integrations/jira/models/test_jira_schema.py +++ b/tests/sentry/integrations/jira/models/test_jira_schema.py @@ -6,6 +6,7 @@ class TestJiraSchema(TestCase): def test_schema_parsing(self) -> None: create_meta = StubJiraApiClient().get_create_meta_for_project("proj-1") + assert create_meta is not None issue_configs = list(JiraIssueTypeMetadata.from_jira_meta_config(create_meta).values()) assert len(issue_configs) == 1 assert issue_configs[0].name == "Bug" diff --git a/tests/sentry/integrations/jira/test_csp.py b/tests/sentry/integrations/jira/test_csp.py index cedfe3e98ee5..cb1bf7c588cb 100644 --- a/tests/sentry/integrations/jira/test_csp.py +++ b/tests/sentry/integrations/jira/test_csp.py @@ -12,7 +12,7 @@ def setUp(self) -> None: self.issue_key = "APP-123" self.path = absolute_uri(f"extensions/jira/issue/{self.issue_key}/") + "?xdm_e=base_url" - def _split_csp_policy(self, policy): + def _split_csp_policy(self, policy: str) -> dict[str, list[str]]: csp = {} for directive in policy.split("; "): parts = directive.split(" ") diff --git a/tests/sentry/integrations/jira/test_installed.py b/tests/sentry/integrations/jira/test_installed.py index 7e054d8b2d0c..a9248bb56eb2 100644 --- a/tests/sentry/integrations/jira/test_installed.py +++ b/tests/sentry/integrations/jira/test_installed.py @@ -58,7 +58,7 @@ def _jwt_token( headers={**(headers or {}), "alg": jira_signing_algorithm}, ) - def jwt_token_cdn(self): + def jwt_token_cdn(self) -> str: return self._jwt_token("RS256", RS256_KEY, headers={"kid": self.kid}) def body(self, client_key: str | None = None) -> Mapping[str, Any]: diff --git a/tests/sentry/integrations/jira/test_integration.py b/tests/sentry/integrations/jira/test_integration.py index 291022823887..ba34150977c4 100644 --- a/tests/sentry/integrations/jira/test_integration.py +++ b/tests/sentry/integrations/jira/test_integration.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, cast from unittest import mock from unittest.mock import patch from urllib.parse import parse_qs, urlparse @@ -7,11 +7,14 @@ import responses from django.test.utils import override_settings from django.urls import reverse +from requests import PreparedRequest from fixtures.integrations.jira.stub_client import StubJiraApiClient from fixtures.integrations.stub_service import StubService from sentry.constants import ObjectStatus +from sentry.integrations.base import IntegrationInstallation from sentry.integrations.jira.integration import ( + JiraIntegration, JiraIntegrationProvider, _build_project_mapping_audit_data, _ProjectStatusMapping, @@ -35,6 +38,7 @@ from sentry.testutils.factories import EventType from sentry.testutils.helpers.datetime import before_now from sentry.testutils.helpers.features import with_feature +from sentry.testutils.helpers.integrations import get_installation_of_type from sentry.testutils.silo import assume_test_silo_mode, assume_test_silo_mode_of, control_silo_test from sentry.testutils.skips import requires_snuba from sentry.users.services.user.serial import serialize_rpc_user @@ -45,7 +49,7 @@ EXPLICIT_MAPPING_REMOVALS_FEATURE = "organizations:jira-explicit-mapping-removals" -def get_client(): +def get_client() -> StubJiraApiClient: return StubJiraApiClient() @@ -121,6 +125,9 @@ def test_build_project_mapping_audit_data() -> None: class RegionJiraIntegrationTest(APITestCase): + def get_installation(self) -> JiraIntegration: + return get_installation_of_type(JiraIntegration, self.integration, self.organization.id) + def setUp(self) -> None: super().setUp() self.min_ago = before_now(minutes=1).isoformat() @@ -142,7 +149,7 @@ def setUp(self) -> None: self.login_as(self.user) def test_issue_url(self) -> None: - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() for path in ("browse/abc-123", "jira/software/projects/ABC/boards/1?selectedIssue=ABC-123"): assert installation.get_issue_link_data(f"https://example.atlassian.net/{path}") == { "externalIssue": "ABC-123" @@ -155,30 +162,30 @@ def test_issue_url(self) -> None: installation.get_issue_link_data(url) def test_create_comment(self) -> None: - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() group_note = mock.Mock() comment = "hello world\nThis is a comment.\n\n\n Glad it's quoted" group_note.data = {"text": comment} with mock.patch.object(StubJiraApiClient, "create_comment") as mock_create_comment: with mock.patch.object(installation, "get_client", get_client): - installation.create_comment(1, self.user.id, group_note) + installation.create_comment("1", self.user.id, group_note) assert ( mock_create_comment.call_args[0][1] == "Sentry Admin wrote:\n\n{quote}%s{quote}" % comment ) def test_update_comment(self) -> None: - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() group_note = mock.Mock() comment = "hello world\nThis is a comment.\n\n\n I've changed it" group_note.data = {"text": comment, "external_id": "123"} with mock.patch.object(StubJiraApiClient, "update_comment") as mock_update_comment: with mock.patch.object(installation, "get_client", get_client): - installation.update_comment(1, self.user.id, group_note) + installation.update_comment("1", self.user.id, group_note) assert mock_update_comment.call_args[0] == ( - 1, + "1", "123", "Sentry Admin wrote:\n\n{quote}%s{quote}" % comment, ) @@ -196,7 +203,7 @@ def test_get_create_issue_config(self) -> None: group = event.group assert group is not None - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() search_url = reverse( "sentry-extensions-jira-search", args=[self.organization.slug, self.integration.id], @@ -349,7 +356,7 @@ def test_get_create_issue_config_with_none_issue(self) -> None: }, ) # None, user, params=self.params - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() fields = installation.get_create_issue_config(group=None, user=self.user) # Find the project field in the config @@ -423,7 +430,7 @@ def test_get_create_issue_config_paginated_projects(self) -> None: }, ) - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() fields = installation.get_create_issue_config(group, self.user) # Find the project field in the config @@ -452,13 +459,15 @@ def test_get_create_issue_config_customer_domain(self) -> None: ) group = event.group - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() with ( self.feature("system:multi-region"), mock.patch.object(installation, "get_client", get_client), ): issue_config = installation.get_create_issue_config(group, self.user) - assert f"{self.organization.slug}.testserver" in issue_config[2]["default"] + default = issue_config[2]["default"] + assert isinstance(default, str) + assert f"{self.organization.slug}.testserver" in default def test_get_create_issue_config_with_persisted_reporter(self) -> None: event = self.store_event( @@ -471,7 +480,7 @@ def test_get_create_issue_config_with_persisted_reporter(self) -> None: default_event_type=EventType.DEFAULT, ) group = event.group - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() # When persisted reporter matches a user JIRA knows about, a default is picked. account_id = StubService.get_stub_data("jira", "user.json")["accountId"] @@ -526,7 +535,7 @@ def test_get_create_issue_config_with_ignored_fields(self) -> None: default_event_type=EventType.DEFAULT, ) group = event.group - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() with mock.patch.object(installation, "get_client", get_client): # Initially all fields are present @@ -548,10 +557,12 @@ def test_get_create_issue_config_with_ignored_fields(self) -> None: ] # After ignoring "customfield_10200", it no longer shows up with assume_test_silo_mode_of(OrganizationIntegration): - installation.org_integration = integration_service.update_organization_integration( + updated_org_integration = integration_service.update_organization_integration( org_integration_id=installation.org_integration.id, config={"issues_ignored_fields": ["customfield_10200"]}, ) + assert updated_org_integration is not None + installation.org_integration = updated_org_integration fields = installation.get_create_issue_config(group, self.user) field_names = [field["name"] for field in fields] @@ -581,13 +592,15 @@ def test_get_create_issue_config_with_default_and_param(self) -> None: ) group = event.group assert group is not None - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() with assume_test_silo_mode_of(OrganizationIntegration): - installation.org_integration = integration_service.update_organization_integration( + updated_org_integration = integration_service.update_organization_integration( org_integration_id=installation.org_integration.id, config={"project_issue_defaults": {str(group.project_id): {"project": "10001"}}}, ) + assert updated_org_integration is not None + installation.org_integration = updated_org_integration with mock.patch.object(installation, "get_client", get_client): fields = installation.get_create_issue_config( @@ -621,13 +634,15 @@ def test_get_create_issue_config_with_default(self) -> None: ) group = event.group assert group is not None - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() with assume_test_silo_mode_of(OrganizationIntegration): - installation.org_integration = integration_service.update_organization_integration( + updated_org_integration = integration_service.update_organization_integration( org_integration_id=installation.org_integration.id, config={"project_issue_defaults": {str(group.project_id): {"project": "10001"}}}, ) + assert updated_org_integration is not None + installation.org_integration = updated_org_integration with mock.patch.object(installation, "get_client", get_client): fields = installation.get_create_issue_config(group, self.user) @@ -649,8 +664,8 @@ def test_get_create_issue_config_with_default(self) -> None: @patch("sentry.integrations.jira.integration.JiraIntegration.fetch_issue_create_meta") def test_get_create_issue_config_with_default_project_deleted( - self, mock_fetch_issue_create_meta - ): + self, mock_fetch_issue_create_meta: mock.MagicMock + ) -> None: event = self.store_event( data={ "event_id": "a" * 32, @@ -662,13 +677,15 @@ def test_get_create_issue_config_with_default_project_deleted( ) group = event.group assert group is not None - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() with assume_test_silo_mode_of(OrganizationIntegration): - installation.org_integration = integration_service.update_organization_integration( + updated_org_integration = integration_service.update_organization_integration( org_integration_id=installation.org_integration.id, config={"project_issue_defaults": {str(group.project_id): {"project": "10004"}}}, ) + assert updated_org_integration is not None + installation.org_integration = updated_org_integration with mock.patch.object(installation, "get_client", get_client): mock_fetch_issue_create_meta_return_value = json.loads( @@ -715,14 +732,16 @@ def test_get_create_issue_config_with_label_default(self) -> None: assert group is not None label_default = "hi" - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() with assume_test_silo_mode_of(OrganizationIntegration): - installation.org_integration = integration_service.update_organization_integration( + updated_org_integration = integration_service.update_organization_integration( org_integration_id=installation.org_integration.id, config={ "project_issue_defaults": {str(group.project_id): {"labels": label_default}} }, ) + assert updated_org_integration is not None + installation.org_integration = updated_org_integration with mock.patch.object(installation, "get_client", get_client): fields = installation.get_create_issue_config(group, self.user) @@ -742,7 +761,7 @@ def test_get_create_issue_config__no_projects(self) -> None: data={"message": "oh no", "timestamp": self.min_ago}, project_id=self.project.id ) - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() # Simulate no projects available. responses.add( @@ -760,7 +779,7 @@ def test_get_create_issue_config__no_issue_config(self) -> None: data={"message": "oh no", "timestamp": self.min_ago}, project_id=self.project.id ) - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() responses.add( responses.GET, @@ -784,7 +803,7 @@ def test_get_create_issue_config__no_issue_config(self) -> None: def test_get_link_issue_config(self) -> None: group = self.create_group() - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() assert installation.get_link_issue_config(group) == [ { @@ -800,7 +819,7 @@ def test_get_link_issue_config(self) -> None: ] def test_create_issue(self) -> None: - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() with mock.patch.object(installation, "get_client", get_client): assert installation.create_issue( @@ -834,7 +853,7 @@ def test_create_issue_with_form_error(self) -> None: content_type="json", ) - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() with pytest.raises(IntegrationFormError): installation.create_issue( { @@ -860,7 +879,7 @@ def test_create_issue_with_configuration_error(self) -> None: body=json.dumps({"error": "Jira had an oopsie"}), content_type="json", ) - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() with pytest.raises(IntegrationConfigurationError): installation.create_issue( { @@ -887,7 +906,7 @@ def test_create_issue_product_unavailable_error(self) -> None: body='\n\n \n Atlassian Cloud Notifications - Product Unavailable\n \n \n

Jira has been deactivated

\n \n', content_type="text/html", ) - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() with pytest.raises( IntegrationConfigurationError, match="Something went wrong while communicating with Jira", @@ -917,7 +936,7 @@ def test_create_issue_page_unavailable_error(self) -> None: body='\n\n \n Page Unavailable\n \n \n

Page Unavailable

\n \n', content_type="text/html", ) - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() with pytest.raises( IntegrationConfigurationError, match="Something went wrong while communicating with Jira", @@ -933,7 +952,7 @@ def test_create_issue_page_unavailable_error(self) -> None: @responses.activate def test_create_issue_labels_and_option(self) -> None: - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() responses.add( responses.GET, @@ -948,8 +967,8 @@ def test_create_issue_labels_and_option(self) -> None: content_type="json", ) - def responder(request): - body = json.loads(request.body) + def responder(request: PreparedRequest) -> tuple[int, dict[str, str], str]: + body = json.loads(cast("str | bytes", request.body)) assert body["fields"]["labels"] == ["fuzzy", "bunnies"] assert body["fields"]["customfield_10200"] == {"value": "sad"} assert body["fields"]["customfield_10300"] == [ @@ -992,7 +1011,7 @@ def test_outbound_issue_sync(self) -> None: unresolved_status="3", ) - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() with mock.patch.object(StubJiraApiClient, "transition_issue") as mock_transition_issue: with mock.patch.object(installation, "get_client", get_client): @@ -1008,7 +1027,7 @@ def test_outbound_issue_sync(self) -> None: def test_sync_assignee_outbound_case_insensitive(self) -> None: user = serialize_rpc_user(self.create_user(email="bob@example.com")) issue_id = "APP-123" - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() assign_issue_url = "https://example.atlassian.net/rest/api/2/issue/%s/assignee" % issue_id external_issue = ExternalIssue.objects.create( @@ -1035,14 +1054,14 @@ def test_sync_assignee_outbound_stops_at_first_match(self) -> None: user = self.create_user(email="bob@example.com") self.create_useremail(user, "bob@other.example.com", is_verified=True) issue_id = "APP-123" - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() external_issue = ExternalIssue.objects.create( organization_id=self.organization.id, integration_id=installation.model.id, key=issue_id ) search_url = "https://example.atlassian.net/rest/api/2/user/assignable/search" - def search(request): - email = parse_qs(urlparse(request.url).query)["query"][0] + def search(request: PreparedRequest) -> tuple[int, dict[str, str], str]: + email = parse_qs(urlparse(cast(str, request.url)).query)["query"][0] return 200, {}, json.dumps([{"accountId": email, "emailAddress": email}]) responses.add_callback(responses.GET, search_url, callback=search) @@ -1059,7 +1078,7 @@ def search(request): def test_sync_assignee_outbound_no_email(self) -> None: user = serialize_rpc_user(self.create_user(email="bob@example.com")) issue_id = "APP-123" - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() external_issue = ExternalIssue.objects.create( organization_id=self.organization.id, integration_id=installation.model.id, @@ -1081,7 +1100,7 @@ def test_sync_assignee_outbound_no_email(self) -> None: def test_sync_assignee_outbound_use_email_api(self) -> None: user = serialize_rpc_user(self.create_user(email="bob@example.com")) issue_id = "APP-123" - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() assign_issue_url = "https://example.atlassian.net/rest/api/2/issue/%s/assignee" % issue_id external_issue = ExternalIssue.objects.create( organization_id=self.organization.id, @@ -1115,7 +1134,7 @@ def test_sync_assignee_outbound_use_email_api(self) -> None: def test_sync_assignee_outbound_api_unauthorized(self) -> None: user = serialize_rpc_user(self.create_user(email="bob@example.com")) issue_id = "APP-123" - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() assign_issue_url = "https://example.atlassian.net/rest/api/2/issue/%s/assignee" % issue_id external_issue = ExternalIssue.objects.create( @@ -1140,7 +1159,7 @@ def test_sync_assignee_outbound_api_unauthorized(self) -> None: def test_sync_assignee_outbound_api_error(self) -> None: user = serialize_rpc_user(self.create_user(email="bob@example.com")) issue_id = "APP-123" - installation = self.integration.get_installation(self.organization.id) + installation = self.get_installation() assign_issue_url = "https://example.atlassian.net/rest/api/2/issue/%s/assignee" % issue_id external_issue = ExternalIssue.objects.create( @@ -1507,7 +1526,9 @@ def _mappings(self, org_integration_id: int) -> dict[str, tuple[str, str]]: ) } - def _jira_installation_with_mappings(self, *external_ids: str): + def _jira_installation_with_mappings( + self, *external_ids: str + ) -> tuple[IntegrationInstallation, OrganizationIntegration]: integration = self.create_provider_integration(provider="jira", name="Example Jira") integration.add_organization(self.organization, self.user) for external_id in external_ids: diff --git a/tests/sentry/integrations/jira/test_notify_action.py b/tests/sentry/integrations/jira/test_notify_action.py index 5f133c6d1823..b65a4456c457 100644 --- a/tests/sentry/integrations/jira/test_notify_action.py +++ b/tests/sentry/integrations/jira/test_notify_action.py @@ -1,3 +1,5 @@ +from typing import Any + import responses from fixtures.integrations.stub_service import StubService @@ -5,6 +7,7 @@ from sentry.integrations.jira.actions.form import JiraNotifyServiceForm from sentry.integrations.models.external_issue import ExternalIssue from sentry.models.grouplink import GroupLink +from sentry.services.eventstore.models import GroupEvent from sentry.silo.base import SiloMode from sentry.testutils.cases import PerformanceIssueTestCase, RuleTestCase from sentry.testutils.helpers.notifications import ( @@ -38,7 +41,7 @@ def setUp(self) -> None: self.installation = self.integration.get_installation(self.organization.id) rule = self.create_project_rule(project=self.project) - self.jira_rule = self.get_rule( + self.jira_rule = self.get_jira_rule( data={ "issuetype": "1", "labels": "bunnies", @@ -52,11 +55,14 @@ def setUp(self) -> None: }, context=create_notification_action_context(rule), ) - self.jira_rule.rule = self.create_project_rule(name="test rule") - self.jira_rule.data["key"] = "APP-123" - def create_issue_base(self, event): + def get_jira_rule(self, **kwargs: Any) -> JiraCreateTicketAction: + rule = self.get_rule(**kwargs) + assert isinstance(rule, JiraCreateTicketAction) + return rule + + def create_issue_base(self, event: GroupEvent) -> dict[str, Any]: responses.add( responses.GET, "https://example.atlassian.net/rest/api/2/project", @@ -92,11 +98,14 @@ def create_issue_base(self, event): # Trigger rule callback rule_future = RuleFuture(context=self.jira_rule.action_context, kwargs=results[0].kwargs) results[0].callback(event, futures=[rule_future]) - return json.loads(responses.calls[1].request.body) + body = json.loads(responses.calls[1].request.body) + if not isinstance(body, dict): + raise TypeError("Jira notification request is not a JSON object") + return body @responses.activate def test_creates_issue(self) -> None: - event = self.get_event() + event = self.get_group_event() data = self.create_issue_base(event) # Make assertions about what would be POSTed to api/2/issue. @@ -111,7 +120,9 @@ def test_creates_issue(self) -> None: def test_creates_performance_issue(self) -> None: """Test that a performance issue properly creates a Jira ticket""" event = self.create_performance_issue() - data = self.create_issue_base(event) + assert event.group is not None + group_event = event if isinstance(event, GroupEvent) else event.for_group(event.group) + data = self.create_issue_base(group_event) # Make assertions about what would be POSTed to api/2/issue. assert data["fields"]["summary"] == "N+1 Query" @@ -128,13 +139,14 @@ def test_creates_generic_issue(self) -> None: occurrence = TEST_ISSUE_OCCURRENCE event = self.get_event() - event = event.for_group(event.groups[0]) - event.occurrence = occurrence - data = self.create_issue_base(event) + group_event = event.for_group(event.groups[0]) + group_event.occurrence = occurrence + data = self.create_issue_base(group_event) # Make assertions about what would be POSTed to api/2/issue. - assert data["fields"]["summary"] == event.occurrence.issue_title - assert event.occurrence.evidence_display[0].value in data["fields"]["description"] + assert group_event.occurrence is not None + assert data["fields"]["summary"] == group_event.occurrence.issue_title + assert group_event.occurrence.evidence_display[0].value in data["fields"]["description"] assert data["fields"]["issuetype"]["id"] == "1" external_issue = ExternalIssue.objects.get(key="APP-123") @@ -144,7 +156,7 @@ def test_creates_generic_issue(self) -> None: def test_doesnt_create_issue(self) -> None: """Don't create an issue if one already exists on the event for the given integration""" - event = self.get_event() + event = self.get_group_event() external_issue = ExternalIssue.objects.create( organization_id=self.organization.id, integration_id=self.integration.id, @@ -169,7 +181,7 @@ def test_doesnt_create_issue(self) -> None: assert len(responses.calls) == 0 def test_render_label(self) -> None: - rule = self.get_rule( + rule = self.get_jira_rule( data={ "integration": self.integration.id, "issuetype": 1, @@ -187,20 +199,20 @@ def test_render_label_without_integration(self) -> None: deleted_id = self.integration.id self.integration.delete() - rule = self.get_rule(data={"integration": deleted_id}) + rule = self.get_jira_rule(data={"integration": deleted_id}) assert rule.render_label() == "Create a Jira issue in [removed] with these " @responses.activate def test_invalid_integration(self) -> None: - rule = self.get_rule(data={"integration": self.integration.id}) + rule = self.get_jira_rule(data={"integration": self.integration.id}) form = JiraNotifyServiceForm(rule.data, integrations=rule.get_integrations()) assert form.is_valid() @responses.activate def test_invalid_project(self) -> None: - rule = self.get_rule(data={"integration": self.integration.id}) + rule = self.get_jira_rule(data={"integration": self.integration.id}) form = JiraNotifyServiceForm(rule.data, integrations=rule.get_integrations()) assert form.is_valid() diff --git a/tests/sentry/integrations/jira/test_search_endpoint.py b/tests/sentry/integrations/jira/test_search_endpoint.py index 9dea1cb421c6..8051f38481ea 100644 --- a/tests/sentry/integrations/jira/test_search_endpoint.py +++ b/tests/sentry/integrations/jira/test_search_endpoint.py @@ -4,8 +4,10 @@ import responses from django.urls import reverse +from requests import PreparedRequest from fixtures.integrations.stub_service import StubService +from sentry.integrations.models.integration import Integration from sentry.integrations.project_management.metrics import ProjectManagementActionType from sentry.integrations.types import EventLifecycleOutcome from sentry.integrations.utils.metrics import EventLifecycle, IntegrationEventLifecycleMetric @@ -51,7 +53,7 @@ def _assert_search_outcome( @control_silo_test class JiraSearchEndpointTest(APITestCase): @cached_property - def integration(self): + def integration(self) -> Integration: integration = self.create_provider_integration( provider="jira", name="Jira Cloud", @@ -89,7 +91,10 @@ def test_issue_search_text(self, mock_record: mock.MagicMock) -> None: @responses.activate @mock.patch("sentry.integrations.utils.metrics.EventLifecycle.record_event", autospec=True) def test_issue_search_id(self, mock_record: mock.MagicMock) -> None: - def responder(request): + def responder( + request: PreparedRequest, + ) -> tuple[int, dict[str, str], str]: + assert request.url is not None query = parse_qs(urlparse(request.url).query) assert 'id="hsp-1"' == query["jql"][0] data = StubService.get_stub_json("jira", "search_response.json") @@ -156,7 +161,10 @@ def test_assignee_search(self, mock_record: mock.MagicMock) -> None: json=[{"key": "HSP", "id": "10000"}], ) - def responder(request): + def responder( + request: PreparedRequest, + ) -> tuple[int, dict[str, str], str]: + assert request.url is not None query = parse_qs(urlparse(request.url).query) assert "HSP" == query["project"][0] assert "bob" == query["query"][0] @@ -212,7 +220,10 @@ def test_assignee_search_error(self, mock_record: mock.MagicMock) -> None: @responses.activate @mock.patch("sentry.integrations.utils.metrics.EventLifecycle.record_event", autospec=True) def test_customfield_search(self, mock_record: mock.MagicMock) -> None: - def responder(request): + def responder( + request: PreparedRequest, + ) -> tuple[int, dict[str, str], str]: + assert request.url is not None query = parse_qs(urlparse(request.url).query) assert "cf[0123]" == query["fieldName"][0] assert "sp" == query["fieldValue"][0] diff --git a/tests/sentry/integrations/jira/test_sentry_installation.py b/tests/sentry/integrations/jira/test_sentry_installation.py index 214cd141c38f..72ecfaac5636 100644 --- a/tests/sentry/integrations/jira/test_sentry_installation.py +++ b/tests/sentry/integrations/jira/test_sentry_installation.py @@ -1,6 +1,8 @@ import re from unittest.mock import MagicMock, patch +from django.http import HttpResponse + from sentry.integrations.jira.views import SALT, UNABLE_TO_VERIFY_INSTALLATION from sentry.integrations.utils.atlassian_connect import ( AtlassianConnectTokenExpiredError, @@ -66,7 +68,7 @@ def setUp(self) -> None: super().setUp() self.login_as(self.user) - def assert_no_errors(self, response): + def assert_no_errors(self, response: HttpResponse) -> None: assert REFRESH_REQUIRED not in response.content assert UNABLE_TO_VERIFY_INSTALLATION.encode() not in response.content diff --git a/tests/sentry/integrations/jira/test_ticket_action.py b/tests/sentry/integrations/jira/test_ticket_action.py index aa14b3dcbcb2..74498679e655 100644 --- a/tests/sentry/integrations/jira/test_ticket_action.py +++ b/tests/sentry/integrations/jira/test_ticket_action.py @@ -1,3 +1,4 @@ +from typing import Never from unittest import mock import pytest @@ -6,8 +7,10 @@ from fixtures.integrations.jira.mock import MockJira from sentry.integrations.jira import JiraCreateTicketAction, JiraIntegration from sentry.integrations.models.external_issue import ExternalIssue +from sentry.integrations.services.integration.serial import serialize_integration from sentry.integrations.types import EventLifecycleOutcome from sentry.models.activity import Activity +from sentry.models.rule import Rule from sentry.services.eventstore.models import GroupEvent from sentry.shared_integrations.exceptions import ( ApiInvalidRequestError, @@ -43,11 +46,13 @@ def setUp(self) -> None: "domain_name": "example.atlassian.net", }, ) - self.installation = self.integration.get_installation(self.organization.id) + installation = self.integration.get_installation(self.organization.id) + assert isinstance(installation, JiraIntegration) + self.installation = installation self.login_as(user=self.user) - def trigger(self, event, rule_object): + def trigger(self, event: GroupEvent, rule_object: Rule) -> object: action = rule_object.data.get("actions", ())[0] context = create_notification_action_context(rule_object) action_inst = self.get_rule(data=action, context=context) @@ -57,12 +62,12 @@ def trigger(self, event, rule_object): rule_future = RuleFuture(context=context, kwargs=results[0].kwargs) return results[0].callback(event, futures=[rule_future]) - def get_key(self, event: GroupEvent): - return ExternalIssue.objects.get_linked_issues(event, self.integration).values_list( - "key", flat=True - )[0] + def get_key(self, event: GroupEvent) -> str: + return ExternalIssue.objects.get_linked_issues( + event, serialize_integration(self.integration) + ).values_list("key", flat=True)[0] - def configure_valid_alert_rule(self): + def configure_valid_alert_rule(self) -> Rule: rule_object = self.create_project_rule( project=self.project, name="hello world", @@ -137,7 +142,7 @@ def test_ticket_rules(self, mock_record_event: mock.MagicMock) -> None: def test_misconfigured_ticket_rule( self, mock_create_issue: mock.MagicMock, mock_record_event: mock.MagicMock ) -> None: - def raise_api_error(*args, **kwargs): + def raise_api_error(*args: object, **kwargs: object) -> Never: raise ApiInvalidRequestError("Invalid data entered") mock_create_issue.side_effect = raise_api_error @@ -147,7 +152,7 @@ def raise_api_error(*args, **kwargs): ): rule_object = self.configure_valid_alert_rule() - event = self.get_event() + event = self.get_group_event() with pytest.raises(IntegrationConfigurationError): self.trigger(event, rule_object) @@ -168,7 +173,7 @@ def test_fails_with_field_configuration_error( ) -> None: # Mock an error from the client response that cotains a field - def raise_api_error_with_payload(*args, **kwargs): + def raise_api_error_with_payload(*args: object, **kwargs: object) -> Never: raise ApiInvalidRequestError(json.dumps({"errors": {"foo": "bar"}})) mock_create_issue.side_effect = raise_api_error_with_payload @@ -178,7 +183,7 @@ def raise_api_error_with_payload(*args, **kwargs): ): rule_object = self.configure_valid_alert_rule() - event = self.get_event() + event = self.get_group_event() with pytest.raises(IntegrationFormError): self.trigger(event, rule_object) @@ -191,15 +196,15 @@ def raise_api_error_with_payload(*args, **kwargs): @mock.patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @mock.patch.object(MockJira, "get_create_meta_for_project", return_value=None) def test_halts_with_external_api_configuration_error( - self, mock_get_create_meta_for_project, mock_record_event - ): + self, mock_get_create_meta_for_project: mock.MagicMock, mock_record_event: mock.MagicMock + ) -> None: with mock.patch( "sentry.integrations.jira.integration.JiraIntegration.get_client", return_value=MockJira(), ): rule_object = self.configure_valid_alert_rule() - event = self.get_event() + event = self.get_group_event() with pytest.raises(IntegrationConfigurationError): self.trigger(event, rule_object) diff --git a/tests/sentry/integrations/jira/test_uninstalled.py b/tests/sentry/integrations/jira/test_uninstalled.py index 4575b8fabf8e..248f5a6a2c3f 100644 --- a/tests/sentry/integrations/jira/test_uninstalled.py +++ b/tests/sentry/integrations/jira/test_uninstalled.py @@ -21,7 +21,7 @@ class JiraUninstalledTest(APITestCase): shared_secret = "garden" path = "/extensions/jira/uninstalled/" - def jwt_token_secret(self): + def jwt_token_secret(self) -> str: jira_signing_algorithm = "HS256" return jwt.encode( { @@ -35,7 +35,7 @@ def jwt_token_secret(self): headers={"alg": jira_signing_algorithm}, ) - def jwt_token_cdn(self): + def jwt_token_cdn(self) -> str: jira_signing_algorithm = "RS256" return jwt.encode( { diff --git a/tests/sentry/integrations/jira/test_webhooks.py b/tests/sentry/integrations/jira/test_webhooks.py index b3e5941e3486..830604dacd9c 100644 --- a/tests/sentry/integrations/jira/test_webhooks.py +++ b/tests/sentry/integrations/jira/test_webhooks.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Any, cast from unittest.mock import MagicMock, patch import responses @@ -26,8 +27,9 @@ from sentry.testutils.cases import APITestCase, TestCase from sentry.testutils.helpers.features import with_feature from sentry.testutils.silo import assume_test_silo_mode +from sentry.users.models.user import User from sentry.users.models.useremail import UserEmail -from sentry.viewer_context import ActorType, get_viewer_context +from sentry.viewer_context import ActorType, ViewerContext, get_viewer_context TOKEN = "JWT anexampletoken" @@ -51,18 +53,18 @@ def setUp(self) -> None: }, ) # Ensure this is region safe, and doesn't require the ORM integration model - self.integration = serialize_integration(integration=integration) + self.rpc_integration = serialize_integration(integration=integration) @patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound") def test_simple_assign(self, mock_sync_group_assignee_inbound: MagicMock) -> None: with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json") self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) mock_sync_group_assignee_inbound.assert_called_with( - self.integration, + self.rpc_integration, "jess@sentry.io", "APP-123", assign=True, @@ -81,7 +83,7 @@ def test_assign_use_email_api(self, mock_sync_group_assignee_inbound: MagicMock) with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json") data["issue"]["fields"]["assignee"]["emailAddress"] = "" @@ -100,7 +102,7 @@ def test_assign_use_email_api_error(self) -> None: with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json") data["issue"]["fields"]["assignee"]["emailAddress"] = "" @@ -113,7 +115,7 @@ def test_assign_use_email_api_error(self) -> None: def test_assign_missing_email(self, mock_sync_group_assignee_inbound: MagicMock) -> None: with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json") data["issue"]["fields"]["assignee"]["emailAddress"] = "" @@ -124,12 +126,12 @@ def test_assign_missing_email(self, mock_sync_group_assignee_inbound: MagicMock) def test_simple_deassign(self, mock_sync_group_assignee_inbound: MagicMock) -> None: with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): data = StubService.get_stub_data("jira", "edit_issue_no_assignee_payload.json") self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) mock_sync_group_assignee_inbound.assert_called_with( - self.integration, + self.rpc_integration, None, "APP-123", assign=False, @@ -142,44 +144,45 @@ def test_simple_deassign_assignee_missing( ) -> None: with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): data = StubService.get_stub_data("jira", "edit_issue_assignee_missing_payload.json") self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) # The payload carries no `updated`, so the ordering guard stays inert. mock_sync_group_assignee_inbound.assert_called_with( - self.integration, None, "APP-123", assign=False, provider_event_updated_at=None + self.rpc_integration, None, "APP-123", assign=False, provider_event_updated_at=None ) def _linked_group_for_assignee_sync(self) -> Group: with assume_test_silo_mode(SiloMode.CONTROL): OrganizationIntegration.objects.get( - organization_id=self.organization.id, integration_id=self.integration.id + organization_id=self.organization.id, integration_id=self.rpc_integration.id ).update(config={"sync_reverse_assignment": True}) group = self.create_group(project=self.project) self.create_integration_external_issue( - group=group, integration=self.integration, key="APP-123" + group=group, integration=self.rpc_integration, key="APP-123" ) return group - def _create_jira_member(self, email: str): + def _create_jira_member(self, email: str) -> User: member = self.create_user(email=email, is_superuser=False) self.create_member(organization=self.organization, user=member, teams=[self.team]) with assume_test_silo_mode(SiloMode.CONTROL): UserEmail.objects.filter(user=member).update(is_verified=True) return member - def _assignee_payload(self, email: str, updated: str) -> dict: + def _assignee_payload(self, email: str, updated: str) -> dict[str, Any]: data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json") data["issue"]["fields"]["assignee"]["emailAddress"] = email data["issue"]["fields"]["updated"] = updated - return data + # justification: the stub service returns the decoded fixture payload. + return cast("dict[str, Any]", data) - def _post_assignee_payload(self, data: dict) -> None: + def _post_assignee_payload(self, data: dict[str, Any]) -> None: with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) @@ -222,7 +225,7 @@ def test_unassignment_delivered_out_of_order_stays_unassigned(self) -> None: def test_simple_status_sync_inbound(self, mock_sync_status_inbound: MagicMock) -> None: with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ) as mock_get_integration_from_jwt: data = StubService.get_stub_data("jira", "edit_issue_status_payload.json") self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) @@ -258,15 +261,15 @@ def test_simple_status_sync_inbound(self, mock_sync_status_inbound: MagicMock) - @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) def test_status_sync_sets_viewer_context(self) -> None: - captured_contexts: list = [] + captured_contexts: list[ViewerContext] = [] - def capture_viewer_context(*args, **kwargs): - captured_contexts.append(get_viewer_context()) + def capture_viewer_context(*args: object, **kwargs: object) -> None: + captured_contexts.append(cast("ViewerContext", get_viewer_context())) with ( patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ), patch.object( IssueSyncIntegration, @@ -290,18 +293,18 @@ def test_adds_context_data( ) -> None: with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json") self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) - mock_set_tag.assert_any_call("integration_id", self.integration.id) + mock_set_tag.assert_any_call("integration_id", self.rpc_integration.id) mock_bind_org_context.assert_called_with(serialize_rpc_organization(self.organization)) def test_missing_changelog(self) -> None: with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): data = StubService.get_stub_data("jira", "changelog_missing.json") self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) @@ -309,12 +312,12 @@ def test_missing_changelog(self) -> None: def test_issue_moved_rekeys_external_issue(self) -> None: group = self.create_group() external_issue = self.create_integration_external_issue( - group=group, integration=self.integration, key="APP-123" + group=group, integration=self.rpc_integration, key="APP-123" ) with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): data = StubService.get_stub_data("jira", "moved_issue_payload.json") self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) @@ -332,7 +335,7 @@ def test_issue_moved_rekeys_before_status_sync( # first or the transition is dropped. group = self.create_group() external_issue = self.create_integration_external_issue( - group=group, integration=self.integration, key="APP-123" + group=group, integration=self.rpc_integration, key="APP-123" ) keys_when_synced = [] @@ -355,7 +358,7 @@ def test_issue_moved_rekeys_before_status_sync( with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) @@ -369,7 +372,7 @@ def test_issue_moved_opens_no_lifecycle_without_a_key_change( # `issue.updated` fires on every Jira edit, so the rekey lifecycle must only open # for the payloads that actually carry a key change. handle_issue_moved( - self.integration, {"changelog": {"items": [{"field": "status"}]}, "issue": {}} + self.rpc_integration, {"changelog": {"items": [{"field": "status"}]}, "issue": {}} ) assert mock_record_event.mock_calls == [] @@ -377,7 +380,7 @@ def test_issue_moved_opens_no_lifecycle_without_a_key_change( @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def test_issue_moved_records_lifecycle_success(self, mock_record_event: MagicMock) -> None: handle_issue_moved( - self.integration, StubService.get_stub_data("jira", "moved_issue_payload.json") + self.rpc_integration, StubService.get_stub_data("jira", "moved_issue_payload.json") ) assert_count_of_metric(mock_record_event, EventLifecycleOutcome.STARTED, 1) @@ -386,7 +389,7 @@ def test_issue_moved_records_lifecycle_success(self, mock_record_event: MagicMoc @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def test_issue_moved_halts_on_unusable_key_change(self, mock_record_event: MagicMock) -> None: handle_issue_moved( - self.integration, + self.rpc_integration, { "changelog": {"items": [{"field": "Key", "fromString": None, "toString": None}]}, "issue": {"key": None}, @@ -399,12 +402,12 @@ def test_issue_moved_halts_on_unusable_key_change(self, mock_record_event: Magic def test_issue_not_moved_leaves_key_alone(self) -> None: group = self.create_group() external_issue = self.create_integration_external_issue( - group=group, integration=self.integration, key="APP-123" + group=group, integration=self.rpc_integration, key="APP-123" ) with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): data = StubService.get_stub_data("jira", "edit_issue_status_payload.json") self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) @@ -417,7 +420,7 @@ def test_changelog_without_items(self) -> None: # both handlers and must not blow them up. with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): data = { "changelog": {"id": "10172"}, @@ -448,13 +451,13 @@ def raising_info(event_name: str, *args: object, **kwargs: object) -> None: with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json") self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) mock_sync_group_assignee_inbound.assert_called_with( - self.integration, + self.rpc_integration, "jess@sentry.io", "APP-123", assign=True, @@ -472,7 +475,7 @@ def test_payload_logging_logs_when_feature_enabled( ) -> None: with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", - return_value=self.integration, + return_value=self.rpc_integration, ): data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json") self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) @@ -489,13 +492,13 @@ class MockErroringJiraEndpoint(JiraWebhookBase): # kwarg we'd like to pass must already be an attibute of the class error = BaseException("unreachable") - def __init__(self, error: Exception = dummy_exception, *args, **kwargs): + def __init__(self, error: Exception = dummy_exception, *args: object, **kwargs: object) -> None: # We allow the error to be passed in so that we have access to it in the test for use # in equality checks self.error = error super().__init__(*args, **kwargs) - def get(self, request): + def get(self, request: object) -> Response: raise self.error @@ -516,7 +519,7 @@ def test_bad_request_errors(self, mock_capture_exception: MagicMock) -> None: @patch("sentry.utils.sdk.capture_exception") def test_atlassian_pen_testing_bot( self, mock_capture_exception: MagicMock, mock_logger: MagicMock - ): + ) -> None: mock_endpoint = MockErroringJiraEndpoint.as_view(error=MethodNotAllowed("GET")) request = self.make_request(method="GET") @@ -623,7 +626,7 @@ def test_replacement_error_messages(self, mock_super_handle_exception: MagicMock @patch("sentry.api.base.Endpoint.handle_exception_with_details", return_value=Response()) def test_unexpected_jira_errors( self, mock_super_handle_exception: MagicMock, mock_logger: MagicMock - ): + ) -> None: unknown_errors = [ ( Exception( diff --git a/tests/sentry/integrations/jira_server/test_integration.py b/tests/sentry/integrations/jira_server/test_integration.py index f12525c1d6d4..98327f103a67 100644 --- a/tests/sentry/integrations/jira_server/test_integration.py +++ b/tests/sentry/integrations/jira_server/test_integration.py @@ -5,6 +5,7 @@ import pytest import responses from django.urls import reverse +from requests import PreparedRequest from fixtures.integrations.jira.stub_client import StubJiraApiClient from fixtures.integrations.stub_service import StubService @@ -40,7 +41,7 @@ DEFAULT_ISSUE_TYPE_ID = 10000 -def get_client(): +def get_client() -> StubJiraApiClient: return StubJiraApiClient() @@ -728,7 +729,10 @@ def test_create_issue_labels_and_option(self) -> None: content_type="json", ) - def responder(request): + def responder( + request: PreparedRequest, + ) -> tuple[int, dict[str, str], str]: + assert request.body is not None body = orjson.loads(request.body) assert body["fields"]["labels"] == ["fuzzy", "bunnies"] assert body["fields"]["customfield_10200"] == {"value": "sad"} diff --git a/tests/sentry/integrations/jira_server/test_search.py b/tests/sentry/integrations/jira_server/test_search.py index 1febc6134af0..45a1eac2a1ec 100644 --- a/tests/sentry/integrations/jira_server/test_search.py +++ b/tests/sentry/integrations/jira_server/test_search.py @@ -3,8 +3,10 @@ import responses from django.urls import reverse +from requests import PreparedRequest from responses.matchers import query_param_matcher +from sentry.integrations.models.integration import Integration from sentry.testutils.cases import APITestCase from sentry.testutils.silo import control_silo_test @@ -14,7 +16,7 @@ @control_silo_test class JiraServerSearchEndpointTest(APITestCase): @cached_property - def integration(self): + def integration(self) -> Integration: return get_integration(self.organization, self.user) @responses.activate @@ -117,7 +119,10 @@ def test_assignee_search(self) -> None: json=[{"key": "HSP", "id": "10000"}], ) - def responder(request): + def responder( + request: PreparedRequest, + ) -> tuple[int, dict[str, str], str]: + assert request.url is not None query = parse_qs(urlparse(request.url).query) assert "HSP" == query["project"][0] assert "bob" == query["username"][0] diff --git a/tests/sentry/integrations/jira_server/test_ticket_action.py b/tests/sentry/integrations/jira_server/test_ticket_action.py index ba5f469a6090..89cb8302b66f 100644 --- a/tests/sentry/integrations/jira_server/test_ticket_action.py +++ b/tests/sentry/integrations/jira_server/test_ticket_action.py @@ -3,6 +3,7 @@ from sentry.integrations.jira_server import JiraServerCreateTicketAction, JiraServerIntegration from sentry.integrations.models.external_issue import ExternalIssue +from sentry.integrations.services.integration.serial import serialize_integration from sentry.models.rule import Rule from sentry.services.eventstore.models import GroupEvent from sentry.silo.base import SiloMode @@ -57,9 +58,9 @@ def trigger(self, event: GroupEvent, rule_object: Rule) -> object: return results[0].callback(event, futures=[rule_future]) def get_key(self, event: GroupEvent) -> str: - return ExternalIssue.objects.get_linked_issues(event, self.integration).values_list( - "key", flat=True - )[0] + return ExternalIssue.objects.get_linked_issues( + event, serialize_integration(self.integration) + ).values_list("key", flat=True)[0] @responses.activate() def test_ticket_rules(self) -> None: diff --git a/tests/sentry/integrations/jira_server/test_webhooks.py b/tests/sentry/integrations/jira_server/test_webhooks.py index 08670d512258..0082e4261f15 100644 --- a/tests/sentry/integrations/jira_server/test_webhooks.py +++ b/tests/sentry/integrations/jira_server/test_webhooks.py @@ -14,8 +14,9 @@ from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase from sentry.testutils.silo import assume_test_silo_mode +from sentry.users.models.user import User from sentry.users.models.useremail import UserEmail -from sentry.viewer_context import ActorType, get_viewer_context +from sentry.viewer_context import ActorType, ViewerContext, get_viewer_context from . import EXAMPLE_PAYLOAD, get_integration, link_group @@ -154,14 +155,14 @@ def _linked_group_for_assignee_sync(self) -> Group: link_group(self.organization, self.integration, group) return group - def _create_jira_member(self, email: str): + def _create_jira_member(self, email: str) -> User: member = self.create_user(email=email) self.create_member(organization=self.organization, user=member, teams=[self.team]) with assume_test_silo_mode(SiloMode.CONTROL): UserEmail.objects.filter(user=member).update(is_verified=True) return member - def _assignee_payload(self, assignee: dict | None, updated: str) -> dict: + def _assignee_payload(self, assignee: dict[str, str] | None, updated: str) -> dict[str, object]: return { "changelog": {"items": [{"field": "assignee"}], "id": 12345}, "issue": { @@ -232,7 +233,7 @@ def test_post_update_status(self, mock_sync: MagicMock) -> None: @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) @patch.object(JiraServerIntegration, "sync_status_inbound") def test_post_update_status_sets_viewer_context(self, mock_sync: MagicMock) -> None: - captured_contexts: list = [] + captured_contexts: list[ViewerContext | None] = [] def capture_context(*args: object, **kwargs: object) -> None: captured_contexts.append(get_viewer_context()) diff --git a/tests/sentry/integrations/msteams/test_action_state_change.py b/tests/sentry/integrations/msteams/test_action_state_change.py index 48bcdbf05cb4..e90e2ccc5172 100644 --- a/tests/sentry/integrations/msteams/test_action_state_change.py +++ b/tests/sentry/integrations/msteams/test_action_state_change.py @@ -133,7 +133,8 @@ def post_webhook( } webhook_url = reverse("sentry-integration-msteams-webhooks") - return self.client.post(webhook_url, data=payload) + response: Response[Any] = self.client.post(webhook_url, data=payload) + return response @patch("sentry.integrations.msteams.webhook.verify_signature", return_vaue=True) @patch("sentry.integrations.msteams.link_identity.sign") diff --git a/tests/sentry/integrations/msteams/test_client.py b/tests/sentry/integrations/msteams/test_client.py index 95610f503eed..0ecff66bb7da 100644 --- a/tests/sentry/integrations/msteams/test_client.py +++ b/tests/sentry/integrations/msteams/test_client.py @@ -148,6 +148,28 @@ def test_skips_client_secret_expiry_gauge_when_invalid(self) -> None: get_token_data() mock_metrics.gauge.assert_not_called() + @responses.activate + def test_get_user_conversation_id(self) -> None: + responses.add( + responses.POST, + "https://smba.trafficmanager.net/amer/v3/conversations", + json={"id": "some_conversation_id"}, + ) + assert ( + self.msteams_client.get_user_conversation_id("user_id", "tenant_id") + == "some_conversation_id" + ) + + @responses.activate + def test_get_user_conversation_id_missing_id_raises(self) -> None: + responses.add( + responses.POST, + "https://smba.trafficmanager.net/amer/v3/conversations", + json={}, + ) + with pytest.raises(KeyError): + self.msteams_client.get_user_conversation_id("user_id", "tenant_id") + @responses.activate def test_simple(self) -> None: self.msteams_client.get_channel_list("foobar") diff --git a/tests/sentry/integrations/msteams/test_integration.py b/tests/sentry/integrations/msteams/test_integration.py index 998c66fdcb4c..e8e23d204201 100644 --- a/tests/sentry/integrations/msteams/test_integration.py +++ b/tests/sentry/integrations/msteams/test_integration.py @@ -146,7 +146,10 @@ def _complete_install(self, installation_type: str) -> str: integration=integration, organization_id=self.organization.id ).exists() - return responses.calls[1].request.body.decode("utf-8") + body = responses.calls[1].request.body + if not isinstance(body, bytes): + raise TypeError("Teams installation request has no body") + return body.decode("utf-8") @responses.activate def test_team_installation(self) -> None: diff --git a/tests/sentry/integrations/msteams/test_message_builder.py b/tests/sentry/integrations/msteams/test_message_builder.py index bdf8a1c8b357..ea19baa33a9b 100644 --- a/tests/sentry/integrations/msteams/test_message_builder.py +++ b/tests/sentry/integrations/msteams/test_message_builder.py @@ -46,6 +46,8 @@ MSTeamsNotificationsMessageBuilder, ) from sentry.integrations.msteams.utils import ACTION_TYPE +from sentry.integrations.services.integration import RpcIntegration +from sentry.integrations.services.integration.serial import serialize_integration from sentry.models.group import GroupStatus from sentry.models.groupassignee import GroupAssignee from sentry.models.organization import Organization @@ -107,6 +109,7 @@ def setUp(self) -> None: external_id="f3ll0wsh1p", metadata={}, ) + self.rpc_integration: RpcIntegration = serialize_integration(self.integration) self.create_organization_integration( organization_id=self.org.id, integration=self.integration ) @@ -336,7 +339,10 @@ def test_issue_message_builder(self) -> None: self.event1.data["type"] = self.group1.data["type"] = "error" issue_card = MSTeamsIssueMessageBuilder( - group=self.group1, event=self.event1, rules=self.origins, integration=self.integration + group=self.group1, + event=self.event1, + rules=self.origins, + integration=self.rpc_integration, ).build_group_card() body = issue_card["body"] @@ -363,6 +369,7 @@ def test_issue_message_builder(self) -> None: issue_id_and_rule = footer["columns"][1]["items"][0] assert _is_text_block(issue_id_and_rule) + assert self.group1.qualified_short_id is not None assert self.group1.qualified_short_id in issue_id_and_rule["text"] assert "rule1" in issue_id_and_rule["text"] assert "+1 other" in issue_id_and_rule["text"] @@ -437,7 +444,7 @@ def test_issue_action_payload_includes_workflow_ids_only(self) -> None: group=self.group1, event=self.event1, rules=[rule], - integration=self.integration, + integration=self.rpc_integration, ).generate_action_payload(ACTION_TYPE.RESOLVE)["payload"] assert "rules" not in payload @@ -449,7 +456,10 @@ def test_issue_description_uses_event(self) -> None: self.event1.data["type"] = self.group1.data["type"] = "error" issue_card = MSTeamsIssueMessageBuilder( - group=self.group1, event=self.event1, rules=self.origins, integration=self.integration + group=self.group1, + event=self.event1, + rules=self.origins, + integration=self.rpc_integration, ).build_group_card() description = issue_card["body"][1] @@ -461,7 +471,10 @@ def test_issue_description_falls_back_to_group(self) -> None: self.group1.data["type"] = "error" issue_card = MSTeamsIssueMessageBuilder( - group=self.group1, event=self.event1, rules=self.origins, integration=self.integration + group=self.group1, + event=self.event1, + rules=self.origins, + integration=self.rpc_integration, ).build_group_card() description = issue_card["body"][1] @@ -470,7 +483,10 @@ def test_issue_description_falls_back_to_group(self) -> None: def test_issue_without_description(self) -> None: issue_card = MSTeamsIssueMessageBuilder( - group=self.group1, event=self.event1, rules=self.origins, integration=self.integration + group=self.group1, + event=self.event1, + rules=self.origins, + integration=self.rpc_integration, ).build_group_card() assert 3 == len(issue_card["body"]) @@ -478,7 +494,7 @@ def test_issue_without_description(self) -> None: def test_issue_with_only_one_rule(self) -> None: one_rule = self.origins[:1] issue_card = MSTeamsIssueMessageBuilder( - group=self.group1, event=self.event1, rules=one_rule, integration=self.integration + group=self.group1, event=self.event1, rules=one_rule, integration=self.rpc_integration ).build_group_card() assert _is_column_set_block(issue_card["body"][1]) @@ -494,7 +510,10 @@ def test_resolved_issue_message(self) -> None: self.group1.save() issue_card = MSTeamsIssueMessageBuilder( - group=self.group1, event=self.event1, rules=self.origins, integration=self.integration + group=self.group1, + event=self.event1, + rules=self.origins, + integration=self.rpc_integration, ).build_group_card() assert _is_container_block(issue_card["body"][2]) @@ -510,7 +529,10 @@ def test_archived_issue_message(self) -> None: self.group1.substatus = None issue_card = MSTeamsIssueMessageBuilder( - group=self.group1, event=self.event1, rules=self.origins, integration=self.integration + group=self.group1, + event=self.event1, + rules=self.origins, + integration=self.rpc_integration, ).build_group_card() assert _is_container_block(issue_card["body"][2]) @@ -525,7 +547,10 @@ def test_assigned_issue_message(self) -> None: GroupAssignee.objects.assign(self.group1, self.user) issue_card = MSTeamsIssueMessageBuilder( - group=self.group1, event=self.event1, rules=self.origins, integration=self.integration + group=self.group1, + event=self.event1, + rules=self.origins, + integration=self.rpc_integration, ).build_group_card() body = issue_card["body"] @@ -569,25 +594,31 @@ def test_simple(self) -> None: assert 4 == len(body) title = body[0] + assert _is_text_block(title) assert "Notification Title with some\\_value" == title["text"] group_title = body[1] + assert _is_text_block(group_title) assert "[My Title]" in group_title["text"] assert TextSize.LARGE == group_title["size"] assert TextWeight.BOLDER == group_title["weight"] description = body[2] + assert _is_text_block(description) assert "Message Description" in description["text"] assert TextSize.MEDIUM == description["size"] footer = body[3] - assert 2 == len(footer) + assert _is_column_set_block(footer) + assert 2 == len(footer["columns"]) logo = footer["columns"][0]["items"][0] + assert _is_image_block(logo) assert "Image" == logo["type"] assert "20px" == logo["height"] footer_text = footer["columns"][1]["items"][0] + assert _is_text_block(footer_text) assert "Notification Footer" in footer_text["text"] assert TextSize.SMALL == footer_text["size"] diff --git a/tests/sentry/integrations/msteams/test_notify_action.py b/tests/sentry/integrations/msteams/test_notify_action.py index fad7ccfa61d3..611fc90dac5a 100644 --- a/tests/sentry/integrations/msteams/test_notify_action.py +++ b/tests/sentry/integrations/msteams/test_notify_action.py @@ -1,5 +1,6 @@ import re import time +from typing import Any from unittest import mock from unittest.mock import MagicMock, patch @@ -13,6 +14,7 @@ from sentry.integrations.msteams.actions.notification import MsTeamsNotifyServiceAction from sentry.integrations.msteams.analytics import MSTeamsIntegrationNotificationSent from sentry.integrations.types import EventLifecycleOutcome +from sentry.services.eventstore.models import GroupEvent from sentry.testutils.asserts import assert_slo_metric from sentry.testutils.cases import PerformanceIssueTestCase, RuleTestCase from sentry.testutils.helpers.analytics import ( @@ -29,6 +31,11 @@ class MsTeamsNotifyActionTest(RuleTestCase, PerformanceIssueTestCase): rule_cls = MsTeamsNotifyServiceAction + def get_msteams_rule(self, **kwargs: Any) -> MsTeamsNotifyServiceAction: + rule = self.get_rule(**kwargs) + assert isinstance(rule, MsTeamsNotifyServiceAction) + return rule + def setUp(self) -> None: event = self.get_event() @@ -56,9 +63,9 @@ def assert_form_valid( @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @mock.patch("sentry.analytics.record") def test_applies_correctly(self, mock_record: MagicMock, mock_record_event: MagicMock) -> None: - event = self.get_event() + event = self.get_group_event() - rule = self.get_rule( + rule = self.get_msteams_rule( data={"team": self.integration.id, "channel": "Naboo", "channel_id": "nb"} ) @@ -116,9 +123,9 @@ def test_applies_correctly(self, mock_record: MagicMock, mock_record_event: Magi @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @mock.patch("sentry.analytics.record") def test_client_error(self, mock_record: MagicMock, mock_record_event: MagicMock) -> None: - event = self.get_event() + event = self.get_group_event() - rule = self.get_rule( + rule = self.get_msteams_rule( data={"team": self.integration.id, "channel": "Naboo", "channel_id": "nb"} ) @@ -176,9 +183,9 @@ def test_client_error(self, mock_record: MagicMock, mock_record_event: MagicMock @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @mock.patch("sentry.analytics.record") def test_halt(self, mock_record: MagicMock, mock_record_event: MagicMock) -> None: - event = self.get_event() + event = self.get_group_event() - rule = self.get_rule( + rule = self.get_msteams_rule( data={"team": self.integration.id, "channel": "Naboo", "channel_id": "nb"} ) @@ -252,7 +259,7 @@ def test_applies_correctly_generic_issue( ) group_event = event.for_group(event.groups[0]) - rule = self.get_rule( + rule = self.get_msteams_rule( data={"team": self.integration.id, "channel": "Hellboy", "channel_id": "nb"} ) results = list(rule.after(event=group_event)) @@ -265,7 +272,7 @@ def test_applies_correctly_generic_issue( json={}, ) - results[0].callback(event, futures=[]) + results[0].callback(group_event, futures=[]) data = orjson.loads(responses.calls[0].request.body) assert "attachments" in data @@ -294,11 +301,13 @@ def test_applies_correctly_performance_issue( self, occurrence: MagicMock, mock_record_event: MagicMock ) -> None: event = self.create_performance_issue() + assert event.group is not None + group_event = event if isinstance(event, GroupEvent) else event.for_group(event.group) - rule = self.get_rule( + rule = self.get_msteams_rule( data={"team": self.integration.id, "channel": "Naboo", "channel_id": "nb"} ) - results = list(rule.after(event=event)) + results = list(rule.after(event=group_event)) assert len(results) == 1 responses.add( @@ -308,7 +317,7 @@ def test_applies_correctly_performance_issue( json={}, ) - results[0].callback(event, futures=[]) + results[0].callback(group_event, futures=[]) data = orjson.loads(responses.calls[0].request.body) assert "attachments" in data @@ -329,7 +338,7 @@ def test_applies_correctly_performance_issue( assert_slo_metric(mock_record_event, EventLifecycleOutcome.SUCCESS) def test_render_label(self) -> None: - rule = self.get_rule(data={"team": self.integration.id, "channel": "Tatooine"}) + rule = self.get_msteams_rule(data={"team": self.integration.id, "channel": "Tatooine"}) assert rule.render_label() == "Send a notification to the Galactic Empire Team to Tatooine" @@ -337,13 +346,13 @@ def test_render_label_without_integration(self) -> None: with assume_test_silo_mode_of(Integration): self.integration.delete() - rule = self.get_rule(data={"team": self.integration.id, "channel": "Coruscant"}) + rule = self.get_msteams_rule(data={"team": self.integration.id, "channel": "Coruscant"}) assert rule.render_label() == "Send a notification to the [removed] Team to Coruscant" @responses.activate def test_valid_channel_selected(self) -> None: - rule = self.get_rule(data={"team": self.integration.id, "channel": "Death Star"}) + rule = self.get_msteams_rule(data={"team": self.integration.id, "channel": "Death Star"}) channels = [{"id": "d_s", "name": "Death Star"}] @@ -358,7 +367,7 @@ def test_valid_channel_selected(self) -> None: @responses.activate def test_valid_member_selected(self) -> None: - rule = self.get_rule(data={"team": self.integration.id, "channel": "Darth Vader"}) + rule = self.get_msteams_rule(data={"team": self.integration.id, "channel": "Darth Vader"}) channels = [{"id": "i_s_d", "name": "Imperial Star Destroyer"}] @@ -387,7 +396,7 @@ def test_valid_member_selected(self) -> None: @responses.activate def test_invalid_channel_selected(self) -> None: - rule = self.get_rule(data={"team": self.integration.id, "channel": "Alderaan"}) + rule = self.get_msteams_rule(data={"team": self.integration.id, "channel": "Alderaan"}) channels = [{"name": "Hoth", "id": "hh"}] diff --git a/tests/sentry/integrations/opsgenie/test_integration.py b/tests/sentry/integrations/opsgenie/test_integration.py index b36e200ccb34..7ee95c98be30 100644 --- a/tests/sentry/integrations/opsgenie/test_integration.py +++ b/tests/sentry/integrations/opsgenie/test_integration.py @@ -11,6 +11,7 @@ from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.pipeline import IntegrationPipeline +from sentry.models.organization import Organization from sentry.shared_integrations.exceptions import ApiRateLimitedError, ApiUnauthorized from sentry.testutils.cases import APITestCase, TestCase from sentry.testutils.helpers.features import with_feature @@ -135,26 +136,26 @@ def setUp(self) -> None: super().setUp() self.login_as(self.user) - def _get_pipeline_url(self, organization=None) -> str: + def _get_pipeline_url(self, organization: Organization | None = None) -> str: organization = organization or self.organization return reverse( self.endpoint, args=[organization.slug, IntegrationPipeline.pipeline_name], ) - def _initialize_pipeline(self, organization=None) -> Any: + def _initialize_pipeline(self, organization: Organization | None = None) -> Any: return self.client.post( self._get_pipeline_url(organization), data={"action": "initialize", "provider": "opsgenie"}, format="json", ) - def _advance_step(self, data: dict[str, Any], organization=None) -> Any: + def _advance_step(self, data: dict[str, Any], organization: Organization | None = None) -> Any: return self.client.post(self._get_pipeline_url(organization), data=data, format="json") def _install( self, - organization=None, + organization: Organization | None = None, *, base_url: str = "https://api.opsgenie.com/", provider: str = "cool-name", diff --git a/tests/sentry/integrations/opsgenie/test_notify_action.py b/tests/sentry/integrations/opsgenie/test_notify_action.py index a651492759f4..a28915b0f1f2 100644 --- a/tests/sentry/integrations/opsgenie/test_notify_action.py +++ b/tests/sentry/integrations/opsgenie/test_notify_action.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, patch import orjson @@ -5,6 +6,7 @@ import responses from sentry.analytics.events.alert_sent import AlertSentEvent +from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.opsgenie.actions import OpsgenieNotifyTeamAction, OpsgenieNotifyTeamForm from sentry.integrations.opsgenie.analytics import OpsgenieIntegrationNotificationSent @@ -32,7 +34,7 @@ class OpsgenieNotifyTeamTest(RuleTestCase, PerformanceIssueTestCase): rule_cls = OpsgenieNotifyTeamAction - def _create_integration(self, name): + def _create_integration(self, name: str) -> Integration: integration = self.create_provider_integration( provider="opsgenie", name=name, external_id=name, metadata=METADATA ) @@ -51,6 +53,11 @@ def setUp(self) -> None: self.org_integration.save() self.installation = self.integration.get_installation(self.organization.id) + def get_opsgenie_rule(self, **kwargs: Any) -> OpsgenieNotifyTeamAction: + rule = self.get_rule(**kwargs) + assert isinstance(rule, OpsgenieNotifyTeamAction) + return rule + @responses.activate @patch("sentry.analytics.record") def test_applies_correctly(self, mock_record: MagicMock) -> None: @@ -63,10 +70,14 @@ def test_applies_correctly(self, mock_record: MagicMock) -> None: }, project_id=self.project.id, ) + assert event.group is not None + group_event = event.for_group(event.group) - rule = self.get_rule(data={"account": self.integration.id, "team": self.team1["id"]}) + rule = self.get_opsgenie_rule( + data={"account": self.integration.id, "team": self.team1["id"]} + ) notification_uuid = "123e4567-e89b-12d3-a456-426614174000" - results = list(rule.after(event=event, notification_uuid=notification_uuid)) + results = list(rule.after(event=group_event, notification_uuid=notification_uuid)) assert len(results) == 1 responses.add( @@ -76,7 +87,7 @@ def test_applies_correctly(self, mock_record: MagicMock) -> None: status=202, ) - results[0].callback(event, futures=[]) + results[0].callback(group_event, futures=[]) data = orjson.loads(responses.calls[0].request.body) assert event.group is not None @@ -107,7 +118,7 @@ def test_applies_correctly(self, mock_record: MagicMock) -> None: ) def test_render_label(self) -> None: - rule = self.get_rule( + rule = self.get_opsgenie_rule( data={"account": self.integration.id, "team": self.team1["id"], "priority": "P2"} ) @@ -120,7 +131,7 @@ def test_render_label_without_integration(self) -> None: with assume_test_silo_mode(SiloMode.CONTROL): self.integration.delete() - rule = self.get_rule( + rule = self.get_opsgenie_rule( data={"account": self.integration.id, "team": self.team1["id"], "priority": "P2"} ) @@ -131,7 +142,9 @@ def test_render_label_without_integration(self) -> None: ) def test_render_label_no_priority(self) -> None: - rule = self.get_rule(data={"account": self.integration.id, "team": self.team1["id"]}) + rule = self.get_opsgenie_rule( + data={"account": self.integration.id, "team": self.team1["id"]} + ) assert ( rule.render_label() @@ -151,15 +164,19 @@ def test_valid_team_options(self) -> None: oi.config = {"team_table": [team2]} oi.save() - rule = self.get_rule(data={"account": self.integration.id}) + rule = self.get_opsgenie_rule(data={"account": self.integration.id}) team_options = rule.get_teams() assert team_options == [(team2["id"], team2["team"])] - assert "choice" == rule.form_fields["team"]["type"] - assert team_options == rule.form_fields["team"]["choices"] + team_field = rule.form_fields["team"] + assert isinstance(team_field, dict) + assert "choice" == team_field["type"] + assert team_options == team_field["choices"] @responses.activate def test_valid_team_selected(self) -> None: - rule = self.get_rule(data={"account": self.integration.id, "team": self.team1["id"]}) + rule = self.get_opsgenie_rule( + data={"account": self.integration.id, "team": self.team1["id"]} + ) form = OpsgenieNotifyTeamForm( rule.data, org_id=rule.project.organization_id, @@ -169,7 +186,7 @@ def test_valid_team_selected(self) -> None: assert form.is_valid() def test_invalid_int_id(self) -> None: - rule = self.get_rule(data={"account": "blah", "team": self.team1["id"]}) + rule = self.get_opsgenie_rule(data={"account": "blah", "team": self.team1["id"]}) form = OpsgenieNotifyTeamForm( rule.data, org_id=rule.project.organization_id, @@ -189,9 +206,9 @@ def test_notifies_with_multiple_og_accounts(self) -> None: org_integration.config = {"team_table": [team2]} org_integration.save() self.installation = integration.get_installation(self.organization.id) - event = self.get_event() + event = self.get_group_event() - rule = self.get_rule(data={"account": integration.id, "team": team2["id"]}) + rule = self.get_opsgenie_rule(data={"account": integration.id, "team": team2["id"]}) results = list(rule.after(event=event)) assert len(results) == 1 @@ -222,7 +239,7 @@ def test_invalid_team_selected(self) -> None: org_integration.save() self.installation = integration.get_installation(self.organization.id) - rule = self.get_rule(data={"account": self.integration.id, "team": team2["id"]}) + rule = self.get_opsgenie_rule(data={"account": self.integration.id, "team": team2["id"]}) form = OpsgenieNotifyTeamForm( rule.data, @@ -244,8 +261,8 @@ def test_team_deleted(self, mock_logger: MagicMock) -> None: org_integration.config = {"team_table": [team2]} org_integration.save() self.installation = integration.get_installation(self.organization.id) - event = self.get_event() - rule = self.get_rule(data={"account": integration.id, "team": team2["id"]}) + event = self.get_group_event() + rule = self.get_opsgenie_rule(data={"account": integration.id, "team": team2["id"]}) results = list(rule.after(event=event)) assert len(results) == 1 @@ -253,8 +270,8 @@ def test_team_deleted(self, mock_logger: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CONTROL): org_integration.config = {"team_table": []} org_integration.save() - event = self.get_event() - rule = self.get_rule(data={"account": integration.id, "team": team2["id"]}) + event = self.get_group_event() + rule = self.get_opsgenie_rule(data={"account": integration.id, "team": team2["id"]}) results = list(rule.after(event=event)) assert len(results) == 0 @@ -268,8 +285,10 @@ def test_integration_deleted(self, mock_logger: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CONTROL): self.integration.delete() - event = self.get_event() - rule = self.get_rule(data={"account": self.integration.id, "team": self.team1["id"]}) + event = self.get_group_event() + rule = self.get_opsgenie_rule( + data={"account": self.integration.id, "team": self.team1["id"]} + ) results = list(rule.after(event=event)) assert len(results) == 0 @@ -281,9 +300,11 @@ def test_integration_deleted(self, mock_logger: MagicMock) -> None: @patch("sentry.integrations.opsgenie.actions.notification.logger") @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @responses.activate - def test_api_error(self, mock_record, mock_logger: MagicMock) -> None: - event = self.get_event() - rule = self.get_rule(data={"account": self.integration.id, "team": self.team1["id"]}) + def test_api_error(self, mock_record: MagicMock, mock_logger: MagicMock) -> None: + event = self.get_group_event() + rule = self.get_opsgenie_rule( + data={"account": self.integration.id, "team": self.team1["id"]} + ) results = list(rule.after(event=event)) assert len(results) == 1 diff --git a/tests/sentry/integrations/pagerduty/test_integration.py b/tests/sentry/integrations/pagerduty/test_integration.py index 6e108af7d73d..b2eb56c4e1a9 100644 --- a/tests/sentry/integrations/pagerduty/test_integration.py +++ b/tests/sentry/integrations/pagerduty/test_integration.py @@ -11,6 +11,7 @@ from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.pagerduty.utils import add_service, get_services from sentry.integrations.pipeline import IntegrationPipeline +from sentry.models.organization import Organization from sentry.shared_integrations.exceptions import IntegrationError from sentry.testutils.cases import APITestCase, TestCase from sentry.testutils.silo import control_silo_test @@ -128,21 +129,21 @@ def setUp(self) -> None: self.app_id = "app_1" options.set("pagerduty.app-id", self.app_id) - def _get_pipeline_url(self, organization=None) -> str: + def _get_pipeline_url(self, organization: Organization | None = None) -> str: organization = organization or self.organization return reverse( self.endpoint, args=[organization.slug, IntegrationPipeline.pipeline_name], ) - def _initialize_pipeline(self, organization=None) -> Any: + def _initialize_pipeline(self, organization: Organization | None = None) -> Any: return self.client.post( self._get_pipeline_url(organization), data={"action": "initialize", "provider": "pagerduty"}, format="json", ) - def _advance_step(self, data: dict[str, Any], organization=None) -> Any: + def _advance_step(self, data: dict[str, Any], organization: Organization | None = None) -> Any: return self.client.post(self._get_pipeline_url(organization), data=data, format="json") def _make_config( @@ -165,7 +166,7 @@ def _make_config( } return orjson.dumps(config).decode() - def _install(self, organization=None, **config: str) -> Any: + def _install(self, organization: Organization | None = None, **config: str) -> Any: self._initialize_pipeline(organization) return self._advance_step({"config": self._make_config(**config)}, organization) diff --git a/tests/sentry/integrations/pagerduty/test_notification.py b/tests/sentry/integrations/pagerduty/test_notification.py index 86d47be5cdeb..a93545488f54 100644 --- a/tests/sentry/integrations/pagerduty/test_notification.py +++ b/tests/sentry/integrations/pagerduty/test_notification.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, patch import orjson @@ -12,6 +13,7 @@ from sentry.integrations.pagerduty.client import PAGERDUTY_SUMMARY_MAX_LENGTH from sentry.integrations.pagerduty.utils import add_service from sentry.integrations.types import EventLifecycleOutcome +from sentry.services.eventstore.models import GroupEvent from sentry.silo.base import SiloMode from sentry.testutils.asserts import assert_halt_metric, assert_slo_metric from sentry.testutils.cases import PerformanceIssueTestCase, RuleTestCase @@ -65,6 +67,11 @@ def setUp(self) -> None: ) self.installation = self.integration.get_installation(self.organization.id) + def get_pagerduty_rule(self, **kwargs: Any) -> PagerDutyNotifyServiceAction: + rule = self.get_rule(**kwargs) + assert isinstance(rule, PagerDutyNotifyServiceAction) + return rule + @responses.activate @patch("sentry.analytics.record") def test_applies_correctly(self, mock_record: MagicMock) -> None: @@ -77,8 +84,10 @@ def test_applies_correctly(self, mock_record: MagicMock) -> None: }, project_id=self.project.id, ) + assert event.group is not None + group_event = event.for_group(event.group) - rule = self.get_rule( + rule = self.get_pagerduty_rule( data={ "account": self.integration.id, "service": str(self.service["id"]), @@ -90,7 +99,7 @@ def test_applies_correctly(self, mock_record: MagicMock) -> None: results = list( rule.after( - event=event, + event=group_event, notification_uuid=notification_uuid, ) ) @@ -109,7 +118,7 @@ def test_applies_correctly(self, mock_record: MagicMock) -> None: context=create_notification_action_context(self.project_rule), kwargs=results[0].kwargs, ) - results[0].callback(event, futures=[rule_future]) + results[0].callback(group_event, futures=[rule_future]) data = orjson.loads(responses.calls[0].request.body) assert data["event_action"] == "trigger" @@ -146,8 +155,12 @@ def test_applies_correctly(self, mock_record: MagicMock) -> None: @responses.activate def test_applies_correctly_performance_issue(self) -> None: event = self.create_performance_issue() - rule = self.get_rule(data={"account": self.integration.id, "service": self.service["id"]}) - results = list(rule.after(event=event)) + assert event.group is not None + group_event = event if isinstance(event, GroupEvent) else event.for_group(event.group) + rule = self.get_pagerduty_rule( + data={"account": self.integration.id, "service": self.service["id"]} + ) + results = list(rule.after(event=group_event)) assert len(results) == 1 responses.add( @@ -163,7 +176,7 @@ def test_applies_correctly_performance_issue(self) -> None: context=create_notification_action_context(self.project_rule), kwargs=results[0].kwargs, ) - results[0].callback(event, futures=[rule_future]) + results[0].callback(group_event, futures=[rule_future]) data = orjson.loads(responses.calls[0].request.body) perf_issue_title = "N+1 Query" @@ -185,7 +198,9 @@ def test_applies_correctly_generic_issue(self) -> None: group_event = event.for_group(event.groups[0]) group_event.occurrence = occurrence - rule = self.get_rule(data={"account": self.integration.id, "service": self.service["id"]}) + rule = self.get_pagerduty_rule( + data={"account": self.integration.id, "service": self.service["id"]} + ) results = list(rule.after(event=group_event)) assert len(results) == 1 @@ -222,7 +237,9 @@ def test_truncates_summary(self) -> None: }, project_id=self.project.id, ) - rule = self.get_rule( + assert event.group is not None + group_event = event.for_group(event.group) + rule = self.get_pagerduty_rule( data={ "account": self.integration.id, "service": str(self.service["id"]), @@ -230,7 +247,7 @@ def test_truncates_summary(self) -> None: } ) - results = list(rule.after(event=event)) + results = list(rule.after(event=group_event)) responses.add( method=responses.POST, @@ -245,7 +262,7 @@ def test_truncates_summary(self) -> None: context=create_notification_action_context(self.project_rule), kwargs=results[0].kwargs, ) - results[0].callback(event, futures=[rule_future]) + results[0].callback(group_event, futures=[rule_future]) data = orjson.loads(responses.calls[0].request.body) assert len(data["payload"]["summary"]) == PAGERDUTY_SUMMARY_MAX_LENGTH @@ -256,7 +273,7 @@ def test_render_label_without_severity(self) -> None: "account": self.integration.id, "service": self.service["id"], } - rule = self.get_rule(data=rule_data) + rule = self.get_pagerduty_rule(data=rule_data) assert ( rule.render_label() @@ -269,7 +286,7 @@ def test_render_label(self) -> None: "service": self.service["id"], "severity": "warning", } - rule = self.get_rule(data=rule_data) + rule = self.get_pagerduty_rule(data=rule_data) assert ( rule.render_label() @@ -285,7 +302,7 @@ def test_render_label_without_integration(self) -> None: "service": self.service["id"], "severity": "default", } - rule = self.get_rule(data=rule_data) + rule = self.get_pagerduty_rule(data=rule_data) assert ( rule.render_label() @@ -298,7 +315,7 @@ def test_valid_service_options(self) -> None: # need to create a new project and set it as self.project because rules are # project based and we want the project in this case to be associated with the - # new organization when we call `self.get_rule()` + # new organization when we call `self.get_pagerduty_rule()` new_project = self.create_project(name="new proj", organization=new_org) self.project = new_project @@ -311,16 +328,20 @@ def test_valid_service_options(self) -> None: integration_key="new_service_key", ) - rule = self.get_rule(data={"account": self.integration.id}) + rule = self.get_pagerduty_rule(data={"account": self.integration.id}) service_options = rule.get_services() assert service_options == [(new_service["id"], new_service["service_name"])] - assert "choice" == rule.form_fields["service"]["type"] - assert service_options == rule.form_fields["service"]["choices"] + service_field = rule.form_fields["service"] + assert isinstance(service_field, dict) + assert "choice" == service_field["type"] + assert service_options == service_field["choices"] @responses.activate def test_valid_service_selected(self) -> None: - rule = self.get_rule(data={"account": self.integration.id, "service": self.service["id"]}) + rule = self.get_pagerduty_rule( + data={"account": self.integration.id, "service": self.service["id"]} + ) form = PagerDutyNotifyServiceForm( rule.data, @@ -356,9 +377,9 @@ def test_notifies_with_multiple_pd_accounts(self) -> None: ) self.installation = integration.get_installation(self.organization.id) - event = self.get_event() + event = self.get_group_event() - rule = self.get_rule(data={"account": integration.id, "service": service["id"]}) + rule = self.get_pagerduty_rule(data={"account": integration.id, "service": service["id"]}) results = list(rule.after(event=event)) assert len(results) == 1 @@ -403,7 +424,9 @@ def test_invalid_service_selected(self, mock_record: MagicMock) -> None: ) self.installation = integration.get_installation(self.organization.id) - rule = self.get_rule(data={"account": self.integration.id, "service": str(service["id"])}) + rule = self.get_pagerduty_rule( + data={"account": self.integration.id, "service": str(service["id"])} + ) form = PagerDutyNotifyServiceForm( rule.data, diff --git a/tests/sentry/integrations/perforce/test_client.py b/tests/sentry/integrations/perforce/test_client.py index 36569c80885a..dd6a7a25ddff 100644 --- a/tests/sentry/integrations/perforce/test_client.py +++ b/tests/sentry/integrations/perforce/test_client.py @@ -1,6 +1,8 @@ import os from datetime import datetime, timezone +from typing import Any from unittest import mock +from unittest.mock import MagicMock import pytest @@ -49,7 +51,7 @@ def setUp(self) -> None: self.addCleanup(patcher.stop) @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_blame_for_files_success(self, mock_p4_class): + def test_get_blame_for_files_success(self, mock_p4_class: MagicMock) -> None: """Test get_blame_for_files returns commit info for files""" # Mock P4 instance mock_p4 = mock.Mock() @@ -119,7 +121,7 @@ def test_get_blame_for_files_success(self, mock_p4_class): assert blames[1].commit.committedDate == datetime(2021, 1, 2, 0, 0, 0, tzinfo=timezone.utc) @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_blame_for_files_no_changelist(self, mock_p4_class): + def test_get_blame_for_files_no_changelist(self, mock_p4_class: MagicMock) -> None: """Test get_blame_for_files handles files with no changelist""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -141,7 +143,7 @@ def test_get_blame_for_files_no_changelist(self, mock_p4_class): assert len(blames) == 0 @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_blame_for_files_with_stream(self, mock_p4_class): + def test_get_blame_for_files_with_stream(self, mock_p4_class: MagicMock) -> None: """Test get_blame_for_files handles files with stream (ref). Note: The stream name in ref is ignored because the path from SourceLineInfo @@ -186,7 +188,9 @@ def test_get_blame_for_files_with_stream(self, mock_p4_class): @mock.patch("sentry.integrations.perforce.client.P4") @mock.patch("sentry.integrations.perforce.client.logger") - def test_get_blame_for_files_p4_exception(self, mock_logger, mock_p4_class): + def test_get_blame_for_files_p4_exception( + self, mock_logger: MagicMock, mock_p4_class: MagicMock + ) -> None: """Test get_blame_for_files handles P4 exceptions gracefully""" from sentry.integrations.perforce.p4protocol import P4Exception @@ -236,7 +240,9 @@ def test_get_blame_for_files_p4_exception(self, mock_logger, mock_p4_class): @mock.patch("sentry.integrations.perforce.client.P4") @mock.patch("sentry.integrations.perforce.client.logger") - def test_get_blame_for_files_invalid_time(self, mock_logger, mock_p4_class): + def test_get_blame_for_files_invalid_time( + self, mock_logger: MagicMock, mock_p4_class: MagicMock + ) -> None: """Test get_blame_for_files handles invalid time values gracefully""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -276,7 +282,7 @@ def test_get_blame_for_files_invalid_time(self, mock_logger, mock_p4_class): assert any("invalid_time_value" in call for call in warning_calls) @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_depots(self, mock_p4_class): + def test_get_depots(self, mock_p4_class: MagicMock) -> None: """Test get_depots returns depot list""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -303,7 +309,7 @@ def test_get_depots(self, mock_p4_class): assert depots[1]["description"] == "Project stream depot" @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_user_existing(self, mock_p4_class): + def test_get_user_existing(self, mock_p4_class: MagicMock) -> None: """Test get_user returns user info for existing user""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -330,7 +336,7 @@ def test_get_user_existing(self, mock_p4_class): assert user_info["full_name"] == "John Doe" @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_user_nonexistent(self, mock_p4_class): + def test_get_user_nonexistent(self, mock_p4_class: MagicMock) -> None: """Test get_user returns None for non-existent user""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -351,7 +357,7 @@ def test_get_user_nonexistent(self, mock_p4_class): assert user_info is None @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_changes_without_range(self, mock_p4_class): + def test_get_changes_without_range(self, mock_p4_class: MagicMock) -> None: """Test get_changes returns changelists without start/end range""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -387,7 +393,7 @@ def test_get_changes_without_range(self, mock_p4_class): assert changes[1]["user"] == "johndoe" @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_changes_with_end_cl(self, mock_p4_class): + def test_get_changes_with_end_cl(self, mock_p4_class: MagicMock) -> None: """Test an end-only request (initial release, no previous changelist). The ``-e`` flag is deliberately NOT used: ``p4 changes -e N`` returns @@ -424,7 +430,7 @@ def test_get_changes_with_end_cl(self, mock_p4_class): assert len(changes) == 2 @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_changes_with_start_cl(self, mock_p4_class): + def test_get_changes_with_start_cl(self, mock_p4_class: MagicMock) -> None: """Test get_changes bounds the lower end exclusively via the revision range. An exclusive lower bound (changes strictly after start_cl) becomes an @@ -460,7 +466,7 @@ def test_get_changes_with_start_cl(self, mock_p4_class): assert changes[1]["change"] == "12346" @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_changes_with_range(self, mock_p4_class): + def test_get_changes_with_range(self, mock_p4_class: MagicMock) -> None: """Test get_changes bounds both ends via the revision range. A request for (start_cl, end_cl] must query the inclusive Perforce range @@ -500,7 +506,7 @@ def test_get_changes_with_range(self, mock_p4_class): assert changes[1]["change"] == "12346" @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_changes_single_changelist(self, mock_p4_class): + def test_get_changes_single_changelist(self, mock_p4_class: MagicMock) -> None: """A one-CL window (start_cl == end_cl - 1... i.e. pin one CL) resolves to an inclusive single-value range like ``@N,@N``.""" mock_p4 = mock.Mock() @@ -527,7 +533,7 @@ def test_get_changes_single_changelist(self, mock_p4_class): assert changes[0]["change"] == "13000" @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_changes_wide_range_not_truncated(self, mock_p4_class): + def test_get_changes_wide_range_not_truncated(self, mock_p4_class: MagicMock) -> None: """A window wider than max_changes must not be capped to max_changes. ``p4 changes -m N`` returns only the N highest-numbered changes, so a @@ -546,7 +552,7 @@ def test_get_changes_wide_range_not_truncated(self, mock_p4_class): mock_p4.run.assert_called_once_with("changes", "-m", "100", "-l", "//depot/...@1001,@1100") @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_changes_empty_window_returns_early(self, mock_p4_class): + def test_get_changes_empty_window_returns_early(self, mock_p4_class: MagicMock) -> None: """An empty window (previousCommit == commit) returns [] without querying. start_cl is exclusive, so start_cl == end_cl selects no changes; it must @@ -563,7 +569,7 @@ def test_get_changes_empty_window_returns_early(self, mock_p4_class): mock_p4.run.assert_not_called() @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_changes_type_validation(self, mock_p4_class): + def test_get_changes_type_validation(self, mock_p4_class: MagicMock) -> None: """Test get_changes validates that start_cl and end_cl are integers""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -577,7 +583,7 @@ def test_get_changes_type_validation(self, mock_p4_class): self.p4_client.get_changes("//depot/...", end_cl="12346") # type: ignore[arg-type] @mock.patch("sentry.integrations.perforce.client.P4") - def test_check_file_exists(self, mock_p4_class): + def test_check_file_exists(self, mock_p4_class: MagicMock) -> None: """Test check_file returns file info when file exists""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -604,7 +610,7 @@ def test_check_file_exists(self, mock_p4_class): assert result["rev"] == "5" @mock.patch("sentry.integrations.perforce.client.P4") - def test_check_file_not_exists(self, mock_p4_class): + def test_check_file_not_exists(self, mock_p4_class: MagicMock) -> None: """Test check_file returns None when file doesn't exist""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -620,7 +626,7 @@ def test_check_file_not_exists(self, mock_p4_class): assert result is None @mock.patch("sentry.integrations.perforce.client.P4") - def test_check_file_empty_result(self, mock_p4_class): + def test_check_file_empty_result(self, mock_p4_class: MagicMock) -> None: """Test check_file returns None when result has no depotFile""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -684,7 +690,7 @@ def test_build_depot_path_nested_depot(self) -> None: assert path == "//depot/game/project/src/main.cpp" @mock.patch("sentry.integrations.perforce.client.P4") - def test_connect_writes_p4config_file(self, mock_p4_class): + def test_connect_writes_p4config_file(self, mock_p4_class: MagicMock) -> None: """_connect() must write a config file named by P4CONFIG with P4TRUST and P4TICKETS.""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -692,7 +698,7 @@ def test_connect_writes_p4config_file(self, mock_p4_class): captured_cwd = None captured_config = None - def capture_state(): + def capture_state() -> None: nonlocal captured_cwd, captured_config captured_cwd = mock_p4.cwd config_filename = os.environ.get("P4CONFIG", ".p4config") @@ -721,7 +727,7 @@ def capture_state(): assert ticket_path.endswith("/.p4tickets") @mock.patch("sentry.integrations.perforce.client.P4") - def test_connect_respects_custom_p4config_filename(self, mock_p4_class): + def test_connect_respects_custom_p4config_filename(self, mock_p4_class: MagicMock) -> None: """_connect() must use the actual P4CONFIG env value as the config filename.""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -729,7 +735,7 @@ def test_connect_respects_custom_p4config_filename(self, mock_p4_class): captured_cwd = None captured_config = None - def capture_state(): + def capture_state() -> None: nonlocal captured_cwd, captured_config captured_cwd = mock_p4.cwd config_path = f"{captured_cwd}/custom-p4.conf" @@ -748,7 +754,7 @@ def capture_state(): assert "P4TICKETS=" in captured_config @mock.patch("sentry.integrations.perforce.client.P4") - def test_connect_does_not_use_set_env(self, mock_p4_class): + def test_connect_does_not_use_set_env(self, mock_p4_class: MagicMock) -> None: """_connect() must NOT use set_env (it only works on Windows/macOS, not Linux).""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -791,7 +797,7 @@ def test_module_level_p4config_is_set(self) -> None: assert os.environ.get("P4CONFIG") == ".p4config" @mock.patch("sentry.integrations.perforce.client.P4") - def test_connect_sets_timeout(self, mock_p4_class): + def test_connect_sets_timeout(self, mock_p4_class: MagicMock) -> None: p4_stub = _P4AttributeRecorder() mock_p4_class.return_value = p4_stub @@ -802,7 +808,7 @@ def test_connect_sets_timeout(self, mock_p4_class): assert "timeout" in p4_stub.attrs_set_before_connect @mock.patch("sentry.integrations.perforce.client.P4") - def test_connect_sets_charset_for_unicode_server(self, mock_p4_class): + def test_connect_sets_charset_for_unicode_server(self, mock_p4_class: MagicMock) -> None: """_connect() must set p4.charset before connect() when configured for a Unicode server.""" p4_stub = _P4AttributeRecorder() mock_p4_class.return_value = p4_stub @@ -834,7 +840,9 @@ def test_connect_sets_charset_for_unicode_server(self, mock_p4_class): assert "charset" in p4_stub.attrs_set_before_connect @mock.patch("sentry.integrations.perforce.client.P4") - def test_connect_does_not_set_charset_for_non_unicode_server(self, mock_p4_class): + def test_connect_does_not_set_charset_for_non_unicode_server( + self, mock_p4_class: MagicMock + ) -> None: """_connect() must NOT touch p4.charset when configured for a non-Unicode server.""" p4_stub = _P4AttributeRecorder() mock_p4_class.return_value = p4_stub @@ -846,7 +854,9 @@ def test_connect_does_not_set_charset_for_non_unicode_server(self, mock_p4_class assert "charset" not in p4_stub.attrs_set @mock.patch("sentry.integrations.perforce.client.P4") - def test_connect_does_not_set_charset_when_explicitly_none(self, mock_p4_class): + def test_connect_does_not_set_charset_when_explicitly_none( + self, mock_p4_class: MagicMock + ) -> None: """The 'none' sentinel must behave the same as not setting charset at all.""" p4_stub = _P4AttributeRecorder() mock_p4_class.return_value = p4_stub @@ -873,7 +883,7 @@ def test_connect_does_not_set_charset_when_explicitly_none(self, mock_p4_class): assert "charset" not in p4_stub.attrs_set @mock.patch("sentry.integrations.perforce.client.P4") - def test_connect_translates_unicode_server_error(self, mock_p4_class): + def test_connect_translates_unicode_server_error(self, mock_p4_class: MagicMock) -> None: """Connecting to a Unicode server without a charset must surface an actionable error.""" from sentry.integrations.perforce.p4protocol import P4Exception from sentry.shared_integrations.exceptions import ApiError @@ -894,7 +904,7 @@ def test_connect_translates_unicode_server_error(self, mock_p4_class): assert "Server Encoding" in message @mock.patch("sentry.integrations.perforce.client.P4") - def test_unicode_error_at_login_is_translated(self, mock_p4_class): + def test_unicode_error_at_login_is_translated(self, mock_p4_class: MagicMock) -> None: """The pure-Python client surfaces the Unicode error at run_login(), not connect(); it must still yield the Server Encoding guidance rather than a misleading password/ticket error.""" @@ -917,7 +927,7 @@ def test_unicode_error_at_login_is_translated(self, mock_p4_class): assert "password" not in message.lower() @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_file_binary_returns_empty(self, mock_p4_class): + def test_get_file_binary_returns_empty(self, mock_p4_class: MagicMock) -> None: """Binary depot files return empty rather than 500-ing or yielding mojibake.""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -929,7 +939,7 @@ def test_get_file_binary_returns_empty(self, mock_p4_class): assert self.p4_client.get_file(self.repo, "logo.bin", None) == "" @mock.patch("sentry.integrations.perforce.client.P4") - def test_get_file_binary_variants_return_empty(self, mock_p4_class): + def test_get_file_binary_variants_return_empty(self, mock_p4_class: MagicMock) -> None: """Legacy/modified binary file types must also be treated as binary.""" mock_p4 = mock.Mock() mock_p4_class.return_value = mock_p4 @@ -1112,7 +1122,7 @@ def disconnect(self) -> None: def connected(self) -> bool: return True - def run(self, *args: object, **kwargs: object) -> list: + def run(self, *args: object, **kwargs: object) -> list[Any]: return [] def run_login(self) -> None: diff --git a/tests/sentry/integrations/perforce/test_code_mapping.py b/tests/sentry/integrations/perforce/test_code_mapping.py index 640a9887a87d..bf8f7ee345ea 100644 --- a/tests/sentry/integrations/perforce/test_code_mapping.py +++ b/tests/sentry/integrations/perforce/test_code_mapping.py @@ -11,6 +11,7 @@ from sentry.models.projectrepository import ProjectRepository, ProjectRepositorySource from sentry.models.repository import Repository from sentry.testutils.cases import IntegrationTestCase +from sentry.testutils.helpers.integrations import get_installation_of_type from sentry.utils.event_frames import EventFrame @@ -29,12 +30,15 @@ def setUp(self) -> None: external_id="perforce-test", metadata={}, ) - self.installation = self.integration.get_installation(self.organization.id) + self.installation = get_installation_of_type( + PerforceIntegration, self.integration, self.organization.id + ) self.project = self.create_project(organization=self.organization) - self.org_integration = self.integration.organizationintegration_set.first() - assert self.org_integration is not None + org_integration = self.integration.organizationintegration_set.first() + assert org_integration is not None + self.org_integration = org_integration - def tearDown(self): + def tearDown(self) -> None: super().tearDown() def test_code_mapping_depot_root_to_slash(self) -> None: @@ -352,10 +356,13 @@ def setUp(self) -> None: external_id="perforce-test", metadata={}, ) - self.installation = self.integration.get_installation(self.organization.id) + self.installation = get_installation_of_type( + PerforceIntegration, self.integration, self.organization.id + ) self.project = self.create_project(organization=self.organization) - self.org_integration = self.integration.organizationintegration_set.first() - assert self.org_integration is not None + org_integration = self.integration.organizationintegration_set.first() + assert org_integration is not None + self.org_integration = org_integration self.repo = Repository.objects.create( name="//depot", @@ -386,7 +393,7 @@ def setUp(self) -> None: ) self.check_file_patcher.start() - def tearDown(self): + def tearDown(self) -> None: self.check_file_patcher.stop() super().tearDown() diff --git a/tests/sentry/integrations/perforce/test_integration.py b/tests/sentry/integrations/perforce/test_integration.py index 5ac00fac0d7f..394d56e06036 100644 --- a/tests/sentry/integrations/perforce/test_integration.py +++ b/tests/sentry/integrations/perforce/test_integration.py @@ -1,5 +1,5 @@ import hashlib -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest import responses @@ -17,6 +17,7 @@ from sentry.shared_integrations.exceptions import ApiError, ApiUnauthorized, IntegrationError from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase, IntegrationTestCase +from sentry.testutils.helpers.integrations import get_installation_of_type from sentry.testutils.silo import assume_test_silo_mode, control_silo_test @@ -33,7 +34,9 @@ def setUp(self) -> None: external_id="perforce-test", metadata={}, ) - self.installation = self.integration.get_installation(self.organization.id) + self.installation = get_installation_of_type( + PerforceIntegration, self.integration, self.organization.id + ) self.repo = Repository.objects.create( name="//depot", organization_id=self.organization.id, @@ -259,7 +262,9 @@ def test_validate_web_url_adds_scheme(self) -> None: assert serializer.validate_web_url("http://swarm.example.com") == "http://swarm.example.com" @patch("sentry.integrations.perforce.client.is_safe_hostname", return_value=True) - def test_serializer_normalizes_schemeless_web_url_end_to_end(self, mock_safe_hostname) -> None: + def test_serializer_normalizes_schemeless_web_url_end_to_end( + self, mock_safe_hostname: MagicMock + ) -> None: """ Exercise the full serializer, not just validate_web_url directly. With a URLField the field-level URLValidator rejected schemeless input before validate_web_url ran, @@ -304,7 +309,9 @@ def test_format_source_url_uses_repo_name_as_fallback_depot(self) -> None: assert url == "p4://myproject/app/services/processor.cpp" @patch("sentry.integrations.perforce.client.PerforceClient.get_depots") - def test_get_repositories_derives_external_id_from_depot_path(self, mock_get_depots) -> None: + def test_get_repositories_derives_external_id_from_depot_path( + self, mock_get_depots: MagicMock + ) -> None: """ get_repositories() must return a depot-path external_id (not empty) so periodic repo sync can diff against stored repos. The external_id must @@ -342,7 +349,7 @@ def test_get_repo_external_id_matches_manual_add(self) -> None: assert self.installation.get_repo_external_id({"name": "depot"}) == "//depot" @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_check_file_absolute_depot_path(self, mock_check_file): + def test_check_file_absolute_depot_path(self, mock_check_file: MagicMock) -> None: """Test check_file with absolute depot path (//depot/...)""" mock_check_file.return_value = {"depotFile": "//depot/app/services/processor.cpp"} result = self.installation.check_file(self.repo, "//depot/app/services/processor.cpp") @@ -350,7 +357,7 @@ def test_check_file_absolute_depot_path(self, mock_check_file): assert "//depot/app/services/processor.cpp" in result @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_check_file_path_with_depot_name(self, mock_check_file): + def test_check_file_path_with_depot_name(self, mock_check_file: MagicMock) -> None: """Test check_file with path starting with depot name (depot/...)""" mock_check_file.return_value = {"depotFile": "//depot/app/services/processor.cpp"} result = self.installation.check_file(self.repo, "depot/app/services/processor.cpp") @@ -360,7 +367,7 @@ def test_check_file_path_with_depot_name(self, mock_check_file): assert "depot/depot" not in result @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_check_file_relative_path(self, mock_check_file): + def test_check_file_relative_path(self, mock_check_file: MagicMock) -> None: """Test check_file with normal relative path (app/...)""" mock_check_file.return_value = {"depotFile": "//depot/app/services/processor.cpp"} result = self.installation.check_file(self.repo, "app/services/processor.cpp") @@ -368,7 +375,7 @@ def test_check_file_relative_path(self, mock_check_file): assert "//depot/app/services/processor.cpp" in result @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_check_file_with_revision_syntax_absolute(self, mock_check_file): + def test_check_file_with_revision_syntax_absolute(self, mock_check_file: MagicMock) -> None: """Test check_file with #revision syntax on absolute path""" mock_check_file.return_value = {"depotFile": "//depot/app/services/processor.cpp"} result = self.installation.check_file(self.repo, "//depot/app/services/processor.cpp#1") @@ -376,7 +383,7 @@ def test_check_file_with_revision_syntax_absolute(self, mock_check_file): assert "#1" in result @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_check_file_with_revision_syntax_depot_name(self, mock_check_file): + def test_check_file_with_revision_syntax_depot_name(self, mock_check_file: MagicMock) -> None: """Test check_file with #revision syntax on path with depot name""" mock_check_file.return_value = {"depotFile": "//depot/app/services/processor.cpp"} result = self.installation.check_file(self.repo, "depot/app/services/processor.cpp#1") @@ -385,7 +392,7 @@ def test_check_file_with_revision_syntax_depot_name(self, mock_check_file): assert "depot/depot" not in result @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_check_file_with_revision_syntax_relative(self, mock_check_file): + def test_check_file_with_revision_syntax_relative(self, mock_check_file: MagicMock) -> None: """Test check_file with #revision syntax on relative path""" mock_check_file.return_value = {"depotFile": "//depot/app/services/processor.cpp"} result = self.installation.check_file(self.repo, "app/services/processor.cpp#1") @@ -393,7 +400,7 @@ def test_check_file_with_revision_syntax_relative(self, mock_check_file): assert "#1" in result @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_get_stacktrace_link(self, mock_check_file): + def test_get_stacktrace_link(self, mock_check_file: MagicMock) -> None: """Test get_stacktrace_link returns format_source_url result""" mock_check_file.return_value = {"depotFile": "//depot/app/services/processor.cpp"} filepath = "app/services/processor.cpp#1" @@ -406,7 +413,9 @@ def test_get_stacktrace_link(self, mock_check_file): assert "#1" in url @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_get_stacktrace_link_changelist_not_url_encoded(self, mock_check_file): + def test_get_stacktrace_link_changelist_not_url_encoded( + self, mock_check_file: MagicMock + ) -> None: """ A changelist arriving as `version` renders as Perforce '@' syntax in the p4:// fallback. `@` is a valid path character and must survive the encode_url @@ -512,7 +521,9 @@ def test_validate_p4port_transport_rejects_invalid(self) -> None: validate_p4port_transport(value) @patch("sentry.integrations.perforce.client.is_safe_hostname", return_value=True) - def test_validate_p4port_transport_allows_host_port(self, mock_safe_hostname) -> None: + def test_validate_p4port_transport_allows_host_port( + self, mock_safe_hostname: MagicMock + ) -> None: for value in ( "perforce.example.com:1666", "test-host.example.com:1666", @@ -523,7 +534,9 @@ def test_validate_p4port_transport_allows_host_port(self, mock_safe_hostname) -> validate_p4port_transport(value) @patch("sentry.integrations.perforce.client.is_safe_hostname", return_value=True) - def test_validate_p4port_transport_allows_every_transport(self, mock_safe_hostname) -> None: + def test_validate_p4port_transport_allows_every_transport( + self, mock_safe_hostname: MagicMock + ) -> None: for transport in ( "tcp", "tcp4", @@ -552,7 +565,7 @@ def test_validate_p4port_transport_rejects_disallowed_transport(self) -> None: validate_p4port_transport(f"{transport}:perforce.example.com:1666") @patch("sentry.integrations.perforce.client.is_safe_hostname", return_value=True) - def test_serializer_accepts_valid_p4port(self, mock_safe_hostname) -> None: + def test_serializer_accepts_valid_p4port(self, mock_safe_hostname: MagicMock) -> None: serializer = PerforceInstallationSerializer(data=self._base_payload()) assert serializer.is_valid(), serializer.errors assert serializer.validated_data["p4port"] == "ssl:perforce.example.com:1666" @@ -619,7 +632,7 @@ def test_update_organization_config_rejects_internal_host(self) -> None: @patch("sentry.integrations.perforce.client.is_safe_hostname", return_value=True) def test_update_organization_config_accepts_and_normalizes_p4port( - self, mock_safe_hostname + self, mock_safe_hostname: MagicMock ) -> None: installation = self._installation_for_update("perforce-update-ok") installation.update_organization_config({"p4port": "tcp:perforce.example.com:1666/"}) @@ -640,7 +653,9 @@ def setUp(self) -> None: external_id="perforce-test", metadata={}, ) - self.installation = self.integration.get_installation(self.organization.id) + self.installation = get_installation_of_type( + PerforceIntegration, self.integration, self.organization.id + ) self.repo = Repository.objects.create( name="//depot", organization_id=self.organization.id, @@ -702,7 +717,9 @@ def setUp(self) -> None: name="Perforce", external_id="perforce-test", ) - self.installation = self.integration.get_installation(self.organization.id) + self.installation = get_installation_of_type( + PerforceIntegration, self.integration, self.organization.id + ) def test_multiple_depots(self) -> None: """Test handling multiple depot configurations""" @@ -774,7 +791,9 @@ def setUp(self) -> None: external_id="perforce-test", metadata={}, ) - self.installation = self.integration.get_installation(self.organization.id) + self.installation = get_installation_of_type( + PerforceIntegration, self.integration, self.organization.id + ) self.repo = Repository.objects.create( name="//depot", organization_id=self.organization.id, @@ -985,7 +1004,7 @@ def test_integration_lifecycle(self) -> None: class PerforceApiPipelineTest(APITestCase): endpoint = "sentry-api-0-organization-pipeline" - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(self.user) patcher = patch("sentry.integrations.perforce.client.is_safe_hostname", return_value=True) @@ -1017,7 +1036,7 @@ def _init_pipeline_in_session(self) -> IntegrationPipeline: "sentry.integrations.perforce.integration.PerforceClient.get_depots", return_value=[{"name": "depot"}], ) - def test_successful_connection(self, mock_get_depots) -> None: + def test_successful_connection(self, mock_get_depots: MagicMock) -> None: pipeline = self._init_pipeline_in_session() pipeline.set_api_mode() url = self._get_pipeline_url() @@ -1046,7 +1065,7 @@ def test_successful_connection(self, mock_get_depots) -> None: "sentry.integrations.perforce.integration.PerforceClient.get_depots", side_effect=ApiUnauthorized("bad credentials"), ) - def test_auth_failure_returns_error(self, mock_get_depots) -> None: + def test_auth_failure_returns_error(self, mock_get_depots: MagicMock) -> None: pipeline = self._init_pipeline_in_session() pipeline.set_api_mode() url = self._get_pipeline_url() @@ -1070,7 +1089,7 @@ def test_auth_failure_returns_error(self, mock_get_depots) -> None: "sentry.integrations.perforce.integration.PerforceClient.get_depots", side_effect=ApiError("connection refused"), ) - def test_connection_failure_returns_error(self, mock_get_depots) -> None: + def test_connection_failure_returns_error(self, mock_get_depots: MagicMock) -> None: pipeline = self._init_pipeline_in_session() pipeline.set_api_mode() url = self._get_pipeline_url() diff --git a/tests/sentry/integrations/perforce/test_stacktrace_link.py b/tests/sentry/integrations/perforce/test_stacktrace_link.py index bcc3bfb03bd0..ef46016d371e 100644 --- a/tests/sentry/integrations/perforce/test_stacktrace_link.py +++ b/tests/sentry/integrations/perforce/test_stacktrace_link.py @@ -1,12 +1,23 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch +from sentry.integrations.models.integration import Integration from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig -from sentry.integrations.perforce.integration import PerforceIntegrationProvider +from sentry.integrations.perforce.integration import ( + PerforceIntegration, + PerforceIntegrationProvider, +) from sentry.integrations.utils.stacktrace_link import get_stacktrace_config from sentry.issues.endpoints.project_stacktrace_link import StacktraceLinkContext from sentry.models.projectrepository import ProjectRepository, ProjectRepositorySource from sentry.models.repository import Repository from sentry.testutils.cases import IntegrationTestCase +from sentry.testutils.helpers.integrations import get_installation_of_type + + +def organization_integration_id(integration: Integration) -> int: + organization_integration = integration.organizationintegration_set.first() + assert organization_integration is not None + return organization_integration.id class PerforceStacktraceLinkTest(IntegrationTestCase): @@ -23,7 +34,9 @@ def setUp(self) -> None: external_id="perforce-test", metadata={}, ) - self.installation = self.integration.get_installation(self.organization.id) + self.installation = get_installation_of_type( + PerforceIntegration, self.integration, self.organization.id + ) self.project = self.create_project(organization=self.organization) self.repo = Repository.objects.create( @@ -40,7 +53,7 @@ def setUp(self) -> None: ) self.code_mapping = RepositoryProjectPathConfig.objects.create( organization_id=self.organization.id, - organization_integration_id=self.integration.organizationintegration_set.first().id, + organization_integration_id=organization_integration_id(self.integration), integration_id=self.integration.id, stack_root="depot/", source_root="/", @@ -55,7 +68,7 @@ def setUp(self) -> None: ) self.check_file_patcher.start() - def tearDown(self): + def tearDown(self) -> None: self.check_file_patcher.stop() super().tearDown() @@ -89,7 +102,7 @@ def test_get_stacktrace_config_python_path(self) -> None: assert result["src_path"] == "app/services/processor.py" @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_get_stacktrace_config_cpp_path_with_revision(self, mock_check_file): + def test_get_stacktrace_config_cpp_path_with_revision(self, mock_check_file: MagicMock) -> None: """Test stacktrace link generation for C++ path with #revision""" mock_check_file.return_value = {"depotFile": "//depot/game/src/main.cpp"} ctx: StacktraceLinkContext = { @@ -136,7 +149,7 @@ def test_get_stacktrace_config_no_matching_code_mapping(self) -> None: assert result["src_path"] is None @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_get_stacktrace_config_multiple_code_mappings(self, mock_check_file): + def test_get_stacktrace_config_multiple_code_mappings(self, mock_check_file: MagicMock) -> None: """Test stacktrace link with multiple code mappings""" mock_check_file.return_value = {"depotFile": "//myproject/app/services/handler.py"} # Add another depot mapping @@ -154,7 +167,7 @@ def test_get_stacktrace_config_multiple_code_mappings(self, mock_check_file): ) myproject_mapping = RepositoryProjectPathConfig.objects.create( organization_id=self.organization.id, - organization_integration_id=self.integration.organizationintegration_set.first().id, + organization_integration_id=organization_integration_id(self.integration), integration_id=self.integration.id, stack_root="myproject/", source_root="/", @@ -184,7 +197,7 @@ def test_get_stacktrace_config_multiple_code_mappings(self, mock_check_file): assert result["src_path"] == "app/services/handler.py" @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_get_stacktrace_config_with_web_viewer(self, mock_check_file): + def test_get_stacktrace_config_with_web_viewer(self, mock_check_file: MagicMock) -> None: """Test stacktrace link with P4Web viewer""" mock_check_file.return_value = {"depotFile": "//depot/app/services/processor.py"} integration_with_web = self.create_integration( @@ -249,7 +262,7 @@ def test_get_stacktrace_config_with_web_viewer(self, mock_check_file): ) @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_get_stacktrace_config_abs_path_fallback(self, mock_check_file): + def test_get_stacktrace_config_abs_path_fallback(self, mock_check_file: MagicMock) -> None: """Test stacktrace link uses abs_path when filename is just basename""" mock_check_file.return_value = {"depotFile": "//depot/app/services/processor.py"} ctx: StacktraceLinkContext = { @@ -273,7 +286,7 @@ def test_get_stacktrace_config_abs_path_fallback(self, mock_check_file): assert result["src_path"] == "app/services/processor.py" @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_get_stacktrace_config_iteration_count(self, mock_check_file): + def test_get_stacktrace_config_iteration_count(self, mock_check_file: MagicMock) -> None: """Test that iteration_count is incremented only for matching mappings""" mock_check_file.return_value = {"depotFile": "//depot/app/services/processor.py"} # Add a non-matching mapping @@ -291,7 +304,7 @@ def test_get_stacktrace_config_iteration_count(self, mock_check_file): ) other_mapping = RepositoryProjectPathConfig.objects.create( organization_id=self.organization.id, - organization_integration_id=self.integration.organizationintegration_set.first().id, + organization_integration_id=organization_integration_id(self.integration), integration_id=self.integration.id, stack_root="other/", source_root="/", @@ -319,7 +332,7 @@ def test_get_stacktrace_config_iteration_count(self, mock_check_file): assert result["source_url"] is not None @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_get_stacktrace_config_stops_on_first_match(self, mock_check_file): + def test_get_stacktrace_config_stops_on_first_match(self, mock_check_file: MagicMock) -> None: """Test that iteration stops after first successful match""" mock_check_file.return_value = {"depotFile": "//depot/app/services/processor.py"} # Add another depot mapping (shouldn't be checked if first matches) @@ -340,7 +353,7 @@ def test_get_stacktrace_config_stops_on_first_match(self, mock_check_file): ) myproject_mapping = RepositoryProjectPathConfig.objects.create( organization_id=self.organization.id, - organization_integration_id=self.integration.organizationintegration_set.first().id, + organization_integration_id=organization_integration_id(self.integration), integration_id=self.integration.id, stack_root="depot/", # Same stack_root as depot mapping (but different project) source_root="/", @@ -393,7 +406,7 @@ def setUp(self) -> None: ) self.check_file_patcher.start() - def tearDown(self): + def tearDown(self) -> None: self.check_file_patcher.stop() super().tearDown() @@ -419,7 +432,7 @@ def test_stacktrace_link_empty_stack_root(self) -> None: ) code_mapping = RepositoryProjectPathConfig.objects.create( organization_id=self.organization.id, - organization_integration_id=self.integration.organizationintegration_set.first().id, + organization_integration_id=organization_integration_id(self.integration), integration_id=self.integration.id, stack_root="", source_root="/", @@ -446,7 +459,9 @@ def test_stacktrace_link_empty_stack_root(self) -> None: assert result["source_url"] is not None @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_stacktrace_link_with_special_characters_in_path(self, mock_check_file): + def test_stacktrace_link_with_special_characters_in_path( + self, mock_check_file: MagicMock + ) -> None: """Test stacktrace link with special characters in file path""" mock_check_file.return_value = {"depotFile": "//depot/app/my services/processor-v2.py"} repo = Repository.objects.create( @@ -463,7 +478,7 @@ def test_stacktrace_link_with_special_characters_in_path(self, mock_check_file): ) code_mapping = RepositoryProjectPathConfig.objects.create( organization_id=self.organization.id, - organization_integration_id=self.integration.organizationintegration_set.first().id, + organization_integration_id=organization_integration_id(self.integration), integration_id=self.integration.id, stack_root="depot/", source_root="/", @@ -491,7 +506,7 @@ def test_stacktrace_link_with_special_characters_in_path(self, mock_check_file): assert result["src_path"] == "app/my services/processor-v2.py" @patch("sentry.integrations.perforce.client.PerforceClient.check_file") - def test_stacktrace_link_deeply_nested_path(self, mock_check_file): + def test_stacktrace_link_deeply_nested_path(self, mock_check_file: MagicMock) -> None: """Test stacktrace link with very deeply nested path""" mock_check_file.return_value = {"depotFile": "//depot/file.py"} repo = Repository.objects.create( @@ -508,7 +523,7 @@ def test_stacktrace_link_deeply_nested_path(self, mock_check_file): ) code_mapping = RepositoryProjectPathConfig.objects.create( organization_id=self.organization.id, - organization_integration_id=self.integration.organizationintegration_set.first().id, + organization_integration_id=organization_integration_id(self.integration), integration_id=self.integration.id, stack_root="depot/", source_root="/", diff --git a/tests/sentry/integrations/services/repository/test_impl.py b/tests/sentry/integrations/services/repository/test_impl.py index b8c9ace3cd92..cdca11222425 100644 --- a/tests/sentry/integrations/services/repository/test_impl.py +++ b/tests/sentry/integrations/services/repository/test_impl.py @@ -244,7 +244,9 @@ def setUp(self) -> None: provider="github", ) self.provider = "integrations:github" - self.org_integration = self.integration.organizationintegration_set.first() + org_integration = self.integration.organizationintegration_set.first() + assert org_integration is not None + self.org_integration = org_integration def test_disassociates_repos(self) -> None: repo = Repository.objects.create( @@ -348,7 +350,7 @@ def setUp(self) -> None: external_id="gitlab.example.com:1", provider="gitlab", ) - self.organization_integration = self.old_integration.organizationintegration_set.get() + self.old_org_integration = self.old_integration.organizationintegration_set.get() self.new_integration = self.create_integration( organization=self.organization, external_id=f"gitlab.example.com:1-{self.organization.id}", @@ -371,7 +373,7 @@ def test_repoints_all_cell_data(self) -> None: code_mapping = self.create_code_mapping( project=self.project, repo=repository, - organization_integration=self.organization_integration, + organization_integration=self.old_org_integration, ) external_issue = ExternalIssue.objects.create( organization=self.organization, @@ -404,7 +406,7 @@ def test_repoints_all_cell_data(self) -> None: assert repository.integration_id == self.new_integration.id code_mapping.refresh_from_db() assert code_mapping.integration_id == self.new_integration.id - assert code_mapping.organization_integration_id == self.organization_integration.id + assert code_mapping.organization_integration_id == self.old_org_integration.id external_issue.refresh_from_db() assert external_issue.integration_id == self.new_integration.id external_actor.refresh_from_db() diff --git a/tests/sentry/integrations/slack/actions/notification/test_slack_notify_service_action.py b/tests/sentry/integrations/slack/actions/notification/test_slack_notify_service_action.py index 29d51a25990c..0e5d678b8dcd 100644 --- a/tests/sentry/integrations/slack/actions/notification/test_slack_notify_service_action.py +++ b/tests/sentry/integrations/slack/actions/notification/test_slack_notify_service_action.py @@ -2,10 +2,10 @@ from unittest.mock import MagicMock, patch import orjson -from slack_sdk.errors import SlackApiError from slack_sdk.web import SlackResponse from sentry.integrations.slack import SlackNotifyServiceAction +from sentry.integrations.slack.utils.errors import TypedSlackApiError from sentry.integrations.types import EventLifecycleOutcome from sentry.notifications.models.notificationmessage import NotificationMessage from sentry.notifications.types import NotificationActionContext, NotificationOrigin @@ -56,6 +56,7 @@ def setUp(self) -> None: project_id=self.project.id, ) assert self.event.group is not None + self.group_event = self.event.for_group(self.event.group) self.action = self.create_action() @@ -77,11 +78,11 @@ def test_after_noa( rule_cls_instance = self.get_rule(data=rule.data["actions"][0]) - results = list(rule_cls_instance.after(event=self.event)) + results = list(rule_cls_instance.after(event=self.group_event)) assert len(results) == 1 results[0].callback( - self.event, + self.group_event, futures=[ RuleFuture( context=create_notification_action_context(rule, with_workflow=False), kwargs={} @@ -93,7 +94,7 @@ def test_after_noa( assert ( blocks[0]["text"]["text"] - == f":large_yellow_circle: " + == f":large_yellow_circle: " ) assert NotificationMessage.objects.all().count() == 1 @@ -110,7 +111,7 @@ def test_after_noa( @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.chat_postMessage") def test_after_noa_slo_halt(self, mock_post: MagicMock, mock_record: MagicMock) -> None: - mock_post.side_effect = SlackApiError( + mock_post.side_effect = TypedSlackApiError( message="account_inactive", response=SlackResponse( client=None, @@ -129,11 +130,11 @@ def test_after_noa_slo_halt(self, mock_post: MagicMock, mock_record: MagicMock) rule_cls_instance = self.get_rule(data=rule.data["actions"][0]) - results = list(rule_cls_instance.after(event=self.event)) + results = list(rule_cls_instance.after(event=self.group_event)) assert len(results) == 1 results[0].callback( - self.event, + self.group_event, futures=[ RuleFuture( context=create_notification_action_context(rule, with_workflow=False), kwargs={} @@ -145,7 +146,7 @@ def test_after_noa_slo_halt(self, mock_post: MagicMock, mock_record: MagicMock) assert ( blocks[0]["text"]["text"] - == f":large_yellow_circle: " + == f":large_yellow_circle: " ) assert ( @@ -168,7 +169,7 @@ def test_after_noa_error( mock_post: MagicMock, mock_record: MagicMock, ) -> None: - mock_post.side_effect = SlackApiError( + mock_post.side_effect = TypedSlackApiError( message="asdf", response=SlackResponse( client=None, @@ -187,11 +188,11 @@ def test_after_noa_error( rule_cls_instance = self.get_rule(data=rule.data["actions"][0]) - results = list(rule_cls_instance.after(event=self.event)) + results = list(rule_cls_instance.after(event=self.group_event)) assert len(results) == 1 results[0].callback( - self.event, + self.group_event, futures=[ RuleFuture( context=create_notification_action_context(rule, with_workflow=False), kwargs={} @@ -230,11 +231,11 @@ def test_after_noa_test_action( rule_cls_instance = self.get_rule(data=rule.data["actions"][0]) - results = list(rule_cls_instance.after(event=self.event)) + results = list(rule_cls_instance.after(event=self.group_event)) assert len(results) == 1 results[0].callback( - self.event, + self.group_event, futures=[ RuleFuture( context=NotificationActionContext( @@ -256,7 +257,7 @@ def test_after_noa_test_action( assert ( blocks[0]["text"]["text"] - == f":large_yellow_circle: " + == f":large_yellow_circle: " ) # Test action should not create a notification message @@ -299,11 +300,11 @@ def test_after_noa_new_ui( context=context, ) - results = list(rule_cls_instance.after(event=self.event)) + results = list(rule_cls_instance.after(event=self.group_event)) assert len(results) == 1 results[0].callback( - self.event, + self.group_event, futures=[RuleFuture(context=context, kwargs={})], ) blocks = mock_post.call_args.kwargs["blocks"] @@ -311,7 +312,7 @@ def test_after_noa_new_ui( assert ( blocks[0]["text"]["text"] - == f":large_yellow_circle: " + == f":large_yellow_circle: " ) assert NotificationMessage.objects.all().count() == 1 @@ -352,11 +353,11 @@ def test_after_with_threads_noa( ) rule_cls_instance = self.get_rule(data=rule.data["actions"][0], context=context) - results = list(rule_cls_instance.after(event=self.event)) + results = list(rule_cls_instance.after(event=self.group_event)) assert len(results) == 1 results[0].callback( - self.event, + self.group_event, futures=[RuleFuture(context=context, kwargs={})], ) blocks = mock_post.call_args.kwargs["blocks"] @@ -364,7 +365,7 @@ def test_after_with_threads_noa( assert ( blocks[0]["text"]["text"] - == f":large_yellow_circle: " + == f":large_yellow_circle: " ) assert NotificationMessage.objects.all().count() == 1 @@ -393,7 +394,7 @@ def test_after_reply_in_thread_noa( with assume_test_silo_mode(SiloMode.CELL): msg = NotificationMessage.objects.create( action_id=self.action.id, - group_id=self.event.group.id, + group_id=self.group_event.group.id, ) event = self.store_event( @@ -405,6 +406,8 @@ def test_after_reply_in_thread_noa( }, project_id=self.project.id, ) + assert event.group is not None + group_event = event.for_group(event.group) rule = self.create_project_rule(action_data=[self.action_data]) rule.id = self.action.id @@ -421,11 +424,11 @@ def test_after_reply_in_thread_noa( ) rule_cls_instance = self.get_rule(data=rule.data["actions"][0], context=context) - results = list(rule_cls_instance.after(event=event)) + results = list(rule_cls_instance.after(event=group_event)) assert len(results) == 1 results[0].callback( - self.event, + self.group_event, futures=[RuleFuture(context=context, kwargs={})], ) blocks = mock_post.call_args.kwargs["blocks"] @@ -433,7 +436,7 @@ def test_after_reply_in_thread_noa( assert ( blocks[0]["text"]["text"] - == f":large_yellow_circle: " + == f":large_yellow_circle: " ) assert NotificationMessage.objects.all().count() == 2 diff --git a/tests/sentry/integrations/slack/notifications/test_assigned.py b/tests/sentry/integrations/slack/notifications/test_assigned.py index 5459b7f74480..71159d1c7d57 100644 --- a/tests/sentry/integrations/slack/notifications/test_assigned.py +++ b/tests/sentry/integrations/slack/notifications/test_assigned.py @@ -4,6 +4,7 @@ from sentry.integrations.types import ExternalProviders from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.notifications.notifications.activity.assigned import AssignedActivityNotification from sentry.testutils.cases import PerformanceIssueTestCase, SlackActivityNotificationTest from sentry.testutils.helpers.notifications import TEST_ISSUE_OCCURRENCE, TEST_PERF_ISSUE_OCCURRENCE @@ -14,7 +15,9 @@ class SlackAssignedNotificationTest(SlackActivityNotificationTest, PerformanceIssueTestCase): - def create_notification(self, group, notification): + def create_notification( + self, group: Group, notification: type[AssignedActivityNotification] + ) -> AssignedActivityNotification: return notification( Activity( project=self.project, @@ -152,6 +155,7 @@ def test_assignment_performance_issue_block_with_culprit_blocks( and block kit is enabled. """ event = self.create_performance_issue() + assert event.group is not None with self.tasks(): self.create_notification(event.group, AssignedActivityNotification).send() diff --git a/tests/sentry/integrations/slack/notifications/test_escalating.py b/tests/sentry/integrations/slack/notifications/test_escalating.py index 0cc3d518d1c2..55a2d24d50dd 100644 --- a/tests/sentry/integrations/slack/notifications/test_escalating.py +++ b/tests/sentry/integrations/slack/notifications/test_escalating.py @@ -3,6 +3,7 @@ import orjson from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.notifications.notifications.activity.escalating import EscalatingActivityNotification from sentry.testutils.cases import PerformanceIssueTestCase, SlackActivityNotificationTest from sentry.testutils.helpers.notifications import TEST_ISSUE_OCCURRENCE, TEST_PERF_ISSUE_OCCURRENCE @@ -13,7 +14,7 @@ class SlackRegressionNotificationTest(SlackActivityNotificationTest, PerformanceIssueTestCase): - def create_notification(self, group): + def create_notification(self, group: Group) -> EscalatingActivityNotification: return EscalatingActivityNotification( Activity( project=self.project, diff --git a/tests/sentry/integrations/slack/notifications/test_issue_alert.py b/tests/sentry/integrations/slack/notifications/test_issue_alert.py index bdcd2b17ddb5..18f4d9fde262 100644 --- a/tests/sentry/integrations/slack/notifications/test_issue_alert.py +++ b/tests/sentry/integrations/slack/notifications/test_issue_alert.py @@ -1,4 +1,5 @@ import uuid +from collections.abc import Mapping, Sequence from datetime import UTC, datetime from unittest import mock from unittest.mock import MagicMock, patch @@ -31,6 +32,7 @@ NotificationOrigin, ) from sentry.plugins.base import Notification +from sentry.services.eventstore.models import Event, GroupEvent from sentry.silo.base import SiloMode from sentry.tasks.digests import deliver_digest from sentry.testutils.cases import PerformanceIssueTestCase, SlackActivityNotificationTest @@ -49,7 +51,10 @@ old_get_tags = get_tags -def fake_get_tags(event_for_tags, tags): +def fake_get_tags( + event_for_tags: Event | GroupEvent | None, + tags: set[str] | list[tuple[str]] | None, +) -> Sequence[Mapping[str, str | bool | None]]: return old_get_tags(event_for_tags, None) @@ -129,13 +134,14 @@ def test_issue_alert_user_block(self) -> None: return_value=TEST_PERF_ISSUE_OCCURRENCE, new_callable=mock.PropertyMock, ) - def test_performance_issue_alert_user_block(self, occurrence) -> None: + def test_performance_issue_alert_user_block(self, occurrence: object) -> None: """ Test that performance issue alerts are sent to a Slack user with the proper payload when block kit is enabled. """ event = self.create_performance_issue() + assert event.group is not None # this is a PerformanceNPlusOneGroupType event notification = AlertRuleNotification( Notification(event=event, rule=self.create_workflow_origin(self.rule)), @@ -221,6 +227,7 @@ def test_generic_issue_alert_user_block(self, occurrence: MagicMock) -> None: event = self.store_event( data={"message": "Hellboy's world", "level": "error"}, project_id=self.project.id ) + assert event.group is not None group_event = event.for_group(event.groups[0]) notification = AlertRuleNotification( @@ -261,6 +268,7 @@ def test_generic_issue_alert_user_block_workflow_engine(self, occurrence: MagicM event = self.store_event( data={"message": "Hellboy's world", "level": "error"}, project_id=self.project.id ) + assert event.group is not None group_event = event.for_group(event.groups[0]) workflow_id = 123 diff --git a/tests/sentry/integrations/slack/notifications/test_note.py b/tests/sentry/integrations/slack/notifications/test_note.py index 0396cc7454ca..716f59c14afb 100644 --- a/tests/sentry/integrations/slack/notifications/test_note.py +++ b/tests/sentry/integrations/slack/notifications/test_note.py @@ -3,6 +3,7 @@ import orjson from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.notifications.notifications.activity.note import NoteActivityNotification from sentry.testutils.cases import PerformanceIssueTestCase, SlackActivityNotificationTest from sentry.testutils.helpers.notifications import TEST_ISSUE_OCCURRENCE @@ -13,7 +14,7 @@ class SlackNoteNotificationTest(SlackActivityNotificationTest, PerformanceIssueTestCase): - def create_notification(self, group): + def create_notification(self, group: Group) -> NoteActivityNotification: return NoteActivityNotification( Activity( project=self.project, diff --git a/tests/sentry/integrations/slack/notifications/test_regression.py b/tests/sentry/integrations/slack/notifications/test_regression.py index c0063d397b04..db825dc2c2b9 100644 --- a/tests/sentry/integrations/slack/notifications/test_regression.py +++ b/tests/sentry/integrations/slack/notifications/test_regression.py @@ -3,6 +3,7 @@ import orjson from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.notifications.notifications.activity.regression import RegressionActivityNotification from sentry.testutils.cases import PerformanceIssueTestCase, SlackActivityNotificationTest from sentry.testutils.helpers.notifications import TEST_ISSUE_OCCURRENCE, TEST_PERF_ISSUE_OCCURRENCE @@ -13,7 +14,9 @@ class SlackRegressionNotificationTest(SlackActivityNotificationTest, PerformanceIssueTestCase): - def create_notification(self, group, data=None): + def create_notification( + self, group: Group, data: dict[str, str] | None = None + ) -> RegressionActivityNotification: return RegressionActivityNotification( Activity( project=self.project, @@ -110,6 +113,7 @@ def test_regression_performance_issue_block_with_culprit_blocks( and block kit is enabled. """ event = self.create_performance_issue() + assert event.group is not None with self.tasks(): self.create_notification(event.group).send() diff --git a/tests/sentry/integrations/slack/notifications/test_resolved.py b/tests/sentry/integrations/slack/notifications/test_resolved.py index c5ebbb6f2bdb..1a034c1b8bff 100644 --- a/tests/sentry/integrations/slack/notifications/test_resolved.py +++ b/tests/sentry/integrations/slack/notifications/test_resolved.py @@ -3,6 +3,7 @@ import orjson from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.notifications.notifications.activity.resolved import ResolvedActivityNotification from sentry.testutils.cases import PerformanceIssueTestCase, SlackActivityNotificationTest from sentry.testutils.helpers.notifications import TEST_ISSUE_OCCURRENCE, TEST_PERF_ISSUE_OCCURRENCE @@ -13,7 +14,7 @@ class SlackResolvedNotificationTest(SlackActivityNotificationTest, PerformanceIssueTestCase): - def create_notification(self, group): + def create_notification(self, group: Group) -> ResolvedActivityNotification: return ResolvedActivityNotification( Activity( project=self.project, diff --git a/tests/sentry/integrations/slack/notifications/test_resolved_in_pull_request.py b/tests/sentry/integrations/slack/notifications/test_resolved_in_pull_request.py index 4ca652b27495..cc34cae65541 100644 --- a/tests/sentry/integrations/slack/notifications/test_resolved_in_pull_request.py +++ b/tests/sentry/integrations/slack/notifications/test_resolved_in_pull_request.py @@ -3,6 +3,7 @@ import orjson from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.notifications.notifications.activity.resolved_in_pull_request import ( ResolvedInPullRequestActivityNotification, ) @@ -21,7 +22,7 @@ def setUp(self) -> None: super().setUp() self.pull_request_url = "https://github.com/example/pull/123" - def create_notification(self, group): + def create_notification(self, group: Group) -> ResolvedInPullRequestActivityNotification: return ResolvedInPullRequestActivityNotification( Activity( project=self.project, diff --git a/tests/sentry/integrations/slack/notifications/test_resolved_in_release.py b/tests/sentry/integrations/slack/notifications/test_resolved_in_release.py index bc0083dde679..c4c716e3b820 100644 --- a/tests/sentry/integrations/slack/notifications/test_resolved_in_release.py +++ b/tests/sentry/integrations/slack/notifications/test_resolved_in_release.py @@ -3,6 +3,7 @@ import orjson from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.notifications.notifications.activity.resolved_in_release import ( ResolvedInReleaseActivityNotification, ) @@ -17,7 +18,9 @@ class SlackResolvedInReleaseNotificationTest( SlackActivityNotificationTest, PerformanceIssueTestCase ): - def create_notification(self, group, version="meow"): + def create_notification( + self, group: Group, version: str = "meow" + ) -> ResolvedInReleaseActivityNotification: return ResolvedInReleaseActivityNotification( Activity( project=self.project, @@ -63,6 +66,7 @@ def test_resolved_in_release_performance_issue_block_with_culprit_blocks( and block kit is enabled. """ event = self.create_performance_issue() + assert event.group is not None notification = self.create_notification(event.group) with self.tasks(): notification.send() diff --git a/tests/sentry/integrations/slack/notifications/test_unassigned.py b/tests/sentry/integrations/slack/notifications/test_unassigned.py index 352cfc89da9b..7f5c18cc2e6d 100644 --- a/tests/sentry/integrations/slack/notifications/test_unassigned.py +++ b/tests/sentry/integrations/slack/notifications/test_unassigned.py @@ -4,6 +4,7 @@ import responses from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.notifications.notifications.activity.unassigned import UnassignedActivityNotification from sentry.testutils.cases import PerformanceIssueTestCase, SlackActivityNotificationTest from sentry.testutils.helpers.notifications import TEST_ISSUE_OCCURRENCE, TEST_PERF_ISSUE_OCCURRENCE @@ -14,7 +15,7 @@ class SlackUnassignedNotificationTest(SlackActivityNotificationTest, PerformanceIssueTestCase): - def create_notification(self, group): + def create_notification(self, group: Group) -> UnassignedActivityNotification: return UnassignedActivityNotification( Activity( project=self.project, @@ -61,6 +62,7 @@ def test_unassignment_performance_issue_block_with_culprit_blocks( and block kit is enabled. """ event = self.create_performance_issue() + assert event.group is not None with self.tasks(): self.create_notification(event.group).send() diff --git a/tests/sentry/integrations/slack/service/test_slack_service.py b/tests/sentry/integrations/slack/service/test_slack_service.py index 38a25ed2ed27..699ad80fbd02 100644 --- a/tests/sentry/integrations/slack/service/test_slack_service.py +++ b/tests/sentry/integrations/slack/service/test_slack_service.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta from unittest import mock +from unittest.mock import MagicMock from uuid import uuid4 import orjson @@ -64,7 +65,7 @@ def test_simple(self) -> None: group_link = self.group.get_absolute_url( params={ "referrer": "activity_notification", - "notification_uuid": uuid, + "notification_uuid": str(uuid), } ) assert ( @@ -127,7 +128,7 @@ def test_none_user_id(self) -> None: extra={ "activity_id": self.activity.id, "project_id": self.activity.project.id, - "group_id": self.activity.group.id, + "group_id": self.group.id, "organization_id": self.organization.id, }, ) @@ -144,7 +145,7 @@ def test_disabled_option(self) -> None: extra={ "activity_id": self.activity.id, "project_id": self.activity.project.id, - "group_id": self.activity.group.id, + "group_id": self.group.id, "organization_id": self.organization.id, }, ) @@ -160,7 +161,7 @@ def test_no_message_to_send(self) -> None: extra={ "activity_id": self.activity.id, "project_id": self.activity.project.id, - "group_id": self.activity.group.id, + "group_id": self.group.id, "organization_id": self.organization.id, }, ) @@ -176,7 +177,7 @@ def test_no_integration(self) -> None: extra={ "activity_id": self.activity.id, "project_id": self.activity.project.id, - "group_id": self.activity.group.id, + "group_id": self.group.id, "organization_id": self.organization.id, }, ) @@ -189,8 +190,11 @@ def test_no_integration(self) -> None: "sentry.integrations.slack.service.SlackService._get_channel_id_from_parent_notification_notification_action" ) def test_calls_handle_parent_notification( - self, mock_get_channel_id, mock_send_notification, mock_record - ): + self, + mock_get_channel_id: MagicMock, + mock_send_notification: MagicMock, + mock_record: MagicMock, + ) -> None: self.service.notify_all_threads_for_activity(activity=self.activity) mock_get_channel_id.assert_called() @@ -212,7 +216,10 @@ def test_calls_handle_parent_notification( "sentry.integrations.slack.service.SlackService._get_channel_id_from_parent_notification_notification_action" ) def test_handle_parent_notification_with_open_period( - self, mock_get_channel_id, mock_send_notification, mock_record + self, + mock_get_channel_id: MagicMock, + mock_send_notification: MagicMock, + mock_record: MagicMock, ) -> None: group = self.create_group(type=UptimeDomainCheckFailure.type_id) @@ -295,7 +302,7 @@ def create_platform_threads(self, group: Group) -> None: "sentry.integrations.slack.service.SlackService._send_notification_to_slack_channel" ) def test_platform_threads_filtered_to_current_open_period( - self, mock_send_notification, mock_record + self, mock_send_notification: MagicMock, mock_record: MagicMock ) -> None: group = self.create_group(type=UptimeDomainCheckFailure.type_id) activity = Activity.objects.create( @@ -324,7 +331,7 @@ def test_platform_threads_filtered_to_current_open_period( "sentry.integrations.slack.service.SlackService._send_notification_to_slack_channel" ) def test_platform_threads_skipped_outside_platform_rollout( - self, mock_send_notification, mock_record + self, mock_send_notification: MagicMock, mock_record: MagicMock ) -> None: group = self.create_group(type=UptimeDomainCheckFailure.type_id) activity = Activity.objects.create( @@ -355,7 +362,10 @@ def test_platform_threads_skipped_outside_platform_rollout( "sentry.integrations.slack.service.SlackService._get_channel_id_from_parent_notification_notification_action" ) def test_handle_parent_notification_with_open_period_model_open_period_model( - self, mock_get_channel_id, mock_send_notification, mock_record + self, + mock_get_channel_id: MagicMock, + mock_send_notification: MagicMock, + mock_record: MagicMock, ) -> None: group = self.create_group(type=UptimeDomainCheckFailure.type_id) @@ -412,7 +422,10 @@ def test_handle_parent_notification_with_open_period_model_open_period_model( "sentry.integrations.slack.service.SlackService._get_channel_id_from_parent_notification_notification_action" ) def test_handle_parent_notification_with_open_period_uptime_resolved( - self, mock_get_channel_id, mock_send_notification, mock_record + self, + mock_get_channel_id: MagicMock, + mock_send_notification: MagicMock, + mock_record: MagicMock, ) -> None: group = self.create_group(type=UptimeDomainCheckFailure.type_id) @@ -485,8 +498,11 @@ def test_none_user_id_uptime_resolved(self) -> None: "sentry.integrations.slack.service.SlackService._get_channel_id_from_parent_notification_notification_action" ) def test_handle_parent_notification_with_notification_action( - self, mock_get_channel_id, mock_send_notification, mock_record - ): + self, + mock_get_channel_id: MagicMock, + mock_send_notification: MagicMock, + mock_record: MagicMock, + ) -> None: """Test that notification action repository is used when feature flag is enabled""" activity = Activity.objects.create( group=self.group, @@ -507,7 +523,7 @@ def test_handle_parent_notification_with_notification_action( group_link = self.group.get_absolute_url( params={ "referrer": "activity_notification", - "notification_uuid": uuid, + "notification_uuid": str(uuid), } ) @@ -533,8 +549,11 @@ def test_handle_parent_notification_with_notification_action( "sentry.integrations.slack.service.SlackService._get_channel_id_from_parent_notification_notification_action" ) def test_handle_parent_notification_with_notification_action_and_open_period( - self, mock_get_channel_id, mock_send_notification, mock_record - ): + self, + mock_get_channel_id: MagicMock, + mock_send_notification: MagicMock, + mock_record: MagicMock, + ) -> None: """Test that notification action repository is used with open period when both features are enabled""" group = self.create_group(type=UptimeDomainCheckFailure.type_id) diff --git a/tests/sentry/integrations/slack/tasks/test_tasks.py b/tests/sentry/integrations/slack/tasks/test_tasks.py index 4dee7d430d7a..46b610d3aeaa 100644 --- a/tests/sentry/integrations/slack/tasks/test_tasks.py +++ b/tests/sentry/integrations/slack/tasks/test_tasks.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from unittest.mock import MagicMock, patch import orjson @@ -19,7 +20,7 @@ def setUp(self) -> None: self.integration = install_slack(self.organization) @pytest.fixture(autouse=True) - def mock_chat_scheduleMessage(self): + def mock_chat_scheduleMessage(self) -> Iterator[None]: with mock_slack_response( "chat_scheduleMessage", body={"ok": True, "channel": "chan-id", "scheduled_message_id": "Q1298393284"}, @@ -27,7 +28,7 @@ def mock_chat_scheduleMessage(self): yield @pytest.fixture(autouse=True) - def mock_chat_deleteScheduledMessage(self): + def mock_chat_deleteScheduledMessage(self) -> Iterator[None]: with mock_slack_response( "chat_deleteScheduledMessage", body={"ok": True} ) as self.mock_delete: diff --git a/tests/sentry/integrations/slack/test_integration.py b/tests/sentry/integrations/slack/test_integration.py index 5503d93d2f88..525d68425c5b 100644 --- a/tests/sentry/integrations/slack/test_integration.py +++ b/tests/sentry/integrations/slack/test_integration.py @@ -17,6 +17,7 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.slack import SlackIntegration, SlackIntegrationProvider from sentry.integrations.slack.utils.constants import SlackScope +from sentry.integrations.slack.utils.errors import TypedSlackApiError from sentry.integrations.slack.utils.users import SLACK_GET_USERS_PAGE_SIZE from sentry.notifications.platform.service import NotificationService from sentry.notifications.platform.slack.provider import SlackNotificationProvider @@ -25,6 +26,7 @@ NotificationProviderKey, NotificationTargetResourceType, ) +from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.shared_integrations.exceptions import IntegrationConfigurationError, IntegrationError from sentry.testutils.cases import APITestCase, TestCase from sentry.testutils.notifications.platform import MockNotification, MockNotificationTemplate @@ -140,7 +142,13 @@ def test_link_multiple_users(self, mock_api_call: MagicMock) -> None: } with self.tasks(): - SlackIntegrationProvider().post_install(self.integration, self.organization, extra={}) + SlackIntegrationProvider().post_install( + self.integration, + serialize_rpc_organization( + self.organization, include_projects=False, include_teams=False + ), + extra={}, + ) user1_identity = Identity.objects.get(user=self.user) assert user1_identity @@ -194,7 +202,13 @@ def test_link_multiple_users_pagination(self, mock_api_call: MagicMock) -> None: ) with self.tasks(): - SlackIntegrationProvider().post_install(self.integration, self.organization, extra={}) + SlackIntegrationProvider().post_install( + self.integration, + serialize_rpc_organization( + self.organization, include_projects=False, include_teams=False + ), + extra={}, + ) user5_identity = Identity.objects.get(user=user5) assert user5_identity @@ -228,7 +242,13 @@ def test_link_multiple_users_pagination_error(self, mock_api_call: MagicMock) -> ) with self.tasks(), pytest.raises(SlackApiError): - SlackIntegrationProvider().post_install(self.integration, self.organization, extra={}) + SlackIntegrationProvider().post_install( + self.integration, + serialize_rpc_organization( + self.organization, include_projects=False, include_teams=False + ), + extra={}, + ) user5_identity = Identity.objects.filter(user=user5).first() assert user5_identity is None @@ -245,7 +265,13 @@ def test_email_no_match(self, mock_api_call: MagicMock) -> None: } with self.tasks(): - SlackIntegrationProvider().post_install(self.integration, self.organization, extra={}) + SlackIntegrationProvider().post_install( + self.integration, + serialize_rpc_organization( + self.organization, include_projects=False, include_teams=False + ), + extra={}, + ) identities = Identity.objects.all() assert identities.count() == 3 @@ -263,7 +289,13 @@ def test_update_identity(self, mock_api_call: MagicMock) -> None: } with self.tasks(): - SlackIntegrationProvider().post_install(self.integration, self.organization, extra={}) + SlackIntegrationProvider().post_install( + self.integration, + serialize_rpc_organization( + self.organization, include_projects=False, include_teams=False + ), + extra={}, + ) user3_identity = Identity.objects.get(user=self.user4) assert user3_identity @@ -355,7 +387,7 @@ def test_send_notification_api_error(self, mock_chat_post: MagicMock) -> None: mock_response.data = {"error": "channel_not_found"} mock_response.api_url = "https://slack.com/api/chat.postMessage" - mock_chat_post.side_effect = SlackApiError("channel_not_found", mock_response) + mock_chat_post.side_effect = TypedSlackApiError("channel_not_found", mock_response) with pytest.raises(IntegrationConfigurationError): self.installation.send_notification(target=self.target, payload=self.slack_renderable) @@ -378,7 +410,7 @@ def test_send_threaded_message_success(self, mock_chat_post: MagicMock) -> None: @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.chat_postMessage") def test_send_threaded_message_error(self, mock_chat_post: MagicMock) -> None: error_message = "thread_not_found" - mock_chat_post.side_effect = SlackApiError(error_message, MagicMock()) + mock_chat_post.side_effect = TypedSlackApiError(error_message, MagicMock()) with pytest.raises(IntegrationError, match=error_message): self.installation.send_threaded_message( @@ -412,7 +444,7 @@ def test_send_threaded_ephemeral_message_success(self, mock_chat_ephemeral: Magi @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.chat_postEphemeral") def test_send_threaded_ephemeral_message_error(self, mock_chat_ephemeral: MagicMock) -> None: error_message = "user_not_in_channel" - mock_chat_ephemeral.side_effect = SlackApiError(error_message, MagicMock()) + mock_chat_ephemeral.side_effect = TypedSlackApiError(error_message, MagicMock()) with pytest.raises(IntegrationError, match=error_message): self.installation.send_threaded_ephemeral_message( @@ -528,7 +560,9 @@ def test_get_thread_history_error_returns_empty_list( mock_conversations_info.return_value = MagicMock( data={"ok": True, "channel": {"is_channel": True, "is_private": False}} ) - mock_conversations_replies.side_effect = SlackApiError("channel_not_found", MagicMock()) + mock_conversations_replies.side_effect = TypedSlackApiError( + "channel_not_found", MagicMock() + ) result = self.installation.get_thread_history( channel_id=self.channel_id, thread_ts=self.thread_ts ) @@ -565,7 +599,9 @@ def _advance_step(self, data: dict[str, Any]) -> Any: return self.client.post(self._get_pipeline_url(), data=data, format="json") def _get_pipeline_signature(self, resp: Any) -> str: - return resp.data["data"]["oauthUrl"].split("state=")[1].split("&")[0] + oauth_url = resp.data["data"]["oauthUrl"] + assert isinstance(oauth_url, str) + return oauth_url.split("state=")[1].split("&")[0] @responses.activate def test_initialize_pipeline(self) -> None: diff --git a/tests/sentry/integrations/slack/test_link_identity.py b/tests/sentry/integrations/slack/test_link_identity.py index 9906fb7560ae..0f0a803b361a 100644 --- a/tests/sentry/integrations/slack/test_link_identity.py +++ b/tests/sentry/integrations/slack/test_link_identity.py @@ -3,12 +3,13 @@ import pytest from django.http import Http404 -from slack_sdk.errors import SlackApiError from slack_sdk.web import SlackResponse from slack_sdk.webhook import WebhookResponse from sentry.constants import ObjectStatus from sentry.integrations.messaging.linkage import UnlinkIdentityView +from sentry.integrations.services.integration.serial import serialize_integration +from sentry.integrations.slack.utils.errors import TypedSlackApiError from sentry.integrations.slack.views.link_identity import ( SUCCESS_LINKED_MESSAGE, build_linking_url, @@ -107,6 +108,7 @@ def setUp(self) -> None: self.response_url = "http://example.slack.com/response_url" self.integration = install_slack(self.organization) + self.rpc_integration = serialize_integration(self.integration) self.idp = add_identity(self.integration, self.user, self.external_id) @pytest.fixture(autouse=True) @@ -144,7 +146,7 @@ class SlackIntegrationLinkIdentityTest(SlackIntegrationLinkIdentityTestBase): def test_basic_flow_with_webhook_client(self) -> None: """Do the auth flow and assert that the identity was created.""" linking_url = build_linking_url( - self.integration, self.external_id, self.channel_id, self.response_url + self.rpc_integration, self.external_id, self.channel_id, self.response_url ) # Load page. @@ -164,10 +166,10 @@ def test_basic_flow_with_webhook_client(self) -> None: def test_basic_flow_with_webhook_client_error(self) -> None: """Do the auth flow and assert that the identity was created.""" - self.mock_webhook.side_effect = SlackApiError("", response={"ok": False}) + self.mock_webhook.side_effect = TypedSlackApiError("", response={"ok": False}) linking_url = build_linking_url( - self.integration, self.external_id, self.channel_id, self.response_url + self.rpc_integration, self.external_id, self.channel_id, self.response_url ) # Load page. @@ -184,7 +186,7 @@ def test_basic_flow_with_webhook_client_error(self) -> None: def test_basic_flow_with_web_client(self) -> None: """No response URL is provided, so we use WebClient.""" - linking_url = build_linking_url(self.integration, self.external_id, self.channel_id, "") + linking_url = build_linking_url(self.rpc_integration, self.external_id, self.channel_id, "") # Load page. response = self.client.get(linking_url) @@ -204,9 +206,9 @@ def test_basic_flow_with_web_client(self) -> None: @patch("sentry.integrations.slack.utils.notifications._logger") def test_basic_flow_with_web_client_error(self, mock_logger: MagicMock) -> None: """No response URL is provided, so we use WebClient.""" - self.mock_post.side_effect = SlackApiError("", response={"ok": False}) + self.mock_post.side_effect = TypedSlackApiError("", response={"ok": False}) - linking_url = build_linking_url(self.integration, self.external_id, self.channel_id, "") + linking_url = build_linking_url(self.rpc_integration, self.external_id, self.channel_id, "") # Load page. response = self.client.get(linking_url) @@ -244,7 +246,7 @@ def test_replays_cached_pending_mention_on_link(self, mock_apply_async: MagicMoc ) linking_url = build_linking_url( - self.integration, self.external_id, self.channel_id, self.response_url + self.rpc_integration, self.external_id, self.channel_id, self.response_url ) self.client.post(linking_url) @@ -284,7 +286,7 @@ def test_dispatches_update_linking_when_response_url_present( ) linking_url = build_linking_url( - self.integration, self.external_id, self.channel_id, self.response_url + self.rpc_integration, self.external_id, self.channel_id, self.response_url ) self.client.post(linking_url) @@ -293,7 +295,7 @@ def test_dispatches_update_linking_when_response_url_present( @patch("sentry.integrations.slack.views.link_identity.route_slack_seer_event.apply_async") def test_no_replay_when_cache_empty(self, mock_apply_async: MagicMock) -> None: linking_url = build_linking_url( - self.integration, self.external_id, self.channel_id, self.response_url + self.rpc_integration, self.external_id, self.channel_id, self.response_url ) self.client.post(linking_url) @@ -303,7 +305,7 @@ def test_standard_flow_does_not_replace_original(self) -> None: """When response_url comes from linking URL params (e.g. issue card flow), the webhook must NOT replace the original message.""" linking_url = build_linking_url( - self.integration, self.external_id, self.channel_id, self.response_url + self.rpc_integration, self.external_id, self.channel_id, self.response_url ) self.client.post(linking_url) @@ -326,7 +328,7 @@ def test_stashed_flow_replaces_original(self) -> None: ) linking_url = build_linking_url( - self.integration, self.external_id, self.channel_id, self.response_url + self.rpc_integration, self.external_id, self.channel_id, self.response_url ) self.client.post(linking_url) @@ -349,7 +351,7 @@ def test_overwrites_existing_identities_with_sdk(self) -> None: ) linking_url = build_linking_url( - self.integration, external_id_2, self.channel_id, self.response_url + self.rpc_integration, external_id_2, self.channel_id, self.response_url ) self.client.post(linking_url) diff --git a/tests/sentry/integrations/slack/test_link_team.py b/tests/sentry/integrations/slack/test_link_team.py index bae65d97ec3d..a8f8791c2be0 100644 --- a/tests/sentry/integrations/slack/test_link_team.py +++ b/tests/sentry/integrations/slack/test_link_team.py @@ -1,4 +1,4 @@ -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence from typing import Any, Optional from unittest.mock import patch from urllib.parse import urlencode @@ -38,7 +38,7 @@ def setUp(self) -> None: self.idp = add_identity(self.integration, self.user, self.external_id) @pytest.fixture(autouse=True) - def mock_chat_postMessage(self): + def mock_chat_postMessage(self) -> Iterator[None]: with patch( "slack_sdk.web.WebClient.chat_postMessage", return_value=SlackResponse( @@ -56,7 +56,7 @@ def mock_chat_postMessage(self): def get_success_response(self, data: Mapping[str, Any] | None = None) -> HttpResponseBase: """This isn't in APITestCase so this isn't really an override.""" if data is not None: - response = self.client.post( + response: HttpResponseBase = self.client.post( self.url, urlencode(data), content_type="application/x-www-form-urlencoded" ) else: @@ -70,7 +70,7 @@ def get_error_response( status_code: int = status.HTTP_404_NOT_FOUND, ) -> HttpResponseBase: if data: - response = self.client.post( + response: HttpResponseBase = self.client.post( self.url, urlencode(data), content_type="application/x-www-form-urlencoded" ) else: @@ -90,7 +90,7 @@ def link_team(self, team: Optional["Team"] = None) -> None: def get_linked_teams( self, team_ids: Sequence[int] | None = None, organization: Organization | None = None - ) -> QuerySet: + ) -> QuerySet[ExternalActor]: team_ids = team_ids or [self.team.id] organization = organization or self.organization return ExternalActor.objects.filter( @@ -102,7 +102,7 @@ def get_linked_teams( external_id=self.channel_id, ) - def _create_user_valid_through_team_admin(self): + def _create_user_valid_through_team_admin(self) -> None: user = self.create_user(email="foo@example.com") self.create_member( team_roles=[(self.team, "admin")], @@ -222,7 +222,7 @@ def setUp(self) -> None: ) @pytest.fixture(autouse=True) - def mock_chat_postMessage(self): + def mock_chat_postMessage(self) -> Iterator[None]: with patch( "slack_sdk.web.WebClient.chat_postMessage", return_value=SlackResponse( diff --git a/tests/sentry/integrations/slack/test_message_builder.py b/tests/sentry/integrations/slack/test_message_builder.py index 177213f2380f..d7ab09c706a6 100644 --- a/tests/sentry/integrations/slack/test_message_builder.py +++ b/tests/sentry/integrations/slack/test_message_builder.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import datetime from typing import Any from unittest.mock import MagicMock, Mock, patch @@ -771,6 +772,7 @@ def test_issue_alert_with_suggested_assignees(self) -> None: # suggested user/suspect commit for user with name with assume_test_silo_mode(SiloMode.CONTROL), outbox_runner(): user2.update(name="Scooby Doo") + assert commit.author is not None commit.author.update(name=user2.name) expected_blocks = build_test_message_blocks( teams={self.team}, @@ -1083,7 +1085,9 @@ def test_identity_and_action(self) -> None: group, self.project, "test txt", [MessageAction(name="TEST")], MOCKIDENTITY ) == ([], "", True) - def _assert_message_actions_list(self, actions, expected): + def _assert_message_actions_list( + self, actions: Sequence[MessageAction], expected: dict[str, Any] + ) -> None: actions_dict = [ {"name": a.name, "label": a.label, "type": a.type, "value": a.value} for a in actions ] @@ -1339,9 +1343,7 @@ def test_get_context_users_affected(self) -> None: def test_get_tags(self) -> None: # don't use default tags. if we don't pass in tags to get_tags, we don't return any - tags = get_tags( - self.endpoint_regression_issue, self.endpoint_regression_issue.get_latest_event() - ) + tags = get_tags(self.endpoint_regression_issue.get_latest_event()) assert not tags @@ -1352,7 +1354,10 @@ def _build_nudge_text(self, has_mentions_read_scope: bool) -> str: group, has_mentions_read_scope=has_mentions_read_scope, ).get_slack_app_update_nudge_block() - return block["elements"][0]["text"] + text = block["elements"][0]["text"] + if not isinstance(text, str): + raise TypeError("Nudge block text is not a string") + return text def test_updated_app_has_app_mentions_scope(self) -> None: # app_mentions:read present -> app is up to date. diff --git a/tests/sentry/integrations/slack/test_notifications.py b/tests/sentry/integrations/slack/test_notifications.py index b84dda3b0e96..164cef8d4c5c 100644 --- a/tests/sentry/integrations/slack/test_notifications.py +++ b/tests/sentry/integrations/slack/test_notifications.py @@ -1,17 +1,21 @@ from unittest.mock import patch import orjson -from slack_sdk.errors import SlackApiError from slack_sdk.web import SlackResponse +from sentry.integrations.models.integration import Integration from sentry.integrations.slack.notifications import send_notification_as_slack +from sentry.integrations.slack.utils.errors import TypedSlackApiError from sentry.integrations.types import ExternalProviders +from sentry.models.organization import Organization from sentry.notifications.additional_attachment_manager import manager from sentry.testutils.cases import SlackActivityNotificationTest from sentry.testutils.helpers.notifications import DummyNotification -def additional_attachment_generator_block_kit(integration, organization): +def additional_attachment_generator_block_kit( + integration: Integration, organization: Organization +) -> list[dict[str, object]]: return [ {"type": "section", "text": {"type": "mrkdwn", "text": organization.slug}}, {"type": "section", "text": {"type": "mrkdwn", "text": integration.id}}, @@ -120,7 +124,7 @@ def test_send_notification_as_slack_error(self) -> None: ), patch( "slack_sdk.web.client.WebClient.chat_postMessage", - side_effect=SlackApiError("error", mock_slack_response), + side_effect=TypedSlackApiError("error", mock_slack_response), ), ): with self.tasks(): diff --git a/tests/sentry/integrations/slack/test_notify_action.py b/tests/sentry/integrations/slack/test_notify_action.py index 88e4218dc3f0..c962987e0799 100644 --- a/tests/sentry/integrations/slack/test_notify_action.py +++ b/tests/sentry/integrations/slack/test_notify_action.py @@ -1,10 +1,12 @@ +from __future__ import annotations + from datetime import datetime, timezone +from typing import Any from unittest import mock -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, _patch, patch import orjson import responses -from slack_sdk.errors import SlackApiError from slack_sdk.web.slack_response import SlackResponse from sentry.analytics.events.alert_sent import AlertSentEvent @@ -13,6 +15,7 @@ from sentry.integrations.slack.actions.form import SlackNotifyServiceForm from sentry.integrations.slack.analytics import SlackIntegrationNotificationSent from sentry.integrations.slack.utils.constants import SLACK_RATE_LIMITED_MESSAGE +from sentry.integrations.slack.utils.errors import TypedSlackApiError from sentry.integrations.types import ExternalProviders from sentry.notifications.additional_attachment_manager import manager from sentry.notifications.types import NotificationActionContext, NotificationOrigin @@ -39,17 +42,26 @@ class SlackNotifyActionTest(RuleTestCase): rule_cls = SlackNotifyServiceAction - def mock_list(self, list_type, channels, result_name="channels"): + def get_slack_notify_rule(self, **kwargs: Any) -> SlackNotifyServiceAction: + rule = self.get_rule(**kwargs) + assert isinstance(rule, SlackNotifyServiceAction) + return rule + + def mock_list( + self, list_type: str, channels: object, result_name: str = "channels" + ) -> _patch[MagicMock]: return mock_slack_response(f"{list_type}_list", body={"ok": True, result_name: channels}) - def mock_conversations_info(self, channel): + def mock_conversations_info(self, channel: object) -> _patch[MagicMock]: return mock_slack_response( "conversations_info", body={"ok": True, "channel": channel}, req_args={"channel": channel}, ) - def mock_msg_schedule_response(self, channel_id, result_name="channel"): + def mock_msg_schedule_response( + self, channel_id: str, result_name: str = "channel" + ) -> _patch[MagicMock]: if channel_id == "channel_not_found": body = {"ok": False, "error": "channel_not_found"} else: @@ -60,7 +72,9 @@ def mock_msg_schedule_response(self, channel_id, result_name="channel"): } return mock_slack_response("chat_scheduleMessage", body) - def mock_msg_delete_scheduled_response(self, channel_id, result_name="channel"): + def mock_msg_delete_scheduled_response( + self, channel_id: str, result_name: str = "channel" + ) -> _patch[MagicMock]: return mock_slack_response("chat_deleteScheduledMessage", {"ok": True}) def setUp(self) -> None: @@ -78,7 +92,12 @@ def setUp(self) -> None: provider="slack", ) - def assert_form_valid(self, form, expected_channel_id, expected_channel): + def assert_form_valid( + self, + form: SlackNotifyServiceForm, + expected_channel_id: str, + expected_channel: str, + ) -> None: assert form.is_valid() assert form.cleaned_data["channel_id"] == expected_channel_id assert form.cleaned_data["channel"] == expected_channel @@ -95,13 +114,13 @@ def assert_form_valid(self, form, expected_channel_id, expected_channel): def test_no_upgrade_notice_bot_app( self, mock_api_call: MagicMock, mock_post: MagicMock ) -> None: - event = self.get_event() + event = self.get_group_event() fake_rule = self.create_project_rule() action = self.create_action(integration_id=self.integration.id) fake_rule.id = action.id - rule = self.get_rule( + rule = self.get_slack_notify_rule( data={"workspace": self.integration.id, "channel": "#my-channel"}, context=create_notification_action_context(fake_rule, with_workflow=False), ) @@ -132,11 +151,11 @@ def test_test_send_skips_nudge(self, mock_api_call: MagicMock, mock_post: MagicM # Rule test sends use action_id -1. They are not real issue alerts, so they must # not append the reinstall nudge nor consume the per-channel weekly budget, even # with the feature flag on and the sampling gate forced open. - event = self.get_event() + event = self.get_group_event() fake_rule = self.create_project_rule() fake_rule.id = -1 - rule = self.get_rule( + rule = self.get_slack_notify_rule( data={ "workspace": self.integration.id, "channel": "#my-channel", @@ -166,7 +185,7 @@ def test_test_send_skips_nudge(self, mock_api_call: MagicMock, mock_post: MagicM assert cache.get(f"slack:alert_nudge:123:{iso_year}:{iso_week}") is None def test_render_label_with_notes(self) -> None: - rule = self.get_rule( + rule = self.get_slack_notify_rule( data={ "workspace": self.integration.id, "channel": "#my-channel", @@ -185,7 +204,7 @@ def test_render_label_without_integration(self) -> None: with assume_test_silo_mode(SiloMode.CONTROL): self.integration.delete() - rule = self.get_rule( + rule = self.get_slack_notify_rule( data={ "workspace": self.integration.id, "channel": "#my-channel", @@ -211,7 +230,7 @@ def test_valid_bot_channel_selected(self) -> None: "installation_type": "born_as_bot", }, ) - rule = self.get_rule( + rule = self.get_slack_notify_rule( data={"workspace": integration.id, "channel": "#my-channel", "tags": ""} ) @@ -223,7 +242,7 @@ def test_valid_bot_channel_selected(self) -> None: @responses.activate def test_valid_member_selected(self) -> None: - rule = self.get_rule( + rule = self.get_slack_notify_rule( data={"workspace": self.integration.id, "channel": "@morty", "tags": ""} ) @@ -242,7 +261,7 @@ def test_valid_member_selected(self) -> None: self.assert_form_valid(form, "morty-id", "@morty") def test_invalid_channel_selected(self) -> None: - rule = self.get_rule( + rule = self.get_slack_notify_rule( data={"workspace": self.integration.id, "channel": "#my-channel", "tags": ""} ) @@ -260,7 +279,7 @@ def test_invalid_channel_selected(self) -> None: def test_rate_limited_response(self, mock_api_call: MagicMock) -> None: """Should surface a 429 from Slack to the frontend form""" - mock_api_call.side_effect = SlackApiError( + mock_api_call.side_effect = TypedSlackApiError( message="ratelimited", response=SlackResponse( client=None, @@ -274,7 +293,7 @@ def test_rate_limited_response(self, mock_api_call: MagicMock) -> None: ) with self.mock_msg_schedule_response("channel_not_found"): - rule = self.get_rule( + rule = self.get_slack_notify_rule( data={ "workspace": self.integration.id, "channel": "#my-channel", @@ -290,7 +309,7 @@ def test_rate_limited_response(self, mock_api_call: MagicMock) -> None: def test_channel_id_provided_sdk(self) -> None: channel = {"name": "my-channel", "id": "C2349874"} with self.mock_conversations_info(channel): - rule = self.get_rule( + rule = self.get_slack_notify_rule( data={ "workspace": self.integration.id, "channel": "#my-channel", @@ -305,9 +324,11 @@ def test_channel_id_provided_sdk(self) -> None: def test_invalid_channel_id_provided_sdk(self) -> None: with patch( "slack_sdk.web.client.WebClient.conversations_info", - side_effect=SlackApiError("", response={"ok": False, "error": "channel_not_found"}), + side_effect=TypedSlackApiError( + "", response={"ok": False, "error": "channel_not_found"} + ), ): - rule = self.get_rule( + rule = self.get_slack_notify_rule( data={ "workspace": self.integration.id, "channel": "#my-chanel", @@ -323,7 +344,7 @@ def test_invalid_channel_id_provided_sdk(self) -> None: def test_invalid_channel_name_provided_sdk(self) -> None: channel = {"name": "my-channel", "id": "C2349874"} with self.mock_conversations_info(channel): - rule = self.get_rule( + rule = self.get_slack_notify_rule( data={ "workspace": self.integration.id, "channel": "#my-chanel", @@ -341,7 +362,9 @@ def test_invalid_channel_name_provided_sdk(self) -> None: def test_invalid_workspace(self) -> None: # the workspace _should_ be the integration id - rule = self.get_rule(data={"workspace": "unknown", "channel": "#my-channel", "tags": ""}) + rule = self.get_slack_notify_rule( + data={"workspace": "unknown", "channel": "#my-channel", "tags": ""} + ) form = SlackNotifyServiceForm(rule.data, integrations=rule.get_integrations()) assert not form.is_valid() @@ -349,7 +372,7 @@ def test_invalid_workspace(self) -> None: @responses.activate def test_display_name_conflict(self) -> None: - rule = self.get_rule( + rule = self.get_slack_notify_rule( data={"workspace": self.integration.id, "channel": "@morty", "tags": ""} ) @@ -376,9 +399,11 @@ def test_disabled_org_integration(self) -> None: ) with assume_test_silo_mode(SiloMode.CONTROL): self.org_integration.update(status=ObjectStatus.DISABLED) - event = self.get_event() + event = self.get_group_event() - rule = self.get_rule(data={"workspace": self.integration.id, "channel": "#my-channel"}) + rule = self.get_slack_notify_rule( + data={"workspace": self.integration.id, "channel": "#my-channel"} + ) results = list(rule.after(event=event)) assert len(results) == 0 @@ -401,12 +426,12 @@ def test_additional_attachment( manager.attachment_generators, {ExternalProviders.SLACK: additional_attachment_generator_block_kit}, ): - event = self.get_event() + event = self.get_group_event() fake_rule = self.create_project_rule() action = self.create_action(integration_id=self.integration.id) fake_rule.id = action.id - rule = self.get_rule( + rule = self.get_slack_notify_rule( data={ "workspace": self.integration.id, "channel": "#my-channel", @@ -456,9 +481,11 @@ def test_multiple_integrations(self) -> None: org = self.create_organization(owner=self.user) self.create_organization_integration(organization_id=org.id, integration=self.integration) - event = self.get_event() + event = self.get_group_event() - rule = self.get_rule(data={"workspace": self.integration.id, "channel": "#my-channel"}) + rule = self.get_slack_notify_rule( + data={"workspace": self.integration.id, "channel": "#my-channel"} + ) results = list(rule.after(event=event)) assert len(results) == 1 diff --git a/tests/sentry/integrations/slack/test_requests.py b/tests/sentry/integrations/slack/test_requests.py index a8d423172472..68f0877d1621 100644 --- a/tests/sentry/integrations/slack/test_requests.py +++ b/tests/sentry/integrations/slack/test_requests.py @@ -20,12 +20,13 @@ from sentry.integrations.slack.utils.auth import set_signing_secret from sentry.integrations.slack.utils.constants import SlackScope from sentry.integrations.slack.webhooks.base import SlackDMEndpoint -from sentry.models.organization import OrganizationStatus +from sentry.models.organization import Organization, OrganizationStatus from sentry.models.organizationmember import InviteStatus, OrganizationMember from sentry.silo.base import SiloMode from sentry.testutils.cases import TestCase from sentry.testutils.helpers import override_options from sentry.testutils.silo import assume_test_silo_mode, assume_test_silo_mode_of, control_silo_test +from sentry.users.models.identity import Identity @control_silo_test @@ -429,7 +430,7 @@ def setUp(self) -> None: self.slack_request.authorize() self.slack_request.validate_integration() - def _add_second_org(self, slug: str = "second-org"): + def _add_second_org(self, slug: str = "second-org") -> Organization: org = self.create_organization(slug=slug, owner=self.slack_user) self.create_organization_integration(organization_id=org.id, integration=self.integration) return org @@ -460,27 +461,27 @@ def _build_request(self, *, text: str = "", thread_ts: str = "") -> SlackEventRe slack_request.validate_integration() return slack_request - def test_identity_not_linked(self): - with assume_test_silo_mode_of(self.identity): + def test_identity_not_linked(self) -> None: + with assume_test_silo_mode_of(Identity): self.identity.delete() result = self.slack_request.resolve_seer_organization() assert result.organization_id is None assert result.halt_reason == SeerSlackHaltReason.IDENTITY_NOT_LINKED - def test_no_organization_integrations(self): + def test_no_organization_integrations(self) -> None: with assume_test_silo_mode_of(OrganizationIntegration): OrganizationIntegration.objects.filter(integration_id=self.integration.id).delete() result = self.slack_request.resolve_seer_organization() assert result.organization_id is None assert result.halt_reason == SeerSlackHaltReason.NO_VALID_INTEGRATION - def test_org_not_found(self): + def test_org_not_found(self) -> None: self.organization.delete() result = self.slack_request.resolve_seer_organization() assert result.organization_id is None assert result.halt_reason == SeerSlackHaltReason.NO_VALID_ORGANIZATION - def test_org_not_active(self): + def test_org_not_active(self) -> None: self.organization.update(status=OrganizationStatus.PENDING_DELETION) result = self.slack_request.resolve_seer_organization() assert result.organization_id is None @@ -490,20 +491,20 @@ def test_org_not_active(self): "sentry.integrations.slack.requests.event.SeerAgentOperator.has_access", return_value=False, ) - def test_org_no_seer_access(self, mock_access): + def test_org_no_seer_access(self, mock_access: MagicMock) -> None: result = self.slack_request.resolve_seer_organization() assert result.organization_id is None assert result.halt_reason == SeerSlackHaltReason.NO_VALID_ORGANIZATION - def test_user_not_member(self): + def test_user_not_member(self) -> None: non_member = self.create_user() - with assume_test_silo_mode_of(self.identity): + with assume_test_silo_mode_of(Identity): self.identity.update(user=non_member) result = self.slack_request.resolve_seer_organization() assert result.organization_id is None assert result.halt_reason == SeerSlackHaltReason.NO_VALID_ORGANIZATION - def test_user_membership_unapproved(self): + def test_user_membership_unapproved(self) -> None: OrganizationMember.objects.filter( user_id=self.slack_user.id, organization_id=self.organization.id ).update(invite_status=InviteStatus.REQUESTED_TO_JOIN.value) @@ -515,7 +516,7 @@ def test_user_membership_unapproved(self): "sentry.integrations.slack.requests.event.SeerAgentOperator.has_access", return_value=True, ) - def test_resolves_valid_organization(self, mock_access): + def test_resolves_valid_organization(self, mock_access: MagicMock) -> None: result = self.slack_request.resolve_seer_organization() assert result.organization_id == self.organization.id assert result.halt_reason is None @@ -524,7 +525,7 @@ def test_resolves_valid_organization(self, mock_access): "sentry.integrations.slack.requests.event.SeerAgentOperator.has_access", return_value=False, ) - def test_control_silo_skips_access_check(self, mock_has_access): + def test_control_silo_skips_access_check(self, mock_has_access: MagicMock) -> None: """ In control silo, has_access is not consulted (it depends on subscription context that getsentry's FlagpoleFeatureHandler does not populate in control silo). @@ -541,7 +542,7 @@ def test_control_silo_skips_access_check(self, mock_has_access): "sentry.integrations.slack.requests.event.SeerAgentOperator.has_access", return_value=True, ) - def test_cell_silo_uses_full_access_check(self, mock_has_access): + def test_cell_silo_uses_full_access_check(self, mock_has_access: MagicMock) -> None: """ In cell silo, has_access (the subscription-gated check) is the gate. """ @@ -557,7 +558,9 @@ def test_cell_silo_uses_full_access_check(self, mock_has_access): "sentry.integrations.slack.requests.event.SeerAgentOperator.has_access", return_value=True, ) - def test_multi_org_resolves_from_message_link(self, mock_access, mock_get_thread_history): + def test_multi_org_resolves_from_message_link( + self, mock_access: MagicMock, mock_get_thread_history: MagicMock + ) -> None: other_org = self._add_second_org() slack_request = self._build_request( text=f"<@U_BOT> what about ?" @@ -573,7 +576,9 @@ def test_multi_org_resolves_from_message_link(self, mock_access, mock_get_thread "sentry.integrations.slack.requests.event.SeerAgentOperator.has_access", return_value=True, ) - def test_multi_org_resolves_from_thread(self, mock_access, mock_get_thread_history): + def test_multi_org_resolves_from_thread( + self, mock_access: MagicMock, mock_get_thread_history: MagicMock + ) -> None: other_org = self._add_second_org() mock_get_thread_history.return_value = [ {"user": "U_OTHER", "text": f""}, @@ -588,7 +593,7 @@ def test_multi_org_resolves_from_thread(self, mock_access, mock_get_thread_histo "sentry.integrations.slack.requests.event.SeerAgentOperator.has_access", return_value=True, ) - def test_multi_org_ignores_link_for_unavailable_org(self, mock_access): + def test_multi_org_ignores_link_for_unavailable_org(self, mock_access: MagicMock) -> None: self._add_second_org() slack_request = self._build_request( text="<@U_BOT> " @@ -602,9 +607,11 @@ def test_multi_org_ignores_link_for_unavailable_org(self, mock_access): "sentry.integrations.slack.requests.event.SeerAgentOperator.has_access", return_value=True, ) - def test_multi_org_resolves_from_preference(self, mock_access, mock_get_thread_history): + def test_multi_org_resolves_from_preference( + self, mock_access: MagicMock, mock_get_thread_history: MagicMock + ) -> None: other_org = self._add_second_org() - with assume_test_silo_mode_of(self.identity): + with assume_test_silo_mode_of(Identity): self.identity.update(data={PREFERRED_ORGANIZATION_ID_KEY: other_org.id}) slack_request = self._build_request(text="<@U_BOT> help", thread_ts="0.9") @@ -617,8 +624,10 @@ def test_multi_org_resolves_from_preference(self, mock_access, mock_get_thread_h "sentry.integrations.slack.requests.event.SeerAgentOperator.has_access", return_value=True, ) - def test_multi_org_ignores_stale_preference(self, mock_access, mock_get_thread_history): - with assume_test_silo_mode_of(self.identity): + def test_multi_org_ignores_stale_preference( + self, mock_access: MagicMock, mock_get_thread_history: MagicMock + ) -> None: + with assume_test_silo_mode_of(Identity): self.identity.update(data={PREFERRED_ORGANIZATION_ID_KEY: 9999999}) slack_request = self._build_request(text="<@U_BOT> help", thread_ts="0.9") diff --git a/tests/sentry/integrations/slack/test_unfurl.py b/tests/sentry/integrations/slack/test_unfurl.py index 3247a615ef0e..2aa291715e49 100644 --- a/tests/sentry/integrations/slack/test_unfurl.py +++ b/tests/sentry/integrations/slack/test_unfurl.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from datetime import datetime, timedelta from typing import Any from unittest.mock import MagicMock, patch @@ -15,7 +16,12 @@ from sentry.integrations.slack.unfurl.explore import _build_heatmap_query, _heatmap_y_buckets from sentry.integrations.slack.unfurl.handlers import link_handlers, match_link from sentry.integrations.slack.unfurl.types import LinkType, UnfurlableUrl -from sentry.models.dashboard_widget import DashboardWidgetDisplayTypes, DashboardWidgetTypes +from sentry.models.dashboard import Dashboard +from sentry.models.dashboard_widget import ( + DashboardWidget, + DashboardWidgetDisplayTypes, + DashboardWidgetTypes, +) from sentry.search.eap.types import SupportedTraceItemType from sentry.snuba import discover, errors, transactions from sentry.testutils.cases import TestCase @@ -202,7 +208,7 @@ ), ], ) -def test_match_link(url, expected) -> None: +def test_match_link(url: str, expected: tuple[LinkType | None, Mapping[str, Any] | None]) -> None: assert match_link(url) == expected @@ -214,7 +220,7 @@ def setUp(self) -> None: # Sharing project ids across tests could result in some race conditions self.project = self.create_project() self._integration = install_slack(self.organization) - self.integration = serialize_integration(self._integration) + self.rpc_integration = serialize_integration(self._integration) self.request = RequestFactory().get("slack/event") self.frozen_time = freeze_time(datetime.now() - timedelta(days=1)) @@ -242,7 +248,7 @@ def test_unfurl_issues(self) -> None: ), ] - unfurls = link_handlers[LinkType.ISSUES].fn(self.integration, links) + unfurls = link_handlers[LinkType.ISSUES].fn(self.rpc_integration, links) assert unfurls[links[0].url] == SlackIssuesMessageBuilder(self.group).build() assert ( @@ -271,7 +277,7 @@ def test_unfurl_issues_block_kit(self) -> None: ), ] - unfurls = link_handlers[LinkType.ISSUES].fn(self.integration, links) + unfurls = link_handlers[LinkType.ISSUES].fn(self.rpc_integration, links) assert unfurls[links[0].url] == SlackIssuesMessageBuilder(self.group).build() assert ( @@ -296,7 +302,7 @@ def test_escape_issue(self) -> None: ), ] - unfurls = link_handlers[LinkType.ISSUES].fn(self.integration, links) + unfurls = link_handlers[LinkType.ISSUES].fn(self.rpc_integration, links) assert unfurls[links[0].url]["blocks"][1]["text"]["text"] == "```" + escape_text + "```" @patch( @@ -321,7 +327,7 @@ def test_unfurl_discover(self, mock_generate_chart: MagicMock, _: MagicMock) -> ] with self.feature(["organizations:discover-basic"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -351,7 +357,7 @@ def test_unfurl_discover_without_fields_or_saved_query( ] with self.feature(["organizations:discover-basic"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -381,7 +387,7 @@ def test_unfurl_discover_top5_without_fields_or_saved_query( ] with self.feature(["organizations:discover-basic"]): - link_handlers[link_type].fn(self.integration, links, self.user) + link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(mock_generate_chart.mock_calls) == 1 assert api_mock.call_args[1]["params"].getlist("field") == [] @@ -412,7 +418,7 @@ def test_unfurl_discover_previous_period( ] with self.feature(["organizations:discover-basic"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -460,7 +466,7 @@ def test_unfurl_discover_multi_y_axis( ] with self.feature(["organizations:discover-basic"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -498,7 +504,7 @@ def test_unfurl_discover_html_escaped( ] with self.feature(["organizations:discover-basic"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -563,7 +569,7 @@ def test_unfurl_discover_short_url(self, mock_generate_chart: MagicMock, _: Magi "organizations:discover-basic", ] ): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -628,7 +634,7 @@ def test_unfurl_correct_y_axis_for_saved_query( "organizations:discover-basic", ] ): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -679,7 +685,7 @@ def test_top_events_url_param(self, mock_generate_chart: MagicMock, _: MagicMock "organizations:discover-basic", ] ): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -746,7 +752,7 @@ def test_top_daily_events_renders_bar_chart( "organizations:discover-basic", ] ): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -806,7 +812,7 @@ def test_unfurl_discover_short_url_without_project_ids( "organizations:discover-basic", ] ): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -832,8 +838,8 @@ def test_unfurl_discover_short_url_without_project_ids( ) @patch("sentry.charts.backend.generate_chart", return_value="chart-url") def test_unfurl_discover_without_project_ids( - self, mock_generate_chart, mock_get_event_stats_data - ): + self, mock_generate_chart: MagicMock, mock_get_event_stats_data: MagicMock + ) -> None: url = f"https://sentry.io/organizations/{self.organization.slug}/discover/results/?dataset=errors&field=title&field=event.type&field=project&field=user.display&field=timestamp&name=All+Events&query=&sort=-timestamp&statsPeriod=24h" link_type, args = match_link(url) @@ -849,7 +855,7 @@ def test_unfurl_discover_without_project_ids( "organizations:discover-basic", ] ): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -906,7 +912,7 @@ def test_bar_chart_display_renders_bar_chart( "organizations:discover-basic", ] ): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -937,7 +943,7 @@ def test_bar_chart_interval_with_absolute_date( "organizations:discover-basic", ] ): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -973,7 +979,7 @@ def test_bar_chart_interval_with_periodic_date( "organizations:discover-basic", ] ): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -1025,7 +1031,7 @@ def test_saved_query_with_interval( "organizations:discover-basic", ] ): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -1077,7 +1083,7 @@ def test_saved_query_with_dataset( "organizations:discover-basic", ] ): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -1116,7 +1122,7 @@ def test_unfurl_discover_homepage( ] with self.feature(["organizations:discover-basic"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -1133,7 +1139,7 @@ def test_unfurl_discover_homepage( dataset = mock_get_event_stats_data.mock_calls[0][2]["dataset"] assert dataset == discover - def _build_mock_timeseries_response(self, y_axis="avg(span.duration)"): + def _build_mock_timeseries_response(self, y_axis: str = "avg(span.duration)") -> dict[str, Any]: return { "timeSeries": [ { @@ -1171,7 +1177,7 @@ def test_unfurl_explore( UnfurlableUrl(url=url, args=args), ] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -1184,7 +1190,7 @@ def test_unfurl_explore( chart_data = mock_generate_chart.call_args[0][1] assert "timeSeries" in chart_data - def _build_mock_heatmap_response(self): + def _build_mock_heatmap_response(self) -> dict[str, Any]: """A HeatMapSeries-shaped events-heatmap response. The Y axis is the generic `value` field, so the API returns no metric unit in the meta.""" return { @@ -1230,7 +1236,7 @@ def test_unfurl_explore_heatmap_patches_metric_unit( links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 1 assert mock_client_get.call_args[1]["path"].endswith("/events-heatmap/") @@ -1275,7 +1281,7 @@ def test_unfurl_explore_heatmap_skips_malformed_response( links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert unfurls == {} assert mock_generate_chart.mock_calls == [] @@ -1337,7 +1343,7 @@ def test_unfurl_explore_with_groupby( UnfurlableUrl(url=url, args=args), ] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 1 assert len(mock_generate_chart.mock_calls) == 1 @@ -1368,7 +1374,7 @@ def test_unfurl_explore_forwards_multiple_groupbys_to_api( raise AssertionError("Missing link_type/args") links = [UnfurlableUrl(url=url, args=args)] - link_handlers[link_type].fn(self.integration, links, self.user) + link_handlers[link_type].fn(self.rpc_integration, links, self.user) mock_view.assert_called_once() request = mock_view.call_args[0][0] @@ -1394,7 +1400,7 @@ def test_unfurl_explore_with_groupby_explicit_sort( UnfurlableUrl(url=url, args=args), ] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 1 @@ -1422,7 +1428,7 @@ def test_unfurl_explore_default_yaxis( UnfurlableUrl(url=url, args=args), ] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 1 assert len(mock_generate_chart.mock_calls) == 1 @@ -1449,7 +1455,7 @@ def test_unfurl_explore_malformed_aggregate_field( UnfurlableUrl(url=url, args=args), ] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) # Should still unfurl with default yAxis assert len(unfurls) == 1 @@ -1484,7 +1490,7 @@ def test_unfurl_explore_end_to_end( # Step 2: Run handler links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) # Step 3: Verify events-timeseries was called with correct args assert mock_client_get.call_count == 1 @@ -1544,7 +1550,7 @@ def test_unfurl_explore_with_visualize_chart_type( UnfurlableUrl(url=url, args=args), ] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 1 assert len(mock_generate_chart.mock_calls) == 1 @@ -1570,7 +1576,7 @@ def test_unfurl_explore_without_chart_type_defaults_to_line( UnfurlableUrl(url=url, args=args), ] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 1 chart_data = mock_generate_chart.call_args[0][1] @@ -1596,7 +1602,7 @@ def test_unfurl_explore_skips_unsupported_chart_type( UnfurlableUrl(url=url, args=args), ] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert unfurls == {} # Skip happens before the events-timeseries call, so neither the API @@ -1625,7 +1631,7 @@ def test_unfurl_explore_without_chart_type_count_defaults_to_bar( UnfurlableUrl(url=url, args=args), ] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 1 chart_data = mock_generate_chart.call_args[0][1] @@ -1867,7 +1873,7 @@ def test_unfurl_explore_with_interval( UnfurlableUrl(url=url, args=args), ] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 1 call_kwargs = mock_client_get.call_args[1] @@ -2017,7 +2023,7 @@ def test_unfurl_explore_logs( UnfurlableUrl(url=url, args=args), ] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -2138,7 +2144,7 @@ def test_unfurl_explore_logs_customer_domain( UnfurlableUrl(url=url, args=args), ] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 1 call_kwargs = mock_client_get.call_args[1] @@ -2165,7 +2171,7 @@ def test_unfurl_explore_metrics( UnfurlableUrl(url=url, args=args), ] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -2209,7 +2215,7 @@ def test_unfurl_explore_metrics_skips_hidden_charts( links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -2245,7 +2251,7 @@ def test_unfurl_explore_metrics_all_hidden_returns_no_unfurl( links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert unfurls == {} assert mock_generate_chart.call_count == 0 @@ -2259,7 +2265,7 @@ def _create_spans_widget( conditions: str = "", orderby: str = "", title: str = "My Spans Widget", - ): + ) -> tuple[Dashboard, DashboardWidget]: dashboard = self.create_dashboard(organization=self.organization) widget = self.create_dashboard_widget( dashboard=dashboard, @@ -2303,7 +2309,7 @@ def test_unfurl_dashboards_spans_widget( links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert ( unfurls[url] @@ -2344,7 +2350,7 @@ def test_unfurl_dashboards_customer_domain( assert args is not None links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 1 assert mock_generate_chart.call_count == 1 @@ -2379,7 +2385,7 @@ def test_unfurl_dashboards_unsupported_widget_type_is_skipped( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 0 assert mock_client_get.call_count == 0 @@ -2403,7 +2409,7 @@ def test_unfurl_dashboards_unsupported_display_type( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 0 assert mock_client_get.call_count == 0 @@ -2424,7 +2430,7 @@ def test_unfurl_dashboards_widget_not_found( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 0 assert mock_client_get.call_count == 0 @@ -2475,7 +2481,7 @@ def test_unfurl_dashboards_multiple_queries_are_joined( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 1 assert mock_client_get.call_count == 2 @@ -2532,7 +2538,7 @@ def test_unfurl_dashboards_multi_query_same_aggregate( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - link_handlers[link_type].fn(self.integration, links, self.user) + link_handlers[link_type].fn(self.rpc_integration, links, self.user) chart_data = mock_generate_chart.call_args[0][1] pairs = chart_data["timeSeries"] @@ -2552,7 +2558,7 @@ def test_unfurl_dashboards_multi_query_same_aggregate( def test_unfurl_dashboards_single_query_multi_series_share_query_index( self, mock_generate_chart: MagicMock, mock_client_get: MagicMock ) -> None: - def grouped_response(group_value: str): + def grouped_response(group_value: str) -> dict[str, Any]: return { "timeSeries": [ { @@ -2590,7 +2596,7 @@ def grouped_response(group_value: str): assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - link_handlers[link_type].fn(self.integration, links, self.user) + link_handlers[link_type].fn(self.rpc_integration, links, self.user) chart_data = mock_generate_chart.call_args[0][1] pairs = chart_data["timeSeries"] @@ -2618,7 +2624,7 @@ def test_unfurl_dashboards_bar_display_type( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.rpc_integration, links, self.user) assert len(unfurls) == 1 chart_data = mock_generate_chart.call_args[0][1] @@ -2659,7 +2665,7 @@ def _make_widget( self, widget_type: int = DashboardWidgetTypes.SPANS, queries: list[dict[str, Any]] | None = None, - ): + ) -> DashboardWidget: dashboard = self.create_dashboard(organization=self.organization) widget = self.create_dashboard_widget( dashboard=dashboard, diff --git a/tests/sentry/integrations/slack/utils/test_channel.py b/tests/sentry/integrations/slack/utils/test_channel.py index 10671ff43581..7169ce423a73 100644 --- a/tests/sentry/integrations/slack/utils/test_channel.py +++ b/tests/sentry/integrations/slack/utils/test_channel.py @@ -1,3 +1,5 @@ +from collections.abc import Mapping, Sequence +from contextlib import AbstractContextManager from unittest.mock import MagicMock, patch import orjson @@ -15,6 +17,7 @@ is_input_a_user_id, validate_user_id, ) +from sentry.integrations.slack.utils.errors import TypedSlackApiError from sentry.shared_integrations.exceptions import ( ApiRateLimitedError, DuplicateDisplayNameError, @@ -58,7 +61,12 @@ def setUp(self) -> None: "response_metadata": {"next_cursor": ""}, } - def patch_mock_list(self, list_type, channels, result_name="channels"): + def patch_mock_list( + self, + list_type: str, + channels: Sequence[Mapping[str, object]], + result_name: str = "channels", + ) -> AbstractContextManager[MagicMock]: return patch( "slack_sdk.web.client.WebClient.%s_list" % list_type, return_value=SlackResponse( @@ -72,7 +80,7 @@ def patch_mock_list(self, list_type, channels, result_name="channels"): ), ) - def patch_msg_response_not_found(self): + def patch_msg_response_not_found(self) -> AbstractContextManager[MagicMock]: return patch( "slack_sdk.web.client.WebClient.chat_scheduleMessage", return_value=SlackResponse( @@ -86,7 +94,9 @@ def patch_msg_response_not_found(self): ), ) - def run_valid_test(self, channel, expected_prefix, expected_id, timed_out): + def run_valid_test( + self, channel: str, expected_prefix: str, expected_id: str | None, timed_out: bool + ) -> None: assert SlackChannelIdData(expected_prefix, expected_id, timed_out) == get_channel_id( self.integration, channel ) @@ -261,7 +271,7 @@ def test_behavior_for_known_slack_identifiers(self) -> None: assert not is_input_a_user_id("F12345678") # File ID -def create_user_response(*, user): +def create_user_response(*, user: Mapping[str, object]) -> SlackResponse: return SlackResponse( client=None, http_verb="POST", @@ -273,8 +283,8 @@ def create_user_response(*, user): ) -def create_user_error(*, error, status_code: int = 400): - return SlackApiError( +def create_user_error(*, error: str, status_code: int = 400) -> SlackApiError: + return TypedSlackApiError( message=error, response=SlackResponse( client=None, diff --git a/tests/sentry/integrations/slack/utils/test_mock_slack_response.py b/tests/sentry/integrations/slack/utils/test_mock_slack_response.py index 10aa1ea4d13c..bb259ad11a86 100644 --- a/tests/sentry/integrations/slack/utils/test_mock_slack_response.py +++ b/tests/sentry/integrations/slack/utils/test_mock_slack_response.py @@ -1,4 +1,6 @@ -from unittest.mock import patch +from __future__ import annotations + +from unittest.mock import MagicMock, _patch, patch from slack_sdk.web.slack_response import SlackResponse @@ -11,7 +13,7 @@ def mock_slack_response( api_url: str | None = None, req_args: dict[str, object] | None = None, headers: dict[str, object] | None = None, -): +) -> _patch[MagicMock]: if api_url is None: api_url = f"https://slack.com/api/{method_name}" diff --git a/tests/sentry/integrations/slack/utils/test_slack_errors.py b/tests/sentry/integrations/slack/utils/test_slack_errors.py index 8ce726dd00e0..1e6627f7e483 100644 --- a/tests/sentry/integrations/slack/utils/test_slack_errors.py +++ b/tests/sentry/integrations/slack/utils/test_slack_errors.py @@ -15,6 +15,8 @@ RATE_LIMITED, SLACK_SDK_ERROR_CATEGORIES, SLACK_SDK_HALT_ERROR_CATEGORIES, + SlackSdkErrorCategory, + TypedSlackApiError, unpack_slack_api_error, ) @@ -29,14 +31,14 @@ def _make_slack_api_error(error: str) -> SlackApiError: headers={}, status_code=200, ) - return SlackApiError( + return TypedSlackApiError( message=f"The request to the Slack API failed. (error: {error})", response=response ) class TestUnpackSlackApiError: @patch("sentry.integrations.slack.utils.errors.capture_message") - def test_fatal_error(self, mock_capture): + def test_fatal_error(self, mock_capture: MagicMock) -> None: exc = _make_slack_api_error("fatal_error") result = unpack_slack_api_error(exc) assert result is FATAL_ERROR @@ -44,7 +46,7 @@ def test_fatal_error(self, mock_capture): mock_capture.assert_not_called() @patch("sentry.integrations.slack.utils.errors.capture_message") - def test_internal_error(self, mock_capture): + def test_internal_error(self, mock_capture: MagicMock) -> None: exc = _make_slack_api_error("internal_error") result = unpack_slack_api_error(exc) assert result is INTERNAL_ERROR @@ -52,7 +54,7 @@ def test_internal_error(self, mock_capture): mock_capture.assert_not_called() @patch("sentry.integrations.slack.utils.errors.capture_message") - def test_invalid_blocks(self, mock_capture): + def test_invalid_blocks(self, mock_capture: MagicMock) -> None: exc = _make_slack_api_error("invalid_blocks") result = unpack_slack_api_error(exc) assert result is INVALID_BLOCKS @@ -60,7 +62,7 @@ def test_invalid_blocks(self, mock_capture): mock_capture.assert_not_called() @patch("sentry.integrations.slack.utils.errors.capture_message") - def test_invalid_attachments(self, mock_capture): + def test_invalid_attachments(self, mock_capture: MagicMock) -> None: exc = _make_slack_api_error("invalid_attachments") result = unpack_slack_api_error(exc) assert result is INVALID_ATTACHMENTS @@ -68,7 +70,7 @@ def test_invalid_attachments(self, mock_capture): mock_capture.assert_not_called() @patch("sentry.integrations.slack.utils.errors.capture_message") - def test_invalid_auth(self, mock_capture): + def test_invalid_auth(self, mock_capture: MagicMock) -> None: exc = _make_slack_api_error("invalid_auth") result = unpack_slack_api_error(exc) assert result is INVALID_AUTH @@ -76,7 +78,7 @@ def test_invalid_auth(self, mock_capture): mock_capture.assert_not_called() @patch("sentry.integrations.slack.utils.errors.capture_message") - def test_non_json_response(self, mock_capture): + def test_non_json_response(self, mock_capture: MagicMock) -> None: response = SlackResponse( client=MagicMock(), http_verb="POST", @@ -89,14 +91,14 @@ def test_non_json_response(self, mock_capture): headers={}, status_code=200, ) - exc = SlackApiError(message="The request to the Slack API failed.", response=response) + exc = TypedSlackApiError(message="The request to the Slack API failed.", response=response) result = unpack_slack_api_error(exc) assert result is NON_JSON_RESPONSE assert result in SLACK_SDK_HALT_ERROR_CATEGORIES mock_capture.assert_not_called() @patch("sentry.integrations.slack.utils.errors.capture_message") - def test_preexisting_category_channel_not_found(self, mock_capture): + def test_preexisting_category_channel_not_found(self, mock_capture: MagicMock) -> None: exc = _make_slack_api_error("channel_not_found") result = unpack_slack_api_error(exc) assert result is CHANNEL_NOT_FOUND @@ -104,7 +106,7 @@ def test_preexisting_category_channel_not_found(self, mock_capture): mock_capture.assert_not_called() @patch("sentry.integrations.slack.utils.errors.capture_message") - def test_preexisting_category_ratelimited(self, mock_capture): + def test_preexisting_category_ratelimited(self, mock_capture: MagicMock) -> None: exc = _make_slack_api_error("ratelimited") result = unpack_slack_api_error(exc) assert result is RATE_LIMITED @@ -112,7 +114,7 @@ def test_preexisting_category_ratelimited(self, mock_capture): mock_capture.assert_not_called() @patch("sentry.integrations.slack.utils.errors.capture_message") - def test_all_categories_recognized(self, mock_capture): + def test_all_categories_recognized(self, mock_capture: MagicMock) -> None: for category in SLACK_SDK_ERROR_CATEGORIES: if category is NON_JSON_RESPONSE: continue @@ -122,21 +124,21 @@ def test_all_categories_recognized(self, mock_capture): mock_capture.assert_not_called() @patch("sentry.integrations.slack.utils.errors.capture_message") - def test_slack_request_error_string_fallback(self, mock_capture): + def test_slack_request_error_string_fallback(self, mock_capture: MagicMock) -> None: exc = SlackRequestError("channel_not_found\nsome extra detail") result = unpack_slack_api_error(exc) assert result is CHANNEL_NOT_FOUND mock_capture.assert_not_called() @patch("sentry.integrations.slack.utils.errors.capture_message") - def test_slack_request_error_unknown(self, mock_capture): + def test_slack_request_error_unknown(self, mock_capture: MagicMock) -> None: exc = SlackRequestError("something_completely_new") result = unpack_slack_api_error(exc) assert result is None mock_capture.assert_called_once() @patch("sentry.integrations.slack.utils.errors.capture_message") - def test_unknown_error_fires_capture_message(self, mock_capture): + def test_unknown_error_fires_capture_message(self, mock_capture: MagicMock) -> None: exc = _make_slack_api_error("totally_new_error") result = unpack_slack_api_error(exc) assert result is None @@ -153,7 +155,9 @@ def test_unknown_error_fires_capture_message(self, mock_capture): (NON_JSON_RESPONSE, True), ], ) - def test_halt_classification(self, category, expected_halt): + def test_halt_classification( + self, category: SlackSdkErrorCategory, expected_halt: bool + ) -> None: if expected_halt: assert category in SLACK_SDK_HALT_ERROR_CATEGORIES else: diff --git a/tests/sentry/integrations/slack/webhooks/actions/__init__.py b/tests/sentry/integrations/slack/webhooks/actions/__init__.py index b3b67954cc22..bc194a22c382 100644 --- a/tests/sentry/integrations/slack/webhooks/actions/__init__.py +++ b/tests/sentry/integrations/slack/webhooks/actions/__init__.py @@ -1,7 +1,10 @@ -from unittest.mock import patch +from collections.abc import Iterator +from typing import Any +from unittest.mock import MagicMock, patch import orjson import pytest +from rest_framework.response import Response from slack_sdk.web import SlackResponse from slack_sdk.webhook import WebhookResponse @@ -24,7 +27,7 @@ def setUp(self) -> None: self.rule = self.create_project_rule(project=self.project) @pytest.fixture(autouse=True) - def mock_webhook_send(self): + def mock_webhook_send(self) -> Iterator[None]: with patch( "slack_sdk.webhook.WebhookClient.send", return_value=WebhookResponse( @@ -37,7 +40,7 @@ def mock_webhook_send(self): yield @pytest.fixture(autouse=True) - def mock_view_open(self): + def mock_view_open(self) -> Iterator[None]: with patch( "slack_sdk.web.client.WebClient.views_open", return_value=SlackResponse( @@ -53,7 +56,7 @@ def mock_view_open(self): yield @pytest.fixture(autouse=True) - def mock_view_update(self): + def mock_view_update(self) -> Iterator[None]: with patch( "slack_sdk.web.client.WebClient.views_update", return_value=SlackResponse( @@ -74,15 +77,15 @@ def mock_view_update(self): ) def post_webhook( self, - check_signing_secret_mock, - action_data=None, - type="event_callback", - data=None, - team_id="TXXXXXXX1", - callback_id=None, - slack_user=None, - original_message=None, - ): + check_signing_secret_mock: MagicMock, + action_data: list[dict[str, Any]] | None = None, + type: str = "event_callback", + data: dict[str, Any] | None = None, + team_id: str = "TXXXXXXX1", + callback_id: str | None = None, + slack_user: dict[str, Any] | None = None, + original_message: dict[str, Any] | None = None, + ) -> Response: if slack_user is None: slack_user = {"id": self.external_id, "domain": "example"} @@ -111,7 +114,8 @@ def post_webhook( payload = {"payload": orjson.dumps(payload).decode()} - return self.client.post("/extensions/slack/action/", data=payload) + response: Response = self.client.post("/extensions/slack/action/", data=payload) + return response @patch( "sentry.integrations.slack.requests.base.SlackRequest._check_signing_secret", @@ -119,18 +123,18 @@ def post_webhook( ) def post_webhook_block_kit( self, - check_signing_secret_mock, - action_data=None, - type="block_actions", - data=None, - team_id="TXXXXXXX1", - slack_user=None, - original_message=None, - selected_option=None, - view=None, - private_metadata=None, - callback_id=None, - ): + check_signing_secret_mock: MagicMock, + action_data: list[dict[str, Any]] | None = None, + type: str = "block_actions", + data: dict[str, Any] | None = None, + team_id: str = "TXXXXXXX1", + slack_user: dict[str, Any] | None = None, + original_message: dict[str, Any] | None = None, + selected_option: str | None = None, + view: dict[str, Any] | None = None, + private_metadata: str | None = None, + callback_id: str | None = None, + ) -> Response: """Respond as if we were Slack""" if slack_user is None: slack_user = { @@ -146,7 +150,7 @@ def post_webhook_block_kit( if callback_id is None: callback_id = orjson.dumps({"issue": self.group.id, "rule": self.rule.id}).decode() - payload = { + payload: dict[str, Any] = { "type": type, "team": { "id": team_id, @@ -278,4 +282,5 @@ def post_webhook_block_kit( payload.update(data) payload = {"payload": orjson.dumps(payload).decode()} - return self.client.post("/extensions/slack/action/", data=payload) + response: Response = self.client.post("/extensions/slack/action/", data=payload) + return response diff --git a/tests/sentry/integrations/slack/webhooks/actions/test_investigation.py b/tests/sentry/integrations/slack/webhooks/actions/test_investigation.py index df498f551f72..8f5b9fd5f3ff 100644 --- a/tests/sentry/integrations/slack/webhooks/actions/test_investigation.py +++ b/tests/sentry/integrations/slack/webhooks/actions/test_investigation.py @@ -4,13 +4,13 @@ import orjson import pytest -from slack_sdk.errors import SlackApiError from slack_sdk.web import SlackResponse from sentry.incidents.grouptype import MetricIssue from sentry.incidents.utils.types import DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION from sentry.integrations.slack.message_builder.routing import encode_action_id from sentry.integrations.slack.message_builder.types import SlackAction +from sentry.integrations.slack.utils.errors import TypedSlackApiError from sentry.integrations.slack.webhooks.actions.investigation import ( InvalidInvestigationActionValue, MissingInvestigationAction, @@ -332,7 +332,7 @@ def test_existing_investigation_replies_with_link(self, mock_count: MagicMock) - @with_feature(FEATURES) def test_slack_error_during_update_still_creates_investigation(self) -> None: - self.mock_update.side_effect = SlackApiError( + self.mock_update.side_effect = TypedSlackApiError( "error", _slack_response({"ok": False, "error": "message_not_found"}) ) @@ -351,7 +351,7 @@ def test_slack_error_during_update_still_creates_investigation(self) -> None: def test_slack_error_during_started_message_still_creates_investigation( self, mock_record_sent: MagicMock ) -> None: - self.mock_post_message.side_effect = SlackApiError( + self.mock_post_message.side_effect = TypedSlackApiError( "error", _slack_response({"ok": False, "error": "invalid_blocks"}) ) diff --git a/tests/sentry/integrations/slack/webhooks/actions/test_seer_agent.py b/tests/sentry/integrations/slack/webhooks/actions/test_seer_agent.py index b3e059a86842..5c84480b619b 100644 --- a/tests/sentry/integrations/slack/webhooks/actions/test_seer_agent.py +++ b/tests/sentry/integrations/slack/webhooks/actions/test_seer_agent.py @@ -1,4 +1,5 @@ -from unittest.mock import Mock, patch +from typing import Any +from unittest.mock import MagicMock, Mock, patch from sentry.constants import ObjectStatus from sentry.integrations.services.integration import integration_service @@ -39,7 +40,7 @@ def setUp(self) -> None: user_id=self.user.id, ) - def get_original_message(self): + def get_original_message(self) -> dict[str, Any]: return { "ts": self.message_ts, "blocks": [ @@ -50,7 +51,7 @@ def get_original_message(self): ], } - def get_action(self, action: SlackAction): + def get_action(self, action: SlackAction) -> dict[str, Any]: return { "action_id": encode_action_id( action=action, @@ -99,7 +100,9 @@ def pending_state( @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_approve_grants_scopes_and_resumes_run(self, mock_fetch, mock_update): + def test_approve_grants_scopes_and_resumes_run( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: self.cache_message() mock_fetch.return_value = self.pending_state() mock_update.return_value = Mock(status=202) @@ -156,7 +159,9 @@ def test_approve_grants_scopes_and_resumes_run(self, mock_fetch, mock_update): @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_approval_update_failure_returns_ephemeral_error(self, mock_fetch, mock_update): + def test_approval_update_failure_returns_ephemeral_error( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: self.cache_message() mock_fetch.return_value = self.pending_state() mock_update.return_value = Mock(status=202) @@ -174,7 +179,9 @@ def test_approval_update_failure_returns_ephemeral_error(self, mock_fetch, mock_ @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_approval_update_timeout_returns_ephemeral_error(self, mock_fetch, mock_update): + def test_approval_update_timeout_returns_ephemeral_error( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: self.cache_message() mock_fetch.return_value = self.pending_state() mock_update.return_value = Mock(status=202) @@ -191,7 +198,9 @@ def test_approval_update_timeout_returns_ephemeral_error(self, mock_fetch, mock_ @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_reject_resumes_run_without_a_grant(self, mock_fetch, mock_update): + def test_reject_resumes_run_without_a_grant( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: self.cache_message() mock_fetch.return_value = self.pending_state() mock_update.return_value = Mock(status=202) @@ -209,7 +218,9 @@ def test_reject_resumes_run_without_a_grant(self, mock_fetch, mock_update): @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_approve_requires_the_agent_token_feature(self, mock_fetch, mock_update): + def test_approve_requires_the_agent_token_feature( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: self.cache_message() response = self.post_webhook_block_kit( @@ -225,7 +236,9 @@ def test_approve_requires_the_agent_token_feature(self, mock_fetch, mock_update) @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_action_requires_slack_agent_access(self, mock_fetch, mock_update): + def test_action_requires_slack_agent_access( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: self.cache_message() self.mock_agent_access.return_value = False @@ -242,7 +255,9 @@ def test_action_requires_slack_agent_access(self, mock_fetch, mock_update): @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_another_user_cannot_approve_the_run(self, mock_fetch, mock_update): + def test_another_user_cannot_approve_the_run( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: self.cache_message() other_user = self.create_user() self.create_member(user=other_user, organization=self.organization) @@ -268,7 +283,9 @@ def test_another_user_cannot_approve_the_run(self, mock_fetch, mock_update): @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_unrecognized_message_cannot_approve(self, mock_fetch, mock_update): + def test_unrecognized_message_cannot_approve( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: with self.feature(agent_token.FEATURE_FLAG): response = self.post_webhook_block_kit( action_data=[self.get_action(SlackAction.SEER_AGENT_WRITE_APPROVE)], @@ -282,7 +299,9 @@ def test_unrecognized_message_cannot_approve(self, mock_fetch, mock_update): @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_invalid_seer_state_returns_ephemeral_error(self, mock_fetch, mock_update): + def test_invalid_seer_state_returns_ephemeral_error( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: self.cache_message() mock_fetch.side_effect = ValueError("No session found") @@ -298,7 +317,9 @@ def test_invalid_seer_state_returns_ephemeral_error(self, mock_fetch, mock_updat @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_stale_message_cannot_approve_a_later_input(self, mock_fetch, mock_update): + def test_stale_message_cannot_approve_a_later_input( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: self.cache_message(input_id="approval-previous") mock_fetch.return_value = self.pending_state(input_id="approval-current") @@ -315,7 +336,9 @@ def test_stale_message_cannot_approve_a_later_input(self, mock_fetch, mock_updat @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_approve_cannot_exceed_the_slack_users_scopes(self, mock_fetch, mock_update): + def test_approve_cannot_exceed_the_slack_users_scopes( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: self.cache_message() member_user = self.create_user() self.create_member(user=member_user, organization=self.organization, role="member") @@ -348,7 +371,9 @@ def test_approve_cannot_exceed_the_slack_users_scopes(self, mock_fetch, mock_upd @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_inactive_user_cannot_approve(self, mock_fetch, mock_update): + def test_inactive_user_cannot_approve( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: self.cache_message() with assume_test_silo_mode(SiloMode.CONTROL): self.user.is_active = False @@ -367,7 +392,9 @@ def test_inactive_user_cannot_approve(self, mock_fetch, mock_update): @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_suspended_user_cannot_approve(self, mock_fetch, mock_update): + def test_suspended_user_cannot_approve( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: self.cache_message() with assume_test_silo_mode(SiloMode.CONTROL): self.user.is_suspended = True @@ -386,7 +413,9 @@ def test_suspended_user_cannot_approve(self, mock_fetch, mock_update): @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_unapproved_member_cannot_approve(self, mock_fetch, mock_update): + def test_unapproved_member_cannot_approve( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: self.cache_message() member = OrganizationMember.objects.get( user_id=self.user.id, @@ -409,7 +438,9 @@ def test_unapproved_member_cannot_approve(self, mock_fetch, mock_update): @patch("sentry.integrations.slack.webhooks.actions.seer_agent.make_agent_update_request") @patch("sentry.integrations.slack.webhooks.actions.seer_agent.fetch_run_status") - def test_disabled_organization_integration_cannot_approve(self, mock_fetch, mock_update): + def test_disabled_organization_integration_cannot_approve( + self, mock_fetch: MagicMock, mock_update: MagicMock + ) -> None: self.cache_message() organization_integration = integration_service.get_organization_integration( integration_id=self.integration.id, diff --git a/tests/sentry/integrations/slack/webhooks/actions/test_seer_autofix.py b/tests/sentry/integrations/slack/webhooks/actions/test_seer_autofix.py index 5ae48ac18f18..9d18eb417cc3 100644 --- a/tests/sentry/integrations/slack/webhooks/actions/test_seer_autofix.py +++ b/tests/sentry/integrations/slack/webhooks/actions/test_seer_autofix.py @@ -1,4 +1,5 @@ -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch import orjson @@ -14,7 +15,7 @@ def setUp(self) -> None: super().setUp() self.group = self.create_group(project=self.project) - def get_original_message(self): + def get_original_message(self) -> dict[str, Any]: return { "ts": "1458170866.000004", "blocks": [ @@ -26,7 +27,7 @@ def get_original_message(self): ], } - def get_autofix_start_action(self): + def get_autofix_start_action(self) -> dict[str, Any]: return { "action_id": encode_action_id( action=SlackAction.SEER_AUTOFIX_START, @@ -40,7 +41,7 @@ def get_autofix_start_action(self): "action_ts": "1458170917.164398", } - def get_autofix_handoff_action(self): + def get_autofix_handoff_action(self) -> dict[str, Any]: return { "action_id": encode_action_id( action=SlackAction.SEER_AUTOFIX_HANDOFF, @@ -54,7 +55,7 @@ def get_autofix_handoff_action(self): "action_ts": "1458170917.164398", } - def non_member_slack_user(self): + def non_member_slack_user(self) -> dict[str, Any]: """A Slack user with a linked Sentry identity but no membership in the org""" non_member = self.create_user() external_id = "slack:2" @@ -68,7 +69,9 @@ def non_member_slack_user(self): @patch("sentry.integrations.slack.webhooks.action.send_not_org_member_message") @patch("sentry.integrations.slack.webhooks.action.SeerAutofixOperator.trigger_autofix") - def test_autofix_start_non_member_is_blocked(self, mock_trigger, mock_not_member_message): + def test_autofix_start_non_member_is_blocked( + self, mock_trigger: MagicMock, mock_not_member_message: MagicMock + ) -> None: response = self.post_webhook_block_kit( action_data=[self.get_autofix_start_action()], original_message=self.get_original_message(), @@ -82,7 +85,9 @@ def test_autofix_start_non_member_is_blocked(self, mock_trigger, mock_not_member @patch("sentry.integrations.slack.webhooks.action.send_not_org_member_message") @patch("sentry.integrations.slack.webhooks.action.SeerAutofixOperator.trigger_autofix") - def test_autofix_start_member_is_allowed(self, mock_trigger, mock_not_member_message): + def test_autofix_start_member_is_allowed( + self, mock_trigger: MagicMock, mock_not_member_message: MagicMock + ) -> None: # The default Slack user is linked to the org owner response = self.post_webhook_block_kit( action_data=[self.get_autofix_start_action()], @@ -95,7 +100,9 @@ def test_autofix_start_member_is_allowed(self, mock_trigger, mock_not_member_mes @patch("sentry.integrations.slack.webhooks.action.send_not_org_member_message") @patch("sentry.integrations.slack.webhooks.action.SeerAutofixOperator.trigger_handoff") - def test_autofix_handoff_non_member_is_blocked(self, mock_trigger, mock_not_member_message): + def test_autofix_handoff_non_member_is_blocked( + self, mock_trigger: MagicMock, mock_not_member_message: MagicMock + ) -> None: response = self.post_webhook_block_kit( action_data=[self.get_autofix_handoff_action()], original_message=self.get_original_message(), @@ -110,7 +117,9 @@ def test_autofix_handoff_non_member_is_blocked(self, mock_trigger, mock_not_memb @patch("sentry.integrations.slack.webhooks.action.send_not_org_member_message") @patch("sentry.integrations.slack.webhooks.action.SeerAutofixOperator.trigger_handoff") - def test_autofix_handoff_member_is_allowed(self, mock_trigger, mock_not_member_message): + def test_autofix_handoff_member_is_allowed( + self, mock_trigger: MagicMock, mock_not_member_message: MagicMock + ) -> None: response = self.post_webhook_block_kit( action_data=[self.get_autofix_handoff_action()], original_message=self.get_original_message(), diff --git a/tests/sentry/integrations/slack/webhooks/actions/test_status.py b/tests/sentry/integrations/slack/webhooks/actions/test_status.py index b6149fa2000e..e4cd6bae087f 100644 --- a/tests/sentry/integrations/slack/webhooks/actions/test_status.py +++ b/tests/sentry/integrations/slack/webhooks/actions/test_status.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, patch import orjson @@ -5,10 +6,11 @@ from django.db import router from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request -from slack_sdk.errors import SlackApiError from slack_sdk.models.views import View from slack_sdk.web import SlackResponse +from sentry.integrations.services.integration.serial import serialize_integration +from sentry.integrations.slack.utils.errors import TypedSlackApiError from sentry.integrations.slack.views.link_identity import build_linking_url from sentry.integrations.slack.views.unlink_identity import build_unlinking_url from sentry.integrations.slack.webhooks.action import ( @@ -39,6 +41,7 @@ from sentry.testutils.skips import requires_snuba from sentry.types.group import GroupSubStatus from sentry.users.models.identity import Identity +from sentry.users.models.user import User from sentry.users.services.user.service import user_service from sentry.utils.samples import load_data @@ -98,7 +101,7 @@ def setUp(self) -> None: self.group = Group.objects.get(id=event.group.id) self.tags = {"escape", "foo", "release"} - def get_original_message(self, group_id): + def get_original_message(self, group_id: int) -> dict[str, Any]: return { "blocks": [ { @@ -117,7 +120,7 @@ def get_original_message(self, group_id): ], } - def get_unfurl_data(self, blocks): + def get_unfurl_data(self, blocks: list[dict[str, Any]]) -> dict[str, Any]: return { "container": { "type": "message_attachment", @@ -132,17 +135,17 @@ def get_unfurl_data(self, blocks): }, } - def get_archive_status_action(self): + def get_archive_status_action(self) -> dict[str, Any]: return { "action_id": "archive_dialog", "block_id": "bXwil", "text": {"type": "plain_text", "text": "Archive", "emoji": True}, "value": "archive_dialog", "type": "button", - "action_ts": before_now(minutes=7).timestamp(), + "action_ts": str(before_now(minutes=7).timestamp()), } - def get_assign_status_action(self, type, text, id): + def get_assign_status_action(self, type: str, text: str, id: int) -> dict[str, Any]: return { "type": "external_select", "action_id": "assign", @@ -152,30 +155,35 @@ def get_assign_status_action(self, type, text, id): "value": f"{type}:{id}", }, "placeholder": {"type": "plain_text", "text": "Select Assignee...", "emoji": True}, - "action_ts": before_now(minutes=5).timestamp(), + "action_ts": str(before_now(minutes=5).timestamp()), } - def get_resolve_status_action(self): + def get_resolve_status_action(self) -> dict[str, Any]: return { "action_id": "resolve_dialog", "block_id": "AeGRw", "text": {"type": "plain_text", "text": "Resolve", "emoji": True}, "value": "resolve_dialog", "type": "button", - "action_ts": before_now(minutes=3).timestamp(), + "action_ts": str(before_now(minutes=3).timestamp()), } - def get_mark_ongoing_action(self): + def get_mark_ongoing_action(self) -> dict[str, Any]: return { "action_id": "unresolved:ongoing", "block_id": "xPlAm", "text": {"type": "plain_text", "text": "Mark as Ongoing", "emoji": True}, "value": "unresolved:ongoing", "type": "button", - "action_ts": before_now(minutes=1).timestamp(), + "action_ts": str(before_now(minutes=1).timestamp()), } - def archive_issue(self, original_message, selected_option, payload_data=None): + def archive_issue( + self, + original_message: dict[str, Any], + selected_option: str, + payload_data: dict[str, Any] | None = None, + ) -> Any: assert selected_option in ARCHIVE_OPTIONS.values() status_action = self.get_archive_status_action() @@ -206,7 +214,12 @@ def archive_issue(self, original_message, selected_option, payload_data=None): assert resp.status_code == 200, resp.content return resp - def assign_issue(self, original_message, selected_option, payload_data=None): + def assign_issue( + self, + original_message: dict[str, Any], + selected_option: Team | User, + payload_data: dict[str, Any] | None = None, + ) -> Any: if isinstance(selected_option, Team): status_action = self.get_assign_status_action( "team", selected_option.slug, selected_option.id @@ -225,7 +238,13 @@ def assign_issue(self, original_message, selected_option, payload_data=None): # resp so that the tests can assert the response blocks looked as expected return resp - def resolve_issue(self, original_message, selected_option, payload_data=None, mock_record=None): + def resolve_issue( + self, + original_message: dict[str, Any], + selected_option: str, + payload_data: dict[str, Any] | None = None, + mock_record: MagicMock | None = None, + ) -> None: status_action = self.get_resolve_status_action() resp = self.post_webhook_block_kit( action_data=[status_action], original_message=original_message, data=payload_data @@ -264,7 +283,7 @@ def resolve_issue(self, original_message, selected_option, payload_data=None, mo def test_ask_linking(self) -> None: resp = self.post_webhook(slack_user={"id": "invalid-id", "domain": "example"}) associate_url = build_linking_url( - self.integration, "invalid-id", "C065W1189", self.response_url + serialize_integration(self.integration), "invalid-id", "C065W1189", self.response_url ) assert resp.status_code == 200, resp.content @@ -587,7 +606,7 @@ def test_assign_issue_error(self, mock_logger: MagicMock) -> None: status_code=200, ) - self.mock_post.side_effect = SlackApiError("error", mock_slack_response) + self.mock_post.side_effect = TypedSlackApiError("error", mock_slack_response) user2 = self.create_user(is_superuser=False) self.create_member(user=user2, organization=self.organization, teams=[self.team]) @@ -826,8 +845,8 @@ def test_resolve_perf_issue(self, mock_tags: MagicMock, mock_record: MagicMock) perf_issue = self.create_performance_issue( event_data=event_data_2, fingerprint=group_fingerprint ) + assert perf_issue.group is not None self.group = perf_issue.group - assert self.group original_message = self.get_original_message(self.group.id) self.resolve_issue(original_message, "resolved", mock_record) diff --git a/tests/sentry/integrations/slack/webhooks/commands/__init__.py b/tests/sentry/integrations/slack/webhooks/commands/__init__.py index 862d053fa4f3..c8252dffd425 100644 --- a/tests/sentry/integrations/slack/webhooks/commands/__init__.py +++ b/tests/sentry/integrations/slack/webhooks/commands/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Generator, Mapping -from typing import Any +from typing import Any, cast from unittest.mock import patch from urllib.parse import urlencode @@ -55,7 +55,8 @@ def send_slack_message(self, command: str, **kwargs: Any) -> dict[str, str]: **kwargs, } ) - return orjson.loads(response.content) + # justification: the Slack command response is a JSON object of strings. + return cast("dict[str, str]", orjson.loads(response.content)) def link_user(self) -> None: return link_user(user=self.user, idp=self.idp, slack_id=self.slack_id) @@ -80,7 +81,8 @@ def get_slack_response( **set_signing_secret(options.get("slack.signing-secret"), data), ) assert response.status_code == (status_code or status.HTTP_200_OK) - return response + # justification: the Django test client returns an HttpResponse here. + return cast("HttpResponse", response) @pytest.fixture(autouse=True) def mock_webhook_send(self) -> Generator[None]: diff --git a/tests/sentry/integrations/slack/webhooks/commands/test_link_user.py b/tests/sentry/integrations/slack/webhooks/commands/test_link_user.py index add3490abb00..310f82869baf 100644 --- a/tests/sentry/integrations/slack/webhooks/commands/test_link_user.py +++ b/tests/sentry/integrations/slack/webhooks/commands/test_link_user.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.integrations.services.integration.serial import serialize_integration from sentry.integrations.slack.views.link_identity import SUCCESS_LINKED_MESSAGE, build_linking_url from sentry.integrations.slack.views.unlink_identity import ( SUCCESS_UNLINKED_MESSAGE, @@ -21,7 +22,10 @@ class SlackLinkIdentityViewTest(SlackCommandsTest): def test_link_user_identity(self) -> None: linking_url = build_linking_url( - self.integration, self.external_id, self.channel_id, self.response_url + serialize_integration(self.integration), + self.external_id, + self.channel_id, + self.response_url, ) response = self.client.post(linking_url) diff --git a/tests/sentry/integrations/slack/webhooks/events/__init__.py b/tests/sentry/integrations/slack/webhooks/events/__init__.py index acf49f3e5962..1bfa2bda8e19 100644 --- a/tests/sentry/integrations/slack/webhooks/events/__init__.py +++ b/tests/sentry/integrations/slack/webhooks/events/__init__.py @@ -1,8 +1,9 @@ from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch import orjson from django.test import override_settings +from rest_framework.response import Response from sentry.testutils.cases import APITestCase from sentry.testutils.helpers import install_slack @@ -38,7 +39,7 @@ } -def build_test_block(link): +def build_test_block(link: str) -> dict[str, object]: return { "blocks": [ { @@ -63,12 +64,12 @@ def setUp(self) -> None: type="slack", external_id=self.integration.external_id ) - def link_identity(self, slack_user_id: str, user: User | None = None): + def link_identity(self, slack_user_id: str, user: User | None = None) -> None: self.create_identity( user=user or self.user, identity_provider=self.idp, external_id=slack_user_id ) - def unlink_identity(self, user=None): + def unlink_identity(self, user: User | None = None) -> None: with assume_test_silo_mode_of(Identity): Identity.objects.filter(user=user or self.user).delete() @@ -77,13 +78,13 @@ def unlink_identity(self, user=None): ) def post_webhook( self, - check_signing_secret_mock, - event_data=None, - type="event_callback", - data=None, - token=UNSET, - team_id="TXXXXXXX1", - ): + check_signing_secret_mock: MagicMock, + event_data: dict[str, object] | None = None, + type: str = "event_callback", + data: dict[str, object] | None = None, + token: object = UNSET, + team_id: str = "TXXXXXXX1", + ) -> Response: payload = { "team_id": team_id, "api_app_id": "AXXXXXXXX1", @@ -95,6 +96,9 @@ def post_webhook( if data: payload.update(data) if event_data: - payload.setdefault("event", {}).update(event_data) + event_payload = payload.setdefault("event", {}) + assert isinstance(event_payload, dict) + event_payload.update(event_data) - return self.client.post("/extensions/slack/event/", payload) + response: Response = self.client.post("/extensions/slack/event/", payload) + return response diff --git a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py index 45ad7e29e020..47ae1d570e1b 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py @@ -1,4 +1,5 @@ -from unittest.mock import patch +from collections.abc import Iterator +from unittest.mock import MagicMock, patch import pytest @@ -20,20 +21,20 @@ class AppMentionEventTest(BaseEventTest): - def setUp(self): + def setUp(self) -> None: super().setUp() # Tests default to having the mention come from a linked user. self.link_identity(user=self.user, slack_user_id=APP_MENTION_EVENT["user"]) @pytest.fixture(autouse=True) - def mock_set_thread_status(self): + def mock_set_thread_status(self) -> Iterator[None]: with patch( "sentry.integrations.slack.integration.SlackIntegration.set_thread_status", ) as self.mock_status: yield @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_dispatches_task(self, mock_apply_async): + def test_app_mention_dispatches_task(self, mock_apply_async: MagicMock) -> None: with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook( event_data=THREADED_APP_MENTION_EVENT, data=AUTHORIZATIONS_DATA @@ -53,7 +54,7 @@ def test_app_mention_dispatches_task(self, mock_apply_async): assert kwargs["attachments"] is None @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_forwards_unfurl_attachments(self, mock_apply_async): + def test_app_mention_forwards_unfurl_attachments(self, mock_apply_async: MagicMock) -> None: """When Slack auto-unfurls a permalink in the mention, the attachment list rides along to the task so we can skip the API call.""" unfurl_attachment = { @@ -74,7 +75,9 @@ def test_app_mention_forwards_unfurl_attachments(self, mock_apply_async): assert kwargs["attachments"] == [unfurl_attachment] @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_dispatches_task_no_authorizations(self, mock_apply_async): + def test_app_mention_dispatches_task_no_authorizations( + self, mock_apply_async: MagicMock + ) -> None: with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=THREADED_APP_MENTION_EVENT) @@ -84,7 +87,7 @@ def test_app_mention_dispatches_task_no_authorizations(self, mock_apply_async): assert kwargs["bot_user_id"] == "" @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_non_threaded_dispatches_task(self, mock_apply_async): + def test_app_mention_non_threaded_dispatches_task(self, mock_apply_async: MagicMock) -> None: """Non-threaded mentions dispatch with ts set and thread_ts as None.""" with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=APP_MENTION_EVENT) @@ -97,7 +100,9 @@ def test_app_mention_non_threaded_dispatches_task(self, mock_apply_async): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_empty_text(self, mock_apply_async, mock_record): + def test_app_mention_empty_text( + self, mock_apply_async: MagicMock, mock_record: MagicMock + ) -> None: event_data = {**APP_MENTION_EVENT, "text": ""} with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data) @@ -109,7 +114,9 @@ def test_app_mention_empty_text(self, mock_apply_async, mock_record): @patch("sentry.integrations.slack.workspace.send_threaded_ephemeral_message") @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_only_the_bot_mention_halts(self, mock_apply_async, mock_record, mock_send): + def test_app_mention_only_the_bot_mention_halts( + self, mock_apply_async: MagicMock, mock_record: MagicMock, mock_send: MagicMock + ) -> None: for text in ("<@U0BOT>", " <@U0BOT> "): with self.subTest(text=text): mock_apply_async.reset_mock() @@ -136,8 +143,8 @@ def test_app_mention_only_the_bot_mention_halts(self, mock_apply_async, mock_rec @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_app_mention_only_the_bot_mention_halts_without_authorizations( - self, mock_apply_async, mock_record, mock_send - ): + self, mock_apply_async: MagicMock, mock_record: MagicMock, mock_send: MagicMock + ) -> None: with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data={**APP_MENTION_EVENT, "text": "<@U0BOT>"}) @@ -149,7 +156,9 @@ def test_app_mention_only_the_bot_mention_halts_without_authorizations( @patch("sentry.integrations.slack.workspace.send_threaded_ephemeral_message") @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_only_mentions_halts(self, mock_apply_async, mock_record, mock_send): + def test_app_mention_only_mentions_halts( + self, mock_apply_async: MagicMock, mock_record: MagicMock, mock_send: MagicMock + ) -> None: """Mentioning us alongside other users, with no question, is still empty.""" with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook( @@ -162,7 +171,9 @@ def test_app_mention_only_mentions_halts(self, mock_apply_async, mock_record, mo assert_halt_metric(mock_record, SeerSlackHaltReason.EMPTY_PROMPT) @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_other_user_mention_with_text_dispatches(self, mock_apply_async): + def test_app_mention_other_user_mention_with_text_dispatches( + self, mock_apply_async: MagicMock + ) -> None: """A mention of someone else is context, not emptiness, when text follows.""" with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook( @@ -178,7 +189,9 @@ def test_app_mention_other_user_mention_with_text_dispatches(self, mock_apply_as @patch("sentry.integrations.slack.workspace.send_threaded_ephemeral_message") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_empty_prompt_reply_failure_is_swallowed(self, mock_apply_async, mock_send): + def test_app_mention_empty_prompt_reply_failure_is_swallowed( + self, mock_apply_async: MagicMock, mock_send: MagicMock + ) -> None: mock_send.side_effect = Exception("slack down") with self.feature(SEER_EXPLORER_FEATURES): @@ -191,7 +204,9 @@ def test_app_mention_empty_prompt_reply_failure_is_swallowed(self, mock_apply_as mock_apply_async.assert_not_called() @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_with_text_after_the_mention_dispatches(self, mock_apply_async): + def test_app_mention_with_text_after_the_mention_dispatches( + self, mock_apply_async: MagicMock + ) -> None: with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook( event_data={**APP_MENTION_EVENT, "text": "<@U0BOT> why is this slow?"}, @@ -203,7 +218,9 @@ def test_app_mention_with_text_after_the_mention_dispatches(self, mock_apply_asy @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_no_integration(self, mock_apply_async, mock_record): + def test_app_mention_no_integration( + self, mock_apply_async: MagicMock, mock_record: MagicMock + ) -> None: """When the integration has no org integrations, we should not dispatch.""" with patch( "sentry.integrations.slack.requests.event.integration_service.get_organization_integrations", @@ -218,7 +235,9 @@ def test_app_mention_no_integration(self, mock_apply_async, mock_record): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_no_explorer_access(self, mock_apply_async, mock_record): + def test_app_mention_no_explorer_access( + self, mock_apply_async: MagicMock, mock_record: MagicMock + ) -> None: resp = self.post_webhook(event_data=APP_MENTION_EVENT) assert resp.status_code == 200 @@ -227,7 +246,9 @@ def test_app_mention_no_explorer_access(self, mock_apply_async, mock_record): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_no_identity_halts(self, mock_apply_async, mock_record): + def test_app_mention_no_identity_halts( + self, mock_apply_async: MagicMock, mock_record: MagicMock + ) -> None: self.unlink_identity() resp = self.post_webhook(event_data=APP_MENTION_EVENT) @@ -238,7 +259,9 @@ def test_app_mention_no_identity_halts(self, mock_apply_async, mock_record): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_linked_user_not_org_member(self, mock_apply_async, mock_record): + def test_app_mention_linked_user_not_org_member( + self, mock_apply_async: MagicMock, mock_record: MagicMock + ) -> None: self.unlink_identity() other_user = self.create_user() diff --git a/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py index fb35bbfba101..7ecc9b04580f 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py @@ -1,5 +1,5 @@ from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch from sentry.integrations.messaging.metrics import SeerSlackHaltReason from sentry.testutils.asserts import assert_halt_metric @@ -24,12 +24,12 @@ class AssistantThreadStartedEventTest(BaseEventTest): - def setUp(self): + def setUp(self) -> None: super().setUp() self.link_identity(slack_user_id=ASSISTANT_THREAD["user_id"]) @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") - def test_sends_suggested_prompts(self, mock_set_prompts): + def test_sends_suggested_prompts(self, mock_set_prompts: MagicMock) -> None: with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) @@ -42,7 +42,7 @@ def test_sends_suggested_prompts(self, mock_set_prompts): assert kwargs["title"] @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") - def test_prompt_titles_and_messages(self, mock_set_prompts): + def test_prompt_titles_and_messages(self, mock_set_prompts: MagicMock) -> None: with self.feature(SEER_EXPLORER_FEATURES): self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) @@ -55,7 +55,7 @@ def test_prompt_titles_and_messages(self, mock_set_prompts): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") - def test_identity_not_linked(self, mock_set_prompts, mock_record): + def test_identity_not_linked(self, mock_set_prompts: MagicMock, mock_record: MagicMock) -> None: self.unlink_identity() resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) @@ -65,7 +65,9 @@ def test_identity_not_linked(self, mock_set_prompts, mock_record): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") - def test_feature_flag_disabled(self, mock_set_prompts, mock_record): + def test_feature_flag_disabled( + self, mock_set_prompts: MagicMock, mock_record: MagicMock + ) -> None: resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) assert resp.status_code == 200 @@ -74,7 +76,7 @@ def test_feature_flag_disabled(self, mock_set_prompts, mock_record): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") - def test_no_integration(self, mock_set_prompts, mock_record): + def test_no_integration(self, mock_set_prompts: MagicMock, mock_record: MagicMock) -> None: with patch( "sentry.integrations.slack.requests.event.integration_service.get_organization_integrations", return_value=[], @@ -88,7 +90,7 @@ def test_no_integration(self, mock_set_prompts, mock_record): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") - def test_missing_channel_id(self, mock_set_prompts, mock_record): + def test_missing_channel_id(self, mock_set_prompts: MagicMock, mock_record: MagicMock) -> None: event_data = { "type": "assistant_thread_started", "assistant_thread": { @@ -105,7 +107,7 @@ def test_missing_channel_id(self, mock_set_prompts, mock_record): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") - def test_missing_thread_ts(self, mock_set_prompts, mock_record): + def test_missing_thread_ts(self, mock_set_prompts: MagicMock, mock_record: MagicMock) -> None: event_data = { "type": "assistant_thread_started", "assistant_thread": { @@ -124,7 +126,7 @@ def test_missing_thread_ts(self, mock_set_prompts, mock_record): "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", side_effect=Exception("API error"), ) - def test_set_prompts_failure_does_not_raise(self, mock_set_prompts): + def test_set_prompts_failure_does_not_raise(self, mock_set_prompts: MagicMock) -> None: """If set_suggested_prompts fails, we still return 200.""" with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) diff --git a/tests/sentry/integrations/slack/webhooks/events/test_direct_message.py b/tests/sentry/integrations/slack/webhooks/events/test_direct_message.py index cf6e10c86884..b0665ce60657 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_direct_message.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_direct_message.py @@ -1,4 +1,5 @@ -from unittest.mock import patch +from collections.abc import Iterator +from unittest.mock import MagicMock, patch import pytest @@ -29,7 +30,7 @@ class DirectMessageTest(BaseEventTest): that DMs are routed to on_direct_message instead of the help message handler. """ - def setUp(self): + def setUp(self) -> None: super().setUp() with assume_test_silo_mode_of(Integration): self.integration.metadata["scopes"] = [SlackScope.ASSISTANT_WRITE] @@ -38,14 +39,14 @@ def setUp(self): self.link_identity(slack_user_id=MESSAGE_DM_EVENT["user"]) @pytest.fixture(autouse=True) - def mock_set_thread_status(self): + def mock_set_thread_status(self) -> Iterator[None]: with patch( "sentry.integrations.slack.integration.SlackIntegration.set_thread_status", ) as self.mock_status: yield @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_dm_dispatches_task(self, mock_apply_async): + def test_dm_dispatches_task(self, mock_apply_async: MagicMock) -> None: with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=MESSAGE_DM_EVENT, data=AUTHORIZATIONS_DATA) @@ -62,7 +63,7 @@ def test_dm_dispatches_task(self, mock_apply_async): assert kwargs["bot_user_id"] == AUTHORIZATIONS_DATA["authorizations"][0]["user_id"] @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_dm_threaded_dispatches_task(self, mock_apply_async): + def test_dm_threaded_dispatches_task(self, mock_apply_async: MagicMock) -> None: with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=THREADED_MESSAGE_DM_EVENT, data=AUTHORIZATIONS_DATA) @@ -74,7 +75,9 @@ def test_dm_threaded_dispatches_task(self, mock_apply_async): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_dm_identity_not_linked(self, mock_apply_async, mock_record): + def test_dm_identity_not_linked( + self, mock_apply_async: MagicMock, mock_record: MagicMock + ) -> None: """When no identity is linked, halt without dispatching the mention task.""" self.unlink_identity() with self.feature(SEER_EXPLORER_FEATURES): @@ -86,7 +89,9 @@ def test_dm_identity_not_linked(self, mock_apply_async, mock_record): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_dm_feature_flag_disabled(self, mock_apply_async, mock_record): + def test_dm_feature_flag_disabled( + self, mock_apply_async: MagicMock, mock_record: MagicMock + ) -> None: resp = self.post_webhook(event_data=MESSAGE_DM_EVENT) assert resp.status_code == 200 @@ -95,7 +100,7 @@ def test_dm_feature_flag_disabled(self, mock_apply_async, mock_record): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_dm_no_integration(self, mock_apply_async, mock_record): + def test_dm_no_integration(self, mock_apply_async: MagicMock, mock_record: MagicMock) -> None: with patch( "sentry.integrations.slack.requests.event.integration_service.get_organization_integrations", return_value=[], @@ -109,7 +114,7 @@ def test_dm_no_integration(self, mock_apply_async, mock_record): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_dm_empty_text(self, mock_apply_async, mock_record): + def test_dm_empty_text(self, mock_apply_async: MagicMock, mock_record: MagicMock) -> None: event_data = {**MESSAGE_DM_EVENT, "text": ""} with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data) diff --git a/tests/sentry/integrations/slack/webhooks/events/test_discover_link_shared.py b/tests/sentry/integrations/slack/webhooks/events/test_discover_link_shared.py index 2335fa0fbbf9..5bd9c22741ba 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_discover_link_shared.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_discover_link_shared.py @@ -1,6 +1,7 @@ import re -from typing import Any -from unittest.mock import Mock, patch +from collections.abc import Iterator +from typing import Any, cast +from unittest.mock import MagicMock, Mock, patch from urllib.parse import parse_qsl import orjson @@ -34,7 +35,7 @@ class DiscoverLinkSharedEvent(BaseEventTest): @pytest.fixture(autouse=True) - def mock_chat_postEphemeral(self): + def mock_chat_postEphemeral(self) -> Iterator[None]: with patch( "slack_sdk.web.client.WebClient.chat_postEphemeral", return_value=SlackResponse( @@ -50,7 +51,7 @@ def mock_chat_postEphemeral(self): yield @pytest.fixture(autouse=True) - def mock_chat_unfurl(self): + def mock_chat_unfurl(self) -> Iterator[None]: with patch( "slack_sdk.web.client.WebClient.chat_unfurl", return_value=SlackResponse( @@ -87,7 +88,7 @@ def mock_chat_unfurl(self): ) }, ) - def share_discover_links(self, mock_match_link, mock_): + def share_discover_links(self, mock_match_link: MagicMock, mock_: MagicMock) -> dict[str, Any]: responses.add(responses.POST, "https://slack.com/api/chat.postEphemeral", json={"ok": True}) responses.add(responses.POST, "https://slack.com/api/chat.unfurl", json={"ok": True}) @@ -118,11 +119,14 @@ def share_discover_links(self, mock_match_link, mock_): ) }, ) - def share_discover_links_sdk(self, mock_match_link, mock_): + def share_discover_links_sdk( + self, mock_match_link: MagicMock, mock_: MagicMock + ) -> dict[str, Any]: resp = self.post_webhook(event_data=LINK_SHARED_EVENT) assert resp.status_code == 200, resp.content - return self.mock_unfurl.call_args[1] + # justification: Mock records the kwargs the webhook handler passed. + return cast("dict[str, Any]", self.mock_unfurl.call_args[1]) @patch( "sentry.integrations.slack.webhooks.event.match_link", @@ -145,11 +149,14 @@ def share_discover_links_sdk(self, mock_match_link, mock_): ) }, ) - def share_discover_links_ephermeral_sdk(self, mock_match_link, mock_): + def share_discover_links_ephermeral_sdk( + self, mock_match_link: MagicMock, mock_: MagicMock + ) -> dict[str, Any]: resp = self.post_webhook(event_data=LINK_SHARED_EVENT) assert resp.status_code == 200, resp.content - return self.mock_post.call_args[1] + # justification: Mock records the kwargs the webhook handler passed. + return cast("dict[str, Any]", self.mock_post.call_args[1]) def test_share_discover_links_unlinked_user_sdk(self) -> None: with self.feature("organizations:discover-basic"): diff --git a/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py b/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py index d7b6946d46e9..668975d4eb91 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py @@ -1,5 +1,7 @@ import re -from unittest.mock import Mock, patch +from collections.abc import Iterator +from typing import Any, cast +from unittest.mock import MagicMock, Mock, patch import orjson import pytest @@ -13,7 +15,7 @@ class ExploreLinkSharedEvent(BaseEventTest): @pytest.fixture(autouse=True) - def mock_chat_postEphemeral(self): + def mock_chat_postEphemeral(self) -> Iterator[None]: with patch( "slack_sdk.web.client.WebClient.chat_postEphemeral", return_value=SlackResponse( @@ -29,7 +31,7 @@ def mock_chat_postEphemeral(self): yield @pytest.fixture(autouse=True) - def mock_chat_unfurl(self): + def mock_chat_unfurl(self) -> Iterator[None]: with patch( "slack_sdk.web.client.WebClient.chat_unfurl", return_value=SlackResponse( @@ -63,10 +65,13 @@ def mock_chat_unfurl(self): ) }, ) - def share_explore_links_sdk(self, mock_match_link, mock_): + def share_explore_links_sdk( + self, mock_match_link: MagicMock, mock_: MagicMock + ) -> dict[str, Any]: resp = self.post_webhook(event_data=LINK_SHARED_EVENT) assert resp.status_code == 200, resp.content - return self.mock_unfurl.call_args[1] + # justification: Mock records the kwargs the webhook handler passed. + return cast("dict[str, Any]", self.mock_unfurl.call_args[1]) @patch( "sentry.integrations.slack.webhooks.event.match_link", @@ -87,10 +92,13 @@ def share_explore_links_sdk(self, mock_match_link, mock_): ) }, ) - def share_explore_links_ephemeral_sdk(self, mock_match_link, mock_): + def share_explore_links_ephemeral_sdk( + self, mock_match_link: MagicMock, mock_: MagicMock + ) -> dict[str, Any]: resp = self.post_webhook(event_data=LINK_SHARED_EVENT) assert resp.status_code == 200, resp.content - return self.mock_post.call_args[1] + # justification: Mock records the kwargs the webhook handler passed. + return cast("dict[str, Any]", self.mock_post.call_args[1]) def test_share_explore_links_unlinked_user(self) -> None: with self.feature("organizations:visibility-explore-view"): diff --git a/tests/sentry/integrations/slack/webhooks/events/test_link_shared.py b/tests/sentry/integrations/slack/webhooks/events/test_link_shared.py index 6c0246efa374..0634d45df446 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_link_shared.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_link_shared.py @@ -1,12 +1,13 @@ import re -from unittest.mock import Mock, patch +from collections.abc import Iterator +from unittest.mock import MagicMock, Mock, patch import pytest import responses -from slack_sdk.errors import SlackApiError from slack_sdk.web import SlackResponse from sentry.integrations.slack.unfurl.types import Handler, make_type_coercer +from sentry.integrations.slack.utils.errors import TypedSlackApiError from sentry.integrations.types import EventLifecycleOutcome from sentry.testutils.asserts import assert_slo_metric_calls @@ -16,7 +17,7 @@ @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") class LinkSharedEventTest(BaseEventTest): @pytest.fixture(autouse=True) - def mock_chat_unfurlMessage(self): + def mock_chat_unfurlMessage(self) -> Iterator[None]: with patch( "slack_sdk.web.client.WebClient.chat_unfurl", return_value=SlackResponse( @@ -52,7 +53,7 @@ def mock_chat_unfurlMessage(self): ) }, ) - def test_share_links_sdk(self, mock_match_link, mock_record) -> None: + def test_share_links_sdk(self, mock_match_link: MagicMock, mock_record: MagicMock) -> None: resp = self.post_webhook(event_data=LINK_SHARED_EVENT) assert resp.status_code == 200, resp.content assert len(mock_match_link.mock_calls) == 3 @@ -90,7 +91,9 @@ def test_share_links_sdk(self, mock_match_link, mock_record) -> None: ) }, ) - def test_share_links_block_kit_sdk(self, mock_match_link, mock_record) -> None: + def test_share_links_block_kit_sdk( + self, mock_match_link: MagicMock, mock_record: MagicMock + ) -> None: resp = self.post_webhook(event_data=LINK_SHARED_EVENT) assert resp.status_code == 200, resp.content assert len(mock_match_link.mock_calls) == 3 @@ -134,10 +137,10 @@ def test_share_links_block_kit_sdk(self, mock_match_link, mock_record) -> None: ) }, ) - def test_share_links_failure(self, mock_match_link, mock_record) -> None: + def test_share_links_failure(self, mock_match_link: MagicMock, mock_record: MagicMock) -> None: with patch("slack_sdk.web.client.WebClient.chat_unfurl") as custom_mock: # Your custom behavior - custom_mock.side_effect = SlackApiError( + custom_mock.side_effect = TypedSlackApiError( message="beep boop", response=SlackResponse( client=None, @@ -155,7 +158,7 @@ def test_share_links_failure(self, mock_match_link, mock_record) -> None: assert_slo_metric_calls(mock_record.mock_calls[-2:], EventLifecycleOutcome.FAILURE) - def test_share_links_no_matches(self, mock_record) -> None: + def test_share_links_no_matches(self, mock_record: MagicMock) -> None: event_data = {**LINK_SHARED_EVENT} event_data["links"] = [{}] resp = self.post_webhook(event_data=event_data) diff --git a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py index cfe2131c06e8..459501d2ac1b 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py @@ -1,3 +1,5 @@ +from collections.abc import Iterator +from typing import Any from unittest.mock import MagicMock, patch import orjson @@ -58,12 +60,12 @@ class MessageIMEventTest(BaseEventTest, IntegratedApiTestCase): This will be superceded by the explorer agentic workflow. """ - def get_block_section_text(self, data): + def get_block_section_text(self, data: dict[str, Any]) -> tuple[str, str]: blocks = data["blocks"] return blocks[0]["text"]["text"], blocks[1]["text"]["text"] @pytest.fixture(autouse=True) - def mock_chat_postMessage(self): + def mock_chat_postMessage(self) -> Iterator[None]: with patch( "slack_sdk.web.client.WebClient.chat_postMessage", return_value=SlackResponse( diff --git a/tests/sentry/integrations/slack/webhooks/events/test_reaction_added.py b/tests/sentry/integrations/slack/webhooks/events/test_reaction_added.py index 8e686e0892a1..d9f6ace4b81c 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_reaction_added.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_reaction_added.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from sentry.testutils.silo import assume_test_silo_mode_of @@ -22,7 +22,7 @@ class ReactionAddedEventTest(BaseEventTest): - def setUp(self): + def setUp(self) -> None: super().setUp() with assume_test_silo_mode_of(type(self.integration)): self.integration.metadata["bot_user_id"] = BOT_USER_ID @@ -30,7 +30,7 @@ def setUp(self): self.link_identity(user=self.user, slack_user_id="U1234567890") @patch("sentry.seer.entrypoints.slack.tasks.process_reaction_for_slack.apply_async") - def test_thumbs_reacts_dispatches_task(self, mock_process_reaction): + def test_thumbs_reacts_dispatches_task(self, mock_process_reaction: MagicMock) -> None: event_data = {**REACTION_ADDED_EVENT, "reaction": "+1"} with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data, data=AUTHORIZATIONS_DATA) @@ -57,7 +57,7 @@ def test_thumbs_reacts_dispatches_task(self, mock_process_reaction): assert kwargs["reaction"] == "-1" @patch("sentry.seer.entrypoints.slack.tasks.process_reaction_for_slack.apply_async") - def test_supports_skin_tones(self, mock_process_reaction): + def test_supports_skin_tones(self, mock_process_reaction: MagicMock) -> None: event_data = {**REACTION_ADDED_EVENT, "reaction": "+1::skin-tone-5"} with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data, data=AUTHORIZATIONS_DATA) @@ -74,7 +74,7 @@ def test_supports_skin_tones(self, mock_process_reaction): mock_process_reaction.assert_called_once() @patch("sentry.seer.entrypoints.slack.tasks.process_reaction_for_slack.apply_async") - def test_unsupported_reaction_ignored(self, mock_process_reaction): + def test_unsupported_reaction_ignored(self, mock_process_reaction: MagicMock) -> None: event_data = {**REACTION_ADDED_EVENT, "reaction": "heart"} with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data, data=AUTHORIZATIONS_DATA) @@ -83,7 +83,7 @@ def test_unsupported_reaction_ignored(self, mock_process_reaction): mock_process_reaction.assert_not_called() @patch("sentry.seer.entrypoints.slack.tasks.process_reaction_for_slack.apply_async") - def test_non_bot_message_ignored(self, mock_process_reaction): + def test_non_bot_message_ignored(self, mock_process_reaction: MagicMock) -> None: event_data = {**REACTION_ADDED_EVENT, "item_user": "U_ANOTHER_USER"} with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data, data=AUTHORIZATIONS_DATA) @@ -92,7 +92,7 @@ def test_non_bot_message_ignored(self, mock_process_reaction): mock_process_reaction.assert_not_called() @patch("sentry.seer.entrypoints.slack.tasks.process_reaction_for_slack.apply_async") - def test_non_message_item_ignored(self, mock_process_reaction): + def test_non_message_item_ignored(self, mock_process_reaction: MagicMock) -> None: event_data = { **REACTION_ADDED_EVENT, "item": {"type": "file", "file": "F1234567890"}, @@ -104,7 +104,7 @@ def test_non_message_item_ignored(self, mock_process_reaction): mock_process_reaction.assert_not_called() @patch("sentry.seer.entrypoints.slack.tasks.process_reaction_for_slack.apply_async") - def test_no_item_user_does_not_dispatch(self, mock_process_reaction): + def test_no_item_user_does_not_dispatch(self, mock_process_reaction: MagicMock) -> None: """When item_user is absent, it won't match bot_user_id so we don't dispatch.""" event_data = {k: v for k, v in REACTION_ADDED_EVENT.items() if k != "item_user"} with self.feature(SEER_EXPLORER_FEATURES): diff --git a/tests/sentry/integrations/slack/webhooks/options_load/__init__.py b/tests/sentry/integrations/slack/webhooks/options_load/__init__.py index 373232eb34b0..7dedf928ebf5 100644 --- a/tests/sentry/integrations/slack/webhooks/options_load/__init__.py +++ b/tests/sentry/integrations/slack/webhooks/options_load/__init__.py @@ -1,6 +1,8 @@ -from unittest.mock import patch +from collections.abc import Mapping +from unittest.mock import MagicMock, patch import orjson +from rest_framework.response import Response from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.slack import install_slack @@ -18,15 +20,17 @@ def setUp(self) -> None: ) def post_webhook( self, - check_signing_secret_mock, - substring="", - type="block_suggestion", - data=None, - team_id="TXXXXXXX1", - slack_user=None, - original_message=None, - action_id="assign", - ): + check_signing_secret_mock: MagicMock, + substring: str = "", + type: str = "block_suggestion", + data: Mapping[str, object] | None = None, + team_id: str = "TXXXXXXX1", + slack_user: Mapping[str, object] | None = None, + original_message: Mapping[str, object] | None = None, + action_id: str = "assign", + ) -> Response: + payload: dict[str, object] = {} + if slack_user is None: slack_user = { "id": self.external_id, @@ -38,8 +42,6 @@ def post_webhook( if original_message is None: original_message = {} - payload = {} - if type == "block_suggestion": payload = { "payload": orjson.dumps( @@ -67,4 +69,5 @@ def post_webhook( if data: payload.update(data) - return self.client.post("/extensions/slack/options-load/", payload) + response: Response = self.client.post("/extensions/slack/options-load/", payload) + return response diff --git a/tests/sentry/integrations/slack/webhooks/options_load/test_dynamic_assignment_dropdown.py b/tests/sentry/integrations/slack/webhooks/options_load/test_dynamic_assignment_dropdown.py index 5ec82d6e526d..03b526507251 100644 --- a/tests/sentry/integrations/slack/webhooks/options_load/test_dynamic_assignment_dropdown.py +++ b/tests/sentry/integrations/slack/webhooks/options_load/test_dynamic_assignment_dropdown.py @@ -1,3 +1,5 @@ +from typing import Any + import orjson from sentry.constants import ObjectStatus @@ -29,7 +31,8 @@ def setUp(self) -> None: ] }, } - self.original_message: dict = { + # justification: Slack message payload is dynamic JSON. + self.original_message: dict[str, Any] = { "type": "message", "text": "[internal] IntegrationError", "blocks": [ diff --git a/tests/sentry/integrations/source_code_management/test_commit_context.py b/tests/sentry/integrations/source_code_management/test_commit_context.py index 8e3cfbfbe2eb..914b12a1f28c 100644 --- a/tests/sentry/integrations/source_code_management/test_commit_context.py +++ b/tests/sentry/integrations/source_code_management/test_commit_context.py @@ -52,6 +52,7 @@ class MockCommitContextIntegration(CommitContextIntegration): integration_name = "mock_integration" integration_id = 1 + organization_id: int | None = None def __init__(self) -> None: self.client = Mock() @@ -72,7 +73,7 @@ def on_create_or_update_comment_error(self, api_error: ApiError, metrics_base: s class TestCommitContextIntegrationSLO(TestCase): def setUp(self) -> None: super().setUp() - self.integration = MockCommitContextIntegration() + self.mock_integration = MockCommitContextIntegration() self.repo = Repository.objects.create( organization_id=self.organization.id, name="example/repo", @@ -84,9 +85,9 @@ def setUp(self) -> None: @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def test_get_blame_for_files_success(self, mock_record: MagicMock) -> None: """Test successful blame retrieval records correct lifecycle events""" - self.integration.client.get_blame_for_files.return_value = [] + self.mock_integration.client.get_blame_for_files.return_value = [] - result = self.integration.get_blame_for_files([self.source_line], {}) + result = self.mock_integration.get_blame_for_files([self.source_line], {}) assert result == [] assert_slo_metric(mock_record, EventLifecycleOutcome.SUCCESS) @@ -97,19 +98,19 @@ def test_get_blame_for_files_success(self, mock_record: MagicMock) -> None: def test_get_blame_for_files_event_includes_organization_id( self, mock_interaction_event: MagicMock ) -> None: - self.integration.organization_id = self.organization.id - self.integration.client.get_blame_for_files.return_value = [] + self.mock_integration.organization_id = self.organization.id + self.mock_integration.client.get_blame_for_files.return_value = [] lifecycle = MagicMock() mock_interaction_event.return_value.capture.return_value.__enter__.return_value = lifecycle mock_interaction_event.return_value.capture.return_value.__exit__.return_value = False - self.integration.get_blame_for_files([self.source_line], {}) + self.mock_integration.get_blame_for_files([self.source_line], {}) mock_interaction_event.assert_called_once_with( interaction_type=SCMIntegrationInteractionType.GET_BLAME_FOR_FILES, - provider_key=self.integration.integration_name, - integration_id=self.integration.integration_id, + provider_key=self.mock_integration.integration_name, + integration_id=self.mock_integration.integration_id, organization_id=self.organization.id, ) @@ -119,27 +120,26 @@ def test_get_blame_for_files_event_includes_organization_id( def test_get_blame_for_files_event_omits_organization_id_when_unavailable( self, mock_interaction_event: MagicMock ) -> None: - self.integration.client.get_blame_for_files.return_value = [] + self.mock_integration.client.get_blame_for_files.return_value = [] lifecycle = MagicMock() mock_interaction_event.return_value.capture.return_value.__enter__.return_value = lifecycle mock_interaction_event.return_value.capture.return_value.__exit__.return_value = False - self.integration.get_blame_for_files([self.source_line], {}) + self.mock_integration.get_blame_for_files([self.source_line], {}) mock_interaction_event.assert_called_once_with( interaction_type=SCMIntegrationInteractionType.GET_BLAME_FOR_FILES, - provider_key=self.integration.integration_name, - integration_id=self.integration.integration_id, + provider_key=self.mock_integration.integration_name, + integration_id=self.mock_integration.integration_id, organization_id=None, ) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def test_get_blame_for_files_missing_identity(self, mock_record: MagicMock) -> None: """Test missing identity records failure""" - self.integration.get_client = Mock(side_effect=Identity.DoesNotExist()) - - result = self.integration.get_blame_for_files([self.source_line], {}) + with patch.object(self.mock_integration, "get_client", side_effect=Identity.DoesNotExist()): + result = self.mock_integration.get_blame_for_files([self.source_line], {}) assert result == [] assert len(mock_record.mock_calls) == 2 @@ -151,9 +151,9 @@ def test_get_blame_for_files_invalid_identity(self, mock_record: MagicMock) -> N """Test invalid identity records failure""" from sentry.auth.exceptions import IdentityNotValid - self.integration.client.get_blame_for_files = Mock(side_effect=IdentityNotValid()) + self.mock_integration.client.get_blame_for_files = Mock(side_effect=IdentityNotValid()) - result = self.integration.get_blame_for_files([self.source_line], {}) + result = self.mock_integration.get_blame_for_files([self.source_line], {}) assert result == [] assert_slo_metric(mock_record, EventLifecycleOutcome.FAILURE) @@ -164,11 +164,11 @@ def test_get_blame_for_files_rate_limited(self, mock_record: MagicMock) -> None: """Test rate limited requests record halt""" from sentry.shared_integrations.exceptions import ApiRateLimitedError - self.integration.client.get_blame_for_files = Mock( + self.mock_integration.client.get_blame_for_files = Mock( side_effect=ApiRateLimitedError(text="Rate limited") ) - result = self.integration.get_blame_for_files([self.source_line], {}) + result = self.mock_integration.get_blame_for_files([self.source_line], {}) assert result == [] assert_slo_metric(mock_record, EventLifecycleOutcome.HALTED) @@ -179,11 +179,11 @@ def test_get_blame_for_files_invalid_request(self, mock_record: MagicMock) -> No """Test invalid request records halt""" from sentry.shared_integrations.exceptions import ApiInvalidRequestError - self.integration.client.get_blame_for_files = Mock( + self.mock_integration.client.get_blame_for_files = Mock( side_effect=ApiInvalidRequestError(text="Invalid request") ) - self.integration.get_blame_for_files([self.source_line], {}) + self.mock_integration.get_blame_for_files([self.source_line], {}) assert_slo_metric(mock_record, EventLifecycleOutcome.HALTED) assert_halt_metric(mock_record, ApiInvalidRequestError(text="Invalid request")) @@ -196,13 +196,13 @@ def test_get_blame_for_files_invalid_request_gitlab(self, mock_record: MagicMock class MockGitlabIntegration(MockCommitContextIntegration): integration_name = "gitlab" - self.integration = MockGitlabIntegration() + self.mock_integration = MockGitlabIntegration() - self.integration.client.get_blame_for_files = Mock( + self.mock_integration.client.get_blame_for_files = Mock( side_effect=ApiInvalidRequestError(text="Invalid request") ) - result = self.integration.get_blame_for_files([self.source_line], {}) + result = self.mock_integration.get_blame_for_files([self.source_line], {}) assert result == [] assert_slo_metric(mock_record, EventLifecycleOutcome.HALTED) @@ -213,13 +213,13 @@ def test_get_blame_for_files_api_host_error_gitlab(self, mock_record: MagicMock) class MockGitlabIntegration(MockCommitContextIntegration): integration_name = "gitlab" - self.integration = MockGitlabIntegration() + self.mock_integration = MockGitlabIntegration() - self.integration.client.get_blame_for_files = Mock( + self.mock_integration.client.get_blame_for_files = Mock( side_effect=ApiHostError(text="retried too many times") ) - result = self.integration.get_blame_for_files([self.source_line], {}) + result = self.mock_integration.get_blame_for_files([self.source_line], {}) assert result == [] assert_slo_metric(mock_record, EventLifecycleOutcome.HALTED) @@ -239,13 +239,13 @@ def __init__(self) -> None: super().__init__() self.client.base_url = self.base_url - self.integration = MockGitlabIntegration() + self.mock_integration = MockGitlabIntegration() - self.integration.client.get_blame_for_files = Mock( + self.mock_integration.client.get_blame_for_files = Mock( side_effect=ApiRetryError(text="Host error") ) - result = self.integration.get_blame_for_files([self.source_line], {}) + result = self.mock_integration.get_blame_for_files([self.source_line], {}) assert result == [] assert_slo_metric(mock_record, EventLifecycleOutcome.HALTED) @@ -265,14 +265,14 @@ def __init__(self) -> None: super().__init__() self.client.base_url = self.base_url - self.integration = MockGitlabIntegration() + self.mock_integration = MockGitlabIntegration() - self.integration.client.get_blame_for_files = Mock( + self.mock_integration.client.get_blame_for_files = Mock( side_effect=ApiRetryError(text="Host error") ) with pytest.raises(ApiRetryError): - self.integration.get_blame_for_files([self.source_line], {}) + self.mock_integration.get_blame_for_files([self.source_line], {}) assert_slo_metric(mock_record, EventLifecycleOutcome.FAILURE) assert_failure_metric(mock_record, ApiRetryError(text="Host error")) @@ -280,9 +280,9 @@ def __init__(self) -> None: @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def test_get_commit_context_all_frames(self, mock_record: MagicMock) -> None: """Test get_commit_context_all_frames records correct lifecycle events""" - self.integration.client.get_blame_for_files.return_value = [] + self.mock_integration.client.get_blame_for_files.return_value = [] - result = self.integration.get_commit_context_all_frames([self.source_line], {}) + result = self.mock_integration.get_commit_context_all_frames([self.source_line], {}) assert result == [] assert len(mock_record.mock_calls) == 2 @@ -294,8 +294,8 @@ class TestEAPGetTop5IssuesByCount(TestCase, SnubaTestCase): def setUp(self) -> None: super().setUp() - self.integration = MockCommitContextIntegration() - self.pr_comment_workflow = GitHubPRCommentWorkflow(self.integration) + self.mock_integration = MockCommitContextIntegration() + self.pr_comment_workflow = GitHubPRCommentWorkflow(self.mock_integration) def _query_both( self, issue_ids: list[int] diff --git a/tests/sentry/integrations/tasks/test_create_comment.py b/tests/sentry/integrations/tasks/test_create_comment.py index 52dd20a3467a..ecf94d834380 100644 --- a/tests/sentry/integrations/tasks/test_create_comment.py +++ b/tests/sentry/integrations/tasks/test_create_comment.py @@ -13,7 +13,7 @@ from sentry.types.activity import ActivityType -def raise_create_comment_exception(*args, **kwargs): +def raise_create_comment_exception(*args: object, **kwargs: object) -> None: raise Exception("Something went wrong creating comment") @@ -46,7 +46,7 @@ def test_remote_creation_preserves_intervening_edit(self, remote: mock.MagicMock group=self.group, key="foo-1234", integration=self.example_integration ) - def edit_during_request(*args): + def edit_during_request(*args: object) -> dict[str, str]: note = Activity.objects.get(id=self.activity.id) note.data = {"text": "edited during remote request", "mentions": []} note.save(update_fields=["data"]) @@ -68,7 +68,7 @@ def test_remote_creation_does_not_recreate_deleted_note(self, remote: mock.Magic ) note_id = self.activity.id - def delete_during_request(*args): + def delete_during_request(*args: object) -> dict[str, str]: Activity.objects.get(id=note_id).delete() return {"id": "remote-42"} diff --git a/tests/sentry/integrations/tasks/test_sync_assignee_outbound.py b/tests/sentry/integrations/tasks/test_sync_assignee_outbound.py index 04f477adf32f..859040bb3b3c 100644 --- a/tests/sentry/integrations/tasks/test_sync_assignee_outbound.py +++ b/tests/sentry/integrations/tasks/test_sync_assignee_outbound.py @@ -16,7 +16,7 @@ from sentry.users.services.user import RpcUser -def raise_sync_assignee_exception(*args, **kwargs): +def raise_sync_assignee_exception(*args: object, **kwargs: object) -> None: raise Exception("Something went wrong") @@ -90,8 +90,11 @@ def test_sync_failure( @mock.patch.object(ExampleIntegration, "sync_assignee_outbound") @mock.patch.object(ExampleIntegration, "should_sync") def test_skips_syncing_if_should_sync_false( - self, mock_should_sync, mock_sync_assignee, mock_record_event - ): + self, + mock_should_sync: mock.MagicMock, + mock_sync_assignee: mock.MagicMock, + mock_record_event: mock.MagicMock, + ) -> None: mock_should_sync.return_value = False sync_assignee_outbound(self.external_issue.id, self.user.id, True, None) @@ -166,8 +169,8 @@ def test_integration_sync_target_not_found( @mock.patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @mock.patch.object(ExampleIntegration, "sync_assignee_outbound") def test_integration_installation_configuration_error( - self, mock_sync_assignee, mock_record_event - ): + self, mock_sync_assignee: mock.MagicMock, mock_record_event: mock.MagicMock + ) -> None: mock_sync_assignee.side_effect = IntegrationConfigurationError( "Insufficient permissions to assign user" ) diff --git a/tests/sentry/integrations/tasks/test_sync_status_inbound.py b/tests/sentry/integrations/tasks/test_sync_status_inbound.py index 2fa27fc72b78..a9491e9203fd 100644 --- a/tests/sentry/integrations/tasks/test_sync_status_inbound.py +++ b/tests/sentry/integrations/tasks/test_sync_status_inbound.py @@ -168,16 +168,18 @@ def setUp(self) -> None: group=self.group, integration=self.integration, key=TEST_ISSUE_KEY ) - def _assert_group_resolved(self, group_id: int): + def _assert_group_resolved(self, group_id: int) -> None: group = Group.objects.get(id=group_id) assert group.status == GroupStatus.RESOLVED - def _assert_group_unresolved(self, group_id: int): + def _assert_group_unresolved(self, group_id: int) -> None: group = Group.objects.get(id=group_id) assert group.status == GroupStatus.UNRESOLVED assert group.substatus == GroupSubStatus.ONGOING - def _assert_resolve_activity_created(self, additional_data=None): + def _assert_resolve_activity_created( + self, additional_data: dict[str, object] | None = None + ) -> None: activity = self.group.activity_set.filter(type=ActivityType.SET_RESOLVED.value).first() assert activity is not None assert activity.data["provider"] == "Example" @@ -188,7 +190,7 @@ def _assert_resolve_activity_created(self, additional_data=None): for key, value in additional_data.items(): assert activity.data.get(key) == value - def _assert_unresolve_activity_created(self): + def _assert_unresolve_activity_created(self) -> None: activity = self.group.activity_set.filter(type=ActivityType.SET_UNRESOLVED.value).first() assert activity is not None assert activity.data["provider"] == "Example" @@ -196,8 +198,8 @@ def _assert_unresolve_activity_created(self): assert activity.data["integration_id"] == self.integration.id def _assert_resolve_in_release_activity_created( - self, in_next_release=False, additional_data=None - ): + self, in_next_release: bool = False, additional_data: dict[str, object] | None = None + ) -> None: activity = self.group.activity_set.filter( type=ActivityType.SET_RESOLVED_IN_RELEASE.value ).first() @@ -215,7 +217,7 @@ def _assert_resolve_in_release_activity_created( for key, value in additional_data.items(): assert activity.data.get(key) == value - def _assert_no_resolve_activity(self): + def _assert_no_resolve_activity(self) -> None: activity_count = self.group.activity_set.filter( type=ActivityType.SET_RESOLVED.value ).count() diff --git a/tests/sentry/integrations/tasks/test_sync_status_outbound.py b/tests/sentry/integrations/tasks/test_sync_status_outbound.py index 5da5ad48283a..6a5367f9c2bc 100644 --- a/tests/sentry/integrations/tasks/test_sync_status_outbound.py +++ b/tests/sentry/integrations/tasks/test_sync_status_outbound.py @@ -12,15 +12,15 @@ from sentry.testutils.silo import assume_test_silo_mode_of, cell_silo_test -def raise_exception(_external_issue, _is_resolved, _group_proj_id): +def raise_exception(_external_issue: object, _is_resolved: object, _group_proj_id: object) -> None: raise Exception("Something went wrong") -def raise_integration_form_error(*args, **kwargs): +def raise_integration_form_error(*args: object, **kwargs: object) -> None: raise IntegrationFormError(field_errors={"foo": "Invalid foo provided"}) -def raise_api_unauthorized_error(*args, **kwargs): +def raise_api_unauthorized_error(*args: object, **kwargs: object) -> None: raise ApiUnauthorized(text="auth failed") diff --git a/tests/sentry/integrations/tasks/test_update_comment.py b/tests/sentry/integrations/tasks/test_update_comment.py index fe28a9a553a4..bc08e92cf132 100644 --- a/tests/sentry/integrations/tasks/test_update_comment.py +++ b/tests/sentry/integrations/tasks/test_update_comment.py @@ -13,7 +13,7 @@ from sentry.types.activity import ActivityType -def raise_update_comment_exception(*args, **kwargs): +def raise_update_comment_exception(*args: object, **kwargs: object) -> None: raise Exception("Something went wrong updating comment") diff --git a/tests/sentry/integrations/test_client.py b/tests/sentry/integrations/test_client.py index 97fa8baf4e70..faa8ab1b7af3 100644 --- a/tests/sentry/integrations/test_client.py +++ b/tests/sentry/integrations/test_client.py @@ -70,7 +70,7 @@ def test_patch(self) -> None: def test_cache_mocked(self, cache: mock.MagicMock) -> None: cache.get.return_value = None responses.add(responses.GET, "http://example.com", json={"key": "value1"}) - resp = ApiClient().get_cached("http://example.com") + resp = ApiClient().get_cached_mapping("http://example.com") assert resp == {"key": "value1"} key = "integration.undefined.client:41c2952996340270af611f0d7fad7286" @@ -81,18 +81,18 @@ def test_cache_mocked(self, cache: mock.MagicMock) -> None: def test_get_cached_basic(self) -> None: responses.add(responses.GET, "http://example.com", json={"key": "value1"}) - resp = ApiClient().get_cached("http://example.com") + resp = ApiClient().get_cached_mapping("http://example.com") assert resp == {"key": "value1"} assert len(responses.calls) == 1 # should still return old value responses.replace(responses.GET, "http://example.com", json={"key": "value2"}) - resp = ApiClient().get_cached("http://example.com") + resp = ApiClient().get_cached_mapping("http://example.com") assert resp == {"key": "value1"} assert len(responses.calls) == 1 # make sure normal get isn't impacted - resp = ApiClient().get("http://example.com") + resp = ApiClient().get_mapping("http://example.com") assert resp == {"key": "value2"} assert len(responses.calls) == 2 @@ -142,21 +142,25 @@ def test_get_and_head_cached(self) -> None: ) resp = ApiClient().head_cached(url) + assert resp.headers is not None assert resp.headers["x-method"] == "HEAD" assert len(responses.calls) == 1 resp = ApiClient().head_cached(url) + assert resp.headers is not None assert resp.headers["x-method"] == "HEAD" assert len(responses.calls) == 1 - resp = ApiClient().get_cached(url, raw_response=True) - assert resp.headers["x-method"] == "GET" - assert resp.json() == {"key": "response-for-get"} + raw_resp = ApiClient().get_cached(url, raw_response=True) + assert raw_resp.headers is not None + assert raw_resp.headers["x-method"] == "GET" + assert raw_resp.json() == {"key": "response-for-get"} assert len(responses.calls) == 2 - resp = ApiClient().get_cached(url, raw_response=True) - assert resp.headers["x-method"] == "GET" - assert resp.json() == {"key": "response-for-get"} + raw_resp = ApiClient().get_cached(url, raw_response=True) + assert raw_resp.headers is not None + assert raw_resp.headers["x-method"] == "GET" + assert raw_resp.json() == {"key": "response-for-get"} assert len(responses.calls) == 2 @responses.activate diff --git a/tests/sentry/integrations/test_issues.py b/tests/sentry/integrations/test_issues.py index d77748b2dc97..563f18ebed31 100644 --- a/tests/sentry/integrations/test_issues.py +++ b/tests/sentry/integrations/test_issues.py @@ -577,8 +577,8 @@ def setUp(self) -> None: @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) @patch("sentry.analytics.record") def test_status_sync_inbound_resolve_webhook_and_sends_to_sentry_app( - self, mock_record, mock_safe_urlopen - ): + self, mock_record: MagicMock, mock_safe_urlopen: MagicMock + ) -> None: # Run the sync with self.feature("organizations:integrations-issue-sync"), self.tasks(): assert isinstance(self.installation, IssueSyncIntegrationBase) @@ -643,6 +643,7 @@ def setUp(self) -> None: data={"message": "test error", "level": "error"}, project_id=self.project.id, ) + assert event.group is not None self.group = event.group self.group.status = GroupStatus.RESOLVED @@ -790,6 +791,7 @@ def test_ai_text_replaces_defaults(self, mock_generate: MagicMock) -> None: desc_field = next(f for f in config if f["name"] == "description") assert title_field["default"] == "LLM Title" + assert isinstance(desc_field["default"], str) assert desc_field["default"].startswith("**") assert "LLM Description" in desc_field["default"] @@ -800,14 +802,18 @@ def test_falls_back_when_ai_returns_empty(self, mock_generate: MagicMock) -> Non config = self.installation.get_create_issue_config(self.group, self.user) title_field = next(f for f in config if f["name"] == "title") - assert title_field["default"] == self.group.get_latest_event().title + latest_event = self.group.get_latest_event() + assert latest_event is not None + assert title_field["default"] == latest_event.title @patch("sentry.integrations.utils.external_issues.run_oneshot") def test_feature_flag_disabled_skips_ai(self, mock_request: MagicMock) -> None: config = self.installation.get_create_issue_config(self.group, self.user) title_field = next(f for f in config if f["name"] == "title") - assert title_field["default"] == self.group.get_latest_event().title + latest_event = self.group.get_latest_event() + assert latest_event is not None + assert title_field["default"] == latest_event.title mock_request.assert_not_called() @patch("sentry.integrations.utils.external_issues.run_oneshot") @@ -818,7 +824,9 @@ def test_hide_ai_features_skips_ai(self, mock_request: MagicMock) -> None: config = self.installation.get_create_issue_config(self.group, self.user) title_field = next(f for f in config if f["name"] == "title") - assert title_field["default"] == self.group.get_latest_event().title + latest_event = self.group.get_latest_event() + assert latest_event is not None + assert title_field["default"] == latest_event.title mock_request.assert_not_called() @patch("sentry.integrations.utils.external_issues.run_oneshot") @@ -829,4 +837,6 @@ def test_ai_exception_falls_back(self, mock_request: MagicMock) -> None: config = self.installation.get_create_issue_config(self.group, self.user) title_field = next(f for f in config if f["name"] == "title") - assert title_field["default"] == self.group.get_latest_event().title + latest_event = self.group.get_latest_event() + assert latest_event is not None + assert title_field["default"] == latest_event.title diff --git a/tests/sentry/integrations/test_pipeline.py b/tests/sentry/integrations/test_pipeline.py index 2319bd031e37..5885ef6d9dc6 100644 --- a/tests/sentry/integrations/test_pipeline.py +++ b/tests/sentry/integrations/test_pipeline.py @@ -1,6 +1,8 @@ from __future__ import annotations -from unittest.mock import patch +from collections.abc import Generator +from typing import Any +from unittest.mock import MagicMock, patch import pytest from django.http.response import HttpResponse @@ -25,7 +27,7 @@ from sentry.users.models.identity import Identity -def naive_build_integration(data): +def naive_build_integration(data: Any) -> Any: return data @@ -39,14 +41,14 @@ class FinishPipelineTestCase(IntegrationTestCase): external_id = "dummy_id-123" @pytest.fixture(autouse=True) - def _modify_provider(self): + def _modify_provider(self) -> Generator[None]: with patch.multiple( self.provider, needs_default_identity=False, ): yield - def test_with_data(self, *args) -> None: + def test_with_data(self, *args: Any) -> None: data = { "external_id": self.external_id, "name": "Name", @@ -68,7 +70,7 @@ def test_with_data(self, *args) -> None: organization_id=self.organization.id, integration_id=integration.id ).exists() - def test_with_customer_domain(self, *args) -> None: + def test_with_customer_domain(self, *args: Any) -> None: with self.feature({"system:multi-region": True}): data = { "external_id": self.external_id, @@ -94,7 +96,7 @@ def test_with_customer_domain(self, *args) -> None: organization_id=self.organization.id, integration_id=integration.id ).exists() - def test_aliased_integration_key(self, *args) -> None: + def test_aliased_integration_key(self, *args: Any) -> None: self.provider = AliasedIntegrationProvider self.setUp() @@ -113,7 +115,7 @@ def test_aliased_integration_key(self, *args) -> None: provider=self.provider.integration_key, external_id=self.external_id ).exists() - def test_with_expect_exists(self, *args) -> None: + def test_with_expect_exists(self, *args: Any) -> None: old_integration = self.create_provider_integration( provider=self.provider.key, external_id=self.external_id, name="Tester" ) @@ -129,7 +131,7 @@ def test_with_expect_exists(self, *args) -> None: organization_id=self.organization.id, integration_id=integration.id ).exists() - def test_expect_exists_does_not_update(self, *args) -> None: + def test_expect_exists_does_not_update(self, *args: Any) -> None: old_integration = self.create_provider_integration( provider=self.provider.key, external_id=self.external_id, @@ -154,7 +156,7 @@ def test_expect_exists_does_not_update(self, *args) -> None: organization_id=self.organization.id, integration_id=integration.id ).exists() - def test_with_default_id(self, *args) -> None: + def test_with_default_id(self, *args: Any) -> None: self.provider.needs_default_identity = True data = { "external_id": self.external_id, @@ -187,7 +189,7 @@ def test_with_default_id(self, *args) -> None: assert Identity.objects.filter(id=org_integration.default_auth_id).exists() @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_default_identity_does_update(self, mock_record, *args) -> None: + def test_default_identity_does_update(self, mock_record: MagicMock, *args: Any) -> None: self.provider.needs_default_identity = True old_identity_id = 234567 integration = self.create_provider_integration( @@ -236,7 +238,7 @@ def test_default_identity_does_update(self, mock_record, *args) -> None: mock_record=mock_record, outcome=EventLifecycleOutcome.SUCCESS, outcome_count=1 ) - def test_existing_identity_becomes_default_auth_on_new_orgintegration(self, *args) -> None: + def test_existing_identity_becomes_default_auth_on_new_orgintegration(self, *args: Any) -> None: # The reinstall flow will result in an existing identity provider, identity # and integration records. Ensure that the new organizationintegration gets # a default_auth_id set. @@ -276,7 +278,7 @@ def test_existing_identity_becomes_default_auth_on_new_orgintegration(self, *arg ) assert org_integration.default_auth_id == identity.id - def test_new_external_id_same_user(self, *args) -> None: + def test_new_external_id_same_user(self, *args: Any) -> None: # we need to make sure any other org_integrations have the same # identity that we use for the new one self.provider.needs_default_identity = True @@ -317,7 +319,7 @@ def test_new_external_id_same_user(self, *args) -> None: for org_integration in org_integrations: assert org_integration.default_auth_id == identity.id - def test_different_user_same_external_id_no_default_needed(self, *args) -> None: + def test_different_user_same_external_id_no_default_needed(self, *args: Any) -> None: new_user = self.create_user() integration = self.create_provider_integration( provider=self.provider.key, @@ -348,7 +350,7 @@ def test_different_user_same_external_id_no_default_needed(self, *args) -> None: ).exists() @patch("sentry.integrations.pipeline.logger") - def test_disallow_with_no_permission(self, mock_logger, *args) -> None: + def test_disallow_with_no_permission(self, mock_logger: MagicMock, *args: Any) -> None: member_user = self.create_user() self.create_member(user=member_user, organization=self.organization, role="member") self.login_as(member_user) @@ -391,7 +393,7 @@ def test_disallow_with_no_permission(self, mock_logger, *args) -> None: } mock_logger.info.assert_called_with("build-integration.permission_error", extra=extra) - def test_allow_with_superuser(self, *args) -> None: + def test_allow_with_superuser(self, *args: Any) -> None: member_user = self.create_user(is_superuser=True) self.create_member(user=member_user, organization=self.organization, role="member") self.login_as(member_user, superuser=True) @@ -421,7 +423,7 @@ def test_allow_with_superuser(self, *args) -> None: self.assertDialogSuccess(resp) @override_options({"superuser.read-write.ga-rollout": True}) - def test_allow_with_superuser_su_split(self, *args) -> None: + def test_allow_with_superuser_su_split(self, *args: Any) -> None: member_user = self.create_user(is_superuser=True) self.create_member(user=member_user, organization=self.organization, role="member") self.login_as(member_user, superuser=True) @@ -452,7 +454,9 @@ def test_allow_with_superuser_su_split(self, *args) -> None: @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.integrations.pipeline.logger") - def test_disallow_with_removed_membership(self, mock_logger, mock_record, *args) -> None: + def test_disallow_with_removed_membership( + self, mock_logger: MagicMock, mock_record: MagicMock, *args: Any + ) -> None: member_user = self.create_user() om = self.create_member(user=member_user, organization=self.organization, role="manager") self.login_as(member_user) @@ -518,14 +522,14 @@ class ApiFinishPipelineTestCase(IntegrationTestCase): external_id = "dummy_id-123" @pytest.fixture(autouse=True) - def _modify_provider(self): + def _modify_provider(self) -> Generator[None]: with patch.multiple( self.provider, needs_default_identity=False, ): yield - def test_api_finish_pipeline_success(self, *args) -> None: + def test_api_finish_pipeline_success(self, *args: Any) -> None: data = { "external_id": self.external_id, "name": "Name", @@ -545,7 +549,7 @@ def test_api_finish_pipeline_success(self, *args) -> None: organization_id=self.organization.id, integration_id=integration.id ).exists() - def test_api_finish_pipeline_disallow_with_no_permission(self, *args) -> None: + def test_api_finish_pipeline_disallow_with_no_permission(self, *args: Any) -> None: member_user = self.create_user() self.create_member(user=member_user, organization=self.organization, role="member") self.login_as(member_user) @@ -577,7 +581,7 @@ def test_api_finish_pipeline_disallow_with_no_permission(self, *args) -> None: == "You must be an organization owner, manager or admin to install this integration." ) - def test_api_finish_pipeline_disallow_with_removed_membership(self, *args) -> None: + def test_api_finish_pipeline_disallow_with_removed_membership(self, *args: Any) -> None: member_user = self.create_user() om = self.create_member(user=member_user, organization=self.organization, role="manager") self.login_as(member_user) @@ -611,7 +615,7 @@ def test_api_finish_pipeline_disallow_with_removed_membership(self, *args) -> No == "You must be an organization owner, manager or admin to install this integration." ) - def test_api_finish_pipeline_disallow_with_no_org_context(self, *args) -> None: + def test_api_finish_pipeline_disallow_with_no_org_context(self, *args: Any) -> None: data = { "external_id": self.external_id, "name": "Name", @@ -631,7 +635,7 @@ def test_api_finish_pipeline_disallow_with_no_org_context(self, *args) -> None: == "You must be an organization owner, manager or admin to install this integration." ) - def test_api_finish_pipeline_identity_conflict(self, *args) -> None: + def test_api_finish_pipeline_identity_conflict(self, *args: Any) -> None: self.provider.needs_default_identity = True new_user = self.create_user() integration = self.create_provider_integration( @@ -665,7 +669,7 @@ def test_api_finish_pipeline_identity_conflict(self, *args) -> None: ) assert not OrganizationIntegration.objects.filter(integration_id=integration.id).exists() - def test_api_finish_pipeline_add_organization_integrity_error(self, *args) -> None: + def test_api_finish_pipeline_add_organization_integrity_error(self, *args: Any) -> None: data = { "external_id": self.external_id, "name": "Name", @@ -689,7 +693,7 @@ class GitlabFinishPipelineTest(IntegrationTestCase): provider = GitlabIntegrationProvider external_id = "dummy_id-123" - def test_install_schedules_repo_sync_and_webhook_update(self, *args) -> None: + def test_install_schedules_repo_sync_and_webhook_update(self, *args: Any) -> None: self.pipeline.state.data = { "external_id": self.external_id, "name": "GitLab", @@ -721,7 +725,7 @@ def test_install_schedules_repo_sync_and_webhook_update(self, *args) -> None: ) sync.assert_called_once_with(organization_integration_id=org_integration.id) - def test_failed_install_does_not_schedule_repo_sync_or_webhook_update(self, *args) -> None: + def test_failed_install_does_not_schedule_repo_sync_or_webhook_update(self, *args: Any) -> None: with ( patch.object( self.pipeline, "_install_integration", side_effect=IntegrationError("Deleting") @@ -738,7 +742,7 @@ def test_failed_install_does_not_schedule_repo_sync_or_webhook_update(self, *arg schedule.assert_not_called() sync.assert_not_called() - def test_different_user_same_external_id(self, *args) -> None: + def test_different_user_same_external_id(self, *args: Any) -> None: new_user = self.create_user() self.setUp() integration = self.create_provider_integration( diff --git a/tests/sentry/integrations/utils/test_feature_flags.py b/tests/sentry/integrations/utils/test_feature_flags.py index ae289a6c31c5..f74dcec4c3b4 100644 --- a/tests/sentry/integrations/utils/test_feature_flags.py +++ b/tests/sentry/integrations/utils/test_feature_flags.py @@ -3,7 +3,7 @@ from sentry.integrations.utils.feature_flags import EventFeatureFlag, get_event_feature_flags -def _make_event(data): +def _make_event(data: dict[str, object]) -> MagicMock: event = MagicMock() event.data = data return event diff --git a/tests/sentry/integrations/utils/test_github_permission_tiers.py b/tests/sentry/integrations/utils/test_github_permission_tiers.py index f1f654e7c327..f1cbeb384cb3 100644 --- a/tests/sentry/integrations/utils/test_github_permission_tiers.py +++ b/tests/sentry/integrations/utils/test_github_permission_tiers.py @@ -1,4 +1,6 @@ +from collections.abc import Mapping from unittest import mock +from unittest.mock import MagicMock import pytest @@ -63,7 +65,7 @@ ] -def _keys(permissions, required=None): +def _keys(permissions: Mapping[str, str], required: Mapping[str, str] | None = None) -> list[str]: tiers = get_permission_tiers(permissions, required or REQUIRED_PERMISSIONS) return [tier.key for tier in tiers] @@ -79,7 +81,9 @@ def _keys(permissions, required=None): ({}, ALL_KEYS), ], ) -def test_tiers_for_production_permission_sets(permissions, expected) -> None: +def test_tiers_for_production_permission_sets( + permissions: Mapping[str, str], expected: list[str] +) -> None: assert _keys(permissions) == expected @@ -106,7 +110,7 @@ def test_pull_requests_read_falls_short_of_the_pr_comments_watermark() -> None: @mock.patch("sentry.integrations.utils.github_permission_tiers.logger.warning") -def test_a_newer_tier_held_without_an_older_one_is_inconsistent(mock_warning) -> None: +def test_a_newer_tier_held_without_an_older_one_is_inconsistent(mock_warning: MagicMock) -> None: assert _keys({**UP_TO_DATE, "contents": "read"}) == ALL_KEYS assert mock_warning.call_args[0][0] == "github_permission_tiers.inconsistent_permissions" @@ -114,7 +118,7 @@ def test_a_newer_tier_held_without_an_older_one_is_inconsistent(mock_warning) -> @mock.patch("sentry.integrations.utils.github_permission_tiers.logger.warning") -def test_a_gap_in_the_middle_of_the_chain_is_inconsistent(mock_warning) -> None: +def test_a_gap_in_the_middle_of_the_chain_is_inconsistent(mock_warning: MagicMock) -> None: assert _keys({**UP_TO_DATE, "actions": "read", "checks": "read"}) == ALL_KEYS assert mock_warning.call_args[1]["extra"]["behind_tiers"] == ["pr_iteration", "code_review"] @@ -122,7 +126,7 @@ def test_a_gap_in_the_middle_of_the_chain_is_inconsistent(mock_warning) -> None: @mock.patch("sentry.integrations.utils.github_permission_tiers.logger.warning") def test_a_requirement_no_tier_claims_falls_short_of_baseline( - mock_warning, + mock_warning: MagicMock, ) -> None: required = {**REQUIRED_PERMISSIONS, "packages": "write"} assert _keys(UP_TO_DATE, required) == ALL_KEYS @@ -138,7 +142,7 @@ def test_a_requirement_no_tier_claims_falls_short_of_baseline( @mock.patch("sentry.integrations.utils.github_permission_tiers.logger.warning") -def test_a_consistent_run_is_not_warned_about(mock_warning) -> None: +def test_a_consistent_run_is_not_warned_about(mock_warning: MagicMock) -> None: assert _keys(MISSING_AUTOFIX_PRS) == ALL_KEYS[:2] mock_warning.assert_not_called() diff --git a/tests/sentry/integrations/utils/test_github_permissions.py b/tests/sentry/integrations/utils/test_github_permissions.py index bea5b2943417..e10a241a6f34 100644 --- a/tests/sentry/integrations/utils/test_github_permissions.py +++ b/tests/sentry/integrations/utils/test_github_permissions.py @@ -1,4 +1,5 @@ from unittest import mock +from unittest.mock import MagicMock import pytest @@ -58,7 +59,11 @@ ), ], ) -def test_get_missing_github_app_permissions(required_permissions, permissions, expected) -> None: +def test_get_missing_github_app_permissions( + required_permissions: dict[str, object], + permissions: dict[str, str] | None, + expected: list[dict[str, object]] | None, +) -> None: with mock.patch.dict( "sentry.integrations.utils.github_permissions.GITHUB_APP_LATEST_PERMISSIONS", required_permissions, @@ -81,12 +86,12 @@ def test_levels_are_ordered_weakest_to_strongest() -> None: ("", None), ], ) -def test_parsing_a_level(level, expected) -> None: +def test_parsing_a_level(level: str, expected: PermissionLevel | None) -> None: assert PermissionLevel.parse(level) is expected @mock.patch("sentry.integrations.utils.github_permissions.logger.warning") -def test_parse_reads_the_levels_it_knows(mock_warning) -> None: +def test_parse_reads_the_levels_it_knows(mock_warning: MagicMock) -> None: parsed = parse_github_app_permissions( {"contents": "read", "issues": "write", "actions": "admin"}, source="installation" ) @@ -101,7 +106,7 @@ def test_parse_reads_the_levels_it_knows(mock_warning) -> None: @mock.patch("sentry.integrations.utils.github_permissions.logger.warning") -def test_parse_sets_aside_a_level_it_does_not_know(mock_warning) -> None: +def test_parse_sets_aside_a_level_it_does_not_know(mock_warning: MagicMock) -> None: parsed = parse_github_app_permissions( {"contents": "write", "issues": "wrtie"}, source="installation" ) @@ -111,7 +116,7 @@ def test_parse_sets_aside_a_level_it_does_not_know(mock_warning) -> None: @mock.patch("sentry.integrations.utils.github_permissions.logger.warning") -def test_parse_reports_a_level_of_the_wrong_type_apart(mock_warning) -> None: +def test_parse_reports_a_level_of_the_wrong_type_apart(mock_warning: MagicMock) -> None: """A level that is not a string is someone writing the constant wrong.""" parsed = parse_github_app_permissions( {"contents": 2, "issues": None, "actions": "wrtie"}, source="required_permissions" @@ -126,7 +131,7 @@ def test_parse_reports_a_level_of_the_wrong_type_apart(mock_warning) -> None: @mock.patch("sentry.integrations.utils.github_permissions.logger.warning") -def test_an_unreadable_required_level_stops_enforcing_anything(mock_warning) -> None: +def test_an_unreadable_required_level_stops_enforcing_anything(mock_warning: MagicMock) -> None: """One level we cannot place means we do not trust the comparison at all.""" required = {"contents": "wrtie", "issues": "write"} @@ -139,7 +144,7 @@ def test_an_unreadable_required_level_stops_enforcing_anything(mock_warning) -> @mock.patch("sentry.integrations.utils.github_permissions.logger.warning") -def test_an_unreadable_held_level_stops_enforcing_anything(mock_warning) -> None: +def test_an_unreadable_held_level_stops_enforcing_anything(mock_warning: MagicMock) -> None: with mock.patch.dict( "sentry.integrations.utils.github_permissions.GITHUB_APP_LATEST_PERMISSIONS", {"contents": "write"}, @@ -149,7 +154,9 @@ def test_an_unreadable_held_level_stops_enforcing_anything(mock_warning) -> None @mock.patch("sentry.integrations.utils.github_permissions.logger.warning") -def test_a_mistyped_required_level_stops_enforcing_rather_than_raising(mock_warning) -> None: +def test_a_mistyped_required_level_stops_enforcing_rather_than_raising( + mock_warning: MagicMock, +) -> None: """A non-string required level is a mistake in the codebase constant.""" with mock.patch.dict( "sentry.integrations.utils.github_permissions.GITHUB_APP_LATEST_PERMISSIONS", @@ -186,7 +193,7 @@ def test_a_mistyped_required_level_stops_enforcing_rather_than_raising(mock_warn ], ) def test_get_github_permissions_update_url( - installation_id, account_type, account_login, expected + installation_id: str, account_type: str | None, account_login: str, expected: str | None ) -> None: assert ( get_github_permissions_update_url(installation_id, account_type, account_login) == expected @@ -213,5 +220,5 @@ def test_get_github_permissions_update_url( ({"last_refresh_at": 1752192000}, True), ], ) -def test_is_permissions_snapshot_stale(metadata, expected) -> None: +def test_is_permissions_snapshot_stale(metadata: dict[str, object] | None, expected: bool) -> None: assert is_permissions_snapshot_stale(metadata) is expected diff --git a/tests/sentry/integrations/utils/test_lifecycle_metrics.py b/tests/sentry/integrations/utils/test_lifecycle_metrics.py index 3f1948366930..e1856cc53ef8 100644 --- a/tests/sentry/integrations/utils/test_lifecycle_metrics.py +++ b/tests/sentry/integrations/utils/test_lifecycle_metrics.py @@ -1,4 +1,5 @@ from unittest import TestCase, mock +from unittest.mock import MagicMock import pytest @@ -45,7 +46,7 @@ def test_key_and_tag_assignment(self) -> None: } @staticmethod - def _check_metrics_call_args(mock_metrics, expected_termination: str): + def _check_metrics_call_args(mock_metrics: MagicMock, expected_termination: str) -> None: assert mock_metrics.incr.call_args_list == [ mock.call( "integrations.slo.started", @@ -184,8 +185,8 @@ def test_recording_failure( @mock.patch("sentry.integrations.utils.metrics.logger") @mock.patch("sentry.integrations.utils.metrics.metrics") def test_recording_explicit_failure_with_exception( - self, mock_metrics, mock_logger, mock_sentry_sdk - ): + self, mock_metrics: MagicMock, mock_logger: MagicMock, mock_sentry_sdk: MagicMock + ) -> None: mock_sentry_sdk.capture_exception.return_value = "test-event-id" metric_obj = self.TestLifecycleMetric() @@ -240,8 +241,8 @@ def test_recording_explicit_failure_with_str( @mock.patch("sentry.integrations.utils.metrics.logger") @mock.patch("sentry.integrations.utils.metrics.metrics") def test_recording_halt_with_create_issue_true( - self, mock_metrics, mock_logger, mock_sentry_sdk - ): + self, mock_metrics: MagicMock, mock_logger: MagicMock, mock_sentry_sdk: MagicMock + ) -> None: """ Test that halt can create Sentry issues when create_issue=True """ @@ -297,8 +298,8 @@ def test_recording_failure_with_create_issue_false( @mock.patch("sentry.integrations.utils.metrics.logger") @mock.patch("sentry.integrations.utils.metrics.metrics") def test_sample_log_rate_always_logs_when_rate_is_one( - self, mock_metrics, mock_logger, mock_random - ): + self, mock_metrics: MagicMock, mock_logger: MagicMock, mock_random: MagicMock + ) -> None: """Test that sample_log_rate=1.0 always logs (default behavior)""" metric_obj = self.TestLifecycleMetric() @@ -373,8 +374,8 @@ def test_sample_log_rate_halt_with_sampling( @mock.patch("sentry.integrations.utils.metrics.logger") @mock.patch("sentry.integrations.utils.metrics.metrics") def test_per_call_sample_log_rate_overrides_instance_rate( - self, mock_metrics, mock_logger, mock_random - ): + self, mock_metrics: MagicMock, mock_logger: MagicMock, mock_random: MagicMock + ) -> None: """Test that per-call sample_log_rate overrides instance default""" mock_random.random.return_value = 0.15 # Between 0.1 and 0.3 @@ -393,8 +394,8 @@ def test_per_call_sample_log_rate_overrides_instance_rate( @mock.patch("sentry.integrations.utils.metrics.logger") @mock.patch("sentry.integrations.utils.metrics.metrics") def test_per_call_sample_log_rate_skips_when_below_threshold( - self, mock_metrics, mock_logger, mock_random - ): + self, mock_metrics: MagicMock, mock_logger: MagicMock, mock_random: MagicMock + ) -> None: """Test that per-call sample_log_rate can cause skipping even with higher instance rate""" mock_random.random.return_value = 0.15 # Between 0.05 and 1.0 @@ -454,8 +455,8 @@ def test_sample_log_rate_on_exception_exit( @mock.patch("sentry.integrations.utils.metrics.logger") @mock.patch("sentry.integrations.utils.metrics.metrics") def test_sample_log_rate_on_assume_success_false_exit( - self, mock_metrics, mock_logger, mock_random - ): + self, mock_metrics: MagicMock, mock_logger: MagicMock, mock_random: MagicMock + ) -> None: """Test that sample rate is respected when exiting context with assume_success=False""" mock_random.random.return_value = 0.25 # Above 0.2 threshold diff --git a/tests/sentry/integrations/utils/test_sync.py b/tests/sentry/integrations/utils/test_sync.py index a78bd7b683d5..4876c8ef6838 100644 --- a/tests/sentry/integrations/utils/test_sync.py +++ b/tests/sentry/integrations/utils/test_sync.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from unittest import mock import pytest @@ -49,7 +50,7 @@ def setUp(self) -> None: user=self.test_user, email="test@example.com", is_verified=True ).exists() - def assign_default_group_to_user(self, user: User, group: Group | None = None): + def assign_default_group_to_user(self, user: User, group: Group | None = None) -> None: group_to_update: Group = group or self.group GroupAssignee.objects.assign(group_to_update, serialize_rpc_user(user)) group_to_update.refresh_from_db() @@ -106,7 +107,7 @@ def test_assignment(self, mock_record_event: mock.MagicMock) -> None: ) updated_assignee = self.group.get_assignee() - assert updated_assignee is not None + assert isinstance(updated_assignee, RpcUser) assert updated_assignee.id == self.test_user.id assert updated_assignee.email == "test@example.com" mock_record_event.assert_called_with(EventLifecycleOutcome.SUCCESS, None, False, None) @@ -196,7 +197,7 @@ def test_assign_with_no_user_found(self, mock_record_halt: mock.MagicMock) -> No def test_assignment_fails( self, mock_group_assign: mock.MagicMock, mock_record_failure: mock.MagicMock ) -> None: - def raise_exception(*args, **kwargs): + def raise_exception(*args: object, **kwargs: object) -> None: raise Exception("oops, something went wrong") assert self.group.get_assignee() is None @@ -410,7 +411,7 @@ def test_watermark_advances_when_no_user_is_resolved(self) -> None: @cell_silo_test class TestSyncAssigneeInboundByExternalActor(TestCase): @pytest.fixture(autouse=True) - def mock_where_should_sync(self): + def mock_where_should_sync(self) -> Iterator[mock.MagicMock]: with mock.patch( "sentry.integrations.utils.sync.where_should_sync" ) as mock_where_should_sync: @@ -441,7 +442,7 @@ def setUp(self) -> None: user=self.test_user, email="test@example.com", is_verified=True ).exists() - def assign_default_group_to_user(self, user: User, group: Group | None = None): + def assign_default_group_to_user(self, user: User, group: Group | None = None) -> None: group_to_update: Group = group or self.group GroupAssignee.objects.assign(group_to_update, serialize_rpc_user(user)) group_to_update.refresh_from_db() @@ -520,7 +521,7 @@ def test_assignment_with_external_actor( ) updated_assignee = self.group.get_assignee() - assert updated_assignee is not None + assert isinstance(updated_assignee, RpcUser) assert updated_assignee.id == self.test_user.id assert updated_assignee.email == "test@example.com" mock_record_event.assert_called_with(EventLifecycleOutcome.SUCCESS, None, False, None) @@ -555,7 +556,7 @@ def test_assignment_with_external_actor_case_insensitive( ) updated_assignee = self.group.get_assignee() - assert updated_assignee is not None + assert isinstance(updated_assignee, RpcUser) assert updated_assignee.id == self.test_user.id assert updated_assignee.email == "test@example.com" mock_record_event.assert_called_with(EventLifecycleOutcome.SUCCESS, None, False, None) @@ -722,7 +723,7 @@ def test_assignment_fails( ) -> None: """Test handling when assignment operation fails.""" - def raise_exception(*_args, **_kwargs): + def raise_exception(*_args: object, **_kwargs: object) -> None: raise Exception("oops, something went wrong during assignment") assert self.group.get_assignee() is None @@ -884,12 +885,12 @@ def test_same_external_name_different_users_across_orgs( # Both groups should be assigned to their respective users assignee1 = group1.get_assignee() - assert assignee1 is not None + assert isinstance(assignee1, RpcUser) assert assignee1.id == user1.id assert assignee1.email == "test@example.com" assignee2 = group2.get_assignee() - assert assignee2 is not None + assert isinstance(assignee2, RpcUser) assert assignee2.id == user2.id assert assignee2.email == "user2@example.com" @@ -1007,7 +1008,7 @@ def test_assign_when_already_assigned_to_same_user_is_reported_as_assigned( assert [group.id for group in groups_assigned] == [self.group.id] updated_assignee = self.group.get_assignee() - assert updated_assignee is not None + assert isinstance(updated_assignee, RpcUser) assert updated_assignee.id == self.test_user.id mock_record_halt.assert_not_called() diff --git a/tests/sentry/integrations/utils/test_webhook_viewer_context.py b/tests/sentry/integrations/utils/test_webhook_viewer_context.py index d338956abdd3..cbb0d42f1291 100644 --- a/tests/sentry/integrations/utils/test_webhook_viewer_context.py +++ b/tests/sentry/integrations/utils/test_webhook_viewer_context.py @@ -7,7 +7,7 @@ class WebhookViewerContextTest(TestCase): @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) - def test_sets_viewer_context_when_enabled(self): + def test_sets_viewer_context_when_enabled(self) -> None: with webhook_viewer_context(42): ctx = get_viewer_context() assert isinstance(ctx, ViewerContext) @@ -18,6 +18,6 @@ def test_sets_viewer_context_when_enabled(self): assert get_viewer_context() is None @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=False) - def test_noop_when_disabled(self): + def test_noop_when_disabled(self) -> None: with webhook_viewer_context(42): assert get_viewer_context() is None diff --git a/tests/sentry/integrations/vsts/test_client.py b/tests/sentry/integrations/vsts/test_client.py index 25f783747433..8b5d24896750 100644 --- a/tests/sentry/integrations/vsts/test_client.py +++ b/tests/sentry/integrations/vsts/test_client.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import Generator from time import time -from typing import TypedDict +from typing import TypedDict, cast from unittest import mock from unittest.mock import MagicMock, call from urllib.parse import parse_qs, quote_plus @@ -10,6 +11,7 @@ import pytest import responses from django.test import override_settings +from requests import PreparedRequest from responses import matchers from fixtures.vsts import VstsIntegrationTestCase @@ -29,7 +31,7 @@ @control_silo_test class VstsApiClientTest(VstsIntegrationTestCase): @pytest.fixture(autouse=True) - def _setup_metric_patch(self): + def _setup_metric_patch(self) -> Generator[None]: with mock.patch("sentry.shared_integrations.client.base.metrics") as self.metrics: yield @@ -116,8 +118,8 @@ def test_identity_property_raises_when_identity_id_is_none(self) -> None: client.identity def test_project_pagination(self) -> None: - def request_callback(request): - query = parse_qs(request.url.split("?")[1]) + def request_callback(request: PreparedRequest) -> tuple[int, dict[str, str], str]: + query = parse_qs(cast(str, request.url).split("?")[1]) # allow for 220 responses if int(query["$skip"][0]) >= 200: projects = [self.project_a, self.project_b] * 10 @@ -398,8 +400,9 @@ def test_get_stacktrace_link_identity_deleted( ) -def assert_proxy_request(request, is_proxy=True): - assert (PROXY_BASE_PATH in request.url) == is_proxy +def assert_proxy_request(request: PreparedRequest, is_proxy: bool = True) -> None: + url = cast(str, request.url) + assert (PROXY_BASE_PATH in url) == is_proxy assert (PROXY_OI_HEADER in request.headers) == is_proxy assert (PROXY_SIGNATURE_HEADER in request.headers) == is_proxy assert ("Authorization" in request.headers) != is_proxy diff --git a/tests/sentry/integrations/vsts/test_integration.py b/tests/sentry/integrations/vsts/test_integration.py index 50e600617c6e..80f0fd50489d 100644 --- a/tests/sentry/integrations/vsts/test_integration.py +++ b/tests/sentry/integrations/vsts/test_integration.py @@ -354,7 +354,10 @@ def _get_step_info(self) -> Any: return self.client.get(self._get_pipeline_url()) def _get_pipeline_signature(self, init_resp: Any) -> str: - return parse_qs(urlparse(init_resp.data["data"]["oauthUrl"]).query)["state"][0] + oauth_url = init_resp.data["data"]["oauthUrl"] + if not isinstance(oauth_url, str): + raise TypeError("VSTS pipeline response carries no OAuth URL") + return parse_qs(urlparse(oauth_url).query)["state"][0] def test_initialize_pipeline(self) -> None: resp = self._initialize_pipeline() @@ -1003,7 +1006,7 @@ def test_create_comment(self, mock_update_work_item: MagicMock) -> None: comment = Mock() comment.data = {"text": comment_text} - work_item = self.installation.create_comment(1, self.user.id, comment) + work_item = self.installation.create_comment("1", self.user.id, comment) assert work_item and work_item["id"] assert ( @@ -1017,4 +1020,4 @@ def test_update_comment(self) -> None: group_note.data = {"text": comment, "external_id": "123"} # Does nothing. - self.installation.update_comment(1, self.user.id, group_note) + self.installation.update_comment("1", self.user.id, group_note) diff --git a/tests/sentry/integrations/vsts/test_issues.py b/tests/sentry/integrations/vsts/test_issues.py index 15584fe6d9df..fcf7254d0502 100644 --- a/tests/sentry/integrations/vsts/test_issues.py +++ b/tests/sentry/integrations/vsts/test_issues.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from time import time from typing import Any from unittest.mock import MagicMock, patch @@ -39,7 +40,9 @@ pytestmark = [requires_snuba] -def generate_mock_response(*, method: str, non_region_url: str, path: str, **kwargs): +def generate_mock_response( + *, method: str, non_region_url: str, path: str, **kwargs: Any +) -> None: # justification: heterogeneous kwargs forwarded to the responses library if SiloMode.get_current_mode() == SiloMode.CELL: match: list[Any] | None = kwargs.pop("match", None) if match is None: @@ -57,7 +60,9 @@ def generate_mock_response(*, method: str, non_region_url: str, path: str, **kwa responses.add(method=method, url=non_region_url, **kwargs) -def assert_response_calls(expected_region_response, expected_non_region_response): +def assert_response_calls( + expected_region_response: Sequence[str], expected_non_region_response: Sequence[str] +) -> None: assert len(expected_region_response) == len(expected_non_region_response) if SiloMode.get_current_mode() == SiloMode.CELL: for index, path in enumerate(expected_region_response): @@ -101,7 +106,7 @@ def setUp(self) -> None: "sync_forward_assignment": True, "sync_reverse_assignment": True, } - self.integration = VstsIntegration(model, self.organization.id) + self.installation = VstsIntegration(model, self.organization.id) self.issue_id = "309" responses.add( responses.GET, @@ -110,7 +115,7 @@ def setUp(self) -> None: ) self.project_id_with_states = "c0bf429a-c03c-4a99-9336-d45be74db5a6" - def mock_categories(self, project): + def mock_categories(self, project: str) -> None: responses.add( responses.GET, f"https://fabrikam-fiber-inc.visualstudio.com/{project}/_apis/wit/workitemtypecategories", @@ -181,7 +186,7 @@ def test_create_issue(self) -> None: "project": "0987654321", "work_item_type": "Microsoft.VSTS.WorkItemTypes.Task", } - assert self.integration.create_issue(form_data) == { + assert self.installation.create_issue(form_data) == { "key": self.issue_id, "description": "Fix this.", "title": "Hello", @@ -213,7 +218,7 @@ def test_create_issue_integration_form_error(self, create_work_item: MagicMock) "work_item_type": "Microsoft.VSTS.WorkItemTypes.Task", } with pytest.raises(IntegrationFormError): - self.integration.create_issue(form_data) + self.installation.create_issue(form_data) @responses.activate def test_create_issue_title_too_long(self) -> None: @@ -233,7 +238,7 @@ def test_create_issue_title_too_long(self) -> None: "project": "0987654321", "work_item_type": "Microsoft.VSTS.WorkItemTypes.Task", } - assert self.integration.create_issue(form_data) == { + assert self.installation.create_issue(form_data) == { "key": self.issue_id, "description": "Fix this.", "title": expected_title, @@ -258,10 +263,10 @@ def test_create_issue_failure(self) -> None: } with pytest.raises(IntegrationFormError): - self.integration.create_issue(form_data) + self.installation.create_issue(form_data) def test_issue_url(self) -> None: - self.integration.model.metadata["domain_name"] = ( + self.installation.model.metadata["domain_name"] = ( "https://Fabrikam-Fiber-Inc.VisualStudio.COM/" ) for url in ( @@ -270,14 +275,14 @@ def test_issue_url(self) -> None: "https://dev.azure.com/FABRIKAM-FIBER-INC/project/_workitems/edit/309?view=1", "https://dev.azure.com:443/fabrikam-fiber-inc/project/_workitems/edit/309", ): - assert self.integration.get_issue_link_data(url) == {"externalIssue": "309"} + assert self.installation.get_issue_link_data(url) == {"externalIssue": "309"} for url in ( "https://dev.azure.com/other/_workitems/edit/309", "http://dev.azure.com/fabrikam-fiber-inc/_workitems/edit/309", "https://dev.azure.com:8443/fabrikam-fiber-inc/_workitems/edit/309", ): with pytest.raises(IntegrationFormError): - self.integration.get_issue_link_data(url) + self.installation.get_issue_link_data(url) @responses.activate def test_get_issue(self) -> None: @@ -287,7 +292,7 @@ def test_get_issue(self) -> None: body=WORK_ITEM_RESPONSE, content_type="application/json", ) - assert self.integration.get_issue(self.issue_id) == { + assert self.installation.get_issue(self.issue_id) == { "key": self.issue_id, "description": "Fix this.", "title": "Hello", @@ -319,12 +324,12 @@ def test_sync_assignee_outbound(self, use_proxy_url_for_tests: MagicMock) -> Non user = user_service.get_user(user_id=self.create_user("ftotten@vscsi.us").id) external_issue = ExternalIssue.objects.create( organization_id=self.organization.id, - integration_id=self.integration.model.id, + integration_id=self.installation.model.id, key=vsts_work_item_id, title="I'm a title!", description="I'm a description.", ) - self.integration.sync_assignee_outbound(external_issue, user, assign=True) + self.installation.sync_assignee_outbound(external_issue, user, assign=True) assert len(responses.calls) == 2 assert_response_calls( expected_region_response=[ @@ -380,12 +385,12 @@ def test_sync_assignee_outbound_with_paging(self, use_proxy_url_for_tests: Magic user = user_service.get_user(user_id=self.create_user("ftotten@vscsi.us").id) external_issue = ExternalIssue.objects.create( organization_id=self.organization.id, - integration_id=self.integration.model.id, + integration_id=self.installation.model.id, key=vsts_work_item_id, title="I'm a title!", description="I'm a description.", ) - self.integration.sync_assignee_outbound(external_issue, user, assign=True) + self.installation.sync_assignee_outbound(external_issue, user, assign=True) assert len(responses.calls) == 3 assert_response_calls( expected_region_response=[ @@ -437,7 +442,7 @@ def test_sync_status_outbound(self) -> None: external_issue = ExternalIssue.objects.create( organization_id=self.organization.id, - integration_id=self.integration.model.id, + integration_id=self.installation.model.id, key=vsts_work_item_id, title="I'm a title!", description="I'm a description.", @@ -446,11 +451,11 @@ def test_sync_status_outbound(self) -> None: with assume_test_silo_mode(SiloMode.CONTROL): IntegrationExternalProject.objects.create( external_id="ac7c05bb-7f8e-4880-85a6-e08f37fd4a10", - organization_integration_id=self.integration.org_integration.id, + organization_integration_id=self.installation.org_integration.id, resolved_status="Resolved", unresolved_status="New", ) - self.integration.sync_status_outbound(external_issue, True, self.project.id) + self.installation.sync_status_outbound(external_issue, True, self.project.id) assert len(responses.calls) == 3 req = responses.calls[2].request assert ( @@ -473,7 +478,7 @@ def test_sync_status_outbound_invalid_identity(self, get_work_item: MagicMock) - vsts_work_item_id = 5 external_issue = ExternalIssue.objects.create( organization_id=self.organization.id, - integration_id=self.integration.model.id, + integration_id=self.installation.model.id, key=vsts_work_item_id, title="I'm a title!", description="I'm a description.", @@ -482,23 +487,23 @@ def test_sync_status_outbound_invalid_identity(self, get_work_item: MagicMock) - with assume_test_silo_mode(SiloMode.CONTROL): IntegrationExternalProject.objects.create( external_id="ac7c05bb-7f8e-4880-85a6-e08f37fd4a10", - organization_integration_id=self.integration.org_integration.id, + organization_integration_id=self.installation.org_integration.id, resolved_status="Resolved", unresolved_status="New", ) with pytest.raises(ApiUnauthorized): - self.integration.sync_status_outbound(external_issue, True, self.project.id) + self.installation.sync_status_outbound(external_issue, True, self.project.id) def test_get_issue_url(self) -> None: - work_id = 345 - url = self.integration.get_issue_url(work_id) + work_id = "345" + url = self.installation.get_issue_url(work_id) assert url == "https://fabrikam-fiber-inc.visualstudio.com/_workitems/edit/345" @responses.activate def test_should_resolve_active_to_resolved(self) -> None: assert ( - self.integration.get_resolve_sync_action( + self.installation.get_resolve_sync_action( { "project": self.project_id_with_states, "old_state": "Active", @@ -511,7 +516,7 @@ def test_should_resolve_active_to_resolved(self) -> None: @responses.activate def test_should_resolve_resolved_to_active(self) -> None: assert ( - self.integration.get_resolve_sync_action( + self.installation.get_resolve_sync_action( { "project": self.project_id_with_states, "old_state": "Resolved", @@ -524,7 +529,7 @@ def test_should_resolve_resolved_to_active(self) -> None: @responses.activate def test_should_resolve_new(self) -> None: assert ( - self.integration.get_resolve_sync_action( + self.installation.get_resolve_sync_action( {"project": self.project_id_with_states, "old_state": None, "new_state": "New"} ) == ResolveSyncAction.UNRESOLVE @@ -545,7 +550,7 @@ def test_should_resolve_done_status_failure(self) -> None: # Answering with an empty set would read as "no state is a done state" and unresolve # every transition, so the lookup has to fail rather than guess. with pytest.raises(ApiError): - self.integration.get_resolve_sync_action( + self.installation.get_resolve_sync_action( { "project": self.project_id_with_states, "old_state": "Active", @@ -556,7 +561,7 @@ def test_should_resolve_done_status_failure(self) -> None: @responses.activate def test_should_not_unresolve_resolved_to_closed(self) -> None: assert ( - self.integration.get_resolve_sync_action( + self.installation.get_resolve_sync_action( { "project": self.project_id_with_states, "old_state": "Resolved", @@ -586,23 +591,36 @@ def setUp(self) -> None: event = self.store_event( data={"fingerprint": ["group1"], "timestamp": min_ago}, project_id=self.project.id ) + assert event.group is not None self.group = event.group def tearDown(self) -> None: responses.reset() - def update_issue_defaults(self, defaults): - self.integration.org_integration = integration_service.update_organization_integration( - org_integration_id=self.integration.org_integration.id, + def update_issue_defaults(self, defaults: Mapping[str, object]) -> None: + org_integration = integration_service.update_organization_integration( + org_integration_id=self.installation.org_integration.id, config={"project_issue_defaults": {str(self.group.project_id): defaults}}, ) + assert org_integration is not None + self.installation.org_integration = org_integration - def assert_project_field(self, fields, default_value, choices): + def assert_project_field( + self, + fields: Sequence[Mapping[str, object]], + default_value: object, + choices: Sequence[object], + ) -> None: project_field = [field for field in fields if field["name"] == "project"][0] assert project_field["defaultValue"] == default_value assert project_field["choices"] == choices - def assert_work_item_type_field(self, fields, default_value, choices): + def assert_work_item_type_field( + self, + fields: Sequence[Mapping[str, object]], + default_value: object, + choices: Sequence[object], + ) -> None: project_field = [field for field in fields if field["name"] == "work_item_type"][0] assert project_field["defaultValue"] == default_value assert project_field["choices"] == choices @@ -611,7 +629,7 @@ def assert_work_item_type_field(self, fields, default_value, choices): def test_default_project(self) -> None: self.mock_categories("project-2-id") self.update_issue_defaults({"project": "project-2-id"}) - fields = self.integration.get_create_issue_config(self.group, self.user) + fields = self.installation.get_create_issue_config(self.group, self.user) self.assert_project_field( fields, "project-2-id", [("project-1-id", "project_1"), ("project-2-id", "project_2")] @@ -621,7 +639,7 @@ def test_default_project(self) -> None: def test_default_project_and_category(self) -> None: self.mock_categories("project-2-id") self.update_issue_defaults({"project": "project-2-id", "work_item_type": "Task"}) - fields = self.integration.get_create_issue_config(self.group, self.user) + fields = self.installation.get_create_issue_config(self.group, self.user) self.assert_project_field( fields, "project-2-id", [("project-1-id", "project_1"), ("project-2-id", "project_2")] @@ -647,7 +665,7 @@ def test_default_project_default_missing_in_choices(self) -> None: json={"id": "project-3-id", "name": "project_3"}, ) self.update_issue_defaults({"project": "project-3-id"}) - fields = self.integration.get_create_issue_config(self.group, self.user) + fields = self.installation.get_create_issue_config(self.group, self.user) self.assert_project_field( fields, @@ -667,7 +685,7 @@ def test_default_project_error_on_default_project(self) -> None: status=404, ) self.update_issue_defaults({"project": "project-3-id"}) - fields = self.integration.get_create_issue_config(self.group, self.user) + fields = self.installation.get_create_issue_config(self.group, self.user) self.assert_project_field( fields, None, [("project-1-id", "project_1"), ("project-2-id", "project_2")] @@ -681,7 +699,7 @@ def test_get_create_issue_config_error_on_get_projects(self) -> None: ) with pytest.raises(IntegrationError): - self.integration.get_create_issue_config(self.group, self.user) + self.installation.get_create_issue_config(self.group, self.user) @responses.activate def test_default_project_no_projects(self) -> None: @@ -691,7 +709,7 @@ def test_default_project_no_projects(self) -> None: "https://fabrikam-fiber-inc.visualstudio.com/_apis/projects", json={"value": [], "count": 0}, ) - fields = self.integration.get_create_issue_config(self.group, self.user) + fields = self.installation.get_create_issue_config(self.group, self.user) self.assert_project_field(fields, None, []) @@ -703,4 +721,4 @@ def test_raise_error_api_unauthorized(self) -> None: error_message = "According to Microsoft Entra, your Identity xxx is currently Deleted within the following Microsoft Entra tenant: xxx Please contact your Microsoft Entra administrator to resolve this." api_error = ApiError(error_message) with pytest.raises(ApiUnauthorized): - self.integration.raise_error(api_error) + self.installation.raise_error(api_error) diff --git a/tests/sentry/integrations/vsts/test_kickoff_vsts_subscription_check.py b/tests/sentry/integrations/vsts/test_kickoff_vsts_subscription_check.py index e66287e64448..9e57c8f8dc82 100644 --- a/tests/sentry/integrations/vsts/test_kickoff_vsts_subscription_check.py +++ b/tests/sentry/integrations/vsts/test_kickoff_vsts_subscription_check.py @@ -17,7 +17,10 @@ def _get_subscription_data(external_id: str) -> Mapping[str, Any]: integration = Integration.objects.get(provider=PROVIDER, external_id=external_id) - return integration.metadata["subscription"] + subscription = integration.metadata["subscription"] + if not isinstance(subscription, Mapping): + raise TypeError("VSTS integration has no subscription metadata") + return subscription def assert_no_subscription(external_id: str, subscription_id: str) -> None: diff --git a/tests/sentry/integrations/vsts/test_notify_action.py b/tests/sentry/integrations/vsts/test_notify_action.py index 585f764b416a..d227decdbf8d 100644 --- a/tests/sentry/integrations/vsts/test_notify_action.py +++ b/tests/sentry/integrations/vsts/test_notify_action.py @@ -1,4 +1,5 @@ from time import time +from typing import Any import orjson import responses @@ -25,6 +26,11 @@ class AzureDevopsCreateTicketActionTest(RuleTestCase, VstsIssueBase): rule_cls = AzureDevopsCreateTicketAction + def get_azure_rule(self, **kwargs: Any) -> AzureDevopsCreateTicketAction: + rule = self.get_rule(**kwargs) + assert isinstance(rule, AzureDevopsCreateTicketAction) + return rule + def setUp(self) -> None: integration, _, _, _ = self.create_identity_integration( user=self.user, @@ -43,20 +49,20 @@ def setUp(self) -> None: "data": {"access_token": "123456789", "expires": time() + 1234567}, }, ) - self.integration = VstsIntegration(integration, self.organization.id) + self.installation = VstsIntegration(integration, self.organization.id) @responses.activate def test_create_issue(self) -> None: self.mock_categories("ac7c05bb-7f8e-4880-85a6-e08f37fd4a10") - event = self.get_event() + event = self.get_group_event() rule = self.create_project_rule(project=self.project) - azuredevops_rule = self.get_rule( + azuredevops_rule = self.get_azure_rule( data={ "title": "Hello", "description": "Fix this.", "project": "0987654321", "work_item_type": "Microsoft.VSTS.WorkItemTypes.Task", - "integration": self.integration.model.id, + "integration": self.installation.model.id, }, context=create_notification_action_context(rule), ) @@ -88,10 +94,10 @@ def test_doesnt_create_issue(self) -> None: """Don't create an issue if one already exists on the event""" self.mock_categories("ac7c05bb-7f8e-4880-85a6-e08f37fd4a10") - event = self.get_event() + event = self.get_group_event() external_issue = ExternalIssue.objects.create( organization_id=self.organization.id, - integration_id=self.integration.model.id, + integration_id=self.installation.model.id, key="6", title=event.title, description="Fix this.", @@ -102,7 +108,7 @@ def test_doesnt_create_issue(self) -> None: linked_type=GroupLink.LinkedType.issue, linked_id=external_issue.id, relationship=GroupLink.Relationship.references, - data={"provider": self.integration.model.provider}, + data={"provider": self.installation.model.provider}, ) responses.add( responses.GET, @@ -110,16 +116,15 @@ def test_doesnt_create_issue(self) -> None: body=GET_PROJECTS_RESPONSE, content_type="application/json", ) - azuredevops_rule = self.get_rule( + azuredevops_rule = self.get_azure_rule( data={ "title": "Hello", "description": "Fix this.", "project": "0987654321", "work_item_type": "Microsoft.VSTS.WorkItemTypes.Task", - "integration": self.integration.model.id, + "integration": self.installation.model.id, } ) - azuredevops_rule.rule = self.create_project_rule(name="test rule") results = list(azuredevops_rule.after(event=event)) assert len(results) == 1 @@ -127,9 +132,9 @@ def test_doesnt_create_issue(self) -> None: assert len(responses.calls) == 0 def test_render_label(self) -> None: - azuredevops_rule = self.get_rule( + azuredevops_rule = self.get_azure_rule( data={ - "integration": self.integration.model.id, + "integration": self.installation.model.id, "work_item_type": "Microsoft.VSTS.WorkItemTypes.Task", "project": "0987654321", "dynamic_form_fields": { @@ -190,6 +195,6 @@ def test_render_label_without_integration(self) -> None: with assume_test_silo_mode(SiloMode.CONTROL): integration.delete() - rule = self.get_rule(data={"integration": deleted_id}) + rule = self.get_azure_rule(data={"integration": deleted_id}) assert rule.render_label() == "Create an Azure DevOps work item in [removed] with these " diff --git a/tests/sentry/integrations/vsts/test_provider.py b/tests/sentry/integrations/vsts/test_provider.py index 59d114e3d481..3408fc141175 100644 --- a/tests/sentry/integrations/vsts/test_provider.py +++ b/tests/sentry/integrations/vsts/test_provider.py @@ -122,7 +122,7 @@ def test_exchange_token(self) -> None: @control_silo_test class TestVSTSOAuth2LoginView(TestCase): - def get_authorize_params(self) -> dict[str, str]: + def get_authorize_params(self) -> dict[str, str | int | None]: view = VSTSOAuth2LoginView( authorize_url="https://login.microsoftonline.com/common/oauth2/v2.0/authorize", client_id="vsts-new-client-id", diff --git a/tests/sentry/integrations/vsts/test_repository.py b/tests/sentry/integrations/vsts/test_repository.py index 0f49f796e901..5750dfb8ce6b 100644 --- a/tests/sentry/integrations/vsts/test_repository.py +++ b/tests/sentry/integrations/vsts/test_repository.py @@ -1,13 +1,16 @@ import datetime +from collections.abc import Mapping from datetime import timezone from functools import cached_property from time import time +from typing import cast import responses from fixtures.vsts import COMMIT_DETAILS_EXAMPLE, COMPARE_COMMITS_EXAMPLE, FILE_CHANGES_EXAMPLE from sentry.integrations.vsts.repository import VstsRepositoryProvider from sentry.models.repository import Repository +from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.silo.base import SiloMode from sentry.testutils.cases import IntegrationRepositoryTestCase, TestCase from sentry.testutils.silo import assume_test_silo_mode, control_silo_test @@ -21,7 +24,7 @@ def setUp(self) -> None: self.vsts_external_id = "654321" @cached_property - def provider(self): + def provider(self) -> VstsRepositoryProvider: return VstsRepositoryProvider("integrations:vsts") @responses.activate @@ -101,9 +104,12 @@ def test_build_repository_config(self) -> None: "project": "MyFirstProject", "installation": integration.id, } - data = self.provider.build_repository_config(organization, data) + repository_config = self.provider.build_repository_config( + serialize_rpc_organization(organization, include_projects=False, include_teams=False), + data, + ) - assert data == { + assert repository_config == { "name": "MyFirstProject", "external_id": self.vsts_external_id, "url": "https://mbittker.visualstudio.com/_git/MyFirstProject/", @@ -184,7 +190,7 @@ def tearDown(self) -> None: super().tearDown() responses.reset() - def add_create_repository_responses(self, repository_config): + def add_create_repository_responses(self, repository_config: Mapping[str, object]) -> None: responses.add( responses.GET, "https://visualstudio.com/_apis/git/repositories/123", @@ -192,18 +198,21 @@ def add_create_repository_responses(self, repository_config): ) @assume_test_silo_mode(SiloMode.CELL) - def assert_repository(self, repository_config, organization_id=None): + def assert_repository( + self, repository_config: Mapping[str, object], organization_id: int | None = None + ) -> None: repo = Repository.objects.get( organization_id=organization_id or self.organization.id, provider=self.provider_name, external_id=repository_config["id"], ) assert repo.name == repository_config["name"] - assert repo.url == repository_config["_links"]["web"]["href"] + links = cast(Mapping[str, Mapping[str, object]], repository_config["_links"]) + assert repo.url == links["web"]["href"] assert repo.integration_id == self.integration.id assert repo.config == { "instance": self.base_url, - "project": repository_config["project"]["name"], + "project": cast(Mapping[str, object], repository_config["project"])["name"], "name": repository_config["name"], } diff --git a/tests/sentry/integrations/vsts/test_webhooks.py b/tests/sentry/integrations/vsts/test_webhooks.py index 1bd69338ddb2..190f2cde4526 100644 --- a/tests/sentry/integrations/vsts/test_webhooks.py +++ b/tests/sentry/integrations/vsts/test_webhooks.py @@ -1,5 +1,7 @@ +from collections.abc import Mapping from copy import deepcopy from time import time +from typing import Any from unittest.mock import MagicMock, patch import responses @@ -18,13 +20,14 @@ from sentry.models.activity import Activity from sentry.models.group import Group, GroupStatus from sentry.models.grouplink import GroupLink +from sentry.models.project import Project from sentry.silo.base import SiloMode from sentry.testutils.asserts import assert_failure_metric, assert_success_metric from sentry.testutils.cases import APITestCase from sentry.testutils.silo import assume_test_silo_mode from sentry.users.models.identity import Identity from sentry.utils.http import absolute_uri -from sentry.viewer_context import ActorType, get_viewer_context +from sentry.viewer_context import ActorType, ViewerContext, get_viewer_context class VstsWebhookWorkItemTest(APITestCase): @@ -66,14 +69,16 @@ def setUp(self) -> None: "sync_reverse_assignment": True, } self.org_integration.save() - self.integration = VstsIntegration(self.model, self.organization.id) + self.installation = VstsIntegration(self.model, self.organization.id) self.user_to_assign = self.create_user("sentryuseremail@email.com") def tearDown(self) -> None: responses.reset() - def create_linked_group(self, external_issue, project, status): + def create_linked_group( + self, external_issue: ExternalIssue, project: Project, status: int + ) -> Group: group = self.create_group(project=project, status=status) GroupLink.objects.create( group_id=group.id, @@ -84,8 +89,10 @@ def create_linked_group(self, external_issue, project, status): ) return group - def set_workitem_state(self, old_value, new_value): - work_item = deepcopy(WORK_ITEM_UPDATED_STATUS) + def set_workitem_state( + self, old_value: str | None, new_value: str + ) -> dict[str, Any]: # justification: fixture payload is untyped JSON + work_item: dict[str, Any] = deepcopy(WORK_ITEM_UPDATED_STATUS) state = work_item["resource"]["fields"]["System.State"] if old_value is None: @@ -338,13 +345,15 @@ def test_inbound_status_sync_new_workitem(self) -> None: @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) def test_status_change_sets_viewer_context(self) -> None: """ViewerContext is set with correct org_id and actor_type during status sync.""" - captured_contexts: list = [] + captured_contexts: list[ViewerContext | None] = [] original_sync = VstsIntegration.sync_status_inbound - def capturing_sync(self_integration, *args, **kwargs): + def capturing_sync( + self_integration: VstsIntegration, issue_key: str, data: Mapping[str, object] + ) -> None: captured_contexts.append(get_viewer_context()) - return original_sync(self_integration, *args, **kwargs) + return original_sync(self_integration, issue_key, data) work_item_id = 33 ExternalIssue.objects.create( diff --git a/tests/sentry/investigations/endpoints/serializers/test_investigation.py b/tests/sentry/investigations/endpoints/serializers/test_investigation.py index 259df4473a2b..1cfafc80ca2b 100644 --- a/tests/sentry/investigations/endpoints/serializers/test_investigation.py +++ b/tests/sentry/investigations/endpoints/serializers/test_investigation.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from uuid import uuid4 from django.db import connection @@ -176,10 +176,14 @@ def setUp(self) -> None: self.block = self.create_notebook_block(notebook=self.investigation, position=0) def serialize_detail(self) -> InvestigationDetailsSerializerResponse: - return serialize( - self.investigation, - self.user, - InvestigationDetailsSerializer(), + # justification: the investigation serializer emits the detailed response payload. + return cast( + "InvestigationDetailsSerializerResponse", + serialize( + self.investigation, + self.user, + InvestigationDetailsSerializer(), + ), ) def test_extends_the_list_representation(self) -> None: @@ -323,9 +327,16 @@ def add_request(self, key: str = "widen-window", **values: Any) -> NotebookActio def serialize_requests(self) -> list[dict[str, Any]]: access = from_user(self.user, self.organization) - return serialize( - self.investigation, self.user, InvestigationDetailsSerializer(access=access) - )["actionRequests"] + detail = cast( + "InvestigationDetailsSerializerResponse", + serialize( + self.investigation, + self.user, + InvestigationDetailsSerializer(access=access), + ), + ) + # justification: helper callers treat action requests as plain mappings. + return cast("list[dict[str, Any]]", detail["actionRequests"]) def test_serializes_an_action_request(self) -> None: action_request = self.add_request( @@ -653,12 +664,20 @@ def test_providers_cannot_be_connected_without_infra_telemetry(self) -> None: def serialize_for(self, user: Any) -> list[dict[str, Any]]: access = from_user(user, self.organization) - return serialize(self.investigation, user, InvestigationDetailsSerializer(access=access))[ - "actionRequests" - ] + detail = cast( + "InvestigationDetailsSerializerResponse", + serialize( + self.investigation, + user, + InvestigationDetailsSerializer(access=access), + ), + ) + # justification: helper callers treat action requests as plain mappings. + return cast("list[dict[str, Any]]", detail["actionRequests"]) def cannot_apply_reason(self, user: Any = None) -> str | None: - return self.serialize_for(user or self.user)[0]["cannotApplyReason"] + reason = self.serialize_for(user or self.user)[0]["cannotApplyReason"] + return reason if isinstance(reason, str) else None def test_an_editor_can_apply_a_suggestion(self) -> None: self.add_request() diff --git a/tests/sentry/investigations/endpoints/test_organization_investigation_action_request_apply.py b/tests/sentry/investigations/endpoints/test_organization_investigation_action_request_apply.py index ca8d85f1ec10..f5ad40100163 100644 --- a/tests/sentry/investigations/endpoints/test_organization_investigation_action_request_apply.py +++ b/tests/sentry/investigations/endpoints/test_organization_investigation_action_request_apply.py @@ -1,8 +1,8 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from datetime import timedelta -from typing import Any +from typing import Any, cast from django.test import override_settings from django.urls import reverse @@ -126,7 +126,8 @@ def create_metric_detector( self.create_data_condition( condition_group=group, type=condition_type, - comparison=comparison, + # justification: DataCondition.comparison is a JSON field where floats are valid. + comparison=cast("str | int | Mapping[str, object]", comparison), condition_result=result, ) detector = self.create_detector( @@ -284,7 +285,8 @@ def details_request(self) -> dict[str, Any]: ) assert response.status_code == 200 [action_request] = response.data["actionRequests"] - return action_request + # justification: the endpoint serializes action requests as JSON objects. + return cast("dict[str, Any]", action_request) def test_details_say_the_viewer_can_apply_a_change_to_the_monitor(self) -> None: self.suggest( diff --git a/tests/sentry/investigations/endpoints/test_organization_investigation_block_executions.py b/tests/sentry/investigations/endpoints/test_organization_investigation_block_executions.py index 052079e9e34d..b4d87a9e4ab1 100644 --- a/tests/sentry/investigations/endpoints/test_organization_investigation_block_executions.py +++ b/tests/sentry/investigations/endpoints/test_organization_investigation_block_executions.py @@ -145,6 +145,7 @@ def start_run(*args: Any, **kwargs: Any) -> Any: assert first.data["id"] != second.data["id"] assert mock_client.return_value.start_run.call_count == 2 self.block.refresh_from_db() + assert self.block.current_execution is not None assert str(self.block.current_execution.id) == second.data["id"] @patch( diff --git a/tests/sentry/investigations/endpoints/test_organization_investigation_candidates.py b/tests/sentry/investigations/endpoints/test_organization_investigation_candidates.py index aa22f6fb9e04..084c0867f4c3 100644 --- a/tests/sentry/investigations/endpoints/test_organization_investigation_candidates.py +++ b/tests/sentry/investigations/endpoints/test_organization_investigation_candidates.py @@ -46,7 +46,7 @@ def create_metric_open_period( self, *, detection_type: AlertRuleDetectionType = AlertRuleDetectionType.STATIC, - condition_type: str = Condition.GREATER, + condition_type: Condition = Condition.GREATER, condition_comparison: Any = 100, comparison_delta: int | None = None, ) -> tuple[Any, GroupOpenPeriod]: diff --git a/tests/sentry/investigations/services/test_auto_run.py b/tests/sentry/investigations/services/test_auto_run.py index 124bc5f74467..6b4044255604 100644 --- a/tests/sentry/investigations/services/test_auto_run.py +++ b/tests/sentry/investigations/services/test_auto_run.py @@ -526,7 +526,8 @@ def test_failed_command_can_redeliver_the_same_idempotent_request( command.refresh_from_db() self.orchestration_run.refresh_from_db() assert command.status == InvestigationOrchestrationCommandStatus.ACKNOWLEDGED - assert self.orchestration_run.error is None + assert not self.orchestration_run.error + assert self.orchestration_run.projection is not None assert self.orchestration_run.projection["errors"] == [] @mock.patch("sentry.tasks.seer.investigation.dispatch_investigation_orchestration_command") @@ -623,6 +624,7 @@ def test_command_conflict_reconciliation_retries_then_persists_failure( command.refresh_from_db() assert command.status == InvestigationOrchestrationCommandStatus.FAILED + assert command.error is not None assert command.error["code"] == "seer_command_dispatch_failed" assert command.error["retryable"] is True @@ -713,6 +715,7 @@ def test_command_version_conflict_reconciles_and_fails_stale_queue( "retryable": False, } assert later_command.status == InvestigationOrchestrationCommandStatus.FAILED + assert later_command.error is not None assert later_command.error["code"] == "earlier_command_conflicted" with mock.patch( @@ -857,6 +860,7 @@ def get_with_callback(*args: Any, **kwargs: Any) -> dict[str, Any]: assert self.orchestration_run.status == "completed" assert self.orchestration_run.last_event_sequence == 1 assert command.status == InvestigationOrchestrationCommandStatus.FAILED + assert command.error is not None assert command.error["reason"] == "workflow_version_conflict" @mock.patch("sentry.tasks.seer.investigation.current_task") diff --git a/tests/sentry/investigations/services/test_orchestration_events.py b/tests/sentry/investigations/services/test_orchestration_events.py index decadc8f3e37..2d824b461327 100644 --- a/tests/sentry/investigations/services/test_orchestration_events.py +++ b/tests/sentry/investigations/services/test_orchestration_events.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import timedelta -from typing import Any +from typing import Any, cast from unittest.mock import Mock, patch from uuid import UUID, uuid4 @@ -64,11 +64,15 @@ class SeerRunMirrorMixin: def seer_run_mirror(self, seer_run_state_id: int) -> SeerRun: """A SeerRun mirror standing in for a run Seer already accepted.""" - return self.create_seer_run( # type: ignore[attr-defined] - organization=self.organization, # type: ignore[attr-defined] - type=SeerRunType.INVESTIGATION, - seer_run_state_id=seer_run_state_id, - mirror_status=SeerRunMirrorStatus.LIVE, + # justification: create_seer_run builds and returns a SeerRun. + return cast( + "SeerRun", + self.create_seer_run( # type: ignore[attr-defined] + organization=self.organization, # type: ignore[attr-defined] + type=SeerRunType.INVESTIGATION, + seer_run_state_id=seer_run_state_id, + mirror_status=SeerRunMirrorStatus.LIVE, + ), ) @@ -255,7 +259,7 @@ def projection( "heartbeatAt": "2025-01-01T00:00:00+00:00", } - def event( + def make_event( self, sequence: int, event_type: str, @@ -300,12 +304,12 @@ def test_execution_timestamps_survive_completion_and_snapshot_replay(self) -> No "sentry.investigations.services.orchestration_events.timezone.now", return_value=started_at, ): - self.deliver(self.event(1, "report_block_started", payload)) + self.deliver(self.make_event(1, "report_block_started", payload)) with patch( "sentry.investigations.services.orchestration_events.timezone.now", return_value=completed_at, ): - self.deliver(self.event(2, "report_block_upserted", payload)) + self.deliver(self.make_event(2, "report_block_upserted", payload)) block = NotebookBlock.objects.get(notebook=self.investigation, producer_key="summary") assert block.current_execution is not None execution_id = block.current_execution_id @@ -314,7 +318,7 @@ def test_execution_timestamps_survive_completion_and_snapshot_replay(self) -> No assert block.current_execution.input_snapshot["source"] == self.orchestration_run.source self.deliver( - self.event( + self.make_event( 3, "state_snapshot", { @@ -334,9 +338,9 @@ def test_execution_timestamps_survive_completion_and_snapshot_replay(self) -> No assert block.current_execution.completed_at == completed_at def test_archived_notebook_is_unchanged_by_projection_responses(self) -> None: - self.deliver(self.event(1, "report_block_started", self.report_block())) + self.deliver(self.make_event(1, "report_block_started", self.report_block())) self.deliver( - self.event( + self.make_event( 2, "report_failed", { @@ -381,7 +385,7 @@ def test_archived_notebook_is_unchanged_by_projection_responses(self) -> None: def test_report_move_positions_match_the_final_seer_plan(self) -> None: for sequence, key in enumerate(["a", "b", "c", "d"], start=1): self.deliver( - self.event( + self.make_event( sequence, "report_block_upserted", self.report_block(key, position=sequence - 1) ) ) @@ -389,7 +393,7 @@ def test_report_move_positions_match_the_final_seer_plan(self) -> None: [("a", 2), ("b", 3), ("c", 1), ("d", 0)], start=5 ): self.deliver( - self.event( + self.make_event( sequence, "report_block_moved", { @@ -420,7 +424,7 @@ def test_scope_fallback_keeps_output_permissions_after_project_selection_changes } ) payload = self.report_block(projectIds=[], useInvestigationProjectScope=True) - self.deliver(self.event(1, "report_block_started", payload)) + self.deliver(self.make_event(1, "report_block_started", payload)) self.investigation.refresh_from_db() update_investigation( investigation=self.investigation, @@ -428,8 +432,8 @@ def test_scope_fallback_keeps_output_permissions_after_project_selection_changes fields={}, project_ids=[other_project.id], ) - completed = self.event(2, "report_block_upserted", payload) - replayed = self.event( + completed = self.make_event(2, "report_block_upserted", payload) + replayed = self.make_event( 3, "state_snapshot", { @@ -441,14 +445,14 @@ def test_scope_fallback_keeps_output_permissions_after_project_selection_changes "blocks": [payload], }, ) - preserved = self.event( + preserved = self.make_event( 4, "workflow_updated", { "projection": self.projection(workflow_version=3, report_revision=1), }, ) - recovered = self.event( + recovered = self.make_event( 5, "state_snapshot", { @@ -461,7 +465,7 @@ def test_scope_fallback_keeps_output_permissions_after_project_selection_changes "blocks": [{**payload, "reportRevision": 1}], }, ) - recovered_without_projection = self.event( + recovered_without_projection = self.make_event( 7, "state_snapshot", { @@ -474,20 +478,20 @@ def test_scope_fallback_keeps_output_permissions_after_project_selection_changes "blocks": [{**payload, "reportRevision": 2}], }, ) - preserved_for_replacement = self.event( + preserved_for_replacement = self.make_event( 8, "workflow_updated", {"projection": self.projection(workflow_version=6, report_revision=3)}, ) - replacement_started = self.event( + replacement_started = self.make_event( 9, "report_block_started", {**payload, "reportRevision": 3} ) - replacement_failed = self.event( + replacement_failed = self.make_event( 10, "report_failed", {"reportRevision": 3, "error": {"code": "failed", "message": "Replacement failed"}}, ) - recovered_after_failure = self.event( + recovered_after_failure = self.make_event( 11, "state_snapshot", { @@ -547,7 +551,7 @@ def test_identical_replacement_result_keeps_the_started_project_scope(self) -> N source={"type": "manual", "projectScope": {"type": "investigation"}} ) payload = self.report_block(projectIds=[], useInvestigationProjectScope=True) - self.deliver(self.event(1, "report_block_upserted", payload)) + self.deliver(self.make_event(1, "report_block_upserted", payload)) self.investigation.refresh_from_db() update_investigation( investigation=self.investigation, @@ -556,15 +560,15 @@ def test_identical_replacement_result_keeps_the_started_project_scope(self) -> N project_ids=[replacement_project.id], ) self.deliver( - self.event( + self.make_event( 2, "workflow_updated", {"projection": self.projection(workflow_version=2, report_revision=1)}, ) ) replacement = {**payload, "reportRevision": 1} - self.deliver(self.event(3, "report_block_started", replacement)) - self.deliver(self.event(4, "report_block_upserted", replacement)) + self.deliver(self.make_event(3, "report_block_started", replacement)) + self.deliver(self.make_event(4, "report_block_upserted", replacement)) block = NotebookBlock.objects.get(notebook=self.investigation, deleted_at__isnull=True) assert block.content_execution is not None assert block.content_execution.input_snapshot["projectIds"] == [replacement_project.id] @@ -583,7 +587,7 @@ def test_identical_replacement_result_keeps_the_started_project_scope(self) -> N def test_started_report_block_accepts_null_display(self) -> None: for sequence, kind in enumerate(["text", "query"], start=1): receipt = self.deliver( - self.event( + self.make_event( sequence, "report_block_started", self.report_block(kind, kind=kind, display=None), @@ -610,7 +614,7 @@ def test_invalid_started_report_display_does_not_block_later_events(self) -> Non invalid_displays = [[], ["table"], "table", 1, False] for sequence, display in enumerate(invalid_displays, start=1): receipt = self.deliver( - self.event( + self.make_event( sequence, "report_block_started", self.report_block(display=display), @@ -624,7 +628,7 @@ def test_invalid_started_report_display_does_not_block_later_events(self) -> Non ).exists() applied = self.deliver( - self.event(len(invalid_displays) + 1, "report_block_upserted", self.report_block()) + self.make_event(len(invalid_displays) + 1, "report_block_upserted", self.report_block()) ) assert applied.application_status == InvestigationOrchestrationEventStatus.APPLIED assert applied.last_applied_sequence == len(invalid_displays) + 1 @@ -647,7 +651,7 @@ def test_query_report_displays_use_the_editable_block_schema(self) -> None: } for sequence, display_type in enumerate(["table", "chart"], start=1): self.deliver( - self.event( + self.make_event( sequence, "report_block_upserted", self.report_block( @@ -678,7 +682,7 @@ def test_query_report_displays_use_the_editable_block_schema(self) -> None: assert block.display["yAxes"] == ["y"] def test_kind_replacement_preserves_history_and_recovers_from_snapshot(self) -> None: - self.deliver(self.event(1, "report_block_upserted", self.report_block())) + self.deliver(self.make_event(1, "report_block_upserted", self.report_block())) block = NotebookBlock.objects.get(notebook=self.investigation, producer_key="summary") assert block.current_execution is not None original_execution = block.current_execution @@ -690,26 +694,26 @@ def test_kind_replacement_preserves_history_and_recovers_from_snapshot(self) -> "isEmpty": False, } payload = self.report_block(kind="query", content="", result=result) - rejected = self.deliver(self.event(2, "report_block_started", payload)) + rejected = self.deliver(self.make_event(2, "report_block_started", payload)) assert rejected.application_status == InvestigationOrchestrationEventStatus.FAILED self.deliver( - self.event( + self.make_event( 3, "workflow_updated", {"projection": self.projection(workflow_version=2, report_revision=1)}, ) ) payload["reportRevision"] = 1 - started = self.deliver(self.event(4, "report_block_started", payload)) + started = self.deliver(self.make_event(4, "report_block_started", payload)) assert started.application_status == InvestigationOrchestrationEventStatus.APPLIED block.refresh_from_db() assert block.kind == "query" assert block.content == "" assert block.content_execution is None assert block.current_execution_id != original_execution.id - completed = self.deliver(self.event(5, "report_block_upserted", payload)) + completed = self.deliver(self.make_event(5, "report_block_upserted", payload)) recovered = self.deliver( - self.event( + self.make_event( 6, "state_snapshot", { @@ -747,7 +751,7 @@ def test_failed_kind_replacement_snapshot_restores_original_output(self) -> None source={"type": "manual", "projectScope": {"type": "investigation"}} ) original = self.report_block(projectIds=[], useInvestigationProjectScope=True) - self.deliver(self.event(1, "report_block_upserted", original)) + self.deliver(self.make_event(1, "report_block_upserted", original)) block = NotebookBlock.objects.get(notebook=self.investigation, producer_key="summary") original_execution = block.current_execution assert original_execution is not None @@ -759,14 +763,14 @@ def test_failed_kind_replacement_snapshot_restores_original_output(self) -> None project_ids=[replacement_project.id], ) self.deliver( - self.event( + self.make_event( 2, "workflow_updated", {"projection": self.projection(workflow_version=2, report_revision=1)}, ) ) self.deliver( - self.event( + self.make_event( 3, "report_block_started", {**original, "reportRevision": 1, "kind": "query"}, @@ -776,14 +780,14 @@ def test_failed_kind_replacement_snapshot_restores_original_output(self) -> None replacement_execution = block.current_execution assert replacement_execution is not None self.deliver( - self.event( + self.make_event( 4, "report_failed", {"reportRevision": 1, "error": {"code": "failed", "message": "Query failed"}}, ) ) restored = self.deliver( - self.event( + self.make_event( 6, "state_snapshot", { @@ -827,13 +831,13 @@ def test_failed_kind_replacement_snapshot_restores_original_output(self) -> None def test_out_of_order_events_deduplicate_and_ignore_delayed_responses(self) -> None: second_id = uuid4() - second = self.event( + second = self.make_event( 2, "workflow_updated", {"projection": self.projection(workflow_version=3, phase="investigating")}, event_id=second_id, ) - first = self.event( + first = self.make_event( 1, "workflow_updated", {"projection": self.projection(workflow_version=2, phase="planning")}, @@ -859,7 +863,7 @@ def test_out_of_order_events_deduplicate_and_ignore_delayed_responses(self) -> N == 2 ) - changed = self.event( + changed = self.make_event( 2, "workflow_updated", {"projection": self.projection(workflow_version=4)}, @@ -903,7 +907,7 @@ def test_workflow_update_schedules_investigation_updates_on_commit( ) -> None: with self.outer_transaction(): self.deliver( - self.event( + self.make_event( 1, "workflow_updated", {"projection": self.projection(workflow_version=2)} ) ) @@ -917,7 +921,7 @@ def test_rolled_back_workflow_update_does_not_schedule_investigation_updates( ) -> None: with pytest.raises(RuntimeError), self.outer_transaction(): self.deliver( - self.event( + self.make_event( 1, "workflow_updated", {"projection": self.projection(workflow_version=2)} ) ) @@ -929,12 +933,12 @@ def test_rolled_back_workflow_update_does_not_schedule_investigation_updates( def test_contiguous_events_schedule_investigation_updates_once(self, mock_delay: Mock) -> None: with self.outer_transaction(): self.deliver( - self.event( + self.make_event( 2, "workflow_updated", {"projection": self.projection(workflow_version=3)} ) ) self.deliver( - self.event( + self.make_event( 1, "workflow_updated", {"projection": self.projection(workflow_version=2)} ) ) @@ -950,7 +954,7 @@ def test_terminal_snapshot_fast_forward_schedules_investigation_updates( ) -> None: with self.outer_transaction(): receipt = self.deliver( - self.event( + self.make_event( 3, "state_snapshot", { @@ -974,7 +978,7 @@ def test_workflow_failure_with_projection_schedules_investigation_updates( ) -> None: with self.outer_transaction(): self.deliver( - self.event( + self.make_event( 1, "workflow_failed", { @@ -992,7 +996,7 @@ def test_workflow_failure_without_projection_does_not_schedule_investigation_upd self, mock_delay: Mock ) -> None: receipt = self.deliver( - self.event( + self.make_event( 1, "workflow_failed", {"error": {"code": "seer_failed", "message": "Seer gave up."}}, @@ -1011,7 +1015,7 @@ def test_stale_event_does_not_schedule_investigation_updates(self, mock_delay: M ) receipt = self.deliver( - self.event(1, "workflow_updated", {"projection": self.projection()}, generation=1) + self.make_event(1, "workflow_updated", {"projection": self.projection()}, generation=1) ) assert receipt.application_status == InvestigationOrchestrationEventStatus.IGNORED @@ -1019,9 +1023,9 @@ def test_stale_event_does_not_schedule_investigation_updates(self, mock_delay: M @patch("sentry.seer.entrypoints.operator.process_investigation_updates.delay") def test_notebook_event_does_not_schedule_investigation_updates(self, mock_delay: Mock) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) self.deliver( - self.event( + self.make_event( 2, "report_block_started", { @@ -1036,7 +1040,7 @@ def test_notebook_event_does_not_schedule_investigation_updates(self, mock_delay ) ) receipt = self.deliver( - self.event( + self.make_event( 3, "report_text_delta", {"reportRevision": 1, "stableAgentKey": "summary", "delta": "text"}, @@ -1084,6 +1088,7 @@ def test_synchronize_projection_rejects_invalid_seer_run_ids(self) -> None: ) self.orchestration_run.refresh_from_db() + assert self.orchestration_run.seer_run is not None assert self.orchestration_run.seer_run.seer_run_state_id == self.seer_run_id def test_reconcile_projection_can_authoritatively_replace_state(self) -> None: @@ -1140,16 +1145,16 @@ def test_manual_title_override_survives_generated_title_events(self) -> None: self.orchestration_run.projection = projection self.orchestration_run.save(update_fields=["projection", "date_updated"]) - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) self.deliver( - self.event( + self.make_event( 2, "title_delta", {"reportRevision": 1, "delta": "Generated title", "reset": True}, ) ) self.deliver( - self.event( + self.make_event( 3, "metadata_completed", { @@ -1234,13 +1239,15 @@ def test_archive_generation_fence_survives_restore_until_a_new_generation(self) assert block.deleted_at is None assert block.producer_revision == 1 - fenced = self.deliver(self.event(1, "report_clear", {"reportRevision": 1}, generation=1)) + fenced = self.deliver( + self.make_event(1, "report_clear", {"reportRevision": 1}, generation=1) + ) assert fenced.application_status == InvestigationOrchestrationEventStatus.IGNORED self.orchestration_run.refresh_from_db() assert self.orchestration_run.notebook_revision == 0 self.deliver( - self.event( + self.make_event( 2, "workflow_updated", { @@ -1252,7 +1259,9 @@ def test_archive_generation_fence_survives_restore_until_a_new_generation(self) generation=2, ) ) - accepted = self.deliver(self.event(3, "report_clear", {"reportRevision": 1}, generation=2)) + accepted = self.deliver( + self.make_event(3, "report_clear", {"reportRevision": 1}, generation=2) + ) assert accepted.application_status == InvestigationOrchestrationEventStatus.APPLIED self.orchestration_run.refresh_from_db() @@ -1272,8 +1281,8 @@ def test_text_and_title_stream_resets_replace_stale_content(self) -> None: manual.current_execution = manual_execution manual.save(update_fields=["current_execution", "date_updated"]) events = [ - self.event(1, "report_clear", {"reportRevision": 1}), - self.event( + self.make_event(1, "report_clear", {"reportRevision": 1}), + self.make_event( 2, "report_block_started", { @@ -1286,12 +1295,12 @@ def test_text_and_title_stream_resets_replace_stale_content(self) -> None: "projectIds": [self.project.id], }, ), - self.event( + self.make_event( 3, "report_text_delta", {"reportRevision": 1, "stableAgentKey": "summary", "delta": "stale"}, ), - self.event( + self.make_event( 4, "report_text_delta", { @@ -1301,13 +1310,13 @@ def test_text_and_title_stream_resets_replace_stale_content(self) -> None: "reset": True, }, ), - self.event(5, "title_delta", {"reportRevision": 1, "delta": "Stale"}), - self.event( + self.make_event(5, "title_delta", {"reportRevision": 1, "delta": "Stale"}), + self.make_event( 6, "title_delta", {"reportRevision": 1, "delta": "Fresh", "reset": True}, ), - self.event( + self.make_event( 7, "metadata_completed", { @@ -1362,9 +1371,9 @@ def test_text_and_title_stream_resets_replace_stale_content(self) -> None: assert self.orchestration_run.notebook_revision == 7 def test_query_upsert_persists_an_immutable_completed_snapshot(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) self.deliver( - self.event( + self.make_event( 2, "report_block_started", { @@ -1399,7 +1408,7 @@ def test_query_upsert_persists_an_immutable_completed_snapshot(self) -> None: "chartUnavailableReason": "A chart is not useful for one value.", } self.deliver( - self.event( + self.make_event( 3, "report_block_upserted", { @@ -1432,7 +1441,7 @@ def test_query_upsert_persists_an_immutable_completed_snapshot(self) -> None: ] self.deliver( - self.event( + self.make_event( 4, "workflow_updated", { @@ -1445,7 +1454,7 @@ def test_query_upsert_persists_an_immutable_completed_snapshot(self) -> None: ) ) self.deliver( - self.event( + self.make_event( 5, "report_block_started", { @@ -1476,9 +1485,9 @@ def test_replacement_text_is_published_with_its_execution_provenance(self) -> No notebook=self.investigation, project=replacement_project, ) - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) self.deliver( - self.event( + self.make_event( 2, "report_block_started", { @@ -1492,7 +1501,7 @@ def test_replacement_text_is_published_with_its_execution_provenance(self) -> No ) ) self.deliver( - self.event( + self.make_event( 3, "report_block_upserted", { @@ -1511,7 +1520,7 @@ def test_replacement_text_is_published_with_its_execution_provenance(self) -> No completed_execution_id = block.content_execution_id self.deliver( - self.event( + self.make_event( 4, "workflow_updated", { @@ -1524,7 +1533,7 @@ def test_replacement_text_is_published_with_its_execution_provenance(self) -> No ) ) self.deliver( - self.event( + self.make_event( 5, "report_block_started", { @@ -1538,7 +1547,7 @@ def test_replacement_text_is_published_with_its_execution_provenance(self) -> No ) ) self.deliver( - self.event( + self.make_event( 6, "report_text_delta", { @@ -1562,7 +1571,7 @@ def test_replacement_text_is_published_with_its_execution_provenance(self) -> No ] self.deliver( - self.event( + self.make_event( 7, "report_block_upserted", { @@ -1589,7 +1598,7 @@ def test_reconcile_projection_rejects_an_intervening_event(self) -> None: projection=self.projection(workflow_version=2), ) self.deliver( - self.event( + self.make_event( 1, "workflow_updated", {"projection": self.projection(workflow_version=2, phase="planning")}, @@ -1638,7 +1647,7 @@ def test_reconcile_projection_rejects_an_intervening_command(self) -> None: def test_workflow_failure_fails_the_run_without_a_projection(self) -> None: error = {"code": "seer_unavailable", "message": "Seer stopped responding."} - receipt = self.deliver(self.event(1, "workflow_failed", {"error": error})) + receipt = self.deliver(self.make_event(1, "workflow_failed", {"error": error})) assert receipt.application_status == InvestigationOrchestrationEventStatus.APPLIED self.orchestration_run.refresh_from_db() @@ -1649,14 +1658,15 @@ def test_workflow_failure_fails_the_run_without_a_projection(self) -> None: def test_applying_an_event_records_a_heartbeat(self) -> None: before = timezone.now() - self.deliver(self.event(1, "workflow_updated", {"projection": self.projection()})) + self.deliver(self.make_event(1, "workflow_updated", {"projection": self.projection()})) self.orchestration_run.refresh_from_db() + assert self.orchestration_run.heartbeat_at is not None assert self.orchestration_run.heartbeat_at >= before def test_workflow_failure_carrying_a_projection_may_advance_the_generation(self) -> None: receipt = self.deliver( - self.event( + self.make_event( 1, "workflow_failed", { @@ -1674,11 +1684,13 @@ def test_workflow_failure_carrying_a_projection_may_advance_the_generation(self) def test_workflow_failure_does_not_apply_a_stale_projection(self) -> None: self.deliver( - self.event(1, "workflow_updated", {"projection": self.projection(workflow_version=5)}) + self.make_event( + 1, "workflow_updated", {"projection": self.projection(workflow_version=5)} + ) ) self.deliver( - self.event( + self.make_event( 2, "workflow_failed", { @@ -1696,7 +1708,7 @@ def test_workflow_failure_does_not_apply_a_stale_projection(self) -> None: def test_stale_and_future_generations_are_consumed_without_mutation(self) -> None: self.deliver( - self.event( + self.make_event( 1, "workflow_updated", {"projection": self.projection(workflow_version=2, generation=2)}, @@ -1704,10 +1716,10 @@ def test_stale_and_future_generations_are_consumed_without_mutation(self) -> Non ) ) stale = self.deliver( - self.event(2, "workflow_updated", {"projection": self.projection()}, generation=1) + self.make_event(2, "workflow_updated", {"projection": self.projection()}, generation=1) ) future_without_projection = self.deliver( - self.event( + self.make_event( 3, "workflow_failed", {"error": {"code": "seer_failed", "message": "Seer gave up."}}, @@ -1729,9 +1741,9 @@ def test_stale_and_future_generations_are_consumed_without_mutation(self) -> Non assert self.orchestration_run.last_event_sequence == 3 def test_stale_report_revision_is_consumed_without_mutation(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) - stale = self.deliver(self.event(2, "report_completed", {"reportRevision": 0})) + stale = self.deliver(self.make_event(2, "report_completed", {"reportRevision": 0})) assert stale.application_status == InvestigationOrchestrationEventStatus.IGNORED self.orchestration_run.refresh_from_db() @@ -1746,7 +1758,7 @@ def test_terminal_full_snapshot_recovers_a_sequence_gap(self) -> None: content="stale report", ) waiting = self.deliver( - self.event( + self.make_event( 3, "state_snapshot", { @@ -1760,7 +1772,7 @@ def test_terminal_full_snapshot_recovers_a_sequence_gap(self) -> None: assert waiting.last_applied_sequence == 0 reconciled = self.deliver( - self.event( + self.make_event( 5, "state_snapshot", { @@ -1829,7 +1841,7 @@ def test_terminal_snapshots_preserve_authoritative_report_status(self) -> None: for sequence, (phase, status, report_status) in enumerate(terminal_states, start=1): with self.subTest(status=status): delivered = self.deliver( - self.event( + self.make_event( sequence, "state_snapshot", { @@ -1858,7 +1870,7 @@ def test_terminal_snapshots_preserve_authoritative_report_status(self) -> None: def test_failed_terminal_snapshot_is_consumed_after_the_gap_closes(self) -> None: foreign_project = self.create_project(organization=self.create_organization()) failed = self.deliver( - self.event( + self.make_event( 3, "state_snapshot", { @@ -1889,14 +1901,14 @@ def test_failed_terminal_snapshot_is_consumed_after_the_gap_closes(self) -> None assert not NotebookBlock.objects.filter(producer_key="invalid").exists() self.deliver( - self.event( + self.make_event( 1, "workflow_updated", {"projection": self.projection()}, ) ) closed = self.deliver( - self.event( + self.make_event( 2, "workflow_updated", {"projection": self.projection(workflow_version=2, phase="planning")}, @@ -1909,8 +1921,8 @@ def test_failed_terminal_snapshot_is_consumed_after_the_gap_closes(self) -> None def test_targeted_report_revision_preserves_blocks_until_explicit_clear(self) -> None: events = [ - self.event(1, "report_clear", {"reportRevision": 1}), - self.event( + self.make_event(1, "report_clear", {"reportRevision": 1}), + self.make_event( 2, "report_block_upserted", { @@ -1923,7 +1935,7 @@ def test_targeted_report_revision_preserves_blocks_until_explicit_clear(self) -> "projectIds": [self.project.id], }, ), - self.event( + self.make_event( 3, "report_block_upserted", { @@ -1936,7 +1948,7 @@ def test_targeted_report_revision_preserves_blocks_until_explicit_clear(self) -> "projectIds": [self.project.id], }, ), - self.event( + self.make_event( 4, "workflow_updated", { @@ -1947,22 +1959,22 @@ def test_targeted_report_revision_preserves_blocks_until_explicit_clear(self) -> ) }, ), - self.event( + self.make_event( 5, "report_block_moved", {"reportRevision": 2, "stableAgentKey": "first", "position": 1}, ), - self.event( + self.make_event( 6, "report_block_moved", {"reportRevision": 2, "stableAgentKey": "second", "position": 0}, ), - self.event( + self.make_event( 7, "report_block_removed", {"reportRevision": 2, "stableAgentKey": "second"}, ), - self.event( + self.make_event( 8, "report_block_upserted", { @@ -1996,7 +2008,7 @@ def test_targeted_report_revision_preserves_blocks_until_explicit_clear(self) -> assert second.deleted_at is not None self.deliver( - self.event( + self.make_event( 9, "workflow_updated", { @@ -2017,14 +2029,14 @@ def test_targeted_report_revision_preserves_blocks_until_explicit_clear(self) -> first.refresh_from_db() assert first.producer_revision == 2 assert first.deleted_at is None - self.deliver(self.event(10, "report_clear", {"reportRevision": 3})) + self.deliver(self.make_event(10, "report_clear", {"reportRevision": 3})) first.refresh_from_db() assert first.deleted_at is not None def test_targeted_report_revision_discards_in_flight_block(self) -> None: events = [ - self.event(1, "report_clear", {"reportRevision": 1}), - self.event( + self.make_event(1, "report_clear", {"reportRevision": 1}), + self.make_event( 2, "report_block_upserted", { @@ -2037,7 +2049,7 @@ def test_targeted_report_revision_discards_in_flight_block(self) -> None: "projectIds": [self.project.id], }, ), - self.event( + self.make_event( 3, "report_block_started", { @@ -2049,7 +2061,7 @@ def test_targeted_report_revision_discards_in_flight_block(self) -> None: "projectIds": [self.project.id], }, ), - self.event( + self.make_event( 4, "report_text_delta", { @@ -2058,7 +2070,7 @@ def test_targeted_report_revision_discards_in_flight_block(self) -> None: "delta": "unfinished", }, ), - self.event( + self.make_event( 5, "workflow_updated", { @@ -2088,8 +2100,8 @@ def test_targeted_report_revision_discards_in_flight_block(self) -> None: def test_report_failure_finishes_partial_text_and_query_executions(self) -> None: events = [ - self.event(1, "report_clear", {"reportRevision": 1}), - self.event( + self.make_event(1, "report_clear", {"reportRevision": 1}), + self.make_event( 2, "report_block_started", { @@ -2101,7 +2113,7 @@ def test_report_failure_finishes_partial_text_and_query_executions(self) -> None "projectIds": [self.project.id], }, ), - self.event( + self.make_event( 3, "report_text_delta", { @@ -2110,7 +2122,7 @@ def test_report_failure_finishes_partial_text_and_query_executions(self) -> None "delta": "Evidence gathered so far", }, ), - self.event( + self.make_event( 4, "report_block_started", { @@ -2122,7 +2134,7 @@ def test_report_failure_finishes_partial_text_and_query_executions(self) -> None "projectIds": [self.project.id], }, ), - self.event( + self.make_event( 5, "report_failed", { @@ -2153,7 +2165,7 @@ def test_report_failure_finishes_partial_text_and_query_executions(self) -> None def test_workflow_failure_finishes_partial_execution(self) -> None: self.deliver( - self.event( + self.make_event( 1, "report_block_started", { @@ -2167,7 +2179,7 @@ def test_workflow_failure_finishes_partial_execution(self) -> None: ) ) self.deliver( - self.event( + self.make_event( 2, "workflow_failed", { @@ -2190,7 +2202,7 @@ def test_workflow_failure_finishes_partial_execution(self) -> None: def test_workflow_cancellation_cancels_partial_execution(self) -> None: self.deliver( - self.event( + self.make_event( 1, "report_block_started", { @@ -2204,7 +2216,7 @@ def test_workflow_cancellation_cancels_partial_execution(self) -> None: ) ) self.deliver( - self.event( + self.make_event( 2, "workflow_updated", { @@ -2228,7 +2240,7 @@ def test_workflow_cancellation_cancels_partial_execution(self) -> None: def test_invalid_contiguous_event_does_not_block_later_events(self) -> None: foreign_project = self.create_project(organization=self.create_organization()) failed = self.deliver( - self.event( + self.make_event( 1, "report_block_started", { @@ -2246,7 +2258,7 @@ def test_invalid_contiguous_event_does_not_block_later_events(self) -> None: assert not NotebookBlockExecution.objects.filter(block__producer_key="invalid").exists() applied = self.deliver( - self.event( + self.make_event( 2, "workflow_updated", {"projection": self.projection(workflow_version=2, phase="planning")}, @@ -2268,7 +2280,7 @@ def test_manual_report_block_can_use_persisted_investigation_project_scope(self) self.orchestration_run.save(update_fields=["source", "date_updated"]) self.deliver( - self.event( + self.make_event( 1, "report_block_started", { @@ -2292,7 +2304,7 @@ def test_a_new_block_requires_a_position(self) -> None: # position is optional on the schema so an update can keep the stored one, # which leaves the requirement for a new block to the apply path. receipt = self.deliver( - self.event( + self.make_event( 1, "report_block_upserted", { @@ -2338,12 +2350,14 @@ def test_projection_evidence_must_use_investigation_projects(self) -> None: } ] - invalid_event = self.event(2, "workflow_updated", {"projection": projection}) + invalid_event = self.make_event(2, "workflow_updated", {"projection": projection}) waiting = self.deliver(invalid_event) assert waiting.application_status == InvestigationOrchestrationEventStatus.PENDING applied = self.deliver( - self.event(1, "workflow_updated", {"projection": self.projection(workflow_version=2)}) + self.make_event( + 1, "workflow_updated", {"projection": self.projection(workflow_version=2)} + ) ) assert applied.application_status == InvestigationOrchestrationEventStatus.APPLIED assert applied.last_applied_sequence == 2 @@ -2384,7 +2398,7 @@ def test_projection_accepts_evidence_from_an_investigation_project(self) -> None } ] - delivered = self.deliver(self.event(1, "workflow_updated", {"projection": projection})) + delivered = self.deliver(self.make_event(1, "workflow_updated", {"projection": projection})) assert delivered.application_status == InvestigationOrchestrationEventStatus.APPLIED self.orchestration_run.refresh_from_db() @@ -2436,7 +2450,7 @@ def test_verification_evidence_must_use_investigation_projects(self) -> None: } ] - delivered = self.deliver(self.event(1, "workflow_updated", {"projection": projection})) + delivered = self.deliver(self.make_event(1, "workflow_updated", {"projection": projection})) assert delivered.application_status == InvestigationOrchestrationEventStatus.FAILED self.orchestration_run.refresh_from_db() @@ -2463,16 +2477,16 @@ def deliver_request( self, sequence: int, item: Any, *, generation: int = 1 ) -> OrchestrationEventReceipt: return self.deliver( - self.event(sequence, "action_requested", {"request": item}, generation=generation) + self.make_event(sequence, "action_requested", {"request": item}, generation=generation) ) def deliver_withdrawn(self, sequence: int, key: str, reason: str) -> OrchestrationEventReceipt: return self.deliver( - self.event(sequence, "action_withdrawn", {"key": key, "reason": reason}) + self.make_event(sequence, "action_withdrawn", {"key": key, "reason": reason}) ) def test_action_requested_stores_the_request(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) self.investigation.refresh_from_db() self.orchestration_run.refresh_from_db() version = self.investigation.version @@ -2535,7 +2549,7 @@ def test_a_request_seer_cannot_describe_is_ignored(self) -> None: assert not NotebookActionRequest.objects.exists() def test_a_request_for_an_older_report_is_ignored(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 2})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 2})) receipt = self.deliver_request(2, self.action_request("late", reportRevision=1)) @@ -2545,7 +2559,7 @@ def test_a_request_for_an_older_report_is_ignored(self) -> None: assert not NotebookActionRequest.objects.exists() def test_requests_upsert_by_key(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) action = self.action_request("x")["action"] changed = {**action, "target": "b"} rows = {} @@ -2609,7 +2623,7 @@ def test_requests_upsert_by_key(self) -> None: assert reloaded["reconsidered"].reason is None def test_a_new_primary_supersedes_the_last_one(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) self.deliver_request(2, self.action_request("first", primary=True)) self.deliver_request(3, self.action_request("side")) self.create_notebook_action_request( @@ -2630,7 +2644,7 @@ def test_a_new_primary_supersedes_the_last_one(self) -> None: } def test_a_new_primary_keeps_an_anchored_old_primary_as_a_request(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) anchor = {"type": "hypothesis", "id": "hyp-1"} self.deliver_request( 2, @@ -2656,14 +2670,14 @@ def test_a_new_primary_keeps_an_anchored_old_primary_as_a_request(self) -> None: def test_an_anchored_primary_resent_for_the_next_report_stays_live(self) -> None: anchor = {"type": "hypothesis", "id": "hyp-1"} connect = fake_action("gcp") - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) self.deliver_request( 2, self.action_request( "connect-telemetry-gcp-r1", primary=True, anchor=anchor, action=connect ), ) - self.deliver(self.event(3, "report_clear", {"reportRevision": 2})) + self.deliver(self.make_event(3, "report_clear", {"reportRevision": 2})) self.deliver_request( 4, @@ -2687,7 +2701,7 @@ def test_an_anchored_primary_resent_for_the_next_report_stays_live(self) -> None } def test_a_primary_on_a_terminal_key_still_replaces_the_last_primary(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) self.deliver_request(2, self.action_request("first", primary=True)) self.create_notebook_action_request( notebook=self.investigation, @@ -2754,7 +2768,7 @@ def test_action_requests_are_fenced_on_archived_investigations(self) -> None: def test_action_requests_are_fenced_by_generation(self) -> None: self.deliver( - self.event( + self.make_event( 1, "workflow_updated", {"projection": self.projection(workflow_version=2, generation=2)}, @@ -2776,7 +2790,7 @@ def test_action_requests_are_fenced_by_generation(self) -> None: assert not NotebookActionRequest.objects.exists() def completed_snapshot(self, sequence: int, **payload: Any) -> dict[str, Any]: - return self.event( + return self.make_event( sequence, "state_snapshot", { @@ -2881,9 +2895,9 @@ def test_a_snapshot_only_tells_the_kind_about_the_primary_that_wins(self) -> Non assert [request.key for request in kind.upserted] == ["winner"] def test_removing_a_block_supersedes_the_requests_under_it(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) self.deliver( - self.event( + self.make_event( 2, "report_block_upserted", { @@ -2905,7 +2919,9 @@ def test_removing_a_block_supersedes_the_requests_under_it(self) -> None: ) self.deliver( - self.event(5, "report_block_removed", {"reportRevision": 1, "stableAgentKey": "chart"}) + self.make_event( + 5, "report_block_removed", {"reportRevision": 1, "stableAgentKey": "chart"} + ) ) assert self.request_states() == { @@ -2914,7 +2930,7 @@ def test_removing_a_block_supersedes_the_requests_under_it(self) -> None: } def test_an_older_snapshot_with_blocks_leaves_current_requests_alone(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 2})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 2})) self.deliver_request(2, self.action_request("current", reportRevision=2, primary=True)) self.deliver(self.completed_snapshot(3, reportRevision=1, blocks=[], actionRequests=[])) @@ -2925,7 +2941,7 @@ def test_snapshot_without_action_requests_leaves_them_alone(self) -> None: self.create_notebook_action_request(notebook=self.investigation, key="current") self.deliver( - self.event( + self.make_event( 1, "state_snapshot", { @@ -2942,11 +2958,11 @@ def test_snapshot_without_action_requests_leaves_them_alone(self) -> None: assert self.request_states() == {"current": NotebookActionRequestState.SUGGESTED} def test_an_adopted_report_revision_keeps_earlier_requests(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) self.deliver_request(2, self.action_request("widen-window", primary=True)) self.deliver( - self.event( + self.make_event( 3, "workflow_updated", { @@ -2961,27 +2977,27 @@ def test_an_adopted_report_revision_keeps_earlier_requests(self) -> None: def test_a_recheck_leaves_the_notebook_untouched(self) -> None: events = [ - self.event(1, "report_clear", {"reportRevision": 1}), + self.make_event(1, "report_clear", {"reportRevision": 1}), *( - self.event( + self.make_event( sequence, "report_block_upserted", self.report_block(key, reportRevision=1, position=sequence - 2), ) for sequence, key in [(2, "cause"), (3, "remediation"), (4, "evidence")] ), - self.event(5, "report_completed", {"reportRevision": 1}), - self.event( + self.make_event(5, "report_completed", {"reportRevision": 1}), + self.make_event( 6, "metadata_completed", {"reportRevision": 1, "summary": "Seer summary", "summaryDescription": "Body"}, ), - self.event( + self.make_event( 7, "action_requested", {"request": self.action_request("widen-window", primary=True)}, ), - self.event( + self.make_event( 8, "workflow_updated", { @@ -3032,10 +3048,10 @@ def test_a_recheck_leaves_the_notebook_untouched(self) -> None: report_status="completed", ) for event in [ - self.event(9, "workflow_updated", {"projection": reopened}), - self.event(10, "action_requested", {"request": refreshed}), - self.event(11, "workflow_updated", {"projection": completed}), - self.event( + self.make_event(9, "workflow_updated", {"projection": reopened}), + self.make_event(10, "action_requested", {"request": refreshed}), + self.make_event(11, "workflow_updated", {"projection": completed}), + self.make_event( 12, "state_snapshot", { @@ -3062,7 +3078,7 @@ def test_a_recheck_leaves_the_notebook_untouched(self) -> None: assert row.action_payload == refreshed["action"] def test_a_requests_only_snapshot_for_another_revision_is_ignored(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) self.deliver( self.completed_snapshot( @@ -3083,7 +3099,7 @@ def test_report_clear_supersedes_requests_tied_to_the_old_report(self) -> None: for key, values in requests: self.create_notebook_action_request(notebook=self.investigation, key=key, **values) - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) assert self.request_states() == { "primary": NotebookActionRequestState.SUPERSEDED, @@ -3121,7 +3137,7 @@ def test_seer_can_withdraw_a_stale_request(self) -> None: assert self.request_states() == {"widen-window": NotebookActionRequestState.WITHDRAWN} def test_a_newer_request_under_the_same_anchor_replaces_a_stale_one(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) anchor = {"type": "hypothesis", "id": "h-1"} self.stale("old", anchor_type="hypothesis", anchor_id="h-1") self.stale("elsewhere", anchor_type="hypothesis", anchor_id="h-2") @@ -3131,7 +3147,7 @@ def test_a_newer_request_under_the_same_anchor_replaces_a_stale_one(self) -> Non assert self.stale_keys() == {"elsewhere"} def test_a_newer_primary_replaces_a_stale_primary(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) self.stale("old", primary=True, anchor_type="hypothesis", anchor_id="h-1") self.deliver_request(2, self.action_request("new", primary=True)) @@ -3139,7 +3155,7 @@ def test_a_newer_primary_replaces_a_stale_primary(self) -> None: assert self.stale_keys() == set() def test_a_newer_primary_replaces_a_stale_primary_seer_demoted_first(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) anchor = {"type": "hypothesis", "id": "h-1"} self.stale("old", primary=True, anchor_type="hypothesis", anchor_id="h-1") @@ -3151,7 +3167,7 @@ def test_a_newer_primary_replaces_a_stale_primary_seer_demoted_first(self) -> No def test_seer_sending_a_dropped_stale_request_again_keeps_it_superseded(self) -> None: self.stale("old", anchor_type="hypothesis", anchor_id="h-1") - self.deliver(self.event(1, "report_clear", {"reportRevision": 2})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 2})) self.deliver_request( 2, @@ -3164,7 +3180,7 @@ def test_seer_sending_a_dropped_stale_request_again_keeps_it_superseded(self) -> assert self.stale_keys() == set() def test_seer_sending_a_stale_request_again_keeps_it_stale(self) -> None: - self.deliver(self.event(1, "report_clear", {"reportRevision": 1})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 1})) self.stale("old", primary=True, anchor_type="hypothesis", anchor_id="h-1") self.deliver_request( @@ -3188,7 +3204,7 @@ def test_a_new_report_stops_listing_every_stale_request(self) -> None: self.stale("hypothesis", anchor_type="hypothesis", anchor_id="h-1") self.stale("unanchored") - self.deliver(self.event(1, "report_clear", {"reportRevision": 2})) + self.deliver(self.make_event(1, "report_clear", {"reportRevision": 2})) assert self.stale_keys() == set() assert set(self.request_states().values()) == {NotebookActionRequestState.SUPERSEDED} @@ -3207,7 +3223,7 @@ def orchestration_run(self) -> InvestigationOrchestrationRun: ) return run - def event(self, run: InvestigationOrchestrationRun, **overrides: Any) -> dict[str, Any]: + def make_event(self, run: InvestigationOrchestrationRun, **overrides: Any) -> dict[str, Any]: run_id = overrides.pop("run_id", 8128) event: dict[str, Any] = { "schema_version": 1, @@ -3247,7 +3263,9 @@ def test_adopts_the_run_id_from_the_first_event(self) -> None: run = self.orchestration_run() assert run.seer_run is None - deliver_orchestration_event(organization_id=self.organization.id, event=self.event(run)) + deliver_orchestration_event( + organization_id=self.organization.id, event=self.make_event(run) + ) adopted = InvestigationOrchestrationRun.objects.get(id=run.id) assert adopted.seer_run is not None @@ -3266,7 +3284,9 @@ def test_reuses_an_existing_mirror_for_the_same_seer_run(self) -> None: seer_run_state_id=8128, ) - deliver_orchestration_event(organization_id=self.organization.id, event=self.event(run)) + deliver_orchestration_event( + organization_id=self.organization.id, event=self.make_event(run) + ) run.refresh_from_db() assert run.seer_run_id == existing.id @@ -3284,5 +3304,5 @@ def test_rejects_an_event_whose_run_id_contradicts_the_link(self) -> None: with pytest.raises(serializers.ValidationError): deliver_orchestration_event( - organization_id=self.organization.id, event=self.event(run, run_id=9999) + organization_id=self.organization.id, event=self.make_event(run, run_id=9999) ) diff --git a/tests/sentry/investigations/services/test_queued_commands.py b/tests/sentry/investigations/services/test_queued_commands.py index bd7f1608e10c..c7389e59ce22 100644 --- a/tests/sentry/investigations/services/test_queued_commands.py +++ b/tests/sentry/investigations/services/test_queued_commands.py @@ -1,7 +1,7 @@ from __future__ import annotations from contextlib import AbstractContextManager, nullcontext -from typing import Any +from typing import Any, cast from unittest import mock from uuid import uuid4 @@ -534,7 +534,8 @@ def respond(pool: object, path: str, *, body: bytes, **kwargs: object) -> mock.M request_id=json.loads(body)["requestId"] ) self.seer_sends_rerun_updated(command, name) - return answer(pool, path, body=body, **kwargs) + # justification: the fake Seer transport answers with a mock response. + return cast("mock.Mock", answer(pool, path, body=body, **kwargs)) seer.side_effect = respond diff --git a/tests/sentry/investigations/services/test_update_detector_changes.py b/tests/sentry/investigations/services/test_update_detector_changes.py index 672fad5aada9..d5dafe3f95ee 100644 --- a/tests/sentry/investigations/services/test_update_detector_changes.py +++ b/tests/sentry/investigations/services/test_update_detector_changes.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import Mapping from datetime import timedelta -from typing import Any +from typing import Any, cast from sentry.incidents.grouptype import MetricIssue from sentry.incidents.metric_issue_detector import fetch_snuba_query @@ -67,7 +68,8 @@ def create_metric_detector( self.create_data_condition( condition_group=group, type=condition_type, - comparison=500.0, + # justification: DataCondition.comparison is a JSON field where floats are valid. + comparison=cast("str | int | Mapping[str, object]", 500.0), condition_result=result, ) detector = self.create_detector( diff --git a/tests/sentry/investigations/test_agent.py b/tests/sentry/investigations/test_agent.py index c7deefd1f579..f5f6c0c12072 100644 --- a/tests/sentry/investigations/test_agent.py +++ b/tests/sentry/investigations/test_agent.py @@ -187,6 +187,7 @@ def test_completed_query_keeps_result_and_source_projects(self) -> None: self.execution.refresh_from_db() self.block.refresh_from_db() assert self.execution.status == NotebookBlockExecutionStatus.COMPLETED + assert self.execution.result is not None assert self.execution.result["tableMarkdown"].startswith("| Errors |") assert self.execution.result["queryLinks"][0]["kind"] == "telemetry" params = self.execution.result["queryLinks"][0]["params"] @@ -260,6 +261,7 @@ def test_completed_query_keeps_reused_result_projects(self) -> None: self.execution.refresh_from_db() assert self.execution.status == NotebookBlockExecutionStatus.COMPLETED assert list(self.execution.data_projects.all()) == [self.project] + assert self.execution.result is not None assert self.execution.result["queryLinks"] == original_links def test_start_run_requests_a_final_response_without_an_artifact_writer(self) -> None: @@ -449,6 +451,7 @@ def test_completed_query_accepts_strict_json_final_message(self) -> None: self.execution.refresh_from_db() assert self.execution.status == NotebookBlockExecutionStatus.COMPLETED + assert self.execution.result is not None assert self.execution.result["tableMarkdown"].startswith("| Errors |") @patch("sentry.investigations.agent.record_execution_completed") @@ -780,6 +783,7 @@ def test_unsupported_execute_is_redacted_and_stopped(self, interrupt_run: MagicM synchronize_execution(self.execution, run_state) self.execution.refresh_from_db() assert self.execution.status == NotebookBlockExecutionStatus.FAILED + assert self.execution.error is not None assert self.execution.error["code"] == "unsupported_tool_use" interrupt_run.assert_called_once() @@ -812,6 +816,7 @@ def test_unsupported_execute_stays_failed_when_interrupt_fails( self.execution.refresh_from_db() assert self.execution.status == NotebookBlockExecutionStatus.FAILED + assert self.execution.error is not None assert self.execution.error["code"] == "unsupported_tool_use" interrupt_run.assert_called_once_with(self.organization, 42) @@ -1181,6 +1186,7 @@ def test_query_links_drop_nested_and_oversized_params(self) -> None: self.execution.refresh_from_db() assert self.execution.status == NotebookBlockExecutionStatus.COMPLETED + assert self.execution.result is not None link = self.execution.result["queryLinks"][0] assert set(link) == {"kind", "params"} assert set(link["params"]) == {"dataset", "query", "project_slugs"} diff --git a/tests/sentry/investigations/test_contracts.py b/tests/sentry/investigations/test_contracts.py index bb1f71cf72cf..6aaddc012d0d 100644 --- a/tests/sentry/investigations/test_contracts.py +++ b/tests/sentry/investigations/test_contracts.py @@ -615,7 +615,8 @@ def action_request(**overrides: Any) -> dict[str, Any]: def requested(item: Any) -> dict[str, Any] | None: - return validated(ActionRequestedPayloadSerializer, {"request": item})["request"] + request = validated(ActionRequestedPayloadSerializer, {"request": item})["request"] + return request if isinstance(request, dict) else None def test_action_requested_accepts_the_wire_shape() -> None: diff --git a/tests/sentry/issue_detection/test_http_overhead_detector.py b/tests/sentry/issue_detection/test_http_overhead_detector.py index f1b341253dd6..39ee63d9d2f5 100644 --- a/tests/sentry/issue_detection/test_http_overhead_detector.py +++ b/tests/sentry/issue_detection/test_http_overhead_detector.py @@ -12,6 +12,7 @@ ) from sentry.issue_detection.performance_problem import PerformanceProblem from sentry.issues.grouptype import PerformanceHTTPOverheadGroupType +from sentry.spans.grouping.strategy.base import Span from sentry.testutils.cases import TestCase from sentry.testutils.issue_detection.event_generators import ( PROJECT_ID, @@ -26,7 +27,7 @@ def overhead_span( url: str, span_start: float = 1.0, span_id: str = "b" * 16, -) -> dict[str, Any]: +) -> Span: span = create_span( "http.client", desc=url, @@ -184,6 +185,7 @@ def test_detect_non_http_1_1(self) -> None: assert len(self.find_problems(event)) == 1 + assert trigger_span["data"] is not None trigger_span["data"]["network.protocol.version"] = "h3" assert len(self.find_problems(event)) == 0 diff --git a/tests/sentry/issue_detection/test_n_plus_one_api_calls_detector.py b/tests/sentry/issue_detection/test_n_plus_one_api_calls_detector.py index 27cd8c6f6e24..e17b516c86b7 100644 --- a/tests/sentry/issue_detection/test_n_plus_one_api_calls_detector.py +++ b/tests/sentry/issue_detection/test_n_plus_one_api_calls_detector.py @@ -14,8 +14,8 @@ run_detector_on_data, ) from sentry.issue_detection.performance_problem import PerformanceProblem -from sentry.issue_detection.types import Span from sentry.issues.grouptype import PerformanceNPlusOneAPICallsGroupType +from sentry.spans.grouping.strategy.base import Span from sentry.testutils.cases import TestCase from sentry.testutils.issue_detection.event_generators import create_event, create_span, get_event diff --git a/tests/sentry/issue_detection/test_performance_detection.py b/tests/sentry/issue_detection/test_performance_detection.py index 8301c135dbab..ea7501d56267 100644 --- a/tests/sentry/issue_detection/test_performance_detection.py +++ b/tests/sentry/issue_detection/test_performance_detection.py @@ -855,7 +855,7 @@ def test_sync_skips_nonexistent_detector(self) -> None: "slow_db_query_duration_threshold": 3000, } - updated = sync_project_options_to_wfe_detectors(self.project, settings_data) + updated = sync_project_options_to_wfe_detectors(self.project.id, settings_data) assert DetectorType.SLOW_DB_QUERY not in updated assert not Detector.objects.filter( @@ -876,7 +876,7 @@ def test_sync_updates_existing_detector(self) -> None: "slow_db_query_duration_threshold": 5000, } - updated = sync_project_options_to_wfe_detectors(self.project, settings_data) + updated = sync_project_options_to_wfe_detectors(self.project.id, settings_data) assert DetectorType.SLOW_DB_QUERY in updated @@ -898,7 +898,7 @@ def test_sync_skips_update_when_config_already_matches(self) -> None: "slow_db_query_duration_threshold": 5000, } - updated = sync_project_options_to_wfe_detectors(self.project, settings_data) + updated = sync_project_options_to_wfe_detectors(self.project.id, settings_data) # Should not be marked as updated since merged config is identical assert DetectorType.SLOW_DB_QUERY not in updated @@ -917,7 +917,7 @@ def test_sync_skips_hardcoded_fields(self) -> None: "slow_db_query_duration_threshold": 2000, } - sync_project_options_to_wfe_detectors(self.project, settings_data) + sync_project_options_to_wfe_detectors(self.project.id, settings_data) detector.refresh_from_db() assert "allowed_span_ops" not in detector.config @@ -939,7 +939,7 @@ def test_reset_clears_enabled_detector_config(self) -> None: config={"duration_threshold": 5000}, ) - reset_wfe_detector_configs(self.project) + reset_wfe_detector_configs(self.project.id) detector.refresh_from_db() assert detector.config == {} @@ -954,14 +954,14 @@ def test_reset_preserves_disabled_detector_config(self) -> None: config={"duration_threshold": 5000}, ) - reset_wfe_detector_configs(self.project) + reset_wfe_detector_configs(self.project.id) detector.refresh_from_db() assert detector.config["duration_threshold"] == 5000 assert detector.enabled is False def test_reset_skips_nonexistent_detectors(self) -> None: - updated = reset_wfe_detector_configs(self.project) + updated = reset_wfe_detector_configs(self.project.id) assert len(updated) == 0 assert ( diff --git a/tests/sentry/issue_detection/test_uncompressed_asset_detector.py b/tests/sentry/issue_detection/test_uncompressed_asset_detector.py index 89a886065696..f23304974149 100644 --- a/tests/sentry/issue_detection/test_uncompressed_asset_detector.py +++ b/tests/sentry/issue_detection/test_uncompressed_asset_detector.py @@ -16,9 +16,9 @@ from sentry.issues.grouptype import PerformanceUncompressedAssetsGroupType from sentry.issues.issue_occurrence import IssueEvidence from sentry.models.options.project_option import ProjectOption +from sentry.spans.grouping.strategy.base import Span from sentry.testutils.cases import TestCase from sentry.testutils.issue_detection.event_generators import PROJECT_ID, create_span, get_event -from sentry.testutils.issue_detection.span_builder import SpanBuilder def create_asset_span( @@ -26,11 +26,11 @@ def create_asset_span( desc: str = "https://s1.sentry-cdn.com/_static/dist/sentry/entrypoints/app.js", duration: float = 1000.0, data: dict[str, Any] | None = None, -) -> SpanBuilder: +) -> Span: return create_span("resource.script", desc=desc, duration=duration, data=data) -def create_compressed_asset_span() -> SpanBuilder: +def create_compressed_asset_span() -> Span: return create_asset_span( desc="https://someothersite.example.com/app.js", duration=1.0, diff --git a/tests/sentry/issue_detection/test_utils.py b/tests/sentry/issue_detection/test_utils.py index 19656560067b..bab40eac33a9 100644 --- a/tests/sentry/issue_detection/test_utils.py +++ b/tests/sentry/issue_detection/test_utils.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Generator -from typing import Any, NamedTuple +from typing import Any, NamedTuple, cast from unittest.mock import MagicMock, patch import pytest @@ -12,6 +12,7 @@ safer_urlparse, span_has_obfuscated_hostname, ) +from sentry.issue_detection.types import Span as IssueSpan from sentry.testutils.issue_detection.event_generators import create_span @@ -148,7 +149,7 @@ class TestDetectorUtils: @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.url) def test_span_has_obfuscated_hostname(self, test_case: URLTestCase) -> None: span = create_span("http.client", 320415204, f"GET {test_case.url}") - assert span_has_obfuscated_hostname(span) == test_case.is_obfuscated + assert span_has_obfuscated_hostname(cast(IssueSpan, span)) == test_case.is_obfuscated @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.url) def test_safer_urlparse(self, test_case: URLTestCase) -> None: @@ -222,10 +223,10 @@ def test_value_found( default = 0 if number_type is int else 0.0 result_without_default = get_numeric_value_from_span( - span, keys, "dog_detector", number_type + cast(IssueSpan, span), keys, "dog_detector", number_type ) result_with_default = get_numeric_value_from_span( - span, keys, "dog_detector", number_type, default=default + cast(IssueSpan, span), keys, "dog_detector", number_type, default=default ) if expected_result is Exception: @@ -257,8 +258,13 @@ def test_value_not_found( keys = ["dogs_are_great"] # Returns the default value (if given) or `None` (if not) - assert get_numeric_value_from_span(span, keys, "dog_detector", int) is None - assert get_numeric_value_from_span(span, keys, "dog_detector", int, default=1231) == 1231 + assert get_numeric_value_from_span(cast(IssueSpan, span), keys, "dog_detector", int) is None + assert ( + get_numeric_value_from_span( + cast(IssueSpan, span), keys, "dog_detector", int, default=1231 + ) + == 1231 + ) # Either way, missing data isn't invalid data, so there's nothing to log assert mock_log_invalid_data.call_count == 0 @@ -266,12 +272,12 @@ def test_uses_first_key_with_a_value(self, mock_log_invalid_data: MagicMock) -> span = create_span("do.dog.stuff", data={"adopt_dont_shop": 1121, "dogs_are_great": 1231}) keys = ["dogs_are_great", "adopt_dont_shop"] - assert get_numeric_value_from_span(span, keys, "dog_detector", int) == 1231 + assert get_numeric_value_from_span(cast(IssueSpan, span), keys, "dog_detector", int) == 1231 assert mock_log_invalid_data.call_count == 0 def test_falls_back_to_later_keys(self, mock_log_invalid_data: MagicMock) -> None: span = create_span("do.dog.stuff", data={"adopt_dont_shop": 1121}) keys = ["dogs_are_great", "adopt_dont_shop"] - assert get_numeric_value_from_span(span, keys, "dog_detector", int) == 1121 + assert get_numeric_value_from_span(cast(IssueSpan, span), keys, "dog_detector", int) == 1121 assert mock_log_invalid_data.call_count == 0 diff --git a/tests/sentry/issues/auto_source_code_config/test_process_event.py b/tests/sentry/issues/auto_source_code_config/test_process_event.py index cfcfdba3563c..4114bb4e3bc8 100644 --- a/tests/sentry/issues/auto_source_code_config/test_process_event.py +++ b/tests/sentry/issues/auto_source_code_config/test_process_event.py @@ -75,7 +75,7 @@ def setUp(self) -> None: self.integration = self.create_integration( organization=self.organization, provider=self.provider, - external_id=self.organization.id, + external_id=str(self.organization.id), metadata={"domain_name": f"{self.domain_name}/test-org"}, ) @@ -275,24 +275,26 @@ class TestTaskBehavior(BaseDeriveCodeMappings): def setUp(self) -> None: super().setUp() # The platform and event are not relevant for these tests - self.event = self.create_event([self.frame("foo/bar.baz", True)], "python") + self.group_event = self.create_event([self.frame("foo/bar.baz", True)], "python") + assert self.group_event.group.id is not None + self.group_id = self.group_event.group.id def test_api_errors_halts(self, mock_record: Any) -> None: error = ApiError('{"message":"Not Found"}') with patch(f"{REPO_TREES_INTEGRATION}.get_trees_for_org", side_effect=error): - process_event(self.project.id, self.event.group_id, self.event.event_id) + process_event(self.project.id, self.group_id, self.group_event.event_id) assert_halt_metric(mock_record, error) def test_unable_to_get_lock_halts(self, mock_record: Any) -> None: error = UnableToAcquireLock() with patch(f"{REPO_TREES_INTEGRATION}.get_trees_for_org", side_effect=error): - process_event(self.project.id, self.event.group_id, self.event.event_id) + process_event(self.project.id, self.group_id, self.group_event.event_id) assert not RepositoryProjectPathConfig.objects.exists() assert_halt_metric(mock_record, error) def test_generic_errors_fail(self, mock_record: Any) -> None: with patch(f"{REPO_TREES_INTEGRATION}.get_trees_for_org", side_effect=Exception("foo")): - process_event(self.project.id, self.event.group_id, self.event.event_id) + process_event(self.project.id, self.group_id, self.group_event.event_id) # Failures require manual investigation. assert_failure_metric(mock_record, DeriveCodeMappingsErrorReason.UNEXPECTED_ERROR) @@ -301,14 +303,14 @@ def test_installation_not_found(self, mock_record: Any) -> None: f"{CODE_ROOT}.task.get_installation", side_effect=InstallationNotFoundError("foo"), ): - process_event(self.project.id, self.event.group_id, self.event.event_id) + process_event(self.project.id, self.group_id, self.group_event.event_id) def test_project_does_not_exist(self, mock_record: Any) -> None: # If the project is deleted after the task is enqueued, process_event # should return early without raising. non_existent_project_id = 0 code_mappings, in_app_rules = process_event( - non_existent_project_id, self.event.group_id, self.event.event_id + non_existent_project_id, self.group_id, self.group_event.event_id ) assert code_mappings == [] assert in_app_rules == [] @@ -433,7 +435,9 @@ def test_extension_is_not_included(self) -> None: frame_filename = "foo/bar.tbd" file_in_repo = "src/foo/bar.tbd" platform = "other" - self.event = self.create_event([{"filename": frame_filename, "in_app": True}], platform) + self.group_event = self.create_event( + [{"filename": frame_filename, "in_app": True}], platform + ) with ( patch(f"{CODE_ROOT}.utils.platform.get_platform_config", return_value={}), @@ -450,7 +454,9 @@ def test_extension_is_included(self) -> None: frame_filename = "foo/bar.tbd" file_in_repo = "src/foo/bar.tbd" platform = "other" - self.event = self.create_event([{"filename": frame_filename, "in_app": True}], platform) + self.group_event = self.create_event( + [{"filename": frame_filename, "in_app": True}], platform + ) with ( patch(f"{CODE_ROOT}.utils.platform.get_platform_config", return_value={}), diff --git a/tests/sentry/issues/endpoints/test_event_owners.py b/tests/sentry/issues/endpoints/test_event_owners.py index 0839aa8b9327..d7d2f51ea11e 100644 --- a/tests/sentry/issues/endpoints/test_event_owners.py +++ b/tests/sentry/issues/endpoints/test_event_owners.py @@ -193,14 +193,13 @@ def test_multiple_owners_order_matters(self) -> None: assert len(resp.data["rules"]) == 3 def test_owners_of_different_types_ordered_correctly(self) -> None: - owners = [self.user, self.team3, self.user2, self.team2, self.user3, self.team] rules = [ - Rule(Matcher("path", "*.py"), [Owner("user", owners[0].email)]), - Rule(Matcher("path", "*py"), [Owner("team", owners[1].slug)]), - Rule(Matcher("path", "*foo*"), [Owner("user", owners[2].email)]), - Rule(Matcher("path", "*y"), [Owner("team", owners[3].slug)]), - Rule(Matcher("path", "*"), [Owner("user", owners[4].email)]), - Rule(Matcher("path", "*o.py"), [Owner("team", owners[5].slug)]), + Rule(Matcher("path", "*.py"), [Owner("user", self.user.email)]), + Rule(Matcher("path", "*py"), [Owner("team", self.team3.slug)]), + Rule(Matcher("path", "*foo*"), [Owner("user", self.user2.email)]), + Rule(Matcher("path", "*y"), [Owner("team", self.team2.slug)]), + Rule(Matcher("path", "*"), [Owner("user", self.user3.email)]), + Rule(Matcher("path", "*o.py"), [Owner("team", self.team.slug)]), ] ProjectOwnership.objects.create( @@ -223,7 +222,14 @@ def test_owners_of_different_types_ordered_correctly(self) -> None: resp = self.client.get(self.path) assert resp.status_code == 200 assert len(resp.data["owners"]) == 6 - assert [o["id"] for o in resp.data["owners"]] == [str(o.id) for o in owners] + assert [o["id"] for o in resp.data["owners"]] == [ + str(self.user.id), + str(self.team3.id), + str(self.user2.id), + str(self.team2.id), + str(self.user3.id), + str(self.team.id), + ] assert [o["type"] for o in resp.data["owners"]] == ["user", "team"] * 3 assert resp.data["rule"] == Matcher("path", "*.py") assert len(resp.data["rules"]) == 6 diff --git a/tests/sentry/issues/endpoints/test_group.py b/tests/sentry/issues/endpoints/test_group.py index c7c33bf13e62..5d44be7ac0bf 100644 --- a/tests/sentry/issues/endpoints/test_group.py +++ b/tests/sentry/issues/endpoints/test_group.py @@ -6,6 +6,7 @@ from sentry.auth.access import from_request from sentry.issues.endpoints.bases.group import GroupAiEndpoint, GroupAiPermission +from sentry.models.apikey import ApiKey from sentry.models.apitoken import ApiToken from sentry.models.group import Group from sentry.testutils.cases import TestCase @@ -59,7 +60,7 @@ def has_object_perm( self, method: str, obj: Group, - auth: ApiToken | None = None, + auth: ApiToken | ApiKey | None = None, user: User | None = None, is_superuser: bool | None = None, ) -> bool: diff --git a/tests/sentry/issues/endpoints/test_group_details.py b/tests/sentry/issues/endpoints/test_group_details.py index dad1aaf8aff2..8f1a90a6c40e 100644 --- a/tests/sentry/issues/endpoints/test_group_details.py +++ b/tests/sentry/issues/endpoints/test_group_details.py @@ -107,8 +107,10 @@ def test_with_first_release(self) -> None: self.login_as(user=self.user) event = self.store_event(data={"release": "1.0"}, project_id=self.project.id) + assert event.group is not None group = event.group + assert group is not None url = f"/api/0/organizations/{group.organization.slug}/issues/{group.qualified_short_id}/" @@ -121,8 +123,10 @@ def test_with_first_release(self) -> None: def test_no_releases(self) -> None: self.login_as(user=self.user) event = self.store_event(data={}, project_id=self.project.id) + assert event.group is not None group = event.group + assert group is not None url = f"/api/0/organizations/{group.organization.slug}/issues/{group.qualified_short_id}/" @@ -474,7 +478,9 @@ def test_count_with_buffer(self) -> None: event = self.store_event( data={"message": "testing", "fingerprint": ["group-1"]}, project_id=self.project.id ) + assert event.group is not None group = event.group + assert group is not None event.group.update(times_seen=1) buffer.backend.incr(Group, {"times_seen": 15}, filters={"id": event.group.id}) @@ -780,6 +786,7 @@ def test_resolved_in_next_release_non_semver(self) -> None: group_resolution = GroupResolution.objects.filter(group=group).first() assert group_resolution is not None + assert group_resolution.group is not None assert group_resolution.group == group assert group_resolution.type == GroupResolution.Type.in_next_release assert group_resolution.status == GroupResolution.Status.pending @@ -795,6 +802,7 @@ def create_group_with_no_release(self, project: Project) -> Group: # Using store_event() instead of create_group() produces GroupRelease objects # which is considered during the update_groups() call event = self.store_event(data={}, project_id=project.id) + assert event.group is not None group = event.group assert group is not None @@ -818,6 +826,7 @@ def resolved_in_next_release_helper(self, with_first_release: bool = True) -> No # Using store_event() instead of create_group() produces GroupRelease objects # which is considered during the update_groups() call event = self.store_event(data=data, project_id=project.id) + assert event.group is not None group = event.group assert group is not None assert group.status == GroupStatus.UNRESOLVED @@ -839,6 +848,7 @@ def resolved_in_next_release_helper(self, with_first_release: bool = True) -> No group_resolution = GroupResolution.objects.filter(group=group).first() assert group_resolution is not None + assert group_resolution.group is not None assert group_resolution.group == group # For semver projects, we consider resolution based on an expression rather than a specific release, # thus, it is considered resolved in the release that has the highest semver @@ -854,6 +864,7 @@ def resolved_in_next_release_helper(self, with_first_release: bool = True) -> No # Let's test that none of these releases regress the group for release in releases + [old_version]: event = self.store_event(data={"release": release.version}, project_id=project.id) + assert event.group is not None assert event.group == group # Refetch from DB to ensure the latest state is fetched group = Group.objects.get(id=group.id, project=project.id) @@ -1167,9 +1178,9 @@ def test_discard(self) -> None: assert resp.status_code == 204 assert not Group.objects.filter(id=group.id).exists() assert GroupHash.objects.filter(id=group_hash.id).exists() - tombstone = GroupTombstone.objects.get( - id=GroupHash.objects.get(id=group_hash.id).group_tombstone_id - ) + group_tombstone_id = GroupHash.objects.get(id=group_hash.id).group_tombstone_id + assert group_tombstone_id is not None + tombstone = GroupTombstone.objects.get(id=group_tombstone_id) assert tombstone.message == group.message assert tombstone.culprit == group.culprit assert tombstone.project == group.project diff --git a/tests/sentry/issues/endpoints/test_group_event_details.py b/tests/sentry/issues/endpoints/test_group_event_details.py index 5a21565ba111..4275117aac08 100644 --- a/tests/sentry/issues/endpoints/test_group_event_details.py +++ b/tests/sentry/issues/endpoints/test_group_event_details.py @@ -55,11 +55,17 @@ def setUp(self) -> None: }, project_id=self.project_1.id, ) + assert self.event_a.group is not None + assert self.event_b.group is not None + self.group_a = self.event_a.group + self.group_b = self.event_b.group class GroupEventDetailsEndpointTest(GroupEventDetailsEndpointTestBase, APITestCase, SnubaTestCase): def test_get_simple_latest(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_a.group.id}/events/latest/" + url = ( + f"/api/0/organizations/{self.organization.slug}/issues/{self.group_a.id}/events/latest/" + ) response = self.client.get(url, format="json") assert response.status_code == 200, response.content @@ -68,7 +74,9 @@ def test_get_simple_latest(self) -> None: assert response.data["nextEventID"] is None def test_get_simple_oldest(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_a.group.id}/events/oldest/" + url = ( + f"/api/0/organizations/{self.organization.slug}/issues/{self.group_a.id}/events/oldest/" + ) response = self.client.get(url, format="json") assert response.status_code == 200, response.content @@ -77,7 +85,7 @@ def test_get_simple_oldest(self) -> None: assert response.data["nextEventID"] == str(self.event_b.event_id) def test_get_simple_event_id(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_b.group.id}/events/{self.event_b.event_id}/" + url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group_b.id}/events/{self.event_b.event_id}/" response = self.client.get(url, format="json") assert response.status_code == 200, response.content @@ -86,7 +94,9 @@ def test_get_simple_event_id(self) -> None: assert response.data["nextEventID"] == str(self.event_c.event_id) def test_get_with_environment_latest(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_a.group.id}/events/latest/" + url = ( + f"/api/0/organizations/{self.organization.slug}/issues/{self.group_a.id}/events/latest/" + ) response = self.client.get(url, format="json", data={"environment": ["production"]}) assert response.status_code == 200, response.content @@ -95,7 +105,9 @@ def test_get_with_environment_latest(self) -> None: assert response.data["nextEventID"] is None def test_get_with_environment_oldest(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_a.group.id}/events/oldest/" + url = ( + f"/api/0/organizations/{self.organization.slug}/issues/{self.group_a.id}/events/oldest/" + ) response = self.client.get(url, format="json", data={"environment": ["production"]}) assert response.status_code == 200, response.content @@ -104,7 +116,9 @@ def test_get_with_environment_oldest(self) -> None: assert response.data["nextEventID"] is None def test_collapse_stacktrace_only(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_a.group.id}/events/latest/" + url = ( + f"/api/0/organizations/{self.organization.slug}/issues/{self.group_a.id}/events/latest/" + ) response = self.client.get(url, format="json", data={"collapse": ["stacktraceOnly"]}) assert response.status_code == 200, response.content @@ -113,7 +127,9 @@ def test_collapse_stacktrace_only(self) -> None: assert "nextEventID" not in response.data def test_collapse_full_release(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_a.group.id}/events/latest/" + url = ( + f"/api/0/organizations/{self.organization.slug}/issues/{self.group_a.id}/events/latest/" + ) response_no_collapse = self.client.get( url, format="json", data={"environment": ["production"]} ) @@ -138,7 +154,7 @@ def test_collapse_full_release(self) -> None: ) def test_collapse_adjacent_events(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_b.group.id}/events/{self.event_b.event_id}/" + url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group_b.id}/events/{self.event_b.event_id}/" with ( mock.patch.object(SnubaEventStorage, "get_adjacent_event_ids") as legacy, mock.patch.object(SnubaEventStorage, "get_adjacent_event_ids_snql") as snql, @@ -153,7 +169,9 @@ def test_collapse_adjacent_events(self) -> None: assert snql.call_count == 0 def test_collapse_adjacent_events_with_full_release(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_a.group.id}/events/latest/" + url = ( + f"/api/0/organizations/{self.organization.slug}/issues/{self.group_a.id}/events/latest/" + ) with mock.patch.object(SnubaEventStorage, "get_adjacent_event_ids") as legacy: response = self.client.get( url, format="json", data={"collapse": ["fullRelease", "adjacentEvents"]} @@ -167,7 +185,7 @@ def test_collapse_adjacent_events_with_full_release(self) -> None: assert legacy.call_count == 0 def test_adjacent_events_computed_by_default(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_b.group.id}/events/{self.event_b.event_id}/" + url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group_b.id}/events/{self.event_b.event_id}/" with mock.patch.object( SnubaEventStorage, "get_adjacent_event_ids", @@ -182,12 +200,12 @@ def test_adjacent_events_computed_by_default(self) -> None: assert legacy.call_count == 1 def test_prev_filtered(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_b.group.id}/events/{self.event_b.event_id}/" + url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group_b.id}/events/{self.event_b.event_id}/" response = self.client.get( url, { "start": self.event_b.datetime.isoformat(), - "end": before_now(minutes=0), + "end": before_now(minutes=0).isoformat(), }, format="json", ) @@ -197,7 +215,7 @@ def test_prev_filtered(self) -> None: assert response.data["nextEventID"] == str(self.event_c.event_id) def test_next_filtered(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_b.group.id}/events/{self.event_b.event_id}/" + url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group_b.id}/events/{self.event_b.event_id}/" response = self.client.get( url, { @@ -259,6 +277,7 @@ def _build_interleaved_tag_group(self) -> tuple[Any, Any, Any, Any, Any]: def test_walk_next_prev_respects_query_filter_for_allowlisted_org(self) -> None: m1, n1, m2, _n2, m3 = self._build_interleaved_tag_group() + assert m1.group is not None group_id = m1.group.id query = "attachmentsAdded:true" @@ -321,6 +340,7 @@ def test_next_prev_respects_or_query_filter_for_allowlisted_org(self) -> None: }, project_id=self.project_1.id, ) + assert blue.group is not None url = f"/api/0/organizations/{self.organization.slug}/issues/{blue.group.id}/events/{blue.event_id}/" response = self.client.get(url, {"query": "color:[red, blue]"}, format="json") @@ -352,7 +372,7 @@ def test_get_simple_helpful(self) -> None: }, project_id=self.project_1.id, ) - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_a.group.id}/events/recommended/" + url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group_a.id}/events/recommended/" response = self.client.get(url, format="json") assert response.status_code == 200, response.content @@ -388,7 +408,7 @@ def test_get_helpful_event_id(self) -> None: }, project_id=self.project_1.id, ) - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_a.group.id}/events/recommended/" + url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group_a.id}/events/recommended/" response = self.client.get(url, format="json") assert response.status_code == 200, response.content @@ -436,7 +456,11 @@ def test_get_helpful_replay_id_order(self) -> None: project_id=self.project_1.id, ) - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_d.group.id}/events/recommended/" + group_d = self.event_d.group + assert group_d is not None + url = ( + f"/api/0/organizations/{self.organization.slug}/issues/{group_d.id}/events/recommended/" + ) response = self.client.get(url, format="json") assert response.status_code == 200, response.content @@ -445,7 +469,7 @@ def test_get_helpful_replay_id_order(self) -> None: assert response.data["nextEventID"] == str(self.event_f.event_id) def test_with_empty_query(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_a.group.id}/events/recommended/" + url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group_a.id}/events/recommended/" response = self.client.get(url, {"query": ""}, format="json") assert response.status_code == 200, response.content @@ -454,7 +478,7 @@ def test_with_empty_query(self) -> None: assert response.data["nextEventID"] is None def test_issue_filter_query_ignored(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_a.group.id}/events/recommended/" + url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group_a.id}/events/recommended/" response = self.client.get(url, {"query": "is:unresolved"}, format="json") assert response.status_code == 200, response.content @@ -463,7 +487,7 @@ def test_issue_filter_query_ignored(self) -> None: assert response.data["nextEventID"] is None def test_event_release_query(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_a.group.id}/events/recommended/" + url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group_a.id}/events/recommended/" response = self.client.get(url, {"query": f"release:{self.release_version}"}, format="json") assert response.status_code == 200, response.content @@ -482,6 +506,7 @@ def test_event_release_semver_query(self) -> None: }, project_id=self.project_1.id, ) + assert event_g.group is not None release = Release.objects.filter(version="test@1.2.3").get() assert release.version == "test@1.2.3" @@ -496,7 +521,7 @@ def test_event_release_semver_query(self) -> None: assert response.data["nextEventID"] is None def test_has_environment(self) -> None: - url = f"/api/0/organizations/{self.organization.slug}/issues/{self.event_a.group.id}/events/recommended/" + url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group_a.id}/events/recommended/" response = self.client.get(url, {"query": "has:environment"}, format="json") assert response.status_code == 200, response.content @@ -523,6 +548,7 @@ def test_skipped_snuba_fields_ignored(self) -> None: }, project_id=self.project_1.id, ) + assert event_e.group is not None event_f = self.store_event( data={ @@ -535,6 +561,7 @@ def test_skipped_snuba_fields_ignored(self) -> None: ) group = event_e.group + assert group is not None group.status = GroupStatus.RESOLVED group.substatus = None group.save(update_fields=["status", "substatus"]) @@ -559,6 +586,7 @@ def test_query_title(self) -> None: }, project_id=self.project_1.id, ) + assert event_e.group is not None url = f"/api/0/organizations/{self.organization.slug}/issues/{event_e.group.id}/events/recommended/" response = self.client.get(url, {"query": f'title:"{title}"'}, format="json") @@ -579,6 +607,7 @@ def test_query_title_not_in_with_wildcards(self) -> None: }, project_id=self.project_1.id, ) + assert event_e.group is not None url = f"/api/0/organizations/{self.organization.slug}/issues/{event_e.group.id}/events/recommended/" response = self.client.get(url, {"query": '!title:["*value1*", "*value2*"]'}, format="json") @@ -592,8 +621,8 @@ def test_query_issue_platform_title(self) -> None: issue_title=issue_title, event_data={"level": "info"}, ) - assert group_info is not None + assert group_info.group is not None url = f"/api/0/organizations/{self.organization.slug}/issues/{group_info.group.id}/events/recommended/" response = self.client.get(url, {"query": f'title:"{issue_title}"'}, format="json") @@ -616,6 +645,7 @@ def _store_platform_tag_collision_events(self) -> Any: }, project_id=self.project_1.id, ) + assert event.group is not None events.append(event) return event.group, events diff --git a/tests/sentry/issues/endpoints/test_group_events.py b/tests/sentry/issues/endpoints/test_group_events.py index ec37d509ae34..36e42b078fbe 100644 --- a/tests/sentry/issues/endpoints/test_group_events.py +++ b/tests/sentry/issues/endpoints/test_group_events.py @@ -1,4 +1,5 @@ from datetime import timedelta +from typing import Any from unittest.mock import MagicMock, patch from django.utils import timezone @@ -25,7 +26,8 @@ def setUp(self) -> None: self.two_min_ago = before_now(minutes=2) def do_request(self, url: str) -> Response: - return self.client.get(url, format="json") + response: Response[Any] = self.client.get(url, format="json") + return response def _parse_links(self, header: str) -> dict[str | None, dict[str, str | None]]: # links come in {url: {...attrs}}, but we need {rel: {...attrs}} @@ -46,6 +48,7 @@ def test_simple(self) -> None: }, project_id=self.project.id, ) + assert event_1.group is not None event_2 = self.store_event( data={ "event_id": "b" * 32, @@ -78,6 +81,7 @@ def test_full_false(self) -> None: }, project_id=self.project.id, ) + assert event_1.group is not None event_2 = self.store_event( data={ "event_id": "b" * 32, @@ -109,6 +113,7 @@ def test_full_true(self) -> None: }, project_id=self.project.id, ) + assert event_1.group is not None self.store_event( data={ "event_id": "b" * 32, @@ -138,6 +143,7 @@ def test_tags(self) -> None: }, project_id=self.project.id, ) + assert event_1.group is not None event_2 = self.store_event( data={ "event_id": "b" * 32, @@ -203,6 +209,7 @@ def test_search_event_by_id(self) -> None: }, project_id=self.project.id, ) + assert event_1.group is not None self.store_event( data={ "event_id": "b" * 32, @@ -230,7 +237,9 @@ def test_search_event_by_message(self) -> None: }, project_id=self.project.id, ) + assert event_1.group is not None group = event_1.group + assert group is not None event_2 = self.store_event( data={ "event_id": "b" * 32, @@ -240,6 +249,7 @@ def test_search_event_by_message(self) -> None: }, project_id=self.project.id, ) + assert event_2.group is not None assert group == event_2.group query_1 = "foo" @@ -275,6 +285,7 @@ def test_search_by_release(self) -> None: }, project_id=self.project.id, ) + assert event_1.group is not None url = f"/api/0/organizations/{self.organization.slug}/issues/{event_1.group.id}/events/?query=release:latest" response = self.do_request(url) @@ -297,7 +308,11 @@ def test_environment(self) -> None: ) # Asserts that all are in the same group - (group_id,) = {e.group.id for e in events.values()} + group_ids = set() + for e in events.values(): + assert e.group is not None + group_ids.add(e.group.id) + (group_id,) = group_ids url = f"/api/0/organizations/{self.organization.slug}/issues/{group_id}/events/" response = self.do_request(url + "?environment=production") @@ -337,7 +352,9 @@ def test_filters_based_on_retention(self) -> None: data={"fingerprint": ["group_1"], "timestamp": self.min_ago.isoformat()}, project_id=self.project.id, ) + assert event_2.group is not None group = event_2.group + assert group is not None with self.options({"system.event-retention-days": 1}): response = self.client.get( @@ -358,6 +375,7 @@ def test_search_event_has_tags(self) -> None: }, project_id=self.project.id, ) + assert event.group is not None response = self.client.get( f"/api/0/organizations/{self.organization.slug}/issues/{event.group.id}/events/" @@ -374,11 +392,14 @@ def test_date_filters(self) -> None: data={"timestamp": before_now(days=5).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event_1.group is not None event_2 = self.store_event( data={"timestamp": before_now(days=1).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event_2.group is not None group = event_1.group + assert group is not None assert group == event_2.group response = self.client.get( @@ -444,6 +465,7 @@ def test_multiple_group(self) -> None: ) for event in (event_1, event_2): + assert event.group is not None url = f"/api/0/organizations/{self.organization.slug}/issues/{event.group.id}/events/" response = self.do_request(url) assert response.status_code == 200, response.content @@ -464,7 +486,9 @@ def test_pagination(self) -> None: project_id=self.project.id, ) - url = f"/api/0/organizations/{self.organization.slug}/issues/{event.group.id}/events/?per_page=1" + group = event.group + assert group is not None + url = f"/api/0/organizations/{self.organization.slug}/issues/{group.id}/events/?per_page=1" response = self.do_request(url) links = self._parse_links(response["Link"]) assert response.status_code == 200, response.content @@ -485,7 +509,9 @@ def _store_events(self, count: int) -> Group: project_id=self.project.id, ) assert event is not None - return event.group + group = event.group + assert group is not None + return group def test_full_clamps_per_page_and_pages_with_cursor(self) -> None: self.login_as(user=self.user) @@ -543,6 +569,7 @@ def test_orderby(self) -> None: }, project_id=self.project.id, ) + assert event.group is not None url = f"/api/0/organizations/{self.organization.slug}/issues/{event.group.id}/events/" response = self.do_request(url) @@ -552,6 +579,7 @@ def test_orderby(self) -> None: def test_perf_issue(self) -> None: event_1 = self.create_performance_issue() + assert event_1.group is not None event_2 = self.create_performance_issue() assert event_1.group is not None @@ -574,6 +602,7 @@ def test_generic_issue(self) -> None: before_now(hours=1), ) assert group_info is not None + assert group_info.group is not None event_2, _, _ = self.store_search_issue( self.project.id, self.user.id, @@ -614,6 +643,7 @@ def test_sample(self) -> None: }, project_id=self.project.id, ) + assert event.group is not None url = f"/api/0/organizations/{self.organization.slug}/issues/{event.group.id}/events/?sample=true" response = self.do_request(url) @@ -630,6 +660,7 @@ def test_double_read_with_eap_enabled(self) -> None: }, project_id=self.project.id, ) + assert event_1.group is not None event_2 = self.store_event( data={ "event_id": "b" * 32, @@ -663,6 +694,7 @@ def test_snuba_read_timeout_returns_504(self, mock_query: MagicMock) -> None: }, project_id=self.project.id, ) + assert event.group is not None url = f"/api/0/organizations/{self.organization.slug}/issues/{event.group.id}/events/" response = self.do_request(url) assert response.status_code == 504 @@ -682,7 +714,9 @@ def _store_platform_tag_collision_events(self) -> tuple[Group, list[Event]]: project_id=self.project.id, ) events.append(event) - return event.group, events + group = event.group + assert group is not None + return group, events def test_platform_tag_collision_resolved_correctly(self) -> None: self.login_as(user=self.user) diff --git a/tests/sentry/issues/endpoints/test_group_first_last_release.py b/tests/sentry/issues/endpoints/test_group_first_last_release.py index 2684536562ad..ab6eece76ebe 100644 --- a/tests/sentry/issues/endpoints/test_group_first_last_release.py +++ b/tests/sentry/issues/endpoints/test_group_first_last_release.py @@ -13,7 +13,9 @@ def test_simple(self) -> None: assert response.data["lastRelease"] is None event = self.store_event(data={"release": "1.0"}, project_id=self.project.id) + assert event.group is not None group = event.group + assert group is not None url = f"/api/0/organizations/{self.organization.slug}/issues/{group.id}/first-last-release/" response = self.client.get(url, format="json") @@ -31,12 +33,15 @@ def test_with_environment_filtering(self) -> None: prod_event = self.store_event( data={"release": "1.0.0", "environment": "production"}, project_id=self.project.id ) + assert prod_event.group is not None staging_event = self.store_event( data={"release": "1.1.0", "environment": "staging"}, project_id=self.project.id ) + assert staging_event.group is not None assert prod_event.group == staging_event.group group = prod_event.group + assert group is not None # Without the environment filter, we should return the first and last across all environments url = f"/api/0/organizations/{self.organization.slug}/issues/{group.id}/first-last-release/" @@ -74,6 +79,7 @@ def test_with_environment_filtering(self) -> None: staging_event_2 = self.store_event( data={"release": "1.2.0", "environment": "staging"}, project_id=self.project.id ) + assert staging_event_2.group is not None assert staging_event_2.group == group url = f"/api/0/organizations/{self.organization.slug}/issues/{group.id}/first-last-release/?environment=staging" response = self.client.get(url) @@ -87,7 +93,9 @@ def test_with_nonexistent_environment(self) -> None: event = self.store_event( data={"release": "1.0.0", "environment": "production"}, project_id=self.project.id ) + assert event.group is not None group = event.group + assert group is not None url = f"/api/0/organizations/{self.organization.slug}/issues/{group.id}/first-last-release/?environment=nonexistent" response = self.client.get(url) diff --git a/tests/sentry/issues/endpoints/test_group_hashes.py b/tests/sentry/issues/endpoints/test_group_hashes.py index b81bcc450b0a..cb58c4c0996d 100644 --- a/tests/sentry/issues/endpoints/test_group_hashes.py +++ b/tests/sentry/issues/endpoints/test_group_hashes.py @@ -77,7 +77,11 @@ def test_return_multiple_hashes(self) -> None: # Merge the events eventstream = SnubaEventStream() - state = eventstream.start_merge(self.project.id, [event2.group_id], event1.group_id) + target_group_id = event1.group_id + assert target_group_id is not None + merged_group_id = event2.group_id + assert merged_group_id is not None + state = eventstream.start_merge(self.project.id, [merged_group_id], target_group_id) assert state is not None eventstream.end_merge(state) @@ -119,7 +123,11 @@ def test_return_multiple_hashes_with_seer_match(self) -> None: # Merge the events eventstream = SnubaEventStream() - state = eventstream.start_merge(self.project.id, [event2.group_id], event1.group_id) + target_group_id = event1.group_id + assert target_group_id is not None + merged_group_id = event2.group_id + assert merged_group_id is not None + state = eventstream.start_merge(self.project.id, [merged_group_id], target_group_id) assert state is not None eventstream.end_merge(state) diff --git a/tests/sentry/issues/endpoints/test_group_integration_details.py b/tests/sentry/issues/endpoints/test_group_integration_details.py index 5eae8763bd31..a73d880b6341 100644 --- a/tests/sentry/issues/endpoints/test_group_integration_details.py +++ b/tests/sentry/issues/endpoints/test_group_integration_details.py @@ -57,6 +57,7 @@ def setUp(self) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert self.event.group is not None self.group = self.event.group def assert_metric_recorded( diff --git a/tests/sentry/issues/endpoints/test_group_pull_requests.py b/tests/sentry/issues/endpoints/test_group_pull_requests.py index 7a406af87559..633a27ecbb1c 100644 --- a/tests/sentry/issues/endpoints/test_group_pull_requests.py +++ b/tests/sentry/issues/endpoints/test_group_pull_requests.py @@ -2,6 +2,7 @@ from collections.abc import Sequence from datetime import datetime, timedelta +from typing import Any from unittest.mock import Mock, patch from django.utils import timezone @@ -109,7 +110,7 @@ def create_linked_pull_request( title=title, author=self.create_commit_author(project=group.project, user=self.user), ) - updates = { + updates: dict[str, Any] = { key: value for key, value in { "state": state, diff --git a/tests/sentry/issues/endpoints/test_group_reprocessing.py b/tests/sentry/issues/endpoints/test_group_reprocessing.py index 905aa3302a3d..1dd8a35e3e80 100644 --- a/tests/sentry/issues/endpoints/test_group_reprocessing.py +++ b/tests/sentry/issues/endpoints/test_group_reprocessing.py @@ -16,6 +16,7 @@ def setUp(self) -> None: }, project_id=self.project.id, ) + assert event.group is not None self.group = event.group self.url = ( f"/api/0/organizations/{self.organization.slug}/issues/{self.group.id}/reprocessing/" diff --git a/tests/sentry/issues/endpoints/test_group_similar_issues_embeddings.py b/tests/sentry/issues/endpoints/test_group_similar_issues_embeddings.py index 89ffedba4da4..860a8fdfcaed 100644 --- a/tests/sentry/issues/endpoints/test_group_similar_issues_embeddings.py +++ b/tests/sentry/issues/endpoints/test_group_similar_issues_embeddings.py @@ -85,8 +85,8 @@ def setUp(self) -> None: "platform": "python", } self.event = self.store_event(data=self.base_error_trace, project_id=self.project) + assert self.event.group is not None self.group = self.event.group - assert self.group self.path = f"/api/0/organizations/{self.org.slug}/issues/{self.group.id}/similar-issues-embeddings/" self.similar_event = self.store_event( data={"message": "Dogs are great!"}, project_id=self.project @@ -114,6 +114,11 @@ def get_expected_response( ) return response + def get_latest_event_id(self) -> str: + latest_event = self.group.get_latest_event() + assert latest_event is not None + return latest_event.event_id + def test_get_formatted_results(self) -> None: event_from_second_similar_group = save_new_event( {"message": "Adopt don't shop"}, self.project @@ -239,7 +244,7 @@ def test_simple( expected_seer_request_params = { "threshold": 0.01, - "event_id": self.group.get_latest_event().event_id, + "event_id": self.get_latest_event_id(), "hash": self.event.get_primary_hash(), "project_id": self.project.id, "stacktrace": EXPECTED_STACKTRACE_STRING, @@ -411,7 +416,7 @@ def test_incomplete_return_data( "Seer similar issues response entry missing key 'parent_hash'", extra={ "request_params": { - "event_id": self.group.get_latest_event().event_id, + "event_id": self.get_latest_event_id(), "hash": self.event.get_primary_hash(), "project_id": self.project.id, "stacktrace": EXPECTED_STACKTRACE_STRING, @@ -531,7 +536,7 @@ def test_grouphash_with_no_group( Test that these groups are not returned. """ existing_grouphash = GroupHash.objects.create(hash="dogs_are_great", project=self.project) - assert existing_grouphash.group_id is None + assert not existing_grouphash.group_id # Create metadata for the grouphash so it has a creation date GroupHashMetadata.objects.get_or_create(grouphash=existing_grouphash) @@ -707,7 +712,7 @@ def test_no_optional_params(self, mock_seer_request: mock.MagicMock) -> None: body=orjson.dumps( { "threshold": 0.01, - "event_id": self.group.get_latest_event().event_id, + "event_id": self.get_latest_event_id(), "hash": self.event.get_primary_hash(), "project_id": self.project.id, "stacktrace": EXPECTED_STACKTRACE_STRING, @@ -742,7 +747,7 @@ def test_no_optional_params(self, mock_seer_request: mock.MagicMock) -> None: body=orjson.dumps( { "threshold": 0.01, - "event_id": self.group.get_latest_event().event_id, + "event_id": self.get_latest_event_id(), "hash": self.event.get_primary_hash(), "project_id": self.project.id, "stacktrace": EXPECTED_STACKTRACE_STRING, @@ -780,7 +785,7 @@ def test_no_optional_params(self, mock_seer_request: mock.MagicMock) -> None: body=orjson.dumps( { "threshold": 0.01, - "event_id": self.group.get_latest_event().event_id, + "event_id": self.get_latest_event_id(), "hash": self.event.get_primary_hash(), "project_id": self.project.id, "stacktrace": EXPECTED_STACKTRACE_STRING, diff --git a/tests/sentry/issues/endpoints/test_group_tagkey_details.py b/tests/sentry/issues/endpoints/test_group_tagkey_details.py index f0950f6dabc3..8695943d26f3 100644 --- a/tests/sentry/issues/endpoints/test_group_tagkey_details.py +++ b/tests/sentry/issues/endpoints/test_group_tagkey_details.py @@ -1,3 +1,5 @@ +from typing import Any, cast + from sentry.models.group import Group from sentry.testutils.cases import APITestCase, PerformanceIssueTestCase, SnubaTestCase from sentry.testutils.helpers.datetime import before_now @@ -27,12 +29,16 @@ def test_simple(self) -> None: def test_simple_perf(self) -> None: event = self.create_performance_issue( - tags=[["foo", "bar"], ["biz", "baz"], ["sentry:release", "releaseme"]], + # justification: the helper forwards tags to the event payload, which accepts pairs. + tags=cast( + "dict[str, Any]", [["foo", "bar"], ["biz", "baz"], ["sentry:release", "releaseme"]] + ), fingerprint="group1", contexts={"trace": {"trace_id": "b" * 32, "span_id": "c" * 16, "op": ""}}, ) self.create_performance_issue( - tags=[["foo", "guux"], ["sentry:release", "releaseme"]], + # justification: the helper forwards tags to the event payload, which accepts pairs. + tags=cast("dict[str, Any]", [["foo", "guux"], ["sentry:release", "releaseme"]]), fingerprint="group1", contexts={"trace": {"trace_id": "b" * 32, "span_id": "c" * 16, "op": ""}}, ) @@ -66,6 +72,7 @@ def test_includes_empty_values_counts(self) -> None: }, project_id=self.project.id, ) + assert event.group is not None self.login_as(user=self.user) diff --git a/tests/sentry/issues/endpoints/test_group_tagkey_values.py b/tests/sentry/issues/endpoints/test_group_tagkey_values.py index c5755fd5f157..56d5139f3b78 100644 --- a/tests/sentry/issues/endpoints/test_group_tagkey_values.py +++ b/tests/sentry/issues/endpoints/test_group_tagkey_values.py @@ -1,5 +1,6 @@ import datetime from datetime import timedelta +from typing import Any, cast from unittest import mock from django.test import override_settings @@ -23,7 +24,9 @@ def test_simple(self, mock_record: mock.MagicMock) -> None: data={"tags": {key: value}, "timestamp": before_now(seconds=1).isoformat()}, project_id=project.id, ) + assert event.group is not None group = event.group + assert group is not None self.login_as(user=self.user) @@ -47,7 +50,8 @@ def test_simple(self, mock_record: mock.MagicMock) -> None: def test_simple_perf(self) -> None: key, value = "foo", "bar" event = self.create_performance_issue( - tags=[[key, value]], + # justification: the helper forwards tags to the event payload, which accepts pairs. + tags=cast("dict[str, Any]", [[key, value]]), fingerprint="group1", contexts={"trace": {"trace_id": "b" * 32, "span_id": "c" * 16, "op": ""}}, ) @@ -80,7 +84,9 @@ def test_user_tag(self) -> None: }, project_id=project.id, ) + assert event.group is not None group = event.group + assert group is not None self.login_as(user=self.user) @@ -112,7 +118,9 @@ def test_tag_value_with_backslash(self) -> None: }, project_id=project.id, ) + assert event.group is not None group = event.group + assert group is not None self.login_as(user=self.user) @@ -143,7 +151,9 @@ def test_includes_empty_values_by_default(self) -> None: }, project_id=project.id, ) + assert event.group is not None group = event.group + assert group is not None self.login_as(user=self.user) @@ -176,8 +186,10 @@ def test_includes_empty_values_backend_helpers(self) -> None: }, project_id=project.id, ) + assert event.group is not None group = event.group + assert group is not None self.login_as(user=self.user) @@ -229,8 +241,10 @@ def test_user_tag_with_empty_values(self) -> None: }, project_id=project.id, ) + assert event.group is not None group = event.group + assert group is not None self.login_as(user=self.user) @@ -261,6 +275,7 @@ def test_count_sort(self) -> None: }, project_id=project.id, ) + assert event.group is not None self.store_event( data={ "message": "message 1", @@ -290,6 +305,7 @@ def test_count_sort(self) -> None: project_id=project.id, ) group = event.group + assert group is not None self.login_as(user=self.user) @@ -317,7 +333,9 @@ def test_ratelimit(self, mock_record: mock.MagicMock) -> None: data={"tags": {key: value}, "timestamp": before_now(seconds=1).isoformat()}, project_id=project.id, ) + assert event.group is not None group = event.group + assert group is not None self.login_as(user=self.user) diff --git a/tests/sentry/issues/endpoints/test_group_tags.py b/tests/sentry/issues/endpoints/test_group_tags.py index e1b33cfda8b5..86f289a6d0f7 100644 --- a/tests/sentry/issues/endpoints/test_group_tags.py +++ b/tests/sentry/issues/endpoints/test_group_tags.py @@ -1,3 +1,5 @@ +from typing import Any, cast + from sentry.testutils.cases import APITestCase, PerformanceIssueTestCase, SnubaTestCase from sentry.testutils.helpers.datetime import before_now @@ -13,6 +15,7 @@ def test_simple(self) -> None: }, project_id=self.project.id, ) + assert event1.group is not None self.store_event( data={ "fingerprint": ["group-1"], @@ -74,12 +77,17 @@ def test_simple(self) -> None: def test_simple_performance(self) -> None: event = self.create_performance_issue( - tags=[["foo", "bar"], ["biz", "baz"], ["sentry:release", "releaseme"]], + # justification: the helper forwards tags to the event payload, which accepts pairs. + tags=cast( + "dict[str, Any]", [["foo", "bar"], ["biz", "baz"], ["sentry:release", "releaseme"]] + ), fingerprint="group5", contexts={"trace": {"trace_id": "b" * 32, "span_id": "c" * 16, "op": ""}}, ) + assert event.group is not None self.create_performance_issue( - tags=[["foo", "quux"], ["sentry:release", "releaseme"]], + # justification: the helper forwards tags to the event payload, which accepts pairs. + tags=cast("dict[str, Any]", [["foo", "quux"], ["sentry:release", "releaseme"]]), fingerprint="group5", contexts={"trace": {"trace_id": "b" * 32, "span_id": "c" * 16, "op": ""}}, ) @@ -135,6 +143,7 @@ def test_date_range_filtering(self) -> None: }, project_id=self.project.id, ) + assert event1.group is not None self.store_event( data={ @@ -206,7 +215,9 @@ def test_valid_env(self) -> None: }, project_id=self.project.id, ) + assert event.group is not None group = event.group + assert group is not None self.login_as(user=self.user) url = f"/api/0/organizations/{self.organization.slug}/issues/{group.id}/tags/" @@ -243,6 +254,7 @@ def test_multi_env(self) -> None: }, project_id=self.project.id, ) + assert event2.group is not None self.login_as(user=self.user) url = f"/api/0/organizations/{self.organization.slug}/issues/{event2.group.id}/tags/" @@ -261,6 +273,7 @@ def test_readable_tag_values(self) -> None: }, project_id=self.project.id, ) + assert event1.group is not None self.store_event( data={ "fingerprint": ["group-1"], @@ -323,6 +336,7 @@ def test_limit(self) -> None: }, project_id=self.project.id, ) + assert event.group is not None self.login_as(user=self.user) @@ -374,6 +388,7 @@ def test_device_class(self) -> None: }, project_id=self.project.id, ) + assert event.group is not None self.login_as(user=self.user) @@ -401,6 +416,7 @@ def test_include_empty_values_for_specific_key(self) -> None: project_id=self.project.id, assert_no_errors=False, ) + assert event.group is not None self.store_event( data={ "fingerprint": ["group-empty-all"], @@ -431,6 +447,7 @@ def test_include_empty_values_in_all_tags(self) -> None: project_id=self.project.id, assert_no_errors=False, ) + assert event.group is not None self.store_event( data={ "fingerprint": ["group-empty-all-tags"], @@ -470,6 +487,7 @@ def test_flags(self) -> None: }, project_id=self.project.id, ) + assert event1.group is not None self.store_event( data={ "fingerprint": ["group-1"], @@ -563,6 +581,8 @@ def test_flags_limit(self) -> None: }, project_id=self.project.id, ) + group = event.group + assert group is not None self.store_event( data={ "fingerprint": ["group-1"], @@ -608,7 +628,7 @@ def test_flags_limit(self) -> None: ) self.login_as(user=self.user) - url = f"/api/0/organizations/{self.organization.slug}/issues/{event.group.id}/tags/?useFlagsBackend=1&limit=3" + url = f"/api/0/organizations/{self.organization.slug}/issues/{group.id}/tags/?useFlagsBackend=1&limit=3" response = self.client.get(url, format="json") assert response.status_code == 200, response.content assert len(response.data) == 1 @@ -635,6 +655,7 @@ def test_flags_reserved_tag_key(self) -> None: }, project_id=self.project.id, ) + assert event1.group is not None self.login_as(user=self.user) diff --git a/tests/sentry/issues/endpoints/test_organization_group_index.py b/tests/sentry/issues/endpoints/test_organization_group_index.py index 189c5fde5883..0f101aeeb864 100644 --- a/tests/sentry/issues/endpoints/test_organization_group_index.py +++ b/tests/sentry/issues/endpoints/test_organization_group_index.py @@ -99,6 +99,7 @@ def _parse_links(self, header: str) -> dict[str | None, dict[str, str | None]]: return links def get_response(self, *args: Organization, **kwargs: Any) -> Response: + org: str | Organization if not args: org = self.project.organization.slug else: @@ -111,7 +112,9 @@ def test_sort_by_date_with_tag(self) -> None: data={"event_id": "a" * 32, "timestamp": before_now(seconds=1).isoformat()}, project_id=self.project.id, ) + assert event.group is not None group = event.group + assert group is not None self.login_as(user=self.user) response = self.get_success_response(sort_by="date", query="is:unresolved") @@ -123,7 +126,9 @@ def test_query_for_archived(self) -> None: data={"event_id": "a" * 32, "timestamp": before_now(seconds=1).isoformat()}, project_id=self.project.id, ) + assert event.group is not None group = event.group + assert group is not None group.status = GroupStatus.IGNORED group.substatus = None group.save() @@ -141,6 +146,7 @@ def test_sort_by_trends(self) -> None: }, project_id=self.project.id, ).group + assert group is not None self.store_event( data={ "timestamp": before_now(seconds=10).isoformat(), @@ -163,6 +169,7 @@ def test_sort_by_trends(self) -> None: }, project_id=self.project.id, ).group + assert group_2 is not None self.store_event( data={ "timestamp": before_now(hours=13).isoformat(), @@ -200,10 +207,12 @@ def test_sort_by_progress(self) -> None: data={"timestamp": before_now(seconds=1).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ).group + assert group_1 is not None group_2 = self.store_event( data={"timestamp": before_now(hours=1).isoformat(), "fingerprint": ["group-2"]}, project_id=self.project.id, ).group + assert group_2 is not None self.create_group_derived_data(group=group_2, progress="diagnosed") self.login_as(user=self.user) @@ -218,10 +227,12 @@ def test_sort_by_progress_over_cap_uses_native_ordering(self) -> None: data={"timestamp": before_now(seconds=1).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ).group + assert group_1 is not None group_2 = self.store_event( data={"timestamp": before_now(hours=1).isoformat(), "fingerprint": ["group-2"]}, project_id=self.project.id, ).group + assert group_2 is not None # group_2 is older (loses recency) but fix_applied (wins progress). self.create_group_derived_data(group=group_2, progress="fix_applied") self.login_as(user=self.user) @@ -237,10 +248,12 @@ def test_recommended_sort_serves_v2_with_experimental_flag(self) -> None: data={"timestamp": before_now(seconds=1).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ).group + assert group_1 is not None group_2 = self.store_event( data={"timestamp": before_now(hours=1).isoformat(), "fingerprint": ["group-2"]}, project_id=self.project.id, ).group + assert group_2 is not None group_2.update(substatus=GroupSubStatus.REGRESSED) self.login_as(user=self.user) @@ -269,6 +282,7 @@ def test_sort_by_inbox(self) -> None: }, project_id=self.project.id, ).group + assert group_1 is not None inbox_1 = add_group_to_inbox(group_1, GroupInboxReason.NEW) group_2 = self.store_event( data={ @@ -278,6 +292,7 @@ def test_sort_by_inbox(self) -> None: }, project_id=self.project.id, ).group + assert group_2 is not None inbox_2 = add_group_to_inbox(group_2, GroupInboxReason.NEW) inbox_2.update(date_added=inbox_1.date_added - timedelta(hours=1)) @@ -304,6 +319,7 @@ def test_sort_by_inbox_me_or_none(self) -> None: }, project_id=self.project.id, ).group + assert group_1 is not None inbox_1 = add_group_to_inbox(group_1, GroupInboxReason.NEW) group_2 = self.store_event( data={ @@ -313,6 +329,7 @@ def test_sort_by_inbox_me_or_none(self) -> None: }, project_id=self.project.id, ).group + assert group_2 is not None inbox_2 = add_group_to_inbox(group_2, GroupInboxReason.NEW) inbox_2.update(date_added=inbox_1.date_added - timedelta(hours=1)) GroupOwner.objects.create( @@ -330,6 +347,7 @@ def test_sort_by_inbox_me_or_none(self) -> None: }, project_id=self.project.id, ).group + assert owner_by_other is not None inbox_3 = add_group_to_inbox(owner_by_other, GroupInboxReason.NEW) inbox_3.update(date_added=inbox_1.date_added - timedelta(hours=1)) other_user = self.create_user() @@ -349,6 +367,7 @@ def test_sort_by_inbox_me_or_none(self) -> None: }, project_id=self.project.id, ).group + assert owned_me_assigned_to_other is not None inbox_4 = add_group_to_inbox(owned_me_assigned_to_other, GroupInboxReason.NEW) inbox_4.update(date_added=inbox_1.date_added - timedelta(hours=1)) GroupAssignee.objects.assign(owned_me_assigned_to_other, other_user) @@ -368,6 +387,7 @@ def test_sort_by_inbox_me_or_none(self) -> None: }, project_id=self.project.id, ).group + assert unowned_assigned_to_other is not None inbox_5 = add_group_to_inbox(unowned_assigned_to_other, GroupInboxReason.NEW) inbox_5.update(date_added=inbox_1.date_added - timedelta(hours=1)) GroupAssignee.objects.assign(unowned_assigned_to_other, other_user) @@ -398,6 +418,7 @@ def test_trace_search(self) -> None: }, project_id=self.project.id, ) + assert event.group is not None self.login_as(user=self.user) response = self.get_success_response( @@ -470,12 +491,16 @@ def test_simple_pagination(self) -> None: data={"timestamp": before_now(seconds=2).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event1.group is not None group1 = event1.group + assert group1 is not None event2 = self.store_event( data={"timestamp": before_now(seconds=1).isoformat(), "fingerprint": ["group-2"]}, project_id=self.project.id, ) + assert event2.group is not None group2 = event2.group + assert group2 is not None self.login_as(user=self.user) response = self.get_success_response(sort_by="date", limit=1) assert len(response.data) == 1 @@ -578,6 +603,7 @@ def test_perf_issue(self) -> None: saved_occurrence, group_info = save_issue_occurrence(occurrence.to_dict(), event) assert group_info is not None perf_group = group_info.group + assert perf_group is not None self.login_as(user=self.user) response = self.get_success_response( query=f"issue.type:{PerformanceNPlusOneGroupType.slug}" @@ -594,7 +620,9 @@ def test_has_seer_last_run(self) -> None: }, project_id=self.project.id, ) + assert event1.group is not None group_without_seer = event1.group + assert group_without_seer is not None event2 = self.store_event( data={ @@ -603,7 +631,9 @@ def test_has_seer_last_run(self) -> None: }, project_id=self.project.id, ) + assert event2.group is not None group_with_legacy_seer = event2.group + assert group_with_legacy_seer is not None group_with_legacy_seer.update(seer_autofix_last_triggered=timezone.now()) event3 = self.store_event( @@ -613,7 +643,9 @@ def test_has_seer_last_run(self) -> None: }, project_id=self.project.id, ) + assert event3.group is not None group_with_explorer_seer = event3.group + assert group_with_explorer_seer is not None group_with_explorer_seer.update(seer_explorer_autofix_last_triggered=timezone.now()) event4 = self.store_event( @@ -623,7 +655,9 @@ def test_has_seer_last_run(self) -> None: }, project_id=self.project.id, ) + assert event4.group is not None group_with_stale_explorer_seer = event4.group + assert group_with_stale_explorer_seer is not None group_with_stale_explorer_seer.update( seer_explorer_autofix_last_triggered=before_now(days=31) ) @@ -668,6 +702,7 @@ def test_lookup_by_event_id(self) -> None: data={"event_id": event_id, "timestamp": self.min_ago.isoformat()}, project_id=self.project.id, ) + assert event.group is not None self.login_as(user=self.user) @@ -687,6 +722,7 @@ def test_lookup_by_event_id_incorrect_project_id(self) -> None: data={"event_id": event_id, "timestamp": self.min_ago.isoformat()}, project_id=self.project.id, ) + assert event.group is not None other_project = self.create_project(teams=[self.team]) user = self.create_user() @@ -705,6 +741,7 @@ def test_lookup_by_event_id_with_whitespace(self) -> None: data={"event_id": event_id, "timestamp": self.min_ago.isoformat()}, project_id=self.project.id, ) + assert event.group is not None self.login_as(user=self.user) response = self.get_success_response(query=" {} ".format("c" * 32)) @@ -736,6 +773,7 @@ def test_lookup_by_short_id_alias(self) -> None: data={"event_id": event_id, "timestamp": before_now(seconds=1).isoformat()}, project_id=self.project.id, ).group + assert group is not None short_id = group.qualified_short_id self.login_as(user=self.user) @@ -751,10 +789,12 @@ def test_lookup_by_multiple_short_id_alias(self) -> None: data={"timestamp": before_now(seconds=2).isoformat()}, project_id=project.id, ) + assert event.group is not None event2 = self.store_event( data={"timestamp": before_now(seconds=1).isoformat()}, project_id=project2.id, ) + assert event2.group is not None response = self.get_success_response( query=f"issue:[{event.group.qualified_short_id},{event2.group.qualified_short_id}]", shortIdLookup=1, @@ -866,10 +906,12 @@ def test_lookup_by_first_release(self) -> None: data={"release": release.version, "timestamp": before_now(seconds=2).isoformat()}, project_id=project.id, ) + assert event.group is not None event2 = self.store_event( data={"release": release.version, "timestamp": before_now(seconds=1).isoformat()}, project_id=project2.id, ) + assert event2.group is not None response = self.get_success_response(**{"query": 'first-release:"%s"' % release.version}) issues = json.loads(response.content) @@ -895,6 +937,7 @@ def test_lookup_by_release(self) -> None: }, project_id=project.id, ) + assert event.group is not None response = self.get_success_response(release=release.version) issues = json.loads(response.content) @@ -919,6 +962,7 @@ def test_release_package_in(self) -> None: }, project_id=project.id, ) + assert event1.group is not None event2 = self.store_event( data={ "release": release2.version, @@ -927,6 +971,7 @@ def test_release_package_in(self) -> None: }, project_id=project.id, ) + assert event2.group is not None self.store_event( data={ "release": release3.version, @@ -954,6 +999,7 @@ def test_lookup_by_release_wildcard(self) -> None: }, project_id=project.id, ) + assert event.group is not None response = self.get_success_response(release=release.version[:3] + "*") issues = json.loads(response.content) @@ -971,6 +1017,7 @@ def test_lookup_by_regressed_in_release(self) -> None: }, project_id=project.id, ) + assert event.group is not None record_group_history(event.group, GroupHistoryStatus.REGRESSED, release=release) response = self.get_success_response(query=f"regressed_in_release:{release.version}") issues = json.loads(response.content) @@ -989,15 +1036,23 @@ def test_pending_delete_pending_merge_excluded(self) -> None: project_id=self.project.id, ) ) - events[0].group.update(status=GroupStatus.PENDING_DELETION, substatus=None) - events[2].group.update(status=GroupStatus.DELETION_IN_PROGRESS, substatus=None) - events[3].group.update(status=GroupStatus.PENDING_MERGE, substatus=None) + pending_delete_group = events[0].group + assert pending_delete_group is not None + pending_delete_group.update(status=GroupStatus.PENDING_DELETION, substatus=None) + deletion_in_progress_group = events[2].group + assert deletion_in_progress_group is not None + deletion_in_progress_group.update(status=GroupStatus.DELETION_IN_PROGRESS, substatus=None) + pending_merge_group = events[3].group + assert pending_merge_group is not None + pending_merge_group.update(status=GroupStatus.PENDING_MERGE, substatus=None) self.login_as(user=self.user) response = self.get_success_response() assert len(response.data) == 1 - assert response.data[0]["id"] == str(events[1].group.id) + visible_group = events[1].group + assert visible_group is not None + assert response.data[0]["id"] == str(visible_group.id) def test_filters_based_on_retention(self) -> None: self.login_as(user=self.user) @@ -1029,7 +1084,7 @@ def test_referrer_differs_for_ui_vs_api(self, mock_query: MagicMock) -> None: token = ApiToken.objects.create(user=self.user, scope_list=["event:read"]) def api_request() -> Response: - return self.client.get( + response: Response[Any] = self.client.get( reverse( "sentry-api-0-organization-group-index", args=[self.project.organization.slug], @@ -1038,6 +1093,7 @@ def api_request() -> Response: data={"query": "is:unresolved"}, HTTP_AUTHORIZATION=f"Bearer {token.token}", ) + return response # API (bearer token) requests get the api-specific referrer... response = api_request() @@ -1054,7 +1110,9 @@ def test_date_range(self) -> None: event = self.store_event( data={"timestamp": before_now(hours=5).isoformat()}, project_id=self.project.id ) + assert event.group is not None group = event.group + assert group is not None self.login_as(user=self.user) @@ -1115,6 +1173,7 @@ def test_assigned_to_pagination(self, patched_params_update: MagicMock) -> None: }, project_id=self.project.id, ).group + assert group is not None groups.append(group) assigned_groups = groups[:2] @@ -1175,6 +1234,7 @@ def test_hits_capped_when_overestimated(self, mock_calculate_hits: MagicMock) -> }, project_id=self.project.id, ) + assert event.group is not None groups.append(event.group) # Mock calculate_hits to return an overestimate (simulating sampling inaccuracy) @@ -1205,6 +1265,7 @@ def test_assigned_me_none(self) -> None: }, project_id=self.project.id, ).group + assert group is not None groups.append(group) assigned_groups = groups[:2] @@ -1234,7 +1295,9 @@ def test_seen_stats(self) -> None: data={"timestamp": before_now_300_seconds, "fingerprint": ["group-2"]}, project_id=self.project.id, ) + assert event2.group is not None group2 = event2.group + assert group2 is not None group2.first_seen = datetime.fromisoformat(before_now_350_seconds) group2.times_seen = 55 group2.save() @@ -1338,7 +1401,9 @@ def test_semver_seen_stats(self) -> None: }, project_id=self.project.id, ) + assert release_1_e_1.group is not None group_1 = release_1_e_1.group + assert group_1 is not None release_2_e_1 = self.store_event( data={ @@ -1415,6 +1480,7 @@ def test_inbox_search(self) -> None: }, project_id=self.project.id, ) + assert event.group is not None self.store_event( data={ @@ -1460,6 +1526,7 @@ def test_assigned_or_suggested_search(self) -> None: }, project_id=self.project.id, ) + assert event.group is not None event1 = self.store_event( data={ "timestamp": before_now(seconds=185).isoformat(), @@ -1468,6 +1535,7 @@ def test_assigned_or_suggested_search(self) -> None: }, project_id=self.project.id, ) + assert event1.group is not None event2 = self.store_event( data={ "timestamp": before_now(seconds=190).isoformat(), @@ -1476,6 +1544,7 @@ def test_assigned_or_suggested_search(self) -> None: }, project_id=self.project.id, ) + assert event2.group is not None assigned_event = self.store_event( data={ @@ -1484,6 +1553,7 @@ def test_assigned_or_suggested_search(self) -> None: }, project_id=self.project.id, ) + assert assigned_event.group is not None assigned_to_other_event = self.store_event( data={ @@ -1492,6 +1562,7 @@ def test_assigned_or_suggested_search(self) -> None: }, project_id=self.project.id, ) + assert assigned_to_other_event.group is not None self.login_as(user=self.user) response = self.get_response(sort_by="date", limit=10, query="assigned_or_suggested:me") @@ -1635,54 +1706,66 @@ def test_semver(self) -> None: release_2 = self.create_release(version="test@1.2.4") release_3 = self.create_release(version="test@1.2.5") - release_1_g_1 = self.store_event( + release_1_g_1_event = self.store_event( data={ "timestamp": before_now(minutes=1).isoformat(), "fingerprint": ["group-1"], "release": release_1.version, }, project_id=self.project.id, - ).group.id - release_1_g_2 = self.store_event( + ) + assert release_1_g_1_event.group is not None + release_1_g_1 = release_1_g_1_event.group.id + release_1_g_2_event = self.store_event( data={ "timestamp": before_now(minutes=2).isoformat(), "fingerprint": ["group-2"], "release": release_1.version, }, project_id=self.project.id, - ).group.id - release_2_g_1 = self.store_event( + ) + assert release_1_g_2_event.group is not None + release_1_g_2 = release_1_g_2_event.group.id + release_2_g_1_event = self.store_event( data={ "timestamp": before_now(minutes=3).isoformat(), "fingerprint": ["group-3"], "release": release_2.version, }, project_id=self.project.id, - ).group.id - release_2_g_2 = self.store_event( + ) + assert release_2_g_1_event.group is not None + release_2_g_1 = release_2_g_1_event.group.id + release_2_g_2_event = self.store_event( data={ "timestamp": before_now(minutes=4).isoformat(), "fingerprint": ["group-4"], "release": release_2.version, }, project_id=self.project.id, - ).group.id - release_3_g_1 = self.store_event( + ) + assert release_2_g_2_event.group is not None + release_2_g_2 = release_2_g_2_event.group.id + release_3_g_1_event = self.store_event( data={ "timestamp": before_now(minutes=5).isoformat(), "fingerprint": ["group-5"], "release": release_3.version, }, project_id=self.project.id, - ).group.id - release_3_g_2 = self.store_event( + ) + assert release_3_g_1_event.group is not None + release_3_g_1 = release_3_g_1_event.group.id + release_3_g_2_event = self.store_event( data={ "timestamp": before_now(minutes=6).isoformat(), "fingerprint": ["group-6"], "release": release_3.version, }, project_id=self.project.id, - ).group.id + ) + assert release_3_g_2_event.group is not None + release_3_g_2 = release_3_g_2_event.group.id self.login_as(user=self.user) response = self.get_response(sort_by="date", limit=10, query=f"{SEMVER_ALIAS}:>1.2.3") assert response.status_code == 200, response.content @@ -1735,7 +1818,7 @@ def test_release_stage(self) -> None: ) self.create_release(version="not_adopted_release", environments=[self.environment]) - adopted_release_g_1 = self.store_event( + adopted_release_g_1_event = self.store_event( data={ "timestamp": before_now(minutes=1).isoformat(), "fingerprint": ["group-1"], @@ -1743,8 +1826,10 @@ def test_release_stage(self) -> None: "environment": self.environment.name, }, project_id=self.project.id, - ).group.id - adopted_release_g_2 = self.store_event( + ) + assert adopted_release_g_1_event.group is not None + adopted_release_g_1 = adopted_release_g_1_event.group.id + adopted_release_g_2_event = self.store_event( data={ "timestamp": before_now(minutes=2).isoformat(), "fingerprint": ["group-2"], @@ -1752,8 +1837,10 @@ def test_release_stage(self) -> None: "environment": self.environment.name, }, project_id=self.project.id, - ).group.id - replaced_release_g_1 = self.store_event( + ) + assert adopted_release_g_2_event.group is not None + adopted_release_g_2 = adopted_release_g_2_event.group.id + replaced_release_g_1_event = self.store_event( data={ "timestamp": before_now(minutes=3).isoformat(), "fingerprint": ["group-3"], @@ -1761,8 +1848,10 @@ def test_release_stage(self) -> None: "environment": self.environment.name, }, project_id=self.project.id, - ).group.id - replaced_release_g_2 = self.store_event( + ) + assert replaced_release_g_1_event.group is not None + replaced_release_g_1 = replaced_release_g_1_event.group.id + replaced_release_g_2_event = self.store_event( data={ "timestamp": before_now(minutes=4).isoformat(), "fingerprint": ["group-4"], @@ -1770,7 +1859,9 @@ def test_release_stage(self) -> None: "environment": self.environment.name, }, project_id=self.project.id, - ).group.id + ) + assert replaced_release_g_2_event.group is not None + replaced_release_g_2 = replaced_release_g_2_event.group.id self.login_as(user=self.user) response = self.get_response( @@ -1829,30 +1920,36 @@ def test_semver_package(self) -> None: release_1 = self.create_release(version="test@1.2.3") release_2 = self.create_release(version="test2@1.2.4") - release_1_g_1 = self.store_event( + release_1_g_1_event = self.store_event( data={ "timestamp": before_now(minutes=1).isoformat(), "fingerprint": ["group-1"], "release": release_1.version, }, project_id=self.project.id, - ).group.id - release_1_g_2 = self.store_event( + ) + assert release_1_g_1_event.group is not None + release_1_g_1 = release_1_g_1_event.group.id + release_1_g_2_event = self.store_event( data={ "timestamp": before_now(minutes=2).isoformat(), "fingerprint": ["group-2"], "release": release_1.version, }, project_id=self.project.id, - ).group.id - release_2_g_1 = self.store_event( + ) + assert release_1_g_2_event.group is not None + release_1_g_2 = release_1_g_2_event.group.id + release_2_g_1_event = self.store_event( data={ "timestamp": before_now(minutes=3).isoformat(), "fingerprint": ["group-3"], "release": release_2.version, }, project_id=self.project.id, - ).group.id + ) + assert release_2_g_1_event.group is not None + release_2_g_1 = release_2_g_1_event.group.id self.login_as(user=self.user) response = self.get_response(sort_by="date", limit=10, query=f"{SEMVER_PACKAGE_ALIAS}:test") assert response.status_code == 200, response.content @@ -1873,30 +1970,36 @@ def test_semver_build(self) -> None: release_1 = self.create_release(version="test@1.2.3+123") release_2 = self.create_release(version="test2@1.2.4+124") - release_1_g_1 = self.store_event( + release_1_g_1_event = self.store_event( data={ "timestamp": before_now(minutes=1).isoformat(), "fingerprint": ["group-1"], "release": release_1.version, }, project_id=self.project.id, - ).group.id - release_1_g_2 = self.store_event( + ) + assert release_1_g_1_event.group is not None + release_1_g_1 = release_1_g_1_event.group.id + release_1_g_2_event = self.store_event( data={ "timestamp": before_now(minutes=2).isoformat(), "fingerprint": ["group-2"], "release": release_1.version, }, project_id=self.project.id, - ).group.id - release_2_g_1 = self.store_event( + ) + assert release_1_g_2_event.group is not None + release_1_g_2 = release_1_g_2_event.group.id + release_2_g_1_event = self.store_event( data={ "timestamp": before_now(minutes=3).isoformat(), "fingerprint": ["group-3"], "release": release_2.version, }, project_id=self.project.id, - ).group.id + ) + assert release_2_g_1_event.group is not None + release_2_g_1 = release_2_g_1_event.group.id self.login_as(user=self.user) response = self.get_response(sort_by="date", limit=10, query=f"{SEMVER_BUILD_ALIAS}:123") assert response.status_code == 200, response.content @@ -1933,6 +2036,7 @@ def test_skipped_fields(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None self.store_event( data={ "timestamp": before_now(seconds=200).isoformat(), @@ -1960,6 +2064,7 @@ def test_inbox_fields(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None add_group_to_inbox(event.group, GroupInboxReason.NEW) query = "status:unresolved" self.login_as(user=self.user) @@ -1994,6 +2099,7 @@ def test_inbox_fields_issue_states(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None add_group_to_inbox(event.group, GroupInboxReason.NEW) query = "status:unresolved" self.login_as(user=self.user) @@ -2026,6 +2132,7 @@ def test_expand_string(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None add_group_to_inbox(event.group, GroupInboxReason.NEW) query = "status:unresolved" self.login_as(user=self.user) @@ -2042,6 +2149,7 @@ def test_expand_integration_issues(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None query = "status:unresolved" self.login_as(user=self.user) response = self.get_response( @@ -2101,8 +2209,12 @@ def test_expand_integration_issues_resolves_group_links_in_two_batched_queries( ) for index in range(3) ] + event_0, event_1, event_2 = events + assert event_0.group is not None + assert event_1.group is not None + assert event_2.group is not None integration = self.create_integration( - organization=events[0].group.organization, + organization=event_0.group.organization, provider="jira", external_id="jira_external_id", name="Jira", @@ -2116,7 +2228,7 @@ def test_expand_integration_issues_resolves_group_links_in_two_batched_queries( title=f"jira issue {index}", description="this is an example description", ) - for index, event in enumerate(events[:2]) + for index, event in enumerate([event_0, event_1]) ] self.login_as(user=self.user) @@ -2131,9 +2243,9 @@ def test_expand_integration_issues_resolves_group_links_in_two_batched_queries( assert response.status_code == 200 issues_by_group = {int(group["id"]): group["integrationIssues"] for group in response.data} - assert issues_by_group[events[0].group.id][0]["title"] == issues[0].title - assert issues_by_group[events[1].group.id][0]["title"] == issues[1].title - assert issues_by_group[events[2].group.id] == [] + assert issues_by_group[event_0.group.id][0]["title"] == issues[0].title + assert issues_by_group[event_1.group.id][0]["title"] == issues[1].title + assert issues_by_group[event_2.group.id] == [] assert len([query for query in queries if "sentry_grouplink" in query["sql"]]) == 1 assert len([query for query in queries if "sentry_externalissue" in query["sql"]]) == 1 @@ -2148,6 +2260,10 @@ def test_integration_annotations_batch_queries_across_integrations(self) -> None ) for index in range(3) ] + event_0, event_1, event_2 = events + assert event_0.group is not None + assert event_1.group is not None + assert event_2.group is not None jira_a = self.create_integration( organization=self.organization, provider="jira", @@ -2161,11 +2277,11 @@ def test_integration_annotations_batch_queries_across_integrations(self) -> None metadata={"base_url": "https://b.example.com"}, ) shared_issue = self.create_integration_external_issue( - group=events[0].group, integration=jira_a, key="A-1" + group=event_0.group, integration=jira_a, key="A-1" ) - self.create_integration_external_issue(group=events[0].group, integration=jira_b, key="B-1") + self.create_integration_external_issue(group=event_0.group, integration=jira_b, key="B-1") self.create_group_link( - group=events[1].group, + group=event_1.group, linked_id=shared_issue.id, linked_type=GroupLink.LinkedType.issue, ) @@ -2176,14 +2292,14 @@ def test_integration_annotations_batch_queries_across_integrations(self) -> None assert response.status_code == 200 annotations_by_group = {int(group["id"]): group["annotations"] for group in response.data} - assert sorted(annotations_by_group[events[0].group.id], key=lambda a: a["url"]) == [ + assert sorted(annotations_by_group[event_0.group.id], key=lambda a: a["url"]) == [ {"url": "https://a.example.com/browse/A-1", "displayName": "A-1"}, {"url": "https://b.example.com/browse/B-1", "displayName": "B-1"}, ] - assert annotations_by_group[events[1].group.id] == [ + assert annotations_by_group[event_1.group.id] == [ {"url": "https://a.example.com/browse/A-1", "displayName": "A-1"}, ] - assert annotations_by_group[events[2].group.id] == [] + assert annotations_by_group[event_2.group.id] == [] assert len([query for query in queries if "sentry_grouplink" in query["sql"]]) == 1 assert len([query for query in queries if "sentry_externalissue" in query["sql"]]) == 1 @@ -2198,27 +2314,30 @@ def test_expand_integration_issues_only_associates_issue_type_group_links(self) ) for index in range(2) ] + event_0, event_1 = events + assert event_0.group is not None + assert event_1.group is not None integration = self.create_integration( - organization=events[0].group.organization, + organization=event_0.group.organization, provider="jira", external_id="jira_external_id", name="Jira", metadata={"base_url": "https://example.com", "domain_name": "test/"}, ) issue = self.create_integration_external_issue( - group=events[0].group, + group=event_0.group, integration=integration, key="APP-1", title="jira issue", description="this is an example description", ) self.create_group_link( - group=events[0].group, + group=event_0.group, linked_id=issue.id, linked_type=GroupLink.LinkedType.commit, ) self.create_group_link( - group=events[1].group, + group=event_1.group, linked_id=issue.id, linked_type=GroupLink.LinkedType.commit, ) @@ -2234,15 +2353,16 @@ def test_expand_integration_issues_only_associates_issue_type_group_links(self) assert response.status_code == 200 issues_by_group = {int(group["id"]): group["integrationIssues"] for group in response.data} - assert len(issues_by_group[events[0].group.id]) == 1 - assert issues_by_group[events[0].group.id][0]["title"] == issue.title - assert issues_by_group[events[1].group.id] == [] + assert len(issues_by_group[event_0.group.id]) == 1 + assert issues_by_group[event_0.group.id][0]["title"] == issue.title + assert issues_by_group[event_1.group.id] == [] def test_expand_sentry_app_issues(self) -> None: event = self.store_event( data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None query = "status:unresolved" self.login_as(user=self.user) response = self.get_response( @@ -2303,15 +2423,19 @@ def test_expand_sentry_app_issues_loads_direct_group_relations_in_one_query(self ) for index in range(3) ] + event_0, event_1, event_2 = events + assert event_0.group is not None + assert event_1.group is not None + assert event_2.group is not None issues = [ PlatformExternalIssue.objects.create( - group_id=event.group.id, - project_id=event.group.project.id, + group_id=group.id, + project_id=group.project.id, service_type=f"sentry-app-{index}", display_name=f"App#issue-{index}", web_url=f"https://example.com/app/issues/{index}", ) - for index, event in enumerate(events[:2]) + for index, group in enumerate((event_0.group, event_1.group)) ] self.login_as(user=self.user) @@ -2326,9 +2450,9 @@ def test_expand_sentry_app_issues_loads_direct_group_relations_in_one_query(self assert response.status_code == 200 issues_by_group = {int(group["id"]): group["sentryAppIssues"] for group in response.data} - assert issues_by_group[events[0].group.id][0]["displayName"] == issues[0].display_name - assert issues_by_group[events[1].group.id][0]["displayName"] == issues[1].display_name - assert issues_by_group[events[2].group.id] == [] + assert issues_by_group[event_0.group.id][0]["displayName"] == issues[0].display_name + assert issues_by_group[event_1.group.id][0]["displayName"] == issues[1].display_name + assert issues_by_group[event_2.group.id] == [] platform_issue_queries = [ query for query in queries if "sentry_platformexternalissue" in query["sql"] ] @@ -2340,6 +2464,7 @@ def test_expand_latest_event_has_attachments(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None query = "status:unresolved" self.login_as(user=self.user) response = self.get_response( @@ -2389,10 +2514,14 @@ def test_expand_latest_event_has_attachments_is_batched( ) for index in range(3) ] + event_0, event_1, event_2 = events + assert event_0.group is not None + assert event_1.group is not None + assert event_2.group is not None EventAttachment.objects.create( - group_id=events[0].group.id, - event_id=events[0].event_id, - project_id=events[0].project_id, + group_id=event_0.group.id, + event_id=event_0.event_id, + project_id=event_0.project_id, name="hello.png", content_type="image/png", ) @@ -2411,9 +2540,9 @@ def test_expand_latest_event_has_attachments_is_batched( int(group["id"]): group["latestEventHasAttachments"] for group in response.data } assert attachments_by_group == { - events[0].group.id: True, - events[1].group.id: False, - events[2].group.id: False, + event_0.group.id: True, + event_1.group.id: False, + event_2.group.id: False, } attachment_queries = [ query for query in queries if "sentry_eventattachment" in query["sql"] @@ -2437,6 +2566,7 @@ def test_expand_latest_event_attachments_match_project_and_event(self) -> None: }, project_id=self.project.id, ) + assert old_event.group is not None latest_first_project_event = self.store_event( data={ "event_id": "a" * 32, @@ -2445,6 +2575,7 @@ def test_expand_latest_event_attachments_match_project_and_event(self) -> None: }, project_id=self.project.id, ) + assert latest_first_project_event.group is not None second_project_event = self.store_event( data={ "event_id": shared_event_id, @@ -2453,6 +2584,7 @@ def test_expand_latest_event_attachments_match_project_and_event(self) -> None: }, project_id=other_project.id, ) + assert second_project_event.group is not None EventAttachment.objects.create( group_id=old_event.group.id, event_id=old_event.event_id, @@ -2501,6 +2633,7 @@ def test_expand_owners(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None query = "status:unresolved" self.login_as(user=self.user) # Test with no owner @@ -2562,10 +2695,12 @@ def test_default_search(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event1.group is not None event2 = self.store_event( data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-2"]}, project_id=self.project.id, ) + assert event2.group is not None event2.group.update(status=GroupStatus.RESOLVED, substatus=None) self.login_as(user=self.user) @@ -2578,6 +2713,7 @@ def test_collapse_stats(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None self.login_as(user=self.user) response = self.get_response( sort_by="date", limit=10, query="is:unresolved", expand="inbox", collapse="stats" @@ -2598,6 +2734,7 @@ def test_collapse_lifetime(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None self.login_as(user=self.user) response = self.get_response( sort_by="date", limit=10, query="is:unresolved", collapse="lifetime" @@ -2617,6 +2754,7 @@ def test_collapse_filtered(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None self.login_as(user=self.user) response = self.get_response( sort_by="date", limit=10, query="is:unresolved", collapse="filtered" @@ -2636,6 +2774,7 @@ def test_collapse_lifetime_and_filtered(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None self.login_as(user=self.user) response = self.get_response( sort_by="date", limit=10, query="is:unresolved", collapse=["filtered", "lifetime"] @@ -2655,6 +2794,7 @@ def test_collapse_base(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None self.login_as(user=self.user) response = self.get_response( sort_by="date", limit=10, query="is:unresolved", collapse=["base"] @@ -2679,6 +2819,7 @@ def test_collapse_stats_group_snooze_bug(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None GroupSnooze.objects.create( group=event.group, user_count=10, @@ -2700,6 +2841,7 @@ def test_collapse_unhandled(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None self.login_as(user=self.user) response = self.get_response( sort_by="date", limit=10, query="is:unresolved", collapse=["unhandled"] @@ -2714,6 +2856,7 @@ def test_query_status_and_substatus_overlapping(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None event.group.update(status=GroupStatus.UNRESOLVED, substatus=GroupSubStatus.ONGOING) self.login_as(user=self.user) @@ -2768,6 +2911,7 @@ def test_query_status_and_substatus_nonoverlapping(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None event.group.update(status=GroupStatus.UNRESOLVED, substatus=GroupSubStatus.ONGOING) self.login_as(user=self.user) @@ -2819,12 +2963,15 @@ def test_query_detector_filter(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None group = event.group + assert group is not None event2 = self.store_event( data={"timestamp": before_now(seconds=400).isoformat(), "fingerprint": ["group-2"]}, project_id=self.project.id, ) + assert event2.group is not None assert event2.group.id != group.id detector_id = 12345 # intentionally multi-digit @@ -2859,12 +3006,15 @@ def test_query_monitor_filter(self) -> None: data={"timestamp": before_now(seconds=500).isoformat(), "fingerprint": ["group-1"]}, project_id=self.project.id, ) + assert event.group is not None group = event.group + assert group is not None event2 = self.store_event( data={"timestamp": before_now(seconds=400).isoformat(), "fingerprint": ["group-2"]}, project_id=self.project.id, ) + assert event2.group is not None assert event2.group.id != group.id detector_id = 12345 # intentionally multi-digit @@ -2957,7 +3107,9 @@ def test_filter_by_bookmarked_by(self) -> None: }, project_id=project.id, ) + assert event1.group is not None group1 = event1.group + assert group1 is not None GroupBookmark.objects.create(user_id=self.user.id, group=group1, project_id=project.id) event2 = self.store_event( @@ -2968,7 +3120,9 @@ def test_filter_by_bookmarked_by(self) -> None: }, project_id=project.id, ) + assert event2.group is not None group2 = event2.group + assert group2 is not None GroupBookmark.objects.create(user_id=user2.id, group=group2, project_id=project.id) # Filter by bookmarked_by the first user @@ -2994,7 +3148,9 @@ def test_filter_by_linked(self) -> None: }, project_id=project.id, ) + assert event1.group is not None group1 = event1.group + assert group1 is not None GroupLink.objects.create( group_id=group1.id, project=project, @@ -3009,7 +3165,9 @@ def test_filter_by_linked(self) -> None: }, project_id=project.id, ) + assert event2.group is not None group2 = event2.group + assert group2 is not None # Filter by linked issues response = self.get_success_response(query="is:linked") @@ -3034,7 +3192,9 @@ def test_filter_by_subscribed_by(self) -> None: }, project_id=project.id, ) + assert event1.group is not None group1 = event1.group + assert group1 is not None GroupSubscription.objects.create( user_id=self.user.id, group=group1, @@ -3074,6 +3234,7 @@ def test_lookup_by_release_build(self) -> None: }, project_id=project.id, ) + assert event.group is not None response = self.get_success_response(query="release.build:123") issues = json.loads(response.content) @@ -3105,6 +3266,7 @@ def test_error_main_thread_condition(self) -> None: }, project_id=project.id, ) + assert event1.group is not None # Simulate sending an event with main_thread set to false event2 = self.store_event( data={ @@ -3123,6 +3285,7 @@ def test_error_main_thread_condition(self) -> None: }, project_id=project.id, ) + assert event2.group is not None # Query for events where main_thread is true response = self.get_success_response(query="error.main_thread:true") @@ -3428,10 +3591,12 @@ def test_bulk_resolve_with_stats_period(self) -> None: data={"fingerprint": ["older"], "timestamp": before_now(days=2).isoformat()}, project_id=self.project.id, ).group + assert older_group is not None recent_group = self.store_event( data={"fingerprint": ["recent"], "timestamp": before_now(minutes=30).isoformat()}, project_id=self.project.id, ).group + assert recent_group is not None self.login_as(user=self.user) self.get_success_response(qs_params={"query": "", "statsPeriod": "1h"}, status="resolved") @@ -3453,7 +3618,9 @@ def test_resolve_with_integration(self, mock_sync_status_outbound: MagicMock) -> event = self.store_event( data={"timestamp": self.min_ago.isoformat()}, project_id=self.project.id ) + assert event.group is not None group = event.group + assert group is not None with assume_test_silo_mode(SiloMode.CONTROL): OrganizationIntegration.objects.filter( @@ -3639,6 +3806,7 @@ def test_in_semver_projects_group_resolution_stores_current_release_version(self }, project_id=self.project.id, ).group + assert group is not None self.login_as(user=self.user) @@ -3651,6 +3819,7 @@ def test_in_semver_projects_group_resolution_stores_current_release_version(self # The current_release_version should be to the latest (in semver) release associated with # a group grp_resolution = GroupResolution.objects.get(group=group) + assert grp_resolution.group is not None assert grp_resolution.current_release_version == release_21_1_2.version @@ -3697,6 +3866,7 @@ def test_in_non_semver_projects_group_resolution_stores_current_release_version( }, project_id=self.project.id, ).group + assert group is not None self.login_as(user=self.user) @@ -3746,6 +3916,7 @@ def test_in_non_semver_projects_store_actual_current_release_version_not_cached_ }, project_id=self.project.id, ).group + assert group is not None # Call this function to cache the `last_seen` release to release_1 # i.e. Set the first last observed by Sentry @@ -3804,6 +3975,7 @@ def test_in_non_semver_projects_resolved_in_next_release_is_equated_to_in_releas }, project_id=self.project.id, ).group + assert group is not None self.login_as(user=self.user) @@ -3814,6 +3986,7 @@ def test_in_non_semver_projects_resolved_in_next_release_is_equated_to_in_releas assert response.data["statusDetails"]["inNextRelease"] grp_resolution = GroupResolution.objects.get(group=group) + assert grp_resolution.group is not None assert grp_resolution.current_release_version == release_1.version assert grp_resolution.release.id == release_2.id @@ -3952,6 +4125,7 @@ def test_in_semver_projects_set_resolved_in_explicit_release(self) -> None: }, project_id=self.project.id, ).group + assert group is not None self.login_as(user=self.user) @@ -4297,6 +4471,7 @@ def test_snooze_user_count(self) -> None: }, project_id=self.project.id, ) + assert event.group is not None assert event.group is not None group = Group.objects.get(id=event.group.id) @@ -4646,9 +4821,9 @@ def test_discard(self) -> None: assert not Group.objects.filter(id=group1.id).exists() assert Group.objects.filter(id=group2.id).exists() assert GroupHash.objects.filter(id=group_hash.id).exists() - tombstone = GroupTombstone.objects.get( - id=GroupHash.objects.get(id=group_hash.id).group_tombstone_id - ) + group_tombstone_id = GroupHash.objects.get(id=group_hash.id).group_tombstone_id + assert group_tombstone_id is not None + tombstone = GroupTombstone.objects.get(id=group_tombstone_id) assert tombstone.message == group1.message assert tombstone.culprit == group1.culprit assert tombstone.project == group1.project @@ -4885,14 +5060,17 @@ def test_bulk_delete_with_date_range(self) -> None: data={"fingerprint": ["older"], "timestamp": before_now(hours=3).isoformat()}, project_id=self.project.id, ).group + assert older_group is not None matching_group = self.store_event( data={"fingerprint": ["matching"], "timestamp": before_now(minutes=90).isoformat()}, project_id=self.project.id, ).group + assert matching_group is not None newer_group = self.store_event( data={"fingerprint": ["newer"], "timestamp": before_now(minutes=30).isoformat()}, project_id=self.project.id, ).group + assert newer_group is not None self.login_as(user=self.user) with self.tasks(): diff --git a/tests/sentry/issues/endpoints/test_project_codeowners_index.py b/tests/sentry/issues/endpoints/test_project_codeowners_index.py index 710c672124d5..cb9e951bb3e0 100644 --- a/tests/sentry/issues/endpoints/test_project_codeowners_index.py +++ b/tests/sentry/issues/endpoints/test_project_codeowners_index.py @@ -63,7 +63,9 @@ def test_get_codeowners_with_integration_post_creation( get_resp = self.client.get(self.url) assert get_resp.status_code == 200 resp_data = get_resp.data[0] - assert resp_data["raw"] == self.data["raw"].strip() + raw_codeowners = self.data["raw"] + assert isinstance(raw_codeowners, str) + assert resp_data["raw"] == raw_codeowners.strip() assert resp_data["codeMappingId"] == str(self.code_mapping.id) assert resp_data["provider"] == self.integration.provider assert resp_data["codeOwnersUrl"] == "https://github.com/test/CODEOWNERS" diff --git a/tests/sentry/issues/endpoints/test_related_issues.py b/tests/sentry/issues/endpoints/test_related_issues.py index e17050460d74..9aaf053bc298 100644 --- a/tests/sentry/issues/endpoints/test_related_issues.py +++ b/tests/sentry/issues/endpoints/test_related_issues.py @@ -44,7 +44,7 @@ def test_same_root_related_issues(self) -> None: # The UI will then make normal calls to get issues-stats # For instance, this URL # https://us.sentry.io/api/0/organizations/sentry/issues-stats/?groups=4741828952&groups=4489703641&statsPeriod=24h - assert response.json() == {"type": "same_root_cause", "data": [related.id], "meta": {}} + assert response.data == {"type": "same_root_cause", "data": [related.id], "meta": {}} def test_trace_connected_errors(self) -> None: error_event, _, another_proj_event = self.load_errors(self.project) @@ -63,7 +63,7 @@ def test_trace_connected_errors(self) -> None: # This sets the group_id to the one we want to query about self.group_id = error_event.group_id response = self.get_success_response(qs_params={"type": "trace_connected"}) - assert response.json() == { + assert response.data == { "type": "trace_connected", # This is the other issue in the trace that it is not itself "data": [another_proj_event.group_id], @@ -85,7 +85,7 @@ def test_trace_connected_errors_specific_event(self) -> None: "project_id": another_proj_event.project_id, } ) - assert response.json() == { + assert response.data == { "type": "trace_connected", # This is the other issue in the trace that it is not itself "data": [error_event.group_id], @@ -112,7 +112,7 @@ def test_trace_connected_excludes_projects_member_cannot_access(self) -> None: self.group_id = error_event.group_id response = self.get_success_response(qs_params={"type": "trace_connected"}) - assert response.json() == { + assert response.data == { "type": "trace_connected", # The connected issue lives in a project the member has no access to "data": [], @@ -137,7 +137,7 @@ def test_trace_connected_open_membership_spans_projects(self) -> None: self.group_id = error_event.group_id response = self.get_success_response(qs_params={"type": "trace_connected"}) - assert response.json() == { + assert response.data == { "type": "trace_connected", # Open membership grants access to the whole organization, including the # project the member has no team membership in diff --git a/tests/sentry/issues/escalating/test_issue_velocity.py b/tests/sentry/issues/escalating/test_issue_velocity.py index f607d07ad82a..fa8e26ca4c32 100644 --- a/tests/sentry/issues/escalating/test_issue_velocity.py +++ b/tests/sentry/issues/escalating/test_issue_velocity.py @@ -239,7 +239,7 @@ def test_update_threshold_simple(self, mock_calculation: MagicMock) -> None: Tests that we save the newly calculated threshold at the default TTL and return it. """ mock_calculation.return_value = 5 - threshold = update_threshold(self.project.id, "threshold-key", "date-key") + threshold = update_threshold(self.project, "threshold-key", "date-key") assert threshold == 5 redis_client = get_redis_client() assert redis_client.get("threshold-key") == "5" diff --git a/tests/sentry/issues/test_action_log.py b/tests/sentry/issues/test_action_log.py index 76faf8c00794..b1a15732caf9 100644 --- a/tests/sentry/issues/test_action_log.py +++ b/tests/sentry/issues/test_action_log.py @@ -190,12 +190,14 @@ def test_legacy_api_key_is_org_actor(self) -> None: def test_sentry_app_token_resolves_to_app(self) -> None: sentry_app = self.create_sentry_app(organization=self.organization) + proxy_user = sentry_app.proxy_user + assert proxy_user is not None auth = AuthenticatedToken( kind="api_token", application_id=sentry_app.application_id, - user_id=sentry_app.proxy_user.id, + user_id=proxy_user.id, ) - request = self._request(auth=auth, user=sentry_app.proxy_user) + request = self._request(auth=auth, user=proxy_user) assert resolve_action_actor(request) == GroupActionActor.sentry_app(sentry_app.id) def test_user_oauth_token_with_application_id_is_user(self) -> None: diff --git a/tests/sentry/issues/test_attributes.py b/tests/sentry/issues/test_attributes.py index 09757a6dad55..97617b3b27b9 100644 --- a/tests/sentry/issues/test_attributes.py +++ b/tests/sentry/issues/test_attributes.py @@ -1,5 +1,6 @@ from collections.abc import Sequence from datetime import timedelta +from typing import Any from unittest.mock import patch import pytest @@ -32,6 +33,8 @@ def test_bulk_retrieve_group_values(self) -> None: first_seen=timezone.now() - timedelta(days=5), num_comments=50, ) + assert group.num_comments is not None + assert group_2.num_comments is not None assert _bulk_retrieve_group_values([group.id, group_2.id]) == [ GroupValues( id=group.id, @@ -153,7 +156,7 @@ def run_attr_test(self, group: Group, update_fields: Sequence[str], expected_str ) as _log_group_attributes_changed, patch("sentry.issues.attributes.send_snapshot_values") as send_snapshot_values, ): - kwargs = {} + kwargs: dict[str, Any] = {} if update_fields: kwargs["update_fields"] = update_fields self.group.save(**kwargs) diff --git a/tests/sentry/issues/test_ingest.py b/tests/sentry/issues/test_ingest.py index 6e7a18392194..b964185ee3be 100644 --- a/tests/sentry/issues/test_ingest.py +++ b/tests/sentry/issues/test_ingest.py @@ -1,8 +1,9 @@ from collections import namedtuple +from collections.abc import Sequence from dataclasses import dataclass from datetime import UTC, datetime, timedelta from hashlib import md5 -from typing import Any +from typing import Any, cast from unittest import mock from unittest.mock import patch @@ -76,7 +77,11 @@ def test(self) -> None: end=now + timedelta(days=1), selected_columns=["event_id", "group_id", "occurrence_id"], groupby=None, - filter_keys={"project_id": [self.project.id], "event_id": [event.event_id]}, + # justification: snuba types filter_keys as int sequences, but event_id keys are strings. + filter_keys={ + "project_id": [self.project.id], + "event_id": cast("Sequence[int]", [event.event_id]), + }, tenant_ids={"referrer": "r", "organization_id": 1}, ) assert len(result["data"]) == 1 diff --git a/tests/sentry/issues/test_occurrence_consumer.py b/tests/sentry/issues/test_occurrence_consumer.py index c44f6014b5b8..4669a67c77dd 100644 --- a/tests/sentry/issues/test_occurrence_consumer.py +++ b/tests/sentry/issues/test_occurrence_consumer.py @@ -642,6 +642,7 @@ def test_status_change(self) -> None: project_id=self.project.id, ) group = event.group + assert group is not None assert group.status == GroupStatus.UNRESOLVED status = GroupStatus.RESOLVED diff --git a/tests/sentry/issues/test_producer.py b/tests/sentry/issues/test_producer.py index 1238294521a0..e7c54593f01d 100644 --- a/tests/sentry/issues/test_producer.py +++ b/tests/sentry/issues/test_producer.py @@ -161,8 +161,8 @@ def setUp(self) -> None: }, project_id=self.project.id, ) + assert self.event.group is not None self.group = self.event.group - assert self.group self.group.substatus = GroupSubStatus.ONGOING self.group.save() diff --git a/tests/sentry/issues/test_utils.py b/tests/sentry/issues/test_utils.py index c568432f1581..4a4431000578 100644 --- a/tests/sentry/issues/test_utils.py +++ b/tests/sentry/issues/test_utils.py @@ -3,7 +3,7 @@ import uuid from collections.abc import Sequence from datetime import datetime, timedelta -from typing import Any +from typing import Any, cast from django.utils import timezone @@ -172,7 +172,11 @@ def store_search_issue( "timestamp", ], groupby=None, - filter_keys={"project_id": [project_id], "event_id": [event.event_id]}, + # justification: snuba types filter_keys as int sequences, but event_id keys are strings. + filter_keys={ + "project_id": [project_id], + "event_id": cast("Sequence[int]", [event.event_id]), + }, referrer="test_utils.store_search_issue", tenant_ids={"referrer": "test_utils.store_search_issue", "organization_id": 1}, ) diff --git a/tests/sentry/lang/java/test_exceptions.py b/tests/sentry/lang/java/test_exceptions.py index e9c551fde8a1..4d376c308e7c 100644 --- a/tests/sentry/lang/java/test_exceptions.py +++ b/tests/sentry/lang/java/test_exceptions.py @@ -1,10 +1,11 @@ import re +from collections.abc import Mapping, Sequence from typing import Any from sentry.lang.java.exceptions import Exceptions -def build_event(exceptions_values): +def build_event(exceptions_values: Sequence[Mapping[str, object]]) -> dict[str, object]: return {"exception": {"values": exceptions_values}} @@ -365,7 +366,7 @@ def test_deobfuscate_is_noop_when_no_classes_mapping() -> None: # Empty mapping excs = Exceptions(build_event([{"value": original}])) - exc = excs.get_processable_exceptions_with_values()[0][0] + processable_exc = excs.get_processable_exceptions_with_values()[0][0] excs.deobfuscate_and_save({}, mapped_exceptions=[]) - assert exc["value"] == original - assert "raw_value" not in exc + assert processable_exc["value"] == original + assert "raw_value" not in processable_exc diff --git a/tests/sentry/lang/javascript/test_cache.py b/tests/sentry/lang/javascript/test_cache.py index ff726846395b..a9a712a157ef 100644 --- a/tests/sentry/lang/javascript/test_cache.py +++ b/tests/sentry/lang/javascript/test_cache.py @@ -13,11 +13,15 @@ def test_basic_features(self) -> None: cache.add(url, b"foo\nbar") assert url in cache - assert cache.get(url)[0] == "foo" + view = cache.get(url) + assert view is not None + assert view[0] == "foo" cache.alias(url + "x", url) assert url + "x" in cache - assert cache.get(url + "x")[0] == "foo" + view = cache.get(url + "x") + assert view is not None + assert view[0] == "foo" def test_encoding_fallback(self) -> None: cache = SourceCache() @@ -25,7 +29,9 @@ def test_encoding_fallback(self) -> None: # fall back to utf-8 cache.add(url, b"foobar", encoding="utf-32") - assert cache.get(url)[0] == "foobar" + view = cache.get(url) + assert view is not None + assert view[0] == "foobar" def test_encoding_support(self) -> None: cache = SourceCache() @@ -33,4 +39,6 @@ def test_encoding_support(self) -> None: # fall back to utf-8 cache.add(url, "foobar".encode("utf-32"), encoding="utf-32") - assert cache.get(url)[0] == "foobar" + view = cache.get(url) + assert view is not None + assert view[0] == "foobar" diff --git a/tests/sentry/lang/javascript/test_processing.py b/tests/sentry/lang/javascript/test_processing.py index bb8e24742e73..ba959c391066 100644 --- a/tests/sentry/lang/javascript/test_processing.py +++ b/tests/sentry/lang/javascript/test_processing.py @@ -122,7 +122,9 @@ def test_node_modules_regex(self) -> None: self.assertIsNone(NODE_MODULES_RE.search("node_modules")) self.assertIsNone(NODE_MODULES_RE.search("mynode_modules")) - def _get_test_data_and_symbolicator(self, in_app_frames: bool, symbolicated_in_app=True): + def _get_test_data_and_symbolicator( + self, in_app_frames: bool, symbolicated_in_app: bool = True + ) -> tuple[dict[str, object], Mock]: """Helper method to create test data and mock symbolicator Args: @@ -221,7 +223,7 @@ def _get_test_data_and_symbolicator(self, in_app_frames: bool, symbolicated_in_a }, ] - data = { + data: dict[str, object] = { "platform": "javascript", "exception": { "values": [ diff --git a/tests/sentry/lang/native/test_applecrashreport.py b/tests/sentry/lang/native/test_applecrashreport.py index 986266031f27..8984b6833e16 100644 --- a/tests/sentry/lang/native/test_applecrashreport.py +++ b/tests/sentry/lang/native/test_applecrashreport.py @@ -2,7 +2,7 @@ from sentry.lang.native.applecrashreport import AppleCrashReport -def _make_acr_with_context(): +def _make_acr_with_context() -> AppleCrashReport: return AppleCrashReport( context={ "device": { @@ -190,7 +190,7 @@ def test_get_threads_apple_string_deduplicates_exception_thread() -> None: ) -def _thread(thread_id): +def _thread(thread_id: int) -> dict[str, object]: return { "id": thread_id, "stacktrace": { @@ -206,7 +206,7 @@ def _thread(thread_id): } -def _thread_order(output): +def _thread_order(output: str) -> list[int]: return [int(line.split()[1]) for line in output.splitlines() if line.startswith("Thread ")] diff --git a/tests/sentry/lang/native/test_processing.py b/tests/sentry/lang/native/test_processing.py index 27dc23dec259..945ad0ab4201 100644 --- a/tests/sentry/lang/native/test_processing.py +++ b/tests/sentry/lang/native/test_processing.py @@ -30,10 +30,12 @@ _merge_image, get_frames_for_symbolication, get_native_symbolication_functions, + get_required_attachment_types, process_native_stacktraces, ) from sentry.lang.native.symbolicator import SymbolicatorFunction from sentry.models.eventerror import EventErrorType +from sentry.models.project import Project from sentry.objectstore import UsecaseId, get_session from sentry.stacktraces.processing import find_stacktraces_in_data from sentry.testutils.helpers.features import Feature @@ -58,6 +60,24 @@ "stacktrace": {"frames": [{"instruction_addr": "0x2a2a3d"}]}, } +FlamegraphPayload = dict[str, Any] # justification: decoded JSON flamegraph payload. +EventData = dict[str, Any] # justification: event payloads are decoded JSON. +CachedFlamegraph = tuple[EventData, FlamegraphPayload, mock.Mock] +MultipleFlamegraphs = tuple[EventData, FlamegraphPayload, FlamegraphPayload, mock.Mock] + + +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"), @@ -112,7 +132,7 @@ def test_get_native_symbolication_functions( @django_db_all -def test_process_native_flamegraph(cached_flamegraph) -> None: +def test_process_native_flamegraph(cached_flamegraph: CachedFlamegraph) -> None: data, _, symbolicator = cached_flamegraph payload: dict[str, Any] = { "version": "1", @@ -223,7 +243,7 @@ def test_process_native_flamegraph(cached_flamegraph) -> None: @django_db_all -def test_flamegraph_symbolication_is_idempotent(cached_flamegraph) -> None: +def test_flamegraph_symbolication_is_idempotent(cached_flamegraph: CachedFlamegraph) -> None: data, _, symbolicator = cached_flamegraph with Feature({"organizations:flamegraph-attachments": True}): process_native_stacktraces(symbolicator, data) @@ -239,7 +259,7 @@ def test_route_flamegraph_to_native_symbolication() -> None: @pytest.fixture -def flamegraph_payload(): +def flamegraph_payload() -> FlamegraphPayload: return { "version": "1", "platform": "cocoa", @@ -266,7 +286,9 @@ def flamegraph_payload(): ], ], ) -def test_flamegraph_indexed_inline_expansion(flamegraph_payload, nodes) -> None: +def test_flamegraph_indexed_inline_expansion( + flamegraph_payload: FlamegraphPayload, nodes: list[dict[str, object]] +) -> None: flamegraph_payload["nodes"] = nodes original = deepcopy(flamegraph_payload) assert _is_valid_flamegraph(flamegraph_payload) @@ -293,7 +315,9 @@ def test_flamegraph_indexed_inline_expansion(flamegraph_payload, nodes) -> None: assert _is_valid_flamegraph(output) -def test_flamegraph_repeated_child_edges_are_not_materialized_as_paths(flamegraph_payload) -> None: +def test_flamegraph_repeated_child_edges_are_not_materialized_as_paths( + flamegraph_payload: FlamegraphPayload, +) -> None: depth = 31 flamegraph_payload["nodes"] = [ {"frame_id": 0, "sample_count": 2 ** (depth - index), "children": [index + 1, index + 1]} @@ -350,8 +374,10 @@ def test_flamegraph_repeated_child_edges_are_not_materialized_as_paths(flamegrap (("trees",), [None], False), ], ) -def test_flamegraph_validation_preserves_types(flamegraph_payload, path, value, valid) -> None: - target = flamegraph_payload +def test_flamegraph_validation_preserves_types( + flamegraph_payload: FlamegraphPayload, path: tuple[str | int, ...], value: object, valid: bool +) -> None: + target: Any = flamegraph_payload # justification: walking decoded JSON with mixed str keys and int indices. for key in path[:-1]: target = target[key] target[path[-1]] = value @@ -362,18 +388,24 @@ def test_flamegraph_validation_preserves_types(flamegraph_payload, path, value, @pytest.mark.parametrize("roots", [[], [-1], [1], [True], [0.0], [{}], "invalid"]) -def test_flamegraph_rejects_invalid_root_indices(flamegraph_payload, roots) -> None: +def test_flamegraph_rejects_invalid_root_indices( + flamegraph_payload: FlamegraphPayload, roots: object +) -> None: flamegraph_payload["trees"][0]["roots"] = roots assert not _is_valid_flamegraph(flamegraph_payload) @pytest.mark.parametrize("nodes", [[], None, {}, ["invalid"]]) -def test_flamegraph_rejects_invalid_node_tables(flamegraph_payload, nodes) -> None: +def test_flamegraph_rejects_invalid_node_tables( + flamegraph_payload: FlamegraphPayload, nodes: object +) -> None: flamegraph_payload["nodes"] = nodes assert not _is_valid_flamegraph(flamegraph_payload) -def test_flamegraph_rejects_children_exceeding_parent_count(flamegraph_payload) -> None: +def test_flamegraph_rejects_children_exceeding_parent_count( + flamegraph_payload: FlamegraphPayload, +) -> None: flamegraph_payload["nodes"] = [ {"frame_id": 0, "sample_count": 10, "children": [1, 1]}, {"frame_id": 0, "sample_count": 6}, @@ -381,13 +413,17 @@ def test_flamegraph_rejects_children_exceeding_parent_count(flamegraph_payload) assert not _is_valid_flamegraph(flamegraph_payload) -def test_flamegraph_validates_unreferenced_nodes(flamegraph_payload) -> None: +def test_flamegraph_validates_unreferenced_nodes( + flamegraph_payload: FlamegraphPayload, +) -> None: flamegraph_payload["nodes"].append({"frame_id": 1, "sample_count": 10}) assert not _is_valid_flamegraph(flamegraph_payload) @pytest.fixture -def cached_flamegraph(default_project, flamegraph_payload): +def cached_flamegraph( + default_project: Project, flamegraph_payload: FlamegraphPayload +) -> CachedFlamegraph: payload = flamegraph_payload data = { "platform": "other", @@ -426,18 +462,24 @@ def cached_flamegraph(default_project, flamegraph_payload): ("field", "value"), [("data", "invalid"), ("data", None), ("addr_mode", 1), ("platform", [])], ) -def test_flamegraph_rejects_invalid_frame_fields(flamegraph_payload, field, value) -> None: +def test_flamegraph_rejects_invalid_frame_fields( + flamegraph_payload: FlamegraphPayload, field: str, value: object +) -> None: flamegraph_payload["frames"][0][field] = value assert not _is_valid_flamegraph(flamegraph_payload) @pytest.mark.parametrize("field", ["debug_id", "id", "uuid"]) -def test_flamegraph_rejects_invalid_image_identifiers(flamegraph_payload, field) -> None: +def test_flamegraph_rejects_invalid_image_identifiers( + flamegraph_payload: FlamegraphPayload, field: str +) -> None: flamegraph_payload["debug_meta"]["images"][0][field] = [] assert not _is_valid_flamegraph(flamegraph_payload) -def test_flamegraph_inline_frames_do_not_inherit_captured_metadata(flamegraph_payload) -> None: +def test_flamegraph_inline_frames_do_not_inherit_captured_metadata( + flamegraph_payload: FlamegraphPayload, +) -> None: original = flamegraph_payload["frames"][0] original.update( filename="captured.cpp", @@ -468,7 +510,7 @@ def test_flamegraph_inline_frames_do_not_inherit_captured_metadata(flamegraph_pa @django_db_all @pytest.mark.parametrize("error", [TimeoutError(), Timeout(), ConnectionError()]) def test_flamegraph_transport_error_does_not_block_event_symbolication( - cached_flamegraph, error + cached_flamegraph: CachedFlamegraph, error: Exception ) -> None: data, payload, symbolicator = cached_flamegraph data["platform"] = "native" @@ -495,7 +537,7 @@ def test_flamegraph_transport_error_does_not_block_event_symbolication( @django_db_all @pytest.mark.parametrize("error", [MissingAttachmentChunks(), FileNotFoundError()]) def test_flamegraph_unavailable_data_does_not_block_event_symbolication( - cached_flamegraph, error + cached_flamegraph: CachedFlamegraph, error: Exception ) -> None: data, _, symbolicator = cached_flamegraph data["platform"] = "native" @@ -516,7 +558,9 @@ def test_flamegraph_unavailable_data_does_not_block_event_symbolication( @django_db_all -def test_flamegraph_invalid_frame_does_not_block_event_symbolication(cached_flamegraph) -> None: +def test_flamegraph_invalid_frame_does_not_block_event_symbolication( + cached_flamegraph: CachedFlamegraph, +) -> None: data, payload, symbolicator = cached_flamegraph payload["frames"][0]["data"] = "invalid" raw = orjson.dumps(payload) @@ -542,7 +586,7 @@ def test_flamegraph_invalid_frame_does_not_block_event_symbolication(cached_flam @django_db_all -def test_flamegraph_feature_disabled(cached_flamegraph) -> None: +def test_flamegraph_feature_disabled(cached_flamegraph: CachedFlamegraph) -> None: data, payload, symbolicator = cached_flamegraph with Feature({"organizations:flamegraph-attachments": False}): assert process_native_stacktraces(symbolicator, data) is None @@ -554,7 +598,7 @@ def test_flamegraph_feature_disabled(cached_flamegraph) -> None: @django_db_all @pytest.mark.parametrize("depth", [100, 1000]) -def test_flamegraph_deep_inline_expansion(cached_flamegraph, depth) -> None: +def test_flamegraph_deep_inline_expansion(cached_flamegraph: CachedFlamegraph, depth: int) -> None: data, payload, symbolicator = cached_flamegraph payload["nodes"] = [ {"frame_id": 0, "sample_count": 10, "children": [index + 1], "custom": "retained"} @@ -587,7 +631,9 @@ def test_flamegraph_deep_inline_expansion(cached_flamegraph, depth) -> None: @django_db_all -def test_flamegraph_large_frame_metadata_is_not_duplicated(cached_flamegraph) -> None: +def test_flamegraph_large_frame_metadata_is_not_duplicated( + cached_flamegraph: CachedFlamegraph, +) -> None: data, payload, symbolicator = cached_flamegraph payload["frames"][0]["custom"] = "x" * (6 * 1024 * 1024) raw = orjson.dumps(payload) @@ -606,7 +652,9 @@ def test_flamegraph_large_frame_metadata_is_not_duplicated(cached_flamegraph) -> @django_db_all -def test_flamegraph_oversized_rewrite_preserves_original(cached_flamegraph) -> None: +def test_flamegraph_oversized_rewrite_preserves_original( + cached_flamegraph: CachedFlamegraph, +) -> None: data, payload, symbolicator = cached_flamegraph raw = orjson.dumps(payload) with ( @@ -620,7 +668,7 @@ def test_flamegraph_oversized_rewrite_preserves_original(cached_flamegraph) -> N @pytest.mark.parametrize("addr_mode", ["rel:1", "rel:84a04d24-0e60-3810-a8c0-90a65e2df61a"]) def test_flamegraph_relative_address_uses_filtered_module_index( - flamegraph_payload, addr_mode + flamegraph_payload: FlamegraphPayload, addr_mode: str ) -> None: flamegraph_payload["debug_meta"]["images"].insert(0, {"type": "macho", "image_addr": "0x2000"}) flamegraph_payload["frames"][0]["addr_mode"] = addr_mode @@ -645,7 +693,7 @@ def test_flamegraph_relative_address_uses_filtered_module_index( "addr_mode", ["rel:0", "rel:2", "rel:00000000-0000-0000-0000-000000000001"] ) def test_flamegraph_relative_address_without_matching_image_is_preserved( - flamegraph_payload, addr_mode + flamegraph_payload: FlamegraphPayload, addr_mode: str ) -> None: flamegraph_payload["debug_meta"]["images"].insert(0, {"type": "macho", "image_addr": "0x2000"}) flamegraph_payload["frames"][0]["addr_mode"] = addr_mode @@ -658,7 +706,7 @@ def test_flamegraph_relative_address_without_matching_image_is_preserved( @django_db_all -def test_flamegraph_frame_limit(cached_flamegraph) -> None: +def test_flamegraph_frame_limit(cached_flamegraph: CachedFlamegraph) -> None: data, payload, symbolicator = cached_flamegraph payload["frames"] *= 2501 store_attachments_for_event( @@ -689,7 +737,9 @@ def test_flamegraph_frame_limit(cached_flamegraph) -> None: "response", [{"status": "failed"}, {"status": "pending"}, {"status": "completed", "stacktraces": []}], ) -def test_flamegraph_failure_preserves_attachment(cached_flamegraph, response) -> None: +def test_flamegraph_failure_preserves_attachment( + cached_flamegraph: CachedFlamegraph, response: dict[str, object] +) -> None: data, payload, symbolicator = cached_flamegraph symbolicator.process_payload.return_value = response with Feature({"organizations:flamegraph-attachments": True}): @@ -703,7 +753,7 @@ def test_flamegraph_failure_preserves_attachment(cached_flamegraph, response) -> @pytest.mark.parametrize( "raw", [b"invalid json", b"\xff", b"null", b'{"version":"2"}', b" " * (10 * 1024 * 1024 + 1)] ) -def test_flamegraph_invalid_attachment(cached_flamegraph, raw) -> None: +def test_flamegraph_invalid_attachment(cached_flamegraph: CachedFlamegraph, raw: bytes) -> None: data, _, symbolicator = cached_flamegraph store_attachments_for_event( symbolicator.project, @@ -739,7 +789,9 @@ def test_flamegraph_invalid_attachment(cached_flamegraph, raw) -> None: {"frame_id": 0, "sample_count": 10, "children": [{"frame_id": 0, "sample_count": 11}]}, ], ) -def test_flamegraph_invalid_tree_is_not_symbolicated(cached_flamegraph, root) -> None: +def test_flamegraph_invalid_tree_is_not_symbolicated( + cached_flamegraph: CachedFlamegraph, root: dict[str, object] +) -> None: data, payload, symbolicator = cached_flamegraph payload["nodes"] = [root] raw = orjson.dumps(payload) @@ -756,7 +808,9 @@ def test_flamegraph_invalid_tree_is_not_symbolicated(cached_flamegraph, root) -> @django_db_all -def test_flamegraph_large_payload_is_not_duplicated(cached_flamegraph) -> None: +def test_flamegraph_large_payload_is_not_duplicated( + cached_flamegraph: CachedFlamegraph, +) -> None: data, payload, symbolicator = cached_flamegraph payload["frames"][0]["custom"] = "x" * (6 * 1024 * 1024) store_attachments_for_event( @@ -781,7 +835,7 @@ def test_flamegraph_large_payload_is_not_duplicated(cached_flamegraph) -> None: @django_db_all @requires_objectstore -def test_flamegraph_objectstore_attachment(cached_flamegraph) -> None: +def test_flamegraph_objectstore_attachment(cached_flamegraph: CachedFlamegraph) -> None: data, payload, symbolicator = cached_flamegraph session = get_session(UsecaseId.ATTACHMENTS, symbolicator.project) stored_id = session.put(orjson.dumps(payload)) @@ -814,7 +868,7 @@ def test_flamegraph_objectstore_attachment(cached_flamegraph) -> None: @pytest.fixture -def multiple_flamegraphs(cached_flamegraph): +def multiple_flamegraphs(cached_flamegraph: CachedFlamegraph) -> MultipleFlamegraphs: data, payload, symbolicator = cached_flamegraph second = deepcopy(payload) second["debug_meta"]["images"][0]["debug_id"] = "502fc0a5-1ec1-3e47-9998-684fa139dca7" @@ -832,7 +886,7 @@ def multiple_flamegraphs(cached_flamegraph): @django_db_all -def test_flamegraph_multiple_attachments(multiple_flamegraphs) -> None: +def test_flamegraph_multiple_attachments(multiple_flamegraphs: MultipleFlamegraphs) -> None: data, first, second, symbolicator = multiple_flamegraphs with Feature({"organizations:flamegraph-attachments": True}): assert process_native_stacktraces(symbolicator, data) is data @@ -853,7 +907,7 @@ def test_flamegraph_multiple_attachments(multiple_flamegraphs) -> None: @django_db_all @pytest.mark.parametrize("missing_type", ["event.attachment", "event.flamegraph"]) def test_flamegraph_checkpoint_does_not_read_missing_attachments( - multiple_flamegraphs, missing_type + multiple_flamegraphs: MultipleFlamegraphs, missing_type: str ) -> None: data, _, _, symbolicator = multiple_flamegraphs data["_attachments"][1]["type"] = missing_type @@ -872,7 +926,9 @@ def test_flamegraph_checkpoint_does_not_read_missing_attachments( @django_db_all @requires_objectstore -def test_flamegraph_checkpoints_do_not_rewrite_processed_objects(multiple_flamegraphs) -> None: +def test_flamegraph_checkpoints_do_not_rewrite_processed_objects( + multiple_flamegraphs: MultipleFlamegraphs, +) -> None: data, first, second, symbolicator = multiple_flamegraphs session = get_session(UsecaseId.ATTACHMENTS, symbolicator.project) first_id = session.put(orjson.dumps(first)) @@ -897,7 +953,9 @@ def test_flamegraph_checkpoints_do_not_rewrite_processed_objects(multiple_flameg @django_db_all -def test_flamegraph_checkpoint_survives_later_timeout(multiple_flamegraphs) -> None: +def test_flamegraph_checkpoint_survives_later_timeout( + multiple_flamegraphs: MultipleFlamegraphs, +) -> None: data, _, second, symbolicator = multiple_flamegraphs symbolicator.process_payload.side_effect = [ symbolicator.process_payload.return_value, @@ -922,7 +980,6 @@ def test_merge_symbolicator_image_empty() -> None: def test_merge_symbolicator_image_basic() -> None: raw_image = {"instruction_addr": 0xFEEBEE, "other": "foo"} - sdk_info = {"sdk_name": "linux"} complete_image = { "debug_status": "found", "unwind_status": "found", @@ -932,7 +989,7 @@ def test_merge_symbolicator_image_basic() -> None: data: dict[str, Any] = {} - _merge_image(raw_image, complete_image, sdk_info, data) + _merge_image(raw_image, complete_image, "linux", data) assert not data.get("errors") assert raw_image == { @@ -946,7 +1003,6 @@ def test_merge_symbolicator_image_basic() -> None: def test_merge_symbolicator_image_basic_success() -> None: raw_image = {"instruction_addr": 0xFEEBEE, "other": "foo"} - sdk_info = {"sdk_name": "linux"} complete_image = { "debug_status": "found", "unwind_status": "found", @@ -955,7 +1011,7 @@ def test_merge_symbolicator_image_basic_success() -> None: } data: dict[str, Any] = {} - _merge_image(raw_image, complete_image, sdk_info, data) + _merge_image(raw_image, complete_image, "linux", data) assert not data.get("errors") assert raw_image == { @@ -970,11 +1026,10 @@ def test_merge_symbolicator_image_basic_success() -> None: def test_merge_symbolicator_image_remove_unknown_arch() -> None: raw_image = {"instruction_addr": 0xFEEBEE} - sdk_info = {"sdk_name": "linux"} complete_image = {"debug_status": "found", "unwind_status": "found", "arch": "unknown"} data: dict[str, Any] = {} - _merge_image(raw_image, complete_image, sdk_info, data) + _merge_image(raw_image, complete_image, "linux", data) assert not data.get("errors") assert raw_image == { @@ -1002,7 +1057,6 @@ def test_merge_frame_file_revision() -> None: ) def test_merge_symbolicator_image_errors(code_file: str, error: EventErrorType) -> None: raw_image = {"instruction_addr": 0xFEEBEE, "other": "foo", "code_file": code_file} - sdk_info = {"sdk_name": "macos"} complete_image = { "debug_status": "found", "unwind_status": "missing", @@ -1011,7 +1065,7 @@ def test_merge_symbolicator_image_errors(code_file: str, error: EventErrorType) } data: dict[str, Any] = {} - _merge_image(raw_image, complete_image, sdk_info, data) + _merge_image(raw_image, complete_image, "linux", data) (e,) = data["errors"] @@ -1030,7 +1084,7 @@ def test_merge_symbolicator_image_errors(code_file: str, error: EventErrorType) @django_db_all @mock.patch("sentry.lang.native.processing.Symbolicator") -def test_cocoa_function_name(mock_symbolicator, default_project) -> None: +def test_cocoa_function_name(mock_symbolicator: mock.Mock, default_project: Project) -> None: data = { "platform": "cocoa", "project": default_project.id, @@ -1061,7 +1115,7 @@ def test_cocoa_function_name(mock_symbolicator, default_project) -> None: def test_filter_frames() -> None: - frames = [ + frames: list[dict[str, Any]] = [ { "instruction_addr": None, }, @@ -1074,7 +1128,7 @@ def test_filter_frames() -> None: }, ] - filtered_frames = get_frames_for_symbolication(frames, {"platform": "native"}, {}) + filtered_frames = get_frames_for_symbolication(frames, {"platform": "native"}, []) assert len(filtered_frames) == 0 @@ -1085,7 +1139,7 @@ def test_instruction_addr_adjustment_auto() -> None: {"instruction_addr": "0xbeefdead", "platform": "native"}, ] - processed_frames = get_frames_for_symbolication(frames, None, None, None) + processed_frames = get_frames_for_symbolication(frames, {}, [], None) assert "adjust_instruction_addr" not in processed_frames[0].keys() assert "adjust_instruction_addr" not in processed_frames[1].keys() @@ -1097,7 +1151,7 @@ def test_instruction_addr_adjustment_all() -> None: {"instruction_addr": "0xbeefdead", "platform": "native"}, ] - processed_frames = get_frames_for_symbolication(frames, None, None, "all") + processed_frames = get_frames_for_symbolication(frames, {}, [], "all") assert processed_frames[0]["adjust_instruction_addr"] assert "adjust_instruction_addr" not in processed_frames[1].keys() @@ -1109,7 +1163,7 @@ def test_instruction_addr_adjustment_all_but_first() -> None: {"instruction_addr": "0xbeefdead", "platform": "native"}, ] - processed_frames = get_frames_for_symbolication(frames, None, None, "all_but_first") + processed_frames = get_frames_for_symbolication(frames, {}, [], "all_but_first") assert not processed_frames[0]["adjust_instruction_addr"] assert "adjust_instruction_addr" not in processed_frames[1].keys() @@ -1121,14 +1175,14 @@ def test_instruction_addr_adjustment_none() -> None: {"instruction_addr": "0xbeefdead", "platform": "native"}, ] - processed_frames = get_frames_for_symbolication(frames, None, None, "none") + processed_frames = get_frames_for_symbolication(frames, {}, [], "none") assert not processed_frames[0]["adjust_instruction_addr"] assert not processed_frames[1]["adjust_instruction_addr"] def test_rewrite_electron_debug_file() -> None: - def rewrite(debug_file): + def rewrite(debug_file: str) -> str: for rule in ELECTRON_FIRST_MODULE_REWRITE_RULES: # Need to patch the regexes and replacement strings here # from Rust to Python syntax. @@ -1167,7 +1221,7 @@ def rewrite(debug_file): @django_db_all @mock.patch("sentry.lang.native.processing.Symbolicator") -def test_il2cpp_symbolication(mock_symbolicator, default_project) -> None: +def test_il2cpp_symbolication(mock_symbolicator: mock.Mock, default_project: Project) -> None: data = { "event_id": "c87700da71534177b92bd912f21a062f", "timestamp": "2022-06-15T10:13:46.963575+00:00", diff --git a/tests/sentry/lang/native/test_sources.py b/tests/sentry/lang/native/test_sources.py index ad2d67991ff0..099979369bf0 100644 --- a/tests/sentry/lang/native/test_sources.py +++ b/tests/sentry/lang/native/test_sources.py @@ -1,5 +1,5 @@ import copy -from unittest.mock import patch +from unittest.mock import Mock, patch import jsonschema import pytest @@ -11,6 +11,7 @@ filter_ignored_sources, get_sources_for_project, ) +from sentry.models.project import Project from sentry.testutils.helpers import override_options from sentry.testutils.pytest.fixtures import django_db_all @@ -50,7 +51,9 @@ class TestGcpBearerAuthentication: @override_settings(SENTRY_BUILTIN_SOURCES=SENTRY_BUILTIN_SOURCES_TEST) @patch("sentry.lang.native.sources.get_gcp_token") @django_db_all - def test_sources_gcp_bearer_authentication(self, mock_get_gcp_token, default_project) -> None: + def test_sources_gcp_bearer_authentication( + self, mock_get_gcp_token: Mock, default_project: Project + ) -> None: mock_get_gcp_token.return_value = "ya29.TOKEN" default_project.update_option("sentry:builtin_symbol_sources", ["aaa", "bbb"]) @@ -64,7 +67,7 @@ def test_sources_gcp_bearer_authentication(self, mock_get_gcp_token, default_pro @override_settings(SENTRY_BUILTIN_SOURCES=SENTRY_BUILTIN_SOURCES_TEST) @patch("sentry.lang.native.sources.get_gcp_token") @django_db_all - def test_source_alias(self, mock_get_gcp_token, default_project) -> None: + def test_source_alias(self, mock_get_gcp_token: Mock, default_project: Project) -> None: mock_get_gcp_token.return_value = "ya29.TOKEN" default_project.update_option("sentry:builtin_symbol_sources", ["ccc"]) @@ -90,7 +93,9 @@ def test_source_alias(self, mock_get_gcp_token, default_project) -> None: @override_settings(SENTRY_BUILTIN_SOURCES=SENTRY_BUILTIN_SOURCES_TEST) @patch("sentry.lang.native.sources.get_gcp_token") @django_db_all - def test_source_with_private_key(self, mock_get_gcp_token, default_project) -> None: + def test_source_with_private_key( + self, mock_get_gcp_token: Mock, default_project: Project + ) -> None: mock_get_gcp_token.return_value = "ya29.TOKEN" default_project.update_option("sentry:builtin_symbol_sources", ["ddd"]) @@ -103,7 +108,7 @@ def test_source_with_private_key(self, mock_get_gcp_token, default_project) -> N @override_settings(SENTRY_BUILTIN_SOURCES=SENTRY_BUILTIN_SOURCES_TEST) @patch("sentry.lang.native.sources.get_gcp_token") @django_db_all - def test_mixed_sources(self, mock_get_gcp_token, default_project) -> None: + def test_mixed_sources(self, mock_get_gcp_token: Mock, default_project: Project) -> None: """ Tests the combination of sources where one uses credentials for authentication and the other one uses pre-fetched token. @@ -123,8 +128,8 @@ def test_mixed_sources(self, mock_get_gcp_token, default_project) -> None: class TestIgnoredSourcesFiltering: @pytest.fixture - def sources(self): - builtins = [ + def sources(self) -> list[dict[str, object]]: + builtins: list[dict[str, object]] = [ { "id": "sentry:microsoft", "name": "Microsoft", @@ -155,7 +160,7 @@ def sources(self): return builtins @pytest.fixture - def reversed_alias_map(self): + def reversed_alias_map(self) -> dict[str, str]: return {"sentry:ios-source": "sentry:ios", "sentry:tvos-source": "sentry:ios"} # Explicitly empty list of sources @@ -168,7 +173,7 @@ def test_sources_included_and_ignored_empty(self) -> None: # Default/unset list of sources @django_db_all - def test_sources_ignored_unset(self, sources) -> None: + def test_sources_ignored_unset(self, sources: list[dict[str, object]]) -> None: sources = filter_ignored_sources(sources) source_ids = list(map(lambda s: s["id"], sources)) @@ -181,7 +186,7 @@ def test_sources_ignored_unset(self, sources) -> None: ] @django_db_all - def test_sources_ignored_empty(self, sources) -> None: + def test_sources_ignored_empty(self, sources: list[dict[str, object]]) -> None: with override_options({"symbolicator.ignored_sources": []}): sources = filter_ignored_sources(sources) @@ -195,7 +200,7 @@ def test_sources_ignored_empty(self, sources) -> None: ] @django_db_all - def test_sources_ignored_builtin(self, sources) -> None: + def test_sources_ignored_builtin(self, sources: list[dict[str, object]]) -> None: with override_options({"symbolicator.ignored_sources": ["sentry:microsoft"]}): sources = filter_ignored_sources(sources) @@ -208,7 +213,9 @@ def test_sources_ignored_builtin(self, sources) -> None: ] @django_db_all - def test_sources_ignored_alias(self, sources, reversed_alias_map) -> None: + def test_sources_ignored_alias( + self, sources: list[dict[str, object]], reversed_alias_map: dict[str, str] + ) -> None: with override_options({"symbolicator.ignored_sources": ["sentry:ios"]}): sources = filter_ignored_sources(sources, reversed_alias_map) @@ -216,7 +223,9 @@ def test_sources_ignored_alias(self, sources, reversed_alias_map) -> None: assert source_ids == ["sentry:microsoft", "sentry:electron", "custom"] @django_db_all - def test_sources_ignored_bypass_alias(self, sources, reversed_alias_map) -> None: + def test_sources_ignored_bypass_alias( + self, sources: list[dict[str, object]], reversed_alias_map: dict[str, str] + ) -> None: with override_options({"symbolicator.ignored_sources": ["sentry:ios-source"]}): sources = filter_ignored_sources(sources, reversed_alias_map) @@ -229,7 +238,7 @@ def test_sources_ignored_bypass_alias(self, sources, reversed_alias_map) -> None ] @django_db_all - def test_sources_ignored_custom(self, sources) -> None: + def test_sources_ignored_custom(self, sources: list[dict[str, object]]) -> None: with override_options({"symbolicator.ignored_sources": ["custom"]}): sources = filter_ignored_sources(sources) @@ -242,7 +251,7 @@ def test_sources_ignored_custom(self, sources) -> None: ] @django_db_all - def test_sources_ignored_unrecognized(self, sources) -> None: + def test_sources_ignored_unrecognized(self, sources: list[dict[str, object]]) -> None: with override_options({"symbolicator.ignored_sources": ["honk"]}): sources = filter_ignored_sources(sources) diff --git a/tests/sentry/lang/native/test_symbolicator.py b/tests/sentry/lang/native/test_symbolicator.py index 8258be58c580..ccc634c0730f 100644 --- a/tests/sentry/lang/native/test_symbolicator.py +++ b/tests/sentry/lang/native/test_symbolicator.py @@ -15,6 +15,7 @@ SymbolicatorFunction, SymbolicatorTaskKind, ) +from sentry.models.project import Project from sentry.testutils.helpers import Feature from sentry.testutils.pytest.fixtures import django_db_all @@ -40,7 +41,7 @@ @django_db_all @pytest.mark.parametrize("enabled", [False, True]) -def test_process_payload_apply_source_server_info(default_project, enabled) -> None: +def test_process_payload_apply_source_server_info(default_project: Project, enabled: bool) -> None: default_project.update_option("sentry:enable_native_source_server_mapping", enabled) symbolicator = Symbolicator( task_kind=SymbolicatorTaskKind(function=SymbolicatorFunction.native), @@ -56,7 +57,7 @@ def test_process_payload_apply_source_server_info(default_project, enabled) -> N @django_db_all -def test_sources_builtin(default_project) -> None: +def test_sources_builtin(default_project: Project) -> None: features = {"organizations:custom-symbol-sources": False} default_project.update_option("sentry:builtin_symbol_sources", ["microsoft"]) @@ -72,7 +73,7 @@ def test_sources_builtin(default_project) -> None: # Test that a builtin source that is not declared in SENTRY_BUILTIN_SOURCES does # not lead to an error. It should simply be ignored. @django_db_all -def test_sources_builtin_unknown(default_project) -> None: +def test_sources_builtin_unknown(default_project: Project) -> None: features = {"organizations:custom-symbol-sources": False} default_project.update_option("sentry:builtin_symbol_sources", ["invalid"]) @@ -85,7 +86,7 @@ def test_sources_builtin_unknown(default_project) -> None: @django_db_all -def test_sources_custom(default_project) -> None: +def test_sources_custom(default_project: Project) -> None: features = {"organizations:custom-symbol-sources": True} # Remove builtin sources explicitly to avoid defaults @@ -102,7 +103,7 @@ def test_sources_custom(default_project) -> None: @django_db_all -def test_sources_custom_with_event_id(default_project) -> None: +def test_sources_custom_with_event_id(default_project: Project) -> None: features = {"organizations:custom-symbol-sources": True} # Remove builtin sources explicitly to avoid defaults @@ -126,7 +127,7 @@ def test_sources_custom_with_event_id(default_project) -> None: # Test that previously saved custom sources are not returned if the feature for # custom sources is missing at query time. @django_db_all -def test_sources_custom_disabled(default_project) -> None: +def test_sources_custom_disabled(default_project: Project) -> None: features = {"organizations:custom-symbol-sources": False} default_project.update_option("sentry:builtin_symbol_sources", []) @@ -275,7 +276,7 @@ def test_sentry_project_notfound_no_location(self) -> None: class TestAliasReversion: @pytest.fixture - def builtin_sources(self): + def builtin_sources(self) -> dict[str, dict[str, object]]: return { "ios": { "id": "sentry:ios", @@ -295,7 +296,7 @@ def builtin_sources(self): }, } - def test_reverse_aliases(self, builtin_sources) -> None: + def test_reverse_aliases(self, builtin_sources: dict[str, dict[str, object]]) -> None: reverse_aliases = reverse_aliases_map(builtin_sources) expected = {"sentry:ios-source": "sentry:ios", "sentry:tvos-source": "sentry:ios"} assert reverse_aliases == expected diff --git a/tests/sentry/logging/test_handler.py b/tests/sentry/logging/test_handler.py index 6a342a53503d..ffe790371378 100644 --- a/tests/sentry/logging/test_handler.py +++ b/tests/sentry/logging/test_handler.py @@ -1,7 +1,7 @@ import logging -from collections.abc import Callable +from collections.abc import Callable, Iterator from contextlib import contextmanager -from typing import Any +from typing import Any, TypedDict from unittest import mock import pytest @@ -18,12 +18,12 @@ @pytest.fixture -def handler(): +def handler() -> StructLogHandler: return StructLogHandler() @pytest.fixture -def logger(): +def logger() -> mock.MagicMock: return mock.MagicMock() @@ -39,7 +39,7 @@ def __str__(self) -> str: @contextmanager def filter_context( logger: logging.Logger, filters: list[logging.Filter | Callable[[logging.LogRecord], bool]] -): +) -> Iterator[None]: """Manages adding and cleaning up log filters""" for f in filters: logger.addFilter(f) @@ -51,7 +51,7 @@ def filter_context( @contextmanager -def level_context(level: int): +def level_context(level: int) -> Iterator[None]: curr_level = logging.getLogger().level logging.basicConfig(level=level) try: @@ -61,11 +61,17 @@ def level_context(level: int): @pytest.fixture -def set_level_debug(): +def set_level_debug() -> Iterator[None]: with level_context(logging.DEBUG): yield +class _LogRecordOverrides(TypedDict, total=False): + msg: str + args: tuple[object, ...] + exc_info: bool + + def make_logrecord( *, name: str = "name", @@ -98,11 +104,16 @@ def make_logrecord( ({"exc_info": True}, {"exc_info": True}), ), ) -def test_emit(record, out, handler, logger) -> None: - record = make_logrecord(**record) +def test_emit( + record: _LogRecordOverrides, + out: dict[str, object], + handler: StructLogHandler, + logger: mock.MagicMock, +) -> None: + log_record = make_logrecord(**record) with reset_trace_context(): - handler.emit(record, logger=logger) - expected = { + handler.emit(log_record, logger=logger) + expected: dict[str, object] = { "level": logging.INFO, "event": "msg", "name": "name", @@ -121,11 +132,16 @@ def test_emit(record, out, handler, logger) -> None: ({"exc_info": True}, {"exc_info": True}), ), ) -def test_emit_with_trace_id(record, out, handler, logger) -> None: +def test_emit_with_trace_id( + record: _LogRecordOverrides, + out: dict[str, object], + handler: StructLogHandler, + logger: mock.MagicMock, +) -> None: with sentry_sdk.traces.start_span(name="test_emit_with_trace_id"): - record = make_logrecord(**record) - handler.emit(record, logger=logger) - expected = { + log_record = make_logrecord(**record) + handler.emit(log_record, logger=logger) + expected: dict[str, object] = { "level": logging.INFO, "event": "msg", "name": "name", @@ -166,17 +182,19 @@ def test_emit_invalid_keys_prod(handler: mock.MagicMock) -> None: def test_JSONRenderer_nonprod() -> None: renderer = JSONRenderer() with pytest.raises(TypeError): - renderer(None, None, {"foo": {mock.Mock(): "foo"}}) + renderer(None, "test", {"foo": {mock.Mock(): "foo"}}) @mock.patch("logging.raiseExceptions", False) def test_JSONRenderer_prod() -> None: renderer = JSONRenderer() - renderer(None, None, {"foo": {mock.Mock(): "foo"}}) + renderer(None, "test", {"foo": {mock.Mock(): "foo"}}) @mock.patch("logging.raiseExceptions", True) -def test_logging_raiseExcpetions_enabled_generic_logging(caplog, snafu) -> None: +def test_logging_raiseExcpetions_enabled_generic_logging( + caplog: pytest.LogCaptureFixture, snafu: object +) -> None: logger = logging.getLogger(__name__) with pytest.raises(Exception) as exc_info: @@ -185,7 +203,9 @@ def test_logging_raiseExcpetions_enabled_generic_logging(caplog, snafu) -> None: @mock.patch("logging.raiseExceptions", False) -def test_logging_raiseExcpetions_disabled_generic_logging(caplog, snafu) -> None: +def test_logging_raiseExcpetions_disabled_generic_logging( + caplog: pytest.LogCaptureFixture, snafu: object +) -> None: logger = logging.getLogger(__name__) logger.log(logging.INFO, snafu) @@ -206,7 +226,7 @@ def test_gke_emit() -> None: @mock.patch("random.random", lambda: 0.1) -def test_sampling_filter(caplog, set_level_debug) -> None: +def test_sampling_filter(caplog: pytest.LogCaptureFixture, set_level_debug: object) -> None: logger = logging.getLogger(__name__) with filter_context(logger, [SamplingFilter(0.2)]): logger.info("msg1") @@ -221,7 +241,7 @@ def test_sampling_filter(caplog, set_level_debug) -> None: @mock.patch("random.random", lambda: 0.1) -def test_sampling_filter_level(caplog, set_level_debug) -> None: +def test_sampling_filter_level(caplog: pytest.LogCaptureFixture, set_level_debug: object) -> None: logger = logging.getLogger(__name__) with filter_context(logger, [SamplingFilter(0.05, level=logging.WARNING)]): logger.debug("debug") @@ -235,7 +255,9 @@ def test_sampling_filter_level(caplog, set_level_debug) -> None: @mock.patch("random.random", lambda: 0.1) -def test_sampling_filter_level_default(caplog, set_level_debug) -> None: +def test_sampling_filter_level_default( + caplog: pytest.LogCaptureFixture, set_level_debug: object +) -> None: logger = logging.getLogger(__name__) with filter_context(logger, [SamplingFilter(0.05)]): logger.debug("debug") diff --git a/tests/sentry/mail/test_actions.py b/tests/sentry/mail/test_actions.py index b26d8606ffa6..e8dba011e064 100644 --- a/tests/sentry/mail/test_actions.py +++ b/tests/sentry/mail/test_actions.py @@ -1,3 +1,5 @@ +from typing import cast + from django.core import mail from sentry.eventstream.types import EventStreamEventType @@ -193,9 +195,8 @@ def test_hack_mail_workflow(self) -> None: assert "uh oh" in x def test_full_integration_performance(self) -> None: - event = self.create_performance_issue() - assert isinstance(event, GroupEvent) - assert event.group is not None + # justification: performance issue fixtures emit GroupEvents even though the fixture is typed as Event. + event = cast("GroupEvent", self.create_performance_issue()) action = self.create_action( config={ @@ -243,7 +244,7 @@ def setUp(self) -> None: } def test_legacy_simple(self) -> None: - event = self.get_event() + event = self.get_group_event() rule = self.get_rule(data={"targetType": ActionTargetType.ISSUE_OWNERS.value}) ProjectOwnership.objects.create(project_id=self.project.id, fallthrough=True) diff --git a/tests/sentry/mail/test_adapter.py b/tests/sentry/mail/test_adapter.py index 7606c9bf3374..8a0de3b8fb46 100644 --- a/tests/sentry/mail/test_adapter.py +++ b/tests/sentry/mail/test_adapter.py @@ -5,6 +5,7 @@ from collections.abc import Mapping, Sequence from datetime import UTC, datetime, timedelta from functools import cached_property +from typing import Any from unittest import mock from unittest.mock import MagicMock @@ -24,6 +25,7 @@ from sentry.issues.ownership import grammar from sentry.issues.ownership.grammar import Matcher, Owner, dump_schema from sentry.mail import build_subject_prefix, mail_adapter +from sentry.mail.adapter import MailAdapter from sentry.mail.adapter import RuleFuture as MailRuleFuture from sentry.mail.analytics import EmailNotificationSent from sentry.models.activity import Activity @@ -114,16 +116,16 @@ def project(self) -> Project: ) @cached_property - def adapter(self): + def adapter(self) -> MailAdapter: return mail_adapter def assert_notify( self, - event, - emails_sent_to, - target_type=ActionTargetType.ISSUE_OWNERS, - target_identifier=None, - ): + event: Event | GroupEvent, + emails_sent_to: Sequence[str], + target_type: ActionTargetType = ActionTargetType.ISSUE_OWNERS, + target_identifier: int | None = None, + ) -> None: mail.outbox = [] with self.options({"system.url-prefix": "http://example.com"}), self.tasks(): self.adapter.notify(Notification(event=event), target_type, target_identifier) @@ -495,7 +497,7 @@ def test_notify_users_renders_interfaces_with_utf8( @mock_notify @mock.patch("sentry.notifications.notifications.rules.logger") - def test_notify_users_does_email(self, mock_logger, mock_func) -> None: + def test_notify_users_does_email(self, mock_logger: MagicMock, mock_func: MagicMock) -> None: self.create_user_option(user=self.user, key="timezone", value="Europe/Vienna") event_manager = EventManager({"message": "hello world", "level": "error"}) event_manager.normalize() @@ -553,7 +555,7 @@ def test_notify_users_does_email(self, mock_logger, mock_func) -> None: ) @mock_notify - def test_email_notification_is_not_sent_to_deleted_email(self, mock_func) -> None: + def test_email_notification_is_not_sent_to_deleted_email(self, mock_func: MagicMock) -> None: """ Test that ensures if we still have some stale emails in UserOption, then upon attempting to send an email notification to those emails, these stale `UserOption` instances are @@ -623,7 +625,7 @@ def test_email_notification_is_not_sent_to_deleted_email(self, mock_func) -> Non assert not len(UserOption.objects.filter(key="mail:email", value="foo@bar.dodo")) @mock_notify - def test_multiline_error(self, mock_func) -> None: + def test_multiline_error(self, mock_func: MagicMock) -> None: event_manager = EventManager({"message": "hello world\nfoo bar", "level": "error"}) event_manager.normalize() event_data = event_manager.get_data() @@ -907,13 +909,13 @@ def test_notify_team_members(self) -> None: team = self.create_team(organization=self.organization, members=[user, user2]) project = self.create_project(teams=[team]) event = self.store_event(data=make_event_data("foo.py"), project_id=project.id) - self.assert_notify(event, [user.email, user2.email], ActionTargetType.TEAM, str(team.id)) + self.assert_notify(event, [user.email, user2.email], ActionTargetType.TEAM, team.id) def test_notify_user(self) -> None: user = self.create_user(email="foo@example.com", is_active=True) self.create_team(organization=self.organization, members=[user]) event = self.store_event(data=make_event_data("foo.py"), project_id=self.project.id) - self.assert_notify(event, [user.email], ActionTargetType.MEMBER, str(user.id)) + self.assert_notify(event, [user.email], ActionTargetType.MEMBER, user.id) class MailAdapterNotifyIssueOwnersTest(BaseMailAdapterTest): @@ -921,9 +923,9 @@ def create_assert_delete_projectownership( self, proj: Project, rules: Sequence[grammar.Rule], - data: Mapping, + data: Mapping[str, Any], asserted_emails_fired: Sequence[str], - ): + ) -> None: po = ProjectOwnership.objects.create( project_id=proj.id, schema=dump_schema(rules), fallthrough=False ) @@ -1746,7 +1748,7 @@ def test_note(self) -> None: class MailAdapterHandleSignalTest(BaseMailAdapterTest): - def create_report(self): + def create_report(self) -> UserReport: user_foo = self.create_user("foo@example.com") team = self.project.teams.first() assert team is not None @@ -1771,10 +1773,11 @@ def test_user_feedback(self) -> None: value="always", ) report = self.create_report() + serialized_report = serialize(report, AnonymousUser(), UserReportWithGroupSerializer()) with self.tasks(): self.adapter.handle_user_report( project=self.project, - report=serialize(report, AnonymousUser(), UserReportWithGroupSerializer()), + report=serialized_report, ) assert len(mail.outbox) == 1 @@ -1803,13 +1806,14 @@ def test_user_feedback__enhanced_privacy(self) -> None: value="always", ) self.organization.update(flags=F("flags").bitor(Organization.flags.enhanced_privacy)) - assert self.organization.flags.enhanced_privacy.is_set is True + assert self.organization.flags.enhanced_privacy report = self.create_report() + serialized_report = serialize(report, AnonymousUser(), UserReportWithGroupSerializer()) with self.tasks(): self.adapter.handle_user_report( project=self.project, - report=serialize(report, AnonymousUser(), UserReportWithGroupSerializer()), + report=serialized_report, ) assert len(mail.outbox) == 1 diff --git a/tests/sentry/management/commands/test_generate_controlsilo_urls.py b/tests/sentry/management/commands/test_generate_controlsilo_urls.py index 5c75cc3d235a..b3295b4ecc7a 100644 --- a/tests/sentry/management/commands/test_generate_controlsilo_urls.py +++ b/tests/sentry/management/commands/test_generate_controlsilo_urls.py @@ -9,7 +9,7 @@ class TestGenerateControlsiloUrls(TestCase): - def call_command(self, *args, **kwargs): + def call_command(self, *args: object, **kwargs: object) -> str: out = StringIO() call_command("generate_controlsilo_urls", *args, stdout=out, stderr=StringIO(), **kwargs) return out.getvalue() diff --git a/tests/sentry/metrics/test_dualwrite.py b/tests/sentry/metrics/test_dualwrite.py index 8380b8c164db..8f3039cf169e 100644 --- a/tests/sentry/metrics/test_dualwrite.py +++ b/tests/sentry/metrics/test_dualwrite.py @@ -7,7 +7,7 @@ @mock.patch("datadog.threadstats.base.ThreadStats.timing") @mock.patch("datadog.dogstatsd.base.DogStatsd.distribution") @thread_leak_allowlist(reason="datadog dualwrite metrics", issue=98803) -def test_dualwrite_distribution(distribution, timing): +def test_dualwrite_distribution(distribution: mock.MagicMock, timing: mock.MagicMock) -> None: backend = DualWriteMetricsBackend( primary_backend="sentry.metrics.datadog.DatadogMetricsBackend", secondary_backend="sentry.metrics.precise_dogstatsd.PreciseDogStatsdMetricsBackend", @@ -38,7 +38,9 @@ def test_dualwrite_distribution(distribution, timing): @mock.patch("sentry_sdk.metrics.count") @mock.patch("datadog.dogstatsd.base.DogStatsd.increment") @thread_leak_allowlist(reason="datadog dualwrite metrics", issue=98803) -def test_dualwrite_experimental_backend(dogstatsd_incr, sentry_sdk_incr): +def test_dualwrite_experimental_backend( + dogstatsd_incr: mock.MagicMock, sentry_sdk_incr: mock.MagicMock +) -> None: backend = DualWriteMetricsBackend( primary_backend="sentry.metrics.dogstatsd.DogStatsdMetricsBackend", experimental_backend="sentry.metrics.sentry_sdk.SentrySDKMetricsBackend", @@ -53,7 +55,9 @@ def test_dualwrite_experimental_backend(dogstatsd_incr, sentry_sdk_incr): @mock.patch("sentry_sdk.metrics.count") @mock.patch("datadog.dogstatsd.base.DogStatsd.increment") @thread_leak_allowlist(reason="datadog dualwrite metrics", issue=98803) -def test_dualwrite_experimental_backend_allow_list(dogstatsd_incr, sentry_sdk_incr): +def test_dualwrite_experimental_backend_allow_list( + dogstatsd_incr: mock.MagicMock, sentry_sdk_incr: mock.MagicMock +) -> None: backend = DualWriteMetricsBackend( primary_backend="sentry.metrics.dogstatsd.DogStatsdMetricsBackend", experimental_backend="sentry.metrics.sentry_sdk.SentrySDKMetricsBackend", @@ -72,7 +76,9 @@ def test_dualwrite_experimental_backend_allow_list(dogstatsd_incr, sentry_sdk_in @mock.patch("sentry_sdk.metrics.gauge") @mock.patch("datadog.dogstatsd.base.DogStatsd.gauge") @thread_leak_allowlist(reason="datadog dualwrite metrics", issue=98803) -def test_dualwrite_experimental_backend_rollout_disabled(dogstatsd_gauge, sentry_sdk_gauge): +def test_dualwrite_experimental_backend_rollout_disabled( + dogstatsd_gauge: mock.MagicMock, sentry_sdk_gauge: mock.MagicMock +) -> None: backend = DualWriteMetricsBackend( primary_backend="sentry.metrics.dogstatsd.DogStatsdMetricsBackend", experimental_backend="sentry.metrics.sentry_sdk.SentrySDKMetricsBackend", @@ -87,7 +93,7 @@ def test_dualwrite_experimental_backend_rollout_disabled(dogstatsd_gauge, sentry @mock.patch("datadog.dogstatsd.base.DogStatsd.set") @mock.patch("datadog.dogstatsd.base.statsd.set") @thread_leak_allowlist(reason="datadog dualwrite metrics", issue=98803) -def test_dualwrite_set(primary_set, secondary_set): +def test_dualwrite_set(primary_set: mock.MagicMock, secondary_set: mock.MagicMock) -> None: # Primary DogStatsdMetricsBackend uses the module-level statsd client; # PreciseDogStatsdMetricsBackend uses its own DogStatsd instance. Patch them # separately so routing to primary vs secondary is actually observable. @@ -112,7 +118,9 @@ def test_dualwrite_set(primary_set, secondary_set): @mock.patch("sentry.metrics.sentry_sdk.SentrySDKMetricsBackend.set") @mock.patch("datadog.dogstatsd.base.statsd.set") @thread_leak_allowlist(reason="datadog dualwrite metrics", issue=98803) -def test_dualwrite_set_experimental_backend(primary_set, experimental_set): +def test_dualwrite_set_experimental_backend( + primary_set: mock.MagicMock, experimental_set: mock.MagicMock +) -> None: # The experimental backend's set is patched directly (SentrySDK's set is a # no-op otherwise) so forwarding to the experimental backend is observable. backend = DualWriteMetricsBackend( diff --git a/tests/sentry/metrics/test_precise.py b/tests/sentry/metrics/test_precise.py index de3790d85124..bf5e47a57566 100644 --- a/tests/sentry/metrics/test_precise.py +++ b/tests/sentry/metrics/test_precise.py @@ -6,7 +6,7 @@ @thread_leak_allowlist(reason="datadog precise metrics", issue=98805) @mock.patch("datadog.dogstatsd.base.DogStatsd.distribution") -def test_precise_distribution(distribution): +def test_precise_distribution(distribution: mock.MagicMock) -> None: backend = PreciseDogStatsdMetricsBackend(prefix="sentrytest.") backend.distribution("foo", 100, tags={"some": "stuff"}, unit="byte") @@ -19,7 +19,7 @@ def test_precise_distribution(distribution): @thread_leak_allowlist(reason="datadog precise metrics", issue=98805) @mock.patch("datadog.dogstatsd.base.DogStatsd.set") -def test_precise_set(mock_set): +def test_precise_set(mock_set: mock.MagicMock) -> None: backend = PreciseDogStatsdMetricsBackend(prefix="sentrytest.") backend.set("foo", 4242, tags={"some": "stuff"}) @@ -27,15 +27,15 @@ def test_precise_set(mock_set): @thread_leak_allowlist(reason="datadog precise metrics", issue=98805) -def test_precise_constant_tags(): +def test_precise_constant_tags() -> None: backend = PreciseDogStatsdMetricsBackend( prefix="sentrytest.", tags={"metrics_source": "dd-agent"}, statsd_constant_tags=["component:web", "service:getsentry"], ) - backend.statsd._send = mock.Mock() + setattr(backend.statsd, "_send", mock.Mock()) backend.incr("foo", tags={"service": "billing"}) - backend.statsd._send.assert_called_once_with( + getattr(backend.statsd, "_send").assert_called_once_with( "sentrytest.foo:1|c|#service:billing,metrics_source:dd-agent,component:web,service:getsentry" ) diff --git a/tests/sentry/metrics/test_sentry_sdk.py b/tests/sentry/metrics/test_sentry_sdk.py index 25dc8f111000..a7d849f363e3 100644 --- a/tests/sentry/metrics/test_sentry_sdk.py +++ b/tests/sentry/metrics/test_sentry_sdk.py @@ -7,11 +7,11 @@ class TestSentrySDKMetricsBackend: @pytest.fixture - def backend(self): + def backend(self) -> SentrySDKMetricsBackend: return SentrySDKMetricsBackend(prefix="test.", experimental_sample_rate=1.0) @mock.patch("sentry_sdk.metrics.count") - def test_incr(self, mock_count, backend): + def test_incr(self, mock_count: mock.MagicMock, backend: SentrySDKMetricsBackend) -> None: with mock.patch.object(backend, "_should_send", return_value=True): backend.incr("foo", tags={"x": "y"}, amount=2, unit="count") @@ -23,7 +23,9 @@ def test_incr(self, mock_count, backend): ) @mock.patch("sentry_sdk.metrics.count") - def test_incr_no_unit(self, mock_count, backend): + def test_incr_no_unit( + self, mock_count: mock.MagicMock, backend: SentrySDKMetricsBackend + ) -> None: with mock.patch.object(backend, "_should_send", return_value=True): backend.incr("foo", tags={"x": "y"}, amount=2) @@ -35,7 +37,7 @@ def test_incr_no_unit(self, mock_count, backend): ) @mock.patch("sentry_sdk.metrics.gauge") - def test_gauge(self, mock_gauge, backend): + def test_gauge(self, mock_gauge: mock.MagicMock, backend: SentrySDKMetricsBackend) -> None: with mock.patch.object(backend, "_should_send", return_value=True): backend.gauge("bar", value=42.0, tags={"x": "y"}, unit="bytes") @@ -47,7 +49,9 @@ def test_gauge(self, mock_gauge, backend): ) @mock.patch("sentry_sdk.metrics.distribution") - def test_distribution(self, mock_distribution, backend): + def test_distribution( + self, mock_distribution: mock.MagicMock, backend: SentrySDKMetricsBackend + ) -> None: with mock.patch.object(backend, "_should_send", return_value=True): backend.distribution("baz", value=100.0, tags={"x": "y"}, unit="millisecond") @@ -59,7 +63,9 @@ def test_distribution(self, mock_distribution, backend): ) @mock.patch("sentry_sdk.metrics.count") - def test_incr_with_instance(self, mock_count, backend): + def test_incr_with_instance( + self, mock_count: mock.MagicMock, backend: SentrySDKMetricsBackend + ) -> None: with mock.patch.object(backend, "_should_send", return_value=True): backend.incr("foo", instance="web", tags={"x": "y"}, amount=1) @@ -71,7 +77,9 @@ def test_incr_with_instance(self, mock_count, backend): ) @mock.patch("sentry_sdk.metrics.distribution") - def test_timing(self, mock_distribution, backend): + def test_timing( + self, mock_distribution: mock.MagicMock, backend: SentrySDKMetricsBackend + ) -> None: with mock.patch.object(backend, "_should_send", return_value=True): backend.timing("foo", 42.0, tags={"x": "y"}) @@ -82,20 +90,20 @@ def test_timing(self, mock_distribution, backend): attributes={"x": "y", "is_timing": True}, ) - def test_event_noop(self, backend): + def test_event_noop(self, backend: SentrySDKMetricsBackend) -> None: backend.event("title", "message") - def test_set_noop(self, backend): + def test_set_noop(self, backend: SentrySDKMetricsBackend) -> None: backend.set("foo", 4242, tags={"x": "y"}) @mock.patch("sentry_sdk.metrics.count") - def test_incr_sampling(self, mock_count): + def test_incr_sampling(self, mock_count: mock.MagicMock) -> None: backend = SentrySDKMetricsBackend(prefix="test.", experimental_sample_rate=0.0) backend.incr("foo", amount=1) mock_count.assert_not_called() @mock.patch("sentry_sdk.metrics.count") - def test_incr_allow_list(self, mock_count): + def test_incr_allow_list(self, mock_count: mock.MagicMock) -> None: backend = SentrySDKMetricsBackend( prefix="test.", experimental_sample_rate=1.0, allow_list=["test.allowed"] ) @@ -106,7 +114,7 @@ def test_incr_allow_list(self, mock_count): mock_count.assert_called_once() @mock.patch("sentry_sdk.metrics.count") - def test_incr_deny_list_ignored(self, mock_count): + def test_incr_deny_list_ignored(self, mock_count: mock.MagicMock) -> None: backend = SentrySDKMetricsBackend( prefix="test.", experimental_sample_rate=1.0, deny_list=["test.denied"] ) @@ -114,7 +122,7 @@ def test_incr_deny_list_ignored(self, mock_count): mock_count.assert_called_once() @mock.patch("sentry_sdk.metrics.count") - def test_incr_empty_allow_list_sends_nothing(self, mock_count): + def test_incr_empty_allow_list_sends_nothing(self, mock_count: mock.MagicMock) -> None: backend = SentrySDKMetricsBackend( prefix="test.", experimental_sample_rate=1.0, allow_list=[] ) @@ -122,7 +130,7 @@ def test_incr_empty_allow_list_sends_nothing(self, mock_count): mock_count.assert_not_called() @mock.patch("sentry_sdk.metrics.count") - def test_incr_allow_list_sampling(self, mock_count): + def test_incr_allow_list_sampling(self, mock_count: mock.MagicMock) -> None: backend = SentrySDKMetricsBackend( prefix="test.", experimental_sample_rate=0.0, allow_list=["test.allowed"] ) @@ -130,7 +138,9 @@ def test_incr_allow_list_sampling(self, mock_count): mock_count.assert_not_called() @mock.patch("sentry_sdk.metrics.count") - def test_incr_with_sample_rate(self, mock_count, backend): + def test_incr_with_sample_rate( + self, mock_count: mock.MagicMock, backend: SentrySDKMetricsBackend + ) -> None: with ( mock.patch.object(backend, "_should_send", return_value=True), mock.patch.object(backend, "_should_sample", return_value=True), @@ -145,7 +155,9 @@ def test_incr_with_sample_rate(self, mock_count, backend): ) @mock.patch("sentry_sdk.metrics.gauge") - def test_gauge_with_sample_rate(self, mock_gauge, backend): + def test_gauge_with_sample_rate( + self, mock_gauge: mock.MagicMock, backend: SentrySDKMetricsBackend + ) -> None: with ( mock.patch.object(backend, "_should_send", return_value=True), mock.patch.object(backend, "_should_sample", return_value=True), @@ -160,7 +172,9 @@ def test_gauge_with_sample_rate(self, mock_gauge, backend): ) @mock.patch("sentry_sdk.metrics.distribution") - def test_distribution_with_sample_rate(self, mock_distribution, backend): + def test_distribution_with_sample_rate( + self, mock_distribution: mock.MagicMock, backend: SentrySDKMetricsBackend + ) -> None: with ( mock.patch.object(backend, "_should_send", return_value=True), mock.patch.object(backend, "_should_sample", return_value=True), @@ -177,7 +191,9 @@ def test_distribution_with_sample_rate(self, mock_distribution, backend): ) @mock.patch("sentry_sdk.metrics.distribution") - def test_timing_with_sample_rate(self, mock_distribution, backend): + def test_timing_with_sample_rate( + self, mock_distribution: mock.MagicMock, backend: SentrySDKMetricsBackend + ) -> None: with ( mock.patch.object(backend, "_should_send", return_value=True), mock.patch.object(backend, "_should_sample", return_value=True), @@ -192,7 +208,9 @@ def test_timing_with_sample_rate(self, mock_distribution, backend): ) @mock.patch("sentry_sdk.metrics.count") - def test_incr_effective_sample_rate_includes_experimental(self, mock_count): + def test_incr_effective_sample_rate_includes_experimental( + self, mock_count: mock.MagicMock + ) -> None: backend = SentrySDKMetricsBackend(prefix="test.", experimental_sample_rate=0.5) with ( mock.patch.object(backend, "_should_sample_experimental", return_value=True), diff --git a/tests/sentry/metrics/test_statsd.py b/tests/sentry/metrics/test_statsd.py index 65fc5b3e349b..71e1cadc0620 100644 --- a/tests/sentry/metrics/test_statsd.py +++ b/tests/sentry/metrics/test_statsd.py @@ -1,4 +1,5 @@ -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import MagicMock, patch import pytest @@ -6,7 +7,7 @@ @pytest.fixture(scope="module") -def statsd_backend(): +def statsd_backend() -> Generator[StatsdMetricsBackend]: backend = StatsdMetricsBackend(prefix="sentrytest.") try: yield backend @@ -16,24 +17,24 @@ def statsd_backend(): @patch("statsd.StatsClient.incr") -def test_incr(mock_incr, statsd_backend) -> None: +def test_incr(mock_incr: MagicMock, statsd_backend: StatsdMetricsBackend) -> None: statsd_backend.incr("foo") mock_incr.assert_called_once_with("sentrytest.foo", 1, 1) @patch("statsd.StatsClient.timing") -def test_timing(mock_timing, statsd_backend) -> None: +def test_timing(mock_timing: MagicMock, statsd_backend: StatsdMetricsBackend) -> None: statsd_backend.timing("foo", 30) mock_timing.assert_called_once_with("sentrytest.foo", 30, 1) @patch("statsd.StatsClient.gauge") -def test_gauge(mock_gauge, statsd_backend) -> None: +def test_gauge(mock_gauge: MagicMock, statsd_backend: StatsdMetricsBackend) -> None: statsd_backend.gauge("foo", 5) mock_gauge.assert_called_once_with("sentrytest.foo", 5, 1) @patch("statsd.StatsClient.set") -def test_set(mock_set, statsd_backend) -> None: +def test_set(mock_set: MagicMock, statsd_backend: StatsdMetricsBackend) -> None: statsd_backend.set("foo", 4242) mock_set.assert_called_once_with("sentrytest.foo", 4242, 1) diff --git a/tests/sentry/middleware/integrations/parsers/test_github.py b/tests/sentry/middleware/integrations/parsers/test_github.py index 7499e551b29e..33e1cd65d070 100644 --- a/tests/sentry/middleware/integrations/parsers/test_github.py +++ b/tests/sentry/middleware/integrations/parsers/test_github.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from unittest.mock import Mock, patch import pytest @@ -1294,6 +1295,6 @@ class GithubRequestParserTypeRoutingTest(GithubRequestParserTest): """ @pytest.fixture(autouse=True) - def setup(self): + def setup(self) -> Iterator[None]: with override_options({"github.webhook-type-routing.enabled": True}): yield diff --git a/tests/sentry/middleware/integrations/parsers/test_gitlab.py b/tests/sentry/middleware/integrations/parsers/test_gitlab.py index 9128d9bb02c2..a74411b9f8b0 100644 --- a/tests/sentry/middleware/integrations/parsers/test_gitlab.py +++ b/tests/sentry/middleware/integrations/parsers/test_gitlab.py @@ -2,7 +2,7 @@ import responses from django.db import connections, router, transaction -from django.http import HttpRequest, HttpResponse +from django.http import HttpRequest, HttpResponse, HttpResponseBase from django.test import RequestFactory, override_settings from django.test.utils import CaptureQueriesContext from django.urls import reverse @@ -65,7 +65,7 @@ def get_integration(self) -> Integration: }, ) - def run_parser(self, request): + def run_parser(self, request: HttpRequest) -> HttpResponseBase: parser = GitlabRequestParser(request=request, response_handler=self.get_response) return parser.get_response() diff --git a/tests/sentry/middleware/integrations/parsers/test_slack.py b/tests/sentry/middleware/integrations/parsers/test_slack.py index ef90ac332afd..a337fad8aad2 100644 --- a/tests/sentry/middleware/integrations/parsers/test_slack.py +++ b/tests/sentry/middleware/integrations/parsers/test_slack.py @@ -55,7 +55,9 @@ def setUp(self) -> None: def get_response(self, request: HttpRequest) -> HttpResponse: return HttpResponse(status=200, content="passthrough") - def _make_parser_with_seer_event(self, event_type: str = "app_mention", event_id: str = "E1"): + def _make_parser_with_seer_event( + self, event_type: str = "app_mention", event_id: str = "E1" + ) -> SlackRequestParser: data = { "type": "event_callback", "team_id": self.integration.external_id, @@ -280,7 +282,9 @@ def test_issue_submission_with_invalid_signature_is_not_queued( mock_slack_task.apply_async.assert_not_called() @patch("sentry.middleware.integrations.parsers.slack.convert_to_async_slack_response") - def test_skips_async_response_if_org_integration_missing(self, mock_slack_task): + def test_skips_async_response_if_org_integration_missing( + self, mock_slack_task: MagicMock + ) -> None: response_url = "https://hooks.slack.com/commands/TXXXXXXX1/1234567890123/something" data = { "payload": json.dumps( @@ -490,7 +494,7 @@ def test_targeting_issue_actions(self) -> None: def _make_link_identity_action_data( self, slack_user_id: str = "U1234567890", response_url: str = "" - ) -> dict: + ) -> dict[str, Any]: return { "payload": json.dumps( { @@ -520,7 +524,7 @@ def test_link_identity_handled_on_control_silo(self, mock_slack_task: MagicMock) mock_slack_task.apply_async.assert_not_called() @patch("sentry.middleware.integrations.parsers.slack.route_slack_seer_event.apply_async") - def test_seer_event_acks_200_and_enqueues_routing_task(self, mock_apply): + def test_seer_event_acks_200_and_enqueues_routing_task(self, mock_apply: MagicMock) -> None: parser = self._make_parser_with_seer_event(event_type="app_mention") response = parser.get_response() @@ -539,7 +543,7 @@ def test_seer_event_acks_200_and_enqueues_routing_task(self, mock_apply): assert kwargs["payload"]["path"].startswith("/extensions/slack/event") @patch("sentry.middleware.integrations.parsers.slack.route_slack_seer_event.apply_async") - def test_seer_event_dedupes_by_event_id(self, mock_apply): + def test_seer_event_dedupes_by_event_id(self, mock_apply: MagicMock) -> None: event_id = "EvDEDUP1" first = self._make_parser_with_seer_event( @@ -556,7 +560,7 @@ def test_seer_event_dedupes_by_event_id(self, mock_apply): mock_apply.assert_called_once() @patch("sentry.middleware.integrations.parsers.slack.route_slack_seer_event.apply_async") - def test_seer_event_releases_claim_on_schedule_failure(self, mock_apply): + def test_seer_event_releases_claim_on_schedule_failure(self, mock_apply: MagicMock) -> None: # Claim must not stick if apply_async fails — otherwise Slack retries # ACK as duplicates and Seer never runs for the TTL window. event_id = "EvSCHEDFAIL" @@ -577,7 +581,7 @@ def test_seer_event_releases_claim_on_schedule_failure(self, mock_apply): assert mock_apply.call_count == 2 @patch("sentry.middleware.integrations.parsers.slack.route_slack_seer_event.apply_async") - def test_seer_event_different_event_ids_are_not_deduped(self, mock_apply): + def test_seer_event_different_event_ids_are_not_deduped(self, mock_apply: MagicMock) -> None: self._make_parser_with_seer_event(event_type="app_mention", event_id="EvA").get_response() self._make_parser_with_seer_event(event_type="app_mention", event_id="EvB").get_response() @@ -602,7 +606,7 @@ def test_seer_event_missing_event_id_logs_and_schedules( @responses.activate @patch("sentry.middleware.integrations.parsers.slack.route_slack_seer_event.apply_async") - def test_non_seer_event_not_routed_through_task(self, mock_apply): + def test_non_seer_event_not_routed_through_task(self, mock_apply: MagicMock) -> None: responses.add( responses.POST, "http://us.testserver/extensions/slack/event/", diff --git a/tests/sentry/middleware/integrations/test_classifications.py b/tests/sentry/middleware/integrations/test_classifications.py index d65fca57d159..dd23c0ae9d75 100644 --- a/tests/sentry/middleware/integrations/test_classifications.py +++ b/tests/sentry/middleware/integrations/test_classifications.py @@ -1,7 +1,7 @@ from unittest import TestCase from unittest.mock import MagicMock, patch -from django.http import HttpResponse +from django.http import HttpRequest, HttpResponse from django.test import RequestFactory, override_settings from sentry.middleware.integrations.classifications import ( @@ -19,7 +19,7 @@ def setUp(self) -> None: super().setUp() self.factory = RequestFactory() - def validate_mock_ran_with_noop(self, request, mock): + def validate_mock_ran_with_noop(self, request: HttpRequest, mock: MagicMock) -> None: # Ensure mock runs when middleware is called mock.reset_mock() response = IntegrationControlMiddleware(get_response=self.get_response)(request) @@ -39,7 +39,7 @@ class IntegrationClassificationTest(BaseClassificationTestCase): "should_operate", wraps=integration_cls.should_operate, ) - def test_inactive_on_non_prefix(self, mock_should_operate) -> None: + def test_inactive_on_non_prefix(self, mock_should_operate: MagicMock) -> None: request = self.factory.get("/settings/") assert mock_should_operate(request) is False self.validate_mock_ran_with_noop(request, mock_should_operate) @@ -50,7 +50,7 @@ def test_inactive_on_non_prefix(self, mock_should_operate) -> None: "_identify_provider", wraps=integration_cls._identify_provider, ) - def test_invalid_provider(self, mock_identify_provider) -> None: + def test_invalid_provider(self, mock_identify_provider: MagicMock) -> None: request = self.factory.post(f"{self.prefix}🔥🔥🔥/webhook/") assert mock_identify_provider(request) == "🔥🔥🔥" self.validate_mock_ran_with_noop(request, mock_identify_provider) @@ -61,7 +61,7 @@ def test_invalid_provider(self, mock_identify_provider) -> None: "_identify_provider", wraps=integration_cls._identify_provider, ) - def test_empty_provider(self, mock_identify_provider) -> None: + def test_empty_provider(self, mock_identify_provider: MagicMock) -> None: request = self.factory.post(f"{self.prefix}/webhook/") assert mock_identify_provider(request) is None self.validate_mock_ran_with_noop(request, mock_identify_provider) @@ -72,7 +72,7 @@ def test_empty_provider(self, mock_identify_provider) -> None: "_identify_provider", wraps=integration_cls._identify_provider, ) - def test_unknown_provider(self, mock_identify_provider) -> None: + def test_unknown_provider(self, mock_identify_provider: MagicMock) -> None: provider = "acme" request = self.factory.post(f"{self.prefix}{provider}/webhook/") assert mock_identify_provider(request) == provider @@ -81,7 +81,7 @@ def test_unknown_provider(self, mock_identify_provider) -> None: @override_settings(SILO_MODE=SiloMode.CONTROL) @patch.object(SlackRequestParser, "get_response") - def test_returns_parser_get_response(self, mock_parser_get_response) -> None: + def test_returns_parser_get_response(self, mock_parser_get_response: MagicMock) -> None: result = HttpResponse(status=204) mock_parser_get_response.return_value = result response = self.integration_cls.get_response( diff --git a/tests/sentry/middleware/integrations/test_integration_control.py b/tests/sentry/middleware/integrations/test_integration_control.py index 94da48d8febb..8df1bda68933 100644 --- a/tests/sentry/middleware/integrations/test_integration_control.py +++ b/tests/sentry/middleware/integrations/test_integration_control.py @@ -1,7 +1,7 @@ from unittest import TestCase from unittest.mock import MagicMock, patch -from django.http import HttpResponse +from django.http import HttpRequest, HttpResponse from django.test import RequestFactory, override_settings from sentry.middleware.integrations.classifications import ( @@ -28,7 +28,7 @@ class IntegrationControlMiddlewareTest(TestCase): def setUp(self) -> None: self.factory = RequestFactory() - def validate_mock_ran_with_noop(self, request, mock): + def validate_mock_ran_with_noop(self, request: HttpRequest, mock: MagicMock) -> None: # Ensure mock runs when middleware is called mock.reset_mock() response = self.middleware(request) @@ -42,7 +42,7 @@ def validate_mock_ran_with_noop(self, request, mock): "_should_operate", wraps=middleware._should_operate, ) - def test_inactive_on_monolith(self, mock_should_operate) -> None: + def test_inactive_on_monolith(self, mock_should_operate: MagicMock) -> None: request = self.factory.post("/extensions/slack/webhook/") assert mock_should_operate(request) is False self.validate_mock_ran_with_noop(request, mock_should_operate) @@ -53,14 +53,14 @@ def test_inactive_on_monolith(self, mock_should_operate) -> None: "_should_operate", wraps=middleware._should_operate, ) - def test_inactive_on_region_silo(self, mock_should_operate) -> None: + def test_inactive_on_region_silo(self, mock_should_operate: MagicMock) -> None: request = self.factory.post("/extensions/slack/webhook/") assert mock_should_operate(request) is False self.validate_mock_ran_with_noop(request, mock_should_operate) @override_settings(SILO_MODE=SiloMode.CONTROL) @patch.object(IntegrationClassification, "should_operate", wraps=integration_cls.should_operate) - def test_attempts_all_classifications(self, mock_integration_operate) -> None: + def test_attempts_all_classifications(self, mock_integration_operate: MagicMock) -> None: class NewClassification(BaseClassification): pass @@ -79,7 +79,9 @@ class NewClassification(BaseClassification): @override_settings(SILO_MODE=SiloMode.CONTROL) @patch.object(SlackRequestParser, "get_response") - def test_returns_parser_get_response_integration(self, mock_parser_get_response) -> None: + def test_returns_parser_get_response_integration( + self, mock_parser_get_response: MagicMock + ) -> None: result = HttpResponse(status=204) mock_parser_get_response.return_value = result response = self.middleware(self.factory.post("/extensions/slack/webhook/")) @@ -87,7 +89,9 @@ def test_returns_parser_get_response_integration(self, mock_parser_get_response) @override_settings(SILO_MODE=SiloMode.CONTROL) @patch.object(JiraServerRequestParser, "get_response") - def test_returns_parser_get_response_jiraserver(self, mock_parser_get_response) -> None: + def test_returns_parser_get_response_jiraserver( + self, mock_parser_get_response: MagicMock + ) -> None: result = HttpResponse(status=204) mock_parser_get_response.return_value = result response = self.middleware( @@ -103,7 +107,7 @@ def test_returns_parser_get_response_jiraserver(self, mock_parser_get_response) @override_settings(SILO_MODE=SiloMode.CONTROL) @patch.object(JiraRequestParser, "get_response") - def test_returns_parser_get_response_jira(self, mock_parser_get_response) -> None: + def test_returns_parser_get_response_jira(self, mock_parser_get_response: MagicMock) -> None: result = HttpResponse(status=204) mock_parser_get_response.return_value = result response = self.middleware(self.factory.post("/extensions/jira/issue-updated/abc-123/")) diff --git a/tests/sentry/middleware/integrations/test_parsers_defined.py b/tests/sentry/middleware/integrations/test_parsers_defined.py index a09628991ab9..668c425f0614 100644 --- a/tests/sentry/middleware/integrations/test_parsers_defined.py +++ b/tests/sentry/middleware/integrations/test_parsers_defined.py @@ -1,4 +1,6 @@ import importlib +from collections.abc import Iterator +from typing import Any from django.http import HttpResponse from django.urls import URLResolver, get_resolver @@ -6,7 +8,7 @@ from sentry.middleware.integrations.classifications import IntegrationClassification -def extract_matching_url_patterns(urlpatterns, base: str = ""): +def extract_matching_url_patterns(urlpatterns: Any, base: str = "") -> Iterator[str]: for pattern in urlpatterns: if isinstance(pattern, URLResolver): yield from extract_matching_url_patterns( diff --git a/tests/sentry/middleware/test_access_log_middleware.py b/tests/sentry/middleware/test_access_log_middleware.py index d86dfc99d8b6..e4bc5a211c47 100644 --- a/tests/sentry/middleware/test_access_log_middleware.py +++ b/tests/sentry/middleware/test_access_log_middleware.py @@ -1,11 +1,14 @@ import logging +from collections.abc import Callable +from typing import Any, cast from urllib.parse import unquote import pytest -from django.http import HttpRequest +from django.http import HttpRequest, HttpResponseBase from django.test import override_settings from django.urls import re_path, reverse from rest_framework.permissions import AllowAny +from rest_framework.request import Request from rest_framework.response import Response from sentry.api.base import Endpoint @@ -13,6 +16,8 @@ from sentry.api.endpoints.internal.rpc import InternalRpcServiceEndpoint from sentry.api.permissions import SentryIsAuthenticated from sentry.models.apitoken import ApiToken +from sentry.models.organization import Organization +from sentry.organizations.services.organization.model import RpcUserOrganizationContext from sentry.ratelimits.config import RateLimitConfig from sentry.silo.base import SiloMode from sentry.silo.util import PROXY_APIGATEWAY_HEADER @@ -29,10 +34,10 @@ class ImpersonationTestMiddleware: This middleware should be placed before the access log middleware in tests. """ - def __init__(self, get_response): + def __init__(self, get_response: Callable[[HttpRequest], HttpResponseBase]): self.get_response = get_response - def __call__(self, request: HttpRequest): + def __call__(self, request: HttpRequest) -> Any: # Check for impersonator ID in header impersonator_id = request.META.get("HTTP_X_IMPERSONATOR_ID") if impersonator_id: @@ -53,23 +58,23 @@ def __call__(self, request: HttpRequest): class DummyEndpoint(Endpoint): permission_classes = (SentryIsAuthenticated,) - def get(self, request): + def get(self, request: HttpRequest) -> Response: return Response({"ok": True}) class DummyFailEndpoint(Endpoint): permission_classes = (AllowAny,) - def get(self, request): + def get(self, request: HttpRequest) -> None: raise Exception("this is bad yo") class SnubaRateLimitedEndpoint(Endpoint): permission_classes = (AllowAny,) - def get(self, request): + def get(self, request: Request) -> None: # Rate limit middleware will set metadata to indicate the request is not limited by the endpoint itself - request._request.rate_limit_metadata = RateLimitMeta( + cast(Any, request._request).rate_limit_metadata = RateLimitMeta( rate_limit_type=RateLimitType.NOT_LIMITED, concurrent_limit=123, concurrent_requests=1, @@ -108,7 +113,7 @@ class RateLimitedEndpoint(Endpoint): }, ) - def get(self, request): + def get(self, request: HttpRequest) -> None: raise NotImplementedError @@ -126,17 +131,22 @@ class ConcurrentRateLimitedEndpoint(Endpoint): }, ) - def get(self, request): + def get(self, request: HttpRequest) -> Response: return Response({"ok": True}) class MyOrganizationEndpoint(OrganizationEndpoint): - def get(self, request, organization): + def get(self, request: HttpRequest, organization: Organization) -> Response: return Response({"ok": True}) class MyControlOrganizationEndpoint(ControlSiloOrganizationEndpoint): - def get(self, request, organization_context, organization): + def get( + self, + request: HttpRequest, + organization_context: RpcUserOrganizationContext, + organization: Organization, + ) -> Response: return Response({"ok": True}) @@ -226,20 +236,20 @@ def get(self, request, organization_context, organization): ) class LogCaptureAPITestCase(APITestCase): @pytest.fixture(autouse=True) - def inject_fixtures(self, caplog: pytest.LogCaptureFixture): + def inject_fixtures(self, caplog: pytest.LogCaptureFixture) -> None: self._caplog = caplog - def assert_access_log_recorded(self): + def assert_access_log_recorded(self) -> None: sentinel = object() for record in self.captured_logs: for field in required_access_log_fields: assert getattr(record, field, sentinel) != sentinel, field @property - def captured_logs(self): + def captured_logs(self) -> Any: return [r for r in self._caplog.records if r.name == "sentry.access.api"] - def get_tested_log(self, **kwargs): + def get_tested_log(self, **kwargs: Any) -> Any: tested_log_path = unquote(reverse(self.endpoint, **kwargs)) return next(log for log in self.captured_logs if log.path == tested_log_path) diff --git a/tests/sentry/middleware/test_ai_agent.py b/tests/sentry/middleware/test_ai_agent.py index eef9f93dbea6..0ba947165292 100644 --- a/tests/sentry/middleware/test_ai_agent.py +++ b/tests/sentry/middleware/test_ai_agent.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, patch from django.contrib.auth.models import AnonymousUser @@ -39,7 +40,7 @@ def setUp(self) -> None: self.factory = RequestFactory() self.middleware = AIAgentMiddleware(get_response=lambda r: MagicMock(status_code=401)) - def make_anonymous_request(self, path: str, **kwargs): + def make_anonymous_request(self, path: str, **kwargs: Any) -> Any: request = self.factory.get(path, **kwargs) request.user = AnonymousUser() request.auth = None @@ -106,7 +107,7 @@ def test_oauth_path_passes_through(self) -> None: assert self.middleware(request).status_code == 401 @patch("sentry.middleware.ai_agent.logger.info") - def test_logs_request(self, mock_logger: MagicMock): + def test_logs_request(self, mock_logger: MagicMock) -> None: request = self.make_anonymous_request( "/organizations/test-org/", HTTP_ACCEPT="text/markdown", diff --git a/tests/sentry/middleware/test_auth.py b/tests/sentry/middleware/test_auth.py index b6662e5e3150..9ed2a7b4ec0f 100644 --- a/tests/sentry/middleware/test_auth.py +++ b/tests/sentry/middleware/test_auth.py @@ -2,6 +2,7 @@ from typing import Any from unittest.mock import MagicMock, patch +from django.http import HttpRequest from django.test import RequestFactory, override_settings from rest_framework.request import Request @@ -16,6 +17,7 @@ from sentry.testutils.helpers.features import with_feature from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import all_silo_test, assume_test_silo_mode, cell_silo_test +from sentry.users.models.user import User from sentry.users.models.userip import UserIP from sentry.users.services.user.service import user_service from sentry.utils import jwt @@ -27,11 +29,11 @@ class AuthenticationMiddlewareTestCase(TestCase): middleware = cached_property(AuthenticationMiddleware) - def assert_user_equals(self, request): + def assert_user_equals(self, request: HttpRequest) -> None: assert request.user == user_service.get_user(user_id=self.user.id) @cached_property - def request(self): + def request(self) -> Any: rv = RequestFactory().get("/") rv.session = self.session return rv @@ -113,7 +115,7 @@ def test_process_request_invalid_authtoken(self) -> None: assert request.user.is_anonymous assert request.auth is None - def _agent_token(self, user) -> str: + def _agent_token(self, user: User) -> str: token, _ = agent_token.encode_agent_token( user_id=user.id, organization_id=self.organization.id, diff --git a/tests/sentry/middleware/test_customer_domain.py b/tests/sentry/middleware/test_customer_domain.py index d79ddd383447..819cf9624d8f 100644 --- a/tests/sentry/middleware/test_customer_domain.py +++ b/tests/sentry/middleware/test_customer_domain.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Any from unittest import mock from django.conf import settings @@ -183,7 +184,7 @@ def test_billing_route(self) -> None: class OrganizationTestEndpoint(Endpoint): permission_classes = (AllowAny,) - def get(self, request, organization_id_or_slug): + def get(self, request: HttpRequest, organization_id_or_slug: str) -> Response: return Response( { "organization_id_or_slug": organization_id_or_slug, @@ -192,7 +193,7 @@ def get(self, request, organization_id_or_slug): } ) - def post(self, request, organization_id_or_slug): + def post(self, request: HttpRequest, organization_id_or_slug: str) -> Response: request.session["activeorg"] = organization_id_or_slug return Response( { @@ -206,7 +207,7 @@ def post(self, request, organization_id_or_slug): class OrganizationIdOrSlugTestEndpoint(Endpoint): permission_classes = (AllowAny,) - def get(self, request, organization_id_or_slug): + def get(self, request: HttpRequest, organization_id_or_slug: str) -> Response: return Response( { "organization_id_or_slug": organization_id_or_slug, @@ -235,7 +236,7 @@ def get(self, request, organization_id_or_slug): ] -def provision_middleware(): +def provision_middleware() -> Any: middleware = list(settings.MIDDLEWARE) if "sentry.middleware.customer_domain.CustomerDomainMiddleware" not in middleware: index = middleware.index("sentry.middleware.auth.AuthenticationMiddleware") diff --git a/tests/sentry/middleware/test_demo_mode_guard.py b/tests/sentry/middleware/test_demo_mode_guard.py index 1d103bdd28a0..cfecba0e29ef 100644 --- a/tests/sentry/middleware/test_demo_mode_guard.py +++ b/tests/sentry/middleware/test_demo_mode_guard.py @@ -1,7 +1,8 @@ from functools import cached_property +from typing import Any from unittest.mock import sentinel -from django.http import HttpResponseRedirect +from django.http import HttpRequest, HttpResponseRedirect from django.test import RequestFactory from sentry.middleware.demo_mode_guard import DemoModeGuardMiddleware @@ -15,11 +16,11 @@ class DemoModeGuardMiddlewareTestCase(TestCase): middleware = DemoModeGuardMiddleware(lambda request: sentinel.response) - def assert_user_equals(self, request): + def assert_user_equals(self, request: HttpRequest) -> None: assert request.user == user_service.get_user(user_id=self.user.id) @cached_property - def request(self): + def request(self) -> Any: rv = RequestFactory().get("/") rv.session = self.session rv.subdomain = None diff --git a/tests/sentry/middleware/test_devtoolbar.py b/tests/sentry/middleware/test_devtoolbar.py index d7af2cd8be30..4e56145f3cef 100644 --- a/tests/sentry/middleware/test_devtoolbar.py +++ b/tests/sentry/middleware/test_devtoolbar.py @@ -1,5 +1,5 @@ from functools import cached_property -from typing import cast +from typing import Any, cast from unittest.mock import MagicMock, patch from django.http import HttpResponse @@ -18,12 +18,13 @@ class DevToolbarAnalyticsMiddlewareUnitTest(TestCase): analytics_event_name = DevToolbarApiRequestEvent.type @cached_property - def factory(self): + def factory(self) -> RequestFactory: return RequestFactory() def setUp(self) -> None: # Allows changing the get_response mock for each test. - self.middleware.get_response = MagicMock(return_value=HttpResponse(status=200)) + self.get_response = MagicMock(return_value=HttpResponse(status=200)) + self.middleware.get_response = self.get_response @override_options({"devtoolbar.analytics.enabled": True}) @patch("sentry.analytics.record") @@ -126,7 +127,7 @@ def test_origin_from_referrer(self, mock_record: MagicMock) -> None: def test_response_status_code(self, mock_record: MagicMock) -> None: request = self.factory.get("/?queryReferrer=devtoolbar") request.resolver_match = MagicMock(view_name="my-endpoint", route="/issues/(?P)/") - self.middleware.get_response.return_value = HttpResponse(status=420) + self.get_response.return_value = HttpResponse(status=420) self.middleware(request) event = cast(DevToolbarApiRequestEvent, get_last_analytics_event(mock_record)) @@ -173,11 +174,11 @@ def _test_endpoint( expected_view_name: str, expected_route: str, mock_record: MagicMock, - expected_org_id=None, - expected_org_slug=None, - expected_proj_id=None, - expected_proj_slug=None, - ): + expected_org_id: Any = None, + expected_org_slug: Any = None, + expected_proj_id: Any = None, + expected_proj_slug: Any = None, + ) -> None: url = path + query_string response: HttpResponse = getattr(self.client, method.lower())( url, diff --git a/tests/sentry/middleware/test_health.py b/tests/sentry/middleware/test_health.py index f8e5a3c16c0f..e2844cb1f383 100644 --- a/tests/sentry/middleware/test_health.py +++ b/tests/sentry/middleware/test_health.py @@ -1,4 +1,5 @@ from functools import cached_property +from typing import Any from unittest import TestCase from unittest.mock import MagicMock, patch @@ -13,7 +14,7 @@ class HealthCheckTest(TestCase): middleware = cached_property(HealthCheck) @cached_property - def factory(self): + def factory(self) -> Any: return RequestFactory() @patch("sentry.status_checks.check_all") @@ -27,6 +28,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 +37,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 +49,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/middleware/test_ratelimit_middleware.py b/tests/sentry/middleware/test_ratelimit_middleware.py index 2a522697f324..8c75c8a41fb4 100644 --- a/tests/sentry/middleware/test_ratelimit_middleware.py +++ b/tests/sentry/middleware/test_ratelimit_middleware.py @@ -1,5 +1,6 @@ from functools import cached_property from time import sleep, time +from typing import Any, cast from unittest.mock import MagicMock, patch, sentinel import orjson @@ -11,6 +12,7 @@ from rest_framework.response import Response from sentry.api.base import Endpoint +from sentry.auth.services.auth import AuthenticatedToken from sentry.middleware.ratelimit import RatelimitMiddleware from sentry.ratelimits.config import RateLimitConfig, get_default_rate_limits_for_group from sentry.ratelimits.utils import get_rate_limit_config, get_rate_limit_value @@ -28,33 +30,35 @@ class RatelimitMiddlewareTest(TestCase, BaseTestCase): middleware = RatelimitMiddleware(lambda request: sentinel.response) @cached_property - def factory(self): + def factory(self) -> RequestFactory: return RequestFactory() class TestEndpoint(Endpoint): enforce_rate_limit = True - def get(self): + def get(self) -> None: raise NotImplementedError class TestEndpointNoRateLimits(Endpoint): enforce_rate_limit = False - def get(self): + def get(self) -> None: raise NotImplementedError _test_endpoint = TestEndpoint.as_view() _test_endpoint_no_rate_limits = TestEndpointNoRateLimits.as_view() - def _populate_public_integration_request(self, request) -> None: + def _populate_public_integration_request(self, request: HttpRequest) -> None: install = self.create_sentry_app_installation(organization=self.organization) token = install.api_token + assert token is not None with assume_test_silo_mode_of(User): request.user = User.objects.get(id=install.sentry_app.proxy_user_id) - request.auth = token + # justification: the middleware receives raw token models though the request attribute is typed for converted tokens. + request.auth = cast("AuthenticatedToken", token) - def _populate_internal_integration_request(self, request) -> None: + def _populate_internal_integration_request(self, request: HttpRequest) -> None: internal_integration = self.create_internal_integration( name="my_app", organization=self.organization, @@ -67,24 +71,25 @@ def _populate_internal_integration_request(self, request) -> None: ) with assume_test_silo_mode_of(User): request.user = User.objects.get(id=internal_integration.proxy_user_id) - request.auth = token + # justification: the middleware receives raw token models though the request attribute is typed for converted tokens. + request.auth = cast("AuthenticatedToken", token) @patch("sentry.middleware.ratelimit.get_rate_limit_value", side_effect=Exception) def test_fails_open(self, default_rate_limit_mock: MagicMock) -> None: """Test that if something goes wrong in the rate limit middleware, the request still goes through""" - request = self.factory.get("/") + request: Any = self.factory.get("/") with freeze_time("2000-01-01"): default_rate_limit_mock.return_value = RateLimit(limit=0, window=100) self.middleware.process_view(request, self._test_endpoint, [], {}) def test_process_response_fails_open(self) -> None: - request = self.factory.get("/") + request: Any = self.factory.get("/") bad_response = sentinel.response assert self.middleware.process_response(request, bad_response) is bad_response class BadRequest(HttpRequest): - def __getattr__(self, attr): + def __getattr__(self, attr: str) -> None: raise Exception("nope") bad_request = BadRequest() @@ -92,7 +97,7 @@ def __getattr__(self, attr): @patch("sentry.middleware.ratelimit.get_rate_limit_value") def test_positive_rate_limit_check(self, default_rate_limit_mock: MagicMock) -> None: - request = self.factory.get("/") + request: Any = self.factory.get("/") with freeze_time("2000-01-01"): default_rate_limit_mock.return_value = RateLimit(limit=0, window=100) self.middleware.process_view(request, self._test_endpoint, [], {}) @@ -110,7 +115,7 @@ def test_positive_rate_limit_check(self, default_rate_limit_mock: MagicMock) -> @patch("sentry.middleware.ratelimit.get_rate_limit_value") def test_positive_rate_limit_response_headers(self, default_rate_limit_mock: MagicMock) -> None: - request = self.factory.get("/") + request: Any = self.factory.get("/") with ( freeze_time("2000-01-01"), @@ -133,9 +138,9 @@ def test_positive_rate_limit_response_headers(self, default_rate_limit_mock: Mag @patch("sentry.ratelimits.utils.ratelimiter.is_limited_with_value") @override_settings(ENFORCE_CONCURRENT_RATE_LIMITS=True) def test_positive_concurrent_rate_limit_response_headers( - self, is_limited_with_value, default_rate_limit_mock - ): - request = self.factory.get("/") + self, is_limited_with_value: MagicMock, default_rate_limit_mock: MagicMock + ) -> None: + request: Any = self.factory.get("/") with ( freeze_time("2000-01-01"), @@ -162,7 +167,7 @@ def test_positive_concurrent_rate_limit_response_headers( @patch("sentry.middleware.ratelimit.get_rate_limit_value") def test_negative_rate_limit_check(self, default_rate_limit_mock: MagicMock) -> None: - request = self.factory.get("/") + request: Any = self.factory.get("/") default_rate_limit_mock.return_value = RateLimit(limit=10, window=100) self.middleware.process_view(request, self._test_endpoint, [], {}) assert not request.will_be_rate_limited @@ -180,7 +185,7 @@ def test_negative_rate_limit_check(self, default_rate_limit_mock: MagicMock) -> @override_settings(SENTRY_SELF_HOSTED=True) def test_self_hosted_rate_limit_check(self, default_rate_limit_mock: MagicMock) -> None: """Check that for self hosted installs we don't rate limit""" - request = self.factory.get("/") + request: Any = self.factory.get("/") default_rate_limit_mock.return_value = RateLimit(limit=10, window=100) self.middleware.process_view(request, self._test_endpoint, [], {}) assert not request.will_be_rate_limited @@ -194,7 +199,7 @@ def test_self_hosted_rate_limit_check(self, default_rate_limit_mock: MagicMock) assert not request.will_be_rate_limited def test_rate_limit_category(self) -> None: - request = self.factory.get("/") + request: Any = self.factory.get("/") request.META["REMOTE_ADDR"] = None self.middleware.process_view(request, self._test_endpoint, [], {}) assert request.rate_limit_category is None @@ -217,7 +222,7 @@ def test_rate_limit_category(self) -> None: assert request.rate_limit_category == RateLimitCategory.ORGANIZATION def test_enforce_rate_limit_is_false(self) -> None: - request = self.factory.get("/") + request: Any = self.factory.get("/") self.middleware.process_view(request, self._test_endpoint_no_rate_limits, [], {}) assert request.will_be_rate_limited is False assert request.rate_limit_category is None @@ -227,7 +232,7 @@ def test_enforce_rate_limit_is_false(self) -> None: @override_settings(SENTRY_IMPERSONATION_RATE_LIMIT=1) def test_impersonation_enforces_rate_limits_when_disabled(self) -> None: """Test that rate limiting is enforced during impersonation even when endpoint has enforce_rate_limit=False""" - request = self.factory.get("/") + request: Any = self.factory.get("/") request.session = {} request.user = self.user @@ -252,7 +257,7 @@ def test_impersonation_enforces_rate_limits_when_disabled(self) -> None: @override_settings(SENTRY_IMPERSONATION_RATE_LIMIT=1) def test_impersonation_without_actual_user_not_enforced(self) -> None: """Test that endpoints with enforce_rate_limit=False are not rate limited without impersonation""" - request = self.factory.get("/") + request: Any = self.factory.get("/") request.session = {} request.user = self.user @@ -280,12 +285,12 @@ class HighLimitEndpoint(Endpoint): } ) - def get(self): + def get(self) -> None: raise NotImplementedError high_limit_view = HighLimitEndpoint.as_view() - request = self.factory.get("/") + request: Any = self.factory.get("/") request.session = {} request.user = self.user @@ -311,7 +316,7 @@ def test_impersonation_rate_limit_uses_actual_user(self) -> None: impersonated_user = self.create_user(email="impersonated@example.com") impersonator_user = self.create_user(email="impersonator@example.com") - request = self.factory.get("/") + request: Any = self.factory.get("/") request.session = {} request.user = impersonated_user request.actual_user = impersonator_user @@ -397,10 +402,10 @@ class TestEndpointWithRate(Endpoint): } ) - def get(self): + def get(self) -> None: raise NotImplementedError - request = self.factory.get("/") + request: Any = self.factory.get("/") request.session = {} request.user = self.user @@ -481,10 +486,10 @@ class RateLimitHeaderTestEndpoint(Endpoint): limit_overrides={"GET": {RateLimitCategory.IP: RateLimit(limit=2, window=100)}} ) - def inject_call(self): + def inject_call(self) -> None: return - def get(self, request): + def get(self, request: HttpRequest) -> Response: self.inject_call() return Response({"ok": True}) @@ -497,7 +502,7 @@ class RaceConditionEndpoint(Endpoint): limit_overrides={"GET": {RateLimitCategory.IP: RateLimit(limit=40, window=100)}} ) - def get(self, request): + def get(self, request: HttpRequest) -> Response: return Response({"ok": True}) @@ -519,7 +524,7 @@ class ConcurrentRateLimitedEndpoint(Endpoint): }, ) - def get(self, request): + def get(self, request: HttpRequest) -> Response: sleep(CONCURRENT_ENDPOINT_DURATION) return Response({"ok": True}) @@ -579,7 +584,7 @@ def test_omit_header(self, can_be_ratelimited_patch: MagicMock) -> None: def test_header_race_condition(self) -> None: """Make sure concurrent requests don't affect each other's rate limit""" - def parallel_request(*args, **kwargs): + def parallel_request(*args: Any, **kwargs: Any) -> None: self.client.get(reverse("race-condition-endpoint")) with patch.object( diff --git a/tests/sentry/middleware/test_security.py b/tests/sentry/middleware/test_security.py index 2fe910844619..bc0020b008c3 100644 --- a/tests/sentry/middleware/test_security.py +++ b/tests/sentry/middleware/test_security.py @@ -1,6 +1,7 @@ from __future__ import annotations from functools import cached_property +from typing import Any from unittest import TestCase from django.test import RequestFactory, override_settings @@ -14,7 +15,7 @@ class SecurityHeadersMiddlewareTest(TestCase): middleware = cached_property(SecurityHeadersMiddleware) @cached_property - def factory(self): + def factory(self) -> Any: return RequestFactory() def test_standard_headers_set(self) -> None: diff --git a/tests/sentry/middleware/test_staff.py b/tests/sentry/middleware/test_staff.py index d9ce8edc2276..31334c775280 100644 --- a/tests/sentry/middleware/test_staff.py +++ b/tests/sentry/middleware/test_staff.py @@ -1,5 +1,6 @@ from __future__ import annotations +from django.http import HttpRequest from django.test import override_settings from django.urls import re_path from rest_framework.permissions import AllowAny @@ -13,7 +14,7 @@ class APITestEndpoint(Endpoint): permission_classes = (AllowAny,) - def get(self, request): + def get(self, request: HttpRequest) -> Response: return Response(status=200) diff --git a/tests/sentry/middleware/test_stats.py b/tests/sentry/middleware/test_stats.py index a4055db8e576..493ab16b7445 100644 --- a/tests/sentry/middleware/test_stats.py +++ b/tests/sentry/middleware/test_stats.py @@ -1,4 +1,5 @@ from functools import cached_property +from typing import Any from unittest.mock import MagicMock, Mock, patch, sentinel from django.test import RequestFactory, override_settings @@ -21,7 +22,7 @@ class RateLimitedEndpoint(Endpoint): limit_overrides={"GET": {RateLimitCategory.IP: RateLimit(limit=0, window=10)}} ) - def get(self): + def get(self) -> None: raise NotImplementedError @@ -29,7 +30,7 @@ class RequestTimingMiddlewareTest(TestCase): middleware = cached_property(RequestTimingMiddleware) @cached_property - def factory(self): + def factory(self) -> Any: return RequestFactory() @patch("sentry.utils.metrics.incr") diff --git a/tests/sentry/middleware/test_subdomain.py b/tests/sentry/middleware/test_subdomain.py index 0177fb1baa9a..cedf9dfcf955 100644 --- a/tests/sentry/middleware/test_subdomain.py +++ b/tests/sentry/middleware/test_subdomain.py @@ -1,6 +1,8 @@ from __future__ import annotations +from typing import cast from unittest import mock +from unittest.mock import MagicMock from django.conf import settings from django.http import HttpResponseRedirect @@ -23,7 +25,8 @@ def request_with_host(host: str) -> tuple[HttpRequest, HttpResponseBase]: def get_response(request: HttpRequest) -> HttpResponseBase: nonlocal got_request got_request = request - return mock.sentinel.response + # justification: the middleware test stubs the view response with a sentinel. + return cast("HttpResponseBase", mock.sentinel.response) subdomain_middleware = SubdomainMiddleware(get_response) request = RequestFactory().get("/", HTTP_HOST=host) @@ -42,7 +45,7 @@ def run_response(host: str) -> HttpResponseBase: class SubdomainMiddlewareTest(TestCase): @override_settings(ALLOWED_HOSTS=["web-default.sentry-internal", "web.sentry.io"]) @mock.patch("sentry.middleware.subdomain.sentry_sdk.capture_exception") - def test_disallowed_host_captured(self, mock_capture) -> None: + def test_disallowed_host_captured(self, mock_capture: MagicMock) -> None: assert run_response("web-default.sentry-internal") == mock.sentinel.response assert not mock_capture.called @@ -77,7 +80,7 @@ def test_attaches_subdomain_attribute(self) -> None: class APITestEndpoint(Endpoint): permission_classes = (AllowAny,) - def get(self, request): + def get(self, request: HttpRequest) -> Response: # return HttpResponse(status=status.HTTP_200_OK) return Response( { diff --git a/tests/sentry/middleware/test_superuser.py b/tests/sentry/middleware/test_superuser.py index b30aa18e1707..213598ae50fd 100644 --- a/tests/sentry/middleware/test_superuser.py +++ b/tests/sentry/middleware/test_superuser.py @@ -1,4 +1,5 @@ from functools import cached_property +from typing import Any from unittest.mock import MagicMock, patch from django.test import RequestFactory, override_settings @@ -12,7 +13,7 @@ class SuperuserMiddlewareTestCase(TestCase): middleware = cached_property(SuperuserMiddleware) - def _create_request(self, is_superuser: bool): + def _create_request(self, is_superuser: bool) -> Any: request = RequestFactory().get("/") request.user = self.user request.session = self.session diff --git a/tests/sentry/middleware/test_suspended.py b/tests/sentry/middleware/test_suspended.py index 0f2438c15470..3d7508a57867 100644 --- a/tests/sentry/middleware/test_suspended.py +++ b/tests/sentry/middleware/test_suspended.py @@ -1,10 +1,13 @@ from __future__ import annotations +from collections.abc import Callable from functools import cached_property +from typing import Any from unittest.mock import MagicMock, patch, sentinel from django.conf import settings from django.contrib.auth.models import AnonymousUser +from django.http import HttpRequest, HttpResponseBase from django.test import RequestFactory, override_settings from django.urls import re_path from django.utils.deprecation import MiddlewareMixin @@ -18,6 +21,7 @@ from sentry.testutils.cases import APITestCase, TestCase from sentry.testutils.silo import no_silo_test 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 import json from sentry.utils.auth import EmailAuthBackend @@ -27,44 +31,44 @@ class SuspendedUserMiddlewareUnitTest(TestCase): middleware = SuspendedUserMiddleware(lambda request: sentinel.response) @cached_property - def factory(self): + def factory(self) -> RequestFactory: return RequestFactory() - def _make_request(self, path="/test/", method="GET"): + def _make_request(self, path: str = "/test/", method: str = "GET") -> HttpRequest: factory_method = getattr(self.factory, method.lower(), self.factory.get) return factory_method(path) - def _make_mock_user(self, *, is_suspended=False): + def _make_mock_user(self, *, is_suspended: bool = False) -> MagicMock: mock_user = MagicMock() mock_user.is_authenticated = True mock_user.is_suspended = is_suspended mock_user.id = self.user.id return mock_user - def _make_suspended_request(self, path="/test/", method="GET"): + def _make_suspended_request(self, path: str = "/test/", method: str = "GET") -> HttpRequest: request = self._make_request(path, method) request.user = self._make_mock_user(is_suspended=True) return request - def test_anonymous_user_passes_through(self): + def test_anonymous_user_passes_through(self) -> None: request = self._make_request() request.user = AnonymousUser() result = self.middleware.process_request(request) assert result is None - def test_no_user_attribute_passes_through(self): + def test_no_user_attribute_passes_through(self) -> None: request = self._make_request() assert not hasattr(request, "user") result = self.middleware.process_request(request) assert result is None - def test_authenticated_non_suspended_user_passes_through(self): + def test_authenticated_non_suspended_user_passes_through(self) -> None: request = self._make_request() request.user = self._make_mock_user(is_suspended=False) result = self.middleware.process_request(request) assert result is None - def test_suspended_user_blocked_api_path(self): + def test_suspended_user_blocked_api_path(self) -> None: request = self._make_suspended_request(path="/api/0/organizations/") result = self.middleware.process_request(request) assert result is not None @@ -72,57 +76,57 @@ def test_suspended_user_blocked_api_path(self): body = json.loads(result.content) assert body == {"detail": "Your account has been suspended."} - def test_suspended_user_blocked_web_path(self): + def test_suspended_user_blocked_web_path(self) -> None: request = self._make_suspended_request(path="/organizations/foo/") result = self.middleware.process_request(request) assert result is not None assert result.status_code == 403 assert result.content == b"Your account has been suspended." - def test_exempt_path_reactivate(self): + def test_exempt_path_reactivate(self) -> None: request = self._make_suspended_request(path="/auth/reactivate/") result = self.middleware.process_request(request) assert result is None - def test_exempt_path_login(self): + def test_exempt_path_login(self) -> None: request = self._make_suspended_request(path="/auth/login/") result = self.middleware.process_request(request) assert result is None - def test_exempt_path_logout(self): + def test_exempt_path_logout(self) -> None: request = self._make_suspended_request(path="/auth/logout/") result = self.middleware.process_request(request) assert result is None - def test_exempt_path_api_auth_login(self): + def test_exempt_path_api_auth_login(self) -> None: request = self._make_suspended_request(path="/api/0/auth/login/") result = self.middleware.process_request(request) assert result is None - def test_api_auth_delete_exempt(self): + def test_api_auth_delete_exempt(self) -> None: request = self._make_suspended_request(path="/api/0/auth/", method="DELETE") result = self.middleware.process_request(request) assert result is None - def test_api_auth_get_blocked(self): + def test_api_auth_get_blocked(self) -> None: request = self._make_suspended_request(path="/api/0/auth/") result = self.middleware.process_request(request) assert result is not None assert result.status_code == 403 - def test_api_auth_put_blocked(self): + def test_api_auth_put_blocked(self) -> None: request = self._make_suspended_request(path="/api/0/auth/", method="PUT") result = self.middleware.process_request(request) assert result is not None assert result.status_code == 403 - def test_exempt_path_prefix_match(self): + def test_exempt_path_prefix_match(self) -> None: request = self._make_suspended_request(path="/auth/login/sso/") result = self.middleware.process_request(request) assert result is None @patch("sentry.middleware.suspended.logger") - def test_logger_error_called(self, mock_logger): + def test_logger_error_called(self, mock_logger: MagicMock) -> None: request = self._make_suspended_request(path="/api/0/organizations/") self.middleware.process_request(request) @@ -140,7 +144,7 @@ def test_logger_error_called(self, mock_logger): class SuspendedTestEndpoint(Endpoint): permission_classes = (AllowAny,) - def get(self, request): + def get(self, request: HttpRequest) -> Response: return Response({"ok": True}) @@ -156,7 +160,7 @@ def get(self, request): class LeakyAuthMiddleware(MiddlewareMixin): """Simulates a new auth class that authenticates suspended users without checking is_suspended.""" - def process_request(self, request): + def process_request(self, request: Any) -> None: auth_header = request.META.get("HTTP_AUTHORIZATION", "") if auth_header.startswith("Bearer "): user_id = auth_header.split(" ", 1)[1] @@ -185,17 +189,17 @@ def process_request(self, request): class SuspensionUnawareAuthBackend(EmailAuthBackend): """Simulates a bug where get_user() doesn't filter out suspended users.""" - def get_user(self, user_id): + def get_user(self, user_id: int) -> RpcUser | None: # type: ignore[override] # justification: matches EmailAuthBackend HC user model deviation return user_service.get_user(user_id=user_id) class SuspendedUserInjectionMiddleware: """Simulates a buggy middleware that injects a suspended user into the request.""" - def __init__(self, get_response): + def __init__(self, get_response: Callable[[HttpRequest], HttpResponseBase]): self.get_response = get_response - def __call__(self, request): + def __call__(self, request: HttpRequest) -> HttpResponseBase: user_id = request.META.get("HTTP_X_INJECT_USER_ID") if user_id: request.user = User.objects.get(id=int(user_id)) @@ -221,7 +225,7 @@ class TestLeakyAuthClassBypass(APITestCase): endpoint = "suspended-test-endpoint" @override_settings(MIDDLEWARE=_MIDDLEWARE_LEAKY_AUTH) - def test_leaky_auth_class_suspended_user_blocked(self): + def test_leaky_auth_class_suspended_user_blocked(self) -> None: user = self.create_user() user.update(is_suspended=True) response = self.client.get( @@ -232,7 +236,7 @@ def test_leaky_auth_class_suspended_user_blocked(self): assert response.json() == {"detail": "Your account has been suspended."} @override_settings(MIDDLEWARE=_MIDDLEWARE_LEAKY_AUTH) - def test_leaky_auth_class_normal_user_passes(self): + def test_leaky_auth_class_normal_user_passes(self) -> None: user = self.create_user() response = self.client.get( "/api/0/suspended-test/", @@ -251,7 +255,7 @@ class TestLeakyAuthBackendBypass(APITestCase): endpoint = "suspended-test-endpoint" - def test_backend_bug_suspended_user_blocked(self): + def test_backend_bug_suspended_user_blocked(self) -> None: user = self.create_user() self.login_as(user) User.objects.filter(id=user.id).update(is_suspended=True) @@ -270,7 +274,7 @@ class TestBuggyMiddlewareBypass(APITestCase): endpoint = "suspended-test-endpoint" - def test_injected_suspended_user_blocked(self): + def test_injected_suspended_user_blocked(self) -> None: user = self.create_user() user.update(is_suspended=True) response = self.client.get( @@ -280,7 +284,7 @@ def test_injected_suspended_user_blocked(self): assert response.status_code == 403 assert response.json() == {"detail": "Your account has been suspended."} - def test_injected_normal_user_passes(self): + def test_injected_normal_user_passes(self) -> None: user = self.create_user() response = self.client.get( "/api/0/suspended-test/", @@ -288,7 +292,7 @@ def test_injected_normal_user_passes(self): ) assert response.status_code == 200 - def test_no_injection_header_unaffected(self): + def test_no_injection_header_unaffected(self) -> None: self.login_as(self.user) response = self.client.get("/api/0/suspended-test/") assert response.status_code != 403 @@ -304,7 +308,7 @@ class TestSessionNonceBypass(APITestCase): endpoint = "suspended-test-endpoint" - def test_no_nonce_set_suspension_caught(self): + def test_no_nonce_set_suspension_caught(self) -> None: user = self.create_user() assert user.session_nonce is None self.login_as(user) @@ -313,7 +317,7 @@ def test_no_nonce_set_suspension_caught(self): assert response.status_code == 403 @override_settings(SESSION_ENGINE="django.contrib.sessions.backends.db") - def test_matching_nonce_raw_update_suspension_caught(self): + def test_matching_nonce_raw_update_suspension_caught(self) -> None: user = self.create_user() self.login_as(user) user.refresh_session_nonce() diff --git a/tests/sentry/middleware/test_viewer_context.py b/tests/sentry/middleware/test_viewer_context.py index d0d43da5e7f9..50e7eb437827 100644 --- a/tests/sentry/middleware/test_viewer_context.py +++ b/tests/sentry/middleware/test_viewer_context.py @@ -1,9 +1,10 @@ from __future__ import annotations -from typing import cast +from typing import Any, cast from unittest.mock import MagicMock, patch from django.contrib.auth.models import AnonymousUser +from django.http import HttpRequest, HttpResponseBase from django.test import RequestFactory, override_settings from rest_framework.request import Request @@ -22,11 +23,11 @@ class ViewerContextFromRequestTest(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.factory = RequestFactory() - def test_anonymous_request(self): + def test_anonymous_request(self) -> None: request = self.factory.get("/") request.user = AnonymousUser() request.auth = None @@ -37,7 +38,7 @@ def test_anonymous_request(self): assert ctx.organization_id is None assert ctx.actor.type is ActorType.UNKNOWN - def test_session_authenticated_user(self): + def test_session_authenticated_user(self) -> None: request = self.factory.get("/") request.user = self.user request.auth = None @@ -48,7 +49,7 @@ def test_session_authenticated_user(self): assert ctx.organization_id is None assert ctx.actor.type is ActorType.USER - def test_token_authenticated_user(self): + def test_token_authenticated_user(self) -> None: request = self.factory.get("/") token = AuthenticatedToken( allowed_origins=["*"], @@ -68,7 +69,7 @@ def test_token_authenticated_user(self): assert ctx.actor.type is ActorType.USER assert request.auth is token - def test_org_scoped_token_without_user(self): + def test_org_scoped_token_without_user(self) -> None: request = self.factory.get("/") request.user = AnonymousUser() token = AuthenticatedToken( @@ -86,7 +87,7 @@ def test_org_scoped_token_without_user(self): assert ctx.organization_id == self.organization.id assert request.auth is token - def test_token_without_organization(self): + def test_token_without_organization(self) -> None: request = self.factory.get("/") token = AuthenticatedToken( allowed_origins=[], @@ -106,15 +107,15 @@ def test_token_without_organization(self): class ViewerContextMiddlewareTest(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.factory = RequestFactory() @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=False) - def test_skipped_when_disabled(self): - captured: list = [] + def test_skipped_when_disabled(self) -> None: + captured: list[Any] = [] - def get_response(request): + def get_response(request: HttpRequest) -> MagicMock: captured.append(get_viewer_context()) return MagicMock(status_code=200) @@ -130,10 +131,10 @@ def get_response(request): assert captured[0] is None @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) - def test_sets_context_during_request(self): - captured: list = [] + def test_sets_context_during_request(self) -> None: + captured: list[Any] = [] - def get_response(request): + def get_response(request: HttpRequest) -> MagicMock: captured.append(get_viewer_context()) return MagicMock(status_code=200) @@ -150,7 +151,7 @@ def get_response(request): assert captured[0].actor.actor_id == self.user.id @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) - def test_cleans_up_after_request(self): + def test_cleans_up_after_request(self) -> None: middleware = ViewerContextMiddleware(lambda r: MagicMock(status_code=200)) request = self.factory.get("/") @@ -162,8 +163,8 @@ def test_cleans_up_after_request(self): assert get_viewer_context() is None @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) - def test_cleans_up_on_exception(self): - def get_response(request): + def test_cleans_up_on_exception(self) -> None: + def get_response(request: HttpRequest) -> HttpResponseBase: raise RuntimeError("boom") middleware = ViewerContextMiddleware(get_response) @@ -180,10 +181,10 @@ def get_response(request): assert get_viewer_context() is None @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) - def test_anonymous_request_sets_empty_context(self): - captured: list = [] + def test_anonymous_request_sets_empty_context(self) -> None: + captured: list[Any] = [] - def get_response(request): + def get_response(request: HttpRequest) -> MagicMock: captured.append(get_viewer_context()) return MagicMock(status_code=200) @@ -203,7 +204,7 @@ def get_response(request): @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) @override_settings(SEER_API_SHARED_SECRET="test-secret") - def test_agent_token_sets_agent_context(self): + def test_agent_token_sets_agent_context(self) -> None: # Through the real chain: AuthenticationMiddleware resolves the agent bearer, # then this middleware derives user + org + agent actor from it. token, _ = agent_token.encode_agent_token( @@ -213,9 +214,9 @@ def test_agent_token_sets_agent_context(self): session_id="s1", ) - captured: list = [] + captured: list[Any] = [] - def get_response(request): + def get_response(request: HttpRequest) -> MagicMock: captured.append(get_viewer_context()) return MagicMock(status_code=200) @@ -235,16 +236,16 @@ def get_response(request): @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) @override_settings(SEER_API_SHARED_SECRET="test-secret") - def test_jwt_header_sets_viewer_context(self): + def test_jwt_header_sets_viewer_context(self) -> None: vc = ViewerContext( organization_id=self.organization.id, actor=Actor(type=ActorType.AGENT, actor_id=90, delegated_user_id=self.user.id), ) token = encode_viewer_context(vc) - captured: list = [] + captured: list[Any] = [] - def get_response(request): + def get_response(request: HttpRequest) -> MagicMock: captured.append(get_viewer_context()) return MagicMock(status_code=200) @@ -261,13 +262,13 @@ def get_response(request): @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) @override_settings(SEER_API_SHARED_SECRET="test-secret") - def test_authenticated_user_takes_precedence_over_jwt(self): + def test_authenticated_user_takes_precedence_over_jwt(self) -> None: vc = ViewerContext(actor=Actor(type=ActorType.INTEGRATION), organization_id=99) token = encode_viewer_context(vc) - captured: list = [] + captured: list[Any] = [] - def get_response(request): + def get_response(request: HttpRequest) -> MagicMock: captured.append(get_viewer_context()) return MagicMock(status_code=200) @@ -286,13 +287,13 @@ def get_response(request): @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) @override_settings(SEER_API_SHARED_SECRET="test-secret") - def test_jwt_used_when_no_authenticated_user(self): + def test_jwt_used_when_no_authenticated_user(self) -> None: vc = ViewerContext(actor=Actor(type=ActorType.INTEGRATION), organization_id=99) token = encode_viewer_context(vc) - captured: list = [] + captured: list[Any] = [] - def get_response(request): + def get_response(request: HttpRequest) -> MagicMock: captured.append(get_viewer_context()) return MagicMock(status_code=200) @@ -313,7 +314,7 @@ def get_response(request): @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) @override_settings(SEER_API_SHARED_SECRET="test-secret") @patch("sentry.middleware.viewer_context.logger") - def test_logs_warning_on_jwt_request_mismatch(self, mock_logger): + def test_logs_warning_on_jwt_request_mismatch(self, mock_logger: MagicMock) -> None: vc = ViewerContext(actor=Actor(type=ActorType.INTEGRATION), organization_id=99) token = encode_viewer_context(vc) @@ -342,10 +343,10 @@ def test_logs_warning_on_jwt_request_mismatch(self, mock_logger): @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) @override_settings(SEER_API_SHARED_SECRET="test-secret") - def test_invalid_jwt_falls_back_to_request_user(self): - captured: list = [] + def test_invalid_jwt_falls_back_to_request_user(self) -> None: + captured: list[Any] = [] - def get_response(request): + def get_response(request: HttpRequest) -> MagicMock: captured.append(get_viewer_context()) return MagicMock(status_code=200) @@ -363,10 +364,10 @@ def get_response(request): assert ctx.actor.type == ActorType.USER @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) - def test_raw_json_without_signature_falls_back(self): - captured: list = [] + def test_raw_json_without_signature_falls_back(self) -> None: + captured: list[Any] = [] - def get_response(request): + def get_response(request: HttpRequest) -> MagicMock: captured.append(get_viewer_context()) return MagicMock(status_code=200) @@ -388,10 +389,10 @@ def get_response(request): @override_settings(SENTRY_VIEWER_CONTEXT_ENABLED=True) @override_settings(SEER_API_SHARED_SECRET="test-secret") - def test_non_jwt_header_ignored(self): - captured: list = [] + def test_non_jwt_header_ignored(self) -> None: + captured: list[Any] = [] - def get_response(request): + def get_response(request: HttpRequest) -> MagicMock: captured.append(get_viewer_context()) return MagicMock(status_code=200) diff --git a/tests/sentry/migrations/test_0004_generalize_investigation_sources.py b/tests/sentry/migrations/test_0004_generalize_investigation_sources.py index 4e1925835bef..8055221abe87 100644 --- a/tests/sentry/migrations/test_0004_generalize_investigation_sources.py +++ b/tests/sentry/migrations/test_0004_generalize_investigation_sources.py @@ -2,6 +2,7 @@ import pytest +from django.db.migrations.state import StateApps from sentry.silo.safety import unguarded_write from sentry.testutils.cases import TestMigrations from sentry.utils import json @@ -13,7 +14,7 @@ class GeneralizeInvestigationSourcesTest(TestMigrations): migrate_from = "0003_remove_investigation_cell_models" migrate_to = "0004_generalize_investigation_sources" - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: Organization = apps.get_model("sentry", "Organization") Investigation = apps.get_model("investigations", "Investigation") with unguarded_write(using="default"): @@ -36,7 +37,7 @@ def setup_before_migration(self, apps): filters={"breachedMetric": self.snapshot}, ) - def test_backfills_new_source_fields_without_mutating_legacy_fields(self): + def test_backfills_new_source_fields_without_mutating_legacy_fields(self) -> None: Investigation = self.apps.get_model("investigations", "Investigation") investigation = Investigation.objects.get(id=self.investigation.id) expected_source = { @@ -67,7 +68,7 @@ class ReverseGeneralizeInvestigationSourcesTest(TestMigrations): migrate_from = "0004_generalize_investigation_sources" migrate_to = "0003_remove_investigation_cell_models" - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: Organization = apps.get_model("sentry", "Organization") Investigation = apps.get_model("investigations", "Investigation") with unguarded_write(using="default"): @@ -96,7 +97,7 @@ def setup_before_migration(self, apps): filters={"breachedMetric": self.snapshot}, ) - def test_reverse_preserves_the_legacy_source_contract(self): + def test_reverse_preserves_the_legacy_source_contract(self) -> None: Investigation = self.apps.get_model("investigations", "Investigation") investigation = Investigation.objects.get(id=self.investigation.id) diff --git a/tests/sentry/migrations/test_0015_backfill_notificationthread_object.py b/tests/sentry/migrations/test_0015_backfill_notificationthread_object.py index 2c6c00f99e98..fbf216e167ed 100644 --- a/tests/sentry/migrations/test_0015_backfill_notificationthread_object.py +++ b/tests/sentry/migrations/test_0015_backfill_notificationthread_object.py @@ -1,3 +1,5 @@ +from typing import Any +from django.db.migrations.state import StateApps from sentry.testutils.cases import TestMigrations @@ -6,11 +8,11 @@ class BackfillNotificationThreadObjectTest(TestMigrations): migrate_from = "0014_add_notificationthread_object" migrate_to = "0015_backfill_notificationthread_object" - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: NotificationThread = apps.get_model("notifications", "NotificationThread") thread_keys = iter(range(100)) - def create(key_type, key_data, **kwargs): + def create(key_type: str, key_data: dict[str, Any], **kwargs: Any) -> Any: return NotificationThread.objects.create( thread_key=f"thread-key-{next(thread_keys)}", provider_key="slack", @@ -32,7 +34,7 @@ def create(key_type, key_data, **kwargs): self.other_type = create("test", {"group_id": 321}) self.no_group = create("metric-alert", {"action_id": 4}) - def test_backfill(self): + def test_backfill(self) -> None: self.metric_thread.refresh_from_db() assert (self.metric_thread.object_type, self.metric_thread.object_id) == ("group", 123) diff --git a/tests/sentry/migrations/test_0019_move_saved_query_range_to_stats_period.py b/tests/sentry/migrations/test_0019_move_saved_query_range_to_stats_period.py index aca8d961d2cd..e03e214e1104 100644 --- a/tests/sentry/migrations/test_0019_move_saved_query_range_to_stats_period.py +++ b/tests/sentry/migrations/test_0019_move_saved_query_range_to_stats_period.py @@ -1,3 +1,5 @@ +from typing import Any +from django.db.migrations.state import StateApps from sentry.silo.safety import unguarded_write from sentry.testutils.cases import TestMigrations @@ -7,7 +9,7 @@ class MoveSavedQueryRangeToStatsPeriodTest(TestMigrations): migrate_from = "0018_add_prebuilt_delete_to_formulas" migrate_to = "0019_move_saved_query_range_to_stats_period" - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: Organization = apps.get_model("sentry", "Organization") ExploreSavedQuery = apps.get_model("explore", "ExploreSavedQuery") DiscoverSavedQuery = apps.get_model("discover", "DiscoverSavedQuery") @@ -15,8 +17,10 @@ def setup_before_migration(self, apps): with unguarded_write(using="default"): organization = Organization.objects.create(slug="range-migration", name="Range") - def create(model, name: str, query: dict[str, object]) -> int: - return model.objects.create(organization_id=organization.id, name=name, query=query).id + def create(model: Any, name: str, query: dict[str, object]) -> int: + return int( + model.objects.create(organization_id=organization.id, name=name, query=query).id + ) self.explore_range = create(ExploreSavedQuery, "explore-range", {"range": "24h"}) self.explore_both = create( diff --git a/tests/sentry/migrations/test_0020_backfill_checkin_config.py b/tests/sentry/migrations/test_0020_backfill_checkin_config.py index 3469ec9a8b3e..981df92bcc5e 100644 --- a/tests/sentry/migrations/test_0020_backfill_checkin_config.py +++ b/tests/sentry/migrations/test_0020_backfill_checkin_config.py @@ -1,5 +1,7 @@ +from typing import Any from uuid import uuid4 +from django.db.migrations.state import StateApps from sentry.testutils.cases import TestMigrations @@ -9,7 +11,7 @@ class BackfillCheckinConfigTest(TestMigrations): migrate_to = "0020_backfill_checkin_config" connection = "secondary" - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: Monitor = apps.get_model("monitors", "Monitor") MonitorEnvironment = apps.get_model("monitors", "MonitorEnvironment") MonitorCheckIn = apps.get_model("monitors", "MonitorCheckIn") @@ -25,7 +27,7 @@ def setup_before_migration(self, apps): ) monitor_env = MonitorEnvironment.objects.create(monitor=monitor, environment_id=1) - def create_checkin(**kwargs): + def create_checkin(**kwargs: Any) -> Any: return MonitorCheckIn.objects.create( guid=uuid4(), monitor=monitor, diff --git a/tests/sentry/migrations/test_0117_backfill_all_project_detectors.py b/tests/sentry/migrations/test_0117_backfill_all_project_detectors.py index af2da1fd7ac3..d13f99fdb88c 100644 --- a/tests/sentry/migrations/test_0117_backfill_all_project_detectors.py +++ b/tests/sentry/migrations/test_0117_backfill_all_project_detectors.py @@ -1,5 +1,6 @@ import pytest +from django.db.migrations.state import StateApps from sentry.silo.safety import unguarded_write from sentry.testutils.cases import TestMigrations @@ -10,7 +11,7 @@ class BackfillAllProjectDetectorsTest(TestMigrations): migrate_from = "0116_detector_nullable_project" migrate_to = "0117_backfill_all_project_detectors" - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: Organization = apps.get_model("sentry", "Organization") Detector = apps.get_model("workflow_engine", "Detector") @@ -31,7 +32,7 @@ def setup_before_migration(self, apps): slug="without-detector", name="Without Detector" ) - def test(self): + def test(self) -> None: Detector = self.apps.get_model("workflow_engine", "Detector") existing = Detector.objects.filter(project__isnull=True, type="issue_stream").count() diff --git a/tests/sentry/migrations/test_0118_repair_latest_adopted_release_environments.py b/tests/sentry/migrations/test_0118_repair_latest_adopted_release_environments.py index 1ca2530a75e1..e5c6cd2d18ad 100644 --- a/tests/sentry/migrations/test_0118_repair_latest_adopted_release_environments.py +++ b/tests/sentry/migrations/test_0118_repair_latest_adopted_release_environments.py @@ -1,5 +1,6 @@ import pytest +from django.db.migrations.state import StateApps from sentry.silo.safety import unguarded_write from sentry.testutils.cases import TestMigrations @@ -10,7 +11,7 @@ class RepairLatestAdoptedReleaseEnvironmentsTest(TestMigrations): migrate_from = "0117_backfill_all_project_detectors" migrate_to = "0118_repair_latest_adopted_release_environments" - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: Organization = apps.get_model("sentry", "Organization") Environment = apps.get_model("sentry", "Environment") DataCondition = apps.get_model("workflow_engine", "DataCondition") @@ -71,7 +72,7 @@ def setup_before_migration(self, apps): condition_result=True, ) - def test(self): + def test(self) -> None: DataCondition = self.apps.get_model("workflow_engine", "DataCondition") broken_condition = DataCondition.objects.get(id=self.broken_condition.id) diff --git a/tests/sentry/migrations/test_0120_replace_boolean_deescalation_comparisons.py b/tests/sentry/migrations/test_0120_replace_boolean_deescalation_comparisons.py index 09346f41f01b..ffae953350c0 100644 --- a/tests/sentry/migrations/test_0120_replace_boolean_deescalation_comparisons.py +++ b/tests/sentry/migrations/test_0120_replace_boolean_deescalation_comparisons.py @@ -1,5 +1,6 @@ import pytest +from django.db.migrations.state import StateApps from sentry.testutils.cases import TestMigrations @@ -9,7 +10,7 @@ class ReplaceBooleanDeescalationComparisonsTest(TestMigrations): migrate_from = "0119_add_index_for_all_project_detectors" migrate_to = "0120_replace_boolean_deescalation_comparisons" - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: DataCondition = apps.get_model("workflow_engine", "DataCondition") DataConditionGroup = apps.get_model("workflow_engine", "DataConditionGroup") @@ -33,7 +34,7 @@ def setup_before_migration(self, apps): condition_result=True, ) - def test(self): + def test(self) -> None: DataCondition = self.apps.get_model("workflow_engine", "DataCondition") boolean_condition = DataCondition.objects.get(id=self.boolean_condition.id) diff --git a/tests/sentry/migrations/test_0122_sync_cron_detector_enabled.py b/tests/sentry/migrations/test_0122_sync_cron_detector_enabled.py index d00dd397e7c3..2a996cb798cc 100644 --- a/tests/sentry/migrations/test_0122_sync_cron_detector_enabled.py +++ b/tests/sentry/migrations/test_0122_sync_cron_detector_enabled.py @@ -3,6 +3,7 @@ import pytest from django.db.migrations.loader import MigrationLoader +from django.db.migrations.state import StateApps from sentry.constants import ObjectStatus from sentry.silo.safety import unguarded_write from sentry.testutils.cases import TestMigrations @@ -14,7 +15,7 @@ class SyncCronDetectorEnabledTest(TestMigrations): migrate_from = "0121_add_activation_id_to_detector_state" migrate_to = "0122_sync_cron_detector_enabled" - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: Organization = apps.get_model("sentry", "Organization") Project = apps.get_model("sentry", "Project") # monitors isn't in the migrate_from state. @@ -51,7 +52,7 @@ def create(slug: str, status: int, enabled: bool) -> int: config={}, ) DataSourceDetector.objects.create(data_source=data_source, detector=detector) - return detector.id + return int(detector.id) self.disabled_shown_enabled = create("disabled-enabled", ObjectStatus.DISABLED, True) self.active_shown_disabled = create("active-disabled", ObjectStatus.ACTIVE, False) diff --git a/tests/sentry/migrations/test_1126_transactions_to_spans_widgets_self_hosted.py b/tests/sentry/migrations/test_1126_transactions_to_spans_widgets_self_hosted.py index e3e9c5fc6f36..0db04061e5e1 100644 --- a/tests/sentry/migrations/test_1126_transactions_to_spans_widgets_self_hosted.py +++ b/tests/sentry/migrations/test_1126_transactions_to_spans_widgets_self_hosted.py @@ -1,5 +1,6 @@ import pytest +from django.db.migrations.state import StateApps from sentry.testutils.cases import SnubaTestCase, TestMigrations @@ -11,7 +12,7 @@ class MigrateTransactionsToSpansWidgetsSelfHostedTest(TestMigrations, SnubaTestC migrate_from = "1125_transactions_to_spans_alerts_self_hosted" migrate_to = "1126_transactions_to_spans_widgets_self_hosted" - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: DashboardWidget = apps.get_model("sentry", "DashboardWidget") DashboardWidgetQuery = apps.get_model("sentry", "DashboardWidgetQuery") Dashboard = apps.get_model("sentry", "Dashboard") @@ -95,7 +96,7 @@ def setup_before_migration(self, apps): field_aliases=[], ) - def test(self): + def test(self) -> None: DashboardWidgetQuery = self.apps.get_model("sentry", "DashboardWidgetQuery") self.transaction_widget.refresh_from_db() diff --git a/tests/sentry/migrations/test_1127_discover_to_explore_queries_self_hosted.py b/tests/sentry/migrations/test_1127_discover_to_explore_queries_self_hosted.py index 65681bd2dc8a..3bdc084acb5e 100644 --- a/tests/sentry/migrations/test_1127_discover_to_explore_queries_self_hosted.py +++ b/tests/sentry/migrations/test_1127_discover_to_explore_queries_self_hosted.py @@ -1,5 +1,6 @@ import pytest +from django.db.migrations.state import StateApps from sentry.discover.models import ( DiscoverSavedQuery, DiscoverSavedQueryProject, @@ -17,7 +18,7 @@ class MigrateDiscoverQueriesToExploreQueriesSelfHostedTest(TestMigrations, Snuba migrate_from = "1126_transactions_to_spans_widgets_self_hosted" migrate_to = "1127_discover_to_explore_queries_self_hosted" - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: self.org = self.create_organization(name="Test org", slug="test-org") self.project = self.create_project(organization=self.org) self.project_ids = [self.project.id] @@ -59,7 +60,7 @@ def setup_before_migration(self, apps): dataset=DiscoverSavedQueryTypes.ERROR_EVENTS, ) - def test(self): + def test(self) -> None: self.discover_query_with_fields_to_translate.refresh_from_db() self.discover_query_without_projects.refresh_from_db() self.discover_query_dropped_fields.refresh_from_db() diff --git a/tests/sentry/migrations/test_1155_backfill_organizationmemberteam_new_id.py b/tests/sentry/migrations/test_1155_backfill_organizationmemberteam_new_id.py index be9b934219b6..a2f38f1c083f 100644 --- a/tests/sentry/migrations/test_1155_backfill_organizationmemberteam_new_id.py +++ b/tests/sentry/migrations/test_1155_backfill_organizationmemberteam_new_id.py @@ -3,6 +3,7 @@ import pytest +from django.db.migrations.state import StateApps from sentry.testutils.cases import TestMigrations # A leading digit is legal in a module name but not in the dotted path mock.patch parses, @@ -16,13 +17,13 @@ class BackfillOrganizationMemberTeamNewIdTest(TestMigrations): migrate_from = "1154_pullrequest_external_id" migrate_to = "1155_backfill_organizationmemberteam_new_id" - def setUp(self): + def setUp(self) -> None: # A handful of rows spans several batches at this size, so the migration's # chunking is actually exercised rather than served by a single batch. with mock.patch.object(migration, "BATCH_SIZE", 2): super().setUp() - def setup_initial_state(self): + def setup_initial_state(self) -> None: self.organization = self.create_organization() self.team = self.create_team(organization=self.organization) self.members = [ @@ -30,7 +31,7 @@ def setup_initial_state(self): for _ in range(6) ] - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: OrganizationMemberTeam = apps.get_model("sentry", "OrganizationMemberTeam") # The historical model has none of the dual-write hooks, so these land with a @@ -51,7 +52,7 @@ def setup_before_migration(self, apps): new_id=-1, ) - def test_backfills_only_rows_missing_new_id(self): + def test_backfills_only_rows_missing_new_id(self) -> None: for row in self.pending: row.refresh_from_db() assert row.new_id == row.id diff --git a/tests/sentry/migrations/test_1178_backfill_release_new_last_deploy_id.py b/tests/sentry/migrations/test_1178_backfill_release_new_last_deploy_id.py index 1fb27f32f7a7..909fb4e45766 100644 --- a/tests/sentry/migrations/test_1178_backfill_release_new_last_deploy_id.py +++ b/tests/sentry/migrations/test_1178_backfill_release_new_last_deploy_id.py @@ -1,8 +1,10 @@ +from typing import Any import importlib from unittest import mock import pytest +from django.db.migrations.state import StateApps from sentry.testutils.cases import TestMigrations # A leading digit is legal in a module name but not in the dotted path mock.patch parses, @@ -16,19 +18,21 @@ class BackfillReleaseNewLastDeployIdTest(TestMigrations): migrate_from = "1177_drop_dashboardhiddenuser" migrate_to = "1178_backfill_release_new_last_deploy_id" - def setUp(self): + def setUp(self) -> None: # A handful of rows spans several batches at this size, so the migration's # chunking is actually exercised rather than served by a single batch. with mock.patch.object(migration, "BATCH_SIZE", 2): super().setUp() - def setup_initial_state(self): + def setup_initial_state(self) -> None: self.organization = self.create_organization() - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: Release = apps.get_model("sentry", "Release") - def create_release(version, last_deploy_id, new_last_deploy_id): + def create_release( + version: str, last_deploy_id: int | None, new_last_deploy_id: int | None + ) -> Any: return Release.objects.create( organization_id=self.organization.id, version=version, @@ -47,7 +51,7 @@ def create_release(version, last_deploy_id, new_last_deploy_id): # A shadow value over a null deploy is the mismatch "is null" would miss. self.stale_shadow = create_release("stale-shadow", None, 999) - def test_backfills_every_row_out_of_sync(self): + def test_backfills_every_row_out_of_sync(self) -> None: for release in self.pending: release.refresh_from_db() assert release.new_last_deploy_id == release.last_deploy_id diff --git a/tests/sentry/migrations/test_1185_backfill_release_new_id.py b/tests/sentry/migrations/test_1185_backfill_release_new_id.py index 01884a6d4c2f..3fb100d0307c 100644 --- a/tests/sentry/migrations/test_1185_backfill_release_new_id.py +++ b/tests/sentry/migrations/test_1185_backfill_release_new_id.py @@ -1,8 +1,10 @@ +from typing import Any import importlib from unittest import mock import pytest +from django.db.migrations.state import StateApps from sentry.testutils.cases import TestMigrations # A leading digit is legal in a module name but not in the dotted path mock.patch parses, @@ -16,17 +18,17 @@ class BackfillReleaseNewIdTest(TestMigrations): migrate_from = "1184_custominboundfilter_add_legacy_filter" migrate_to = "1185_backfill_release_new_id" - def setUp(self): + def setUp(self) -> None: with mock.patch.object(migration, "BATCH_SIZE", 2): super().setUp() - def setup_initial_state(self): + def setup_initial_state(self) -> None: self.organization = self.create_organization() - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: Release = apps.get_model("sentry", "Release") - def create_release(version): + def create_release(version: str) -> Any: return Release.objects.create(organization_id=self.organization.id, version=version) self.pending = [create_release(f"pending-{i}") for i in range(5)] @@ -37,7 +39,7 @@ def create_release(version): self.stale_shadow = create_release("stale-shadow") Release.objects.filter(id=self.stale_shadow.id).update(new_id=self.stale_shadow.id + 1000) - def test_backfills_every_row_out_of_sync(self): + def test_backfills_every_row_out_of_sync(self) -> None: releases = [*self.pending, self.already_synced, self.stale_shadow] for release in releases: release.refresh_from_db() diff --git a/tests/sentry/migrations/test_1186_backfill_custominboundfilter_data_type.py b/tests/sentry/migrations/test_1186_backfill_custominboundfilter_data_type.py index 6e480617e734..e4f2071c6506 100644 --- a/tests/sentry/migrations/test_1186_backfill_custominboundfilter_data_type.py +++ b/tests/sentry/migrations/test_1186_backfill_custominboundfilter_data_type.py @@ -1,5 +1,6 @@ import pytest +from django.db.migrations.state import StateApps from sentry.testutils.cases import TestMigrations @@ -9,7 +10,7 @@ class BackfillCustomInboundFilterDataTypeTest(TestMigrations): migrate_from = "1185_backfill_release_new_id" migrate_to = "1186_backfill_custominboundfilter_data_type" - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: CustomInboundFilter = apps.get_model("sentry", "CustomInboundFilter") self.log = CustomInboundFilter.objects.create( @@ -42,7 +43,7 @@ def setup_before_migration(self, apps): conditions=[{"type": "release", "value": ["1.*"]}], ) - def test_backfill(self): + def test_backfill(self) -> None: for custom_filter in ( self.log, self.metric, diff --git a/tests/sentry/migrations/test_1189_backfill_pullrequest_external_id_str.py b/tests/sentry/migrations/test_1189_backfill_pullrequest_external_id_str.py index 6c176ad9262e..7d3f84e862d9 100644 --- a/tests/sentry/migrations/test_1189_backfill_pullrequest_external_id_str.py +++ b/tests/sentry/migrations/test_1189_backfill_pullrequest_external_id_str.py @@ -1,8 +1,10 @@ +from typing import Any import importlib from unittest import mock import pytest +from django.db.migrations.state import StateApps from sentry.testutils.cases import TestMigrations # A leading digit is legal in a module name but not in the dotted path mock.patch parses, @@ -16,18 +18,18 @@ class BackfillPullRequestExternalIdStrTest(TestMigrations): migrate_from = "1188_pullrequest_external_id_str" migrate_to = "1189_backfill_pullrequest_external_id_str" - def setUp(self): + def setUp(self) -> None: with mock.patch.object(migration, "BATCH_SIZE", 2): super().setUp() - def setup_initial_state(self): + def setup_initial_state(self) -> None: self.organization = self.create_organization() self.repo = self.create_repo(self.create_project(organization=self.organization)) - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: PullRequest = apps.get_model("sentry", "PullRequest") - def create(key, **kwargs): + def create(key: str, **kwargs: Any) -> Any: return PullRequest.objects.create( organization_id=self.organization.id, repository_id=self.repo.id, key=key, **kwargs ) @@ -37,7 +39,7 @@ def create(key, **kwargs): self.already_set = create("already-set", external_id=7, external_id_str="7") self.string_only = create("string-only", external_id_str="pr_01abc") - def test_backfill(self): + def test_backfill(self) -> None: for i, pr in enumerate(self.pending): pr.refresh_from_db() assert pr.external_id_str == str(1000 + i) diff --git a/tests/sentry/migrations/test_1199_backfill_custominboundfilter_legacy_lists.py b/tests/sentry/migrations/test_1199_backfill_custominboundfilter_legacy_lists.py index 3eb73a86f801..5be3f03aafc6 100644 --- a/tests/sentry/migrations/test_1199_backfill_custominboundfilter_legacy_lists.py +++ b/tests/sentry/migrations/test_1199_backfill_custominboundfilter_legacy_lists.py @@ -1,5 +1,7 @@ +from typing import Any import pytest +from django.db.migrations.state import StateApps from sentry.testutils.cases import TestMigrations @@ -9,10 +11,10 @@ class BackfillCustomInboundFilterLegacyListsTest(TestMigrations): migrate_from = "1198_prepare_incidentactivity_retirement" migrate_to = "1199_backfill_custominboundfilter_legacy_lists" - def setup_initial_state(self): + def setup_initial_state(self) -> None: self.other_project = self.create_project(organization=self.organization) - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: ProjectOption = apps.get_model("sentry", "ProjectOption") CustomInboundFilter = apps.get_model("sentry", "CustomInboundFilter") @@ -62,10 +64,10 @@ def setup_before_migration(self, apps): conditions=[{"type": "release", "value": ["3.*"]}], ) - def test_backfill(self): + def test_backfill(self) -> None: CustomInboundFilter = self.apps.get_model("sentry", "CustomInboundFilter") - def rows(project_id): + def rows(project_id: int) -> dict[str | None, Any]: return { row.legacy_filter: row for row in CustomInboundFilter.objects.filter(project_id=project_id) diff --git a/tests/sentry/migrations/test_1209_update_if_syntax_on_dashboard_spans.py b/tests/sentry/migrations/test_1209_update_if_syntax_on_dashboard_spans.py index 15a28398fe7c..1d4ffbdf1c3d 100644 --- a/tests/sentry/migrations/test_1209_update_if_syntax_on_dashboard_spans.py +++ b/tests/sentry/migrations/test_1209_update_if_syntax_on_dashboard_spans.py @@ -1,5 +1,6 @@ import pytest +from django.db.migrations.state import StateApps from sentry.testutils.cases import TestMigrations @@ -9,7 +10,7 @@ class UpdateIfSyntaxOnDashboardSpansTest(TestMigrations): migrate_from = "1208_sentryappinstallation_uuid_index" migrate_to = "1209_update_if_syntax_on_dashboard_spans" - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: Dashboard = apps.get_model("sentry", "Dashboard") DashboardWidget = apps.get_model("sentry", "DashboardWidget") DashboardWidgetQuery = apps.get_model("sentry", "DashboardWidgetQuery") @@ -125,7 +126,7 @@ def setup_before_migration(self, apps): orderby="", ) - def test_rewrites_spans_widget_queries(self): + def test_rewrites_spans_widget_queries(self) -> None: self.spans_query.refresh_from_db() self.field_link.refresh_from_db() self.migrated_query.refresh_from_db() diff --git a/tests/sentry/migrations/test_1210_release_validate_foreign_keys.py b/tests/sentry/migrations/test_1210_release_validate_foreign_keys.py index 24186d404292..40b154862c3f 100644 --- a/tests/sentry/migrations/test_1210_release_validate_foreign_keys.py +++ b/tests/sentry/migrations/test_1210_release_validate_foreign_keys.py @@ -1,10 +1,11 @@ +from typing import Any import pytest from django.db import connection from sentry.testutils.cases import TestMigrations -def fetch_release_foreign_keys(): +def fetch_release_foreign_keys() -> list[tuple[Any, ...]]: with connection.cursor() as cursor: cursor.execute( """ @@ -15,7 +16,7 @@ def fetch_release_foreign_keys(): ORDER BY 1, 2 """ ) - return cursor.fetchall() + return list(cursor.fetchall()) @pytest.mark.skip(reason="Migration already applied; test is slow and only useful before merge") @@ -26,7 +27,7 @@ class ValidateReleaseForeignKeysTest(TestMigrations): migrate_from = "1206_add_dashboard_create_scope" migrate_to = "1210_release_validate_foreign_keys" - def test_every_foreign_key_is_validated(self): + def test_every_foreign_key_is_validated(self) -> None: foreign_keys = fetch_release_foreign_keys() assert foreign_keys unvalidated = [(table, name) for table, name, validated in foreign_keys if not validated] diff --git a/tests/sentry/migrations/test_1232_backfill_commit_new_repository_id.py b/tests/sentry/migrations/test_1232_backfill_commit_new_repository_id.py index b963f33b872f..6eeffdf1f4b0 100644 --- a/tests/sentry/migrations/test_1232_backfill_commit_new_repository_id.py +++ b/tests/sentry/migrations/test_1232_backfill_commit_new_repository_id.py @@ -1,5 +1,7 @@ +from typing import Any import pytest +from django.db.migrations.state import StateApps from sentry.testutils.cases import TestMigrations @@ -9,16 +11,16 @@ class BackfillCommitNewRepositoryIdTest(TestMigrations): migrate_from = "1231_drop_group_logger_indexes" migrate_to = "1232_backfill_commit_new_repository_id" - def setup_initial_state(self): + def setup_initial_state(self) -> None: self.organization = self.create_organization() project = self.create_project(organization=self.organization) self.repo = self.create_repo(project) self.other_repo = self.create_repo(project, name="other-repo") - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: Commit = apps.get_model("sentry", "Commit") - def create(key, repository_id, **kwargs): + def create(key: str, repository_id: int, **kwargs: Any) -> Any: return Commit.objects.create( organization_id=self.organization.id, repository_id=repository_id, @@ -33,7 +35,7 @@ def create(key, repository_id, **kwargs): # Historical models skip Commit.save(), so these start out null. assert all(c.new_repository_id is None for c in self.pending) - def test_backfill(self): + def test_backfill(self) -> None: for commit in self.pending: commit.refresh_from_db() assert commit.new_repository_id == self.repo.id diff --git a/tests/sentry/migrations/test_1234_backfill_grouprelease_new_release_id.py b/tests/sentry/migrations/test_1234_backfill_grouprelease_new_release_id.py index 2d47656aa8e5..aa1f957bf43d 100644 --- a/tests/sentry/migrations/test_1234_backfill_grouprelease_new_release_id.py +++ b/tests/sentry/migrations/test_1234_backfill_grouprelease_new_release_id.py @@ -1,8 +1,10 @@ +from typing import Any import importlib from unittest import mock import pytest +from django.db.migrations.state import StateApps from sentry.testutils.cases import TestMigrations from sentry.utils.redis import redis_clusters @@ -17,20 +19,20 @@ class BackfillGroupReleaseNewReleaseIdTest(TestMigrations): migrate_from = "1232_backfill_commit_new_repository_id" migrate_to = "1233_backfill_grouprelease_new_release_id" - def setUp(self): + def setUp(self) -> None: self.redis_client = redis_clusters.get("default") self.redis_client.delete(migration.RESUME_ID_KEY) with mock.patch.object(migration, "BATCH_SIZE", 2): super().setUp() - def setup_initial_state(self): + def setup_initial_state(self) -> None: self.project = self.create_project() self.group = self.create_group(project=self.project) - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: GroupRelease = apps.get_model("sentry", "GroupRelease") - def create(release_id): + def create(release_id: int) -> Any: return GroupRelease.objects.create( project_id=self.project.id, group_id=self.group.id, release_id=release_id ) @@ -46,7 +48,7 @@ def create(release_id): # Historical models skip GroupRelease.save(), so these start out null. assert all(row.new_release_id is None for row in self.pending) - def test_backfills_every_row_out_of_sync(self): + def test_backfills_every_row_out_of_sync(self) -> None: rows = [*self.pending, self.already_synced, self.stale_shadow] for row in rows: row.refresh_from_db() @@ -61,16 +63,16 @@ class BackfillGroupReleaseNewReleaseIdResumeTest(TestMigrations): migrate_from = "1232_backfill_commit_new_repository_id" migrate_to = "1233_backfill_grouprelease_new_release_id" - def setUp(self): + def setUp(self) -> None: self.redis_client = redis_clusters.get("default") with mock.patch.object(migration, "BATCH_SIZE", 2): super().setUp() - def setup_initial_state(self): + def setup_initial_state(self) -> None: self.project = self.create_project() self.group = self.create_group(project=self.project) - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: GroupRelease = apps.get_model("sentry", "GroupRelease") self.rows = [ GroupRelease.objects.create( @@ -82,7 +84,7 @@ def setup_before_migration(self, apps): GroupRelease.objects.filter(id=self.rows[1].id).update(new_release_id=102) self.redis_client.set(migration.RESUME_ID_KEY, self.rows[2].id, ex=60) - def test_resumes_from_the_stored_id_and_clears_it(self): + def test_resumes_from_the_stored_id_and_clears_it(self) -> None: for row in self.rows: row.refresh_from_db() diff --git a/tests/sentry/migrations/test_notebooks_0002_copy_from_investigations.py b/tests/sentry/migrations/test_notebooks_0002_copy_from_investigations.py index 2d613e7781cf..c2bbe11034a7 100644 --- a/tests/sentry/migrations/test_notebooks_0002_copy_from_investigations.py +++ b/tests/sentry/migrations/test_notebooks_0002_copy_from_investigations.py @@ -5,6 +5,7 @@ import pytest from django.db import connections +from django.db.migrations.state import StateApps from sentry.silo.safety import unguarded_write from sentry.testutils.cases import TestMigrations @@ -19,7 +20,7 @@ class CopyFromInvestigationsTest(TestMigrations): migrate_from = "0001_initial" migrate_to = "0002_copy_from_investigations" - def setup_before_migration(self, apps): + def setup_before_migration(self, apps: StateApps) -> None: Organization = apps.get_model("sentry", "Organization") Project = apps.get_model("sentry", "Project") Investigation = apps.get_model("investigations", "Investigation") @@ -113,7 +114,7 @@ def setup_before_migration(self, apps): Investigation.objects.update(date_added=OLD, date_updated=OLD) - def test_copies_notebooks_and_can_run_again(self): + def test_copies_notebooks_and_can_run_again(self) -> None: Investigation = self.apps.get_model("investigations", "Investigation") InvestigationFavoriteUser = self.apps.get_model( "investigations", "InvestigationFavoriteUser" diff --git a/tests/sentry/models/test_apiapplication.py b/tests/sentry/models/test_apiapplication.py index 04d6dd116854..69f104521a28 100644 --- a/tests/sentry/models/test_apiapplication.py +++ b/tests/sentry/models/test_apiapplication.py @@ -301,7 +301,7 @@ def test_normalize_url_preserves_percent_literal_bytes(self) -> None: assert "%EF%BF%BD" not in result assert "%25ab" in result.lower() - def _assert_no_bypass(self, app, attack_uri, registered_path): + def _assert_no_bypass(self, app: ApiApplication, attack_uri: str, registered_path: str) -> None: """Verify an attack URI cannot redirect outside the registered path. Passes if EITHER validation rejects the URI, OR the normalized diff --git a/tests/sentry/models/test_apitoken.py b/tests/sentry/models/test_apitoken.py index 37cb875bf756..5f5d0c7741f4 100644 --- a/tests/sentry/models/test_apitoken.py +++ b/tests/sentry/models/test_apitoken.py @@ -60,6 +60,7 @@ def test_organization_id_for_non_internal(self) -> None: with outbox_runner(): install = self.create_sentry_app_installation() token = install.api_token + assert token is not None org_id = token.organization_id with assume_test_silo_mode(SiloMode.CELL): diff --git a/tests/sentry/models/test_authidentity.py b/tests/sentry/models/test_authidentity.py index 58775ab97c24..e29fd671f528 100644 --- a/tests/sentry/models/test_authidentity.py +++ b/tests/sentry/models/test_authidentity.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from django.utils import timezone @@ -23,7 +23,7 @@ def setUp(self) -> None: ) @patch("sentry.models.authidentity.logger") - def test_update_data_logs(self, mock_logger): + def test_update_data_logs(self, mock_logger: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CONTROL): self.auth_identity.update(data={"new": "value"}) @@ -38,7 +38,7 @@ def test_update_data_logs(self, mock_logger): ) @patch("sentry.models.authidentity.logger") - def test_update_ident_logs(self, mock_logger): + def test_update_ident_logs(self, mock_logger: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CONTROL): self.auth_identity.update(ident="new-ident") @@ -53,7 +53,7 @@ def test_update_ident_logs(self, mock_logger): ) @patch("sentry.models.authidentity.logger") - def test_update_user_id_logs_old_and_new(self, mock_logger): + def test_update_user_id_logs_old_and_new(self, mock_logger: MagicMock) -> None: old_user_id = self.user.id new_user = self.create_user() with assume_test_silo_mode(SiloMode.CONTROL): @@ -71,14 +71,14 @@ def test_update_user_id_logs_old_and_new(self, mock_logger): ) @patch("sentry.models.authidentity.logger") - def test_update_timestamp_only_does_not_log(self, mock_logger): + def test_update_timestamp_only_does_not_log(self, mock_logger: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CONTROL): self.auth_identity.update(last_synced=timezone.now()) mock_logger.info.assert_not_called() @patch("sentry.models.authidentity.logger") - def test_update_mixed_meaningful_and_timestamp_logs(self, mock_logger): + def test_update_mixed_meaningful_and_timestamp_logs(self, mock_logger: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CONTROL): self.auth_identity.update(data={"refreshed": True}, last_synced=timezone.now()) @@ -93,14 +93,14 @@ def test_update_mixed_meaningful_and_timestamp_logs(self, mock_logger): ) @patch("sentry.models.authidentity.logger") - def test_update_returns_affected_count(self, mock_logger): + def test_update_returns_affected_count(self, mock_logger: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CONTROL): result = self.auth_identity.update(data={"new": "value"}) assert result == 1 @patch("sentry.models.authidentity.logger") - def test_delete_logs(self, mock_logger): + def test_delete_logs(self, mock_logger: MagicMock) -> None: identity_id = self.auth_identity.id provider_id = self.auth_provider.id user_id = self.user.id diff --git a/tests/sentry/models/test_commit.py b/tests/sentry/models/test_commit.py index 2a040db9e93f..53e5190d6f1f 100644 --- a/tests/sentry/models/test_commit.py +++ b/tests/sentry/models/test_commit.py @@ -23,7 +23,10 @@ def _create_commit(self, message: str) -> Commit: ) def _url_prefix(self) -> str: - return options.get("system.url-prefix") + url_prefix = options.get("system.url-prefix") + if not isinstance(url_prefix, str): + raise TypeError("system.url-prefix is not configured") + return url_prefix def _sentry_host(self) -> str: return urlparse(self._url_prefix()).netloc diff --git a/tests/sentry/models/test_debugfile.py b/tests/sentry/models/test_debugfile.py index e2280f48bd50..794c0ef4598a 100644 --- a/tests/sentry/models/test_debugfile.py +++ b/tests/sentry/models/test_debugfile.py @@ -5,7 +5,7 @@ import time import zipfile from io import BytesIO -from typing import Any +from typing import IO, Any from unittest.mock import MagicMock, patch import pytest @@ -13,6 +13,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from django.utils import timezone +from objectstore_client import Session from symbolic.debuginfo import Archive from sentry.models.debugfile import ( @@ -43,7 +44,7 @@ """ -def get_local_fixture_path(name): +def get_local_fixture_path(name: str) -> str: return os.path.join(os.path.dirname(__file__), "fixtures", name) @@ -54,10 +55,11 @@ def test_delete_dif(self) -> None: ) dif_id = dif.id + file_id = dif.file_id dif.delete() assert not ProjectDebugFile.objects.filter(id=dif_id).exists() - assert not File.objects.filter(id=dif.file.id).exists() + assert not File.objects.filter(id=file_id).exists() def test_delete_shared_file_keeps_file_until_last_reference_removed(self) -> None: file = self.create_file( @@ -165,6 +167,7 @@ def test_find_dif_miss_by_features(self) -> None: def test_find_missing(self) -> None: dif = self.create_dif_file(debug_id="dfb8e43a-f242-3d73-a453-aeb6a777ef75-feedface") + assert dif.checksum is not None ret = ProjectDebugFile.objects.find_missing([dif.checksum, "a" * 40], self.project) assert ret == ["a" * 40] @@ -253,10 +256,12 @@ def test_file_extension_macho_lib(self) -> None: class DebugFileObjectstoreTest(TestCase): """Tests for Objectstore-backed debug files.""" - def _get_session(self): + def _get_session(self) -> Session: return get_session(UsecaseId.DEBUG_FILES, self.project) - def _create_objectstore_dif(self, content=b"test-content", **kwargs): + def _create_objectstore_dif( + self, content: bytes = b"test-content", **kwargs: object + ) -> ProjectDebugFile: storage_path = self._get_session().put(content, compress="zstd") defaults = { "debug_id": "dfb8e43a-f242-3d73-a453-aeb6a777ef75", @@ -273,12 +278,12 @@ def _create_objectstore_dif(self, content=b"test-content", **kwargs): return ProjectDebugFile.objects.create(**defaults) def _create_non_objectstore_dif( - self, debug_id: str = "dfb8e43a-f242-3d73-a453-aeb6a777ef75", **kwargs: Any - ): - return self.create_dif_file(debug_id=debug_id, **kwargs) + self, debug_id: str = "dfb8e43a-f242-3d73-a453-aeb6a777ef75", **kwargs: object + ) -> ProjectDebugFile: + return self.create_dif_file(self.project, debug_id=debug_id, **kwargs) @requires_objectstore - def test_metadata_reads_from_new_columns_when_storage_path_set(self): + def test_metadata_reads_from_new_columns_when_storage_path_set(self) -> None: ts = timezone.now() dif = self._create_objectstore_dif( content_type="text/x-proguard+plain", @@ -291,21 +296,22 @@ def test_metadata_reads_from_new_columns_when_storage_path_set(self): assert dif.get_date_created() == ts assert dif.file_format == "proguard" - def test_metadata_reads_from_file_columns_when_file_set(self): + def test_metadata_reads_from_file_columns_when_file_set(self) -> None: dif = self._create_non_objectstore_dif() assert dif.get_content_type() == "application/x-mach-binary" + assert dif.file is not None assert dif.get_file_size() == dif.file.size assert dif.get_date_created() == dif.file.timestamp @requires_objectstore - def test_get_file(self): + def test_get_file(self) -> None: dif = self._create_objectstore_dif() assert dif.get_file().read() == b"test-content" @requires_objectstore - def test_save_to(self): + def test_save_to(self) -> None: dif = self._create_objectstore_dif() with tempfile.TemporaryDirectory() as tmpdir: @@ -316,7 +322,7 @@ def test_save_to(self): assert f.read() == b"test-content" @requires_objectstore - def test_delete(self): + def test_delete(self) -> None: dif = self._create_objectstore_dif() storage_path = dif.storage_path assert storage_path is not None @@ -329,10 +335,10 @@ def test_delete(self): class CreateDebugFileTest(APITestCase): @property - def file_path(self): + def file_path(self) -> str: return get_local_fixture_path("crash.dsym") - def _create_meta(self, **kwargs) -> DifMeta: + def _create_meta(self, **kwargs: object) -> DifMeta: args: dict[str, Any] = { "file_format": "macho", "arch": "x86_64", @@ -344,16 +350,20 @@ def _create_meta(self, **kwargs) -> DifMeta: args.update(kwargs) return DifMeta(**args) - def create_dif(self, file: File, **kwargs): + def create_dif(self, file: File, **kwargs: object) -> tuple[ProjectDebugFile, bool]: if file.size is None: file.size = 0 file.save(update_fields=["size"]) return create_dif_from_file(self.project, self._create_meta(**kwargs), file) - def create_dif_from_fileobj(self, fileobj, **kwargs): + def create_dif_from_fileobj( + self, fileobj: IO[bytes], **kwargs: object + ) -> tuple[ProjectDebugFile, bool]: return create_dif_from_fileobj(self.project, self._create_meta(**kwargs), fileobj) - def create_dif_from_content(self, content: bytes, **kwargs): + def create_dif_from_content( + self, content: bytes, **kwargs: object + ) -> tuple[ProjectDebugFile, bool]: file = self.create_file(name="crash.dsym") file.putfile(ContentFile(content)) return self.create_dif(file, **kwargs) @@ -369,6 +379,7 @@ def test_create_dif_from_file(self) -> None: assert created assert dif is not None + assert dif.file is not None assert dif.file.type == "project.dif" assert "Content-Type" in dif.file.headers assert ProjectDebugFile.objects.filter(id=dif.id).exists() @@ -420,7 +431,7 @@ def test_exclusive_objectstore_create_dif_from_fileobj(self) -> None: dif, created = self.create_dif_from_fileobj(BytesIO(content)) assert created - assert dif.file_id is None + assert dif.file is None assert dif.storage_path is not None assert dif.checksum == checksum assert dif.content_type == "application/x-mach-binary" @@ -428,7 +439,7 @@ def test_exclusive_objectstore_create_dif_from_fileobj(self) -> None: assert dif.get_file().read() == content @patch("sentry.models.debugfile.get_session") - def test_exclusive_objectstore_dif_is_idempotent(self, get_session) -> None: + def test_exclusive_objectstore_dif_is_idempotent(self, get_session: MagicMock) -> None: content = b"objectstore-dif-content" get_session.return_value.put.return_value = "storage-path" @@ -444,7 +455,7 @@ def test_exclusive_objectstore_dif_is_idempotent(self, get_session) -> None: @patch("sentry.models.debugfile.ProjectDebugFile.objects.create") @patch("sentry.models.debugfile.get_session") def test_exclusive_objectstore_dif_cleans_up_after_database_error( - self, get_session, create + self, get_session: MagicMock, create: MagicMock ) -> None: get_session.return_value.put.return_value = "storage-path" create.side_effect = RuntimeError @@ -461,7 +472,7 @@ def test_exclusive_objectstore_dif_cleans_up_after_database_error( @patch("sentry.utils.retries.time.sleep") @patch("sentry.models.debugfile.get_session") def test_exclusive_objectstore_write_failure_does_not_create_file( - self, get_session, sleep + self, get_session: MagicMock, sleep: MagicMock ) -> None: get_session.return_value.put.side_effect = RuntimeError with ( @@ -476,7 +487,9 @@ def test_exclusive_objectstore_write_failure_does_not_create_file( assert not File.objects.filter(type="project.dif").exists() @patch("sentry.models.debugfile.create_dif_from_file", side_effect=RuntimeError) - def test_create_debug_file_cleans_up_legacy_file_after_create_error(self, create_dif) -> None: + def test_create_debug_file_cleans_up_legacy_file_after_create_error( + self, create_dif: MagicMock + ) -> None: with tempfile.NamedTemporaryFile() as file: file.write(b"debug symbols") file.flush() @@ -518,6 +531,7 @@ def test_objectstore_write_failure_preserves_legacy_dif(self) -> None: assert created assert dif.file_id is not None assert dif.storage_path is None + assert dif.file is not None assert dif.file.getfile().read() == content @requires_objectstore diff --git a/tests/sentry/models/test_environment.py b/tests/sentry/models/test_environment.py index 189b23cebff0..773924ffe541 100644 --- a/tests/sentry/models/test_environment.py +++ b/tests/sentry/models/test_environment.py @@ -14,7 +14,9 @@ def test_simple(self) -> None: env = Environment.get_or_create(project=project, name="prod") assert env.name == "prod" - assert env.projects.first().id == project.id + first_project = env.projects.first() + assert first_project is not None + assert first_project.id == project.id env2 = Environment.get_or_create(project=project, name="prod") diff --git a/tests/sentry/models/test_eventattachment.py b/tests/sentry/models/test_eventattachment.py index 1256ed273738..e265ce8f6e32 100644 --- a/tests/sentry/models/test_eventattachment.py +++ b/tests/sentry/models/test_eventattachment.py @@ -65,7 +65,7 @@ class PendingEventAttachmentDeleteTest(TestCase): blob is only safe while we still own the row. """ - def _create_pending(self, prefix="eventattachments/v1") -> PendingEventAttachment: + def _create_pending(self, prefix: str = "eventattachments/v1") -> PendingEventAttachment: return PendingEventAttachment.objects.create( event_id=uuid4().hex, project_id=self.project.id, @@ -251,26 +251,26 @@ def test_objectstore_upload_stores_normalized_content_type( class NormalizeContentTypeTest(TestCase): - def test_returns_explicit_content_type(self): + def test_returns_explicit_content_type(self) -> None: assert normalize_content_type("image/png", "file.png") == "image/png" - def test_strips_charset_from_content_type(self): + def test_strips_charset_from_content_type(self) -> None: assert normalize_content_type("text/plain; charset=utf-8", "file.txt") == "text/plain" - def test_infers_from_filename_when_none(self): + def test_infers_from_filename_when_none(self) -> None: assert normalize_content_type(None, "screenshot.png") == "image/png" - def test_infers_from_filename_when_octet_stream(self): + def test_infers_from_filename_when_octet_stream(self) -> None: assert normalize_content_type("application/octet-stream", "screenshot.png") == "image/png" - def test_falls_back_to_octet_stream_when_unrecognized(self): + def test_falls_back_to_octet_stream_when_unrecognized(self) -> None: assert normalize_content_type(None, "data.bin") == "application/octet-stream" - def test_octet_stream_with_unrecognized_name(self): + def test_octet_stream_with_unrecognized_name(self) -> None: assert ( normalize_content_type("application/octet-stream", "noext") == "application/octet-stream" ) - def test_octet_stream_case_insensitive(self): + def test_octet_stream_case_insensitive(self) -> None: assert normalize_content_type("Application/Octet-Stream", "screenshot.png") == "image/png" diff --git a/tests/sentry/models/test_group.py b/tests/sentry/models/test_group.py index a5d46ccb3445..8a8cd904e471 100644 --- a/tests/sentry/models/test_group.py +++ b/tests/sentry/models/test_group.py @@ -106,6 +106,7 @@ def test_get_group_with_redirect_from_qualified_short_id(self) -> None: ) == (group, False) duplicate_group = self.create_group() + assert duplicate_group.qualified_short_id duplicate_id = duplicate_group.id GroupRedirect.create_for_group(duplicate_group, group) Group.objects.filter(id=duplicate_id).delete() @@ -131,6 +132,7 @@ def test_qualified_share_id(self) -> None: project = self.create_project(name="foo bar") group = self.create_group(project=project, short_id=project.next_short_id()) short_id = group.qualified_short_id + assert short_id is not None assert short_id.startswith("FOO-BAR-") @@ -157,6 +159,8 @@ def test_qualified_share_id_bulk(self) -> None: group_2 = self.create_group(project=project, short_id=project.next_short_id()) group_short_id = group.qualified_short_id group_2_short_id = group_2.qualified_short_id + assert group_short_id is not None + assert group_2_short_id is not None assert [group] == Group.objects.by_qualified_short_id_bulk( group.organization.id, [group_short_id], project_ids=None ) @@ -189,6 +193,8 @@ def test_by_qualified_short_id_bulk_missing_id_colliding_short_id_across_project a_short_id = group_a.qualified_short_id b_short_id = group_b.qualified_short_id + assert a_short_id is not None + assert b_short_id is not None # Make PROJ-B-1 unresolvable while PROJ-A-1 still resolves to the colliding short_id. group_b.update(status=GroupStatus.PENDING_DELETION, substatus=None) @@ -204,6 +210,8 @@ def test_by_qualified_short_id_scoped_to_projects(self) -> None: group_a = self.create_group(project=project_a, short_id=project_a.next_short_id()) group_b = self.create_group(project=project_b, short_id=project_b.next_short_id()) org_id = self.organization.id + assert group_a.qualified_short_id + assert group_b.qualified_short_id # In-scope short id resolves. assert ( @@ -229,6 +237,8 @@ def test_by_qualified_short_id_bulk_scoped_to_projects(self) -> None: group_a = self.create_group(project=project_a, short_id=project_a.next_short_id()) group_b = self.create_group(project=project_b, short_id=project_b.next_short_id()) org_id = self.organization.id + assert group_a.qualified_short_id + assert group_b.qualified_short_id assert Group.objects.by_qualified_short_id_bulk( org_id, [group_a.qualified_short_id], project_ids=[project_a.id] @@ -252,6 +262,7 @@ def test_by_qualified_short_id_bulk_case_insensitive_project_slug(self) -> None: # Re-fetch to ensure updated relation is used when computing qualified_short_id group = Group.objects.get(id=group.id) short_id = group.qualified_short_id + assert short_id is not None # Should resolve via case-insensitive slug fallback resolved = Group.objects.by_qualified_short_id_bulk( @@ -266,6 +277,7 @@ def test_first_last_release(self) -> None: data={"release": "a", "timestamp": self.min_ago}, project_id=project.id ) group = event.group + assert group is not None release = Release.objects.get(version="a") @@ -281,6 +293,7 @@ def test_first_release_from_tag(self) -> None: ) group = event.group + assert group is not None assert group.get_first_release() == "a" cache.delete(_get_cache_key(group.id, group.project_id, True)) @@ -344,6 +357,7 @@ def test_get_absolute_url_event(self) -> None: data={"fingerprint": ["group1"], "timestamp": self.min_ago}, project_id=project.id ) group = event.group + assert group is not None url = f"http://testserver/organizations/{project.organization.slug}/issues/{group.id}/events/{event.event_id}/" assert url == group.get_absolute_url(event_id=event.event_id) @@ -355,6 +369,7 @@ def test_get_absolute_url_customer_domains(self) -> None: ) org = self.organization group = event.group + assert group is not None expected = f"http://{org.slug}.testserver/issues/{group.id}/events/{event.event_id}/" assert expected == group.get_absolute_url(event_id=event.event_id) @@ -368,6 +383,7 @@ def test_get_absolute_api_url(self) -> None: ) org = self.organization group = event.group + assert group is not None assert ( group.get_absolute_api_url() @@ -562,6 +578,7 @@ def test_no_replay_project(self) -> None: project_id=self.project.id, ) group = event.group + assert group is not None assert group.has_replays() is False def test_no_replay_on_issue(self) -> None: @@ -574,6 +591,7 @@ def test_no_replay_on_issue(self) -> None: ) group = event.group + assert group is not None assert group.has_replays() is False # test caching @@ -626,9 +644,11 @@ def test_update_group_status_without_custom_update_date(self) -> None: after = timezone.now() group.refresh_from_db() + resolved_at = group.resolved_at + assert resolved_at is not None assert group.status == GroupStatus.RESOLVED - assert before <= group.resolved_at <= after + assert before <= resolved_at <= after activity = Activity.objects.filter( group=group, type=ActivityType.SET_RESOLVED.value diff --git a/tests/sentry/models/test_groupassignee.py b/tests/sentry/models/test_groupassignee.py index 4ceb7f36bd3c..3ad992759684 100644 --- a/tests/sentry/models/test_groupassignee.py +++ b/tests/sentry/models/test_groupassignee.py @@ -53,7 +53,7 @@ def _assert_corrupt_derived_data_allows_assignment( assert GroupDerivedData.objects.filter(id=derived.id).values().get() == before logger.exception.assert_called_once() - context = logger.exception.call_args.kwargs["extra"] + context: dict[str, Any] = logger.exception.call_args.kwargs["extra"] assert context["group_id"] == self.group.id assert context["operation"] == "assign" assert context["stage"] == "decode" @@ -114,7 +114,7 @@ def _assert_failed_replay_allows_assignment(self) -> dict[str, Any]: replay.assert_called_once() assert list(GroupDerivedData.objects.filter(group_id=self.group.id).values()) == before logger.exception.assert_called_once() - context = logger.exception.call_args.kwargs["extra"] + context: dict[str, Any] = logger.exception.call_args.kwargs["extra"] assert context["group_id"] == self.group.id assert context["operation"] == "assign" assert context["stage"] == "aggregate" @@ -346,8 +346,8 @@ def test_assignee_sync_outbound_assign( @mock.patch.object(ExampleIntegration, "sync_assignee_outbound") def test_assignee_sync_outbound_assign_with_matching_source_integration( - self, mock_sync_assignee_outbound - ): + self, mock_sync_assignee_outbound: mock.MagicMock + ) -> None: group = self.group integration = self.create_integration( organization=group.organization, diff --git a/tests/sentry/models/test_grouphistory.py b/tests/sentry/models/test_grouphistory.py index 5150454b4a9e..f317fcae012f 100644 --- a/tests/sentry/models/test_grouphistory.py +++ b/tests/sentry/models/test_grouphistory.py @@ -24,7 +24,7 @@ def test_owner(self) -> None: assert owner assert owner.identifier == actor.identifier assert history.user_id == self.user.id - assert history.team_id is None + assert history.team is None actor = Actor.from_id(team_id=team.id) assert actor diff --git a/tests/sentry/models/test_groupowner.py b/tests/sentry/models/test_groupowner.py index b704c72c87fb..d3fc4bf5bd3b 100644 --- a/tests/sentry/models/test_groupowner.py +++ b/tests/sentry/models/test_groupowner.py @@ -15,25 +15,25 @@ def setUp(self) -> None: repo=self.create_repo(self.project), ) - self.lookup_kwargs = { + self.lookup_kwargs: dict[str, object] = { "group_id": self.group.id, "type": GroupOwnerType.SUSPECT_COMMIT.value, "user_id": self.user.id, "project_id": self.project.id, "organization_id": self.organization.id, } - self.scm_extra_lookup = {"context__asjsonb__commitId": self.c.id} + self.scm_extra_lookup: dict[str, object] = {"context__asjsonb__commitId": self.c.id} - self.defaults = { + self.defaults: dict[str, object] = { "date_added": self.timestamp, } - self.scm_context_defaults = { + self.scm_context_defaults: dict[str, object] = { "commitId": self.c.id, "suspectCommitStrategy": SuspectCommitStrategy.SCM_BASED, } - self.rb_context_defaults = { + self.rb_context_defaults: dict[str, object] = { "suspectCommitStrategy": SuspectCommitStrategy.RELEASE_BASED, } diff --git a/tests/sentry/models/test_groupsnooze.py b/tests/sentry/models/test_groupsnooze.py index 93d0d6a5ff24..77e1d4a3088f 100644 --- a/tests/sentry/models/test_groupsnooze.py +++ b/tests/sentry/models/test_groupsnooze.py @@ -83,6 +83,7 @@ def test_user_delta_reached(self) -> None: ) group = event.group + assert group is not None snooze = GroupSnooze.objects.create(group=group, user_count=5, state={"users_seen": 0}) assert not snooze.is_valid(test_rates=True) @@ -98,6 +99,7 @@ def test_user_rate_reached(self) -> None: }, project_id=self.project.id, ).group + assert group is not None snooze = GroupSnooze.objects.create(group=group, user_count=5, user_window=60) assert not snooze.is_valid(test_rates=True) @@ -143,6 +145,7 @@ def test_rate_reached(self) -> None: }, project_id=self.project.id, ).group + assert group is not None snooze = GroupSnooze.objects.create(group=group, count=5, window=24 * 60) assert not snooze.is_valid(test_rates=True) diff --git a/tests/sentry/models/test_organization.py b/tests/sentry/models/test_organization.py index f9a9599a2295..63fb1a4c27e9 100644 --- a/tests/sentry/models/test_organization.py +++ b/tests/sentry/models/test_organization.py @@ -174,12 +174,14 @@ def setUp(self) -> None: self.org = self.create_organization(owner=self.owner) self.request = self.make_request(user=self.owner) - def _create_user(self, has_email=True): + def _create_user(self, has_email: bool = True) -> User: if not has_email: return self.create_user("") return self.create_user() - def _create_user_and_member(self, has_2fa=False, has_user_email=True): + def _create_user_and_member( + self, has_2fa: bool = False, has_user_email: bool = True + ) -> tuple[User, OrganizationMember]: user = self._create_user(has_email=has_user_email) if has_2fa: with assume_test_silo_mode(SiloMode.CONTROL): @@ -187,7 +189,7 @@ def _create_user_and_member(self, has_2fa=False, has_user_email=True): member = self.create_member(organization=self.org, user=user) return user, member - def is_organization_member(self, user_id, member_id): + def is_organization_member(self, user_id: int, member_id: int) -> None: member = OrganizationMember.objects.get(id=member_id) with assume_test_silo_mode(SiloMode.CONTROL): @@ -196,7 +198,9 @@ def is_organization_member(self, user_id, member_id): assert not member.email assert member.user_id == user.id - def is_pending_organization_member(self, user_id, member_id, was_booted=True): + def is_pending_organization_member( + self, user_id: int | None, member_id: int, was_booted: bool = True + ) -> None: member = OrganizationMember.objects.get(id=member_id) if user_id: with assume_test_silo_mode(SiloMode.CONTROL): @@ -464,7 +468,7 @@ def test_get_bulk_owner_profiles(self) -> None: class OrganizationDeletionTest(TestCase): - def add_org_notification_settings(self, org: Organization, user: User): + def add_org_notification_settings(self, org: Organization, user: User) -> None: with assume_test_silo_mode(SiloMode.CONTROL): args = { "scope_type": "organization", diff --git a/tests/sentry/models/test_organizationmember.py b/tests/sentry/models/test_organizationmember.py index f438302d75bc..e32fd0f698b9 100644 --- a/tests/sentry/models/test_organizationmember.py +++ b/tests/sentry/models/test_organizationmember.py @@ -1,4 +1,6 @@ +from collections.abc import Sequence from datetime import timedelta +from typing import cast from unittest.mock import MagicMock, patch import pytest @@ -42,16 +44,16 @@ class MockOrganizationRoles: {"id": "carol", "name": "Carol", "desc": "A nanny?"}, ] - def __init__(self): + def __init__(self) -> None: from sentry.roles.manager import RoleManager self.default_manager = RoleManager(self.TEST_ORG_ROLES, self.TEST_TEAM_ROLES) self.organization_roles = self.default_manager.organization_roles - def get_all(self): + def get_all(self) -> Sequence[object]: return self.organization_roles.get_all() - def get(self, x): + def get(self, x: str) -> object: return self.organization_roles.get(x) @@ -83,6 +85,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: @@ -138,6 +141,7 @@ def test_send_sso_unlink_email(self, builder: MagicMock) -> None: with self.options({"system.url-prefix": "http://example.com"}), self.tasks(): rpc_user = user_service.get_user(user_id=user.id) + assert rpc_user is not None member.send_sso_unlink_email(rpc_user, provider) context = builder.call_args[1]["context"] @@ -210,9 +214,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_project.py b/tests/sentry/models/test_project.py index 7f929d936d4c..63a72e95edb0 100644 --- a/tests/sentry/models/test_project.py +++ b/tests/sentry/models/test_project.py @@ -145,11 +145,11 @@ def test_remove_team_clears_alerts(self) -> None: self.project.remove_team(team) rule.refresh_from_db() - assert rule.owner_team_id is None + assert rule.owner_team is None assert rule.owner_user_id is None alert_rule.refresh_from_db() - assert alert_rule.team_id is None + assert alert_rule.team is None assert alert_rule.user_id is None def test_project_detectors(self) -> None: @@ -308,7 +308,7 @@ def test_transfer_to_organization_slug_collision(self) -> None: assert Project.objects.filter(organization=self.from_org).count() == 0 def test_transfer_to_organization_releases(self) -> None: - def project_props(proj: Project): + def project_props(proj: Project) -> dict[str, object]: return { "id": proj.id, "slug": proj.slug, @@ -1025,11 +1025,11 @@ def setUp(self) -> None: # there is a default rule added to project self.rules = Rule.objects.filter(project_id=self.other_project.id).order_by("label") - def assert_other_project_settings_not_changed(self): + def assert_other_project_settings_not_changed(self) -> None: # other_project should not have changed. This should check that. self.assert_settings_copied(self.other_project) - def assert_settings_copied(self, project): + def assert_settings_copied(self, project: Project) -> None: for key, value in self.options_dict.items(): assert project.get_option(key) == value @@ -1100,7 +1100,7 @@ def test_copy_keeps_the_name_and_the_active_flag_of_a_list_row(self) -> None: @control_silo_test class FilterToSubscribedUsersTest(TestCase): - def run_test(self, users: Iterable[User], expected_users: Iterable[User]): + def run_test(self, users: Iterable[User], expected_users: Iterable[User]) -> None: recipients = get_notification_recipients( recipients=Actor.many_from_object(users), type=NotificationSettingEnum.ISSUE_ALERTS, diff --git a/tests/sentry/models/test_projectcounter.py b/tests/sentry/models/test_projectcounter.py index 193b053b71d9..13dc97038db8 100644 --- a/tests/sentry/models/test_projectcounter.py +++ b/tests/sentry/models/test_projectcounter.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from contextlib import contextmanager from unittest.mock import MagicMock, patch @@ -22,13 +23,13 @@ @django_db_all -def test_increment(default_project) -> None: +def test_increment(default_project: Project) -> None: assert Counter.increment(default_project) == 1 assert Counter.increment(default_project) == 2 @contextmanager -def patch_group_creation(): +def patch_group_creation() -> Iterator[tuple[MagicMock, list[Group | Exception]]]: group_creation_results: list[Group | Exception] = [] group_creation_spy = MagicMock( side_effect=capture_results(Group.objects.create, group_creation_results) @@ -37,7 +38,7 @@ def patch_group_creation(): yield (group_creation_spy, group_creation_results) -def create_existing_group(project, message): +def create_existing_group(project: Project, message: str) -> Group: with patch_group_creation() as patches: group_creation_spy, group_creation_results = patches @@ -56,7 +57,7 @@ def create_existing_group(project, message): @django_db_all -def test_group_creation_simple(default_project) -> None: +def test_group_creation_simple(default_project: Project) -> None: group = create_existing_group(default_project, "Dogs are great!") # See `create_existing_group` for more assertions @@ -103,6 +104,7 @@ def test_group_creation_with_stuck_project_counter( # Get the short_id that would conflict (one of the existing group's short_ids) conflicting_short_id = existing_groups[0].short_id + assert conflicting_short_id is not None # Populate Redis with the conflicting short_id and some additional values # This will cause the Redis-based counter to return the conflicting short_id @@ -169,6 +171,7 @@ def test_group_creation_with_stuck_project_counter( # We successfully created a new group assert isinstance(group_creation_results[0], Group) new_new_group = group_creation_results[0] + assert new_new_group.short_id is not None assert hopefully_normal_event.group_id == new_new_group.id assert new_new_group.id not in [group.id for group in existing_groups] @@ -181,14 +184,16 @@ def test_group_creation_with_stuck_project_counter( # === Redis-based Counter Tests === @pytest.fixture -def redis_mock(): +def redis_mock() -> Iterator[MagicMock]: with patch("sentry.models.counter.redis_clusters") as mock: mock.get.return_value = MagicMock() yield mock.get.return_value @django_db_all -def test_increment_project_counter_in_cache(default_project, redis_mock) -> None: +def test_increment_project_counter_in_cache( + default_project: Project, redis_mock: MagicMock +) -> None: # Enable the feature flag # Patch the pipeline context manager pipeline_mock = MagicMock() @@ -212,7 +217,7 @@ def test_increment_project_counter_in_cache(default_project, redis_mock) -> None @django_db_all -def test_refill_cached_short_ids(default_project, redis_mock) -> None: +def test_refill_cached_short_ids(default_project: Project, redis_mock: MagicMock) -> None: # Mock the lock lock_mock = MagicMock() lock_mock.locked.return_value = False @@ -238,7 +243,9 @@ def test_refill_cached_short_ids(default_project, redis_mock) -> None: @django_db_all -def test_refill_cached_short_ids_lock_contention(default_project, redis_mock) -> None: +def test_refill_cached_short_ids_lock_contention( + default_project: Project, redis_mock: MagicMock +) -> None: # Mock the lock as already locked block_size = calculate_cached_id_block_size(1) lock = locks.get( @@ -251,7 +258,7 @@ def test_refill_cached_short_ids_lock_contention(default_project, redis_mock) -> @django_db_all -def test_low_water_mark_trigger(default_project, redis_mock) -> None: +def test_low_water_mark_trigger(default_project: Project, redis_mock: MagicMock) -> None: pipeline_mock = MagicMock() pipeline_mock.__enter__.return_value = pipeline_mock pipeline_mock.__exit__.return_value = None @@ -272,7 +279,7 @@ def test_low_water_mark_trigger(default_project, redis_mock) -> None: @django_db_all -def test_fallback_to_database(default_project, redis_mock) -> None: +def test_fallback_to_database(default_project: Project, redis_mock: MagicMock) -> None: # Enable the feature flag # Patch the pipeline context manager pipeline_mock = MagicMock() @@ -290,7 +297,7 @@ def test_fallback_to_database(default_project, redis_mock) -> None: @django_db_all -def test_preallocation_end_to_end(default_project) -> None: +def test_preallocation_end_to_end(default_project: Project) -> None: # The first increment should trigger a refill with TaskRunner(): current_value = Counter.increment(default_project) @@ -315,7 +322,7 @@ def test_preallocation_end_to_end(default_project) -> None: @django_db_all -def test_preallocation_early_return(default_project) -> None: +def test_preallocation_early_return(default_project: Project) -> None: block_size = calculate_cached_id_block_size(1) with TaskRunner(): current_value = Counter.increment(default_project) diff --git a/tests/sentry/models/test_projectkey.py b/tests/sentry/models/test_projectkey.py index 71f9e450898d..82409bf44480 100644 --- a/tests/sentry/models/test_projectkey.py +++ b/tests/sentry/models/test_projectkey.py @@ -3,6 +3,7 @@ import pytest from django.test import override_settings +from sentry.models.project import Project from sentry.models.projectkey import ProjectKey, ProjectKeyManager, ProjectKeyStatus from sentry.silo.base import SiloMode from sentry.testutils.cases import TestCase @@ -184,7 +185,9 @@ def test_get_dsn_org_subdomain_and_multiregion(self) -> None: @mock.patch("sentry.models.projectkey.schedule_invalidate_project_config") @django_db_all -def test_key_deleted_projconfig_invalidated(inv_proj_config, default_project) -> None: +def test_key_deleted_projconfig_invalidated( + inv_proj_config: mock.MagicMock, default_project: Project +) -> None: assert inv_proj_config.call_count == 0 key = ProjectKey.objects.get(project=default_project) @@ -196,7 +199,9 @@ def test_key_deleted_projconfig_invalidated(inv_proj_config, default_project) -> @mock.patch("sentry.models.projectkey.schedule_invalidate_project_config") @django_db_all -def test_key_saved_projconfig_invalidated(inv_proj_config, default_project) -> None: +def test_key_saved_projconfig_invalidated( + inv_proj_config: mock.MagicMock, default_project: Project +) -> None: assert inv_proj_config.call_count == 0 key = ProjectKey.objects.get(project=default_project) diff --git a/tests/sentry/models/test_projectownership.py b/tests/sentry/models/test_projectownership.py index 7e53ac32f647..404d70be6e56 100644 --- a/tests/sentry/models/test_projectownership.py +++ b/tests/sentry/models/test_projectownership.py @@ -1,3 +1,5 @@ +from collections.abc import Sequence +from typing import cast from unittest.mock import MagicMock, patch from sentry.issues.ownership.grammar import Matcher, Owner, Rule, dump_schema, resolve_actors @@ -23,7 +25,7 @@ pytestmark = requires_snuba -def actor_key(actor): +def actor_key(actor: Actor) -> int: return actor.id @@ -46,7 +48,7 @@ def setUp(self) -> None: organization=self.organization, teams=[self.team, self.team2] ) - def python_event_data(self): + def python_event_data(self) -> dict[str, object]: return { "message": "Kaboom!", "platform": "python", @@ -74,11 +76,15 @@ def python_event_data(self): "tags": {"sentry:release": self.release.version}, } - def assert_ownership_equals(self, o1, o2): + def assert_ownership_equals( + self, + o1: tuple[list[Actor], Sequence[Rule] | None], + o2: tuple[list[Actor], Sequence[Rule] | None], + ) -> None: # Ensure actors match assert sorted(o1[0], key=actor_key) == sorted(o2[0], key=actor_key) # Ensure rules match - assert sorted(o1[1]) == sorted(o2[1]) + assert sorted(cast("Sequence[Rule]", o1[1])) == sorted(cast("Sequence[Rule]", o2[1])) def test_get_owners_default(self) -> None: ProjectOwnership.objects.create(project_id=self.project.id, fallthrough=True) @@ -666,7 +672,7 @@ def test_no_group_owner(self) -> None: def test_handle_auto_assignment_when_suspect_committer_and_codeowners_and_issueowners_exists( self, - ): + ) -> None: self.repo = Repository.objects.create( organization_id=self.organization.id, name="example", diff --git a/tests/sentry/models/test_projectsdk.py b/tests/sentry/models/test_projectsdk.py index fb986dcd54d6..77418b62779d 100644 --- a/tests/sentry/models/test_projectsdk.py +++ b/tests/sentry/models/test_projectsdk.py @@ -1,7 +1,8 @@ -from typing import Any +from typing import Any, cast from django.test import override_settings +from sentry.models.project import Project from sentry.models.projectsdk import EventType, ProjectSDK from sentry.tasks.release_registry import SDK_INDEX_CACHE_KEY from sentry.testutils.cases import TestCase @@ -21,11 +22,11 @@ def setUp(self) -> None: def assert_db_entry( self, - project, - event_type, - sdk_name, - sdk_version, - ): + project: Project, + event_type: EventType, + sdk_name: str, + sdk_version: str, + ) -> ProjectSDK: project_sdk = ProjectSDK.objects.get( project=project, event_type=event_type.value, @@ -36,11 +37,11 @@ def assert_db_entry( def assert_cache_entry( self, - project, - event_type, - sdk_name, - sdk_version, - ): + project: Project, + event_type: EventType, + sdk_name: str, + sdk_version: str, + ) -> ProjectSDK: cache_key = ProjectSDK.get_cache_key(project, event_type, sdk_name) project_sdk = cache.get(cache_key) @@ -48,7 +49,8 @@ def assert_cache_entry( assert project_sdk.event_type == event_type.value assert project_sdk.sdk_name == sdk_name assert project_sdk.sdk_version == sdk_version - return project_sdk + # justification: the cache stores the ProjectSDK row created for this key. + return cast("ProjectSDK", project_sdk) def test_first_sdk_version(self) -> None: ProjectSDK.update_with_newest_version_or_create( diff --git a/tests/sentry/models/test_pullrequest.py b/tests/sentry/models/test_pullrequest.py index b77c782679c9..8d14121a852a 100644 --- a/tests/sentry/models/test_pullrequest.py +++ b/tests/sentry/models/test_pullrequest.py @@ -1,5 +1,5 @@ from collections.abc import Callable -from datetime import timedelta +from datetime import datetime, timedelta from hashlib import sha1 from uuid import uuid4 @@ -17,6 +17,7 @@ CommentType, PullRequest, PullRequestCommit, + ResolvedPullRequest, parse_pull_request_url, ) from sentry.models.releasecommit import ReleaseCommit @@ -246,7 +247,9 @@ def setUp(self) -> None: email="test@example.com", ) - def create_pr(self, date_added=None, key=None): + def create_pr( + self, date_added: datetime | None = None, key: int | str | None = None + ) -> PullRequest: """Helper to create a PR with specified date""" if date_added is None: date_added = self.old_date @@ -262,7 +265,7 @@ def create_pr(self, date_added=None, key=None): pr.refresh_from_db() return pr - def create_old_commit(self): + def create_old_commit(self) -> Commit: """Helper to create a commit that is itself past the cutoff""" commit = self.create_commit( project=self.project, @@ -495,7 +498,7 @@ def _resolve( provider: str | None = "github", key: int | str = 42, repo_external_id: str | None = None, - ): + ) -> ResolvedPullRequest: return PullRequest.objects.get_or_create_from_reference( organization_id=self.organization.id, repo_name=repo_name, @@ -691,7 +694,9 @@ def setUp(self) -> None: organization_id=self.organization.id, repository_id=self.repo.id, key="42" ) - def _for_provider_pr(self, *, integration_id: int | None = None, key: int | str = 42): + def _for_provider_pr( + self, *, integration_id: int | None = None, key: int | str = 42 + ) -> list[PullRequest]: return PullRequest.objects.for_provider_pr( external_id=self.external_id, integration_id=self.integration_id if integration_id is None else integration_id, diff --git a/tests/sentry/models/test_release.py b/tests/sentry/models/test_release.py index 68d5c76eaaa7..a507b3e8e6d8 100644 --- a/tests/sentry/models/test_release.py +++ b/tests/sentry/models/test_release.py @@ -1,4 +1,5 @@ from datetime import timedelta +from typing import Any from unittest.mock import MagicMock, call, patch import pytest @@ -171,7 +172,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 +199,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 @@ -789,6 +790,7 @@ def test_resolution_records_commit_provider( release.set_commits([{"id": commit.key, "repository": repo.name}]) assert Group.objects.get(id=group.id).status == GroupStatus.RESOLVED + assert org.default_owner_id is not None assert_any_analytics_event( mock_record, IssueResolvedEvent( @@ -836,6 +838,7 @@ def test_resolution_without_repository_records_no_provider( assert Group.objects.get(id=group.id).status == GroupStatus.RESOLVED commit = Commit.objects.get(organization_id=org.id, key="a" * 40) + assert org.default_owner_id is not None assert_any_analytics_event( mock_record, IssueResolvedEvent( @@ -904,6 +907,7 @@ def test_resolution_via_pull_request_records_merge_commit( release.set_commits([{"id": commit.key, "repository": repo.name}]) assert Group.objects.get(id=group.id).status == GroupStatus.RESOLVED + assert org.default_owner_id is not None assert_any_analytics_event( mock_record, IssueResolvedEvent( @@ -1160,7 +1164,7 @@ def setUp(self) -> None: @patch("sentry.tasks.commits.fetch_commits") @receivers_raise_on_send() def test_simple(self, mock_fetch_commit: MagicMock) -> None: - refs = [ + refs: list[dict[str, Any]] = [ { "repository": "test/repo", "previousCommit": "previous-commit-id", @@ -1209,7 +1213,7 @@ def test_invalid_repos(self, mock_fetch_commit: MagicMock) -> None: @patch("sentry.tasks.commits.fetch_commits") @receivers_raise_on_send() def test_handle_commit_ranges(self, mock_fetch_commit: MagicMock) -> None: - refs = [ + refs: list[dict[str, Any]] = [ { "repository": "test/repo", "previousCommit": None, @@ -1508,7 +1512,14 @@ def test_invalid_query(self) -> None: ): Release.objects.filter_by_semver(self.organization.id, parse_semver("1.2.hi", ">")) - def run_test(self, operator, version, expected_releases, organization_id=None, projects=None): + def run_test( + self, + operator: str, + version: str, + expected_releases: list[Release], + organization_id: int | None = None, + projects: list[Project] | None = None, + ) -> None: organization_id = organization_id if organization_id else self.organization.id project_ids = [p.id for p in projects] if projects else None assert set( @@ -1599,7 +1610,14 @@ def test_project(self) -> None: class ReleaseFilterBySemverBuildTest(TestCase): - def run_test(self, operator, build, expected_releases, organization_id=None, projects=None): + def run_test( + self, + operator: str, + build: str, + expected_releases: list[Release], + organization_id: int | None = None, + projects: list[Project] | None = None, + ) -> None: organization_id = organization_id if organization_id else self.organization.id project_ids = [p.id for p in projects] if projects else None assert set( diff --git a/tests/sentry/models/test_releasefile.py b/tests/sentry/models/test_releasefile.py index 9f99d5207d13..69fb488ca8ba 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 @@ -10,6 +11,8 @@ from sentry.models.files.file import File from sentry.models.releasefile import ( ARTIFACT_INDEX_FILENAME, + ArtifactIndex, + ReleaseArchive, ReleaseFile, _ArtifactIndexGuard, delete_from_artifact_index, @@ -72,7 +75,30 @@ def test_count_artifacts(self) -> None: class ReleaseArchiveTestCase(TestCase): - def create_archive(self, fields, files, dist=None): + def test_manifest_must_be_a_json_object(self) -> None: + buffer = BytesIO() + with ZipFile(buffer, mode="w") as zf: + zf.writestr("manifest.json", "[]") + buffer.seek(0) + + with pytest.raises(ValueError, match="expected a JSON object"): + ReleaseArchive(buffer) + + def test_manifest_files_must_be_a_json_object(self) -> None: + buffer = BytesIO() + with ZipFile(buffer, mode="w") as zf: + zf.writestr("manifest.json", json.dumps({"files": []})) + buffer.seek(0) + + with pytest.raises(ValueError, match="manifest files to be a JSON object"): + ReleaseArchive(buffer) + + def create_archive( + self, + fields: dict[str, object], + files: dict[str, str], + dist: Distribution | None = None, + ) -> ReleaseFile | None: manifest = dict( fields, files={filename: {"url": f"fake://{filename}"} for filename in files} ) @@ -103,6 +129,7 @@ def test_multi_archive(self) -> None: "baz": "bazaa", }, ) + assert archive1 is not None assert read_artifact_index(self.release, None) == { "files": { @@ -144,9 +171,10 @@ def test_multi_archive(self) -> None: "zap": "zapz", }, ) + assert archive2 is not None # Two files were overwritten, one was added - expected = { + expected: ArtifactIndex = { "files": { "fake://bar": { "archive_ident": archive2.ident, @@ -202,10 +230,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) @@ -232,7 +263,7 @@ def test_locking(self) -> None: # Only one `File` was created: assert File.objects.filter(name=ARTIFACT_INDEX_FILENAME).count() == 1 - def delete(): + def delete() -> None: sleep(2 * self.tick) delete_from_artifact_index(release, dist, "foo") @@ -254,6 +285,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) @@ -270,7 +302,7 @@ def test_lock_existing(self) -> None: assert index is not None assert index["files"].keys() == {"0", "foo", "123"} - def delete(): + def delete() -> None: sleep(2 * self.tick) delete_from_artifact_index(release, dist, "foo") diff --git a/tests/sentry/models/test_repository.py b/tests/sentry/models/test_repository.py index 10f3a8637266..826c69aaf71e 100644 --- a/tests/sentry/models/test_repository.py +++ b/tests/sentry/models/test_repository.py @@ -5,7 +5,7 @@ from sentry.constants import DEFAULT_CODE_REVIEW_TRIGGERS, ObjectStatus from sentry.models.options.organization_option import OrganizationOption -from sentry.models.repository import Repository +from sentry.models.repository import RepoResolution, Repository from sentry.models.repositorysettings import RepositorySettings from sentry.testutils.cases import TestCase from sentry.testutils.helpers.features import with_feature @@ -24,7 +24,7 @@ def setUp(self) -> None: ) self.provider = MagicMock() self.provider.name = "Example" - self.repo.get_provider = lambda: self.provider + setattr(self.repo, "get_provider", lambda: self.provider) @with_feature("organizations:notification-platform.internal-testing") @override_options( @@ -277,7 +277,7 @@ def setUp(self) -> None: def _resolve( self, *, name: str = "getsentry/sentry", normalized_provider: str | None = "github" - ): + ) -> tuple[Repository | None, RepoResolution]: return Repository.objects.resolve_active( organization_id=self.organization.id, name=name, diff --git a/tests/sentry/monitors/clock_tasks/test_check_missed.py b/tests/sentry/monitors/clock_tasks/test_check_missed.py index cdd7f4018022..609e20407847 100644 --- a/tests/sentry/monitors/clock_tasks/test_check_missed.py +++ b/tests/sentry/monitors/clock_tasks/test_check_missed.py @@ -448,11 +448,11 @@ def test_missing_checkin_with_skipped_clock_ticks( @mock.patch("sentry.monitors.clock_tasks.check_missed.produce_task") def assert_state_does_not_change_for_status( self, - state, - mock_produce_task, - is_muted=False, - environment_is_muted=False, - ): + state: int, + mock_produce_task: mock.MagicMock, + is_muted: bool = False, + environment_is_muted: bool = False, + ) -> None: org = self.create_organization() project = self.create_project(organization=org) diff --git a/tests/sentry/monitors/consumers/test_clock_tick_consumer.py b/tests/sentry/monitors/consumers/test_clock_tick_consumer.py index b0044f6e4404..43b724aabfa3 100644 --- a/tests/sentry/monitors/consumers/test_clock_tick_consumer.py +++ b/tests/sentry/monitors/consumers/test_clock_tick_consumer.py @@ -6,6 +6,7 @@ from arroyo.backends.local.storages.memory import MemoryMessageStorage from arroyo.commit import ONCE_PER_SECOND from arroyo.processing.processor import StreamProcessor +from arroyo.processing.strategies import ProcessingStrategy from arroyo.types import BrokerValue, Message, Partition, Topic from django.test.utils import override_settings from django.utils import timezone @@ -32,7 +33,7 @@ partition = Partition(Topic("test"), 0) -def create_consumer(): +def create_consumer() -> ProcessingStrategy[KafkaPayload]: factory = MonitorClockTickStrategyFactory() commit = mock.Mock() return factory.create_with_partitions(commit, {partition: 0}) @@ -43,10 +44,10 @@ def create_consumer(): @mock.patch("sentry.monitors.consumers.clock_tick_consumer.process_clock_tick_for_system_incidents") @override_options({"crons.system_incidents.use_decisions": True}) def test_simple( - mock_process_clock_tick_for_system_incidents, - mock_dispatch_check_timeout, - mock_dispatch_check_missing, -): + mock_process_clock_tick_for_system_incidents: mock.MagicMock, + mock_dispatch_check_timeout: mock.MagicMock, + mock_dispatch_check_missing: mock.MagicMock, +) -> None: consumer = create_consumer() ts = timezone.now().replace(second=0, microsecond=0) @@ -80,11 +81,11 @@ def test_simple( @mock.patch("sentry.monitors.consumers.clock_tick_consumer.process_clock_tick_for_system_incidents") @override_options({"crons.system_incidents.use_decisions": True}) def test_incident_mark_unknown( - mock_process_clock_tick_for_system_incidents, - mock_dispatch_check_timeout, - mock_dispatch_check_missing, - mock_dispatch_mark_unknown, -): + mock_process_clock_tick_for_system_incidents: mock.MagicMock, + mock_dispatch_check_timeout: mock.MagicMock, + mock_dispatch_check_missing: mock.MagicMock, + mock_dispatch_mark_unknown: mock.MagicMock, +) -> None: """ Test that during an incident we mark in-progress check-ins as unknown and do NOT dispatch mark timeouts. diff --git a/tests/sentry/monitors/consumers/test_end_to_end.py b/tests/sentry/monitors/consumers/test_end_to_end.py index 04068d2447ba..494747e1e6a9 100644 --- a/tests/sentry/monitors/consumers/test_end_to_end.py +++ b/tests/sentry/monitors/consumers/test_end_to_end.py @@ -10,6 +10,7 @@ from arroyo.backends.local.storages.memory import MemoryMessageStorage from arroyo.commit import ONCE_PER_SECOND from arroyo.processing.processor import StreamProcessor +from arroyo.processing.strategies import ProcessingStrategy from arroyo.types import BrokerValue, Message, Partition, Topic from django.test.utils import override_settings from django.utils import timezone @@ -41,7 +42,7 @@ class ExpectNoProcessingError: partition = Partition(Topic("test"), 0) -def create_consumer(): +def create_consumer() -> ProcessingStrategy[KafkaPayload]: factory = StoreMonitorCheckInStrategyFactory() commit = mock.Mock() return factory.create_with_partitions(commit, {partition: 0}) @@ -100,7 +101,7 @@ def check_processing_errors( expected_checkin: CheckIn, expected_error: ProcessingErrorsException | ExpectNoProcessingError | None, expected_monitor_slug: str | None, - ) -> Generator: + ) -> Generator[None]: if expected_error is None: yield None return @@ -132,7 +133,7 @@ def check_processing_errors( assert error.monitor == expected_error.monitor assert error.processing_errors == expected_error.processing_errors - def init(self, checkin_margin=1, max_runtime=1): + def init(self, checkin_margin: int = 1, max_runtime: int = 1) -> None: self.time = timezone.now().replace(second=0, microsecond=0) self.time_delta = timedelta(minutes=1) self.broker: LocalBroker[KafkaPayload] = LocalBroker(MemoryMessageStorage()) @@ -186,7 +187,7 @@ def init(self, checkin_margin=1, max_runtime=1): commit_policy=ONCE_PER_SECOND, ) - def tick_clock(self, delta: timedelta | None = None): + def tick_clock(self, delta: timedelta | None = None) -> None: if delta is None: delta = self.time_delta self.time = self.time + delta diff --git a/tests/sentry/monitors/consumers/test_incident_occurrence_consumer.py b/tests/sentry/monitors/consumers/test_incident_occurrence_consumer.py index 5c8cce161a78..3961f11016d2 100644 --- a/tests/sentry/monitors/consumers/test_incident_occurrence_consumer.py +++ b/tests/sentry/monitors/consumers/test_incident_occurrence_consumer.py @@ -36,7 +36,7 @@ def send_incident_occurrence( consumer: ProcessingStrategy[KafkaPayload], ts: datetime, incident_occurrence: IncidentOccurrence, -): +) -> None: value = BrokerValue( KafkaPayload(b"fake-key", MONITORS_INCIDENT_OCCURRENCES.encode(incident_occurrence), []), partition, @@ -145,9 +145,9 @@ def test_missing_data(self, mock_logger: mock.MagicMock) -> None: @override_options({"crons.system_incidents.use_decisions": True}) def test_delayed_decision_dispatch( self, - mock_memoized_tick_decision, - mock_send_incident_occurrence, - ): + mock_memoized_tick_decision: mock.MagicMock, + mock_send_incident_occurrence: mock.MagicMock, + ) -> None: """ Tests that the consumer does NOT send an incident occurrence when the clock tick decision is marked as ABNORMAL or RECOVERING. In these cases @@ -183,9 +183,9 @@ def test_delayed_decision_dispatch( @override_options({"crons.system_incidents.use_decisions": True}) def test_incident_decision_dispatch( self, - mock_memoized_tick_decision, - mock_send_incident_occurrence, - ): + mock_memoized_tick_decision: mock.MagicMock, + mock_send_incident_occurrence: mock.MagicMock, + ) -> None: """ Tests that the cosnumer does NOT send an incident occurrence when the clock decision is INCIDENT, in which case we should update the failed diff --git a/tests/sentry/monitors/consumers/test_monitor_consumer.py b/tests/sentry/monitors/consumers/test_monitor_consumer.py index 5c04e921b159..e17ca441db1a 100644 --- a/tests/sentry/monitors/consumers/test_monitor_consumer.py +++ b/tests/sentry/monitors/consumers/test_monitor_consumer.py @@ -33,7 +33,7 @@ get_detector_for_monitor, ) from sentry.monitors.processing_errors.errors import ProcessingErrorsException, ProcessingErrorType -from sentry.monitors.types import CheckinItem +from sentry.monitors.types import CheckinItem, CrontabSchedule from sentry.testutils.asserts import assert_org_audit_log_exists from sentry.testutils.cases import TestCase from sentry.testutils.helpers.datetime import freeze_time @@ -52,7 +52,7 @@ def setUp(self) -> None: super().setUp() self.partition = Partition(Topic("test"), 0) - def _create_monitor(self, **kwargs): + def _create_monitor(self, **kwargs: object) -> Monitor: return Monitor.objects.create( organization_id=self.organization.id, project_id=self.project.id, @@ -65,7 +65,9 @@ def _create_monitor(self, **kwargs): **kwargs, ) - def create_consumer(self, factory_opts: Mapping | None = None): + def create_consumer( + self, factory_opts: Mapping[str, Any] | None = None + ) -> ProcessingStrategy[KafkaPayload]: if factory_opts is None: factory_opts = {} @@ -79,7 +81,7 @@ def send_checkin( guid: str | None = None, ts: datetime | None = None, item_ts: datetime | None = None, - consumer: ProcessingStrategy | None = None, + consumer: ProcessingStrategy[KafkaPayload] | None = None, expected_error: ProcessingErrorsException | ExpectNoProcessingError | None = None, expected_monitor_slug: str | None = None, **overrides: Any, @@ -130,7 +132,7 @@ def check_processing_errors( expected_checkin: CheckIn, expected_error: ProcessingErrorsException | ExpectNoProcessingError | None, expected_monitor_slug: str | None, - ) -> Generator: + ) -> Generator[None]: if expected_error is None: yield None return @@ -165,7 +167,7 @@ def check_processing_errors( def send_clock_pulse( self, ts: datetime | None = None, - consumer: ProcessingStrategy | None = None, + consumer: ProcessingStrategy[KafkaPayload] | None = None, partition_ids: list[int] | None = None, ) -> None: if ts is None: @@ -214,6 +216,7 @@ def test_payload(self) -> None: self.send_checkin(monitor.slug) checkin = MonitorCheckIn.objects.get(guid=self.guid) assert checkin.expected_time == monitor_environment.next_checkin + assert checkin.trace_id is not None assert checkin.trace_id.hex == self.trace_id @mock.patch("sentry.monitors.consumers.monitor_consumer.process_checkin_group") @@ -1033,7 +1036,7 @@ def test_invalid_duration(self) -> None: date_added=monitor.date_added - timedelta(weeks=52), ) - self.send_checkin("my-monitor", guid=old_checkin.guid) + self.send_checkin("my-monitor", guid=str(old_checkin.guid)) checkin = MonitorCheckIn.objects.get(guid=old_checkin.guid) assert checkin.status == CheckInStatus.IN_PROGRESS @@ -1062,7 +1065,8 @@ def test_upsert_existing_monitor(self) -> None: monitor = self._create_monitor(slug="my-monitor") self.send_checkin(monitor.slug) - assert not monitor.is_upserting + is_upserting = monitor.is_upserting + assert not is_upserting # Update schedule via upsert self.send_checkin( @@ -1071,16 +1075,21 @@ def test_upsert_existing_monitor(self) -> None: ) monitor.refresh_from_db() - assert monitor.is_upserting - assert monitor.schedule.type == "crontab" - assert monitor.schedule.crontab == "13 * * * *" + is_upserting = monitor.is_upserting + assert is_upserting + schedule = monitor.schedule + assert isinstance(schedule, CrontabSchedule) + assert schedule.crontab == "13 * * * *" # A check-in without config keeps is_upserting self.send_checkin(monitor.slug) monitor.refresh_from_db() - assert monitor.is_upserting - assert monitor.schedule.crontab == "13 * * * *" + is_upserting = monitor.is_upserting + assert is_upserting + schedule = monitor.schedule + assert isinstance(schedule, CrontabSchedule) + assert schedule.crontab == "13 * * * *" def test_upsert_with_in_progress_completion(self) -> None: """ @@ -1099,23 +1108,32 @@ def test_upsert_with_in_progress_completion(self) -> None: ) monitor.refresh_from_db() - assert monitor.is_upserting - assert monitor.schedule.crontab == "13 * * * *" + is_upserting = monitor.is_upserting + assert is_upserting + schedule = monitor.schedule + assert isinstance(schedule, CrontabSchedule) + assert schedule.crontab == "13 * * * *" # Send completing check-in WITHOUT config (updates existing check-in) # is_upserting should STAY True since we're updating an existing check-in self.send_checkin("my-monitor", guid=guid, status="ok") monitor.refresh_from_db() - assert monitor.is_upserting - assert monitor.schedule.crontab == "13 * * * *" + is_upserting = monitor.is_upserting + assert is_upserting + schedule = monitor.schedule + assert isinstance(schedule, CrontabSchedule) + assert schedule.crontab == "13 * * * *" # A new check-in without config also keeps is_upserting self.send_checkin("my-monitor") monitor.refresh_from_db() - assert monitor.is_upserting - assert monitor.schedule.crontab == "13 * * * *" + is_upserting = monitor.is_upserting + assert is_upserting + schedule = monitor.schedule + assert isinstance(schedule, CrontabSchedule) + assert schedule.crontab == "13 * * * *" def test_monitor_upsert_empty_timezone(self) -> None: self.send_checkin( @@ -1590,7 +1608,7 @@ def test_monitor_quotas_timeout_accepts( A hung quotas seat check must fail open so the consumer does not stall. """ - def hang(*args, **kwargs): + def hang(*args: object, **kwargs: object) -> PermitCheckInStatus: time.sleep(1.0) return PermitCheckInStatus.DROP @@ -1670,9 +1688,9 @@ def test_monitor_quotas_timeout_rollout_disabled( @mock.patch("sentry.quotas.backend.check_accept_monitor_checkin") def test_monitor_accept_upsert_with_seat( self, - check_accept_monitor_checkin, - assign_seat, - ): + check_accept_monitor_checkin: mock.MagicMock, + assign_seat: mock.MagicMock, + ) -> None: """ Validates that a monitor can be upserted and processes a full check-in when the PermitCheckInStatus is ACCEPTED_FOR_UPSERT and a seat is @@ -1707,9 +1725,9 @@ def test_monitor_accept_upsert_with_seat( @mock.patch("sentry.quotas.backend.check_accept_monitor_checkin") def test_monitor_accept_upsert_no_seat( self, - check_accept_monitor_checkin, - assign_seat, - ): + check_accept_monitor_checkin: mock.MagicMock, + assign_seat: mock.MagicMock, + ) -> None: """ Validates that a monitor can be upserted but have the check-in dropped when the PermitCheckInStatus is ACCEPTED_FOR_UPSERT and a seat is @@ -1746,9 +1764,9 @@ def test_monitor_accept_upsert_no_seat( @mock.patch("sentry.quotas.backend.check_accept_monitor_checkin") def test_monitor_accept_upsert_existing_monitor( self, - check_accept_monitor_checkin, - assign_seat, - ): + check_accept_monitor_checkin: mock.MagicMock, + assign_seat: mock.MagicMock, + ) -> None: """ Validate the unusual casse where a seat does not already exist but a monitor does exist. We should ensure assign_seat is called diff --git a/tests/sentry/monitors/endpoints/test_base.py b/tests/sentry/monitors/endpoints/test_base.py index 56df35d4c017..d80be46f08ae 100644 --- a/tests/sentry/monitors/endpoints/test_base.py +++ b/tests/sentry/monitors/endpoints/test_base.py @@ -1,10 +1,12 @@ +from rest_framework.response import Response + from sentry.testutils.cases import APITestCase class BaseProjectMonitorTest(APITestCase): __test__ = False - def get_response(self, *args, **params): + def get_response(self, *args: object, **params: object) -> Response: list_args = list(args) list_args.insert(1, self.project.slug) return super().get_response(*list_args, **params) diff --git a/tests/sentry/monitors/endpoints/test_base_monitor_checkin_index.py b/tests/sentry/monitors/endpoints/test_base_monitor_checkin_index.py index 71f99cd1e84d..5d9d428b94c7 100644 --- a/tests/sentry/monitors/endpoints/test_base_monitor_checkin_index.py +++ b/tests/sentry/monitors/endpoints/test_base_monitor_checkin_index.py @@ -1,11 +1,13 @@ import uuid -from datetime import timedelta +from datetime import datetime, timedelta +from typing import Any from django.utils import timezone from sentry.models.environment import Environment from sentry.models.group import Group from sentry.monitors.models import CheckInStatus, MonitorCheckIn, MonitorStatus, ScheduleType +from sentry.services.eventstore.models import Event from sentry.testutils.cases import MonitorTestCase from sentry.testutils.helpers.datetime import freeze_time from sentry.testutils.skips import requires_snuba @@ -22,7 +24,9 @@ def setUp(self) -> None: super().setUp() self.login_as(user=self.user) - def create_error(self, platform, trace_id, project_id, timestamp): + def create_error( + self, platform: str, trace_id: str, project_id: int, timestamp: datetime + ) -> Event: data = load_data(platform, timestamp=timestamp) if "contexts" not in data: data["contexts"] = {} @@ -199,6 +203,7 @@ def test_trace_ids(self) -> None: project_id=self.project.id, timestamp=monitor.date_added, ) + assert error.group_id is not None group = Group.objects.get(id=error.group_id) checkin1 = MonitorCheckIn.objects.create( @@ -228,7 +233,7 @@ def test_trace_ids(self) -> None: def test_serializes_monitor_config_correctly(self) -> None: monitor = self.create_monitor(project=self.project) - config = { + config: dict[str, Any] = { "schedule": "0 0 * * *", "schedule_type": ScheduleType.CRONTAB, "timezone": "US/Arizona", diff --git a/tests/sentry/monitors/endpoints/test_base_monitor_details.py b/tests/sentry/monitors/endpoints/test_base_monitor_details.py index 43d3c93bc601..a5e46ec1689b 100644 --- a/tests/sentry/monitors/endpoints/test_base_monitor_details.py +++ b/tests/sentry/monitors/endpoints/test_base_monitor_details.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +from typing import Any from unittest.mock import MagicMock, call, patch from uuid import UUID @@ -148,7 +149,7 @@ def test_expand_issue_alert_rule_metric( ) -> None: monitor = self._create_monitor() - def expand_alert_rule_calls() -> list: + def expand_alert_rule_calls() -> list[Any]: return [ c for c in mock_incr.call_args_list @@ -444,10 +445,9 @@ def test_checkin_margin(self) -> None: mark_ok(check_in, check_in.date_added) monitor_environment.refresh_from_db() - assert ( - monitor_environment.next_checkin + timedelta(minutes=1) - == monitor_environment.next_checkin_latest - ) + next_checkin = monitor_environment.next_checkin + assert next_checkin is not None + assert next_checkin + timedelta(minutes=1) == monitor_environment.next_checkin_latest resp = self.get_error_response( self.organization.slug, @@ -470,10 +470,9 @@ def test_checkin_margin(self) -> None: # check that next_checkin_latest was updated appropriately monitor_environment.refresh_from_db() - assert ( - monitor_environment.next_checkin + timedelta(minutes=30) - == monitor_environment.next_checkin_latest - ) + next_checkin = monitor_environment.next_checkin + assert next_checkin is not None + assert next_checkin + timedelta(minutes=30) == monitor_environment.next_checkin_latest # check that unsetting the parameter works resp = self.get_success_response( @@ -488,10 +487,9 @@ def test_checkin_margin(self) -> None: assert monitor.config["checkin_margin"] is None monitor_environment.refresh_from_db() - assert ( - monitor_environment.next_checkin + timedelta(minutes=1) - == monitor_environment.next_checkin_latest - ) + next_checkin = monitor_environment.next_checkin + assert next_checkin is not None + assert next_checkin + timedelta(minutes=1) == monitor_environment.next_checkin_latest def test_max_runtime(self) -> None: monitor = self._create_monitor() @@ -808,11 +806,14 @@ def test_no_disable_if_already_disabled(self, disable_seat: MagicMock) -> None: @patch("sentry.quotas.backend.check_assign_seat") @patch("sentry.quotas.backend.assign_seat") def test_update_slug_sends_right_slug_to_assign( - self, assign_seat, check_seat, update_monitor_slug - ): + self, + assign_seat: MagicMock, + check_seat: MagicMock, + update_monitor_slug: MagicMock, + ) -> None: check_seat.return_value = SeatAssignmentResult(assignable=True) - def dummy_assign(data_category=None, seat_object=None): + def dummy_assign(data_category: Any = None, seat_object: Any = None) -> Outcome: assert seat_object.slug == old_slug return Outcome.ACCEPTED diff --git a/tests/sentry/monitors/endpoints/test_base_monitor_stats.py b/tests/sentry/monitors/endpoints/test_base_monitor_stats.py index f771740a8bb5..24e7ccb34c5d 100644 --- a/tests/sentry/monitors/endpoints/test_base_monitor_stats.py +++ b/tests/sentry/monitors/endpoints/test_base_monitor_stats.py @@ -1,6 +1,7 @@ +from collections.abc import Mapping from datetime import datetime, timedelta -from sentry.monitors.models import CheckInStatus, MonitorCheckIn +from sentry.monitors.models import CheckInStatus, MonitorCheckIn, MonitorEnvironment from sentry.testutils.cases import MonitorTestCase from sentry.testutils.helpers.datetime import freeze_time @@ -11,7 +12,13 @@ class BaseMonitorStatsTest(MonitorTestCase): __test__ = False - def add_checkin(self, offset, duration=None, env=None, status=None): + def add_checkin( + self, + offset: Mapping[str, int], + duration: int | None = None, + env: MonitorEnvironment | None = None, + status: int | None = None, + ) -> None: if status is None: status = CheckInStatus.OK if env is None: diff --git a/tests/sentry/monitors/endpoints/test_organization_monitor_index.py b/tests/sentry/monitors/endpoints/test_organization_monitor_index.py index b948490ff8af..b3c64677b19f 100644 --- a/tests/sentry/monitors/endpoints/test_organization_monitor_index.py +++ b/tests/sentry/monitors/endpoints/test_organization_monitor_index.py @@ -1,12 +1,15 @@ from __future__ import annotations import uuid +from collections.abc import Mapping, Sequence from datetime import UTC, datetime, timedelta +from typing import Any from unittest.mock import MagicMock, patch from django.conf import settings from django.test.utils import override_settings from rest_framework.exceptions import ErrorDetail +from rest_framework.response import Response from sentry import audit_log from sentry.analytics.events.cron_monitor_created import CronMonitorCreated, FirstCronMonitorCreated @@ -14,6 +17,7 @@ from sentry.models.projectteam import ProjectTeam from sentry.monitors.models import ( Monitor, + MonitorEnvironment, MonitorStatus, ScheduleType, get_detector_for_monitor, @@ -37,12 +41,19 @@ def setUp(self) -> None: super().setUp() self.login_as(self.user) - def check_valid_response(self, response, expected_monitors): + def check_valid_response( + self, response: Response, expected_monitors: Sequence[Monitor] + ) -> None: assert [monitor.slug for monitor in expected_monitors] == [ monitor_resp["slug"] for monitor_resp in response.data ] - def check_valid_environments_response(self, response, monitor, expected_environments): + def check_valid_environments_response( + self, + response: Response, + monitor: Mapping[str, Any], + expected_environments: Sequence[MonitorEnvironment], + ) -> None: assert [ monitor_environment.get_environment().name for monitor_environment in expected_environments @@ -64,7 +75,7 @@ def add_status_monitor( env_status_key: str, mon_status_key: str = "ACTIVE", date: datetime | None = None, - ): + ) -> Monitor: env_status = getattr(MonitorStatus, env_status_key) mon_status = getattr(ObjectStatus, mon_status_key) @@ -1032,8 +1043,10 @@ def test_bulk_mute_unmute(self) -> None: env_two.refresh_from_db() assert is_monitor_muted(monitor_one) assert is_monitor_muted(monitor_two) - assert env_one.is_muted - assert env_two.is_muted + muted_one = env_one.is_muted + muted_two = env_two.is_muted + assert muted_one + assert muted_two assert_org_audit_log_exists( organization=self.organization, event=audit_log.get_event_id("MONITOR_EDIT"), diff --git a/tests/sentry/monitors/endpoints/test_organization_monitor_index_stats.py b/tests/sentry/monitors/endpoints/test_organization_monitor_index_stats.py index f58321c193a9..c472b0e8c84a 100644 --- a/tests/sentry/monitors/endpoints/test_organization_monitor_index_stats.py +++ b/tests/sentry/monitors/endpoints/test_organization_monitor_index_stats.py @@ -1,9 +1,15 @@ import uuid +from collections.abc import Mapping from datetime import datetime, timedelta, timezone from sentry.monitors.endpoints.base_monitor_stats import MAX_STATS_BUCKETS from sentry.monitors.endpoints.organization_monitor_index_stats import MAX_STATS_MONITORS -from sentry.monitors.models import CheckInStatus, MonitorCheckIn +from sentry.monitors.models import ( + CheckInStatus, + Monitor, + MonitorCheckIn, + MonitorEnvironment, +) from sentry.testutils.cases import MonitorTestCase from sentry.testutils.helpers.datetime import freeze_time @@ -12,7 +18,13 @@ class OrganizationMonitorIndexStatsTest(MonitorTestCase): endpoint = "sentry-api-0-organization-monitor-index-stats" - def add_checkin(self, monitor, offset, env=None, status=None): + def add_checkin( + self, + monitor: Monitor, + offset: Mapping[str, int], + env: MonitorEnvironment | None = None, + status: int | None = None, + ) -> None: if status is None: status = CheckInStatus.OK if env is None: diff --git a/tests/sentry/monitors/logic/test_incident_occurrence.py b/tests/sentry/monitors/logic/test_incident_occurrence.py index e6d841b9b615..b5908ad224a7 100644 --- a/tests/sentry/monitors/logic/test_incident_occurrence.py +++ b/tests/sentry/monitors/logic/test_incident_occurrence.py @@ -31,7 +31,7 @@ class IncidentOccurrenceTestCase(TestCase): - def build_occurrence_test_data(self): + def build_occurrence_test_data(self) -> None: self.monitor = Monitor.objects.create( name="test monitor", organization_id=self.organization.id, @@ -363,9 +363,9 @@ def test_send_incident_occurrence_invalid_owner( @mock.patch("sentry.monitors.logic.incident_occurrence.queue_incident_occurrence") def test_dispatch_incident_occurrence( self, - mock_queue_incident_occurrence, - mock_send_incident_occurrence, - ): + mock_queue_incident_occurrence: mock.MagicMock, + mock_send_incident_occurrence: mock.MagicMock, + ) -> None: monitor = self.create_monitor() monitor_environment = MonitorEnvironment.objects.create( monitor=monitor, diff --git a/tests/sentry/monitors/logic/test_mark_failed.py b/tests/sentry/monitors/logic/test_mark_failed.py index efb5b7c0fbbc..b7418533f732 100644 --- a/tests/sentry/monitors/logic/test_mark_failed.py +++ b/tests/sentry/monitors/logic/test_mark_failed.py @@ -422,8 +422,10 @@ def test_mark_failed_issue_assignment(self) -> None: @mock.patch("sentry.monitors.logic.incidents.dispatch_incident_occurrence") @mock.patch("sentry.monitors.logic.incident_occurrence.resolve_incident_group") def test_mark_failed_fingerprint_after_resolution( - self, mock_resolve_incident_group, mock_dispatch_incident_occurrence - ): + self, + mock_resolve_incident_group: mock.MagicMock, + mock_dispatch_incident_occurrence: mock.MagicMock, + ) -> None: """Test that resolving and recreating an incident maintains the same fingerprint""" monitor = Monitor.objects.create( name="test monitor", diff --git a/tests/sentry/monitors/processing_errors/test_manager.py b/tests/sentry/monitors/processing_errors/test_manager.py index 9f3ed5939746..7cd6c1635bbd 100644 --- a/tests/sentry/monitors/processing_errors/test_manager.py +++ b/tests/sentry/monitors/processing_errors/test_manager.py @@ -25,7 +25,7 @@ def assert_processing_errors_equal( error_1: CheckinProcessingError, error_2: CheckinProcessingError, -): +) -> None: assert error_1.errors == error_2.errors assert error_2.checkin == error_2.checkin @@ -234,7 +234,7 @@ def test_delete_for_project_by_type(self) -> None: class HandleProcessingErrorsTest(TestCase): @mock.patch("sentry.monitors.processing_errors.manager.ANALYTICS_SAMPLING_RATE", 1.0) @mock.patch("sentry.analytics.record") - def test(self, mock_record) -> None: + def test(self, mock_record: mock.Mock) -> None: monitor = self.create_monitor() exception = ProcessingErrorsException( [{"type": ProcessingErrorType.CHECKIN_INVALID_GUID}], @@ -275,7 +275,7 @@ def test_killswitched_errors_are_never_stored(self) -> None: assert get_errors_for_monitor(monitor) == [] @mock.patch("sentry.monitors.processing_errors.manager.random.random", return_value=0.5) - def test_rate_limited_errors_are_sampled_out(self, mock_random) -> None: + def test_rate_limited_errors_are_sampled_out(self, mock_random: mock.Mock) -> None: monitor = self.create_monitor() handle_processing_errors( build_checkin_item( @@ -290,7 +290,7 @@ def test_rate_limited_errors_are_sampled_out(self, mock_random) -> None: assert get_errors_for_monitor(monitor) == [] @mock.patch("sentry.monitors.processing_errors.manager.random.random", return_value=0.0) - def test_rate_limited_errors_are_sampled_in(self, mock_random) -> None: + def test_rate_limited_errors_are_sampled_in(self, mock_random: mock.Mock) -> None: monitor = self.create_monitor() handle_processing_errors( build_checkin_item( @@ -305,7 +305,7 @@ def test_rate_limited_errors_are_sampled_in(self, mock_random) -> None: assert len(get_errors_for_monitor(monitor)) == 1 @mock.patch("sentry.monitors.processing_errors.manager.random.random", return_value=0.5) - def test_throttled_type_alongside_a_real_error_is_stored(self, mock_random) -> None: + def test_throttled_type_alongside_a_real_error_is_stored(self, mock_random: mock.Mock) -> None: monitor = self.create_monitor() handle_processing_errors( build_checkin_item( diff --git a/tests/sentry/monitors/tasks/test_detect_broken_monitor_envs.py b/tests/sentry/monitors/tasks/test_detect_broken_monitor_envs.py index ee3f6c00ca12..269ffc28958b 100644 --- a/tests/sentry/monitors/tasks/test_detect_broken_monitor_envs.py +++ b/tests/sentry/monitors/tasks/test_detect_broken_monitor_envs.py @@ -45,7 +45,7 @@ def tearDown(self) -> None: super().tearDown() self._run_tasks.__exit__(None, None, None) - def create_monitor_env(self, monitor, environment_id): + def create_monitor_env(self, monitor: Monitor, environment_id: int) -> MonitorEnvironment: return MonitorEnvironment.objects.create( monitor=monitor, environment_id=environment_id, @@ -59,8 +59,12 @@ def generate_cron_monitor_url(self, org_slug: str, project_slug: str, monitor_sl ) def create_monitor_and_env( - self, name="test monitor", organization_id=None, project_id=None, environment_id=None - ): + self, + name: str = "test monitor", + organization_id: int | None = None, + project_id: int | None = None, + environment_id: int | None = None, + ) -> tuple[Monitor, MonitorEnvironment]: if organization_id is None: organization_id = self.organization.id if project_id is None: @@ -93,7 +97,9 @@ def create_monitor_and_env( self.detector_ids[monitor.slug] = detector.id return (monitor, self.create_monitor_env(monitor=monitor, environment_id=environment_id)) - def create_incident_for_monitor_env(self, monitor, monitor_environment): + def create_incident_for_monitor_env( + self, monitor: Monitor, monitor_environment: MonitorEnvironment + ) -> MonitorIncident: first_checkin = MonitorCheckIn.objects.create( monitor_environment=monitor_environment, status=CheckInStatus.ERROR, diff --git a/tests/sentry/monitors/test_clock_dispatch.py b/tests/sentry/monitors/test_clock_dispatch.py index 5879124b51de..7f64cd95dc58 100644 --- a/tests/sentry/monitors/test_clock_dispatch.py +++ b/tests/sentry/monitors/test_clock_dispatch.py @@ -1,5 +1,6 @@ from collections.abc import Generator from datetime import datetime, timedelta +from typing import Any from unittest import mock import pytest @@ -308,7 +309,7 @@ def test_monitor_task_trigger_no_pulse_seen( assert gauge_values(metrics_mock, "monitors.task.clock_stall_gap") == [] -def get_redis_client(): +def get_redis_client() -> Any: return redis.redis_clusters.get(settings.SENTRY_MONITORS_REDIS_CLUSTER) diff --git a/tests/sentry/monitors/test_serializers.py b/tests/sentry/monitors/test_serializers.py index c0dfffb452f5..7487d177a499 100644 --- a/tests/sentry/monitors/test_serializers.py +++ b/tests/sentry/monitors/test_serializers.py @@ -165,4 +165,4 @@ def test_serialize_checkin_config_matches_legacy(self) -> None: expected = {**config, "schedule_type": "crontab"} assert result[0]["monitorConfig"] == expected assert result[1]["monitorConfig"] == expected - assert result[2]["monitorConfig"] == {} + assert not result[2]["monitorConfig"] diff --git a/tests/sentry/monitors/test_system_incidents.py b/tests/sentry/monitors/test_system_incidents.py index 13749bc42e7e..6b101731cc42 100644 --- a/tests/sentry/monitors/test_system_incidents.py +++ b/tests/sentry/monitors/test_system_incidents.py @@ -2,6 +2,7 @@ from collections.abc import Sequence from datetime import datetime, timedelta from unittest import mock +from unittest.mock import MagicMock from django.conf import settings from django.utils import timezone @@ -36,7 +37,7 @@ def fill_historic_volume( start: datetime, length: timedelta, step: timedelta, counts: Sequence[int] -): +) -> None: """ Creates a volume history starting at the `start` and going back `length`, where each bucket is spaced by `step`s apart. @@ -64,7 +65,7 @@ def fill_historic_volume( update_check_in_volume(ts_list) -def fill_historic_metrics(start: datetime, metrics: Sequence[float | None]): +def fill_historic_metrics(start: datetime, metrics: Sequence[float | None]) -> None: """ Creates historic metrics starting from the `start` datetime backfilling the `metrics`, popping from the end of the list until all metrics have been @@ -116,12 +117,12 @@ def make_key(offset: timedelta) -> str: @mock.patch("sentry.monitors.system_incidents.make_clock_tick_decision") @mock.patch("sentry.monitors.system_incidents.prune_incident_check_in_volume") def test_process_clock_tick_for_system_incident( - mock_prune_incident_check_in_volume, - mock_make_clock_tick_decision, - mock_record_clock_tick_volume_metric, - mock_metrics, - mock_logger, -): + mock_prune_incident_check_in_volume: MagicMock, + mock_make_clock_tick_decision: MagicMock, + mock_record_clock_tick_volume_metric: MagicMock, + mock_metrics: MagicMock, + mock_logger: MagicMock, +) -> None: ts = timezone.now().replace(second=0, microsecond=0) mock_make_clock_tick_decision.return_value = DecisionResult( diff --git a/tests/sentry/monitors/test_validators.py b/tests/sentry/monitors/test_validators.py index 5dc5af07676a..bd94ebda44dc 100644 --- a/tests/sentry/monitors/test_validators.py +++ b/tests/sentry/monitors/test_validators.py @@ -1,4 +1,5 @@ from datetime import timedelta +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -57,7 +58,7 @@ def setUp(self) -> None: } @patch("sentry.analytics.record") - def test_simple(self, mock_record): + def test_simple(self, mock_record: MagicMock) -> None: data = { "project": self.project.slug, "name": "My Monitor", @@ -209,7 +210,7 @@ def test_checkin_margin_zero(self) -> None: assert monitor.config["checkin_margin"] == 1 def test_max_runtime_limit(self) -> None: - data = { + data: dict[str, Any] = { "project": self.project.slug, "name": "My Monitor", "slug": "cron_job", @@ -227,7 +228,7 @@ def test_max_runtime_limit(self) -> None: ] @patch("sentry.quotas.backend.assign_seat") - def test_create_monitor_assigns_seat(self, assign_seat): + def test_create_monitor_assigns_seat(self, assign_seat: MagicMock) -> None: assign_seat.return_value = Outcome.ACCEPTED data = { @@ -245,7 +246,7 @@ def test_create_monitor_assigns_seat(self, assign_seat): assert monitor.status == ObjectStatus.ACTIVE @patch("sentry.quotas.backend.assign_seat") - def test_create_monitor_without_seat(self, assign_seat): + def test_create_monitor_without_seat(self, assign_seat: MagicMock) -> None: assign_seat.return_value = Outcome.RATE_LIMITED data = { @@ -653,7 +654,7 @@ def test_update_status_to_disabled(self) -> None: assert not Detector.objects.get(id=detector.id).enabled @patch("sentry.quotas.backend.check_assign_seat") - def test_update_status_to_active_with_quota_check(self, mock_check_seat): + def test_update_status_to_active_with_quota_check(self, mock_check_seat: MagicMock) -> None: """Test updating monitor status to active checks quota.""" # Start with disabled monitor self.monitor.update(status=ObjectStatus.DISABLED) @@ -684,7 +685,7 @@ def test_update_status_to_active_with_quota_check(self, mock_check_seat): assert Detector.objects.get(id=detector.id).enabled @patch("sentry.quotas.backend.check_assign_seat") - def test_update_status_to_active_quota_exceeded(self, mock_check_seat): + def test_update_status_to_active_quota_exceeded(self, mock_check_seat: MagicMock) -> None: """Test updating monitor status to active fails when quota exceeded.""" # Start with disabled monitor self.monitor.update(status=ObjectStatus.DISABLED) @@ -963,7 +964,7 @@ def setUp(self) -> None: "access": access, } - def _get_base_config(self, schedule_type="crontab", **overrides): + def _get_base_config(self, schedule_type: str = "crontab", **overrides: Any) -> dict[str, Any]: """Get base monitor config with optional overrides.""" config = { "schedule": "0 * * * *", @@ -983,7 +984,7 @@ def setUp(self) -> None: super().setUp() self.valid_data = self._get_valid_data() - def _get_valid_data(self, **overrides): + def _get_valid_data(self, **overrides: Any) -> dict[str, Any]: data = { "name": "Test Monitor", "slug": "test-monitor", @@ -992,7 +993,12 @@ def _get_valid_data(self, **overrides): data.update(overrides) return data - def _create_validator(self, data=None, instance=None, partial=False): + def _create_validator( + self, + data: dict[str, Any] | None = None, + instance: Monitor | None = None, + partial: bool = False, + ) -> MonitorDataSourceValidator: return MonitorDataSourceValidator( data=data or self.valid_data, instance=instance, @@ -1001,8 +1007,13 @@ def _create_validator(self, data=None, instance=None, partial=False): ) def _assert_valid_monitor_data( - self, validator, expected_name, expected_slug, expected_schedule, expected_type - ): + self, + validator: MonitorDataSourceValidator, + expected_name: str, + expected_slug: str, + expected_schedule: object, + expected_type: object, + ) -> None: """Helper to assert common monitor validation results.""" assert validator.is_valid(), validator.errors validated_data = validator.validated_data @@ -1076,7 +1087,14 @@ def test_nonstandard_crontab_schedules(self) -> None: assert validator.is_valid(), validator.errors assert validator.validated_data["config"]["schedule"] == "0 * * * *" - def _assert_monitor_attributes(self, monitor, name, slug, schedule, status=ObjectStatus.ACTIVE): + def _assert_monitor_attributes( + self, + monitor: Monitor, + name: str, + slug: str, + schedule: object, + status: int = ObjectStatus.ACTIVE, + ) -> None: """Helper to assert monitor attributes after creation.""" assert isinstance(monitor, Monitor) assert monitor.name == name @@ -1129,7 +1147,7 @@ def test_slug_uniqueness_validation(self) -> None: assert "slug" in validator.errors assert 'The slug "test-monitor" is already in use.' in str(validator.errors["slug"]) - def _create_monitor_without_slug(self, name): + def _create_monitor_without_slug(self, name: str) -> Monitor: validator = self._create_validator({"name": name, "config": self._get_base_config()}) assert validator.is_valid(), validator.errors return validator.create_source(validator.validated_data) @@ -1202,7 +1220,7 @@ def setUp(self) -> None: super().setUp() self.valid_data = self._get_valid_detector_data() - def _get_valid_detector_data(self, **overrides): + def _get_valid_detector_data(self, **overrides: Any) -> dict[str, Any]: data = { "type": "monitor_check_in_failure", "name": "Test Monitor Detector", @@ -1217,7 +1235,12 @@ def _get_valid_detector_data(self, **overrides): data.update(overrides) return data - def _create_validator(self, data=None, instance=None, partial=False): + def _create_validator( + self, + data: dict[str, Any] | None = None, + instance: Detector | None = None, + partial: bool = False, + ) -> MonitorIncidentDetectorValidator: return MonitorIncidentDetectorValidator( data=data or self.valid_data, instance=instance, @@ -1280,7 +1303,7 @@ def test_create_detector_validates_data_source(self) -> None: assert validator.validated_data["data_sources"][0]["data_source_type"] == "cron_monitor" @patch("sentry.quotas.backend.assign_seat", return_value=Outcome.ACCEPTED) - def test_create_enabled_assigns_seat(self, mock_assign_seat): + def test_create_enabled_assigns_seat(self, mock_assign_seat: MagicMock) -> None: """Test that creating an enabled detector assigns a billing seat.""" condition_group = DataConditionGroup.objects.create( @@ -1303,7 +1326,7 @@ def test_create_enabled_assigns_seat(self, mock_assign_seat): mock_assign_seat.assert_called_once_with(seat_object=monitor) @patch("sentry.quotas.backend.assign_seat", return_value=Outcome.RATE_LIMITED) - def test_create_enabled_no_seat_available(self, mock_assign_seat): + def test_create_enabled_no_seat_available(self, mock_assign_seat: MagicMock) -> None: """ Test that creating a detector with no seats available creates it but leaves it disabled. @@ -1330,7 +1353,7 @@ def test_create_enabled_no_seat_available(self, mock_assign_seat): mock_assign_seat.assert_called_once_with(seat_object=monitor) @patch("sentry.quotas.backend.assign_seat", return_value=Outcome.ACCEPTED) - def test_create_with_monitor_status_disabled(self, mock_assign_seat): + def test_create_with_monitor_status_disabled(self, mock_assign_seat: MagicMock) -> None: condition_group = DataConditionGroup.objects.create( organization_id=self.organization.id, logic_type=DataConditionGroup.Type.ANY, @@ -1348,7 +1371,7 @@ def test_create_with_monitor_status_disabled(self, mock_assign_seat): assert detector.enabled is False @patch("sentry.quotas.backend.assign_seat", return_value=Outcome.ACCEPTED) - def test_update_enable_assigns_seat(self, mock_assign_seat): + def test_update_enable_assigns_seat(self, mock_assign_seat: MagicMock) -> None: """ Test that enabling a previously disabled detector assigns a seat. """ @@ -1389,7 +1412,7 @@ def test_update_enable_assigns_seat(self, mock_assign_seat): "sentry.quotas.backend.check_assign_seat", return_value=SeatAssignmentResult(assignable=False, reason="No seats available"), ) - def test_update_enable_no_seat_available(self, mock_check_seat): + def test_update_enable_no_seat_available(self, mock_check_seat: MagicMock) -> None: """ Test that enabling fails with validation error when no seats are available. @@ -1432,7 +1455,7 @@ def test_update_enable_no_seat_available(self, mock_check_seat): mock_check_seat.assert_called_with(seat_object=monitor) @patch("sentry.quotas.backend.disable_seat") - def test_update_disable_disables_seat(self, mock_disable_seat): + def test_update_disable_disables_seat(self, mock_disable_seat: MagicMock) -> None: """Test that disabling a previously enabled detector disables the seat.""" # Create an enabled detector detector = self.create_detector( diff --git a/tests/sentry/new_migrations/test_migrations.py b/tests/sentry/new_migrations/test_migrations.py index 135151ddd413..ae760614e3a3 100644 --- a/tests/sentry/new_migrations/test_migrations.py +++ b/tests/sentry/new_migrations/test_migrations.py @@ -1,3 +1,5 @@ +from typing import cast + import pytest from django.core.exceptions import FieldDoesNotExist from django.db import connection, models @@ -31,7 +33,8 @@ def _state_with_model() -> SentryProjectState: def _migration(*operations: Operation) -> Migration: migration_class = type("Migration", (CheckedMigration,), {"operations": list(operations)}) - return migration_class("0002_move_to_pending", "fakeapp") + # justification: the synthesized class is a Migration with the given operations. + return cast("Migration", migration_class("0002_move_to_pending", "fakeapp")) @pytest.mark.parametrize("preserve", [True, False], ids=["preserve", "in_place"]) diff --git a/tests/sentry/newsletter/test_dummy.py b/tests/sentry/newsletter/test_dummy.py index 52d5b8239fcc..4ffe8efcc2a6 100644 --- a/tests/sentry/newsletter/test_dummy.py +++ b/tests/sentry/newsletter/test_dummy.py @@ -1,6 +1,7 @@ from sentry.newsletter.dummy import DummyNewsletter from sentry.testutils.cases import TestCase from sentry.testutils.silo import control_silo_test +from sentry.users.models.user import User @control_silo_test @@ -12,7 +13,7 @@ def test_defaults(self) -> None: assert self.newsletter.DEFAULT_LISTS == self.newsletter.get_default_list_ids() assert self.newsletter.DEFAULT_LIST_ID == self.newsletter.get_default_list_id() - def assert_subscriptions(self, user, count): + def assert_subscriptions(self, user: User, count: int) -> None: subscriptions = self.newsletter.get_subscriptions(user) assert subscriptions.get("subscriptions") is not None subscribed = [sub for sub in subscriptions["subscriptions"] if sub.subscribed] diff --git a/tests/sentry/notebooks/test_contracts.py b/tests/sentry/notebooks/test_contracts.py index 5bd88d7770cb..d2812278caf8 100644 --- a/tests/sentry/notebooks/test_contracts.py +++ b/tests/sentry/notebooks/test_contracts.py @@ -19,7 +19,10 @@ def golden_payload() -> dict[str, Any]: - return json.loads(FIXTURE.read_text()) + payload = json.loads(FIXTURE.read_text()) + if not isinstance(payload, dict): + raise TypeError("Golden payload is not a JSON object") + return payload def test_query_result_accepts_the_versioned_wire_shape() -> None: diff --git a/tests/sentry/notifications/api/endpoints/test_notification_actions_available.py b/tests/sentry/notifications/api/endpoints/test_notification_actions_available.py index ffd70b0f2e8f..ba91c4949b89 100644 --- a/tests/sentry/notifications/api/endpoints/test_notification_actions_available.py +++ b/tests/sentry/notifications/api/endpoints/test_notification_actions_available.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, patch from rest_framework import status @@ -44,6 +45,9 @@ def test_get_dynamic_response(self, mock_action_trigger: MagicMock) -> None: class MockActionRegistration(ActionRegistration): serialize_available = MagicMock(return_value=[trigger_available_response]) + def fire(self, data: Any) -> None: + pass + response = self.get_success_response( self.organization.slug, status_code=status.HTTP_200_OK, diff --git a/tests/sentry/notifications/api/endpoints/test_notification_actions_details.py b/tests/sentry/notifications/api/endpoints/test_notification_actions_details.py index d2b85a538ce5..f9447b9819fa 100644 --- a/tests/sentry/notifications/api/endpoints/test_notification_actions_details.py +++ b/tests/sentry/notifications/api/endpoints/test_notification_actions_details.py @@ -1,6 +1,8 @@ +from __future__ import annotations + from collections.abc import Callable, MutableMapping from typing import Any, TypeVar -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, _patch, patch from rest_framework import serializers, status @@ -65,7 +67,9 @@ def setUp(self) -> None: self.login_as(user=self.user) - def mock_msg_schedule_response(self, channel_id, result_name="channel"): + def mock_msg_schedule_response( + self, channel_id: str, result_name: str = "channel" + ) -> _patch[MagicMock]: body = { "ok": True, result_name: channel_id, @@ -73,7 +77,9 @@ def mock_msg_schedule_response(self, channel_id, result_name="channel"): } return mock_slack_response("chat_scheduleMessage", body=body) - def mock_msg_delete_scheduled_response(self, channel_id, result_name="channel"): + def mock_msg_delete_scheduled_response( + self, channel_id: str, result_name: str = "channel" + ) -> _patch[MagicMock]: if channel_id == "channel_not_found": body = {"ok": False, "error": "channel_not_found"} else: @@ -96,8 +102,8 @@ def test_requires_project_access(self) -> None: This only tests 'GET' since members aren't granted project:write scopes so they 403 before reaching any endpoint logic (for PUT/DELETE) """ - self.organization.flags = 0 - self.organization.save() + self.organization.update(flags=0) + self.organization.refresh_from_db() action = self.create_notification_action( organization=self.organization, projects=[self.create_project(organization=self.organization)], diff --git a/tests/sentry/notifications/api/endpoints/test_notification_actions_index.py b/tests/sentry/notifications/api/endpoints/test_notification_actions_index.py index 204540b4ab2b..cc651bb17a36 100644 --- a/tests/sentry/notifications/api/endpoints/test_notification_actions_index.py +++ b/tests/sentry/notifications/api/endpoints/test_notification_actions_index.py @@ -326,7 +326,9 @@ def fire(self, data: Any) -> None: body={"ok": True, "channel": "CABC123", "scheduled_message_id": "Q1298393284"}, ) @mock_slack_response("chat_deleteScheduledMessage", body={"ok": True}) - def test_post_with_slack_validation(self, mock_delete, mock_schedule) -> None: + def test_post_with_slack_validation( + self, mock_delete: MagicMock, mock_schedule: MagicMock + ) -> None: class MockActionRegistration(ActionRegistration): def fire(self, data: Any) -> None: raise NotImplementedError diff --git a/tests/sentry/notifications/notification_action/metric_alert_registry/test_discord_metric_alert_handler.py b/tests/sentry/notifications/notification_action/metric_alert_registry/test_discord_metric_alert_handler.py index 1cea7ead0510..ac4fa8efb850 100644 --- a/tests/sentry/notifications/notification_action/metric_alert_registry/test_discord_metric_alert_handler.py +++ b/tests/sentry/notifications/notification_action/metric_alert_registry/test_discord_metric_alert_handler.py @@ -1,5 +1,6 @@ import uuid from dataclasses import asdict +from typing import cast from unittest import mock from sentry.incidents.models.alert_rule import AlertRuleDetectionType, AlertRuleThresholdType @@ -11,6 +12,7 @@ OpenPeriodContext, ) from sentry.models.activity import Activity +from sentry.models.organization import Organization from sentry.notifications.models.notificationaction import ActionTarget from sentry.notifications.notification_action.metric_alert_registry import DiscordMetricAlertHandler from sentry.notifications.notification_action.metric_alert_registry.handlers.utils import ( @@ -24,8 +26,16 @@ MetricAlertHandlerBase, ) +_UnpackedSendAlert = tuple[ + NotificationContext, AlertContext, MetricIssueContext, OpenPeriodContext, Organization, str +] + class TestDiscordMetricAlertHandler(MetricAlertHandlerBase): + def unpack_kwargs(self, mock_send_alert: mock.MagicMock) -> _UnpackedSendAlert: + # justification: unittest.mock exposes call kwargs as untyped values + return cast(_UnpackedSendAlert, super().unpack_kwargs(mock_send_alert)) + def setUp(self) -> None: self.create_models() self.action = self.create_action( diff --git a/tests/sentry/notifications/notification_action/metric_alert_registry/test_email_metric_alert_handler.py b/tests/sentry/notifications/notification_action/metric_alert_registry/test_email_metric_alert_handler.py index 6fd6c57832b4..7ac4439f61c1 100644 --- a/tests/sentry/notifications/notification_action/metric_alert_registry/test_email_metric_alert_handler.py +++ b/tests/sentry/notifications/notification_action/metric_alert_registry/test_email_metric_alert_handler.py @@ -1,7 +1,7 @@ import uuid from dataclasses import asdict from datetime import timedelta -from typing import Any +from typing import Any, cast from unittest import mock from urllib.parse import parse_qs, urlsplit @@ -22,6 +22,7 @@ ) from sentry.models.activity import Activity from sentry.models.groupopenperiod import create_open_period, get_latest_open_period +from sentry.models.organization import Organization from sentry.models.team import Team from sentry.notifications.models.notificationaction import ActionTarget from sentry.notifications.notification_action.metric_alert_registry import EmailMetricAlertHandler @@ -40,8 +41,16 @@ MetricAlertHandlerBase, ) +_UnpackedSendAlert = tuple[ + NotificationContext, AlertContext, MetricIssueContext, OpenPeriodContext, Organization, str +] + class TestEmailMetricAlertHandler(MetricAlertHandlerBase): + def unpack_kwargs(self, mock_send_alert: mock.MagicMock) -> _UnpackedSendAlert: + # justification: unittest.mock exposes call kwargs as untyped values + return cast(_UnpackedSendAlert, super().unpack_kwargs(mock_send_alert)) + def setUp(self) -> None: self.create_models() self.action = self.create_action( diff --git a/tests/sentry/notifications/notification_action/metric_alert_registry/test_msteams_metric_alert_handler.py b/tests/sentry/notifications/notification_action/metric_alert_registry/test_msteams_metric_alert_handler.py index 107647e0e815..c983a5070485 100644 --- a/tests/sentry/notifications/notification_action/metric_alert_registry/test_msteams_metric_alert_handler.py +++ b/tests/sentry/notifications/notification_action/metric_alert_registry/test_msteams_metric_alert_handler.py @@ -1,5 +1,6 @@ import uuid from dataclasses import asdict +from typing import cast from unittest import mock from sentry.incidents.models.alert_rule import AlertRuleDetectionType, AlertRuleThresholdType @@ -11,6 +12,7 @@ OpenPeriodContext, ) from sentry.models.activity import Activity +from sentry.models.organization import Organization from sentry.notifications.models.notificationaction import ActionTarget from sentry.notifications.notification_action.metric_alert_registry import MSTeamsMetricAlertHandler from sentry.testutils.helpers.datetime import freeze_time @@ -21,8 +23,16 @@ MetricAlertHandlerBase, ) +_UnpackedSendAlert = tuple[ + NotificationContext, AlertContext, MetricIssueContext, OpenPeriodContext, Organization, str +] + class TestMsteamsMetricAlertHandler(MetricAlertHandlerBase): + def unpack_kwargs(self, mock_send_alert: mock.MagicMock) -> _UnpackedSendAlert: + # justification: unittest.mock exposes call kwargs as untyped values + return cast(_UnpackedSendAlert, super().unpack_kwargs(mock_send_alert)) + def setUp(self) -> None: self.create_models() self.action = self.create_action( diff --git a/tests/sentry/notifications/notification_action/metric_alert_registry/test_opsgenie_metric_alert_handler.py b/tests/sentry/notifications/notification_action/metric_alert_registry/test_opsgenie_metric_alert_handler.py index aac2d0a2d9b0..990a34c11d61 100644 --- a/tests/sentry/notifications/notification_action/metric_alert_registry/test_opsgenie_metric_alert_handler.py +++ b/tests/sentry/notifications/notification_action/metric_alert_registry/test_opsgenie_metric_alert_handler.py @@ -1,5 +1,6 @@ import uuid from dataclasses import asdict +from typing import cast from unittest import mock from sentry.incidents.models.alert_rule import AlertRuleDetectionType, AlertRuleThresholdType @@ -11,6 +12,7 @@ OpenPeriodContext, ) from sentry.models.activity import Activity +from sentry.models.organization import Organization from sentry.notifications.models.notificationaction import ActionTarget from sentry.notifications.notification_action.metric_alert_registry import ( OpsgenieMetricAlertHandler, @@ -22,8 +24,16 @@ MetricAlertHandlerBase, ) +_UnpackedSendAlert = tuple[ + NotificationContext, AlertContext, MetricIssueContext, OpenPeriodContext, Organization, str +] + class TestOpsgenieMetricAlertHandler(MetricAlertHandlerBase): + def unpack_kwargs(self, mock_send_alert: mock.MagicMock) -> _UnpackedSendAlert: + # justification: unittest.mock exposes call kwargs as untyped values + return cast(_UnpackedSendAlert, super().unpack_kwargs(mock_send_alert)) + def setUp(self) -> None: self.create_models() self.action = self.create_action( diff --git a/tests/sentry/notifications/notification_action/metric_alert_registry/test_pagerduty_metric_alert_handler.py b/tests/sentry/notifications/notification_action/metric_alert_registry/test_pagerduty_metric_alert_handler.py index 7c0d48563772..a49e289e0e2e 100644 --- a/tests/sentry/notifications/notification_action/metric_alert_registry/test_pagerduty_metric_alert_handler.py +++ b/tests/sentry/notifications/notification_action/metric_alert_registry/test_pagerduty_metric_alert_handler.py @@ -1,5 +1,6 @@ import uuid from dataclasses import asdict +from typing import cast from unittest import mock from sentry.incidents.models.alert_rule import AlertRuleDetectionType, AlertRuleThresholdType @@ -11,6 +12,7 @@ OpenPeriodContext, ) from sentry.models.activity import Activity +from sentry.models.organization import Organization from sentry.notifications.models.notificationaction import ActionTarget from sentry.notifications.notification_action.metric_alert_registry import ( PagerDutyMetricAlertHandler, @@ -22,8 +24,16 @@ MetricAlertHandlerBase, ) +_UnpackedSendAlert = tuple[ + NotificationContext, AlertContext, MetricIssueContext, OpenPeriodContext, Organization, str +] + class TestPagerDutyMetricAlertHandler(MetricAlertHandlerBase): + def unpack_kwargs(self, mock_send_alert: mock.MagicMock) -> _UnpackedSendAlert: + # justification: unittest.mock exposes call kwargs as untyped values + return cast(_UnpackedSendAlert, super().unpack_kwargs(mock_send_alert)) + def setUp(self) -> None: self.create_models() self.action = self.create_action( diff --git a/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py b/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py index b5ae92043c3d..85a9ce07f627 100644 --- a/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py +++ b/tests/sentry/notifications/notification_action/metric_alert_registry/test_sentry_app_metric_alert_handler.py @@ -1,5 +1,6 @@ import uuid from dataclasses import asdict +from typing import cast from unittest import mock from sentry.incidents.models.alert_rule import AlertRuleDetectionType, AlertRuleThresholdType @@ -11,6 +12,7 @@ OpenPeriodContext, ) from sentry.models.activity import Activity +from sentry.models.organization import Organization from sentry.notifications.models.notificationaction import ActionTarget from sentry.notifications.notification_action.metric_alert_registry.handlers.sentry_app_metric_alert_handler import ( SentryAppMetricAlertHandler, @@ -26,8 +28,16 @@ MetricAlertHandlerBase, ) +_UnpackedSendAlert = tuple[ + NotificationContext, AlertContext, MetricIssueContext, OpenPeriodContext, Organization, str +] + class TestSentryAppMetricAlertHandler(MetricAlertHandlerBase): + def unpack_kwargs(self, mock_send_alert: mock.MagicMock) -> _UnpackedSendAlert: + # justification: unittest.mock exposes call kwargs as untyped values + return cast(_UnpackedSendAlert, super().unpack_kwargs(mock_send_alert)) + def setUp(self) -> None: super().setUp() self.sentry_app = self.create_sentry_app( diff --git a/tests/sentry/notifications/notification_action/metric_alert_registry/test_slack_metric_alert_handler.py b/tests/sentry/notifications/notification_action/metric_alert_registry/test_slack_metric_alert_handler.py index 12986c0eab37..d966c915dbc0 100644 --- a/tests/sentry/notifications/notification_action/metric_alert_registry/test_slack_metric_alert_handler.py +++ b/tests/sentry/notifications/notification_action/metric_alert_registry/test_slack_metric_alert_handler.py @@ -2,7 +2,7 @@ import uuid from dataclasses import asdict, replace -from typing import Any +from typing import Any, cast from unittest import mock from unittest.mock import patch @@ -21,6 +21,7 @@ from sentry.integrations.slack.message_builder.types import SlackAction from sentry.integrations.types import IntegrationProviderSlug from sentry.models.activity import Activity +from sentry.models.organization import Organization from sentry.notifications.models.notificationaction import ActionTarget from sentry.notifications.notification_action.metric_alert_registry import SlackMetricAlertHandler from sentry.notifications.notification_action.metric_alert_registry.handlers.utils import ( @@ -212,9 +213,11 @@ def _invocation(self, resolved: bool = False) -> ActionInvocation: @override_options({"notifications.platform-rollout.internal-testing": {"metric-alert": 1.0}}) @override_options({"notifications.platform.alert-providers": {"metric-alert": ["slack"]}}) - @with_feature("organizations:notification-platform.internal-testing") def _send_platform_alert_and_get_blocks(self, invocation: ActionInvocation) -> list[Any]: - with patch("sentry.integrations.slack.integration.SlackSdkClient") as mock_client: + with ( + with_feature("organizations:notification-platform.internal-testing"), + patch("sentry.integrations.slack.integration.SlackSdkClient") as mock_client, + ): mock_client.return_value.chat_postMessage.return_value = SlackResponse( client=mock_client.return_value, http_verb="POST", @@ -226,7 +229,8 @@ def _send_platform_alert_and_get_blocks(self, invocation: ActionInvocation) -> l ) self.handler.invoke_legacy_registry(invocation) payload = mock_client.return_value.chat_postMessage.call_args.kwargs - return payload["attachments"][0]["blocks"] + # justification: the Slack payload carries its block list under attachments. + return cast("list[Any]", payload["attachments"][0]["blocks"]) def test_send_alert_via_platform_includes_workflow_link(self) -> None: blocks = self._send_platform_alert_and_get_blocks(self._invocation()) @@ -290,7 +294,16 @@ def test_send_alert_without_investigations_flag_has_no_investigation_button(self assert [block["type"] for block in blocks] == ["section", "context"] +_UnpackedSendAlert = tuple[ + NotificationContext, AlertContext, MetricIssueContext, OpenPeriodContext, Organization, str +] + + class TestSlackMetricAlertHandlerInvokeRegistry(MetricAlertHandlerBase): + def unpack_kwargs(self, mock_send_alert: mock.MagicMock) -> _UnpackedSendAlert: + # justification: unittest.mock exposes call kwargs as untyped values + return cast(_UnpackedSendAlert, super().unpack_kwargs(mock_send_alert)) + """Tests for invoke_legacy_registry — verifies context extraction from GroupEvent and Activity.""" def setUp(self) -> None: diff --git a/tests/sentry/notifications/notification_action/test_group_type_notification_registry_handlers.py b/tests/sentry/notifications/notification_action/test_group_type_notification_registry_handlers.py index b5ee76934461..cee25dbc64f1 100644 --- a/tests/sentry/notifications/notification_action/test_group_type_notification_registry_handlers.py +++ b/tests/sentry/notifications/notification_action/test_group_type_notification_registry_handlers.py @@ -81,7 +81,7 @@ def test_handle_workflow_action_no_handler(self, mock_registry_get: mock.MagicMo def test_handle_activity_update(self) -> None: self.event_data = WorkflowEventData(event=self.activity, group=self.group) - with mock.patch.object(self.activity, "send_notification"): + with mock.patch.object(self.activity, "send_notification") as mock_send_notification: notification_uuid = str(uuid.uuid4()) invocation = ActionInvocation( @@ -92,10 +92,12 @@ def test_handle_activity_update(self) -> None: workflow_id=self.workflow.id, ) execute_via_group_type_registry(invocation) - self.activity.send_notification.assert_called_once_with() + mock_send_notification.assert_called_once_with() @mock.patch("sentry.notifications.notification_action.utils.execute_via_metric_alert_handler") - def test_handle_metric_issue_resolution(self, mock_execute_metric_alert_handler) -> None: + def test_handle_metric_issue_resolution( + self, mock_execute_metric_alert_handler: mock.MagicMock + ) -> None: group = self.create_group(type=MetricIssue.type_id) activity = self.create_group_activity( group=group, diff --git a/tests/sentry/notifications/notification_action/test_issue_alert_registry_handlers.py b/tests/sentry/notifications/notification_action/test_issue_alert_registry_handlers.py index 9f9f2b29c953..c658895a1f51 100644 --- a/tests/sentry/notifications/notification_action/test_issue_alert_registry_handlers.py +++ b/tests/sentry/notifications/notification_action/test_issue_alert_registry_handlers.py @@ -1,5 +1,7 @@ import uuid +from typing import Any from unittest import mock +from unittest.mock import MagicMock import pytest @@ -51,7 +53,9 @@ from tests.sentry.workflow_engine.test_base import BaseWorkflowTest -def pop_keys_from_data_blob(data_blob: dict, action_type: str) -> dict: +def pop_keys_from_data_blob( + data_blob: dict[str, Any], action_type: str +) -> dict[str, Any]: # justification: rule action data blob is decoded JSON. """ Remove standard action fields from each dictionary in the data blob. @@ -94,11 +98,15 @@ def setUp(self) -> None: class TestHandler(BaseIssueAlertHandler): @classmethod - def get_additional_fields(cls, action: Action, mapping: ActionFieldMapping): + def get_additional_fields( + cls, action: Action, mapping: ActionFieldMapping + ) -> dict[str, str]: return {"tags": "environment,user,my_tag"} @classmethod - def get_target_display(cls, action: Action, mapping: ActionFieldMapping): + def get_target_display( + cls, action: Action, mapping: ActionFieldMapping + ) -> dict[str, object]: return {} self.handler = TestHandler() @@ -121,7 +129,9 @@ def create_context_and_data( def test_create_rule_instance_from_action_missing_properties_raises_value_error(self) -> None: class TestHandler(BaseIssueAlertHandler): @classmethod - def get_additional_fields(cls, action: Action, mapping: ActionFieldMapping): + def get_additional_fields( + cls, action: Action, mapping: ActionFieldMapping + ) -> dict[str, str]: return {"tags": "environment,user,my_tag"} handler = TestHandler() @@ -251,8 +261,11 @@ def test_create_action_context_no_environment(self) -> None: @mock.patch("sentry.notifications.notification_action.types.activate_downstream_actions") @mock.patch("uuid.uuid4") def test_invoke_legacy_registry( - self, mock_uuid, mock_activate_downstream_actions, mock_invoke_future_with_error_handling - ): + self, + mock_uuid: MagicMock, + mock_activate_downstream_actions: MagicMock, + mock_invoke_future_with_error_handling: MagicMock, + ) -> None: # Test that invoke_legacy_registry correctly processes the action mock_uuid.return_value = uuid.UUID("12345678-1234-5678-1234-567812345678") @@ -526,7 +539,9 @@ def setUp(self) -> None: self.detector = self.create_detector(project=self.project) self.handler: TicketingIssueAlertHandler - def _test_build_rule_action_blob(self, expected, action_type: Action.Type): + def _test_build_rule_action_blob( + self, expected: dict[str, Any], action_type: Action.Type + ) -> None: # justification: rule action data blob is decoded JSON. action_data = pop_keys_from_data_blob(expected, action_type) action = self.create_action( type=action_type, @@ -545,7 +560,7 @@ def _test_build_rule_action_blob(self, expected, action_type: Action.Type): **expected, } - def _form_ticketing_action_blob(self, expected): + def _form_ticketing_action_blob(self, expected: dict[str, Any]) -> dict[str, object]: dynamic_form_fields, additional_fields = TicketingActionDataBlobHelper.separate_fields( expected ) @@ -924,7 +939,7 @@ def test_raises_retry_error_for_api_error(self) -> None: self.mock_callback.assert_called_once() @mock.patch("logging.getLogger") - def test_safe_execute_exception_handling(self, mock_get_logger): + def test_safe_execute_exception_handling(self, mock_get_logger: MagicMock) -> None: from sentry.notifications.notification_action.types import invoke_future_with_error_handling mock_localized_logger = mock.Mock() @@ -933,11 +948,11 @@ def test_safe_execute_exception_handling(self, mock_get_logger): test_exception = ValueError("Generic test error") class TestCallbackClass: - def __call__(self, event, futures): # noqa: ARG002 + def __call__(self, event: object, futures: object) -> None: # noqa: ARG002 raise test_exception @property - def __name__(self): + def __name__(self) -> str: return "test_callback" failing_callback = TestCallbackClass() @@ -951,7 +966,7 @@ def __name__(self): ) @mock.patch("logging.getLogger") - def test_generic_exception_with_no_name_attribute(self, mock_get_logger): + def test_generic_exception_with_no_name_attribute(self, mock_get_logger: MagicMock) -> None: from sentry.notifications.notification_action.types import invoke_future_with_error_handling mock_localized_logger = mock.Mock() @@ -960,7 +975,7 @@ def test_generic_exception_with_no_name_attribute(self, mock_get_logger): test_exception = Exception("Test error") class CallableWithoutName: - def __call__(self, event, futures): # noqa: ARG002 + def __call__(self, event: object, futures: object) -> None: # noqa: ARG002 raise test_exception callback_without_name = CallableWithoutName() diff --git a/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py b/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py index 1fe6f40234e8..bec904154df4 100644 --- a/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py +++ b/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py @@ -4,6 +4,7 @@ from datetime import datetime from typing import Any from unittest import mock +from unittest.mock import MagicMock import pytest from django.utils import timezone @@ -69,7 +70,7 @@ def send_alert( class MetricAlertHandlerBase(BaseWorkflowTest): - def create_models(self): + def create_models(self) -> None: self.workflow, self.detector, _, _ = self.create_detector_and_workflow() self.snuba_query = self.create_snuba_query() self.subscription = self.create_snuba_query_subscription(snuba_query_id=self.snuba_query.id) @@ -168,7 +169,7 @@ def create_issue_occurrence( priority: int | None = None, level: str = "error", evidence_data: Mapping[str, Any] | None = None, - ): + ) -> IssueOccurrence: if evidence_data is None: evidence_data = {} @@ -202,7 +203,7 @@ def assert_notification_context( notes: str | None = None, workflow_id: int | None = None, workflow_name: str | None = None, - ): + ) -> None: assert asdict(notification_context) == { "id": notification_context.id, "integration_id": integration_id, @@ -227,7 +228,7 @@ def assert_alert_context( sensitivity: AlertRuleSensitivity | AnomalyDetectionSensitivity | None = None, resolve_threshold: float | None = None, alert_threshold: float | None = None, - ): + ) -> None: assert asdict(alert_context) == { "name": name, "action_identifier_id": action_identifier_id, @@ -249,7 +250,7 @@ def assert_metric_issue_context( metric_value: StoredMetricResult | None = None, subscription: QuerySubscription | None = None, group: Group | None = None, - ): + ) -> None: assert asdict(metric_issue_context) == { "id": metric_issue_context.id, "open_period_identifier": open_period_identifier, @@ -267,14 +268,14 @@ def assert_open_period_context( id: int, date_started: datetime, date_closed: datetime | None, - ): + ) -> None: assert open_period_context.dict() == { "id": id, "date_started": date_started, "date_closed": date_closed, } - def unpack_kwargs(self, mock_send_alert): + def unpack_kwargs(self, mock_send_alert: MagicMock) -> tuple[object, ...]: _, kwargs = mock_send_alert.call_args notification_context = kwargs["notification_context"] alert_context = kwargs["alert_context"] diff --git a/tests/sentry/notifications/notification_action/test_platform_dispatch.py b/tests/sentry/notifications/notification_action/test_platform_dispatch.py index 85b37b667a1f..7c7237144bbd 100644 --- a/tests/sentry/notifications/notification_action/test_platform_dispatch.py +++ b/tests/sentry/notifications/notification_action/test_platform_dispatch.py @@ -249,7 +249,7 @@ def setUp(self) -> None: ) assert event.group is not None self.group = event.group - self.event = event.for_group(self.group) + self.group_event = event.for_group(self.group) def create_channel_action(self, action_type: str, provider: str) -> Action: integration, _ = self.create_provider_integration_for( @@ -271,7 +271,7 @@ def create_channel_action(self, action_type: str, provider: str) -> Action: def invocation(self, action: Action) -> ActionInvocation: return ActionInvocation( - event_data=WorkflowEventData(event=self.event, group=self.group), + event_data=WorkflowEventData(event=self.group_event, group=self.group), action=action, detector=self.detector, notification_uuid=str(uuid.uuid4()), diff --git a/tests/sentry/notifications/notifications/test_assigned.py b/tests/sentry/notifications/notifications/test_assigned.py index a12a18158ed1..33584634c5cc 100644 --- a/tests/sentry/notifications/notifications/test_assigned.py +++ b/tests/sentry/notifications/notifications/test_assigned.py @@ -1,19 +1,24 @@ import re +from collections.abc import Sequence from unittest.mock import MagicMock, patch import orjson import responses from django.core import mail -from django.core.mail.message import EmailMultiAlternatives +from django.core.mail.message import EmailMessage, EmailMultiAlternatives from django.db.models import F from slack_sdk.web import SlackResponse from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.models.groupowner import GroupOwner, GroupOwnerType from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.models.team import Team from sentry.testutils.cases import APITestCase from sentry.testutils.skips import requires_snuba from sentry.types.activity import ActivityType +from sentry.users.models.user import User pytestmark = [requires_snuba] @@ -35,7 +40,14 @@ ), ) class AssignedNotificationAPITest(APITestCase): - def validate_email(self, outbox, index, email, txt_msg, html_msg): + def validate_email( + self, + outbox: Sequence[EmailMessage | EmailMultiAlternatives], + index: int, + email: str, + txt_msg: str, + html_msg: str, + ) -> None: msg = outbox[index] assert msg.to == [email] assert isinstance(msg, EmailMultiAlternatives) @@ -46,7 +58,15 @@ def validate_email(self, outbox, index, email, txt_msg, html_msg): assert isinstance(alt0, str) assert html_msg in alt0 - def validate_slack_message(self, msg, group, project, user_id, mock_post, index=0): + def validate_slack_message( + self, + msg: str, + group: Group, + project: Project, + user_id: int, + mock_post: MagicMock, + index: int = 0, + ) -> None: blocks = orjson.loads(mock_post.call_args_list[index].kwargs["blocks"]) fallback_text = mock_post.call_args_list[index].kwargs["text"] assert fallback_text == msg @@ -56,7 +76,7 @@ def validate_slack_message(self, msg, group, project, user_id, mock_post, index= channel = mock_post.call_args_list[index].kwargs["channel"] assert channel == str(user_id) - def setup_user(self, user, team): + def setup_user(self, user: User, team: Team) -> None: member = self.create_member(user=user, organization=self.organization, role="member") self.create_team_membership(team, member, role="admin") self.create_user_option(user=user, key="self_notifications", value="1") @@ -113,7 +133,7 @@ def test_sends_assignment_notification(self, mock_post: MagicMock) -> None: assert self.project.slug in blocks[-1]["elements"][0]["text"] @responses.activate - def test_sends_assignment_notification_with_suspect_commits(self, mock_post): + def test_sends_assignment_notification_with_suspect_commits(self, mock_post: MagicMock) -> None: """ Test that suspect commits are included in assignment notification emails when GroupOwner records exist. @@ -168,7 +188,9 @@ def test_sends_assignment_notification_with_suspect_commits(self, mock_post): assert "abc123d" in html_content # shortened commit ID assert user.get_display_name() in html_content # commit author - def test_sends_assignment_notification_without_suspect_commits(self, mock_post): + def test_sends_assignment_notification_without_suspect_commits( + self, mock_post: MagicMock + ) -> None: """ Test that assignment notifications work normally when no suspect commits exist. """ @@ -193,7 +215,7 @@ def test_sends_assignment_notification_without_suspect_commits(self, mock_post): # But assignment notification should still work assert f"assigned {self.group.qualified_short_id} to themselves" in msg.body - def test_enhanced_privacy_hides_suspect_commits_in_emails(self, mock_post): + def test_enhanced_privacy_hides_suspect_commits_in_emails(self, mock_post: MagicMock) -> None: user = self.create_user() self.setup_user(user, self.team) self.login_as(user) @@ -221,7 +243,8 @@ def test_enhanced_privacy_hides_suspect_commits_in_emails(self, mock_post): # Enable enhanced privacy flag self.organization.update(flags=F("flags").bitor(Organization.flags.enhanced_privacy)) self.organization.refresh_from_db() - assert self.organization.flags.enhanced_privacy.is_set is True + # justification: bitfield's TypedClassBitField annotates flags as bool, but instances expose Bit values. + assert getattr(self.organization.flags, "enhanced_privacy").is_set is True url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group.id}/" with self.tasks(): @@ -245,7 +268,9 @@ def test_enhanced_privacy_hides_suspect_commits_in_emails(self, mock_post): assert f"assigned {self.group.qualified_short_id} to themselves" in msg.body @responses.activate - def test_enhanced_privacy_default_shows_suspect_commits_in_emails(self, mock_post): + def test_enhanced_privacy_default_shows_suspect_commits_in_emails( + self, mock_post: MagicMock + ) -> None: """ Test that suspect commits are shown by default in assignment notification emails when enhanced privacy is not set. @@ -275,7 +300,8 @@ def test_enhanced_privacy_default_shows_suspect_commits_in_emails(self, mock_pos context={"commitId": commit.id}, ) - assert self.organization.flags.enhanced_privacy.is_set is False + # justification: bitfield's TypedClassBitField annotates flags as bool, but instances expose Bit values. + assert getattr(self.organization.flags, "enhanced_privacy").is_set is False url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group.id}/" with self.tasks(): @@ -299,7 +325,9 @@ def test_enhanced_privacy_default_shows_suspect_commits_in_emails(self, mock_pos assert f"assigned {self.group.qualified_short_id} to themselves" in msg.body @responses.activate - def test_sends_assignment_notification_with_multiple_suspect_commits(self, mock_post): + def test_sends_assignment_notification_with_multiple_suspect_commits( + self, mock_post: MagicMock + ) -> None: """ Test that when multiple suspect commits exist, the most recent one is displayed in notifications. """ diff --git a/tests/sentry/notifications/notifications/test_suspect_commits_activity.py b/tests/sentry/notifications/notifications/test_suspect_commits_activity.py index 81447ec2bb64..839ccab46784 100644 --- a/tests/sentry/notifications/notifications/test_suspect_commits_activity.py +++ b/tests/sentry/notifications/notifications/test_suspect_commits_activity.py @@ -2,6 +2,7 @@ from django.utils import timezone from sentry.models.activity import Activity +from sentry.models.commit import Commit from sentry.models.groupassignee import GroupAssignee from sentry.models.groupowner import GroupOwner, GroupOwnerType from sentry.models.organization import Organization @@ -12,6 +13,7 @@ from sentry.testutils.cases import TestCase from sentry.testutils.skips import requires_snuba from sentry.types.activity import ActivityType +from sentry.users.models.user import User pytestmark = [requires_snuba] @@ -45,7 +47,7 @@ def setUp(self) -> None: message="fix: Critical bug fix", ) - def _create_suspect_commit_owner(self, commit, user=None): + def _create_suspect_commit_owner(self, commit: Commit, user: User | None = None) -> GroupOwner: """Helper to create a GroupOwner record for suspect commit.""" return GroupOwner.objects.create( group=self.group, @@ -235,7 +237,7 @@ def test_enhanced_privacy_hides_suspect_commits(self) -> None: self._create_suspect_commit_owner(self.commit1) self.organization.update(flags=F("flags").bitor(Organization.flags.enhanced_privacy)) self.organization.refresh_from_db() - assert self.organization.flags.enhanced_privacy.is_set is True + assert self.organization.flags.enhanced_privacy activity = Activity.objects.create( project=self.project, diff --git a/tests/sentry/notifications/platform/discord/test_provider.py b/tests/sentry/notifications/platform/discord/test_provider.py index 7cdd3336b9ef..f8cde31328d3 100644 --- a/tests/sentry/notifications/platform/discord/test_provider.py +++ b/tests/sentry/notifications/platform/discord/test_provider.py @@ -28,6 +28,7 @@ ParagraphSection, PlainTextBlock, ) +from sentry.organizations.services.organization.serial import serialize_organization_summary from sentry.testutils.cases import TestCase from sentry.testutils.notifications.platform import MockNotification, MockNotificationTemplate @@ -211,7 +212,12 @@ def test_basic_fields(self) -> None: def test_is_available(self) -> None: assert DiscordNotificationProvider.is_available() is False - assert DiscordNotificationProvider.is_available(organization=self.organization) is False + assert ( + DiscordNotificationProvider.is_available( + organization=serialize_organization_summary(self.organization) + ) + is False + ) class DiscordNotificationProviderSendTest(TestCase): diff --git a/tests/sentry/notifications/platform/email/test_provider.py b/tests/sentry/notifications/platform/email/test_provider.py index 7a619bdfaa78..b793eab7e659 100644 --- a/tests/sentry/notifications/platform/email/test_provider.py +++ b/tests/sentry/notifications/platform/email/test_provider.py @@ -32,6 +32,7 @@ ParagraphSection, PlainTextBlock, ) +from sentry.organizations.services.organization.serial import serialize_organization_summary from sentry.testutils.cases import TestCase from sentry.testutils.notifications.platform import MockNotification, MockNotificationTemplate from sentry.types.activity import ActivityType @@ -227,7 +228,12 @@ def test_provider_configuration(self) -> None: assert self.provider.target_class == GenericNotificationTarget assert self.provider.target_resource_types == [NotificationTargetResourceType.EMAIL] assert EmailNotificationProvider.is_available() is True - assert EmailNotificationProvider.is_available(organization=self.organization) is True + assert ( + EmailNotificationProvider.is_available( + organization=serialize_organization_summary(self.organization) + ) + is True + ) @mock.patch("sentry.notifications.platform.email.provider.send_messages") def test_send(self, mock_send_messages: mock.MagicMock) -> None: diff --git a/tests/sentry/notifications/platform/msteams/test_provider.py b/tests/sentry/notifications/platform/msteams/test_provider.py index 04b049079f90..eeeb7295a715 100644 --- a/tests/sentry/notifications/platform/msteams/test_provider.py +++ b/tests/sentry/notifications/platform/msteams/test_provider.py @@ -23,6 +23,7 @@ NotificationRenderedTemplate, NotificationTargetResourceType, ) +from sentry.organizations.services.organization.serial import serialize_organization_summary from sentry.testutils.cases import TestCase from sentry.testutils.notifications.platform import MockNotification, MockNotificationTemplate from tests.sentry.integrations.msteams.test_message_builder import _is_open_url_action @@ -236,7 +237,12 @@ def test_basic_fields(self) -> None: def test_is_available(self) -> None: assert MSTeamsNotificationProvider.is_available() is False - assert MSTeamsNotificationProvider.is_available(organization=self.organization) is False + assert ( + MSTeamsNotificationProvider.is_available( + organization=serialize_organization_summary(self.organization) + ) + is False + ) class MSTeamsNotificationProviderSendTest(TestCase): diff --git a/tests/sentry/notifications/platform/slack/test_provider.py b/tests/sentry/notifications/platform/slack/test_provider.py index 399330e4d9cb..f9bbcc2e3a3e 100644 --- a/tests/sentry/notifications/platform/slack/test_provider.py +++ b/tests/sentry/notifications/platform/slack/test_provider.py @@ -2,7 +2,6 @@ from unittest.mock import Mock, patch import pytest -from slack_sdk.errors import SlackApiError from slack_sdk.models.blocks import ( HeaderBlock, MarkdownTextObject, @@ -13,6 +12,7 @@ from sentry.incidents.models.incident import IncidentStatus from sentry.incidents.typings.metric_detector import OpenPeriodContext +from sentry.integrations.slack.utils.errors import TypedSlackApiError from sentry.integrations.types import IntegrationProviderSlug from sentry.notifications.models.notificationthread import NotificationThread from sentry.notifications.platform.provider import ( @@ -40,6 +40,7 @@ NotificationSource, NotificationTargetResourceType, ) +from sentry.organizations.services.organization.serial import serialize_organization_summary from sentry.testutils.cases import TestCase from sentry.testutils.notifications.platform import MockNotification, MockNotificationTemplate @@ -121,7 +122,12 @@ def test_basic_fields(self) -> None: def test_is_available(self) -> None: assert SlackNotificationProvider.is_available() is False - assert SlackNotificationProvider.is_available(organization=self.organization) is False + assert ( + SlackNotificationProvider.is_available( + organization=serialize_organization_summary(self.organization) + ) + is False + ) def test_staging_resolves_slack_renderers(self) -> None: data = MetricAlertNotificationData( @@ -438,7 +444,7 @@ def test_send_with_threading_slack_api_error(self, mock_slack_client: Mock) -> N headers={}, status_code=400, ) - mock_client_instance.chat_postMessage.side_effect = SlackApiError( + mock_client_instance.chat_postMessage.side_effect = TypedSlackApiError( message="channel_not_found", response=error_response ) diff --git a/tests/sentry/notifications/platform/strategies/test_deploy_release.py b/tests/sentry/notifications/platform/strategies/test_deploy_release.py index fe37ce7d059a..c9221613d317 100644 --- a/tests/sentry/notifications/platform/strategies/test_deploy_release.py +++ b/tests/sentry/notifications/platform/strategies/test_deploy_release.py @@ -16,7 +16,7 @@ class DeployReleaseStrategyTest(TestCase): def _make_strategy(self, **overrides: Any) -> DeployReleaseStrategy: - defaults = dict( + defaults: dict[str, Any] = dict( projects=frozenset([self.project]), activity=Activity( project=self.project, diff --git a/tests/sentry/notifications/test_notificationcontroller.py b/tests/sentry/notifications/test_notificationcontroller.py index 5b33e0c6d6dc..4e8a21034d64 100644 --- a/tests/sentry/notifications/test_notificationcontroller.py +++ b/tests/sentry/notifications/test_notificationcontroller.py @@ -14,13 +14,13 @@ def add_notification_setting_option( - scope_type, - scope_identifier, - type, - value, - user_id=None, - team_id=None, -): + scope_type: NotificationScopeEnum, + scope_identifier: int, + type: NotificationSettingEnum, + value: NotificationSettingsOptionEnum, + user_id: int | None = None, + team_id: int | None = None, +) -> NotificationSettingOption: return NotificationSettingOption.objects.create( scope_type=scope_type.value, scope_identifier=scope_identifier, @@ -32,14 +32,14 @@ def add_notification_setting_option( def add_notification_setting_provider( - scope_type, - scope_identifier, - provider, - type, - value, - user_id=None, - team_id=None, -): + scope_type: NotificationScopeEnum, + scope_identifier: int, + provider: ExternalProviderEnum, + type: NotificationSettingEnum, + value: NotificationSettingsOptionEnum, + user_id: int | None = None, + team_id: int | None = None, +) -> NotificationSettingProvider: return NotificationSettingProvider.objects.create( scope_type=scope_type.value, scope_identifier=scope_identifier, diff --git a/tests/sentry/notifications/test_notifications.py b/tests/sentry/notifications/test_notifications.py index 7bd048222e8a..ce194197a847 100644 --- a/tests/sentry/notifications/test_notifications.py +++ b/tests/sentry/notifications/test_notifications.py @@ -42,8 +42,8 @@ pytestmark = [requires_snuba] -def make_event(**kwargs): - result = { +def make_event(**kwargs: object) -> dict[str, object]: + result: dict[str, object] = { "event_id": uuid.uuid1().hex, "level": logging.ERROR, "logger": "default", @@ -53,7 +53,7 @@ def make_event(**kwargs): return result -def get_attachment(): +def get_attachment() -> tuple[dict[str, object], str]: assert len(responses.calls) >= 1 data = parse_qs(responses.calls[0].request.body) assert "text" in data @@ -64,7 +64,7 @@ def get_attachment(): return attachments[0], data["text"][0] -def get_notification_uuid(url: str): +def get_notification_uuid(url: str) -> str: query_params = parse_qs(urlparse(url).query) notification_uuid = query_params["notification_uuid"][0].split("|")[0] assert len(notification_uuid) > 1 @@ -589,7 +589,9 @@ def test_regression_enhanced_privacy_email(self, mock_post: MagicMock) -> None: assert "enhanced privacy" in html_content assert self.group.title not in msg.subject - assert self.group.qualified_short_id in msg.subject + qualified_short_id = self.group.qualified_short_id + assert qualified_short_id is not None + assert qualified_short_id in msg.subject @patch("sentry.analytics.record") def test_sends_resolved_in_release_notification( diff --git a/tests/sentry/notifications/test_utils.py b/tests/sentry/notifications/test_utils.py index b02115f9585c..f76be6229922 100644 --- a/tests/sentry/notifications/test_utils.py +++ b/tests/sentry/notifications/test_utils.py @@ -1,4 +1,5 @@ import types +from typing import Any from unittest import TestCase from sentry.issue_detection.performance_problem import PerformanceProblem @@ -15,7 +16,7 @@ ) -def mock_event(*, transaction, data=None): +def mock_event(*, transaction: str, data: dict[str, Any] | None = None) -> Any: return types.SimpleNamespace(data=data or {}, transaction=transaction) @@ -183,27 +184,27 @@ def test_returns_render_blocking_asset_context(self) -> None: class GetReplayIdTestCase(TestCase): - def test_returns_replay_id(self): + def test_returns_replay_id(self) -> None: event = mock_event( transaction="tx", data={"contexts": {"replay": {"replay_id": "abc123"}}}, ) assert get_replay_id(event) == "abc123" - def test_returns_none_when_replay_context_is_none(self): + def test_returns_none_when_replay_context_is_none(self) -> None: event = mock_event( transaction="tx", data={"contexts": {"replay": None}}, ) assert get_replay_id(event) is None - def test_returns_none_when_contexts_is_none(self): + def test_returns_none_when_contexts_is_none(self) -> None: event = mock_event( transaction="tx", data={"contexts": None}, ) assert get_replay_id(event) is None - def test_returns_none_when_no_contexts(self): + def test_returns_none_when_no_contexts(self) -> None: event = mock_event(transaction="tx", data={}) assert get_replay_id(event) is None diff --git a/tests/sentry/objectstore/endpoints/test_organization.py b/tests/sentry/objectstore/endpoints/test_organization.py index 9bfb43dbc25f..3f7e6c67b6d1 100644 --- a/tests/sentry/objectstore/endpoints/test_organization.py +++ b/tests/sentry/objectstore/endpoints/test_organization.py @@ -14,7 +14,6 @@ from sentry.objectstore.endpoints.organization import ObjectstoreEndpoint, stream_response from sentry.silo.base import SiloMode, SingleProcessSiloModeState -from sentry.testutils.asserts import assert_status_code from sentry.testutils.cases import TransactionTestCase from sentry.testutils.cell import override_cells from sentry.testutils.helpers.response import close_streaming_response @@ -161,7 +160,7 @@ def test_accept_encoding_passthrough(self) -> None: }, stream=True, ) - assert_status_code(get_resp, 206) + assert get_resp.status_code == 206, get_resp.content assert get_resp.headers.get("Content-Encoding") == "zstd" assert get_resp.headers.get("Content-Length") == str(range_end + 1) assert get_resp.headers.get("Content-Range") == (f"bytes 0-{range_end}/{len(compressed)}") @@ -176,7 +175,7 @@ def test_accept_encoding_passthrough(self) -> None: "Range": f"bytes=0-{range_end}", }, ) - assert_status_code(get_resp, 200) + assert get_resp.status_code == 200, get_resp.content assert get_resp.headers.get("Content-Encoding") is None assert get_resp.headers.get("Content-Length") is None assert get_resp.headers.get("Content-Range") is None @@ -247,7 +246,7 @@ def test_full_cycle(self) -> None: content_type="application/octet-stream", follow=True, ) - assert_status_code(response, 201) + assert response.status_code == 201, response.content object_key = json.loads(close_streaming_response(response))["key"] assert object_key is not None @@ -256,7 +255,7 @@ def test_full_cycle(self) -> None: HTTP_AUTHORIZATION=auth_header, follow=True, ) - assert_status_code(response, 200) + assert response.status_code == 200, response.content assert close_streaming_response(response) == b"test data" response = self.client.put( @@ -266,7 +265,7 @@ def test_full_cycle(self) -> None: HTTP_AUTHORIZATION=auth_header, follow=True, ) - assert_status_code(response, 200) + assert response.status_code == 200, response.content new_key = json.loads(close_streaming_response(response))["key"] assert new_key == object_key @@ -275,7 +274,7 @@ def test_full_cycle(self) -> None: HTTP_AUTHORIZATION=auth_header, follow=True, ) - assert_status_code(response, 200) + assert response.status_code == 200, response.content assert close_streaming_response(response) == b"new data" response = self.client.delete( @@ -283,7 +282,7 @@ def test_full_cycle(self) -> None: HTTP_AUTHORIZATION=auth_header, follow=True, ) - assert_status_code(response, 204) + assert response.status_code == 204, response.content close_streaming_response(response) response = self.client.get( @@ -291,7 +290,7 @@ def test_full_cycle(self) -> None: HTTP_AUTHORIZATION=auth_header, follow=True, ) - assert_status_code(response, 404) + assert response.status_code == 404, response.content close_streaming_response(response) def test_roundtrip_compressed(self) -> None: @@ -317,7 +316,7 @@ def test_roundtrip_compressed(self) -> None: content_type="application/octet-stream", follow=True, ) - assert_status_code(response, 201) + assert response.status_code == 201, response.content object_key = json.loads(close_streaming_response(response))["key"] assert object_key is not None @@ -326,7 +325,7 @@ def test_roundtrip_compressed(self) -> None: HTTP_AUTHORIZATION=auth_header, follow=True, ) - assert_status_code(response, 200) + assert response.status_code == 200, response.content assert close_streaming_response(response) == data diff --git a/tests/sentry/options/test_manager.py b/tests/sentry/options/test_manager.py index 3c6193b34fa5..6739602e526b 100644 --- a/tests/sentry/options/test_manager.py +++ b/tests/sentry/options/test_manager.py @@ -1,6 +1,7 @@ from collections.abc import Generator from functools import cached_property -from unittest.mock import patch +from typing import cast +from unittest.mock import MagicMock, patch import pytest from django.conf import settings @@ -25,7 +26,7 @@ from sentry.options.store import OptionsStore from sentry.testutils.cases import TestCase from sentry.testutils.silo import all_silo_test -from sentry.utils.types import Int, String +from sentry.utils.types import Int, String, Type @all_silo_test @@ -98,7 +99,7 @@ def test_register(self) -> None: self.manager.register("wrong-type", default=1, type=String) with pytest.raises(TypeError): - self.manager.register("none-type", default=None, type=type(None)) + self.manager.register("none-type", default=None, type=cast(Type[object], type(None))) with pytest.raises(ValueError): self.manager.register("bad_flags", flags=FLAG_NOSTORE | FLAG_ADMIN_MODIFIABLE) @@ -287,6 +288,7 @@ def test_db_unavailable(self) -> None: @override_settings(SENTRY_OPTIONS_COMPLAIN_ON_ERRORS=False) def test_db_and_cache_unavailable(self) -> None: + assert self.store.cache is not None self.store.cache.clear() self.manager.set("foo", "bar") self.store.flush_local_cache() @@ -390,7 +392,7 @@ def test_flag_checking(self) -> None: assert not opt.has_any_flag({FLAG_REQUIRED}) @patch("sentry.utils.metrics.timer") - def test_get_metric_source_store(self, mock_timer) -> None: + def test_get_metric_source_store(self, mock_timer: MagicMock) -> None: self.manager.set("foo", "bar") self.store.flush_local_cache() self.manager.get("foo") @@ -404,7 +406,7 @@ def test_get_metric_source_store(self, mock_timer) -> None: ctx.__setitem__.assert_any_call("source", "store") @patch("sentry.utils.metrics.timer") - def test_get_metric_source_disk(self, mock_timer) -> None: + def test_get_metric_source_disk(self, mock_timer: MagicMock) -> None: self.manager.register("disk_opt", flags=FLAG_PRIORITIZE_DISK) with self.settings(SENTRY_OPTIONS={"disk_opt": "val"}): self.manager.get("disk_opt") @@ -413,7 +415,7 @@ def test_get_metric_source_disk(self, mock_timer) -> None: ctx.__setitem__.assert_any_call("source", "disk") @patch("sentry.utils.metrics.timer") - def test_get_metric_source_default(self, mock_timer) -> None: + def test_get_metric_source_default(self, mock_timer: MagicMock) -> None: self.manager.delete("foo") self.store.flush_local_cache() self.manager.get("foo") diff --git a/tests/sentry/options/test_rollout.py b/tests/sentry/options/test_rollout.py index ce8c41b2e627..a25bedca7c17 100644 --- a/tests/sentry/options/test_rollout.py +++ b/tests/sentry/options/test_rollout.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Iterator, Sequence import pytest @@ -10,7 +10,7 @@ @pytest.fixture -def register_option(): +def register_option() -> Iterator[None]: options.register("feature.rollout", default=0.0, flags=options.FLAG_AUTOMATOR_MODIFIABLE) yield @@ -29,7 +29,7 @@ def run_random_sample(option_name: str) -> Sequence[int]: return (success, failure) -def test_in_random_rollout(register_option) -> None: +def test_in_random_rollout(register_option: None) -> None: success = 0 failure = 0 @@ -65,7 +65,7 @@ def run_rollout_group(option_name: str, id: int | str) -> Sequence[int]: return (success, failure) -def test_in_rollout_group_int(register_option) -> None: +def test_in_rollout_group_int(register_option: None) -> None: with override_options({"feature.rollout": 0.25}): success, failure = run_rollout_group("feature.rollout", 123456871) assert success == 0 @@ -76,7 +76,7 @@ def test_in_rollout_group_int(register_option) -> None: assert failure == 0 -def test_in_rollout_group_str(register_option) -> None: +def test_in_rollout_group_str(register_option: None) -> None: with override_options({"feature.rollout": 0.25}): success, failure = run_rollout_group("feature.rollout", "some-value") assert success == 0 diff --git a/tests/sentry/options/test_store.py b/tests/sentry/options/test_store.py index 95db16042e43..ae049aadf20a 100644 --- a/tests/sentry/options/test_store.py +++ b/tests/sentry/options/test_store.py @@ -1,4 +1,5 @@ from functools import cached_property +from typing import Any, cast from unittest.mock import MagicMock, patch from uuid import uuid1 @@ -9,7 +10,7 @@ from sentry.models.options.option import Option from sentry.options.manager import OptionsManager, UpdateChannel -from sentry.options.store import OptionsStore +from sentry.options.store import Key, OptionsStore from sentry.testutils.cases import TestCase from sentry.testutils.silo import no_silo_test @@ -17,27 +18,27 @@ @no_silo_test class OptionsStoreTest(TestCase): @cached_property - def store(self): + def store(self) -> OptionsStore: c = LocMemCache("test", settings.CACHES["default"]) c.clear() return OptionsStore(cache=c) @cached_property - def manager(self): + def manager(self) -> OptionsManager: return OptionsManager(store=self.store) @cached_property - def key(self): + def key(self) -> Key: return self.make_key() @pytest.fixture(autouse=True) - def flush_local_cache(self): + def flush_local_cache(self) -> None: self.store.flush_local_cache() - def make_key(self, ttl=10, grace=10, key_name: str | None = None): + def make_key(self, ttl: int = 10, grace: int = 10, key_name: str | None = None) -> Key: if key_name is None: key_name = uuid1().hex - return self.manager.make_key(key_name, "", object, 0, ttl, grace, None) + return self.manager.make_key(key_name, "", cast(Any, object), 0, ttl, grace, None) def test_simple(self) -> None: store, key = self.store, self.key @@ -127,6 +128,7 @@ def test_key_ttl(self, mocked_time: MagicMock) -> None: assert store.get(key) == "bar" Option.objects.filter(key=key.name).update(value="lol") + assert store.cache is not None store.cache.delete(key.cache_key) # Still within TTL, so don't check database assert store.get(key) == "bar" diff --git a/tests/sentry/options/test_update_channels.py b/tests/sentry/options/test_update_channels.py index 016da6672e98..bca1b9a99d56 100644 --- a/tests/sentry/options/test_update_channels.py +++ b/tests/sentry/options/test_update_channels.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from typing import Any import pytest @@ -20,7 +21,7 @@ @pytest.fixture() -def manager(): +def manager() -> Iterator[OptionsManager]: """ Initializes an options storage, an options cache and an options manager """ @@ -36,7 +37,7 @@ def manager(): @pytest.mark.django_db -def test_drift_conditions(manager) -> None: +def test_drift_conditions(manager: OptionsManager) -> None: """ Test multiple drift conditions, specifically, validates we can always update an option that is not set, we can reset an option @@ -118,7 +119,7 @@ def test_drift_conditions(manager) -> None: TEST_CASES_READONLY, ) def test_non_writable_options( - manager_fixture, + manager_fixture: str, flags: int, set_settings_val: bool, outcome: NotWritableReason, @@ -140,7 +141,7 @@ def test_non_writable_options( @pytest.mark.django_db -def test_legacy_option(manager) -> None: +def test_legacy_option(manager: OptionsManager) -> None: """ Test the update process of legacy options. These options are not registered so we cannot reuse the use cases diff --git a/tests/sentry/pipeline/test_pipeline.py b/tests/sentry/pipeline/test_pipeline.py index 716845b9e5d1..62cd36e40f5c 100644 --- a/tests/sentry/pipeline/test_pipeline.py +++ b/tests/sentry/pipeline/test_pipeline.py @@ -1,6 +1,6 @@ from collections.abc import Sequence from functools import cached_property -from typing import Any, Never +from typing import Any, Never, cast from unittest.mock import MagicMock, patch import pytest @@ -22,9 +22,10 @@ class PipelineStep: - def dispatch(self, request, pipeline): + def dispatch(self, request: HttpRequest, pipeline: "DummyPipeline") -> HttpResponseBase: pipeline.dispatch_count += 1 pipeline.bind_state("some_state", "value") + return HttpResponse() class DummyProvider(PipelineProvider["DummyPipeline"]): @@ -41,7 +42,8 @@ class DummyPipeline(Pipeline[Never, PipelineSessionStore]): pipeline_name = "test_pipeline" - def __init__(self, *args, **kwargs): + # justification: exercises the base pipeline constructor contract. + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.finished = False self.dispatch_count = 0 @@ -56,8 +58,9 @@ def provider(self) -> DummyProvider: def get_pipeline_views(self) -> Sequence[PipelineStep]: return self.provider.get_pipeline_views() - def finish_pipeline(self): + def finish_pipeline(self) -> HttpResponseBase: self.finished = True + return HttpResponse() class ChooseThingView: @@ -66,7 +69,8 @@ class ChooseThingView: def dispatch(self, request: HttpRequest, pipeline: Any) -> HttpResponseBase: if "thing" in request.POST: pipeline.bind_state("thing", request.POST["thing"]) - return pipeline.next_step() + # justification: pipeline steps render Django responses. + return cast("HttpResponseBase", pipeline.next_step()) return HttpResponse('
') @@ -76,7 +80,8 @@ class ConfirmView: def dispatch(self, request: HttpRequest, pipeline: Any) -> HttpResponseBase: if request.method == "POST": pipeline.bind_state("confirmed", True) - return pipeline.next_step() + # justification: pipeline steps render Django responses. + return cast("HttpResponseBase", pipeline.next_step()) thing = pipeline.fetch_state("thing") return HttpResponse( f"

Confirm {thing}?

" diff --git a/tests/sentry/plugins/test_integration_repository.py b/tests/sentry/plugins/test_integration_repository.py index 05676b7b51b9..621aab3dbcb5 100644 --- a/tests/sentry/plugins/test_integration_repository.py +++ b/tests/sentry/plugins/test_integration_repository.py @@ -8,6 +8,7 @@ from sentry.constants import ObjectStatus from sentry.integrations.github.repository import GitHubRepositoryProvider from sentry.models.repository import REPOSITORY_NAME_LENGTH, REPOSITORY_URL_LENGTH, Repository +from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.plugins.providers.integration_repository import RepoExistsError from sentry.shared_integrations.exceptions import IntegrationError from sentry.testutils.cases import TestCase @@ -22,9 +23,11 @@ def setUp(self) -> None: organization=self.organization, provider="github", external_id="654321" ) self.repo_name = "getsentry/sentry" - self.config = { + self.repo_external_id = "654321" + self.rpc_organization = serialize_rpc_organization(self.organization) + self.config: dict[str, str | int] = { "identifier": self.repo_name, - "external_id": "654321", + "external_id": self.repo_external_id, "integration_id": self.integration.id, "url": "https://github.com/getsentry/sentry", } @@ -65,19 +68,19 @@ def _create_repo( ) def test_create_repository_truncate_overflows(self, get_jwt: MagicMock) -> None: - config = self.config.copy() - config["identifier"] = "a" * 510 - self.provider.create_repository(config, self.organization) + identifier = "a" * 510 + config = {**self.config, "identifier": identifier} + self.provider.create_repository(config, self.rpc_organization) repos = Repository.objects.all() assert len(repos) == 1 - assert repos[0].name == config["identifier"][:REPOSITORY_NAME_LENGTH] - assert repos[0].url == f"https://github.com/{config['identifier']}"[:REPOSITORY_URL_LENGTH] + assert repos[0].name == identifier[:REPOSITORY_NAME_LENGTH] + assert repos[0].url == f"https://github.com/{identifier}"[:REPOSITORY_URL_LENGTH] assert repos[0].provider == "integrations:github" def test_create_repository(self, get_jwt: MagicMock) -> None: - self.provider.create_repository(self.config, self.organization) + self.provider.create_repository(self.config, self.rpc_organization) repos = Repository.objects.all() assert len(repos) == 1 @@ -86,10 +89,10 @@ def test_create_repository(self, get_jwt: MagicMock) -> None: assert repos[0].provider == "integrations:github" def test_create_repository__repo_exists(self, get_jwt: MagicMock) -> None: - existing = self._create_repo(external_id=self.config["external_id"]) + existing = self._create_repo(external_id=self.repo_external_id) with pytest.raises(RepoExistsError) as exc_info: - self.provider.create_repository(self.config, self.organization) + self.provider.create_repository(self.config, self.rpc_organization) assert exc_info.value.existing_repo is not None assert exc_info.value.existing_repo.id == existing.id @@ -103,13 +106,13 @@ def test_create_repository__transfers_repo_from_other_integration_in_org( organization=self.organization, provider="github", external_id="123456" ) existing = self._create_repo( - external_id=self.config["external_id"], + external_id=self.repo_external_id, name="getsentry/santry", status=ObjectStatus.DISABLED, integration_id=integration.id, ) - _, repo = self.provider.create_repository(self.config, self.organization) + _, repo = self.provider.create_repository(self.config, self.rpc_organization) assert repo.id == existing.id assert Repository.objects.count() == 1 @@ -126,7 +129,7 @@ def test_create_repository__does_not_transfer_when_provider_cannot( organization=self.organization, provider="github", external_id="123456" ) repo = self._create_repo( - external_id=self.config["external_id"], + external_id=self.repo_external_id, name="getsentry/santry", status=ObjectStatus.DISABLED, integration_id=integration.id, @@ -136,7 +139,7 @@ def test_create_repository__does_not_transfer_when_provider_cannot( patch.object(GitHubRepositoryProvider, "can_transfer_repositories", False), pytest.raises(RepoExistsError) as exc_info, ): - self.provider.create_repository(self.config, self.organization) + self.provider.create_repository(self.config, self.rpc_organization) assert exc_info.value.existing_repo is None repo.refresh_from_db() @@ -155,11 +158,11 @@ def test_create_repository__does_not_reuse_hidden_repo_from_other_provider( name="ghe-org/sentry", provider="integrations:github_enterprise", integration_id=ghe_integration.id, - external_id=self.config["external_id"], + external_id=self.repo_external_id, ) Repository.objects.filter(id=ghe_repo.id).update(status=ObjectStatus.DISABLED) - _, repo = self.provider.create_repository(self.config, self.organization) + _, repo = self.provider.create_repository(self.config, self.rpc_organization) assert repo.id != ghe_repo.id assert repo.provider == "integrations:github" @@ -170,10 +173,10 @@ def test_create_repository__does_not_reuse_hidden_repo_from_other_provider( assert ghe_repo.status == ObjectStatus.DISABLED def test_create_repository__repo_exists_update_name(self, get_jwt: MagicMock) -> None: - repo = self._create_repo(external_id=self.config["external_id"], name="getsentry/santry") + repo = self._create_repo(external_id=self.repo_external_id, name="getsentry/santry") with pytest.raises(RepoExistsError) as exc_info: - self.provider.create_repository(self.config, self.organization) + self.provider.create_repository(self.config, self.rpc_organization) existing = exc_info.value.existing_repo assert existing is not None @@ -183,11 +186,11 @@ def test_create_repository__repo_exists_update_name(self, get_jwt: MagicMock) -> assert repo.name == self.repo_name def test_create_repository__repo_exists_preserves_config(self, get_jwt: MagicMock) -> None: - repo = self._create_repo(external_id=self.config["external_id"]) + repo = self._create_repo(external_id=self.repo_external_id) repo.update(config={**repo.config, "webhook_id": "hook-1"}) with pytest.raises(RepoExistsError): - self.provider.create_repository(self.config, self.organization) + self.provider.create_repository(self.config, self.rpc_organization) repo.refresh_from_db() assert repo.config["webhook_id"] == "hook-1" @@ -203,7 +206,7 @@ def test_create_repository__delete_webhook( mock_on_delete.side_effect = IntegrationError with pytest.raises(RepoExistsError) as exc_info: - self.provider.create_repository(self.config, self.organization) + self.provider.create_repository(self.config, self.rpc_organization) # Pre-existing repo is under the default external_id=123456, not the # config's 654321 — the lookup by (integration_id, external_id) misses, @@ -211,13 +214,13 @@ def test_create_repository__delete_webhook( assert exc_info.value.existing_repo is None def test_create_repository__integrity_error_attaches_existing(self, get_jwt: MagicMock) -> None: - existing = self._create_repo(external_id=self.config["external_id"]) + existing = self._create_repo(external_id=self.repo_external_id) with ( patch("sentry.models.Repository.objects.create", side_effect=IntegrityError), pytest.raises(RepoExistsError) as exc_info, ): - self.provider.create_repository(self.config, self.organization) + self.provider.create_repository(self.config, self.rpc_organization) assert exc_info.value.existing_repo is not None assert exc_info.value.existing_repo.id == existing.id @@ -226,11 +229,11 @@ def test_create_repository__integrity_error_attaches_existing(self, get_jwt: Mag def test_create_repository__activates_existing_hidden_repo( self, mock_metrics: MagicMock, get_jwt: MagicMock ) -> None: - repo = self._create_repo(external_id=self.config["external_id"]) + repo = self._create_repo(external_id=self.repo_external_id) repo.status = ObjectStatus.HIDDEN repo.save() - self.provider.create_repository(self.config, self.organization) + self.provider.create_repository(self.config, self.rpc_organization) repo.refresh_from_db() assert repo.status == ObjectStatus.ACTIVE mock_metrics.incr.assert_called_with("sentry.integration_repo_provider.repo_relink") @@ -243,11 +246,11 @@ def test_create_repository__ignores_hidden_repo_of_other_provider( project=self.project, name="group/project", provider="integrations:gitlab", - external_id=self.config["external_id"], + external_id=self.repo_external_id, ) other.update(status=ObjectStatus.HIDDEN) - _, repo = self.provider.create_repository(self.config, self.organization) + _, repo = self.provider.create_repository(self.config, self.rpc_organization) assert repo.id != other.id assert repo.provider == "integrations:github" @@ -258,11 +261,11 @@ def test_create_repository__activates_hidden_repo_without_provider( self, get_jwt: MagicMock ) -> None: orphan = self.create_repo( - project=self.project, name=self.repo_name, external_id=self.config["external_id"] + project=self.project, name=self.repo_name, external_id=self.repo_external_id ) orphan.update(status=ObjectStatus.HIDDEN) - _, repo = self.provider.create_repository(self.config, self.organization) + _, repo = self.provider.create_repository(self.config, self.rpc_organization) assert repo.id == orphan.id orphan.refresh_from_db() @@ -276,7 +279,7 @@ def test_create_repositories__ignores_rows_of_other_provider(self, get_jwt: Magi project=self.project, name="group/hidden", provider="integrations:gitlab", - external_id=self.config["external_id"], + external_id=self.repo_external_id, ) hidden.update(status=ObjectStatus.HIDDEN) unlinked = self.create_repo( @@ -288,7 +291,7 @@ def test_create_repositories__ignores_rows_of_other_provider(self, get_jwt: Magi other_config = {**self.config, "external_id": "999", "identifier": "getsentry/other"} created, reactivated, missing = self.provider.create_repositories( - [self.config, other_config], self.organization + [self.config, other_config], self.rpc_organization ) assert {repo.external_id for repo in created} == {self.config["external_id"], "999"} @@ -306,14 +309,14 @@ def test_create_repositories__adopts_own_and_provider_less_rows( project=self.project, name=self.repo_name, provider="integrations:github", - external_id=self.config["external_id"], + external_id=self.repo_external_id, ) hidden.update(status=ObjectStatus.HIDDEN) orphan = self.create_repo(project=self.project, name="getsentry/other", external_id="999") other_config = {**self.config, "external_id": "999", "identifier": "getsentry/other"} created, reactivated, missing = self.provider.create_repositories( - [self.config, other_config], self.organization + [self.config, other_config], self.rpc_organization ) assert created == [] @@ -334,12 +337,12 @@ def test_create_repository__does_not_adopt_over_an_existing_row( project=self.project, name=self.repo_name, provider="github", - external_id=self.config["external_id"], + external_id=self.repo_external_id, ) - existing = self._create_repo(external_id=self.config["external_id"]) + existing = self._create_repo(external_id=self.repo_external_id) with pytest.raises(RepoExistsError) as exc_info: - self.provider.create_repository(self.config, self.organization) + self.provider.create_repository(self.config, self.rpc_organization) assert exc_info.value.existing_repo is not None assert exc_info.value.existing_repo.id == existing.id @@ -353,18 +356,20 @@ def test_create_repositories__does_not_adopt_over_an_existing_row( project=self.project, name=self.repo_name, provider="github", - external_id=self.config["external_id"], + external_id=self.repo_external_id, ) legacy.update(status=ObjectStatus.HIDDEN) - self._create_repo(external_id=self.config["external_id"]) + self._create_repo(external_id=self.repo_external_id) created, reactivated, missing = self.provider.create_repositories( - [self.config], self.organization + [self.config], self.rpc_organization ) assert created == [] assert reactivated == [] - assert missing == [self.provider.build_repository_config(self.organization, self.config)] + assert missing == [ + self.provider.build_repository_config(self.rpc_organization, self.config) + ] legacy.refresh_from_db() assert (legacy.status, legacy.provider) == (ObjectStatus.HIDDEN, "github") @@ -375,10 +380,10 @@ def test_create_repository__ignores_unlinked_repo_of_other_provider( project=self.project, name="group/project", provider="gitlab", - external_id=self.config["external_id"], + external_id=self.repo_external_id, ) - _, repo = self.provider.create_repository(self.config, self.organization) + _, repo = self.provider.create_repository(self.config, self.rpc_organization) assert repo.id != other.id other.refresh_from_db() @@ -390,10 +395,10 @@ def test_create_repository__adopts_plugin_era_repo(self, get_jwt: MagicMock) -> project=self.project, name=self.repo_name, provider="github", - external_id=self.config["external_id"], + external_id=self.repo_external_id, ) - _, repo = self.provider.create_repository(self.config, self.organization) + _, repo = self.provider.create_repository(self.config, self.rpc_organization) assert repo.id == legacy.id legacy.refresh_from_db() @@ -401,12 +406,12 @@ def test_create_repository__adopts_plugin_era_repo(self, get_jwt: MagicMock) -> assert legacy.integration_id == self.integration.id def test_create_repository__only_activates_hidden_repo(self, get_jwt: MagicMock) -> None: - repo = self._create_repo(external_id=self.config["external_id"]) + repo = self._create_repo(external_id=self.repo_external_id) repo.status = ObjectStatus.PENDING_DELETION repo.save() with pytest.raises(RepoExistsError) as exc_info: - self.provider.create_repository(self.config, self.organization) + self.provider.create_repository(self.config, self.rpc_organization) assert exc_info.value.existing_repo is None repo.refresh_from_db() @@ -414,11 +419,11 @@ def test_create_repository__only_activates_hidden_repo(self, get_jwt: MagicMock) def test_create_repositories__adopts_unlinked_active_repo(self, get_jwt: MagicMock) -> None: # ACTIVE unlinked rows are adopted, not reported as already-active/not-created - repo = self._create_repo(external_id=self.config["external_id"]) + repo = self._create_repo(external_id=self.repo_external_id) Repository.objects.filter(id=repo.id).update(integration_id=None) created, updated, not_created = self.provider.create_repositories( - [self.config], self.organization + [self.config], self.rpc_organization ) assert created == [] @@ -435,12 +440,12 @@ def test_create_repositories__adopts_legacy_plugin_repo(self, get_jwt: MagicMock name=self.repo_name, provider="github", integration_id=None, - external_id=self.config["external_id"], + external_id=self.repo_external_id, url="https://github.com/" + self.repo_name, ) created, updated, not_created = self.provider.create_repositories( - [self.config], self.organization + [self.config], self.rpc_organization ) assert created == [] @@ -454,10 +459,10 @@ def test_create_repositories__adopts_legacy_plugin_repo(self, get_jwt: MagicMock def test_create_repositories__already_active_on_integration_is_not_created( self, get_jwt: MagicMock ) -> None: - self._create_repo(external_id=self.config["external_id"]) + self._create_repo(external_id=self.repo_external_id) created, updated, not_created = self.provider.create_repositories( - [self.config], self.organization + [self.config], self.rpc_organization ) assert created == [] @@ -468,10 +473,10 @@ def test_create_repositories__already_active_on_integration_is_not_created( def test_create_repositories__reactivating_hidden_is_not_already_active( self, get_jwt: MagicMock ) -> None: - repo = self._create_repo(external_id=self.config["external_id"], status=ObjectStatus.HIDDEN) + repo = self._create_repo(external_id=self.repo_external_id, status=ObjectStatus.HIDDEN) created, updated, not_created = self.provider.create_repositories( - [self.config], self.organization + [self.config], self.rpc_organization ) assert created == [] @@ -487,9 +492,11 @@ def setUp(self) -> None: self.integration = self.create_integration( organization=self.organization, provider="github", external_id="654321" ) - self.config = { + self.repo_external_id = "654321" + self.rpc_organization = serialize_rpc_organization(self.organization) + self.config: dict[str, str | int] = { "identifier": "getsentry/sentry", - "external_id": "654321", + "external_id": self.repo_external_id, "integration_id": self.integration.id, } @@ -503,7 +510,7 @@ def _existing(self, status: int) -> Repository: provider="integrations:github", organization_id=self.organization.id, integration_id=self.integration.id, - external_id="654321", + external_id=self.repo_external_id, status=status, ) @@ -511,7 +518,7 @@ def test_an_active_repository_is_refreshed_but_not_reactivated(self) -> None: repo = self._existing(ObjectStatus.ACTIVE) created, reactivated, _ = self.provider.create_repositories( - configs=[self.config], organization=self.organization + configs=[self.config], organization=self.rpc_organization ) assert (created, reactivated) == ([], []) @@ -522,7 +529,7 @@ def test_a_disabled_repository_is_reactivated(self) -> None: repo = self._existing(ObjectStatus.DISABLED) created, reactivated, _ = self.provider.create_repositories( - configs=[self.config], organization=self.organization + configs=[self.config], organization=self.rpc_organization ) assert created == [] diff --git a/tests/sentry/pr_metrics/test_judge.py b/tests/sentry/pr_metrics/test_judge.py index 671cef4d8c13..fbfcb9453b18 100644 --- a/tests/sentry/pr_metrics/test_judge.py +++ b/tests/sentry/pr_metrics/test_judge.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, timezone -from typing import Any +from typing import Any, cast from unittest.mock import Mock, patch import orjson @@ -66,7 +66,8 @@ def _last_row(mock_record: Any) -> PrCloseMetricsEvent: - return mock_record.call_args_list[-1].args[0] + # justification: the mock records the emitted metrics events positionally. + return cast("PrCloseMetricsEvent", mock_record.call_args_list[-1].args[0]) @cell_silo_test diff --git a/tests/sentry/pr_metrics/test_webhooks.py b/tests/sentry/pr_metrics/test_webhooks.py index 7db79315cdc5..909e1ad8b403 100644 --- a/tests/sentry/pr_metrics/test_webhooks.py +++ b/tests/sentry/pr_metrics/test_webhooks.py @@ -1905,6 +1905,7 @@ def _pull_request_refs( e.g. a fork syncing from upstream) carry a different ``base.repo.id`` and must be skipped by the handler. """ + assert self.repo.external_id is not None same = [ {"number": n, "base": {"repo": {"id": int(self.repo.external_id)}}} for n in pr_numbers ] diff --git a/tests/sentry/preprod/api/bases/test_preprod_artifact_endpoint.py b/tests/sentry/preprod/api/bases/test_preprod_artifact_endpoint.py index d0971c2c67ab..cbe0d52d8167 100644 --- a/tests/sentry/preprod/api/bases/test_preprod_artifact_endpoint.py +++ b/tests/sentry/preprod/api/bases/test_preprod_artifact_endpoint.py @@ -8,7 +8,7 @@ def setUp(self) -> None: self.login_as(self.user) self.artifact = self.create_preprod_artifact(project=self.project) - def _get_url(self, org_slug, artifact_id): + def _get_url(self, org_slug: str, artifact_id: int) -> str: return f"/api/0/organizations/{org_slug}/preprodartifacts/{artifact_id}/build-details/" def test_extracts_project_from_artifact(self) -> None: diff --git a/tests/sentry/preprod/api/endpoints/public/test_organization_preprod_artifact_install_details.py b/tests/sentry/preprod/api/endpoints/public/test_organization_preprod_artifact_install_details.py index 707ca6130445..9059ba6e587d 100644 --- a/tests/sentry/preprod/api/endpoints/public/test_organization_preprod_artifact_install_details.py +++ b/tests/sentry/preprod/api/endpoints/public/test_organization_preprod_artifact_install_details.py @@ -29,7 +29,7 @@ def setUp(self) -> None: build_number=42, ) - def _get_url(self, artifact_id=None): + def _get_url(self, artifact_id: int | None = None) -> str: artifact_id = artifact_id or self.preprod_artifact.id return reverse( self.endpoint, diff --git a/tests/sentry/preprod/api/endpoints/public/test_organization_preprod_size_analysis.py b/tests/sentry/preprod/api/endpoints/public/test_organization_preprod_size_analysis.py index b673c20cc387..5747fb51972f 100644 --- a/tests/sentry/preprod/api/endpoints/public/test_organization_preprod_size_analysis.py +++ b/tests/sentry/preprod/api/endpoints/public/test_organization_preprod_size_analysis.py @@ -1,7 +1,9 @@ from io import BytesIO +from typing import Any from django.urls import reverse +from sentry.models.files.file import File from sentry.preprod.models import ( PreprodArtifact, PreprodArtifactSizeComparison, @@ -34,19 +36,20 @@ def setUp(self) -> None: build_number=42, ) - def _get_url(self, artifact_id=None): + def _get_url(self, artifact_id: int | None = None) -> str: artifact_id = artifact_id or self.preprod_artifact.id return reverse( self.endpoint, args=[self.organization.slug, artifact_id], ) - def _create_analysis_file(self, data): + def _create_analysis_file(self, data: dict[str, Any]) -> File: f = self.create_file(name="analysis.json", type="application/json") f.putfile(BytesIO(json.dumps(data).encode())) return f - def _make_analysis_data(self, **overrides): + # justification: test payload overrides vary per case. + def _make_analysis_data(self, **overrides: Any) -> dict[str, Any]: defaults = { "analysis_duration": 1.5, "download_size": 512000, diff --git a/tests/sentry/preprod/api/endpoints/public/test_project_preprod_build_distribution_latest.py b/tests/sentry/preprod/api/endpoints/public/test_project_preprod_build_distribution_latest.py index 6d35219575cc..8fcd17be94d6 100644 --- a/tests/sentry/preprod/api/endpoints/public/test_project_preprod_build_distribution_latest.py +++ b/tests/sentry/preprod/api/endpoints/public/test_project_preprod_build_distribution_latest.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Any from django.urls import reverse @@ -37,18 +38,20 @@ def setUp(self) -> None: project=self.project, name="release" ) - def _get_url(self): + def _get_url(self) -> str: return reverse( self.endpoint, args=[self.organization.slug, self.project.slug], ) - def _get(self, url, data=None): + # justification: Django test client responses are untyped in stubs. + def _get(self, url: str, data: dict[str, Any] | None = None) -> Any: return self.client.get(url, data, HTTP_AUTHORIZATION=f"Bearer {self.api_token}") - def _create_installable_artifact(self, **kwargs): + # justification: factory kwargs vary per test case. + def _create_installable_artifact(self, **kwargs: Any) -> PreprodArtifact: """Helper to create an installable artifact with sensible defaults.""" - defaults = { + defaults: dict[str, Any] = { "project": self.project, "file_id": self.file.id, "installable_app_file_id": self.installable_file.id, diff --git a/tests/sentry/preprod/api/endpoints/public/test_project_preprod_size_analysis_status_check_rules.py b/tests/sentry/preprod/api/endpoints/public/test_project_preprod_size_analysis_status_check_rules.py index b5eba855f5bc..68b78e337b98 100644 --- a/tests/sentry/preprod/api/endpoints/public/test_project_preprod_size_analysis_status_check_rules.py +++ b/tests/sentry/preprod/api/endpoints/public/test_project_preprod_size_analysis_status_check_rules.py @@ -1,4 +1,4 @@ -from typing import get_args +from typing import Any, get_args from django.urls import reverse @@ -23,6 +23,7 @@ from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase from sentry.testutils.silo import assume_test_silo_mode +from sentry.users.models.user import User from sentry.utils import json from sentry.utils.security.orgauthtoken_token import generate_token, hash_token @@ -35,20 +36,29 @@ def setUp(self) -> None: self.organization = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.organization) - def _get_url(self, organization_slug=None, project_slug=None): + def _get_url( + self, organization_slug: str | None = None, project_slug: str | None = None + ) -> str: return reverse( self.endpoint, args=[organization_slug or self.organization.slug, project_slug or self.project.slug], ) - def _get_with_user_token(self, scope_list=None, user=None, url=None): + # justification: Django test client responses are untyped in stubs. + def _get_with_user_token( + self, + scope_list: list[str] | None = None, + user: User | None = None, + url: str | None = None, + ) -> Any: token = self.create_user_auth_token( user or self.user, scope_list=scope_list or ["project:read"], ) return self.client.get(url or self._get_url(), HTTP_AUTHORIZATION=f"Bearer {token.token}") - def _get_with_org_token(self, scope_list=None): + # justification: Django test client responses are untyped in stubs. + def _get_with_org_token(self, scope_list: list[str] | None = None) -> Any: token_str = generate_token(self.organization.slug, "") with assume_test_silo_mode(SiloMode.CONTROL): OrgAuthToken.objects.create( diff --git a/tests/sentry/preprod/api/endpoints/public/test_project_preprod_skip_status_check.py b/tests/sentry/preprod/api/endpoints/public/test_project_preprod_skip_status_check.py index 19db58ace67b..2879654c88a2 100644 --- a/tests/sentry/preprod/api/endpoints/public/test_project_preprod_skip_status_check.py +++ b/tests/sentry/preprod/api/endpoints/public/test_project_preprod_skip_status_check.py @@ -1,6 +1,7 @@ from __future__ import annotations -from unittest.mock import Mock, patch +from typing import Any +from unittest.mock import MagicMock, Mock, _patch, patch from django.urls import reverse @@ -34,13 +35,20 @@ def setUp(self) -> None: ) self.sha = "a" * 40 - def _url(self, organization_slug=None, project_slug=None): + def _url(self, organization_slug: str | None = None, project_slug: str | None = None) -> str: return reverse( self.endpoint, args=[organization_slug or self.organization.slug, project_slug or self.project.slug], ) - def _post(self, data, scope_list=None, url=None, include_provider=True): + # justification: Django test client responses are untyped in stubs. + def _post( + self, + data: dict[str, Any], + scope_list: list[str] | None = None, + url: str | None = None, + include_provider: bool = True, + ) -> Any: # project:releases is what CI upload tokens (sentry-cli) carry, so it's the # realistic scope for this endpoint (see ProjectReleasePermission). token = self.create_user_auth_token( @@ -53,7 +61,9 @@ def _post(self, data, scope_list=None, url=None, include_provider=True): HTTP_AUTHORIZATION=f"Bearer {token.token}", ) - def _patch_provider(self, mock_provider): + def _patch_provider( + self, mock_provider: MagicMock + ) -> tuple[_patch[MagicMock], _patch[MagicMock]]: return ( patch( f"{SKIP_MODULE}.get_status_check_client_for_repo", diff --git a/tests/sentry/preprod/api/endpoints/public/test_project_preprod_snapshot_status_check_rules.py b/tests/sentry/preprod/api/endpoints/public/test_project_preprod_snapshot_status_check_rules.py index ada681023fe9..b4763452e654 100644 --- a/tests/sentry/preprod/api/endpoints/public/test_project_preprod_snapshot_status_check_rules.py +++ b/tests/sentry/preprod/api/endpoints/public/test_project_preprod_snapshot_status_check_rules.py @@ -1,3 +1,5 @@ +from typing import Any + from django.urls import reverse from sentry.models.orgauthtoken import OrgAuthToken @@ -11,6 +13,7 @@ from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase from sentry.testutils.silo import assume_test_silo_mode +from sentry.users.models.user import User from sentry.utils.security.orgauthtoken_token import generate_token, hash_token @@ -22,20 +25,29 @@ def setUp(self) -> None: self.organization = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.organization) - def _get_url(self, organization_slug=None, project_slug=None): + def _get_url( + self, organization_slug: str | None = None, project_slug: str | None = None + ) -> str: return reverse( self.endpoint, args=[organization_slug or self.organization.slug, project_slug or self.project.slug], ) - def _get_with_user_token(self, scope_list=None, user=None, url=None): + # justification: Django test client responses are untyped in stubs. + def _get_with_user_token( + self, + scope_list: list[str] | None = None, + user: User | None = None, + url: str | None = None, + ) -> Any: token = self.create_user_auth_token( user or self.user, scope_list=scope_list or ["project:read"], ) return self.client.get(url or self._get_url(), HTTP_AUTHORIZATION=f"Bearer {token.token}") - def _get_with_org_token(self, scope_list=None): + # justification: Django test client responses are untyped in stubs. + def _get_with_org_token(self, scope_list: list[str] | None = None) -> Any: token_str = generate_token(self.organization.slug, "") with assume_test_silo_mode(SiloMode.CONTROL): OrgAuthToken.objects.create( diff --git a/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_compare.py b/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_compare.py index df05202d7618..7cb6491593a4 100644 --- a/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_compare.py +++ b/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_compare.py @@ -1,5 +1,5 @@ from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch from django.urls import reverse from django.utils import timezone @@ -76,7 +76,9 @@ def setUp(self) -> None: max_download_size=500, ) - def _get_url(self, head_artifact_id=None, base_artifact_id=None): + def _get_url( + self, head_artifact_id: int | None = None, base_artifact_id: int | None = None + ) -> str: head_artifact_id = head_artifact_id or self.head_artifact.id base_artifact_id = base_artifact_id or self.base_artifact.id return reverse( @@ -401,7 +403,7 @@ def test_get_comparison_multiple_metrics(self) -> None: assert watch_comparison_data["comparison_id"] == watch_comparison.id @patch("sentry.preprod.size_analysis.tasks.manual_size_analysis_comparison.apply_async") - def test_post_comparison_success(self, mock_apply_async): + def test_post_comparison_success(self, mock_apply_async: MagicMock) -> None: """Test POST endpoint successfully triggers comparison and creates PENDING records""" response = self.get_success_response( self.organization.slug, @@ -567,7 +569,7 @@ def test_post_comparison_existing_comparison(self) -> None: @patch("sentry.preprod.size_analysis.tasks.manual_size_analysis_comparison.apply_async") def test_post_comparison_existing_failed_comparison_auto_retries( - self, mock_apply_async + self, mock_apply_async: MagicMock ) -> None: """Test POST endpoint auto-retries when all existing comparisons are failed""" self.create_preprod_artifact_size_comparison( @@ -612,7 +614,7 @@ def test_post_comparison_cannot_compare_size_metrics(self) -> None: assert "base has 1 metric(s)" in detail @patch("sentry.preprod.size_analysis.tasks.manual_size_analysis_comparison.apply_async") - def test_post_comparison_multiple_metrics(self, mock_apply_async): + def test_post_comparison_multiple_metrics(self, mock_apply_async: MagicMock) -> None: """Test POST endpoint handles multiple size metrics correctly and creates PENDING records""" # Create additional size metrics head_watch_metric = self.create_preprod_artifact_size_metrics( @@ -737,7 +739,7 @@ def test_post_comparison_different_build_configurations(self) -> None: @patch( "sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare.get_size_retention_cutoff" ) - def test_get_returns_404_for_expired_head_artifact(self, mock_cutoff): + def test_get_returns_404_for_expired_head_artifact(self, mock_cutoff: MagicMock) -> None: mock_cutoff.return_value = timezone.now() - timedelta(days=30) self.head_artifact.date_added = timezone.now() - timedelta(days=60) self.head_artifact.save() @@ -753,7 +755,7 @@ def test_get_returns_404_for_expired_head_artifact(self, mock_cutoff): @patch( "sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare.get_size_retention_cutoff" ) - def test_get_returns_404_for_expired_base_artifact(self, mock_cutoff): + def test_get_returns_404_for_expired_base_artifact(self, mock_cutoff: MagicMock) -> None: mock_cutoff.return_value = timezone.now() - timedelta(days=30) self.base_artifact.date_added = timezone.now() - timedelta(days=60) self.base_artifact.save() @@ -767,7 +769,7 @@ def test_get_returns_404_for_expired_base_artifact(self, mock_cutoff): assert response.data["detail"] == "This build's size data has expired." @patch("sentry.preprod.size_analysis.tasks.manual_size_analysis_comparison.apply_async") - def test_post_rerun_as_staff_deletes_and_recreates(self, mock_apply_async): + def test_post_rerun_as_staff_deletes_and_recreates(self, mock_apply_async: MagicMock) -> None: """Test POST with rerun=true as active staff deletes existing and creates new comparisons""" self.create_preprod_artifact_size_comparison( head_size_analysis=self.head_size_metric, @@ -794,7 +796,7 @@ def test_post_rerun_as_staff_deletes_and_recreates(self, mock_apply_async): assert data["comparisons"][0]["state"] == PreprodArtifactSizeComparison.State.PENDING mock_apply_async.assert_called_once() - def test_post_rerun_as_non_staff_returns_403(self): + def test_post_rerun_as_non_staff_returns_403(self) -> None: """Test POST with rerun=true as non-staff returns 403""" self.create_preprod_artifact_size_comparison( head_size_analysis=self.head_size_metric, @@ -809,7 +811,7 @@ def test_post_rerun_as_non_staff_returns_403(self): assert response.status_code == 403 assert response.json()["detail"] == "Only staff can rerun comparisons." - def test_post_rerun_as_inactive_staff_raises_staff_required(self): + def test_post_rerun_as_inactive_staff_raises_staff_required(self) -> None: """Test POST with rerun=true as is_staff user without active staff session raises StaffRequired""" self.create_preprod_artifact_size_comparison( head_size_analysis=self.head_size_metric, diff --git a/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_compare_download.py b/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_compare_download.py index e5f74e8efdb4..12a7bc487445 100644 --- a/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_compare_download.py +++ b/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_compare_download.py @@ -1,5 +1,5 @@ from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch from django.urls import reverse from django.utils import timezone @@ -61,11 +61,11 @@ def setUp(self) -> None: def _get_url( self, - organization_id_or_slug=None, - project_id_or_slug=None, - head_size_metric_id=None, - base_size_metric_id=None, - ): + organization_id_or_slug: str | None = None, + project_id_or_slug: str | None = None, + head_size_metric_id: int | None = None, + base_size_metric_id: int | None = None, + ) -> str: org = organization_id_or_slug or self.organization.slug proj = project_id_or_slug or self.project.slug head_id = head_size_metric_id or self.head_size_metrics.id @@ -155,7 +155,7 @@ def test_download_size_analysis_comparison_file_retrieval_failure(self) -> None: @patch( "sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare_download.get_size_retention_cutoff" ) - def test_returns_404_for_expired_head_artifact(self, mock_cutoff) -> None: + def test_returns_404_for_expired_head_artifact(self, mock_cutoff: MagicMock) -> None: mock_cutoff.return_value = timezone.now() - timedelta(days=30) self.head_artifact.date_added = timezone.now() - timedelta(days=60) self.head_artifact.save() @@ -176,7 +176,7 @@ def test_returns_404_for_expired_head_artifact(self, mock_cutoff) -> None: @patch( "sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare_download.get_size_retention_cutoff" ) - def test_returns_404_for_expired_base_artifact(self, mock_cutoff) -> None: + def test_returns_404_for_expired_base_artifact(self, mock_cutoff: MagicMock) -> None: mock_cutoff.return_value = timezone.now() - timedelta(days=30) self.base_artifact.date_added = timezone.now() - timedelta(days=60) self.base_artifact.save() diff --git a/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_comparisons.py b/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_comparisons.py index 02f1daad473f..70a8e2516ded 100644 --- a/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_comparisons.py +++ b/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_comparisons.py @@ -1,9 +1,11 @@ from datetime import timedelta -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch from django.urls import reverse from django.utils import timezone +from sentry.models.project import Project from sentry.preprod.models import ( PreprodArtifact, PreprodArtifactSizeComparison, @@ -40,7 +42,9 @@ def setUp(self) -> None: max_download_size=1000, ) - def _make_base(self, version, number, project=None): + def _make_base( + self, version: str, number: int, project: Project | None = None + ) -> tuple[PreprodArtifact, PreprodArtifactSizeMetrics]: base = self.create_preprod_artifact( project=project or self.project, file_id=self.create_file( @@ -62,7 +66,7 @@ def _make_base(self, version, number, project=None): ) return base, metric - def _watch_metric(self, artifact): + def _watch_metric(self, artifact: PreprodArtifact) -> PreprodArtifactSizeMetrics: return self.create_preprod_artifact_size_metrics( artifact, metrics_type=PreprodArtifactSizeMetrics.MetricsArtifactType.WATCH_ARTIFACT, @@ -73,8 +77,12 @@ def _watch_metric(self, artifact): ) def _compare( - self, head_metric, base_metric, file_id, state=PreprodArtifactSizeComparison.State.SUCCESS - ): + self, + head_metric: PreprodArtifactSizeMetrics, + base_metric: PreprodArtifactSizeMetrics, + file_id: int, + state: int = PreprodArtifactSizeComparison.State.SUCCESS, + ) -> PreprodArtifactSizeComparison: return self.create_preprod_artifact_size_comparison( head_size_analysis=head_metric, base_size_analysis=base_metric, @@ -83,7 +91,8 @@ def _compare( file_id=file_id, ) - def _token_request(self, token, artifact_id=None): + # justification: Django test client responses are untyped in stubs. + def _token_request(self, token: str, artifact_id: int | None = None) -> Any: url = reverse( self.endpoint, args=[self.organization.slug, artifact_id or self.head_artifact.id], @@ -119,7 +128,7 @@ def test_cross_org_head_returns_404(self) -> None: @patch( "sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_comparisons.get_size_retention_cutoff" ) - def test_returns_404_for_expired_head(self, mock_cutoff) -> None: + def test_returns_404_for_expired_head(self, mock_cutoff: MagicMock) -> None: mock_cutoff.return_value = timezone.now() - timedelta(days=30) self.head_artifact.date_added = timezone.now() - timedelta(days=60) self.head_artifact.save() diff --git a/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_download.py b/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_download.py index c75695343ff4..a4a64ee7dc0b 100644 --- a/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_download.py +++ b/tests/sentry/preprod/api/endpoints/size_analysis/test_project_preprod_size_analysis_download.py @@ -1,6 +1,6 @@ from datetime import timedelta from io import BytesIO -from unittest.mock import patch +from unittest.mock import MagicMock, patch from django.utils import timezone @@ -164,7 +164,7 @@ def test_completed_with_file_returns_200(self) -> None: @patch( "sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_download.get_size_retention_cutoff" ) - def test_returns_404_for_expired_artifact(self, mock_cutoff): + def test_returns_404_for_expired_artifact(self, mock_cutoff: MagicMock) -> None: mock_cutoff.return_value = timezone.now() - timedelta(days=30) self.artifact.date_added = timezone.now() - timedelta(days=60) self.artifact.save() diff --git a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_archive.py b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_archive.py index 72b7f8477dce..e686d6f42a9e 100644 --- a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_archive.py +++ b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_archive.py @@ -26,13 +26,13 @@ class BaseSnapshotArchiveTest(APITestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(user=self.user) self.org = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.org) - def _artifact(self): + def _artifact(self) -> PreprodArtifact: artifact = self.create_preprod_artifact( project=self.project, state=PreprodArtifact.ArtifactState.PROCESSED ) @@ -41,7 +41,7 @@ def _artifact(self): ) return artifact - def _url(self, snapshot_id, download=False): + def _url(self, snapshot_id: int, download: bool = False) -> str: url = reverse( "sentry-api-0-organization-preprod-snapshots-archive", args=[self.org.slug, snapshot_id], @@ -51,7 +51,7 @@ def _url(self, snapshot_id, download=False): class SnapshotArchiveTriggerTest(BaseSnapshotArchiveTest): @patch(ENQUEUE_TARGET) - def test_post_enqueues_build_and_returns_202(self, mock_task): + def test_post_enqueues_build_and_returns_202(self, mock_task: MagicMock) -> None: artifact = self._artifact() response = self.client.post(self._url(artifact.id)) assert response.status_code == 202 @@ -66,13 +66,13 @@ def test_post_enqueues_build_and_returns_202(self, mock_task): ) @patch(ENQUEUE_TARGET) - def test_post_returns_503_when_enqueue_fails(self, mock_task): + def test_post_returns_503_when_enqueue_fails(self, mock_task: MagicMock) -> None: artifact = self._artifact() mock_task.apply_async.side_effect = RuntimeError("broker down") response = self.client.post(self._url(artifact.id)) assert response.status_code == 503 - def test_rate_limit_applies_to_post_only(self): + def test_rate_limit_applies_to_post_only(self) -> None: overrides = OrganizationPreprodSnapshotArchiveEndpoint.rate_limits.limit_overrides assert isinstance(overrides, Mapping) assert "POST" in overrides @@ -82,7 +82,7 @@ def test_rate_limit_applies_to_post_only(self): class SnapshotArchiveReadinessTest(BaseSnapshotArchiveTest): @patch(SESSION_TARGET) - def test_get_reports_ready_when_archive_present(self, mock_session): + def test_get_reports_ready_when_archive_present(self, mock_session: MagicMock) -> None: artifact = self._artifact() session = MagicMock() session.get.return_value = MagicMock() @@ -93,7 +93,7 @@ def test_get_reports_ready_when_archive_present(self, mock_session): session.get.assert_called_once_with(f"snapshot_archives/{artifact.id}.zip") @patch(SESSION_TARGET) - def test_get_reports_not_ready_when_archive_absent(self, mock_session): + def test_get_reports_not_ready_when_archive_absent(self, mock_session: MagicMock) -> None: artifact = self._artifact() session = MagicMock() session.get.return_value = None @@ -103,7 +103,9 @@ def test_get_reports_not_ready_when_archive_absent(self, mock_session): assert response.data["ready"] is False @patch(SESSION_TARGET) - def test_get_reports_not_ready_on_transient_objectstore_error(self, mock_session): + def test_get_reports_not_ready_on_transient_objectstore_error( + self, mock_session: MagicMock + ) -> None: artifact = self._artifact() session = MagicMock() session.get.side_effect = RequestError("unavailable", status=503, response="") @@ -115,7 +117,7 @@ def test_get_reports_not_ready_on_transient_objectstore_error(self, mock_session class SnapshotArchiveDownloadTest(BaseSnapshotArchiveTest): @patch(SESSION_TARGET) - def test_download_streams_when_object_present(self, mock_session): + def test_download_streams_when_object_present(self, mock_session: MagicMock) -> None: artifact = self._artifact() result = MagicMock() result.payload.read.side_effect = [b"ZIPBYTES", b""] @@ -130,7 +132,7 @@ def test_download_streams_when_object_present(self, mock_session): session.get.assert_called_once_with(f"snapshot_archives/{artifact.id}.zip") @patch(SESSION_TARGET) - def test_download_returns_409_when_absent(self, mock_session): + def test_download_returns_409_when_absent(self, mock_session: MagicMock) -> None: artifact = self._artifact() session = MagicMock() session.get.return_value = None @@ -138,7 +140,7 @@ def test_download_returns_409_when_absent(self, mock_session): response = self.client.get(self._url(artifact.id, download=True)) assert response.status_code == 409 - def _ready_session(self, mock_session): + def _ready_session(self, mock_session: MagicMock) -> None: result = MagicMock() result.payload.read.side_effect = [b"ZIPBYTES", b""] result.metadata = MagicMock() @@ -148,7 +150,9 @@ def _ready_session(self, mock_session): @patch("sentry.analytics.record") @patch(SESSION_TARGET) - def test_download_records_web_client_analytics(self, mock_session, mock_record): + def test_download_records_web_client_analytics( + self, mock_session: MagicMock, mock_record: MagicMock + ) -> None: artifact = self._artifact() self._ready_session(mock_session) # setUp logged the user in via session cookies and sent no token, so the @@ -168,7 +172,9 @@ def test_download_records_web_client_analytics(self, mock_session, mock_record): @patch("sentry.analytics.record") @patch(SESSION_TARGET) - def test_download_records_sentry_cli_client_analytics(self, mock_session, mock_record): + def test_download_records_sentry_cli_client_analytics( + self, mock_session: MagicMock, mock_record: MagicMock + ) -> None: artifact = self._artifact() self._ready_session(mock_session) token = self.create_user_auth_token(user=self.user, scope_list=["project:read"]) @@ -193,7 +199,9 @@ def test_download_records_sentry_cli_client_analytics(self, mock_session, mock_r @patch("sentry.analytics.record") @patch(SESSION_TARGET) - def test_readiness_probe_records_no_download_event(self, mock_session, mock_record): + def test_readiness_probe_records_no_download_event( + self, mock_session: MagicMock, mock_record: MagicMock + ) -> None: artifact = self._artifact() session = MagicMock() session.get.return_value = MagicMock() @@ -207,7 +215,9 @@ def test_readiness_probe_records_no_download_event(self, mock_session, mock_reco @patch("sentry.analytics.record") @patch(ENQUEUE_TARGET) - def test_post_build_trigger_records_no_download_event(self, mock_task, mock_record): + def test_post_build_trigger_records_no_download_event( + self, mock_task: MagicMock, mock_record: MagicMock + ) -> None: artifact = self._artifact() response = self.client.post(self._url(artifact.id)) assert response.status_code == 202 diff --git a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_image_detail.py b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_image_detail.py index 1e629166f2c8..ddebc1739751 100644 --- a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_image_detail.py +++ b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_image_detail.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from unittest.mock import MagicMock, patch import orjson @@ -13,19 +14,21 @@ class OrganizationPreprodSnapshotImageDetailTest(APITestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(user=self.user) self.org = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.org) - def _get_url(self, snapshot_id, image_identifier): + def _get_url(self, snapshot_id: int, image_identifier: str) -> str: return reverse( "sentry-api-0-organization-preprod-snapshots-image-detail", args=[self.org.slug, snapshot_id, image_identifier], ) - def _create_artifact_with_manifest(self, images=None): + def _create_artifact_with_manifest( + self, images: Mapping[str, Mapping[str, object]] | None = None + ) -> tuple[PreprodArtifact, PreprodSnapshotMetrics, str, bytes]: if images is None: images = { "screen1.png": { @@ -49,8 +52,8 @@ def _create_artifact_with_manifest(self, images=None): manifest_json = orjson.dumps({"images": images}) return artifact, snapshot_metrics, manifest_key, manifest_json - def _create_mock_session(self, key_to_data): - def side_effect(key): + def _create_mock_session(self, key_to_data: dict[str, bytes]) -> MagicMock: + def side_effect(key: str) -> MagicMock: mock_result = MagicMock() mock_result.payload.read.return_value = key_to_data[key] return mock_result @@ -59,7 +62,13 @@ def side_effect(key): mock_session.get.side_effect = side_effect return mock_session - def _build_comparison_manifest(self, head_artifact, base_artifact, images, summary=None): + def _build_comparison_manifest( + self, + head_artifact: PreprodArtifact, + base_artifact: PreprodArtifact, + images: Mapping[str, Mapping[str, object]], + summary: dict[str, int] | None = None, + ) -> bytes: if summary is None: summary = { "total": len(images), @@ -82,11 +91,11 @@ def _build_comparison_manifest(self, head_artifact, base_artifact, images, summa def _create_comparison( self, - head_images, - base_images, - comparison_images, - summary=None, - ): + head_images: Mapping[str, Mapping[str, object]], + base_images: Mapping[str, Mapping[str, object]], + comparison_images: Mapping[str, Mapping[str, object]], + summary: dict[str, int] | None = None, + ) -> tuple[PreprodArtifact, PreprodArtifact, dict[str, bytes]]: head_artifact, head_metrics, head_manifest_key, head_manifest_json = ( self._create_artifact_with_manifest(head_images) ) @@ -111,7 +120,7 @@ def _create_comparison( return head_artifact, base_artifact, key_to_data @patch(MOCK_TARGET) - def test_solo_snapshot_returns_head_image(self, mock_get_session): + def test_solo_snapshot_returns_head_image(self, mock_get_session: MagicMock) -> None: images = { "components/alert.png": { "content_hash": "abc123", @@ -158,7 +167,9 @@ def test_solo_snapshot_returns_head_image(self, mock_get_session): @patch("sentry.analytics.record") @patch(MOCK_TARGET) - def test_records_web_client_analytics(self, mock_get_session, mock_record): + def test_records_web_client_analytics( + self, mock_get_session: MagicMock, mock_record: MagicMock + ) -> None: images = { "components/alert.png": { "content_hash": "abc123", @@ -187,7 +198,9 @@ def test_records_web_client_analytics(self, mock_get_session, mock_record): @patch("sentry.analytics.record") @patch(MOCK_TARGET) - def test_records_mcp_client_analytics(self, mock_get_session, mock_record): + def test_records_mcp_client_analytics( + self, mock_get_session: MagicMock, mock_record: MagicMock + ) -> None: images = { "components/alert.png": { "content_hash": "abc123", @@ -219,7 +232,7 @@ def test_records_mcp_client_analytics(self, mock_get_session, mock_record): ) @patch(MOCK_TARGET) - def test_changed_image_returns_full_comparison(self, mock_get_session): + def test_changed_image_returns_full_comparison(self, mock_get_session: MagicMock) -> None: head_images = { "alert.png": { "content_hash": "head_hash", @@ -296,7 +309,9 @@ def test_changed_image_returns_full_comparison(self, mock_get_session): assert data["diff_percentage"] == 0.01 @patch(MOCK_TARGET) - def test_changed_image_missing_from_selective_base_manifest(self, mock_get_session): + def test_changed_image_missing_from_selective_base_manifest( + self, mock_get_session: MagicMock + ) -> None: head_images = { "alert.png": { "content_hash": "head_hash", @@ -330,7 +345,9 @@ def test_changed_image_missing_from_selective_base_manifest(self, mock_get_sessi assert "/files/images/inherited_base_hash/" in base["image_url"] @patch(MOCK_TARGET) - def test_unchanged_image_missing_from_selective_base_manifest(self, mock_get_session): + def test_unchanged_image_missing_from_selective_base_manifest( + self, mock_get_session: MagicMock + ) -> None: head_images = { "alert.png": { "content_hash": "same_hash", @@ -355,7 +372,7 @@ def test_unchanged_image_missing_from_selective_base_manifest(self, mock_get_ses assert response.data["base_image"] == response.data["head_image"] @patch(MOCK_TARGET) - def test_added_image(self, mock_get_session): + def test_added_image(self, mock_get_session: MagicMock) -> None: head_images = { "new_screen.png": { "content_hash": "head_hash", @@ -364,7 +381,7 @@ def test_added_image(self, mock_get_session): "height": 812, }, } - base_images: dict = {} + base_images: Mapping[str, Mapping[str, object]] = {} comparison_images = { "new_screen.png": { "status": "added", @@ -400,8 +417,8 @@ def test_added_image(self, mock_get_session): assert data["diff_image_url"] is None @patch(MOCK_TARGET) - def test_removed_image(self, mock_get_session): - head_images: dict = {} + def test_removed_image(self, mock_get_session: MagicMock) -> None: + head_images: Mapping[str, Mapping[str, object]] = {} base_images = { "old_screen.png": { "content_hash": "base_hash", @@ -446,7 +463,7 @@ def test_removed_image(self, mock_get_session): assert "/files/images/base_hash/" in data["base_image"]["image_url"] @patch(MOCK_TARGET) - def test_renamed_image(self, mock_get_session): + def test_renamed_image(self, mock_get_session: MagicMock) -> None: head_images = { "new_alert.png": { "content_hash": "shared_hash", @@ -499,7 +516,7 @@ def test_renamed_image(self, mock_get_session): assert data["previous_image_file_name"] == "old_alert.png" @patch(MOCK_TARGET) - def test_unchanged_image(self, mock_get_session): + def test_unchanged_image(self, mock_get_session: MagicMock) -> None: images = { "stable.png": { "content_hash": "same_hash", @@ -542,8 +559,8 @@ def test_unchanged_image(self, mock_get_session): assert data["base_image"] is not None @patch(MOCK_TARGET) - def test_skipped_image(self, mock_get_session): - head_images: dict = {} + def test_skipped_image(self, mock_get_session: MagicMock) -> None: + head_images: Mapping[str, Mapping[str, object]] = {} base_images = { "skipped_screen.png": { "content_hash": "base_hash", @@ -592,7 +609,7 @@ def test_skipped_image(self, mock_get_session): assert data["diff_percentage"] is None @patch(MOCK_TARGET) - def test_lookup_by_content_hash(self, mock_get_session): + def test_lookup_by_content_hash(self, mock_get_session: MagicMock) -> None: images = { "components/alert.png": { "content_hash": "abc123", @@ -614,7 +631,7 @@ def test_lookup_by_content_hash(self, mock_get_session): assert data["head_image"]["key"] == "abc123" @patch(MOCK_TARGET) - def test_extra_metadata_fields_passed_through(self, mock_get_session): + def test_extra_metadata_fields_passed_through(self, mock_get_session: MagicMock) -> None: images = { "screen.png": { "content_hash": "hash1", @@ -638,7 +655,7 @@ def test_extra_metadata_fields_passed_through(self, mock_get_session): assert head["custom_field"] == "value" @patch(MOCK_TARGET) - def test_canvas_theme_passed_through(self, mock_get_session): + def test_canvas_theme_passed_through(self, mock_get_session: MagicMock) -> None: images = { "screen.png": { "content_hash": "hash1", @@ -660,7 +677,7 @@ def test_canvas_theme_passed_through(self, mock_get_session): assert head["canvas_theme"] == "dark" @patch(MOCK_TARGET) - def test_canvas_theme_invalid_value_coerced_to_null(self, mock_get_session): + def test_canvas_theme_invalid_value_coerced_to_null(self, mock_get_session: MagicMock) -> None: images = { "screen.png": { "content_hash": "hash1", @@ -682,7 +699,7 @@ def test_canvas_theme_invalid_value_coerced_to_null(self, mock_get_session): assert head["canvas_theme"] is None @patch(MOCK_TARGET) - def test_missing_manifest_returns_404(self, mock_get_session): + def test_missing_manifest_returns_404(self, mock_get_session: MagicMock) -> None: artifact, _, _, _ = self._create_artifact_with_manifest() mock_session = MagicMock() mock_session.get.return_value = None diff --git a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_latest_base.py b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_latest_base.py index 8de8d151d0ee..3965649c55dc 100644 --- a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_latest_base.py +++ b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_latest_base.py @@ -13,19 +13,21 @@ class OrganizationPreprodLatestBaseSnapshotTest(APITestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(user=self.user) self.org = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.org) - def _get_url(self): + def _get_url(self) -> str: return reverse( "sentry-api-0-organization-preprod-snapshots-latest-base", args=[self.org.slug], ) - def _create_base_artifact(self, app_id="com.example.app"): + def _create_base_artifact( + self, app_id: str = "com.example.app" + ) -> tuple[PreprodArtifact, str, bytes]: """Create a base snapshot (no commit_comparison) with a manifest.""" artifact = PreprodArtifact.objects.create( project=self.project, @@ -53,7 +55,7 @@ def _create_base_artifact(self, app_id="com.example.app"): ) return artifact, manifest_key, manifest_json - def _create_mock_session(self, manifest_json): + def _create_mock_session(self, manifest_json: bytes) -> MagicMock: mock_result = MagicMock() mock_result.payload.read.return_value = manifest_json mock_session = MagicMock() @@ -61,7 +63,7 @@ def _create_mock_session(self, manifest_json): return mock_session @patch(MOCK_TARGET) - def test_returns_canvas_theme(self, mock_get_session): + def test_returns_canvas_theme(self, mock_get_session: MagicMock) -> None: _, _, manifest_json = self._create_base_artifact() mock_get_session.return_value = self._create_mock_session(manifest_json) @@ -74,7 +76,9 @@ def test_returns_canvas_theme(self, mock_get_session): @patch("sentry.analytics.record") @patch(MOCK_TARGET) - def test_records_web_client_analytics(self, mock_get_session, mock_record): + def test_records_web_client_analytics( + self, mock_get_session: MagicMock, mock_record: MagicMock + ) -> None: artifact, _, manifest_json = self._create_base_artifact() mock_get_session.return_value = self._create_mock_session(manifest_json) @@ -95,7 +99,9 @@ def test_records_web_client_analytics(self, mock_get_session, mock_record): @patch("sentry.analytics.record") @patch(MOCK_TARGET) - def test_records_mcp_client_analytics(self, mock_get_session, mock_record): + def test_records_mcp_client_analytics( + self, mock_get_session: MagicMock, mock_record: MagicMock + ) -> None: artifact, _, manifest_json = self._create_base_artifact() mock_get_session.return_value = self._create_mock_session(manifest_json) @@ -120,7 +126,7 @@ def test_records_mcp_client_analytics(self, mock_get_session, mock_record): ) @patch(MOCK_TARGET) - def test_missing_manifest_returns_404(self, mock_get_session): + def test_missing_manifest_returns_404(self, mock_get_session: MagicMock) -> None: self._create_base_artifact() mock_session = MagicMock() mock_session.get.return_value = None @@ -132,7 +138,7 @@ def test_missing_manifest_returns_404(self, mock_get_session): assert response.data["detail"] == "No snapshot found" @patch(MOCK_TARGET) - def test_objectstore_error_returns_500(self, mock_get_session): + def test_objectstore_error_returns_500(self, mock_get_session: MagicMock) -> None: self._create_base_artifact() mock_session = MagicMock() mock_session.get.side_effect = Exception("Storage error") diff --git a/tests/sentry/preprod/api/endpoints/test_builds.py b/tests/sentry/preprod/api/endpoints/test_builds.py index ae85a1b51ba5..496ed790072f 100644 --- a/tests/sentry/preprod/api/endpoints/test_builds.py +++ b/tests/sentry/preprod/api/endpoints/test_builds.py @@ -1,4 +1,5 @@ -from unittest.mock import ANY, patch +from typing import Any +from unittest.mock import ANY, MagicMock, patch import pytest from django.urls import reverse @@ -15,13 +16,14 @@ class BuildsEndpointTest(APITestCase): @cached_property - def user_auth_token(self): + def user_auth_token(self) -> str: auth_token = self.create_user_auth_token( self.user, scope_list=["org:admin", "project:admin"] ) return auth_token.token - def _request(self, query, token=None): + # justification: Django test client responses are untyped in stubs. + def _request(self, query: dict[str, object], token: str | None = None) -> Any: token = self.user_auth_token if token is None else token url = reverse( "sentry-api-0-organization-builds", @@ -30,7 +32,8 @@ def _request(self, query, token=None): ) return self.client.get(url, format="json", HTTP_AUTHORIZATION=f"Bearer {token}") - def _assert_is_successful(self, response): + # justification: Django test client responses are untyped in stubs. + def _assert_is_successful(self, response: Any) -> None: assert response.status_code == 200, f"status {response.status_code} body {response.json()}" def test_invalid_token(self) -> None: @@ -865,7 +868,7 @@ def test_distribution_error_code_invalid_values(self) -> None: assert self._request({"query": "distribution_error_code:bogus"}).status_code == 400 @patch("sentry.preprod.builds_query.get_size_retention_cutoff") - def test_excludes_expired_artifacts(self, mock_cutoff) -> None: + def test_excludes_expired_artifacts(self, mock_cutoff: MagicMock) -> None: mock_cutoff.return_value = before_now(days=30) self.create_preprod_artifact(app_id="recent.app", date_added=before_now(days=10)) self.create_preprod_artifact(app_id="expired.app", date_added=before_now(days=60)) diff --git a/tests/sentry/preprod/api/endpoints/test_builds_export.py b/tests/sentry/preprod/api/endpoints/test_builds_export.py index ce6a7fbd0644..ba59d3944be0 100644 --- a/tests/sentry/preprod/api/endpoints/test_builds_export.py +++ b/tests/sentry/preprod/api/endpoints/test_builds_export.py @@ -1,6 +1,7 @@ import csv import io -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch from django.urls import reverse from django.utils.functional import cached_property @@ -31,13 +32,14 @@ def _col(row: list[str], name: str) -> str: class BuildsExportEndpointTest(APITestCase): @cached_property - def user_auth_token(self): + def user_auth_token(self) -> str: auth_token = self.create_user_auth_token( self.user, scope_list=["org:admin", "project:admin"] ) return auth_token.token - def _request(self, query, token=None): + # justification: Django test client responses are untyped in stubs. + def _request(self, query: dict[str, object], token: str | None = None) -> Any: token = self.user_auth_token if token is None else token url = reverse( "sentry-api-0-organization-builds-export", @@ -46,7 +48,8 @@ def _request(self, query, token=None): ) return self.client.get(url, HTTP_AUTHORIZATION=f"Bearer {token}") - def _csv_rows(self, response) -> list[list[str]]: + # justification: Django test client responses are untyped in stubs. + def _csv_rows(self, response: Any) -> list[list[str]]: assert response.status_code == 200, ( f"status {response.status_code} body {response.getvalue()!r}" ) @@ -54,7 +57,8 @@ def _csv_rows(self, response) -> list[list[str]]: content = b"".join(response.streaming_content).decode("utf-8") return list(csv.reader(io.StringIO(content))) - def _create_installable_build(self, **kwargs) -> PreprodArtifact: + # justification: factory kwargs vary per test case. + def _create_installable_build(self, **kwargs: Any) -> PreprodArtifact: # Installable per is_installable_artifact(): an installable file (default APK, # so no iOS signature check applies). Tests that want a non-installable build # call create_preprod_artifact directly. @@ -275,7 +279,7 @@ def test_start_end_respected(self) -> None: assert _col(rows[1], "artifact_id") == str(middle.id) @patch("sentry.preprod.builds_query.get_size_retention_cutoff") - def test_excludes_expired_artifacts(self, mock_cutoff) -> None: + def test_excludes_expired_artifacts(self, mock_cutoff: MagicMock) -> None: mock_cutoff.return_value = before_now(days=30) self._create_installable_build(app_id="recent.app", date_added=before_now(days=10)) self._create_installable_build(app_id="expired.app", date_added=before_now(days=60)) diff --git a/tests/sentry/preprod/api/endpoints/test_organization_preprod_retention.py b/tests/sentry/preprod/api/endpoints/test_organization_preprod_retention.py index 96d53cd71a57..5444afc2c0f7 100644 --- a/tests/sentry/preprod/api/endpoints/test_organization_preprod_retention.py +++ b/tests/sentry/preprod/api/endpoints/test_organization_preprod_retention.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import MagicMock, patch from django.urls import reverse from rest_framework.test import APIClient @@ -27,7 +27,7 @@ def test_get_default_retention(self) -> None: assert response.data["buildDistribution"] == 90 @patch("sentry.quotas.backend.get_event_retention") - def test_get_custom_retention(self, mock_get_retention) -> None: + def test_get_custom_retention(self, mock_get_retention: MagicMock) -> None: mock_get_retention.side_effect = lambda organization, category: { DataCategory.SIZE_ANALYSIS: 30, DataCategory.INSTALLABLE_BUILD: 60, diff --git a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_approve.py b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_approve.py index 459a9f71a794..6c5819edc87f 100644 --- a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_approve.py +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_approve.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from django.urls import reverse @@ -30,7 +30,7 @@ def setUp(self) -> None: user=self.outsider, organization=self.org, role="member", teams=[self.team_a] ) - def _approve_url(self, artifact_id): + def _approve_url(self, artifact_id: int) -> str: return reverse( "sentry-api-0-organization-preprod-artifact-approve", args=[self.org.slug, artifact_id], @@ -51,7 +51,9 @@ def test_approve_returns_404_for_member_without_project_access( @patch("sentry.preprod.api.endpoints.preprod_artifact_approve.create_preprod_status_check_task") @patch("sentry.analytics.record") - def test_approve_size_records_analytics(self, mock_analytics, mock_task) -> None: + def test_approve_size_records_analytics( + self, mock_analytics: MagicMock, mock_task: MagicMock + ) -> None: self.login_as(user=self.owner) response = self.client.post( @@ -74,7 +76,9 @@ def test_approve_size_records_analytics(self, mock_analytics, mock_task) -> None @patch("sentry.preprod.api.endpoints.preprod_artifact_approve.update_preprod_snapshot_vcs") @patch("sentry.analytics.record") - def test_approve_snapshots_records_analytics(self, mock_analytics, mock_task) -> None: + def test_approve_snapshots_records_analytics( + self, mock_analytics: MagicMock, mock_task: MagicMock + ) -> None: self.login_as(user=self.owner) response = self.client.post( @@ -97,7 +101,9 @@ def test_approve_snapshots_records_analytics(self, mock_analytics, mock_task) -> @patch("sentry.preprod.api.endpoints.preprod_artifact_approve.create_preprod_status_check_task") @patch("sentry.analytics.record") - def test_already_approved_does_not_record_analytics(self, mock_analytics, mock_task) -> None: + def test_already_approved_does_not_record_analytics( + self, mock_analytics: MagicMock, mock_task: MagicMock + ) -> None: self.login_as(user=self.owner) self.create_preprod_comparison_approval( preprod_artifact=self.artifact, @@ -120,7 +126,9 @@ def test_already_approved_does_not_record_analytics(self, mock_analytics, mock_t @patch("sentry.preprod.api.endpoints.preprod_artifact_approve.update_preprod_snapshot_vcs") @patch("sentry.analytics.record") - def test_already_approved_snapshots_resyncs_vcs(self, mock_analytics, mock_update_vcs) -> None: + def test_already_approved_snapshots_resyncs_vcs( + self, mock_analytics: MagicMock, mock_update_vcs: MagicMock + ) -> None: self.login_as(user=self.owner) self.create_preprod_comparison_approval( preprod_artifact=self.artifact, @@ -149,7 +157,9 @@ def test_already_approved_snapshots_resyncs_vcs(self, mock_analytics, mock_updat ) @patch("sentry.analytics.record") - def test_invalid_feature_type_does_not_record_analytics(self, mock_analytics) -> None: + def test_invalid_feature_type_does_not_record_analytics( + self, mock_analytics: MagicMock + ) -> None: self.login_as(user=self.owner) response = self.client.post( diff --git a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_image.py b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_image.py index 3d7d7491c9f2..7595f221df61 100644 --- a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_image.py +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_image.py @@ -20,14 +20,14 @@ def setUp(self) -> None: self.image_id = "test-image-123" self.base_path = f"/api/0/{self.org.slug}/{self.project.slug}/files/images/{self.image_id}/" - def _get_url(self, image_id=None): + def _get_url(self, image_id: str | None = None) -> str: image_id = image_id or self.image_id return reverse( "sentry-api-0-project-preprod-artifact-image", args=[self.org.slug, self.project.slug, image_id], ) - def _create_mock_session(self, image_data, content_type): + def _create_mock_session(self, image_data: bytes, content_type: str) -> MagicMock: """Create a mock object store session that returns the given data and content type.""" mock_result = MagicMock() mock_result.payload.read.return_value = image_data @@ -41,7 +41,7 @@ def _create_mock_session(self, image_data, content_type): @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.logger") @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") def test_explicit_app_icon_type_does_not_require_prefix( - self, mock_get_session, mock_logger + self, mock_get_session: MagicMock, mock_logger: MagicMock ) -> None: image_id = "opaque-icon-id" icon_data = b"app icon" @@ -57,7 +57,9 @@ def test_explicit_app_icon_type_does_not_require_prefix( mock_logger.info.assert_not_called() @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_explicit_snapshot_type_uses_shared_fallback(self, mock_get_snapshot_storage) -> None: + def test_explicit_snapshot_type_uses_shared_fallback( + self, mock_get_snapshot_storage: MagicMock + ) -> None: image_id = "opaque-snapshot-id" object_key = f"{self.org.id}/{self.project.id}/{image_id}" image_data = b"snapshot image" @@ -76,7 +78,9 @@ def test_explicit_snapshot_type_uses_shared_fallback(self, mock_get_snapshot_sto @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") - def test_rejects_unknown_image_type(self, mock_get_session, mock_get_snapshot_storage) -> None: + def test_rejects_unknown_image_type( + self, mock_get_session: MagicMock, mock_get_snapshot_storage: MagicMock + ) -> None: response = self.client.get(self._get_url("opaque-image-id"), {"image_type": "attachments"}) assert response.status_code == 400 @@ -85,7 +89,9 @@ def test_rejects_unknown_image_type(self, mock_get_session, mock_get_snapshot_st mock_get_snapshot_storage.assert_not_called() @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_omitted_image_type_ignores_icon_prefix(self, mock_get_snapshot_storage) -> None: + def test_omitted_image_type_ignores_icon_prefix( + self, mock_get_snapshot_storage: MagicMock + ) -> None: image_id = "icn_123456789abc" icon_data = b"app icon" session = self._create_mock_session(icon_data, "image/png") @@ -101,7 +107,9 @@ def test_omitted_image_type_ignores_icon_prefix(self, mock_get_snapshot_storage) @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.logger") @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") - def test_app_icon_falls_back_to_preprod(self, mock_get_session, mock_logger) -> None: + def test_app_icon_falls_back_to_preprod( + self, mock_get_session: MagicMock, mock_logger: MagicMock + ) -> None: image_id = "opaque-icon-id" object_key = f"{self.org.id}/{self.project.id}/{image_id}" icon_data = b"legacy app icon" @@ -127,7 +135,9 @@ def test_app_icon_falls_back_to_preprod(self, mock_get_session, mock_logger) -> @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.logger") @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") - def test_app_icon_missing_from_both_usecases(self, mock_get_session, mock_logger) -> None: + def test_app_icon_missing_from_both_usecases( + self, mock_get_session: MagicMock, mock_logger: MagicMock + ) -> None: session = MagicMock() session.get.return_value = None mock_get_session.return_value = session @@ -149,7 +159,9 @@ def test_app_icon_missing_from_both_usecases(self, mock_get_session, mock_logger @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.logger") @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_session") - def test_app_icon_storage_error_does_not_fall_back(self, mock_get_session, mock_logger) -> None: + def test_app_icon_storage_error_does_not_fall_back( + self, mock_get_session: MagicMock, mock_logger: MagicMock + ) -> None: mock_get_session.return_value.get.side_effect = RequestError("unavailable", 503, "") response = self.client.get( @@ -161,7 +173,7 @@ def test_app_icon_storage_error_does_not_fall_back(self, mock_get_session, mock_ mock_logger.info.assert_not_called() @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_successful_image_retrieval_png(self, mock_get_session): + def test_successful_image_retrieval_png(self, mock_get_session: MagicMock) -> None: png_data = b"\x89PNG\r\n\x1a\n" + b"fake png content" * 100 mock_session = self._create_mock_session(png_data, "image/png") mock_get_session.return_value = mock_session @@ -178,7 +190,7 @@ def test_successful_image_retrieval_png(self, mock_get_session): mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}") @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_successful_image_retrieval_jpeg(self, mock_get_session): + def test_successful_image_retrieval_jpeg(self, mock_get_session: MagicMock) -> None: jpeg_data = b"\xff\xd8\xff" + b"fake jpeg content" * 100 mock_session = self._create_mock_session(jpeg_data, "image/jpeg") mock_get_session.return_value = mock_session @@ -195,7 +207,7 @@ def test_successful_image_retrieval_jpeg(self, mock_get_session): mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}") @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_successful_image_retrieval_webp(self, mock_get_session): + def test_successful_image_retrieval_webp(self, mock_get_session: MagicMock) -> None: webp_data = b"RIFF" + b"1234" + b"WEBP" + b"fake webp content" * 100 mock_session = self._create_mock_session(webp_data, "image/webp") mock_get_session.return_value = mock_session @@ -212,7 +224,7 @@ def test_successful_image_retrieval_webp(self, mock_get_session): mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}") @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_unknown_image_format(self, mock_get_session): + def test_unknown_image_format(self, mock_get_session: MagicMock) -> None: unknown_data = b"unknown binary data" * 50 mock_session = self._create_mock_session(unknown_data, "application/octet-stream") mock_get_session.return_value = mock_session @@ -229,7 +241,7 @@ def test_unknown_image_format(self, mock_get_session): mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}") @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_content_disposition_with_filename(self, mock_get_session): + def test_content_disposition_with_filename(self, mock_get_session: MagicMock) -> None: mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -245,7 +257,9 @@ def test_content_disposition_with_filename(self, mock_get_session): assert response["Content-Disposition"] == 'inline; filename="alert-dark-danger-no-icon.png"' @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_no_content_disposition_without_filename_param(self, mock_get_session): + def test_no_content_disposition_without_filename_param( + self, mock_get_session: MagicMock + ) -> None: mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -258,7 +272,7 @@ def test_no_content_disposition_without_filename_param(self, mock_get_session): assert not response.has_header("Content-Disposition") @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_content_disposition_strips_path_traversal(self, mock_get_session): + def test_content_disposition_strips_path_traversal(self, mock_get_session: MagicMock) -> None: mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -274,7 +288,9 @@ def test_content_disposition_strips_path_traversal(self, mock_get_session): assert response["Content-Disposition"] == 'inline; filename="alert-dark-danger-no-icon.png"' @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_content_disposition_strips_parent_dir_traversal(self, mock_get_session): + def test_content_disposition_strips_parent_dir_traversal( + self, mock_get_session: MagicMock + ) -> None: mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -290,7 +306,7 @@ def test_content_disposition_strips_parent_dir_traversal(self, mock_get_session) assert response["Content-Disposition"] == 'inline; filename="passwd"' @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_content_disposition_strips_header_injection(self, mock_get_session): + def test_content_disposition_strips_header_injection(self, mock_get_session: MagicMock) -> None: mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -309,7 +325,7 @@ def test_content_disposition_strips_header_injection(self, mock_get_session): assert not response.has_header("Set-Cookie") @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_content_disposition_strips_quotes(self, mock_get_session): + def test_content_disposition_strips_quotes(self, mock_get_session: MagicMock) -> None: mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -325,7 +341,9 @@ def test_content_disposition_strips_quotes(self, mock_get_session): assert response["Content-Disposition"] == 'inline; filename="foo.png"' @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_no_content_disposition_when_filename_empties_out(self, mock_get_session): + def test_no_content_disposition_when_filename_empties_out( + self, mock_get_session: MagicMock + ) -> None: mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -341,7 +359,7 @@ def test_no_content_disposition_when_filename_empties_out(self, mock_get_session assert not response.has_header("Content-Disposition") @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_content_disposition_non_ascii_filename(self, mock_get_session): + def test_content_disposition_non_ascii_filename(self, mock_get_session: MagicMock) -> None: mock_session = self._create_mock_session(b"\x89PNG\r\n\x1a\n", "image/png") mock_get_session.return_value = mock_session @@ -372,7 +390,7 @@ def test_endpoint_requires_project_access(self) -> None: assert response.status_code == 403 @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_objectstore_404_returns_404(self, mock_get_session): + def test_objectstore_404_returns_404(self, mock_get_session: MagicMock) -> None: mock_session = MagicMock() mock_session.get.return_value = None mock_get_session.return_value = mock_session @@ -386,7 +404,7 @@ def test_objectstore_404_returns_404(self, mock_get_session): assert response.json() == {"detail": "Image not found"} @patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_snapshot_storage") - def test_error_handling_returns_json(self, mock_get_session): + def test_error_handling_returns_json(self, mock_get_session: MagicMock) -> None: mock_session = MagicMock() mock_session.get.side_effect = Exception("Storage error") mock_get_session.return_value = mock_session diff --git a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_rerun_analysis.py b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_rerun_analysis.py index 72b825b96a90..fd5d87a9b00e 100644 --- a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_rerun_analysis.py +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_rerun_analysis.py @@ -1,4 +1,5 @@ -from unittest.mock import patch +from typing import Any, cast +from unittest.mock import MagicMock, patch from sentry.models.files.file import File from sentry.preprod.models import ( @@ -18,7 +19,9 @@ def setUp(self) -> None: self.project = self.create_project(organization=self.organization) self.login_as(user=self.user) - def create_artifact_with_metrics(self): + def create_artifact_with_metrics( + self, + ) -> tuple[PreprodArtifact, File, list[File], PreprodArtifactSizeComparison]: """Creates an artifact with size metrics and comparisons""" main_file = File.objects.create(name="test_artifact.zip", type="application/zip") artifact = self.create_preprod_artifact( @@ -56,7 +59,12 @@ def create_artifact_with_metrics(self): return artifact, main_file, [analysis_file_1, analysis_file_2, comparison_file], comparison - def assert_metrics_cleaned_up(self, artifact, analysis_files, comparison): + def assert_metrics_cleaned_up( + self, + artifact: PreprodArtifact, + analysis_files: list[File], + comparison: PreprodArtifactSizeComparison, + ) -> None: """Asserts that old metrics/comparisons are deleted and new PENDING metric created""" assert PreprodArtifactSizeMetrics.objects.filter(preprod_artifact=artifact).count() == 1 new_metric = PreprodArtifactSizeMetrics.objects.get(preprod_artifact=artifact) @@ -65,19 +73,19 @@ def assert_metrics_cleaned_up(self, artifact, analysis_files, comparison): new_metric.metrics_artifact_type == PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT ) - assert new_metric.analysis_file_id is None + assert cast("object", new_metric.analysis_file_id) is None assert not PreprodArtifactSizeComparison.objects.filter(id=comparison.id).exists() for analysis_file in analysis_files: assert not File.objects.filter(id=analysis_file.id).exists() - def assert_artifact_reset(self, artifact): + def assert_artifact_reset(self, artifact: PreprodArtifact) -> None: """Asserts that artifact state is reset""" artifact.refresh_from_db() assert artifact.state == PreprodArtifact.ArtifactState.UPLOADED - assert artifact.error_code is None - assert artifact.error_message is None - assert artifact.installable_app_error_code is None + assert cast("object", artifact.error_code) is None + assert cast("object", artifact.error_message) is None + assert cast("object", artifact.installable_app_error_code) is None assert artifact.installable_app_error_message is None @@ -168,7 +176,9 @@ def test_rerun_analysis_clears_distribution_error_fields(self) -> None: self.assert_artifact_reset(artifact) @patch("sentry.preprod.api.endpoints.preprod_artifact_rerun_analysis.cleanup_old_metrics") - def test_rerun_analysis_skips_cleanup_when_size_query_excludes_artifact(self, mock_cleanup): + def test_rerun_analysis_skips_cleanup_when_size_query_excludes_artifact( + self, mock_cleanup: MagicMock + ) -> None: from sentry.preprod.quotas import SIZE_ENABLED_QUERY_KEY artifact = self.create_preprod_artifact( @@ -187,7 +197,9 @@ def test_rerun_analysis_skips_cleanup_when_size_query_excludes_artifact(self, mo mock_cleanup.assert_not_called() @patch("sentry.preprod.api.endpoints.preprod_artifact_rerun_analysis.cleanup_old_metrics") - def test_rerun_analysis_runs_cleanup_when_size_query_matches(self, mock_cleanup): + def test_rerun_analysis_runs_cleanup_when_size_query_matches( + self, mock_cleanup: MagicMock + ) -> None: from sentry.preprod.quotas import SIZE_ENABLED_QUERY_KEY artifact = self.create_preprod_artifact( @@ -206,7 +218,7 @@ def test_rerun_analysis_runs_cleanup_when_size_query_matches(self, mock_cleanup) mock_cleanup.assert_called_once() @patch("sentry.preprod.api.endpoints.preprod_artifact_rerun_analysis.cleanup_old_metrics") - def test_rerun_analysis_runs_cleanup_when_no_query_set(self, mock_cleanup): + def test_rerun_analysis_runs_cleanup_when_no_query_set(self, mock_cleanup: MagicMock) -> None: artifact = self.create_preprod_artifact( project=self.project, app_name="MyApp", @@ -221,7 +233,7 @@ def test_rerun_analysis_runs_cleanup_when_no_query_set(self, mock_cleanup): mock_cleanup.assert_called_once() @patch("sentry.preprod.api.endpoints.preprod_artifact_rerun_analysis.cleanup_old_metrics") - def test_rerun_analysis_runs_cleanup_on_invalid_query(self, mock_cleanup): + def test_rerun_analysis_runs_cleanup_on_invalid_query(self, mock_cleanup: MagicMock) -> None: from sentry.preprod.quotas import SIZE_ENABLED_QUERY_KEY artifact = self.create_preprod_artifact( @@ -251,7 +263,8 @@ def setUp(self) -> None: self.project = self.create_project(organization=self.organization) self.login_as(user=self.user) - def get_response(self, *args, **params): + # justification: base get_response is untyped and forwards arbitrary API arguments. + def get_response(self, *args: Any, **params: Any) -> Any: with patch("sentry.api.permissions.is_active_staff", return_value=True): return super().get_response(*args, **params) diff --git a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py index 39b33261c6f9..9c60cc7f6b4f 100644 --- a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py @@ -1,4 +1,6 @@ import os +from collections.abc import Mapping +from typing import Any from unittest.mock import MagicMock, patch import orjson @@ -8,6 +10,7 @@ from sentry.constants import DataCategory from sentry.models.commitcomparison import CommitComparison +from sentry.models.project import Project from sentry.preprod.analytics import PreprodArtifactApiGetSnapshotDetailsEvent from sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot import ( validate_preprod_snapshot_post_schema, @@ -87,13 +90,13 @@ def setUp(self) -> None: self.org = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.org) - def _get_create_url(self): + def _get_create_url(self) -> str: return reverse( "sentry-api-0-project-preprod-snapshots-create", args=[self.org.slug, self.project.slug], ) - def _get_detail_url(self, snapshot_id): + def _get_detail_url(self, snapshot_id: int) -> str: return reverse( "sentry-api-0-project-preprod-snapshots-detail", args=[self.org.slug, snapshot_id], @@ -548,7 +551,8 @@ def test_snapshot_invalid_sha_format(self) -> None: assert response.status_code == 400 - def _selective_data(self, **overrides): + # justification: request payload overrides vary per test case. + def _selective_data(self, **overrides: Any) -> dict[str, Any]: data = { "app_id": "com.test.app", "images": {"screen.png": {"content_hash": "screen", "width": 100, "height": 200}}, @@ -563,32 +567,33 @@ def _selective_data(self, **overrides): data.update(overrides) return data - def _post_selective(self, **overrides): + # justification: Django test client responses are untyped in stubs. + def _post_selective(self, **overrides: Any) -> Any: return self.client.post( self._get_create_url(), self._selective_data(**overrides), format="json" ) - def test_all_image_file_names_rejects_empty_list(self): + def test_all_image_file_names_rejects_empty_list(self) -> None: response = self._post_selective(images={}, all_image_file_names=[]) assert response.status_code == 400 assert "empty" in response.data["detail"] - def test_selective_requires_base_sha(self): + def test_selective_requires_base_sha(self) -> None: response = self._post_selective(base_sha=None) assert response.status_code == 400 assert "base_sha" in response.data["detail"] - def test_all_image_file_names_must_contain_all_images(self): + def test_all_image_file_names_must_contain_all_images(self) -> None: response = self._post_selective(all_image_file_names=["other.png"]) assert response.status_code == 400 assert "all_image_file_names" in response.data["detail"] - def test_all_image_file_names_requires_selective(self): + def test_all_image_file_names_requires_selective(self) -> None: response = self._post_selective(selective=False) assert response.status_code == 400 assert "selective" in response.data["detail"] - def test_selective_without_all_image_file_names_accepted(self): + def test_selective_without_all_image_file_names_accepted(self) -> None: data = self._selective_data() del data["all_image_file_names"] response = self.client.post(self._get_create_url(), data, format="json") @@ -613,7 +618,7 @@ def test_selective_with_all_image_file_names_accepted( @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.compare_snapshots") def test_base_upload_triggers_comparison_for_waiting_head( - self, mock_compare_snapshots, mock_get_session + self, mock_compare_snapshots: MagicMock, mock_get_session: MagicMock ) -> None: """ When a head snapshot is uploaded before its base, uploading the base should @@ -699,7 +704,7 @@ def test_base_upload_triggers_comparison_for_waiting_head( @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.compare_snapshots") def test_selective_base_is_matched_for_comparison( - self, mock_compare_snapshots, mock_get_session + self, mock_compare_snapshots: MagicMock, mock_get_session: MagicMock ) -> None: """ A SELECTIVE base build is matched as a comparison base and a comparison is @@ -781,13 +786,17 @@ def setUp(self) -> None: self.org = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.org) - def _get_detail_url(self, snapshot_id): + def _get_detail_url(self, snapshot_id: int) -> str: return reverse( "sentry-api-0-project-preprod-snapshots-detail", args=[self.org.slug, snapshot_id], ) - def _create_artifact_with_manifest(self, images=None, commit_comparison=None): + def _create_artifact_with_manifest( + self, + images: Mapping[str, object] | None = None, + commit_comparison: CommitComparison | None = None, + ) -> tuple[PreprodArtifact, PreprodSnapshotMetrics, str, bytes, Mapping[str, object]]: """Helper to create an artifact with snapshot metrics and a manifest key.""" if images is None: images = { @@ -822,7 +831,7 @@ def _create_artifact_with_manifest(self, images=None, commit_comparison=None): manifest_json = orjson.dumps({"images": images}) return artifact, snapshot_metrics, manifest_key, manifest_json, images - def _create_mock_session(self, manifest_json): + def _create_mock_session(self, manifest_json: bytes) -> MagicMock: mock_result = MagicMock() mock_result.payload.read.return_value = manifest_json mock_session = MagicMock() @@ -830,7 +839,7 @@ def _create_mock_session(self, manifest_json): return mock_session @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_details(self, mock_get_session): + def test_get_snapshot_details(self, mock_get_session: MagicMock) -> None: artifact, _, manifest_key, manifest_json, images = self._create_artifact_with_manifest() mock_get_session.return_value = self._create_mock_session(manifest_json) @@ -848,7 +857,9 @@ def test_get_snapshot_details(self, mock_get_session): assert response.data["images"][1]["key"] == "img2" @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_details_uses_precomputed_head_images(self, mock_get_session): + def test_get_snapshot_details_uses_precomputed_head_images( + self, mock_get_session: MagicMock + ) -> None: images = { "img1": { "content_hash": "img1", @@ -865,12 +876,13 @@ def test_get_snapshot_details_uses_precomputed_head_images(self, mock_get_sessio } artifact, snapshot_metrics, manifest_key, _, _ = self._create_artifact_with_manifest(images) head_key = f"{self.org.id}/{self.project.id}/{artifact.id}/snapshot_head_images.json" + assert snapshot_metrics.extras is not None snapshot_metrics.extras["head_images_key"] = head_key snapshot_metrics.save() blob = orjson.dumps(build_head_images_payload(images, None)) - def _get(key): + def _get(key: str) -> MagicMock | None: if key == head_key: result = MagicMock() result.payload.read.return_value = blob @@ -888,7 +900,7 @@ def _get(key): mock_session.head.assert_called_once_with(manifest_key) @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_details_returns_canvas_theme(self, mock_get_session): + def test_get_snapshot_details_returns_canvas_theme(self, mock_get_session: MagicMock) -> None: images = { "img1": { "content_hash": "img1", @@ -916,7 +928,9 @@ def test_get_snapshot_details_returns_canvas_theme(self, mock_get_session): @patch("sentry.analytics.record") @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_details_records_web_client(self, mock_get_session, mock_record): + def test_get_snapshot_details_records_web_client( + self, mock_get_session: MagicMock, mock_record: MagicMock + ) -> None: artifact, _, _, manifest_json, _ = self._create_artifact_with_manifest() mock_get_session.return_value = self._create_mock_session(manifest_json) @@ -936,7 +950,9 @@ def test_get_snapshot_details_records_web_client(self, mock_get_session, mock_re @patch("sentry.analytics.record") @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_details_records_mcp_client(self, mock_get_session, mock_record): + def test_get_snapshot_details_records_mcp_client( + self, mock_get_session: MagicMock, mock_record: MagicMock + ) -> None: artifact, _, _, manifest_json, _ = self._create_artifact_with_manifest() mock_get_session.return_value = self._create_mock_session(manifest_json) @@ -959,7 +975,7 @@ def test_get_snapshot_details_records_mcp_client(self, mock_get_session, mock_re ) @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_details_with_vcs_info(self, mock_get_session): + def test_get_snapshot_details_with_vcs_info(self, mock_get_session: MagicMock) -> None: commit_comparison = CommitComparison.objects.create( organization_id=self.org.id, head_repo_name="owner/repo", @@ -987,7 +1003,7 @@ def test_get_snapshot_details_with_vcs_info(self, mock_get_session): assert vcs_info["pr_number"] == 123 @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_details_returns_all_images(self, mock_get_session): + def test_get_snapshot_details_returns_all_images(self, mock_get_session: MagicMock) -> None: images = { f"img{i:03d}": { "content_hash": f"img{i:03d}", @@ -1010,7 +1026,7 @@ def test_get_snapshot_details_returns_all_images(self, mock_get_session): assert response.data["images"][9]["key"] == "img009" @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_diff_omits_images(self, mock_get_session): + def test_get_snapshot_diff_omits_images(self, mock_get_session: MagicMock) -> None: from sentry.preprod.snapshots.manifest import ( ComparisonImageResult, ComparisonManifest, @@ -1114,7 +1130,7 @@ def test_get_snapshot_diff_omits_images(self, mock_get_session): comparison_key: comparison_manifest_json, } - def _session_get(key): + def _session_get(key: str) -> MagicMock: result = MagicMock() result.payload.read.return_value = manifests_by_key[key] return result @@ -1162,7 +1178,7 @@ def test_get_snapshot_wrong_organization(self) -> None: assert response.status_code == 404 @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_objectstore_error(self, mock_get_session): + def test_get_snapshot_objectstore_error(self, mock_get_session: MagicMock) -> None: artifact, _, _, _, _ = self._create_artifact_with_manifest() mock_session = MagicMock() mock_session.get.side_effect = Exception("Storage error") @@ -1175,7 +1191,7 @@ def test_get_snapshot_objectstore_error(self, mock_get_session): assert response.data["detail"] == "Internal server error" @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_missing_manifest_returns_404(self, mock_get_session): + def test_get_snapshot_missing_manifest_returns_404(self, mock_get_session: MagicMock) -> None: artifact, _, _, _, _ = self._create_artifact_with_manifest() mock_session = MagicMock() mock_session.get.return_value = None @@ -1216,7 +1232,7 @@ def test_get_snapshot_returns_404_for_member_without_project_access(self) -> Non assert response.status_code == 404 @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_flat_fields_solo_no_approval(self, mock_get_session): + def test_get_snapshot_flat_fields_solo_no_approval(self, mock_get_session: MagicMock) -> None: artifact, _, _, manifest_json, _ = self._create_artifact_with_manifest() mock_get_session.return_value = self._create_mock_session(manifest_json) @@ -1231,7 +1247,7 @@ def test_get_snapshot_flat_fields_solo_no_approval(self, mock_get_session): assert response.data["comparison_type"] == "solo" @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_flat_fields_pending_comparison(self, mock_get_session): + def test_get_snapshot_flat_fields_pending_comparison(self, mock_get_session: MagicMock) -> None: artifact, snapshot_metrics, _, manifest_json, _ = self._create_artifact_with_manifest( commit_comparison=CommitComparison.objects.create( organization_id=self.org.id, @@ -1266,7 +1282,7 @@ def test_get_snapshot_flat_fields_pending_comparison(self, mock_get_session): assert response.data["comparison_state"] == "pending" @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_flat_fields_with_approval(self, mock_get_session): + def test_get_snapshot_flat_fields_with_approval(self, mock_get_session: MagicMock) -> None: artifact, _, _, manifest_json, _ = self._create_artifact_with_manifest() self.create_preprod_comparison_approval( preprod_artifact=artifact, @@ -1284,7 +1300,7 @@ def test_get_snapshot_flat_fields_with_approval(self, mock_get_session): assert response.data["approvers"][0]["source"] == "sentry" @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_flat_fields_auto_approved(self, mock_get_session): + def test_get_snapshot_flat_fields_auto_approved(self, mock_get_session: MagicMock) -> None: artifact, _, _, manifest_json, _ = self._create_artifact_with_manifest() self.create_preprod_comparison_approval( preprod_artifact=artifact, @@ -1300,7 +1316,7 @@ def test_get_snapshot_flat_fields_auto_approved(self, mock_get_session): assert response.data["approval_status"] == "auto_approved" @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_get_snapshot_flat_fields_waiting_for_base(self, mock_get_session): + def test_get_snapshot_flat_fields_waiting_for_base(self, mock_get_session: MagicMock) -> None: artifact, _, _, manifest_json, _ = self._create_artifact_with_manifest( commit_comparison=CommitComparison.objects.create( organization_id=self.org.id, @@ -1322,19 +1338,21 @@ def test_get_snapshot_flat_fields_waiting_for_base(self, mock_get_session): class OrganizationPreprodLatestBaseSnapshotTest(APITestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(user=self.user) self.org = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.org, slug="sausage") - def _get_url(self): + def _get_url(self) -> str: return reverse( "sentry-api-0-organization-preprod-snapshots-latest-base", args=[self.org.slug], ) - def _create_base_snapshot(self, project=None): + def _create_base_snapshot( + self, project: Project | None = None + ) -> tuple[PreprodArtifact, str, bytes]: project = project or self.project images = { "components/button.png": { @@ -1357,14 +1375,14 @@ def _create_base_snapshot(self, project=None): ) return artifact, manifest_key, orjson.dumps({"images": images}) - def _create_mock_session(self, manifest_json): + def _create_mock_session(self, manifest_json: bytes) -> MagicMock: mock_result = MagicMock() mock_result.payload.read.return_value = manifest_json mock_session = MagicMock() mock_session.get.return_value = mock_result return mock_session - def test_query_params_document_project_id_or_slug(self): + def test_query_params_document_project_id_or_slug(self) -> None: assert LATEST_BASE_SNAPSHOT_GET_QUERY_PARAMS["project"] == { "type": "integer|string", "required": False, @@ -1379,7 +1397,9 @@ def test_query_params_document_project_id_or_slug(self): @patch( "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_snapshot_storage" ) - def test_get_latest_base_snapshot_scoped_by_project_slug(self, mock_get_session): + def test_get_latest_base_snapshot_scoped_by_project_slug( + self, mock_get_session: MagicMock + ) -> None: artifact, manifest_key, manifest_json = self._create_base_snapshot() mock_get_session.return_value = self._create_mock_session(manifest_json) @@ -1398,7 +1418,9 @@ def test_get_latest_base_snapshot_scoped_by_project_slug(self, mock_get_session) @patch( "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_snapshot_storage" ) - def test_get_latest_base_snapshot_scoped_by_project_param_slug(self, mock_get_session): + def test_get_latest_base_snapshot_scoped_by_project_param_slug( + self, mock_get_session: MagicMock + ) -> None: artifact, _, manifest_json = self._create_base_snapshot() mock_get_session.return_value = self._create_mock_session(manifest_json) @@ -1415,7 +1437,9 @@ def test_get_latest_base_snapshot_scoped_by_project_param_slug(self, mock_get_se @patch( "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_snapshot_storage" ) - def test_get_latest_base_snapshot_scoped_by_project_param_id(self, mock_get_session): + def test_get_latest_base_snapshot_scoped_by_project_param_id( + self, mock_get_session: MagicMock + ) -> None: artifact, _, manifest_json = self._create_base_snapshot() mock_get_session.return_value = self._create_mock_session(manifest_json) @@ -1433,8 +1457,8 @@ def test_get_latest_base_snapshot_scoped_by_project_param_id(self, mock_get_sess "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_snapshot_storage" ) def test_get_latest_base_snapshot_project_slug_takes_precedence_over_project( - self, mock_get_session - ): + self, mock_get_session: MagicMock + ) -> None: self._create_base_snapshot() other_project = self.create_project(organization=self.org, slug="other-project") artifact, _, manifest_json = self._create_base_snapshot(project=other_project) @@ -1454,7 +1478,7 @@ def test_get_latest_base_snapshot_project_slug_takes_precedence_over_project( assert response.data["project_slug"] == "other-project" mock_get_session.assert_called_once_with(other_project) - def test_get_latest_base_snapshot_rejects_all_project_id_sentinel(self): + def test_get_latest_base_snapshot_rejects_all_project_id_sentinel(self) -> None: response = self.client.get( self._get_url(), {"app_id": "com.example.app", "project": "-1"}, @@ -1463,7 +1487,7 @@ def test_get_latest_base_snapshot_rejects_all_project_id_sentinel(self): assert response.status_code == 400 assert response.data["detail"] == "Invalid project parameter" - def test_get_latest_base_snapshot_rejects_all_project_slug_sentinel(self): + def test_get_latest_base_snapshot_rejects_all_project_slug_sentinel(self) -> None: response = self.client.get( self._get_url(), {"app_id": "com.example.app", "project": "$all"}, @@ -1472,7 +1496,7 @@ def test_get_latest_base_snapshot_rejects_all_project_slug_sentinel(self): assert response.status_code == 400 assert response.data["detail"] == "Invalid project parameter" - def test_get_latest_base_snapshot_rejects_project_slug_all_sentinel(self): + def test_get_latest_base_snapshot_rejects_project_slug_all_sentinel(self) -> None: response = self.client.get( self._get_url(), {"app_id": "com.example.app", "projectSlug": "$all"}, @@ -1481,7 +1505,7 @@ def test_get_latest_base_snapshot_rejects_project_slug_all_sentinel(self): assert response.status_code == 400 assert response.data["detail"] == "Invalid project parameter" - def test_get_latest_base_snapshot_rejects_project_slug_id_sentinel(self): + def test_get_latest_base_snapshot_rejects_project_slug_id_sentinel(self) -> None: response = self.client.get( self._get_url(), {"app_id": "com.example.app", "projectSlug": "-1"}, @@ -1498,13 +1522,13 @@ def setUp(self) -> None: self.org = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.org) - def _delete_url(self, snapshot_id): + def _delete_url(self, snapshot_id: int) -> str: return reverse( "sentry-api-0-project-preprod-snapshots-detail", args=[self.org.slug, snapshot_id], ) - def _create_snapshot_artifact(self): + def _create_snapshot_artifact(self) -> PreprodArtifact: artifact = PreprodArtifact.objects.create( project=self.project, state=PreprodArtifact.ArtifactState.UPLOADED, @@ -1549,16 +1573,16 @@ def setUp(self) -> None: self.org = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.org) - def _get_detail_url(self, snapshot_id): + def _get_detail_url(self, snapshot_id: int) -> str: return reverse( "sentry-api-0-project-preprod-snapshots-detail", args=[self.org.slug, snapshot_id], ) - def _manifest_key(self, artifact): + def _manifest_key(self, artifact: PreprodArtifact) -> str: return f"{self.org.id}/{self.project.id}/{artifact.id}/manifest.json" - def _create_artifact(self, image_count): + def _create_artifact(self, image_count: int) -> tuple[PreprodArtifact, PreprodSnapshotMetrics]: artifact = self.create_preprod_artifact( project=self.project, state=PreprodArtifact.ArtifactState.UPLOADED, @@ -1572,7 +1596,9 @@ def _create_artifact(self, image_count): metrics.save(update_fields=["extras"]) return artifact, metrics - def _snapshot_manifest_bytes(self, images, diff_threshold=None): + def _snapshot_manifest_bytes( + self, images: Mapping[str, object], diff_threshold: float | None = None + ) -> bytes: # Mirror how the POST handler stores manifests: pydantic .json(exclude_none=True), # so null fields are omitted and tags/canvas_theme are coerced on disk. return ( @@ -1581,7 +1607,7 @@ def _snapshot_manifest_bytes(self, images, diff_threshold=None): .encode() ) - def _head_images(self): + def _head_images(self) -> dict[str, dict[str, object]]: return { "a_added.png": { "content_hash": "h_added", @@ -1622,7 +1648,7 @@ def _head_images(self): }, } - def _base_images(self): + def _base_images(self) -> dict[str, dict[str, object]]: return { "b_changed.png": { "content_hash": "b_changed_hash", @@ -1651,7 +1677,9 @@ def _base_images(self): }, } - def _comparison_manifest_bytes(self, head_artifact, base_artifact): + def _comparison_manifest_bytes( + self, head_artifact: PreprodArtifact, base_artifact: PreprodArtifact + ) -> bytes: images = { "a_added.png": ComparisonImageResult(status="added", head_hash="h_added"), "b_changed.png": ComparisonImageResult( @@ -1712,8 +1740,10 @@ def _comparison_manifest_bytes(self, head_artifact, base_artifact): ).dict() ) - def _mock_multi_session(self, mock_get_session, manifests_by_key): - def _get(key): + def _mock_multi_session( + self, mock_get_session: MagicMock, manifests_by_key: Mapping[str, bytes] + ) -> None: + def _get(key: str) -> MagicMock | None: if key not in manifests_by_key: return None result = MagicMock() @@ -1724,7 +1754,8 @@ def _get(key): session.get.side_effect = _get mock_get_session.return_value = session - def _assert_golden(self, name, response, dynamic_ids): + # justification: Django test client responses are untyped in stubs. + def _assert_golden(self, name: str, response: Any, dynamic_ids: Mapping[str, str]) -> None: assert response.status_code == 200 body = orjson.loads(response.content) for field, token in dynamic_ids.items(): @@ -1752,7 +1783,7 @@ def _assert_golden(self, name, response, dynamic_ids): @patch("sentry.analytics.record") @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_golden_solo(self, mock_get_session, mock_record): + def test_golden_solo(self, mock_get_session: MagicMock, mock_record: MagicMock) -> None: head_images = self._head_images() artifact, _ = self._create_artifact(image_count=len(head_images)) self._mock_multi_session( @@ -1768,7 +1799,7 @@ def test_golden_solo(self, mock_get_session, mock_record): {"head_artifact_id": "", "project_id": ""}, ) - def _head_images_blob_bytes(self, head_images): + def _head_images_blob_bytes(self, head_images: Mapping[str, object]) -> bytes: parsed = orjson.loads(self._snapshot_manifest_bytes(head_images, 0.2)) return orjson.dumps( build_head_images_payload(parsed["images"], parsed.get("diff_threshold")) @@ -1776,10 +1807,13 @@ def _head_images_blob_bytes(self, head_images): @patch("sentry.analytics.record") @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_golden_solo_precomputed_head_images(self, mock_get_session, mock_record): + def test_golden_solo_precomputed_head_images( + self, mock_get_session: MagicMock, mock_record: MagicMock + ) -> None: head_images = self._head_images() artifact, metrics = self._create_artifact(image_count=len(head_images)) head_key = f"{self.org.id}/{self.project.id}/{artifact.id}/snapshot_head_images.json" + assert metrics.extras is not None metrics.extras["head_images_key"] = head_key metrics.save(update_fields=["extras"]) @@ -1797,7 +1831,9 @@ def test_golden_solo_precomputed_head_images(self, mock_get_session, mock_record @patch("sentry.analytics.record") @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_golden_diff_precomputed_head_images(self, mock_get_session, mock_record): + def test_golden_diff_precomputed_head_images( + self, mock_get_session: MagicMock, mock_record: MagicMock + ) -> None: head_artifact, _ = self._setup_diff(mock_get_session, use_precomputed_head=True) response = self.client.get(self._get_detail_url(head_artifact.id)) @@ -1812,7 +1848,9 @@ def test_golden_diff_precomputed_head_images(self, mock_get_session, mock_record }, ) - def _setup_diff(self, mock_get_session, use_precomputed_head=False): + def _setup_diff( + self, mock_get_session: MagicMock, use_precomputed_head: bool = False + ) -> tuple[PreprodArtifact, PreprodArtifact]: head_images = self._head_images() base_images = self._base_images() head_artifact, head_metrics = self._create_artifact(image_count=len(head_images)) @@ -1837,6 +1875,7 @@ def _setup_diff(self, mock_get_session, use_precomputed_head=False): head_key = ( f"{self.org.id}/{self.project.id}/{head_artifact.id}/snapshot_head_images.json" ) + assert head_metrics.extras is not None head_metrics.extras["head_images_key"] = head_key head_metrics.save(update_fields=["extras"]) session_objects[head_key] = self._head_images_blob_bytes(head_images) @@ -1850,7 +1889,7 @@ def _setup_diff(self, mock_get_session, use_precomputed_head=False): @patch("sentry.analytics.record") @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_golden_diff(self, mock_get_session, mock_record): + def test_golden_diff(self, mock_get_session: MagicMock, mock_record: MagicMock) -> None: head_artifact, _ = self._setup_diff(mock_get_session) response = self.client.get(self._get_detail_url(head_artifact.id)) @@ -1867,7 +1906,7 @@ def test_golden_diff(self, mock_get_session, mock_record): @patch("sentry.analytics.record") @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") - def test_golden_diff_compact(self, mock_get_session, mock_record): + def test_golden_diff_compact(self, mock_get_session: MagicMock, mock_record: MagicMock) -> None: head_artifact, _ = self._setup_diff(mock_get_session) response = self.client.get(self._get_detail_url(head_artifact.id) + "?compact_metadata=1") @@ -1885,8 +1924,8 @@ def test_golden_diff_compact(self, mock_get_session, mock_record): @patch("sentry.analytics.record") @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_snapshot_storage") def test_comparison_manifest_missing_base_artifact_id_degrades( - self, mock_get_session, mock_record - ): + self, mock_get_session: MagicMock, mock_record: MagicMock + ) -> None: head_images = self._head_images() head_artifact, head_metrics = self._create_artifact(image_count=len(head_images)) _, base_metrics = self._create_artifact(image_count=1) diff --git a/tests/sentry/preprod/api/endpoints/test_project_installable_preprod_artifact_download.py b/tests/sentry/preprod/api/endpoints/test_project_installable_preprod_artifact_download.py index bc0391c1e5bb..77b5d6f60b00 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_installable_preprod_artifact_download.py +++ b/tests/sentry/preprod/api/endpoints/test_project_installable_preprod_artifact_download.py @@ -1,4 +1,5 @@ from datetime import timedelta +from typing import cast from django.utils import timezone @@ -36,7 +37,12 @@ def setUp(self) -> None: download_count=0, ) - def _get_url(self, organization_id_or_slug=None, project_id_or_slug=None, url_path=None): + def _get_url( + self, + organization_id_or_slug: str | None = None, + project_id_or_slug: str | None = None, + url_path: str | None = None, + ) -> str: org = organization_id_or_slug or self.organization.slug proj = project_id_or_slug or self.project.slug path = url_path or self.installable.url_path @@ -112,7 +118,7 @@ def test_installable_expired(self) -> None: assert response.json()["error"] == "Install link expired" def test_no_installable_file(self) -> None: - self.preprod_artifact.installable_app_file_id = None + self.preprod_artifact.installable_app_file_id = cast("int", None) self.preprod_artifact.save() url = self._get_url() diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_assemble_generic.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_assemble_generic.py index a2b28bda0309..4cdc8aaab4a4 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_assemble_generic.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_assemble_generic.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, patch import orjson @@ -20,11 +21,14 @@ def setUp(self) -> None: state=PreprodArtifact.ArtifactState.UPLOADED ) - def _get_url(self, artifact_id=None): + def _get_url(self, artifact_id: int | None = None) -> str: artifact_id = artifact_id or self.preprod_artifact.id return f"/api/0/internal/{self.organization.slug}/{self.project.slug}/files/preprodartifacts/{artifact_id}/assemble-generic/" - def _make_request(self, data, artifact_id=None, authenticated=True): + # justification: request bodies mix JSON and raw bytes per test case. + def _make_request( + self, data: Any, artifact_id: int | None = None, authenticated: bool = True + ) -> Any: url = self._get_url(artifact_id) json_data: bytes = orjson.dumps(data) if isinstance(data, dict) else data @@ -41,7 +45,7 @@ def _make_request(self, data, artifact_id=None, authenticated=True): else: return self.client.post(url, data=json_data, content_type="application/json") - def _create_blobs(self, count=2): + def _create_blobs(self, count: int = 2) -> list[FileBlob]: return [ FileBlob.from_file_with_organization( self.create_file(name=f"chunk{i}", type="application/octet-stream").getfile(), @@ -50,7 +54,13 @@ def _create_blobs(self, count=2): for i in range(count) ] - def _assert_task_called_with(self, mock_task, checksum, chunks, artifact_id=None): + def _assert_task_called_with( + self, + mock_task: MagicMock, + checksum: str, + chunks: list[str], + artifact_id: int | None = None, + ) -> None: """Helper to assert task was called with expected parameters""" call_kwargs = mock_task.call_args[1]["kwargs"] assert call_kwargs["org_id"] == self.organization.id diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_download.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_download.py index 83ee3fc94646..b0fa35536045 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_download.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_download.py @@ -1,4 +1,5 @@ from io import BytesIO +from typing import Any from unittest.mock import patch from django.test import override_settings @@ -29,7 +30,7 @@ def setUp(self) -> None: artifact_type=PreprodArtifact.ArtifactType.APK, ) - def _get_authenticated_request_headers(self, path, data=b""): + def _get_authenticated_request_headers(self, path: str, data: bytes = b"") -> dict[str, Any]: """Generate the RPC signature authentication headers for the request.""" signature = generate_service_request_signature(path, data, ["test-secret-key"], "Launchpad") return {"HTTP_AUTHORIZATION": f"rpcsignature {signature}"} diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_install_details.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_install_details.py index 58a775cf98af..f1e601a373ba 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_install_details.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_install_details.py @@ -1,9 +1,10 @@ +from typing import Any from unittest.mock import MagicMock, patch from django.utils import timezone from sentry.preprod.analytics import PreprodArtifactApiInstallDetailsEvent -from sentry.preprod.models import InstallablePreprodArtifact +from sentry.preprod.models import InstallablePreprodArtifact, PreprodArtifact from sentry.testutils.cases import TestCase from sentry.testutils.helpers.analytics import assert_any_analytics_event @@ -17,15 +18,16 @@ def setUp(self) -> None: ) self.login_as(user=self.user) - def _get_url(self, artifact_id=None): + def _get_url(self, artifact_id: int | None = None) -> str: artifact_id = artifact_id or self.preprod_artifact.id return f"/api/0/organizations/{self.organization.slug}/preprodartifacts/{artifact_id}/private-install-details/" - def _create_ios_artifact(self, **kwargs): + # justification: factory kwargs vary per test case. + def _create_ios_artifact(self, **kwargs: Any) -> PreprodArtifact: """Helper to create an iOS artifact with default valid extras""" from sentry.preprod.models import PreprodArtifact - defaults = { + defaults: dict[str, Any] = { "project": self.project, "file_id": self.file.id, "state": PreprodArtifact.ArtifactState.PROCESSED, @@ -42,11 +44,12 @@ def _create_ios_artifact(self, **kwargs): defaults.update(kwargs) return self.create_preprod_artifact(**defaults) - def _create_android_artifact(self, **kwargs): + # justification: factory kwargs vary per test case. + def _create_android_artifact(self, **kwargs: Any) -> PreprodArtifact: """Helper to create an Android artifact with default valid extras""" from sentry.preprod.models import PreprodArtifact - defaults = { + defaults: dict[str, Any] = { "project": self.project, "file_id": self.file.id, "state": PreprodArtifact.ArtifactState.PROCESSED, diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_update.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_update.py index df4a01f9aea7..961449828aca 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_update.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_artifact_update.py @@ -1,5 +1,5 @@ -from typing import Any -from unittest.mock import patch +from typing import Any, cast +from unittest.mock import MagicMock, patch import orjson from django.test import override_settings @@ -24,11 +24,14 @@ def setUp(self) -> None: state=PreprodArtifact.ArtifactState.UPLOADING, ) - def _get_url(self, artifact_id=None): + def _get_url(self, artifact_id: int | None = None) -> str: artifact_id = artifact_id or self.preprod_artifact.id return f"/api/0/internal/{self.organization.slug}/{self.project.slug}/files/preprodartifacts/{artifact_id}/update/" - def _make_request(self, data, artifact_id=None, authenticated=True): + # justification: request bodies mix JSON and raw bytes per test case. + def _make_request( + self, data: Any, artifact_id: int | None = None, authenticated: bool = True + ) -> Any: url = self._get_url(artifact_id) json_data = orjson.dumps(data) if isinstance(data, dict) else data @@ -100,7 +103,7 @@ def test_update_preprod_artifact_sets_failed_state_on_error(self) -> None: # Reset for next test self.preprod_artifact.state = PreprodArtifact.ArtifactState.UPLOADING - self.preprod_artifact.error_code = None + self.preprod_artifact.error_code = cast("int", None) self.preprod_artifact.save() # Test that setting error_message sets state to FAILED @@ -526,7 +529,7 @@ def test_update_preprod_artifact_partial_mobile_app_info_update(self) -> None: preprod_artifact=self.preprod_artifact ) assert mobile_app_info.build_version == "3.0.0" - assert mobile_app_info.build_number is None + assert cast("object", mobile_app_info.build_number) is None assert mobile_app_info.app_name is None assert mobile_app_info.app_icon_id is None @@ -587,7 +590,7 @@ def test_update_preprod_artifact_returns_requested_features(self) -> None: ".create_preprod_size_pr_comment_task" ) def test_update_preprod_artifact_filters_size_by_query( - self, mock_pr_comment_task, mock_status_check_task + self, mock_pr_comment_task: MagicMock, mock_status_check_task: MagicMock ) -> None: """Test that SIZE_ANALYSIS is filtered out when project query doesn't match.""" self.preprod_artifact.app_id = "com.my.app" @@ -676,7 +679,9 @@ def test_update_preprod_artifact_filters_distribution_by_query(self) -> None: @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"]) @patch("sentry.preprod.quotas.has_size_quota") - def test_update_preprod_artifact_filters_size_when_no_quota(self, mock_has_size_quota) -> None: + def test_update_preprod_artifact_filters_size_when_no_quota( + self, mock_has_size_quota: MagicMock + ) -> None: """Test that SIZE_ANALYSIS is filtered out when organization has no quota.""" mock_has_size_quota.return_value = False @@ -692,7 +697,7 @@ def test_update_preprod_artifact_filters_size_when_no_quota(self, mock_has_size_ @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"]) @patch("sentry.preprod.quotas.has_installable_quota") def test_update_preprod_artifact_filters_distribution_when_no_quota( - self, mock_has_installable_quota + self, mock_has_installable_quota: MagicMock ) -> None: """Test that BUILD_DISTRIBUTION is filtered out when organization has no quota.""" mock_has_installable_quota.return_value = False @@ -713,12 +718,12 @@ def test_update_no_error_code_when_distribution_can_run(self) -> None: assert response.status_code == 200 self.preprod_artifact.refresh_from_db() - assert self.preprod_artifact.installable_app_error_code is None + assert cast("object", self.preprod_artifact.installable_app_error_code) is None assert self.preprod_artifact.installable_app_error_message is None @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"]) @patch("sentry.preprod.quotas.has_installable_quota") - def test_update_sets_error_code_no_quota(self, mock_has_installable_quota) -> None: + def test_update_sets_error_code_no_quota(self, mock_has_installable_quota: MagicMock) -> None: mock_has_installable_quota.return_value = False data = {"artifact_type": 1} diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_build_details.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_build_details.py index 342b6f188583..aac235959484 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_build_details.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_build_details.py @@ -1,5 +1,5 @@ from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch from django.urls import reverse from django.utils import timezone @@ -49,7 +49,7 @@ def setUp(self) -> None: build_number=42, ) - def _get_url(self, artifact_id=None): + def _get_url(self, artifact_id: int | None = None) -> str: artifact_id = artifact_id or self.preprod_artifact.id return reverse( "sentry-api-0-organization-preprod-artifact-build-details", @@ -552,7 +552,7 @@ def test_base_build_info_none_when_no_base_artifact(self) -> None: assert resp_data["base_build_info"] is None @patch("sentry.preprod.api.endpoints.project_preprod_build_details.get_size_retention_cutoff") - def test_returns_404_for_expired_artifact(self, mock_cutoff) -> None: + def test_returns_404_for_expired_artifact(self, mock_cutoff: MagicMock) -> None: mock_cutoff.return_value = timezone.now() - timedelta(days=30) self.preprod_artifact.date_added = timezone.now() - timedelta(days=60) self.preprod_artifact.save() diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_check_for_updates.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_check_for_updates.py index a2b219d26882..9667f86568e0 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_check_for_updates.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_check_for_updates.py @@ -1,3 +1,5 @@ +from typing import Any + from django.urls import reverse from sentry.models.orgauthtoken import OrgAuthToken @@ -28,13 +30,14 @@ def setUp(self) -> None: self.file = self.create_file(name="test_artifact.apk", type="application/octet-stream") - def _get_url(self): + def _get_url(self) -> str: return reverse( "sentry-api-0-project-preprod-check-for-updates", args=[self.org.slug, self.project.slug], ) - def _get_update_for_current_ios_artifact(self): + # justification: decoded JSON payload shape varies per test case. + def _get_update_for_current_ios_artifact(self) -> dict[str, Any]: response = self.client.get( self._get_url() + "?app_id=com.example.app&platform=ios&build_version=1.0.0&main_binary_identifier=current-identifier", @@ -43,12 +46,14 @@ def _get_update_for_current_ios_artifact(self): ) assert response.status_code == 200 update = response.json()["update"] - assert update is not None + if not isinstance(update, dict): + raise TypeError("Preprod update is not a JSON object") return update - def _create_android_artifact(self, **kwargs): + # justification: factory kwargs vary per test case. + def _create_android_artifact(self, **kwargs: Any) -> PreprodArtifact: """Helper to create an Android artifact with default values""" - defaults = { + defaults: dict[str, Any] = { "project": self.project, "file_id": self.file.id, "state": PreprodArtifact.ArtifactState.PROCESSED, @@ -72,9 +77,12 @@ def _create_android_artifact(self, **kwargs): ) return self.preprod_artifact - def _create_ios_artifact(self, build_number_raw=None, **kwargs): + # justification: factory kwargs vary per test case. + def _create_ios_artifact( + self, build_number_raw: str | None = None, **kwargs: Any + ) -> PreprodArtifact: """Helper to create an iOS artifact with default values""" - defaults = { + defaults: dict[str, Any] = { "project": self.project, "file_id": self.file.id, "state": PreprodArtifact.ArtifactState.PROCESSED, diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py index c09ca9b98fbf..901b500bad48 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py @@ -1,4 +1,5 @@ -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch import orjson from django.test import override_settings @@ -20,7 +21,8 @@ def setUp(self) -> None: state=PreprodArtifact.ArtifactState.PROCESSED, ) - def _put(self, data, secret=SHARED_SECRET_FOR_TESTS): + # justification: Django test client responses are untyped in stubs. + def _put(self, data: bytes, secret: str = SHARED_SECRET_FOR_TESTS) -> Any: url = f"/api/0/organizations/{self.organization.slug}/preprodartifacts/{self.artifact.id}/distribution/" signature = generate_service_request_signature(url, data, [secret], "Launchpad") return self.client.put( @@ -34,7 +36,7 @@ def _put(self, data, secret=SHARED_SECRET_FOR_TESTS): @patch( "sentry.preprod.api.endpoints.project_preprod_distribution.send_build_distribution_webhook" ) - def test_bad_auth(self, mock_send_webhook) -> None: + def test_bad_auth(self, mock_send_webhook: MagicMock) -> None: response = self._put(b"{}", secret="wrong secret") assert response.status_code == 401 mock_send_webhook.assert_not_called() @@ -43,7 +45,7 @@ def test_bad_auth(self, mock_send_webhook) -> None: @patch( "sentry.preprod.api.endpoints.project_preprod_distribution.send_build_distribution_webhook" ) - def test_missing_fields(self, mock_send_webhook) -> None: + def test_missing_fields(self, mock_send_webhook: MagicMock) -> None: response = self._put(b"{}") assert response.status_code == 400 mock_send_webhook.assert_not_called() @@ -52,7 +54,7 @@ def test_missing_fields(self, mock_send_webhook) -> None: @patch( "sentry.preprod.api.endpoints.project_preprod_distribution.send_build_distribution_webhook" ) - def test_bad_json(self, mock_send_webhook) -> None: + def test_bad_json(self, mock_send_webhook: MagicMock) -> None: response = self._put(b"{") assert response.status_code == 400 mock_send_webhook.assert_not_called() @@ -61,7 +63,7 @@ def test_bad_json(self, mock_send_webhook) -> None: @patch( "sentry.preprod.api.endpoints.project_preprod_distribution.send_build_distribution_webhook" ) - def test_accepts_generic_processing_error(self, mock_send_webhook) -> None: + def test_accepts_generic_processing_error(self, mock_send_webhook: MagicMock) -> None: response = self._put(orjson.dumps({"error_code": 3, "error_message": "some novel failure"})) assert response.status_code == 200 @@ -80,7 +82,7 @@ def test_accepts_generic_processing_error(self, mock_send_webhook) -> None: @patch( "sentry.preprod.api.endpoints.project_preprod_distribution.send_build_distribution_webhook" ) - def test_legacy_payload_stored_verbatim(self, mock_send_webhook) -> None: + def test_legacy_payload_stored_verbatim(self, mock_send_webhook: MagicMock) -> None: # Launchpad deployments that still send the legacy shape # (error_code=SKIPPED + error_message="invalid_signature", etc.) # should continue to write unchanged until launchpad emits the @@ -99,7 +101,7 @@ def test_legacy_payload_stored_verbatim(self, mock_send_webhook) -> None: @patch( "sentry.preprod.api.endpoints.project_preprod_distribution.send_build_distribution_webhook" ) - def test_accepts_new_granular_code(self, mock_send_webhook) -> None: + def test_accepts_new_granular_code(self, mock_send_webhook: MagicMock) -> None: response = self._put( orjson.dumps( { diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_size.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_size.py index 649f7862ba6c..0dc22a702d6f 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_size.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_size.py @@ -1,4 +1,7 @@ +from typing import cast + import orjson +from django.http import HttpResponse from django.test import override_settings from sentry.preprod.models import PreprodArtifact, PreprodArtifactSizeMetrics @@ -18,17 +21,25 @@ def setUp(self) -> None: state=PreprodArtifact.ArtifactState.UPLOADING, ) - def _put(self, data, identifier=None, secret=SHARED_SECRET_FOR_TESTS): + def _put( + self, + data: bytes, + identifier: str | None = None, + secret: str = SHARED_SECRET_FOR_TESTS, + ) -> HttpResponse: if identifier: url = f"/api/0/internal/{self.organization.slug}/{self.project.slug}/files/preprodartifacts/{self.artifact.id}/size/{identifier}/" else: url = f"/api/0/internal/{self.organization.slug}/{self.project.slug}/files/preprodartifacts/{self.artifact.id}/size/" signature = generate_service_request_signature(url, data, [secret], "Launchpad") - return self.client.put( - url, - data=data, - content_type="application/json", - HTTP_AUTHORIZATION=f"rpcsignature {signature}", + return cast( + "HttpResponse", + self.client.put( + url, + data=data, + content_type="application/json", + HTTP_AUTHORIZATION=f"rpcsignature {signature}", + ), ) @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS]) diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_upload_options.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_upload_options.py index f74e9594fb7b..c19d81595adb 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_upload_options.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_upload_options.py @@ -21,7 +21,7 @@ def setUp(self) -> None: ) @patch("sentry.preprod.api.endpoints.project_preprod_upload_options.get_session") - def test_returns_upload_options(self, mock_get_session) -> None: + def test_returns_upload_options(self, mock_get_session: MagicMock) -> None: mock_session = MagicMock() mock_session.mint_token.return_value = "fake-token" mock_get_session.return_value = mock_session @@ -44,7 +44,7 @@ def test_returns_upload_options(self, mock_get_session) -> None: mock_get_session.assert_called_once_with(UsecaseId.PREPROD, self.project) @patch("sentry.preprod.api.endpoints.project_preprod_upload_options.get_session") - def test_auto_returns_preprod_snapshots(self, mock_get_session) -> None: + def test_auto_returns_preprod_snapshots(self, mock_get_session: MagicMock) -> None: mock_session = MagicMock() mock_session.mint_token.return_value = "fake-token" mock_get_session.return_value = mock_session @@ -61,7 +61,7 @@ def test_rejects_explicit_usecase(self) -> None: assert response.status_code == 400 @patch("sentry.preprod.api.endpoints.project_preprod_upload_options.get_session") - def test_objectstore_url_uses_region_endpoint(self, mock_get_session) -> None: + def test_objectstore_url_uses_region_endpoint(self, mock_get_session: MagicMock) -> None: mock_session = MagicMock() mock_session.mint_token.return_value = "fake-token" mock_get_session.return_value = mock_session diff --git a/tests/sentry/preprod/api/models/test_project_preprod_build_details_models.py b/tests/sentry/preprod/api/models/test_project_preprod_build_details_models.py index ecece732d60c..223379943ecb 100644 --- a/tests/sentry/preprod/api/models/test_project_preprod_build_details_models.py +++ b/tests/sentry/preprod/api/models/test_project_preprod_build_details_models.py @@ -1,10 +1,12 @@ from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta +from typing import cast import pytest from django.utils import timezone +from sentry.models.commitcomparison import CommitComparison from sentry.preprod.api.models.project_preprod_build_details_models import ( SizeInfoCompleted, SizeInfoFailed, @@ -216,8 +218,8 @@ def test_to_size_info_completed_state_missing_size_fields(self) -> None: size_metrics = PreprodArtifactSizeMetrics( state=PreprodArtifactSizeMetrics.SizeAnalysisState.COMPLETED, metrics_artifact_type=PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT, - max_install_size=None, - max_download_size=None, + max_install_size=cast("int", None), + max_download_size=cast("int", None), ) with pytest.raises( @@ -229,7 +231,7 @@ def test_to_size_info_failed_state_no_error_code(self) -> None: """Test to_size_info raises ValueError when FAILED state has only error_code.""" size_metrics = PreprodArtifactSizeMetrics( state=PreprodArtifactSizeMetrics.SizeAnalysisState.FAILED, - error_code=None, + error_code=cast("int", None), error_message="Processing failed", ) @@ -334,7 +336,9 @@ def test_extras_without_auto_approval_key_treated_as_manual(self) -> None: @cell_silo_test class TestToSnapshotComparisonInfoNoBaseBuild(TestCase): def _create_artifact_with_snapshot_metrics( - self, commit_comparison=None, date_added=None + self, + commit_comparison: CommitComparison | None = None, + date_added: datetime | None = None, ) -> PreprodArtifact: artifact = self.create_preprod_artifact( project=self.project, diff --git a/tests/sentry/preprod/api/models/test_snapshot_status.py b/tests/sentry/preprod/api/models/test_snapshot_status.py index 0d17d42d483f..32b1df27750a 100644 --- a/tests/sentry/preprod/api/models/test_snapshot_status.py +++ b/tests/sentry/preprod/api/models/test_snapshot_status.py @@ -108,7 +108,7 @@ class TestDeriveSnapshotStatusApprovalStatus(TestCase): def _create_approval( self, approval_status: int = PreprodComparisonApproval.ApprovalStatus.APPROVED, - extras: dict | None = None, + extras: dict[str, object] | None = None, ) -> PreprodComparisonApproval: artifact = self.create_preprod_artifact(project=self.project) return self.create_preprod_comparison_approval( diff --git a/tests/sentry/preprod/eap/test_read.py b/tests/sentry/preprod/eap/test_read.py index e28ed2d47241..e89b7a9f04ce 100644 --- a/tests/sentry/preprod/eap/test_read.py +++ b/tests/sentry/preprod/eap/test_read.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta from datetime import timezone as dt_timezone -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from sentry_protos.snuba.v1.endpoint_trace_item_table_pb2 import TraceItemTableResponse @@ -14,7 +14,7 @@ class TestQueryPreprodSizeMetrics(TestCase): @patch("sentry.preprod.eap.read.snuba_rpc.table_rpc") - def test_query_builds_request_correctly(self, mock_rpc): + def test_query_builds_request_correctly(self, mock_rpc: MagicMock) -> None: mock_rpc.return_value = [ TraceItemTableResponse( meta=ResponseMeta(), @@ -59,7 +59,7 @@ def test_query_builds_request_correctly(self, mock_rpc): assert request.filter.comparison_filter.value.val_str == "com.example.app" @patch("sentry.preprod.eap.read.snuba_rpc.table_rpc") - def test_query_without_filters(self, mock_rpc): + def test_query_without_filters(self, mock_rpc: MagicMock) -> None: mock_rpc.return_value = [ TraceItemTableResponse( meta=ResponseMeta(), @@ -82,7 +82,7 @@ def test_query_without_filters(self, mock_rpc): assert not request.HasField("filter") @patch("sentry.preprod.eap.read.snuba_rpc.table_rpc") - def test_query_raises_on_empty_response(self, mock_rpc): + def test_query_raises_on_empty_response(self, mock_rpc: MagicMock) -> None: mock_rpc.return_value = [] with pytest.raises(ValueError, match="No response from Snuba RPC"): diff --git a/tests/sentry/preprod/eap/test_write.py b/tests/sentry/preprod/eap/test_write.py index 48f3214b48e4..e16f30f3593c 100644 --- a/tests/sentry/preprod/eap/test_write.py +++ b/tests/sentry/preprod/eap/test_write.py @@ -1,6 +1,6 @@ from datetime import datetime from datetime import timezone as dt_timezone -from unittest.mock import patch +from unittest.mock import MagicMock, patch from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType @@ -21,7 +21,9 @@ class WritePreprodSizeMetricToEAPTest(TestCase): @patch("sentry.preprod.eap.write._eap_producer.produce") - def test_write_preprod_size_metric_encodes_all_fields_correctly(self, mock_produce): + def test_write_preprod_size_metric_encodes_all_fields_correctly( + self, mock_produce: MagicMock + ) -> None: commit_comparison = CommitComparison.objects.create( organization_id=self.organization.id, head_sha="abc123", @@ -131,7 +133,9 @@ def test_write_preprod_size_metric_encodes_all_fields_correctly(self, mock_produ assert attrs["git_pr_number"].int_value == 42 @patch("sentry.preprod.eap.write._eap_producer.produce") - def test_write_preprod_size_metric_handles_optional_fields(self, mock_produce): + def test_write_preprod_size_metric_handles_optional_fields( + self, mock_produce: MagicMock + ) -> None: artifact = self.create_preprod_artifact( project=self.project, state=PreprodArtifact.ArtifactState.PROCESSED, @@ -171,7 +175,9 @@ def test_write_preprod_size_metric_handles_optional_fields(self, mock_produce): class WritePreprodBuildDistributionToEAPTest(TestCase): @patch("sentry.preprod.eap.write._eap_producer.produce") - def test_write_preprod_build_distribution_encodes_all_fields_correctly(self, mock_produce): + def test_write_preprod_build_distribution_encodes_all_fields_correctly( + self, mock_produce: MagicMock + ) -> None: commit_comparison = CommitComparison.objects.create( organization_id=self.organization.id, head_sha="abc123", @@ -290,7 +296,9 @@ def test_write_preprod_build_distribution_encodes_all_fields_correctly(self, moc assert attrs["git_pr_number"].int_value == 42 @patch("sentry.preprod.eap.write._eap_producer.produce") - def test_write_preprod_build_distribution_handles_optional_fields(self, mock_produce): + def test_write_preprod_build_distribution_handles_optional_fields( + self, mock_produce: MagicMock + ) -> None: artifact = self.create_preprod_artifact( project=self.project, state=PreprodArtifact.ArtifactState.PROCESSED, diff --git a/tests/sentry/preprod/size_analysis/test_compare.py b/tests/sentry/preprod/size_analysis/test_compare.py index 7d3c728c8b95..426048fe2749 100644 --- a/tests/sentry/preprod/size_analysis/test_compare.py +++ b/tests/sentry/preprod/size_analysis/test_compare.py @@ -40,13 +40,13 @@ def setUp(self) -> None: def _create_treemap_element( self, - name, - size, - path=None, - children=None, - element_type="files", + name: str, + size: int, + path: str | None = None, + children: list[TreemapElement] | None = None, + element_type: str = "files", flagged_insights: list[str | FlaggedInsight] | None = None, - ): + ) -> TreemapElement: return TreemapElement( name=name, size=size, @@ -57,17 +57,19 @@ def _create_treemap_element( flagged_insights=flagged_insights or [], ) - def _create_file_info(self, path: str, hash_value: str, children=None) -> FileInfo: + def _create_file_info( + self, path: str, hash_value: str, children: list[FileInfo] | None = None + ) -> FileInfo: return FileInfo(path=path, hash=hash_value, children=children or []) def _create_size_analysis_results( self, - download_size=500, - install_size=1000, - treemap_root=None, - file_analysis=None, - analysis_version=None, - ): + download_size: int = 500, + install_size: int = 1000, + treemap_root: TreemapElement | None = None, + file_analysis: FileAnalysis | None = None, + analysis_version: str | None = None, + ) -> SizeAnalysisResults: treemap = None if treemap_root: treemap = TreemapResults( @@ -569,7 +571,9 @@ def test_compare_size_analysis_duplicate_paths(self) -> None: class ShouldSkipDiffItemComparisonTest(TestCase): - def _create_size_analysis_results(self, analysis_version=None): + def _create_size_analysis_results( + self, analysis_version: str | None = None + ) -> SizeAnalysisResults: """Helper to create SizeAnalysisResults with specified analysis_version.""" return SizeAnalysisResults( analysis_duration=1.0, @@ -1319,8 +1323,8 @@ def setUp(self) -> None: def _create_size_analysis_results( self, insights: AndroidInsightResults | AppleInsightResults | None = None, - analysis_version="1.0.0", - ): + analysis_version: str = "1.0.0", + ) -> SizeAnalysisResults: # Use construct() to bypass Pydantic union coercion which would # otherwise convert AppleInsightResults to AndroidInsightResults return SizeAnalysisResults.construct( diff --git a/tests/sentry/preprod/size_analysis/test_grouptype.py b/tests/sentry/preprod/size_analysis/test_grouptype.py index 7690ec82a47c..00ccf6e1d3cc 100644 --- a/tests/sentry/preprod/size_analysis/test_grouptype.py +++ b/tests/sentry/preprod/size_analysis/test_grouptype.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from unittest import mock import pytest @@ -12,14 +12,16 @@ PreprodSizeAnalysisDetectorValidator, PreprodSizeAnalysisGroupType, SizeAnalysisDataPacket, + SizeAnalysisMetadata, SizeAnalysisValue, ) from sentry.testutils.cases import TestCase from sentry.testutils.silo import cell_silo_test from sentry.workflow_engine.models import DataPacket from sentry.workflow_engine.models.data_condition import Condition +from sentry.workflow_engine.processors import DetectorEvaluation from sentry.workflow_engine.processors.detector import process_detectors -from sentry.workflow_engine.types import DetectorPriorityLevel +from sentry.workflow_engine.types import DetectorGroupKey, DetectorPriorityLevel @cell_silo_test @@ -602,7 +604,9 @@ def setUp(self) -> None: condition_result=DetectorPriorityLevel.HIGH, ) - def _evaluate_with_metadata(self, measurement, metadata): + def _evaluate_with_metadata( + self, measurement: str, metadata: dict[str, Any] + ) -> dict[DetectorGroupKey, DetectorEvaluation]: detector = self.create_detector( name="test-detector", type=PreprodSizeAnalysisGroupType.slug, @@ -615,7 +619,7 @@ def _evaluate_with_metadata(self, measurement, metadata): packet={ "head_install_size_bytes": 5000000, "head_download_size_bytes": 5000000, - "metadata": metadata, + "metadata": cast("SizeAnalysisMetadata", metadata), }, ) handler = PreprodSizeAnalysisDetectorHandler(detector) @@ -868,7 +872,7 @@ def setUp(self) -> None: organization=self.project.organization, ) - def _create_condition(self, condition_type, comparison): + def _create_condition(self, condition_type: Condition, comparison: int) -> None: self.create_data_condition( condition_group=self.condition_group, type=condition_type, @@ -878,14 +882,14 @@ def _create_condition(self, condition_type, comparison): def _evaluate( self, - threshold_type, - measurement, - head_install, - head_download, - metadata=None, - base_install=None, - base_download=None, - ): + threshold_type: str, + measurement: str, + head_install: int, + head_download: int, + metadata: dict[str, Any] | None = None, + base_install: int | None = None, + base_download: int | None = None, + ) -> IssueOccurrence: detector = self.create_detector( name="test-detector", type=PreprodSizeAnalysisGroupType.slug, @@ -902,7 +906,7 @@ def _evaluate( if base_download is not None: packet_data["base_download_size_bytes"] = base_download if metadata is not None: - packet_data["metadata"] = metadata + packet_data["metadata"] = cast("SizeAnalysisMetadata", metadata) data_packet: SizeAnalysisDataPacket = DataPacket( source_id="test-source", diff --git a/tests/sentry/preprod/size_analysis/test_size_analysis_tasks.py b/tests/sentry/preprod/size_analysis/test_size_analysis_tasks.py index 1ae144e75607..31ccc4b198d0 100644 --- a/tests/sentry/preprod/size_analysis/test_size_analysis_tasks.py +++ b/tests/sentry/preprod/size_analysis/test_size_analysis_tasks.py @@ -1,4 +1,5 @@ -from datetime import timedelta +from datetime import datetime, timedelta +from typing import Any from unittest.mock import patch from django.utils import timezone @@ -19,6 +20,7 @@ maybe_emit_issues_from_diff_size_results, ) from sentry.testutils.cases import TestCase +from sentry.workflow_engine.models import Detector from sentry.workflow_engine.models.data_condition import Condition from sentry.workflow_engine.types import DetectorPriorityLevel @@ -28,12 +30,13 @@ class MaybeEmitIssuesFromDiffSizeResultsTest(TestCase): def _create_artifact_with_metrics( self, - max_install_size=5000000, - max_download_size=2000000, - date_added=None, - app_name=None, - **kwargs, - ): + max_install_size: int = 5000000, + max_download_size: int = 2000000, + date_added: datetime | None = None, + app_name: str | None = None, + # justification: factory kwargs vary per test case. + **kwargs: Any, + ) -> PreprodArtifact: artifact = self.create_preprod_artifact( project=self.project, app_id="com.example.app", @@ -51,7 +54,13 @@ def _create_artifact_with_metrics( ) return artifact - def _create_diff_detector(self, threshold_type="absolute_diff", comparison=500000, **kwargs): + def _create_diff_detector( + self, + threshold_type: str = "absolute_diff", + comparison: int = 500000, + # justification: detector config kwargs vary per test case. + **kwargs: Any, + ) -> Detector: condition_group = self.create_data_condition_group( organization=self.project.organization, ) @@ -350,7 +359,7 @@ def test_no_detectors(self) -> None: class GetPlatformTest(TestCase): - def _make_artifact(self, artifact_type): + def _make_artifact(self, artifact_type: int | None) -> PreprodArtifact: file = self.create_file(name="test", type="application/octet-stream") return self.create_preprod_artifact( project=self.project, file_id=file.id, artifact_type=artifact_type diff --git a/tests/sentry/preprod/size_analysis/test_webhooks.py b/tests/sentry/preprod/size_analysis/test_webhooks.py index 6f16a267e3e9..d5861f5f7d56 100644 --- a/tests/sentry/preprod/size_analysis/test_webhooks.py +++ b/tests/sentry/preprod/size_analysis/test_webhooks.py @@ -1,6 +1,10 @@ from io import BytesIO -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch +from sentry.models.commitcomparison import CommitComparison +from sentry.models.files.file import File +from sentry.preprod.api.models.public.size_analysis import SizeAnalysisSummaryResponseDict from sentry.preprod.models import ( PreprodArtifact, PreprodArtifactSizeComparison, @@ -26,12 +30,13 @@ class BuildWebhookPayloadTest(TestCase): # Helpers # ------------------------------------------------------------------ - def _create_analysis_file(self, data): + def _create_analysis_file(self, data: dict[str, Any]) -> File: f = self.create_file(name="analysis.json", type="application/json") f.putfile(BytesIO(json.dumps(data).encode())) return f - def _make_analysis_data(self, **overrides): + # justification: analysis payload overrides vary per test case. + def _make_analysis_data(self, **overrides: Any) -> dict[str, Any]: defaults = { "analysis_duration": 1.5, "download_size": 28311552, @@ -45,9 +50,14 @@ def _make_analysis_data(self, **overrides): defaults.update(overrides) return defaults + # justification: comparison payload shape varies per test case. def _make_comparison_data( - self, head_sizes, base_sizes, metrics_artifact_type=0, identifier=None - ): + self, + head_sizes: tuple[int, int], + base_sizes: tuple[int, int], + metrics_artifact_type: int = 0, + identifier: str | None = None, + ) -> dict[str, Any]: return { "diff_items": [{"size_diff": 100, "path": "/test", "type": "added"}], "insight_diff_items": [], @@ -64,16 +74,16 @@ def _make_comparison_data( def _create_artifact_with_completed_analysis( self, - commit_comparison=None, - artifact_type=PreprodArtifact.ArtifactType.XCARCHIVE, - app_name="My App", - build_version="2.4.1", - build_number=347, - download_size=28311552, - install_size=41943040, - app_id="com.example.myapp", - analysis_data_overrides=None, - ): + commit_comparison: CommitComparison | None = None, + artifact_type: int = PreprodArtifact.ArtifactType.XCARCHIVE, + app_name: str = "My App", + build_version: str = "2.4.1", + build_number: int = 347, + download_size: int = 28311552, + install_size: int = 41943040, + app_id: str = "com.example.myapp", + analysis_data_overrides: dict[str, Any] | None = None, + ) -> tuple[PreprodArtifact, PreprodArtifactSizeMetrics]: """Create an artifact with a COMPLETED main metric backed by an analysis file.""" artifact = self.create_preprod_artifact( project=self.project, @@ -100,7 +110,7 @@ def _create_artifact_with_completed_analysis( ) return artifact, metric - def _assert_no_api_heavy_fields(self, payload): + def _assert_no_api_heavy_fields(self, payload: SizeAnalysisSummaryResponseDict) -> None: """Assert that API-only heavy fields are absent.""" for field in ("insights", "appComponents"): assert field not in payload, f"API-only field '{field}' should not be present" @@ -591,12 +601,12 @@ def test_android_aab_artifact_type(self) -> None: class SendSizeAnalysisWebhookTest(TestCase): """Tests for the send_size_analysis_webhook function.""" - def _create_analysis_file(self, data): + def _create_analysis_file(self, data: dict[str, Any]) -> File: f = self.create_file(name="analysis.json", type="application/json") f.putfile(BytesIO(json.dumps(data).encode())) return f - def _create_artifact_with_completed_analysis(self): + def _create_artifact_with_completed_analysis(self) -> PreprodArtifact: artifact = self.create_preprod_artifact( project=self.project, artifact_type=PreprodArtifact.ArtifactType.XCARCHIVE, @@ -624,7 +634,7 @@ def _create_artifact_with_completed_analysis(self): return artifact @patch("sentry.preprod.size_analysis.webhooks.broadcast_webhooks_for_organization") - def test_sends_webhook(self, mock_broadcast): + def test_sends_webhook(self, mock_broadcast: MagicMock) -> None: """Webhook is sent for a completed build with API-subset payload.""" artifact = self._create_artifact_with_completed_analysis() @@ -641,7 +651,7 @@ def test_sends_webhook(self, mock_broadcast): assert call_kwargs.kwargs["payload"]["state"] == "COMPLETED" @patch("sentry.preprod.size_analysis.webhooks.broadcast_webhooks_for_organization") - def test_does_not_send_for_not_ran(self, mock_broadcast): + def test_does_not_send_for_not_ran(self, mock_broadcast: MagicMock) -> None: """Webhook is NOT sent for NOT_RAN state.""" artifact = self.create_preprod_artifact(project=self.project) self.create_preprod_artifact_size_metrics( @@ -656,7 +666,7 @@ def test_does_not_send_for_not_ran(self, mock_broadcast): mock_broadcast.delay.assert_not_called() @patch("sentry.preprod.size_analysis.webhooks.broadcast_webhooks_for_organization") - def test_does_not_send_for_no_metrics(self, mock_broadcast): + def test_does_not_send_for_no_metrics(self, mock_broadcast: MagicMock) -> None: """Webhook is NOT sent when no size metrics exist.""" artifact = self.create_preprod_artifact(project=self.project) @@ -665,7 +675,7 @@ def test_does_not_send_for_no_metrics(self, mock_broadcast): mock_broadcast.delay.assert_not_called() @patch("sentry.preprod.size_analysis.webhooks.broadcast_webhooks_for_organization") - def test_broadcast_exception_is_caught(self, mock_broadcast): + def test_broadcast_exception_is_caught(self, mock_broadcast: MagicMock) -> None: """Exceptions from broadcast are caught and logged, not re-raised.""" artifact = self._create_artifact_with_completed_analysis() mock_broadcast.delay.side_effect = Exception("Celery task failed") diff --git a/tests/sentry/preprod/snapshots/test_auto_approve.py b/tests/sentry/preprod/snapshots/test_auto_approve.py index 8065f7da5e21..ea56f1214f0e 100644 --- a/tests/sentry/preprod/snapshots/test_auto_approve.py +++ b/tests/sentry/preprod/snapshots/test_auto_approve.py @@ -5,7 +5,11 @@ import orjson from sentry.preprod.analytics import PreprodStatusCheckApprovalCreatedEvent -from sentry.preprod.models import PreprodArtifact, PreprodComparisonApproval +from sentry.preprod.models import ( + PreprodArtifact, + PreprodBuildConfiguration, + PreprodComparisonApproval, +) from sentry.preprod.snapshots.manifest import ( ComparisonImageResult, ComparisonManifest, @@ -54,7 +58,7 @@ def _make_manifest(self, images: dict[str, ComparisonImageResult]) -> Comparison images=images, ) - def test_mixed_statuses(self): + def test_mixed_statuses(self) -> None: manifest = self._make_manifest( { "unchanged.png": ComparisonImageResult( @@ -80,12 +84,12 @@ def test_mixed_statuses(self): ImageFingerprint("renamed.png", "renamed", "f", "old.png"), } - def test_empty_manifest_returns_empty_set(self): + def test_empty_manifest_returns_empty_set(self) -> None: manifest = self._make_manifest({}) fps = _build_comparison_fingerprints(manifest) assert fps == set() - def test_skips_changed_with_missing_head_hash(self): + def test_skips_changed_with_missing_head_hash(self) -> None: manifest = self._make_manifest( { "no_hash.png": ComparisonImageResult(status="changed", base_hash="abc"), @@ -97,7 +101,7 @@ def test_skips_changed_with_missing_head_hash(self): fps = _build_comparison_fingerprints(manifest) assert fps == {ImageFingerprint("has_hash.png", "changed", "def")} - def test_skipped_images_excluded_from_fingerprints(self): + def test_skipped_images_excluded_from_fingerprints(self) -> None: manifest = self._make_manifest( { "changed.png": ComparisonImageResult( @@ -109,7 +113,7 @@ def test_skipped_images_excluded_from_fingerprints(self): fps = _build_comparison_fingerprints(manifest) assert fps == {ImageFingerprint("changed.png", "changed", "b")} - def test_skips_renamed_with_missing_hash_or_previous_name(self): + def test_skips_renamed_with_missing_hash_or_previous_name(self) -> None: manifest = self._make_manifest( { "no_hash.png": ComparisonImageResult( @@ -126,11 +130,11 @@ def test_skips_renamed_with_missing_hash_or_previous_name(self): class HashOnlyDiffsTest(TestCase): - def test_identical_sets_return_empty_list(self): + def test_identical_sets_return_empty_list(self) -> None: fps = {ImageFingerprint("a.png", "changed", "h1")} assert _hash_only_diffs(fps, set(fps)) == [] - def test_hash_only_difference_is_returned(self): + def test_hash_only_difference_is_returned(self) -> None: head = { ImageFingerprint("a.png", "changed", "h1"), ImageFingerprint("b.png", "added", "same"), @@ -141,22 +145,22 @@ def test_hash_only_difference_is_returned(self): } assert _hash_only_diffs(head, sibling) == [_HashOnlyDiff("a.png", "h1", "h2")] - def test_renamed_hash_only_difference_is_returned(self): + def test_renamed_hash_only_difference_is_returned(self) -> None: head = {ImageFingerprint("new.png", "renamed", "h1", "old.png")} sibling = {ImageFingerprint("new.png", "renamed", "h2", "old.png")} assert _hash_only_diffs(head, sibling) == [_HashOnlyDiff("new.png", "h1", "h2")] - def test_renamed_with_different_previous_name_is_structural(self): + def test_renamed_with_different_previous_name_is_structural(self) -> None: head = {ImageFingerprint("new.png", "renamed", "h1", "old.png")} sibling = {ImageFingerprint("new.png", "renamed", "h1", "other.png")} assert _hash_only_diffs(head, sibling) is None - def test_status_difference_is_structural(self): + def test_status_difference_is_structural(self) -> None: head = {ImageFingerprint("a.png", "changed", "h1")} sibling = {ImageFingerprint("a.png", "added", "h1")} assert _hash_only_diffs(head, sibling) is None - def test_extra_head_image_is_structural(self): + def test_extra_head_image_is_structural(self) -> None: head = { ImageFingerprint("a.png", "changed", "h1"), ImageFingerprint("b.png", "changed", "h9"), @@ -164,7 +168,7 @@ def test_extra_head_image_is_structural(self): sibling = {ImageFingerprint("a.png", "changed", "h1")} assert _hash_only_diffs(head, sibling) is None - def test_missing_head_image_is_structural(self): + def test_missing_head_image_is_structural(self) -> None: head = {ImageFingerprint("a.png", "changed", "h1")} sibling = { ImageFingerprint("a.png", "changed", "h1"), @@ -172,12 +176,12 @@ def test_missing_head_image_is_structural(self): } assert _hash_only_diffs(head, sibling) is None - def test_removed_and_errored_never_produce_pairs(self): + def test_removed_and_errored_never_produce_pairs(self) -> None: head = {ImageFingerprint("a.png", "removed"), ImageFingerprint("b.png", "errored")} sibling = {ImageFingerprint("a.png", "removed"), ImageFingerprint("b.png", "errored")} assert _hash_only_diffs(head, sibling) == [] - def test_pairs_are_sorted_by_name(self): + def test_pairs_are_sorted_by_name(self) -> None: head = { ImageFingerprint("z.png", "changed", "z1"), ImageFingerprint("a.png", "changed", "a1"), @@ -195,7 +199,7 @@ def test_pairs_are_sorted_by_name(self): def _mock_session_with_manifests(manifests_by_key: dict[str, bytes]) -> MagicMock: session = MagicMock() - def _get(key): + def _get(key: str) -> MagicMock: result = MagicMock() if key in manifests_by_key: result.payload.read.return_value = manifests_by_key[key] @@ -209,7 +213,7 @@ def _get(key): @cell_silo_test class TryAutoApproveSnapshotTest(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.organization = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.organization) @@ -218,9 +222,9 @@ def setUp(self): def _create_approved_sibling( self, pr_number: int, - comparison_images: dict, + comparison_images: dict[str, ComparisonImageResult], app_id: str = "com.example.app", - build_configuration=None, + build_configuration: PreprodBuildConfiguration | None = None, ) -> tuple[PreprodArtifact, str, bytes]: cc = self.create_commit_comparison( organization=self.organization, @@ -286,7 +290,7 @@ def _create_approved_sibling( ) return artifact, comparison_key, orjson.dumps(manifest.dict()) - def _create_head_manifest(self, images: dict) -> ComparisonManifest: + def _create_head_manifest(self, images: dict[str, ComparisonImageResult]) -> ComparisonManifest: return ComparisonManifest( head_artifact_id=999, base_artifact_id=998, @@ -336,7 +340,7 @@ def _approve( head_artifact, head_manifest, plan, sibling_images or {}, session ) - def test_find_approved_sibling_returns_human_sibling_manifest(self): + def test_find_approved_sibling_returns_human_sibling_manifest(self) -> None: images = { "screen1.png": ComparisonImageResult( status="changed", head_hash="abc", base_hash="old1" @@ -366,14 +370,14 @@ def test_find_approved_sibling_returns_human_sibling_manifest(self): assert call(human_key) in session.get.call_args_list assert call(auto_key) not in session.get.call_args_list - def test_find_approved_sibling_returns_none_without_pr(self): + def test_find_approved_sibling_returns_none_without_pr(self) -> None: cc = self.create_commit_comparison(organization=self.organization, pr_number=None) head_artifact = self.create_preprod_artifact( project=self.project, commit_comparison=cc, app_id="com.example.app" ) assert _find_approved_sibling(head_artifact, MagicMock()) is None - def test_find_approved_sibling_returns_none_without_snapshot_manifest_key(self): + def test_find_approved_sibling_returns_none_without_snapshot_manifest_key(self) -> None: images = { "screen1.png": ComparisonImageResult( status="changed", head_hash="abc", base_hash="old1" @@ -389,7 +393,7 @@ def test_find_approved_sibling_returns_none_without_snapshot_manifest_key(self): assert _find_approved_sibling(head_artifact, session) is None @patch("sentry.analytics.record") - def test_auto_approves_when_fingerprints_match(self, mock_analytics): + def test_auto_approves_when_fingerprints_match(self, mock_analytics: MagicMock) -> None: shared_images = { "screen1.png": ComparisonImageResult( status="changed", head_hash="abc", base_hash="old1" @@ -455,7 +459,7 @@ def test_auto_approves_when_fingerprints_match(self, mock_analytics): ) @patch("sentry.analytics.record") - def test_no_auto_approve_when_fingerprints_differ(self, mock_analytics): + def test_no_auto_approve_when_fingerprints_differ(self, mock_analytics: MagicMock) -> None: sibling_images = { "screen1.png": ComparisonImageResult( status="changed", head_hash="abc", base_hash="old1" @@ -496,7 +500,7 @@ def test_no_auto_approve_when_fingerprints_differ(self, mock_analytics): assert_not_analytics_event(mock_analytics, PreprodStatusCheckApprovalCreatedEvent) - def test_no_auto_approve_when_no_pr_number(self): + def test_no_auto_approve_when_no_pr_number(self) -> None: cc = self.create_commit_comparison( organization=self.organization, pr_number=None, @@ -517,7 +521,7 @@ def test_no_auto_approve_when_no_pr_number(self): approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, ).exists() - def test_no_auto_approve_when_no_approved_sibling(self): + def test_no_auto_approve_when_no_approved_sibling(self) -> None: cc = self.create_commit_comparison( organization=self.organization, pr_number=42, @@ -540,7 +544,7 @@ def test_no_auto_approve_when_no_approved_sibling(self): approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, ).exists() - def test_no_auto_approve_when_no_changes(self): + def test_no_auto_approve_when_no_changes(self) -> None: sibling, comp_key, comp_json = self._create_approved_sibling( pr_number=42, comparison_images={ @@ -565,7 +569,7 @@ def test_no_auto_approve_when_no_changes(self): ).exists() session.get.assert_not_called() - def test_handles_missing_comparison_manifest(self): + def test_handles_missing_comparison_manifest(self) -> None: sibling, comp_key, _ = self._create_approved_sibling( pr_number=42, comparison_images={ @@ -589,7 +593,7 @@ def test_handles_missing_comparison_manifest(self): approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, ).exists() - def test_matches_on_app_id_and_build_config(self): + def test_matches_on_app_id_and_build_config(self) -> None: shared_images = { "screen1.png": ComparisonImageResult( status="changed", head_hash="abc", base_hash="old1" @@ -605,7 +609,7 @@ def test_matches_on_app_id_and_build_config(self): assert _find_approved_sibling(head_artifact, MagicMock()) is None - def test_no_auto_approve_when_sibling_not_approved_for_snapshots(self): + def test_no_auto_approve_when_sibling_not_approved_for_snapshots(self) -> None: cc = self.create_commit_comparison( organization=self.organization, pr_number=42, @@ -644,7 +648,7 @@ def test_no_auto_approve_when_sibling_not_approved_for_snapshots(self): assert _find_approved_sibling(head_artifact, MagicMock()) is None - def test_auto_approves_with_renamed_images(self): + def test_auto_approves_with_renamed_images(self) -> None: shared_images = { "new_name.png": ComparisonImageResult( status="renamed", head_hash="abc", previous_image_file_name="old_name.png" @@ -681,7 +685,7 @@ def test_auto_approves_with_renamed_images(self): approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED, ).exists() - def test_auto_approves_selective_build_against_full_build(self): + def test_auto_approves_selective_build_against_full_build(self) -> None: sibling_images = { "screen1.png": ComparisonImageResult( status="changed", head_hash="abc", base_hash="old1" @@ -734,7 +738,7 @@ def test_auto_approves_selective_build_against_full_build(self): assert approval.extras["prev_approved_artifact_id"] == sibling.id @patch("sentry.analytics.record") - def test_auto_approves_when_sibling_results_unchanged(self, mock_analytics): + def test_auto_approves_when_sibling_results_unchanged(self, mock_analytics: MagicMock) -> None: sibling, comp_key, comp_json = self._create_approved_sibling( pr_number=42, comparison_images={ @@ -783,7 +787,7 @@ def test_auto_approves_when_sibling_results_unchanged(self, mock_analytics): ), ) - def test_no_auto_approve_when_sibling_result_changed(self): + def test_no_auto_approve_when_sibling_result_changed(self) -> None: sibling, comp_key, comp_json = self._create_approved_sibling( pr_number=42, comparison_images={ @@ -814,7 +818,7 @@ def test_no_auto_approve_when_sibling_result_changed(self): ) assert not PreprodComparisonApproval.objects.filter(preprod_artifact=head_artifact).exists() - def test_no_auto_approve_when_sibling_result_missing(self): + def test_no_auto_approve_when_sibling_result_missing(self) -> None: sibling, comp_key, comp_json = self._create_approved_sibling( pr_number=42, comparison_images={ @@ -840,7 +844,7 @@ def test_no_auto_approve_when_sibling_result_missing(self): ) assert not PreprodComparisonApproval.objects.filter(preprod_artifact=head_artifact).exists() - def test_no_auto_approve_when_sibling_result_hashes_mismatch(self): + def test_no_auto_approve_when_sibling_result_hashes_mismatch(self) -> None: sibling, comp_key, comp_json = self._create_approved_sibling( pr_number=42, comparison_images={ @@ -871,7 +875,7 @@ def test_no_auto_approve_when_sibling_result_hashes_mismatch(self): ) assert not PreprodComparisonApproval.objects.filter(preprod_artifact=head_artifact).exists() - def test_no_auto_approve_when_plan_has_no_sibling(self): + def test_no_auto_approve_when_plan_has_no_sibling(self) -> None: head_artifact = self._create_head_artifact() head_manifest = self._create_head_manifest( {"screen1.png": ComparisonImageResult(status="changed", head_hash="h", base_hash="b")} @@ -881,7 +885,7 @@ def test_no_auto_approve_when_plan_has_no_sibling(self): assert not PreprodComparisonApproval.objects.filter(preprod_artifact=head_artifact).exists() session.get.assert_not_called() - def test_no_auto_approve_when_status_differs_even_if_pixels_match(self): + def test_no_auto_approve_when_status_differs_even_if_pixels_match(self) -> None: sibling, comp_key, comp_json = self._create_approved_sibling( pr_number=42, comparison_images={ @@ -909,7 +913,7 @@ def test_no_auto_approve_when_status_differs_even_if_pixels_match(self): assert not PreprodComparisonApproval.objects.filter(preprod_artifact=head_artifact).exists() session.get.assert_called_once_with(comp_key) - def test_sibling_selection_skips_auto_approved_builds(self): + def test_sibling_selection_skips_auto_approved_builds(self) -> None: images = { "screen1.png": ComparisonImageResult( status="changed", head_hash="abc", base_hash="old1" @@ -937,7 +941,7 @@ def test_sibling_selection_skips_auto_approved_builds(self): assert call(human_key) in session.get.call_args_list assert call(auto_key) not in session.get.call_args_list - def test_sibling_selection_keeps_human_approval_with_other_extras(self): + def test_sibling_selection_keeps_human_approval_with_other_extras(self) -> None: images = { "screen1.png": ComparisonImageResult( status="changed", head_hash="abc", base_hash="old1" diff --git a/tests/sentry/preprod/snapshots/test_categorize.py b/tests/sentry/preprod/snapshots/test_categorize.py index 120c8708bf14..fa9a1dbfa647 100644 --- a/tests/sentry/preprod/snapshots/test_categorize.py +++ b/tests/sentry/preprod/snapshots/test_categorize.py @@ -20,7 +20,7 @@ def _m( ) -def test_full_head_marks_missing_base_images_removed(): +def test_full_head_marks_missing_base_images_removed() -> None: head = _m({"a": "h1", "b": "h2"}) base = _m({"a": "h0", "c": "h3"}) matched, added, removed, skipped = categorize_image_sets(head, base) @@ -30,7 +30,7 @@ def test_full_head_marks_missing_base_images_removed(): assert skipped == set() -def test_selective_no_list_skips_uninvolved_base_images(): +def test_selective_no_list_skips_uninvolved_base_images() -> None: head = _m({"a": "h1"}, selective=True) base = _m({"a": "h0", "c": "h3"}) matched, added, removed, skipped = categorize_image_sets(head, base) @@ -40,7 +40,7 @@ def test_selective_no_list_skips_uninvolved_base_images(): assert skipped == {"c"} -def test_selective_with_all_names_expresses_removals(): +def test_selective_with_all_names_expresses_removals() -> None: head = _m({"a": "h1"}, selective=True, all_image_file_names=["a", "c"]) base = _m({"a": "h0", "c": "h3", "d": "h4"}) matched, added, removed, skipped = categorize_image_sets(head, base) diff --git a/tests/sentry/preprod/snapshots/test_compare_snapshots.py b/tests/sentry/preprod/snapshots/test_compare_snapshots.py index b1b3e3d4d37f..346e9c3df1ad 100644 --- a/tests/sentry/preprod/snapshots/test_compare_snapshots.py +++ b/tests/sentry/preprod/snapshots/test_compare_snapshots.py @@ -1,12 +1,19 @@ from __future__ import annotations +from typing import Any, cast from unittest.mock import ANY, MagicMock, call, patch import orjson import pytest from objectstore_client import RequestError -from sentry.preprod.snapshots.image_diff.types import ImageSize +from sentry.preprod.models import PreprodArtifact +from sentry.preprod.snapshots.image_diff.types import DiffResult, ImageSize +from sentry.preprod.snapshots.manifest import ( + ChunkResult, + ComparisonPlan, + SnapshotManifest, +) from sentry.preprod.snapshots.models import PreprodSnapshotComparison from sentry.preprod.snapshots.tasks import _put_json, _retry_objectstore from sentry.testutils.cases import TestCase @@ -26,11 +33,11 @@ def _comparison(self) -> PreprodSnapshotComparison: state=PreprodSnapshotComparison.State.PROCESSING, ) - def test_chunks_total_nullable_default(self): + def test_chunks_total_nullable_default(self) -> None: comparison = self._comparison() assert comparison.chunks_total is None - def test_mark_chunk_done_accumulates_distinct_indices(self): + def test_mark_chunk_done_accumulates_distinct_indices(self) -> None: from sentry.preprod.snapshots.tasks import _mark_chunk_done comparison = self._comparison() @@ -40,7 +47,7 @@ def test_mark_chunk_done_accumulates_distinct_indices(self): comparison.refresh_from_db() assert sorted(comparison.chunks_done_indices) == [0, 2] - def test_mark_chunk_done_bumps_date_updated(self): + def test_mark_chunk_done_bumps_date_updated(self) -> None: from datetime import timedelta from django.utils import timezone @@ -79,7 +86,7 @@ def test_put_json_serializes_once_across_retries(mock_sleep: MagicMock) -> None: def test_retry_objectstore_retries_once_on_429(mock_sleep: MagicMock) -> None: calls = {"n": 0} - def op(): + def op() -> str: calls["n"] += 1 if calls["n"] == 1: raise RequestError("rate limited", 429, "rate limited") @@ -90,8 +97,8 @@ def op(): mock_sleep.assert_called_once_with(0.5) -def test_retry_objectstore_fails_fast_on_404(): - def op(): +def test_retry_objectstore_fails_fast_on_404() -> None: + def op() -> None: raise RequestError("missing", 404, "missing") with pytest.raises(RequestError): @@ -112,7 +119,7 @@ def test_retry_objectstore_gives_up_after_max_attempts(mock_sleep: MagicMock) -> def _mock_session_with_manifests(manifests_by_key: dict[str, bytes]) -> MagicMock: session = MagicMock() - def _get(key): + def _get(key: str) -> MagicMock | None: if key not in manifests_by_key: return None result = MagicMock() @@ -126,14 +133,14 @@ def _get(key): def _dict_backed_session(stored: dict[str, bytes]) -> MagicMock: session = MagicMock() - def _get(key): + def _get(key: str) -> MagicMock | None: if key not in stored: return None result = MagicMock() result.payload.read.return_value = stored[key] return result - def _put(contents, key, content_type): + def _put(contents: bytes, key: str, content_type: str) -> None: stored[key] = contents session.get.side_effect = _get @@ -143,7 +150,7 @@ def _put(contents, key, content_type): @cell_silo_test class ProcessChunkTest(TestCase): - def test_comparison_defaults_images_errored_to_zero(self): + def test_comparison_defaults_images_errored_to_zero(self) -> None: head_artifact = self.create_preprod_artifact(project=self.project) base_artifact = self.create_preprod_artifact(project=self.project) head = self.create_preprod_snapshot_metrics(head_artifact) @@ -154,7 +161,7 @@ def test_comparison_defaults_images_errored_to_zero(self): ) assert comparison.images_errored == 0 - def test_chunk_processes_slice_and_records_done_index(self): + def test_chunk_processes_slice_and_records_done_index(self) -> None: from sentry.preprod.snapshots.image_diff.types import DiffResult from sentry.preprod.snapshots.manifest import ( ChunkAssignment, @@ -244,7 +251,7 @@ def test_chunk_processes_slice_and_records_done_index(self): comparison.refresh_from_db() assert comparison.chunks_done_indices == [0] - def test_chunk_writes_sibling_results_separately(self): + def test_chunk_writes_sibling_results_separately(self) -> None: from sentry.preprod.snapshots.image_diff.types import DiffResult from sentry.preprod.snapshots.manifest import ( ChunkAssignment, @@ -323,7 +330,9 @@ def test_chunk_writes_sibling_results_separately(self): assert stored_result.images == {} assert set(stored_result.sibling_images) == {"a.png"} - def _comparison(self, chunks_total, done_indices=None): + def _comparison( + self, chunks_total: int | None, done_indices: list[int] | None = None + ) -> tuple[PreprodSnapshotComparison, PreprodArtifact, PreprodArtifact]: head_artifact = self.create_preprod_artifact(project=self.project) base_artifact = self.create_preprod_artifact(project=self.project) head = self.create_preprod_snapshot_metrics(head_artifact) @@ -333,13 +342,15 @@ def _comparison(self, chunks_total, done_indices=None): base_snapshot_metrics=base, state=PreprodSnapshotComparison.State.PROCESSING, ) - comparison.chunks_total = chunks_total + comparison.chunks_total = cast("int", chunks_total) if done_indices is not None: comparison.chunks_done_indices = done_indices comparison.save() return comparison, head_artifact, base_artifact - def _single_chunk_plan(self, head_artifact, base_artifact): + def _single_chunk_plan( + self, head_artifact: PreprodArtifact, base_artifact: PreprodArtifact + ) -> ComparisonPlan: from sentry.preprod.snapshots.manifest import ( ChunkAssignment, ChunkCandidate, @@ -366,7 +377,7 @@ def _single_chunk_plan(self, head_artifact, base_artifact): non_diff_images={}, ) - def _diff_result(self): + def _diff_result(self) -> DiffResult: from sentry.preprod.snapshots.image_diff.types import DiffResult return DiffResult( @@ -380,7 +391,7 @@ def _diff_result(self): after_height=10, ) - def test_chunk_triggers_finalize_when_last(self): + def test_chunk_triggers_finalize_when_last(self) -> None: from sentry.preprod.snapshots.tasks import process_snapshot_comparison_chunk comparison, head_artifact, base_artifact = self._comparison(1) @@ -420,7 +431,7 @@ def test_chunk_triggers_finalize_when_last(self): assert 0 in comparison.chunks_done_indices assert finalize.call_count == 1 - def test_chunk_does_not_trigger_finalize_when_not_last(self): + def test_chunk_does_not_trigger_finalize_when_not_last(self) -> None: from sentry.preprod.snapshots.tasks import process_snapshot_comparison_chunk comparison, head_artifact, base_artifact = self._comparison(2) @@ -460,7 +471,7 @@ def test_chunk_does_not_trigger_finalize_when_not_last(self): assert comparison.chunks_done_indices == [0] assert not finalize.called - def test_chunk_with_unchanged_diff_hash_writes_result(self): + def test_chunk_with_unchanged_diff_hash_writes_result(self) -> None: from sentry.preprod.snapshots.image_diff.types import DiffResult from sentry.preprod.snapshots.tasks import process_snapshot_comparison_chunk @@ -511,7 +522,7 @@ def test_chunk_with_unchanged_diff_hash_writes_result(self): result = orjson.loads(stored[chunk_key]) assert result["images"]["a.png"]["status"] == "unchanged" - def test_chunk_hard_failure_still_recorded_and_triggers_finalize(self): + def test_chunk_hard_failure_still_recorded_and_triggers_finalize(self) -> None: from sentry.preprod.snapshots.tasks import process_snapshot_comparison_chunk comparison, head_artifact, base_artifact = self._comparison(1) @@ -547,7 +558,7 @@ def test_chunk_hard_failure_still_recorded_and_triggers_finalize(self): assert f"{prefix}/chunks/0.json" not in stored assert finalize.call_count == 1 - def test_chunk_failure_logs_exception_details(self): + def test_chunk_failure_logs_exception_details(self) -> None: from sentry.preprod.snapshots.tasks import process_snapshot_comparison_chunk comparison, head_artifact, base_artifact = self._comparison(1) @@ -588,8 +599,11 @@ def test_chunk_failure_logs_exception_details(self): @cell_silo_test class FinalizeSnapshotComparisonTest(TestCase): def _comparison( - self, chunks_total, state=PreprodSnapshotComparison.State.PROCESSING, done_indices=None - ): + self, + chunks_total: int | None, + state: int = PreprodSnapshotComparison.State.PROCESSING, + done_indices: list[int] | None = None, + ) -> tuple[PreprodSnapshotComparison, PreprodArtifact, PreprodArtifact]: head_artifact = self.create_preprod_artifact(project=self.project) base_artifact = self.create_preprod_artifact(project=self.project) head = self.create_preprod_snapshot_metrics(head_artifact) @@ -597,13 +611,18 @@ def _comparison( comparison = self.create_preprod_snapshot_comparison( head_snapshot_metrics=head, base_snapshot_metrics=base, state=state ) - comparison.chunks_total = chunks_total + comparison.chunks_total = cast("int", chunks_total) if done_indices is not None: comparison.chunks_done_indices = done_indices comparison.save() return comparison, head_artifact, base_artifact - def _kwargs(self, comparison, head_artifact, base_artifact): + def _kwargs( + self, + comparison: PreprodSnapshotComparison, + head_artifact: PreprodArtifact, + base_artifact: PreprodArtifact, + ) -> dict[str, int]: return dict( comparison_id=comparison.id, org_id=self.organization.id, @@ -612,7 +631,7 @@ def _kwargs(self, comparison, head_artifact, base_artifact): base_artifact_id=base_artifact.id, ) - def _single_chunk_plan(self, h, b): + def _single_chunk_plan(self, h: PreprodArtifact, b: PreprodArtifact) -> ComparisonPlan: from sentry.preprod.snapshots.manifest import ( ChunkAssignment, ChunkCandidate, @@ -639,12 +658,14 @@ def _single_chunk_plan(self, h, b): non_diff_images={}, ) - def _changed_chunk_result(self): + def _changed_chunk_result(self) -> ChunkResult: from sentry.preprod.snapshots.manifest import ChunkResult, ComparisonImageResult return ChunkResult(chunk_index=0, images={"a.png": ComparisonImageResult(status="changed")}) - def _finalize_with_extras(self, extras): + def _finalize_with_extras( + self, extras: dict[str, object] | None + ) -> tuple[PreprodSnapshotComparison, list[object]]: from sentry.preprod.snapshots.tasks import finalize_snapshot_comparison comparison, h, b = self._comparison(1, done_indices=[0]) @@ -670,7 +691,7 @@ def _finalize_with_extras(self, extras): ] return comparison, diff_calls - def test_finalize_skips_diff_duration_metric_on_missing_or_invalid_timestamp(self): + def test_finalize_skips_diff_duration_metric_on_missing_or_invalid_timestamp(self) -> None: missing, missing_calls = self._finalize_with_extras({}) assert missing.state == PreprodSnapshotComparison.State.SUCCESS assert missing_calls == [] @@ -681,7 +702,7 @@ def test_finalize_skips_diff_duration_metric_on_missing_or_invalid_timestamp(sel assert invalid.state == PreprodSnapshotComparison.State.SUCCESS assert invalid_calls == [] - def test_terminal_state_skips_finalize(self): + def test_terminal_state_skips_finalize(self) -> None: from sentry.preprod.snapshots.tasks import finalize_snapshot_comparison comparison, h, b = self._comparison(1, state=PreprodSnapshotComparison.State.SUCCESS) @@ -689,7 +710,7 @@ def test_terminal_state_skips_finalize(self): finalize_snapshot_comparison(**self._kwargs(comparison, h, b)) assert not session.called - def test_all_done_finalizes(self): + def test_all_done_finalizes(self) -> None: from sentry.preprod.snapshots.tasks import finalize_snapshot_comparison comparison, h, b = self._comparison(1, done_indices=[0]) @@ -720,7 +741,7 @@ def test_all_done_finalizes(self): assert called_manifest.head_artifact_id == h.id assert called_session is session - def test_finalize_passes_sibling_results_to_auto_approve(self): + def test_finalize_passes_sibling_results_to_auto_approve(self) -> None: from sentry.preprod.snapshots.manifest import ( ChunkAssignment, ChunkCandidate, @@ -779,7 +800,7 @@ def test_finalize_passes_sibling_results_to_auto_approve(self): assert called_sibling_images == sibling_images assert called_plan.sibling_artifact_id == 77 - def test_finalize_writes_images_errored_column(self): + def test_finalize_writes_images_errored_column(self) -> None: from sentry.preprod.snapshots.tasks import finalize_snapshot_comparison comparison, h, b = self._comparison(1, done_indices=[1]) @@ -792,7 +813,7 @@ def test_finalize_writes_images_errored_column(self): assert comparison.state == PreprodSnapshotComparison.State.SUCCESS assert comparison.images_errored == 1 - def test_finalize_is_exactly_once(self): + def test_finalize_is_exactly_once(self) -> None: from sentry.preprod.snapshots.tasks import finalize_snapshot_comparison comparison, h, b = self._comparison(1, done_indices=[0]) @@ -812,7 +833,7 @@ def test_finalize_is_exactly_once(self): assert comparison.state == PreprodSnapshotComparison.State.SUCCESS assert mock_auto_approve.call_count == 1 - def test_chunks_total_none_skips_finalize(self): + def test_chunks_total_none_skips_finalize(self) -> None: from sentry.preprod.snapshots.tasks import finalize_snapshot_comparison comparison, h, b = self._comparison(None) @@ -822,7 +843,7 @@ def test_chunks_total_none_skips_finalize(self): comparison.refresh_from_db() assert comparison.state == PreprodSnapshotComparison.State.PROCESSING - def test_not_all_done_skips_finalize(self): + def test_not_all_done_skips_finalize(self) -> None: from sentry.preprod.snapshots.tasks import finalize_snapshot_comparison comparison, h, b = self._comparison(3, done_indices=[0]) @@ -832,7 +853,7 @@ def test_not_all_done_skips_finalize(self): comparison.refresh_from_db() assert comparison.state == PreprodSnapshotComparison.State.PROCESSING - def test_finalize_heartbeats_before_assembly(self): + def test_finalize_heartbeats_before_assembly(self) -> None: from datetime import timedelta from django.utils import timezone @@ -852,7 +873,8 @@ def test_finalize_heartbeats_before_assembly(self): date_updated_at_finalize = {} - def _capture(*args, **kwargs): + # justification: mock side_effect receives arbitrary patch arguments. + def _capture(*args: Any, **kwargs: Any) -> MagicMock: date_updated_at_finalize["value"] = PreprodSnapshotComparison.objects.values_list( "date_updated", flat=True ).get(id=comparison.id) @@ -867,7 +889,7 @@ def _capture(*args, **kwargs): comparison.refresh_from_db() assert comparison.state == PreprodSnapshotComparison.State.SUCCESS - def test_finalize_marks_missing_chunk_images_errored(self): + def test_finalize_marks_missing_chunk_images_errored(self) -> None: from sentry.preprod.snapshots.tasks import finalize_snapshot_comparison comparison, h, b = self._comparison(1, done_indices=[1]) @@ -882,7 +904,7 @@ def test_finalize_marks_missing_chunk_images_errored(self): assert comparison_manifest["summary"]["errored"] == 1 assert comparison_manifest["images"]["a.png"]["status"] == "errored" - def test_finalize_degrades_when_done_chunk_result_unreadable(self): + def test_finalize_degrades_when_done_chunk_result_unreadable(self) -> None: from sentry.preprod.snapshots.tasks import finalize_snapshot_comparison comparison, h, b = self._comparison(1, done_indices=[0]) @@ -897,7 +919,7 @@ def test_finalize_degrades_when_done_chunk_result_unreadable(self): assert comparison_manifest["summary"]["errored"] == 1 assert comparison_manifest["images"]["a.png"]["status"] == "errored" - def test_finalize_fails_when_plan_unreadable(self): + def test_finalize_fails_when_plan_unreadable(self) -> None: from sentry.preprod.snapshots.tasks import finalize_snapshot_comparison comparison, h, b = self._comparison(1, done_indices=[0]) @@ -915,7 +937,7 @@ def test_finalize_fails_when_plan_unreadable(self): @cell_silo_test class CompareSnapshotsOrchestratorTest(TestCase): - def _setup(self): + def _setup(self) -> tuple[PreprodArtifact, PreprodArtifact]: head_artifact = self.create_preprod_artifact(project=self.project) base_artifact = self.create_preprod_artifact(project=self.project) head = self.create_preprod_snapshot_metrics(head_artifact) @@ -926,7 +948,7 @@ def _setup(self): base.save() return head_artifact, base_artifact - def test_orchestrator_dispatches_and_sets_total(self): + def test_orchestrator_dispatches_and_sets_total(self) -> None: import orjson from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest @@ -972,7 +994,7 @@ def test_orchestrator_dispatches_and_sets_total(self): ) # finalizer sets SUCCESS, not orchestrator assert dispatch.call_count == 1 - def test_orchestrator_records_sibling_in_plan(self): + def test_orchestrator_records_sibling_in_plan(self) -> None: import orjson from sentry.preprod.snapshots.manifest import ( @@ -1062,7 +1084,7 @@ def test_orchestrator_records_sibling_in_plan(self): assert called_head.id == head_artifact.id assert called_session is session - def test_orchestrator_finalizes_when_no_diff_chunks(self): + def test_orchestrator_finalizes_when_no_diff_chunks(self) -> None: import orjson from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest @@ -1109,7 +1131,7 @@ def test_orchestrator_finalizes_when_no_diff_chunks(self): assert not dispatch.called assert finalize.called - def test_orchestrator_resumes_stuck_processing_row(self): + def test_orchestrator_resumes_stuck_processing_row(self) -> None: import orjson from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest @@ -1127,7 +1149,7 @@ def test_orchestrator_resumes_stuck_processing_row(self): base_snapshot_metrics=base_metrics, state=PreprodSnapshotComparison.State.PROCESSING, ) - assert comparison.chunks_total is None + assert cast("object", comparison.chunks_total) is None head_manifest = SnapshotManifest( images={"changed.png": ImageMetadata(content_hash="h1", width=100, height=100)}, @@ -1165,7 +1187,7 @@ def test_orchestrator_resumes_stuck_processing_row(self): assert comparison.chunks_total == 1 assert dispatch.call_count == 1 - def test_orchestrator_finalizes_when_chunks_already_done(self): + def test_orchestrator_finalizes_when_chunks_already_done(self) -> None: import orjson from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest @@ -1222,7 +1244,7 @@ def test_orchestrator_finalizes_when_chunks_already_done(self): assert comparison.chunks_total == 1 assert finalize.called - def test_resume_of_failed_row_preserves_completed_chunks(self): + def test_resume_of_failed_row_preserves_completed_chunks(self) -> None: import orjson from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest @@ -1277,7 +1299,7 @@ def test_resume_of_failed_row_preserves_completed_chunks(self): assert comparison.chunks_total == 1 assert comparison.chunks_done_indices == [0] - def test_manifest_failure_does_not_clobber_advanced_row(self): + def test_manifest_failure_does_not_clobber_advanced_row(self) -> None: from sentry.preprod.snapshots.models import ( PreprodSnapshotComparison, PreprodSnapshotMetrics, @@ -1293,7 +1315,7 @@ def test_manifest_failure_does_not_clobber_advanced_row(self): state=PreprodSnapshotComparison.State.PROCESSING, ) - def _get(key): + def _get(key: str) -> None: PreprodSnapshotComparison.objects.filter(id=comparison.id).update( state=PreprodSnapshotComparison.State.SUCCESS ) @@ -1320,7 +1342,7 @@ def _get(key): ] assert failure_calls == [] - def test_manifest_failure_on_processing_row_flips_to_failed(self): + def test_manifest_failure_on_processing_row_flips_to_failed(self) -> None: from sentry.preprod.snapshots.models import ( PreprodSnapshotComparison, PreprodSnapshotMetrics, @@ -1358,7 +1380,7 @@ def test_manifest_failure_on_processing_row_flips_to_failed(self): ] assert len(failure_calls) == 1 - def test_missing_base_manifest_fails_with_base_manifest_missing(self): + def test_missing_base_manifest_fails_with_base_manifest_missing(self) -> None: from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest from sentry.preprod.snapshots.tasks import compare_snapshots @@ -1400,7 +1422,7 @@ def test_missing_base_manifest_fails_with_base_manifest_missing(self): assert len(failure_calls) == 1 assert failure_calls[0].kwargs["preprod_artifact_id"] == head_artifact.id - def test_orchestrator_skips_processing_row_with_chunks_total(self): + def test_orchestrator_skips_processing_row_with_chunks_total(self) -> None: from sentry.preprod.snapshots.models import ( PreprodSnapshotComparison, PreprodSnapshotMetrics, @@ -1437,7 +1459,9 @@ def test_orchestrator_skips_processing_row_with_chunks_total(self): comparison.refresh_from_db() assert comparison.chunks_total == 2 - def _setup_selective_chain(self): + def _setup_selective_chain( + self, + ) -> tuple[PreprodArtifact, PreprodArtifact, PreprodArtifact]: # main(full) -> PR1(selective, base=main); head (PR2) compares against PR1. from sentry.models.commitcomparison import CommitComparison @@ -1477,7 +1501,7 @@ def _setup_selective_chain(self): m_pr2.save() return a_pr2, a_pr1, a_main - def test_compare_against_selective_base_reconstructs_and_processes(self): + def test_compare_against_selective_base_reconstructs_and_processes(self) -> None: import orjson from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest @@ -1526,7 +1550,7 @@ def test_compare_against_selective_base_reconstructs_and_processes(self): # base lacks "a", so "a" would be an add with zero diff chunks. assert comparison.chunks_total == 1 - def test_reconstruction_gated_on_manifest_not_db_flag(self): + def test_reconstruction_gated_on_manifest_not_db_flag(self) -> None: import orjson from sentry.preprod.models import PreprodSnapshotMetrics @@ -1583,7 +1607,7 @@ def test_reconstruction_gated_on_manifest_not_db_flag(self): # treat "a" as an add (zero chunks). assert comparison.chunks_total == 1 - def _complete_chain_manifests(self): + def _complete_chain_manifests(self) -> dict[str, bytes]: import orjson from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest @@ -1607,7 +1631,7 @@ def _complete_chain_manifests(self): ), } - def test_resume_from_pending_skips_compare_start_vcs(self): + def test_resume_from_pending_skips_compare_start_vcs(self) -> None: from sentry.preprod.snapshots.tasks import compare_snapshots head_artifact, base_artifact, main_artifact = self._setup_selective_chain() @@ -1641,7 +1665,7 @@ def test_resume_from_pending_skips_compare_start_vcs(self): start_calls = [c for c in vcs.call_args_list if c.kwargs.get("caller") == "compare_start"] assert start_calls == [] - def test_fresh_start_posts_compare_start_vcs(self): + def test_fresh_start_posts_compare_start_vcs(self) -> None: from sentry.preprod.snapshots.tasks import compare_snapshots head_artifact, base_artifact, main_artifact = self._setup_selective_chain() @@ -1663,7 +1687,7 @@ def test_fresh_start_posts_compare_start_vcs(self): start_calls = [c for c in vcs.call_args_list if c.kwargs.get("caller") == "compare_start"] assert len(start_calls) == 1 - def test_incomplete_chain_within_window_defers_to_pending(self): + def test_incomplete_chain_within_window_defers_to_pending(self) -> None: import orjson from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest @@ -1709,7 +1733,7 @@ def test_incomplete_chain_within_window_defers_to_pending(self): assert reschedule_kwargs["head_artifact_id"] == head_artifact.id assert reschedule_kwargs["base_artifact_id"] == base_artifact.id - def test_old_head_with_fresh_comparison_within_window_defers(self): + def test_old_head_with_fresh_comparison_within_window_defers(self) -> None: from datetime import timedelta import orjson @@ -1762,7 +1786,7 @@ def test_old_head_with_fresh_comparison_within_window_defers(self): assert comparison.state == PreprodSnapshotComparison.State.PENDING assert reschedule.call_count == 1 - def test_incomplete_chain_past_window_fails(self): + def test_incomplete_chain_past_window_fails(self) -> None: from datetime import timedelta import orjson @@ -1822,7 +1846,7 @@ def test_incomplete_chain_past_window_fails(self): ] assert len(failure_calls) == 1 # failure posted - def test_corrupt_ancestor_fails_immediately_not_deferred(self): + def test_corrupt_ancestor_fails_immediately_not_deferred(self) -> None: import orjson from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest @@ -1873,7 +1897,7 @@ def test_corrupt_ancestor_fails_immediately_not_deferred(self): @cell_silo_test class EndToEndFanoutTest(TestCase): - def _setup_artifacts(self): + def _setup_artifacts(self) -> tuple[PreprodArtifact, PreprodArtifact]: head_artifact = self.create_preprod_artifact(project=self.project) base_artifact = self.create_preprod_artifact(project=self.project) head = self.create_preprod_snapshot_metrics(head_artifact) @@ -1884,7 +1908,7 @@ def _setup_artifacts(self): base.save() return head_artifact, base_artifact - def _build_manifests(self): + def _build_manifests(self) -> tuple[SnapshotManifest, SnapshotManifest]: from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest head_manifest = SnapshotManifest( @@ -1908,7 +1932,7 @@ def _build_manifests(self): ) return head_manifest, base_manifest - def _diff_result(self): + def _diff_result(self) -> DiffResult: from sentry.preprod.snapshots.image_diff.types import DiffResult return DiffResult( @@ -1922,10 +1946,12 @@ def _diff_result(self): after_height=10, ) - def _fake_fetch(self, session, key_prefix, hashes): + def _fake_fetch( + self, session: MagicMock, key_prefix: str, hashes: list[str] + ) -> tuple[dict[str, bytes], set[str]]: return {h: b"img" for h in hashes}, set() - def test_full_flow_reaches_success(self): + def test_full_flow_reaches_success(self) -> None: from datetime import datetime from sentry.preprod.snapshots.tasks import ( @@ -1942,7 +1968,7 @@ def test_full_flow_reaches_success(self): } session = _dict_backed_session(stored) - dispatched: list[dict] = [] + dispatched: list[dict[str, int]] = [] kwargs = dict( project_id=self.project.id, @@ -2021,7 +2047,7 @@ def test_full_flow_reaches_success(self): tags={"app_id_temp": head_artifact.app_id or ""}, ) - def test_full_flow_auto_approves_via_sibling_results(self): + def test_full_flow_auto_approves_via_sibling_results(self) -> None: from sentry.preprod.models import PreprodComparisonApproval from sentry.preprod.snapshots.image_diff.types import DiffResult from sentry.preprod.snapshots.manifest import ( @@ -2093,7 +2119,7 @@ def test_full_flow_auto_approves_via_sibling_results(self): sibling_comparison_key: orjson.dumps(sibling_manifest.dict()), } session = _dict_backed_session(stored) - dispatched: list[dict] = [] + dispatched: list[dict[str, int]] = [] kwargs = dict( project_id=self.project.id, @@ -2161,7 +2187,7 @@ def test_full_flow_auto_approves_via_sibling_results(self): assert approval.extras["prev_approved_artifact_id"] == 777 assert approval.extras["threshold_matched_image_count"] == 1 - def test_full_flow_auto_approves_exact_match(self): + def test_full_flow_auto_approves_exact_match(self) -> None: from sentry.preprod.models import PreprodComparisonApproval from sentry.preprod.snapshots.image_diff.types import DiffResult from sentry.preprod.snapshots.manifest import ( @@ -2233,7 +2259,7 @@ def test_full_flow_auto_approves_exact_match(self): sibling_comparison_key: orjson.dumps(sibling_manifest.dict()), } session = _dict_backed_session(stored) - dispatched: list[dict] = [] + dispatched: list[dict[str, int]] = [] kwargs = dict( project_id=self.project.id, diff --git a/tests/sentry/preprod/snapshots/test_comparison_categorizer.py b/tests/sentry/preprod/snapshots/test_comparison_categorizer.py index d56a8695bfed..bdc3f946908c 100644 --- a/tests/sentry/preprod/snapshots/test_comparison_categorizer.py +++ b/tests/sentry/preprod/snapshots/test_comparison_categorizer.py @@ -46,7 +46,7 @@ def test_unexpected_status_is_dropped_and_logged( class TestCategorizeComparisonImagesAllStatuses: - def _base_images(self) -> dict[str, dict]: + def _base_images(self) -> dict[str, dict[str, object]]: return { "changed_a.png": { "content_hash": "base_ca", @@ -76,7 +76,7 @@ def _base_images(self) -> dict[str, dict]: "skipped.png": {"content_hash": "base_sk", "width": 16, "height": 26}, } - def _comparison_images(self) -> dict[str, dict]: + def _comparison_images(self) -> dict[str, dict[str, object]]: return { "changed_a.png": { "status": "changed", diff --git a/tests/sentry/preprod/snapshots/test_manifest.py b/tests/sentry/preprod/snapshots/test_manifest.py index ec5718a61de4..1bd448616deb 100644 --- a/tests/sentry/preprod/snapshots/test_manifest.py +++ b/tests/sentry/preprod/snapshots/test_manifest.py @@ -7,8 +7,8 @@ ) -def _meta(**kwargs: object) -> dict: - defaults: dict = {"content_hash": "abc123", "width": 100, "height": 200} +def _meta(**kwargs: object) -> dict[str, object]: + defaults: dict[str, object] = {"content_hash": "abc123", "width": 100, "height": 200} defaults.update(kwargs) return defaults @@ -121,7 +121,7 @@ def test_schema_accepts_numeric_tag_values(self) -> None: jsonschema.validate(_meta(tags={"count": 42}), schema) -def test_comparison_plan_round_trip(): +def test_comparison_plan_round_trip() -> None: from sentry.preprod.snapshots.manifest import ( ChunkAssignment, ChunkCandidate, @@ -154,7 +154,7 @@ def test_comparison_plan_round_trip(): assert restored.non_diff_images["x.png"].status == "added" -def test_chunk_result_round_trip(): +def test_chunk_result_round_trip() -> None: from sentry.preprod.snapshots.manifest import ChunkResult, ComparisonImageResult result = ChunkResult(chunk_index=3, images={"a.png": ComparisonImageResult(status="changed")}) @@ -163,7 +163,9 @@ def test_chunk_result_round_trip(): assert restored.images["a.png"].status == "changed" -def _reference_extras(metadata: ImageMetadata, exclude: set[str] | None = None) -> dict: +def _reference_extras( + metadata: ImageMetadata, exclude: set[str] | None = None +) -> dict[str, object]: schema_fields = frozenset(ImageMetadata.__fields__) skip = schema_fields | exclude if exclude else schema_fields return {k: v for k, v in metadata.dict().items() if k not in skip} diff --git a/tests/sentry/preprod/snapshots/test_reconstruction.py b/tests/sentry/preprod/snapshots/test_reconstruction.py index c2d443b30416..8f315e79a406 100644 --- a/tests/sentry/preprod/snapshots/test_reconstruction.py +++ b/tests/sentry/preprod/snapshots/test_reconstruction.py @@ -7,6 +7,7 @@ from objectstore_client import RequestError from sentry.models.commitcomparison import CommitComparison +from sentry.preprod.models import PreprodArtifact from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest from sentry.preprod.snapshots.reconstruction import ( fold_child_onto_parent, @@ -16,7 +17,12 @@ from sentry.testutils.silo import cell_silo_test -def _m(names_to_hashes, *, selective=False, all_image_file_names=None): +def _m( + names_to_hashes: dict[str, str], + *, + selective: bool = False, + all_image_file_names: Collection[str] | None = None, +) -> SnapshotManifest: return SnapshotManifest( images={ n: ImageMetadata(content_hash=h, width=10, height=10) @@ -27,7 +33,7 @@ def _m(names_to_hashes, *, selective=False, all_image_file_names=None): ) -def test_fold_inherits_untouched_and_overlays_changed(): +def test_fold_inherits_untouched_and_overlays_changed() -> None: parent = _m({"Help": "v0", "Feed": "v0", "Settings": "v0"}) child = _m({"Feed": "v1"}, selective=True) # changed Feed only resolved = fold_child_onto_parent(child, parent) @@ -39,7 +45,7 @@ def test_fold_inherits_untouched_and_overlays_changed(): assert resolved.selective is False -def test_fold_mode1_drops_removed_names(): +def test_fold_mode1_drops_removed_names() -> None: parent = _m({"Help": "v0", "Feed": "v0", "Old": "v0"}) child = _m({"Feed": "v1"}, selective=True, all_image_file_names=["Help", "Feed"]) resolved = fold_child_onto_parent(child, parent) @@ -47,7 +53,7 @@ def test_fold_mode1_drops_removed_names(): assert resolved.images["Feed"].content_hash == "v1" -def test_fold_mode2_keeps_phantom_on_rename(): +def test_fold_mode2_keeps_phantom_on_rename() -> None: parent = _m({"old.png": "v0", "Keep": "v0"}) child = _m({"new.png": "v0"}, selective=True) # rename old->new, mode 2 resolved = fold_child_onto_parent(child, parent) @@ -60,7 +66,7 @@ def _session( ) -> MagicMock: session = MagicMock() - def _get(key): + def _get(key: str) -> MagicMock | None: if key in unavailable_keys: raise RequestError("unavailable", 503, "unavailable") if key not in manifests_by_key: @@ -75,7 +81,7 @@ def _get(key): @cell_silo_test class ReconstructBaseManifestTest(TestCase): - def _commit(self, head_sha, base_sha): + def _commit(self, head_sha: str, base_sha: str | None) -> CommitComparison: return CommitComparison.objects.create( organization_id=self.organization.id, head_repo_name="o/r", @@ -85,7 +91,15 @@ def _commit(self, head_sha, base_sha): provider="github", ) - def _build(self, head_sha, base_sha, images, *, selective, key): + def _build( + self, + head_sha: str, + base_sha: str | None, + images: dict[str, str], + *, + selective: bool, + key: str, + ) -> tuple[PreprodArtifact, str, bytes]: cc = self._commit(head_sha, base_sha) artifact = self.create_preprod_artifact( project=self.project, app_id="com.x", commit_comparison=cc @@ -96,7 +110,7 @@ def _build(self, head_sha, base_sha, images, *, selective, key): manifest = _m(images, selective=selective) return artifact, key, orjson.dumps(manifest.dict()) - def test_non_selective_base_returns_as_is(self): + def test_non_selective_base_returns_as_is(self) -> None: artifact, key, blob = self._build( "h_main", None, {"a": "v0"}, selective=False, key="k_main" ) @@ -105,7 +119,7 @@ def test_non_selective_base_returns_as_is(self): assert result.manifest is not None assert {n: m.content_hash for n, m in result.manifest.images.items()} == {"a": "v0"} - def test_three_deep_carries_pr1_change(self): + def test_three_deep_carries_pr1_change(self) -> None: a_main, k_main, b_main = self._build( "h_main", None, @@ -129,7 +143,7 @@ def test_three_deep_carries_pr1_change(self): "Settings": "v0", } - def test_missing_ancestor_manifest_is_incomplete(self): + def test_missing_ancestor_manifest_is_incomplete(self) -> None: a_main, k_main, b_main = self._build( "h_main", None, {"a": "v0"}, selective=False, key="k_main" ) @@ -142,7 +156,7 @@ def test_missing_ancestor_manifest_is_incomplete(self): assert result.incomplete is True assert result.manifest is None - def test_transient_objectstore_error_is_incomplete(self): + def test_transient_objectstore_error_is_incomplete(self) -> None: # A transient objectstore failure is indistinguishable from a manifest that has # not been written yet, so it defers rather than terminating. a_main, k_main, b_main = self._build( @@ -156,14 +170,14 @@ def test_transient_objectstore_error_is_incomplete(self): assert result.incomplete is True assert result.manifest is None - def test_no_non_selective_ancestor_is_unresolvable(self): + def test_no_non_selective_ancestor_is_unresolvable(self) -> None: # selective build whose base_sha is null -> no complete reference exists. a_pr1, k_pr1, b_pr1 = self._build("h_pr1", None, {"a": "v1"}, selective=True, key="k_pr1") result = reconstruct_base_manifest(a_pr1, _session({k_pr1: b_pr1})) assert result.unresolvable is True assert result.manifest is None - def test_cycle_in_chain_is_unresolvable(self): + def test_cycle_in_chain_is_unresolvable(self) -> None: # A's base points at B, B's base points back at A -> cycle -> unresolvable. a, k_a, b_a = self._build("h_a", "h_b", {"x": "v1"}, selective=True, key="k_a") b, k_b, b_b = self._build("h_b", "h_a", {"y": "v1"}, selective=True, key="k_b") @@ -171,7 +185,7 @@ def test_cycle_in_chain_is_unresolvable(self): assert result.unresolvable is True assert result.manifest is None - def test_resolves_ancestor_when_base_repo_name_is_null(self): + def test_resolves_ancestor_when_base_repo_name_is_null(self) -> None: # Non-fork PR: the child's commit_comparison has base_repo_name=None. The ancestor # must still be found via the head_repo_name fallback. a_main, k_main, b_main = self._build( @@ -197,7 +211,7 @@ def test_resolves_ancestor_when_base_repo_name_is_null(self): assert result.manifest is not None assert {n: m.content_hash for n, m in result.manifest.images.items()} == {"a": "v1"} - def test_corrupt_ancestor_manifest_is_unresolvable(self): + def test_corrupt_ancestor_manifest_is_unresolvable(self) -> None: # A corrupt ancestor manifest is permanent — it will never become valid by waiting, # so it must terminate (unresolvable) immediately rather than deferring through the # whole grace window and failing as a misleading TIMEOUT. @@ -214,7 +228,7 @@ def test_corrupt_ancestor_manifest_is_unresolvable(self): assert result.manifest is None assert result.error_message is not None - def test_manifests_fetched_in_parallel(self): + def test_manifests_fetched_in_parallel(self) -> None: import threading # A 3-deep chain requires reading 3 manifests. If the reads run sequentially the @@ -234,7 +248,7 @@ def test_manifests_fetched_in_parallel(self): session = MagicMock() - def _get(key): + def _get(key: str) -> MagicMock | None: barrier.wait() result = MagicMock() result.payload.read.return_value = blobs[key] @@ -248,7 +262,7 @@ def _get(key): assert result.manifest is not None assert {n: m.content_hash for n, m in result.manifest.images.items()} == {"a": "v2"} - def test_completeness_uses_manifest_flag_not_db_flag(self): + def test_completeness_uses_manifest_flag_not_db_flag(self) -> None: # The manifest is the single source of truth for "is this a complete anchor". # Here the DB flag (is_selective=False) DISAGREES with the manifest # (selective=True). The walk must trust the manifest and fold this build as a diff --git a/tests/sentry/preprod/snapshots/test_storage.py b/tests/sentry/preprod/snapshots/test_storage.py index a24ed5c1577e..4d15128e0ede 100644 --- a/tests/sentry/preprod/snapshots/test_storage.py +++ b/tests/sentry/preprod/snapshots/test_storage.py @@ -15,7 +15,7 @@ def sessions() -> tuple[MagicMock, MagicMock]: @patch("sentry.preprod.snapshots.storage.logger") -def test_get_prefers_primary(mock_logger, sessions) -> None: +def test_get_prefers_primary(mock_logger: MagicMock, sessions: tuple[MagicMock, MagicMock]) -> None: primary, fallback = sessions storage = SnapshotStorage(primary, fallback) assert storage.get("k") is primary.get.return_value @@ -23,7 +23,7 @@ def test_get_prefers_primary(mock_logger, sessions) -> None: mock_logger.info.assert_not_called() -def test_get_uses_fallback_when_primary_missing(sessions) -> None: +def test_get_uses_fallback_when_primary_missing(sessions: tuple[MagicMock, MagicMock]) -> None: primary, fallback = sessions primary.get.return_value = None storage = SnapshotStorage(primary, fallback) @@ -32,7 +32,9 @@ def test_get_uses_fallback_when_primary_missing(sessions) -> None: @patch("sentry.preprod.snapshots.storage.logger") -def test_get_error_propagates_without_fallback(mock_logger, sessions) -> None: +def test_get_error_propagates_without_fallback( + mock_logger: MagicMock, sessions: tuple[MagicMock, MagicMock] +) -> None: primary, fallback = sessions primary.get.side_effect = RequestError("boom", 500, "") storage = SnapshotStorage(primary, fallback) @@ -42,14 +44,14 @@ def test_get_error_propagates_without_fallback(mock_logger, sessions) -> None: mock_logger.info.assert_not_called() -def test_head_uses_fallback_when_primary_missing(sessions) -> None: +def test_head_uses_fallback_when_primary_missing(sessions: tuple[MagicMock, MagicMock]) -> None: primary, fallback = sessions primary.head.return_value = None storage = SnapshotStorage(primary, fallback) assert storage.head("k") is fallback.head.return_value -def test_put_only_writes_primary(sessions) -> None: +def test_put_only_writes_primary(sessions: tuple[MagicMock, MagicMock]) -> None: primary, fallback = sessions storage = SnapshotStorage(primary, fallback) storage.put( @@ -61,7 +63,7 @@ def test_put_only_writes_primary(sessions) -> None: fallback.put.assert_not_called() -def test_delete_removes_from_both(sessions) -> None: +def test_delete_removes_from_both(sessions: tuple[MagicMock, MagicMock]) -> None: primary, fallback = sessions storage = SnapshotStorage(primary, fallback) storage.delete("k") @@ -69,7 +71,7 @@ def test_delete_removes_from_both(sessions) -> None: fallback.delete.assert_called_once_with("k") -def test_delete_ignores_missing(sessions) -> None: +def test_delete_ignores_missing(sessions: tuple[MagicMock, MagicMock]) -> None: primary, fallback = sessions primary.delete.side_effect = RequestError("missing", 404, "") storage = SnapshotStorage(primary, fallback) @@ -79,7 +81,7 @@ def test_delete_ignores_missing(sessions) -> None: @pytest.mark.parametrize("primary_error", [RequestError("primary", 500, ""), HTTPError("primary")]) def test_delete_raises_first_non_404_after_trying_both( - sessions, primary_error: RequestError | HTTPError + sessions: tuple[MagicMock, MagicMock], primary_error: RequestError | HTTPError ) -> None: primary, fallback = sessions primary.delete.side_effect = primary_error @@ -95,7 +97,11 @@ def test_delete_raises_first_non_404_after_trying_both( @patch("sentry.preprod.snapshots.storage.logger") @patch("sentry.preprod.snapshots.storage.metrics") def test_fallback_records_metric_and_log( - mock_metrics, mock_logger, sessions, operation: str, found: bool + mock_metrics: MagicMock, + mock_logger: MagicMock, + sessions: tuple[MagicMock, MagicMock], + operation: str, + found: bool, ) -> None: primary, fallback = sessions getattr(primary, operation).return_value = None @@ -113,7 +119,9 @@ def test_fallback_records_metric_and_log( @django_db_all @patch("sentry.preprod.snapshots.storage.get_session") -def test_factory_uses_snapshots_primary_with_preprod_fallback(mock_get_session) -> None: +def test_factory_uses_snapshots_primary_with_preprod_fallback( + mock_get_session: MagicMock, +) -> None: get_snapshot_storage(42, org=7) assert mock_get_session.call_args_list == [ call(UsecaseId.PREPROD_SNAPSHOTS, 42, org=7), diff --git a/tests/sentry/preprod/snapshots/test_tasks.py b/tests/sentry/preprod/snapshots/test_tasks.py index 505bca8a2a4b..12a69d2f287b 100644 --- a/tests/sentry/preprod/snapshots/test_tasks.py +++ b/tests/sentry/preprod/snapshots/test_tasks.py @@ -27,7 +27,7 @@ ) -def test_objectstore_key_layout(): +def test_objectstore_key_layout() -> None: assert _plan_key(1, 2, 3, 4) == "1/2/3/4/plan.json" assert _chunk_result_key(1, 2, 3, 4, 5) == "1/2/3/4/chunks/5.json" assert _comparison_key(1, 2, 3, 4) == "1/2/3/4/comparison.json" @@ -276,7 +276,7 @@ def test_selective_without_names_all_missing_are_skipped(self) -> None: assert result.matched == {"a.png"} -def test_build_comparison_plan_splits_diff_and_non_diff(): +def test_build_comparison_plan_splits_diff_and_non_diff() -> None: from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest from sentry.preprod.snapshots.tasks import _build_comparison_plan @@ -306,7 +306,7 @@ def test_build_comparison_plan_splits_diff_and_non_diff(): assert plan.non_diff_images["gone.png"].status == "removed" -def test_build_comparison_plan_diff_threshold_precedence(): +def test_build_comparison_plan_diff_threshold_precedence() -> None: from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest from sentry.preprod.snapshots.tasks import _build_comparison_plan @@ -333,7 +333,7 @@ def test_build_comparison_plan_diff_threshold_precedence(): assert thresholds == {"per_image.png": 0.25, "manifest_level.png": 0.1} -def test_build_comparison_plan_diff_threshold_defaults_to_zero(): +def test_build_comparison_plan_diff_threshold_defaults_to_zero() -> None: from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest from sentry.preprod.snapshots.tasks import _build_comparison_plan @@ -352,7 +352,7 @@ def test_build_comparison_plan_diff_threshold_defaults_to_zero(): assert thresholds == {"default.png": 0.0} -def test_build_comparison_plan_uses_comparison_dimensions_for_pixel_limit(): +def test_build_comparison_plan_uses_comparison_dimensions_for_pixel_limit() -> None: from sentry.preprod.snapshots.image_diff.compare import MAX_DIFF_PIXELS from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest from sentry.preprod.snapshots.tasks import _build_comparison_plan @@ -374,7 +374,7 @@ def test_build_comparison_plan_uses_comparison_dimensions_for_pixel_limit(): assert plan.non_diff_images["huge.png"].reason == "exceeds_pixel_limit" -def test_build_comparison_plan_detects_rename(): +def test_build_comparison_plan_detects_rename() -> None: from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest from sentry.preprod.snapshots.tasks import _build_comparison_plan @@ -394,7 +394,7 @@ def test_build_comparison_plan_detects_rename(): assert "old.png" not in plan.non_diff_images -def test_process_chunk_enforces_actual_batch_pixel_limit(): +def test_process_chunk_enforces_actual_batch_pixel_limit() -> None: assignment = ChunkAssignment( chunk_index=0, candidates=[ diff --git a/tests/sentry/preprod/snapshots/test_utils.py b/tests/sentry/preprod/snapshots/test_utils.py index 1fd330e0e77c..6e5fee657843 100644 --- a/tests/sentry/preprod/snapshots/test_utils.py +++ b/tests/sentry/preprod/snapshots/test_utils.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from sentry.models.commitcomparison import CommitComparison from sentry.preprod.snapshots.utils import find_base_snapshot_artifact from sentry.testutils.cases import TestCase @@ -8,7 +10,7 @@ @cell_silo_test class FindBaseSnapshotArtifactTest(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() cc = CommitComparison.objects.create( organization_id=self.organization.id, @@ -23,7 +25,8 @@ def setUp(self): ) self.create_preprod_snapshot_metrics(self.artifact, is_selective=True) - def _args(self): + # justification: artifact_type is an int on the model but str | None in the helper signature. + def _args(self) -> dict[str, Any]: return dict( organization_id=self.organization.id, base_sha="base_head_sha", @@ -34,12 +37,12 @@ def _args(self): build_configuration=None, ) - def test_selective_base_returned(self): + def test_selective_base_returned(self) -> None: result = find_base_snapshot_artifact(**self._args()) assert result is not None assert result.preprodsnapshotmetrics.is_selective is True - def test_non_selective_base_returned(self): + def test_non_selective_base_returned(self) -> None: cc = CommitComparison.objects.create( organization_id=self.organization.id, head_repo_name="o/r", @@ -52,7 +55,7 @@ def test_non_selective_base_returned(self): project=self.project, app_id="com.x", commit_comparison=cc ) self.create_preprod_snapshot_metrics(artifact, is_selective=False) - args = dict( + args: dict[str, Any] = dict( organization_id=self.organization.id, base_sha="full_base_head_sha", base_repo_name="o/r", diff --git a/tests/sentry/preprod/snapshots/test_zip_builder.py b/tests/sentry/preprod/snapshots/test_zip_builder.py index 1812960b13a8..8a4ff8499ba2 100644 --- a/tests/sentry/preprod/snapshots/test_zip_builder.py +++ b/tests/sentry/preprod/snapshots/test_zip_builder.py @@ -20,7 +20,7 @@ def _meta(content_hash: str) -> ImageMetadata: def _session(data_by_key: dict[str, bytes]) -> MagicMock: - def _get(key): + def _get(key: str) -> MagicMock: result = MagicMock() if key in data_by_key: result.payload.read.return_value = data_by_key[key] diff --git a/tests/sentry/preprod/snapshots/test_zip_tasks.py b/tests/sentry/preprod/snapshots/test_zip_tasks.py index 6a78c80a221f..1c1064307371 100644 --- a/tests/sentry/preprod/snapshots/test_zip_tasks.py +++ b/tests/sentry/preprod/snapshots/test_zip_tasks.py @@ -2,6 +2,7 @@ import io import zipfile +from typing import Any, BinaryIO from unittest.mock import MagicMock, patch import orjson @@ -22,7 +23,7 @@ def _manifest_bytes() -> bytes: class BuildSnapshotImagesZipTest(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.org = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.org) @@ -44,7 +45,7 @@ def _session( if existing_archive_key is not None: data[existing_archive_key] = b"ZIPBYTES" - def _get(key): + def _get(key: str) -> MagicMock | None: if key not in data: return None result = MagicMock() @@ -57,15 +58,19 @@ def _get(key): @patch("sentry.preprod.snapshots.zip_tasks._send_archive_email") @patch(SESSION_TARGET) - def test_build_includes_manifest(self, mock_session, mock_email): + def test_build_includes_manifest(self, mock_session: MagicMock, mock_email: MagicMock) -> None: session = self._session() mock_session.return_value = session archive_bytes = b"" - def upload(contents, **kwargs): + # justification: objectstore put forwards arbitrary keyword options. + def upload(contents: BinaryIO, **kwargs: Any) -> str: nonlocal archive_bytes archive_bytes = contents.read() - return kwargs["key"] + key = kwargs["key"] + if not isinstance(key, str): + raise TypeError("Objectstore key is not a string") + return key session.put.side_effect = upload build_snapshot_images_zip( @@ -89,7 +94,7 @@ def upload(contents, **kwargs): @patch("sentry.preprod.snapshots.zip_tasks._send_archive_email") @patch(SESSION_TARGET) - def test_reuses_existing_archive(self, mock_session, mock_email): + def test_reuses_existing_archive(self, mock_session: MagicMock, mock_email: MagicMock) -> None: session = self._session(existing_archive_key=f"snapshot_archives/{self.artifact.id}.zip") mock_session.return_value = session build_snapshot_images_zip( @@ -104,7 +109,9 @@ def test_reuses_existing_archive(self, mock_session, mock_email): @patch("sentry.preprod.snapshots.zip_tasks._send_archive_email") @patch(SESSION_TARGET) - def test_emails_failure_when_upload_raises(self, mock_session, mock_email): + def test_emails_failure_when_upload_raises( + self, mock_session: MagicMock, mock_email: MagicMock + ) -> None: session = self._session() session.put.side_effect = RuntimeError("boom") mock_session.return_value = session @@ -121,8 +128,11 @@ def test_emails_failure_when_upload_raises(self, mock_session, mock_email): @patch("sentry.preprod.snapshots.zip_tasks._send_archive_email") @patch(SESSION_TARGET) def test_emails_ready_when_failure_superseded_by_existing_archive( - self, mock_session, mock_email, mock_available - ): + self, + mock_session: MagicMock, + mock_email: MagicMock, + mock_available: MagicMock, + ) -> None: session = self._session() session.put.side_effect = RuntimeError("boom") mock_session.return_value = session @@ -137,7 +147,9 @@ def test_emails_ready_when_failure_superseded_by_existing_archive( @patch("sentry.preprod.snapshots.zip_tasks._send_archive_email") @patch(SESSION_TARGET) - def test_emails_failure_when_metrics_missing(self, mock_session, mock_email): + def test_emails_failure_when_metrics_missing( + self, mock_session: MagicMock, mock_email: MagicMock + ) -> None: self.artifact.preprodsnapshotmetrics.delete() mock_session.return_value = self._session() build_snapshot_images_zip( @@ -149,7 +161,7 @@ def test_emails_failure_when_metrics_missing(self, mock_session, mock_email): mock_email.assert_called_once() assert mock_email.call_args.kwargs["ready"] is False - def test_snapshot_page_url_targets_frontend_page(self): + def test_snapshot_page_url_targets_frontend_page(self) -> None: from sentry.preprod.snapshots.zip_tasks import _snapshot_page_url url = _snapshot_page_url(self.org, self.artifact.id) @@ -158,7 +170,9 @@ def test_snapshot_page_url_targets_frontend_page(self): @patch("sentry.preprod.snapshots.zip_tasks._send_archive_email") @patch(SESSION_TARGET) - def test_emails_failure_when_objectstore_probe_errors(self, mock_session, mock_email): + def test_emails_failure_when_objectstore_probe_errors( + self, mock_session: MagicMock, mock_email: MagicMock + ) -> None: # A non-404 error from the existence probe must surface as a failure email, # never a silent wedge (there is no status to poll). session = MagicMock() diff --git a/tests/sentry/preprod/test_artifact_search.py b/tests/sentry/preprod/test_artifact_search.py index 2699efb3bd87..184ca6d104cb 100644 --- a/tests/sentry/preprod/test_artifact_search.py +++ b/tests/sentry/preprod/test_artifact_search.py @@ -1,4 +1,5 @@ -from datetime import timedelta +from datetime import datetime, timedelta +from typing import Any import pytest from django.utils import timezone @@ -15,6 +16,7 @@ from sentry.preprod.models import ( PreprodArtifact, PreprodArtifactSizeMetrics, + PreprodBuildConfiguration, PreprodComparisonApproval, PreprodSnapshotMetrics, ) @@ -25,14 +27,15 @@ class GetSequentialBaseArtifactTest(TestCase): def _create_artifact_with_metrics( self, - app_id="com.example.app", - artifact_type=PreprodArtifact.ArtifactType.APK, - build_configuration=None, - date_added=None, - state=PreprodArtifactSizeMetrics.SizeAnalysisState.COMPLETED, - app_name=None, - **kwargs, - ): + app_id: str = "com.example.app", + artifact_type: int = PreprodArtifact.ArtifactType.APK, + build_configuration: PreprodBuildConfiguration | None = None, + date_added: datetime | None = None, + state: int = PreprodArtifactSizeMetrics.SizeAnalysisState.COMPLETED, + app_name: str | None = None, + # justification: factory kwargs vary per test case. + **kwargs: Any, + ) -> PreprodArtifact: artifact = self.create_preprod_artifact( project=self.project, app_id=app_id, @@ -341,7 +344,7 @@ def _create_artifact_with_approval( feature_type: int = PreprodComparisonApproval.FeatureType.SNAPSHOTS, status: int = PreprodComparisonApproval.ApprovalStatus.APPROVED, with_approval_row: bool = True, - extras: dict | None = None, + extras: dict[str, object] | None = None, ) -> PreprodArtifact: artifact = self.create_preprod_artifact(project=self.project) if with_approval_row: diff --git a/tests/sentry/preprod/test_build_distribution_utils.py b/tests/sentry/preprod/test_build_distribution_utils.py index 0ea644aacefa..d9e288ec3834 100644 --- a/tests/sentry/preprod/test_build_distribution_utils.py +++ b/tests/sentry/preprod/test_build_distribution_utils.py @@ -1,3 +1,5 @@ +from typing import cast + from sentry.preprod.build_distribution_utils import is_installable_artifact from sentry.preprod.models import ( PreprodArtifact, @@ -12,10 +14,10 @@ class IsInstallableArtifactTest(TestCase): def _create_artifact( self, - artifact_type=PreprodArtifact.ArtifactType.XCARCHIVE, - installable_app_file_id=1, - build_number="456", - extras=None, + artifact_type: int = PreprodArtifact.ArtifactType.XCARCHIVE, + installable_app_file_id: int | None = 1, + build_number: str = "456", + extras: dict[str, object] | None = None, ) -> PreprodArtifact: build_config = PreprodBuildConfiguration.objects.create( project=self.project, name="Release" @@ -26,14 +28,14 @@ def _create_artifact( artifact_type=artifact_type, app_id="com.example.app", build_configuration=build_config, - installable_app_file_id=installable_app_file_id, + installable_app_file_id=cast("int", installable_app_file_id), extras=extras, ) PreprodArtifactMobileAppInfo.objects.create( preprod_artifact=artifact, app_name="TestApp", build_version="1.0.0", - build_number=build_number, + build_number=cast("int", build_number), ) return PreprodArtifact.objects.select_related("mobile_app_info").get(id=artifact.id) diff --git a/tests/sentry/preprod/test_build_distribution_webhooks.py b/tests/sentry/preprod/test_build_distribution_webhooks.py index 8639785a892a..02fca2b620ca 100644 --- a/tests/sentry/preprod/test_build_distribution_webhooks.py +++ b/tests/sentry/preprod/test_build_distribution_webhooks.py @@ -1,6 +1,10 @@ from datetime import datetime -from unittest.mock import patch +from unittest.mock import MagicMock, patch +from sentry.models.commitcomparison import CommitComparison +from sentry.preprod.api.models.public.installable_builds import ( + BuildDistributionSummaryResponseDict, +) from sentry.preprod.build_distribution_webhooks import ( build_webhook_payload, send_build_distribution_webhook, @@ -23,14 +27,14 @@ class BuildWebhookPayloadTest(TestCase): def _create_installable_artifact( self, - commit_comparison=None, - artifact_type=PreprodArtifact.ArtifactType.AAB, - app_name="Example App", - build_version="1.0.0", - build_number=1, - app_id="com.example.app", - installable_app_file_id=12345, - ): + commit_comparison: CommitComparison | None = None, + artifact_type: int = PreprodArtifact.ArtifactType.AAB, + app_name: str = "Example App", + build_version: str = "1.0.0", + build_number: int = 1, + app_id: str = "com.example.app", + installable_app_file_id: int = 12345, + ) -> PreprodArtifact: """Create an artifact with installable_app_file_id set (success terminal).""" artifact = self.create_preprod_artifact( project=self.project, @@ -47,15 +51,15 @@ def _create_installable_artifact( def _create_failed_artifact( self, - error_code=PreprodArtifact.InstallableAppErrorCode.PROCESSING_ERROR, - error_message="Unsupported artifact type", - commit_comparison=None, - artifact_type=PreprodArtifact.ArtifactType.AAB, - app_name="Example App", - build_version="1.0.0", - build_number=1, - app_id="com.example.app", - ): + error_code: int = PreprodArtifact.InstallableAppErrorCode.PROCESSING_ERROR, + error_message: str = "Unsupported artifact type", + commit_comparison: CommitComparison | None = None, + artifact_type: int = PreprodArtifact.ArtifactType.AAB, + app_name: str = "Example App", + build_version: str = "1.0.0", + build_number: int = 1, + app_id: str = "com.example.app", + ) -> PreprodArtifact: """Create an artifact with installable_app_error_code set (failure terminal).""" artifact = self.create_preprod_artifact( project=self.project, @@ -71,7 +75,7 @@ def _create_failed_artifact( artifact.save(update_fields=["installable_app_error_code", "installable_app_error_message"]) return artifact - def _assert_no_transient_fields(self, payload): + def _assert_no_transient_fields(self, payload: BuildDistributionSummaryResponseDict) -> None: """Assert that transient/mutable fields are absent.""" for field in ("downloadCount", "releaseNotes", "installGroups"): assert field not in payload, f"Transient field '{field}' should not be present" @@ -330,7 +334,7 @@ def test_both_file_and_error_treats_as_failed(self) -> None: class SendBuildDistributionWebhookTest(TestCase): """Tests for the send_build_distribution_webhook function.""" - def _create_installable_artifact(self): + def _create_installable_artifact(self) -> PreprodArtifact: artifact = self.create_preprod_artifact( project=self.project, artifact_type=PreprodArtifact.ArtifactType.AAB, @@ -343,7 +347,7 @@ def _create_installable_artifact(self): return artifact @patch("sentry.preprod.build_distribution_webhooks.broadcast_webhooks_for_organization") - def test_sends_webhook(self, mock_broadcast): + def test_sends_webhook(self, mock_broadcast: MagicMock) -> None: """Webhook is sent for a completed build with correct payload.""" artifact = self._create_installable_artifact() @@ -360,7 +364,7 @@ def test_sends_webhook(self, mock_broadcast): assert call_kwargs.kwargs["payload"]["state"] == "COMPLETED" @patch("sentry.preprod.build_distribution_webhooks.broadcast_webhooks_for_organization") - def test_does_not_send_for_non_terminal(self, mock_broadcast): + def test_does_not_send_for_non_terminal(self, mock_broadcast: MagicMock) -> None: """Webhook is NOT sent for non-terminal state.""" artifact = self.create_preprod_artifact(project=self.project) @@ -369,7 +373,7 @@ def test_does_not_send_for_non_terminal(self, mock_broadcast): mock_broadcast.delay.assert_not_called() @patch("sentry.preprod.build_distribution_webhooks.broadcast_webhooks_for_organization") - def test_broadcast_exception_is_caught(self, mock_broadcast): + def test_broadcast_exception_is_caught(self, mock_broadcast: MagicMock) -> None: """Exceptions from broadcast are caught and logged, not re-raised.""" artifact = self._create_installable_artifact() mock_broadcast.delay.side_effect = Exception("Celery task failed") @@ -380,7 +384,7 @@ def test_broadcast_exception_is_caught(self, mock_broadcast): mock_broadcast.delay.assert_called_once() @patch("sentry.preprod.build_distribution_webhooks.broadcast_webhooks_for_organization") - def test_sends_webhook_for_failure(self, mock_broadcast): + def test_sends_webhook_for_failure(self, mock_broadcast: MagicMock) -> None: """Webhook is sent for a failed build.""" artifact = self.create_preprod_artifact( project=self.project, diff --git a/tests/sentry/preprod/test_models.py b/tests/sentry/preprod/test_models.py index 42472c3354f7..1b2e3c395a80 100644 --- a/tests/sentry/preprod/test_models.py +++ b/tests/sentry/preprod/test_models.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from sentry.models.project import Project from sentry.preprod.models import PreprodArtifact, PreprodArtifactMobileAppInfo from sentry.testutils.cases import TestCase @@ -1792,7 +1794,8 @@ def test_get_base_artifacts_for_commit_excludes_other_project(self) -> None: class PreprodArtifactMobileAppInfoFormatVersionStringTest(PreprodArtifactModelTestBase): """Tests for PreprodArtifactMobileAppInfo.format_version_string.""" - def _create_mobile_app_info(self, **kwargs) -> PreprodArtifactMobileAppInfo: + # justification: factory kwargs vary per test case. + def _create_mobile_app_info(self, **kwargs: Any) -> PreprodArtifactMobileAppInfo: preprod_artifact = self.create_preprod_artifact(project=self.project) return self.create_preprod_artifact_mobile_app_info(preprod_artifact, **kwargs) diff --git a/tests/sentry/preprod/test_quotas.py b/tests/sentry/preprod/test_quotas.py index 748b980803d6..97fdb72523ab 100644 --- a/tests/sentry/preprod/test_quotas.py +++ b/tests/sentry/preprod/test_quotas.py @@ -1,5 +1,5 @@ from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch from sentry.preprod.quotas import should_run_distribution, should_run_size from sentry.testutils.cases import TestCase @@ -11,33 +11,33 @@ def setUp(self) -> None: self.artifact = self.create_preprod_artifact(project=self.project) @patch("sentry.preprod.quotas.has_size_quota", return_value=True) - def test_returns_true_when_enabled_by_default(self, mock_quota): + def test_returns_true_when_enabled_by_default(self, mock_quota: MagicMock) -> None: result, reason = should_run_size(self.artifact) assert result is True assert reason is None @patch("sentry.preprod.quotas.has_size_quota", return_value=True) - def test_returns_false_when_disabled(self, mock_quota): + def test_returns_false_when_disabled(self, mock_quota: MagicMock) -> None: self.project.update_option("sentry:preprod_size_enabled_by_customer", False) result, reason = should_run_size(self.artifact) assert result is False assert reason == "disabled" @patch("sentry.preprod.quotas.has_size_quota", return_value=True) - def test_returns_true_when_explicitly_enabled(self, mock_quota): + def test_returns_true_when_explicitly_enabled(self, mock_quota: MagicMock) -> None: self.project.update_option("sentry:preprod_size_enabled_by_customer", True) result, reason = should_run_size(self.artifact) assert result is True assert reason is None @patch("sentry.preprod.quotas.has_size_quota", return_value=False) - def test_returns_false_when_no_quota(self, mock_quota): + def test_returns_false_when_no_quota(self, mock_quota: MagicMock) -> None: result, reason = should_run_size(self.artifact) assert result is False assert reason == "quota" @patch("sentry.preprod.quotas.has_size_quota", return_value=False) - def test_disabled_check_happens_before_quota_check(self, mock_quota): + def test_disabled_check_happens_before_quota_check(self, mock_quota: MagicMock) -> None: self.project.update_option("sentry:preprod_size_enabled_by_customer", False) result, reason = should_run_size(self.artifact) assert result is False @@ -51,33 +51,33 @@ def setUp(self) -> None: self.artifact = self.create_preprod_artifact(project=self.project) @patch("sentry.preprod.quotas.has_installable_quota", return_value=True) - def test_returns_true_when_enabled_by_default(self, mock_quota): + def test_returns_true_when_enabled_by_default(self, mock_quota: MagicMock) -> None: result, reason = should_run_distribution(self.artifact) assert result is True assert reason is None @patch("sentry.preprod.quotas.has_installable_quota", return_value=True) - def test_returns_false_when_disabled(self, mock_quota): + def test_returns_false_when_disabled(self, mock_quota: MagicMock) -> None: self.project.update_option("sentry:preprod_distribution_enabled_by_customer", False) result, reason = should_run_distribution(self.artifact) assert result is False assert reason == "disabled" @patch("sentry.preprod.quotas.has_installable_quota", return_value=True) - def test_returns_true_when_explicitly_enabled(self, mock_quota): + def test_returns_true_when_explicitly_enabled(self, mock_quota: MagicMock) -> None: self.project.update_option("sentry:preprod_distribution_enabled_by_customer", True) result, reason = should_run_distribution(self.artifact) assert result is True assert reason is None @patch("sentry.preprod.quotas.has_installable_quota", return_value=False) - def test_returns_false_when_no_quota(self, mock_quota): + def test_returns_false_when_no_quota(self, mock_quota: MagicMock) -> None: result, reason = should_run_distribution(self.artifact) assert result is False assert reason == "quota" @patch("sentry.preprod.quotas.has_installable_quota", return_value=False) - def test_disabled_check_happens_before_quota_check(self, mock_quota): + def test_disabled_check_happens_before_quota_check(self, mock_quota: MagicMock) -> None: self.project.update_option("sentry:preprod_distribution_enabled_by_customer", False) result, reason = should_run_distribution(self.artifact) assert result is False @@ -87,7 +87,7 @@ def test_disabled_check_happens_before_quota_check(self, mock_quota): class GetSizeRetentionCutoffTest(TestCase): @patch("sentry.preprod.quotas.quotas.backend.get_event_retention", return_value=None) - def test_default_retention_when_none(self, mock_retention): + def test_default_retention_when_none(self, mock_quota: MagicMock) -> None: from django.utils import timezone from sentry.preprod.quotas import get_size_retention_cutoff @@ -97,7 +97,7 @@ def test_default_retention_when_none(self, mock_retention): assert abs((cutoff - expected).total_seconds()) < 2 @patch("sentry.preprod.quotas.quotas.backend.get_event_retention", return_value=30) - def test_custom_retention(self, mock_retention): + def test_custom_retention(self, mock_quota: MagicMock) -> None: from django.utils import timezone from sentry.preprod.quotas import get_size_retention_cutoff diff --git a/tests/sentry/preprod/test_tasks.py b/tests/sentry/preprod/test_tasks.py index be12de6c2ceb..07a032f497e8 100644 --- a/tests/sentry/preprod/test_tasks.py +++ b/tests/sentry/preprod/test_tasks.py @@ -1,6 +1,7 @@ from datetime import timedelta from hashlib import sha1 -from unittest.mock import patch +from typing import Any, cast +from unittest.mock import MagicMock, patch import pytest import sentry_sdk @@ -295,6 +296,7 @@ def test_assemble_preprod_artifact_checksum_mismatch(self) -> None: AssembleTask.PREPROD_ARTIFACT, self.project.id, wrong_checksum ) assert status == ChunkFileState.ERROR + assert details is not None assert "Reported checksum mismatch" in details delete_assemble_status(AssembleTask.PREPROD_ARTIFACT, self.project.id, wrong_checksum) @@ -323,6 +325,7 @@ def test_assemble_preprod_artifact_missing_chunks(self) -> None: AssembleTask.PREPROD_ARTIFACT, self.project.id, total_checksum ) assert status == ChunkFileState.ERROR + assert details is not None assert "Not all chunks available for assembling" in details delete_assemble_status(AssembleTask.PREPROD_ARTIFACT, self.project.id, total_checksum) @@ -393,7 +396,9 @@ def test_assemble_preprod_artifact_nonexistent_project(self) -> None: # assemble_result.build_configuration which doesn't exist @patch("sentry.preprod.tasks.dispatch_taskbroker") - def test_assemble_preprod_artifact_dispatches_to_taskbroker(self, mock_dispatch) -> None: + def test_assemble_preprod_artifact_dispatches_to_taskbroker( + self, mock_dispatch: MagicMock + ) -> None: content = b"test preprod artifact content no query" fileobj = ContentFile(content) total_checksum = sha1(content).hexdigest() @@ -486,7 +491,7 @@ def test_create_preprod_artifact_succeeds_with_only_size_analysis_quota(self) -> content = b"test with size analysis quota only" total_checksum = sha1(content).hexdigest() - def quota_side_effect(org_id, data_category): + def quota_side_effect(org_id: int, data_category: int) -> bool: return data_category == DataCategory.SIZE_ANALYSIS with self.feature( @@ -511,7 +516,7 @@ def test_create_preprod_artifact_succeeds_with_only_installable_build_quota(self content = b"test with installable build quota only" total_checksum = sha1(content).hexdigest() - def quota_side_effect(org_id, data_category): + def quota_side_effect(org_id: int, data_category: int) -> bool: return data_category == DataCategory.INSTALLABLE_BUILD with self.feature( @@ -561,9 +566,16 @@ def setUp(self) -> None: project=self.project, state=PreprodArtifact.ArtifactState.UPLOADED ) + # justification: get_assemble_status is untyped, so status and details are Any. def _run_task_and_verify_status( - self, content, checksum=None, chunks=None, artifact_id=None, org_id=None, project_id=None - ): + self, + content: bytes, + checksum: str | None = None, + chunks: list[str] | None = None, + artifact_id: int | None = None, + org_id: int | None = None, + project_id: int | None = None, + ) -> tuple[Any, Any]: checksum = checksum or sha1(content).hexdigest() blob = FileBlob.from_file_with_organization(ContentFile(content), self.organization) chunks = chunks or [blob.checksum] @@ -585,7 +597,9 @@ def _run_task_and_verify_status( return status, details @patch("sentry.preprod.tasks.send_build_distribution_webhook") - def test_assemble_preprod_artifact_installable_app_success(self, mock_send_webhook) -> None: + def test_assemble_preprod_artifact_installable_app_success( + self, mock_send_webhook: MagicMock + ) -> None: status, details = self._run_task_and_verify_status(b"test installable app content") assert status == ChunkFileState.OK @@ -606,7 +620,9 @@ def test_assemble_preprod_artifact_installable_app_success(self, mock_send_webho assert call_kwargs.kwargs["organization_id"] == self.organization.id @patch("sentry.preprod.tasks.send_build_distribution_webhook") - def test_assemble_preprod_artifact_installable_app_error_cases(self, mock_send_webhook) -> None: + def test_assemble_preprod_artifact_installable_app_error_cases( + self, mock_send_webhook: MagicMock + ) -> None: # Test nonexistent artifact status, details = self._run_task_and_verify_status( b"nonexistent artifact", artifact_id=99999 @@ -635,7 +651,7 @@ def test_assemble_preprod_artifact_installable_app_error_cases(self, mock_send_w # Verify PreprodArtifact was not updated for error cases self.preprod_artifact.refresh_from_db() - assert self.preprod_artifact.installable_app_file_id is None + assert cast("object", self.preprod_artifact.installable_app_file_id) is None # Verify webhook was NOT sent for any error case mock_send_webhook.assert_not_called() @@ -648,9 +664,16 @@ def setUp(self) -> None: project=self.project, state=PreprodArtifact.ArtifactState.UPLOADED ) + # justification: get_assemble_status is untyped, so status and details are Any. def _run_task_and_verify_status( - self, content, checksum=None, chunks=None, artifact_id=None, org_id=None, project_id=None - ): + self, + content: bytes, + checksum: str | None = None, + chunks: list[str] | None = None, + artifact_id: int | None = None, + org_id: int | None = None, + project_id: int | None = None, + ) -> tuple[Any, Any]: checksum = checksum or sha1(content).hexdigest() blob = FileBlob.from_file_with_organization(ContentFile(content), self.organization) chunks = chunks or [blob.checksum] diff --git a/tests/sentry/preprod/vcs/pr_comments/test_size_tasks.py b/tests/sentry/preprod/vcs/pr_comments/test_size_tasks.py index afecc4444545..8422a474b014 100644 --- a/tests/sentry/preprod/vcs/pr_comments/test_size_tasks.py +++ b/tests/sentry/preprod/vcs/pr_comments/test_size_tasks.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import cast +from collections.abc import Callable +from typing import Any, cast from unittest.mock import Mock, patch import pytest @@ -69,11 +70,11 @@ def setUp(self) -> None: def _create_artifact( self, - with_commit_comparison=True, - pr_number=42, - provider="github", - app_id="com.example.app", - commit_comparison=None, + with_commit_comparison: bool = True, + pr_number: int | None = 42, + provider: str = "github", + app_id: str = "com.example.app", + commit_comparison: CommitComparison | None = None, ) -> PreprodArtifact: if with_commit_comparison and commit_comparison is None: commit_comparison = self.create_commit_comparison( @@ -100,7 +101,8 @@ def _enable(self) -> None: def _set_rules(self, raw: str = _RULES_JSON) -> None: self.project.update_option(RULES_OPTION_KEY, raw) - def _import_task(self): + # justification: the imported task is an untyped Celery function. + def _import_task(self) -> Callable[..., Any]: from sentry.preprod.vcs.pr_comments.size_tasks import create_preprod_size_pr_comment_task return create_preprod_size_pr_comment_task @@ -109,7 +111,9 @@ def _import_task(self): @patch(_CLIENT_PATH) @patch(_EVAL_PATH) - def test_creates_comment_when_rule_triggered(self, mock_eval, mock_get_client) -> None: + def test_creates_comment_when_rule_triggered( + self, mock_eval: Mock, mock_get_client: Mock + ) -> None: mock_client = Mock() mock_client.create_comment.return_value = {"id": 55555} mock_get_client.return_value = mock_client @@ -136,7 +140,9 @@ def test_creates_comment_when_rule_triggered(self, mock_eval, mock_get_client) - @patch(_CLIENT_PATH) @patch(_EVAL_PATH) - def test_creates_comment_when_no_rules_configured(self, mock_eval, mock_get_client) -> None: + def test_creates_comment_when_no_rules_configured( + self, mock_eval: Mock, mock_get_client: Mock + ) -> None: mock_client = Mock() mock_client.create_comment.return_value = {"id": 1} mock_get_client.return_value = mock_client @@ -153,7 +159,9 @@ def test_creates_comment_when_no_rules_configured(self, mock_eval, mock_get_clie @patch(_CLIENT_PATH) @patch(_EVAL_PATH) - def test_skips_when_rules_set_but_nothing_triggered(self, mock_eval, mock_get_client) -> None: + def test_skips_when_rules_set_but_nothing_triggered( + self, mock_eval: Mock, mock_get_client: Mock + ) -> None: mock_client = Mock() mock_get_client.return_value = mock_client mock_eval.return_value = _eval_result(triggered=False) @@ -174,7 +182,7 @@ def test_skips_when_rules_set_but_nothing_triggered(self, mock_eval, mock_get_cl @patch(_CLIENT_PATH) @patch(_EVAL_PATH) def test_updates_existing_comment_even_without_trigger( - self, mock_eval, mock_get_client + self, mock_eval: Mock, mock_get_client: Mock ) -> None: mock_client = Mock() mock_get_client.return_value = mock_client @@ -204,7 +212,7 @@ def test_updates_existing_comment_even_without_trigger( @patch(_CLIENT_PATH) @patch(_EVAL_PATH) - def test_skips_when_no_size_artifacts(self, mock_eval, mock_get_client) -> None: + def test_skips_when_no_size_artifacts(self, mock_eval: Mock, mock_get_client: Mock) -> None: mock_client = Mock() mock_get_client.return_value = mock_client # No evaluated artifacts: every sibling was skipped or had no size metrics. @@ -228,7 +236,7 @@ def test_skips_when_no_size_artifacts(self, mock_eval, mock_get_client) -> None: @patch(_CLIENT_PATH) @patch(_EVAL_PATH) def test_updates_existing_comment_when_all_artifacts_are_skipped( - self, mock_eval, mock_get_client + self, mock_eval: Mock, mock_get_client: Mock ) -> None: mock_client = Mock() mock_get_client.return_value = mock_client @@ -266,7 +274,9 @@ def test_updates_existing_comment_when_all_artifacts_are_skipped( @patch(_CLIENT_PATH) @patch(_EVAL_PATH) - def test_skips_when_project_option_disabled(self, mock_eval, mock_get_client) -> None: + def test_skips_when_project_option_disabled( + self, mock_eval: Mock, mock_get_client: Mock + ) -> None: artifact = self._create_artifact() self._import_task()(artifact.id) @@ -276,7 +286,7 @@ def test_skips_when_project_option_disabled(self, mock_eval, mock_get_client) -> @patch(_CLIENT_PATH) @patch(_EVAL_PATH) - def test_skips_when_no_commit_comparison(self, mock_eval, mock_get_client) -> None: + def test_skips_when_no_commit_comparison(self, mock_eval: Mock, mock_get_client: Mock) -> None: self._enable() artifact = self._create_artifact(with_commit_comparison=False) @@ -286,7 +296,7 @@ def test_skips_when_no_commit_comparison(self, mock_eval, mock_get_client) -> No @patch(_CLIENT_PATH) @patch(_EVAL_PATH) - def test_skips_when_no_pr_number(self, mock_eval, mock_get_client) -> None: + def test_skips_when_no_pr_number(self, mock_eval: Mock, mock_get_client: Mock) -> None: self._enable() commit_comparison = self.create_commit_comparison( organization=self.organization, provider="github", pr_number=None @@ -299,7 +309,7 @@ def test_skips_when_no_pr_number(self, mock_eval, mock_get_client) -> None: @patch(_CLIENT_PATH) @patch(_EVAL_PATH) - def test_skips_when_no_client(self, mock_eval, mock_get_client) -> None: + def test_skips_when_no_client(self, mock_eval: Mock, mock_get_client: Mock) -> None: mock_get_client.return_value = None self._enable() self._set_rules() @@ -311,7 +321,7 @@ def test_skips_when_no_client(self, mock_eval, mock_get_client) -> None: @patch(_CLIENT_PATH) @patch(_EVAL_PATH) - def test_skips_when_feature_flag_disabled(self, mock_eval, mock_get_client) -> None: + def test_skips_when_feature_flag_disabled(self, mock_eval: Mock, mock_get_client: Mock) -> None: self._enable() self._set_rules() artifact = self._create_artifact() @@ -324,7 +334,7 @@ def test_skips_when_feature_flag_disabled(self, mock_eval, mock_get_client) -> N @patch(_CLIENT_PATH) @patch(_EVAL_PATH) - def test_skips_nonexistent_artifact(self, mock_eval, mock_get_client) -> None: + def test_skips_nonexistent_artifact(self, mock_eval: Mock, mock_get_client: Mock) -> None: self._import_task()(1234567) mock_get_client.assert_not_called() @@ -333,7 +343,7 @@ def test_skips_nonexistent_artifact(self, mock_eval, mock_get_client) -> None: @patch(_CLIENT_PATH) @patch(_EVAL_PATH) - def test_handles_api_error_and_reraises(self, mock_eval, mock_get_client) -> None: + def test_handles_api_error_and_reraises(self, mock_eval: Mock, mock_get_client: Mock) -> None: mock_client = Mock() mock_client.create_comment.side_effect = ApiError("boom", code=500) mock_get_client.return_value = mock_client @@ -356,7 +366,7 @@ def test_handles_api_error_and_reraises(self, mock_eval, mock_get_client) -> Non @patch(_CLIENT_PATH) @patch(_EVAL_PATH) def test_retry_after_update_failure_uses_update_not_create( - self, mock_eval, mock_get_client + self, mock_eval: Mock, mock_get_client: Mock ) -> None: mock_client = Mock() mock_get_client.return_value = mock_client @@ -383,7 +393,7 @@ def test_retry_after_update_failure_uses_update_not_create( @patch(_CLIENT_PATH) @patch(_EVAL_PATH) def test_returns_without_retry_when_commit_comparison_deleted( - self, mock_eval, mock_get_client, mock_lock + self, mock_eval: Mock, mock_get_client: Mock, mock_lock: Mock ) -> None: mock_client = Mock() mock_get_client.return_value = mock_client @@ -403,7 +413,7 @@ def test_returns_without_retry_when_commit_comparison_deleted( # --- end-to-end (real evaluation, unpatched) ------------------------ @patch(_CLIENT_PATH) - def test_end_to_end_triggering_rule_creates_comment(self, mock_get_client) -> None: + def test_end_to_end_triggering_rule_creates_comment(self, mock_get_client: Mock) -> None: mock_client = Mock() mock_client.create_comment.return_value = {"id": 314} mock_get_client.return_value = mock_client diff --git a/tests/sentry/preprod/vcs/pr_comments/test_snapshot_tasks.py b/tests/sentry/preprod/vcs/pr_comments/test_snapshot_tasks.py index a5d7bdcd6273..eb753080b4b0 100644 --- a/tests/sentry/preprod/vcs/pr_comments/test_snapshot_tasks.py +++ b/tests/sentry/preprod/vcs/pr_comments/test_snapshot_tasks.py @@ -88,8 +88,13 @@ class CreatePreprodSnapshotPrCommentTaskTest(SnapshotPrCommentTaskTestBase): @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.format_snapshot_pr_comment") @patch("sentry.preprod.models.PreprodArtifact.get_base_artifacts_for_commit") def test_dispatches_subtask( - self, mock_get_base, mock_format, mock_get_client, mock_evaluate_changes, mock_delay - ): + self, + mock_get_base: Mock, + mock_format: Mock, + mock_get_client: Mock, + mock_evaluate_changes: Mock, + mock_delay: Mock, + ) -> None: mock_get_client.return_value = Mock() mock_format.return_value = "## Sentry Snapshot Testing\n..." @@ -111,7 +116,7 @@ def test_dispatches_subtask( ) @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") - def test_skips_when_no_commit_comparison(self, mock_get_client): + def test_skips_when_no_commit_comparison(self, mock_get_client: Mock) -> None: artifact, metrics = self._create_artifact_with_metrics(with_commit_comparison=False) create_preprod_snapshot_pr_comment_task(artifact.id) @@ -119,7 +124,7 @@ def test_skips_when_no_commit_comparison(self, mock_get_client): mock_get_client.assert_not_called() @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") - def test_skips_when_no_pr_number(self, mock_get_client): + def test_skips_when_no_pr_number(self, mock_get_client: Mock) -> None: artifact, metrics = self._create_artifact_with_metrics(pr_number=None) create_preprod_snapshot_pr_comment_task(artifact.id) @@ -127,7 +132,7 @@ def test_skips_when_no_pr_number(self, mock_get_client): mock_get_client.assert_not_called() @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") - def test_skips_when_no_client(self, mock_get_client): + def test_skips_when_no_client(self, mock_get_client: Mock) -> None: mock_get_client.return_value = None artifact, metrics = self._create_artifact_with_metrics() @@ -136,7 +141,7 @@ def test_skips_when_no_client(self, mock_get_client): mock_get_client.assert_called_once() @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") - def test_skips_when_no_snapshot_metrics(self, mock_get_client): + def test_skips_when_no_snapshot_metrics(self, mock_get_client: Mock) -> None: mock_client = Mock() mock_get_client.return_value = mock_client @@ -165,7 +170,7 @@ def test_skips_when_no_snapshot_metrics(self, mock_get_client): mock_client.update_comment.assert_not_called() @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") - def test_skips_when_project_option_disabled(self, mock_get_client): + def test_skips_when_project_option_disabled(self, mock_get_client: Mock) -> None: self.project.update_option("sentry:preprod_snapshot_pr_comments_enabled", False) artifact, metrics = self._create_artifact_with_metrics() @@ -175,7 +180,7 @@ def test_skips_when_project_option_disabled(self, mock_get_client): @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.format_snapshot_pr_comment") @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") - def test_skips_when_no_changes(self, mock_get_client, mock_format): + def test_skips_when_no_changes(self, mock_get_client: Mock, mock_format: Mock) -> None: mock_client = Mock() mock_get_client.return_value = mock_client @@ -190,7 +195,9 @@ def test_skips_when_no_changes(self, mock_get_client, mock_format): @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.post_snapshot_pr_comment_task.delay") @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.format_snapshot_pr_comment") @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") - def test_posts_when_comparison_failed(self, mock_get_client, mock_format, mock_delay): + def test_posts_when_comparison_failed( + self, mock_get_client: Mock, mock_format: Mock, mock_delay: Mock + ) -> None: mock_get_client.return_value = Mock() mock_format.return_value = "body" @@ -213,8 +220,8 @@ def test_posts_when_comparison_failed(self, mock_get_client, mock_format, mock_d @patch("sentry.preprod.models.PreprodArtifact.get_base_artifacts_for_commit") @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") def test_posts_when_all_images_errored( - self, mock_get_client, mock_get_base, mock_format, mock_delay - ): + self, mock_get_client: Mock, mock_get_base: Mock, mock_format: Mock, mock_delay: Mock + ) -> None: mock_get_client.return_value = Mock() mock_format.return_value = "body" @@ -235,12 +242,12 @@ def test_posts_when_all_images_errored( mock_delay.assert_called_once() @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") - def test_skips_nonexistent_artifact(self, mock_get_client): + def test_skips_nonexistent_artifact(self, mock_get_client: Mock) -> None: create_preprod_snapshot_pr_comment_task(99999) mock_get_client.assert_not_called() - def test_snapshot_pr_comment_reporting_criteria_uses_options(self): + def test_snapshot_pr_comment_reporting_criteria_uses_options(self) -> None: self.project.update_option("sentry:preprod_snapshot_pr_comments_post_on_added", True) self.project.update_option("sentry:preprod_snapshot_pr_comments_post_on_removed", False) self.project.update_option("sentry:preprod_snapshot_pr_comments_post_on_changed", False) @@ -257,8 +264,8 @@ def test_snapshot_pr_comment_reporting_criteria_uses_options(self): @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") @patch("sentry.preprod.models.PreprodArtifact.get_base_artifacts_for_commit") def test_reportable_changes_without_approval_requirement( - self, mock_get_base, mock_get_client, mock_delay - ): + self, mock_get_base: Mock, mock_get_client: Mock, mock_delay: Mock + ) -> None: mock_get_client.return_value = Mock() self.project.update_option("sentry:preprod_snapshot_pr_comments_post_on_added", True) self.project.update_option("sentry:preprod_snapshot_status_checks_fail_on_added", False) @@ -285,8 +292,8 @@ def test_reportable_changes_without_approval_requirement( @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") @patch("sentry.preprod.models.PreprodArtifact.get_base_artifacts_for_commit") def test_disabled_status_checks_do_not_require_approval_in_comment( - self, mock_get_base, mock_get_client, mock_delay - ): + self, mock_get_base: Mock, mock_get_client: Mock, mock_delay: Mock + ) -> None: mock_get_client.return_value = Mock() self.project.update_option("sentry:preprod_snapshot_status_checks_enabled", False) self.project.update_option("sentry:preprod_snapshot_pr_comments_post_on_removed", True) @@ -343,8 +350,8 @@ def _approve(self, artifact: PreprodArtifact) -> None: @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.format_missing_base_snapshot_pr_comment") def test_timeout_partially_approved_posts_missing_base_comment( - self, mock_format_missing, mock_get_client, mock_delay - ): + self, mock_format_missing: Mock, mock_get_client: Mock, mock_delay: Mock + ) -> None: mock_get_client.return_value = Mock() mock_format_missing.return_value = "missing body" @@ -366,8 +373,8 @@ def test_timeout_partially_approved_posts_missing_base_comment( "sentry.preprod.vcs.pr_comments.snapshot_tasks.format_approved_without_base_snapshot_pr_comment" ) def test_approved_without_base_posts_approved_comment( - self, mock_format_approved, mock_get_client, mock_delay - ): + self, mock_format_approved: Mock, mock_get_client: Mock, mock_delay: Mock + ) -> None: mock_get_client.return_value = Mock() mock_format_approved.return_value = "approved body" @@ -387,8 +394,8 @@ def test_approved_without_base_posts_approved_comment( "sentry.preprod.vcs.pr_comments.snapshot_tasks.format_approved_without_base_snapshot_pr_comment" ) def test_timeout_approved_without_base_posts_approved_comment( - self, mock_format_approved, mock_get_client, mock_delay - ): + self, mock_format_approved: Mock, mock_get_client: Mock, mock_delay: Mock + ) -> None: mock_get_client.return_value = Mock() mock_format_approved.return_value = "approved body" @@ -404,7 +411,9 @@ def test_timeout_approved_without_base_posts_approved_comment( @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.post_snapshot_pr_comment_task.delay") @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.format_solo_snapshot_pr_comment") - def test_no_base_sha_posts_solo_comment(self, mock_format_solo, mock_get_client, mock_delay): + def test_no_base_sha_posts_solo_comment( + self, mock_format_solo: Mock, mock_get_client: Mock, mock_delay: Mock + ) -> None: mock_get_client.return_value = Mock() mock_format_solo.return_value = "solo body" @@ -433,8 +442,8 @@ def test_no_base_sha_posts_solo_comment(self, mock_format_solo, mock_get_client, @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.format_solo_snapshot_pr_comment") def test_first_upload_with_base_sha_posts_solo_comment( - self, mock_format_solo, mock_get_client, mock_delay - ): + self, mock_format_solo: Mock, mock_get_client: Mock, mock_delay: Mock + ) -> None: mock_get_client.return_value = Mock() mock_format_solo.return_value = "first upload body" @@ -452,8 +461,8 @@ def test_first_upload_with_base_sha_posts_solo_comment( "sentry.preprod.vcs.pr_comments.snapshot_tasks.format_waiting_for_base_snapshot_pr_comment" ) def test_base_sha_no_match_posts_waiting_comment( - self, mock_format_waiting, mock_get_client, mock_delay - ): + self, mock_format_waiting: Mock, mock_get_client: Mock, mock_delay: Mock + ) -> None: mock_get_client.return_value = Mock() mock_format_waiting.return_value = "waiting body" @@ -470,8 +479,8 @@ def test_base_sha_no_match_posts_waiting_comment( @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_commit_context_client") @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.format_missing_base_snapshot_pr_comment") def test_timeout_posts_missing_base_comment( - self, mock_format_missing, mock_get_client, mock_delay - ): + self, mock_format_missing: Mock, mock_get_client: Mock, mock_delay: Mock + ) -> None: mock_get_client.return_value = Mock() mock_format_missing.return_value = "missing body" @@ -490,8 +499,13 @@ def test_timeout_posts_missing_base_comment( @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.format_snapshot_pr_comment") @patch("sentry.preprod.models.PreprodArtifact.get_base_artifacts_for_commit") def test_timeout_with_base_arrived_skips_update( - self, mock_get_base, mock_format_normal, mock_get_client, mock_evaluate_changes, mock_delay - ): + self, + mock_get_base: Mock, + mock_format_normal: Mock, + mock_get_client: Mock, + mock_evaluate_changes: Mock, + mock_delay: Mock, + ) -> None: mock_get_client.return_value = Mock() mock_format_normal.return_value = "normal body" @@ -511,8 +525,13 @@ def test_timeout_with_base_arrived_skips_update( @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.format_snapshot_pr_comment") @patch("sentry.preprod.models.PreprodArtifact.get_base_artifacts_for_commit") def test_updates_existing_comment_when_no_changes( - self, mock_get_base, mock_format, mock_get_client, mock_evaluate_changes, mock_delay - ): + self, + mock_get_base: Mock, + mock_format: Mock, + mock_get_client: Mock, + mock_evaluate_changes: Mock, + mock_delay: Mock, + ) -> None: mock_get_client.return_value = Mock() mock_format.return_value = "unchanged body" @@ -565,7 +584,7 @@ def _create_mock_github_client(self, head_sha: str | None = None) -> Mock: return mock_client @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_github_client") - def test_creates_comment(self, mock_get_client): + def test_creates_comment(self, mock_get_client: Mock) -> None: mock_client = self._create_mock_github_client() mock_client.create_comment.return_value = {"id": 99999} mock_get_client.return_value = mock_client @@ -591,7 +610,7 @@ def test_creates_comment(self, mock_get_client): assert snapshots["comment_id"] == "99999" @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_github_client") - def test_updates_existing_comment(self, mock_get_client): + def test_updates_existing_comment(self, mock_get_client: Mock) -> None: self.commit_comparison.extras = { "pr_comments": {"snapshots": {"success": True, "comment_id": "existing_123"}} } @@ -623,7 +642,7 @@ def test_updates_existing_comment(self, mock_get_client): assert snapshots["comment_id"] == "existing_123" @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_github_client") - def test_skips_comment_when_provider_head_differs(self, mock_get_client): + def test_skips_comment_when_provider_head_differs(self, mock_get_client: Mock) -> None: mock_client = self._create_mock_github_client(head_sha="c" * 40) mock_get_client.return_value = mock_client @@ -642,7 +661,7 @@ def test_skips_comment_when_provider_head_differs(self, mock_get_client): assert "pr_comments" not in self.commit_comparison.extras @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_github_client") - def test_posts_comment_when_provider_head_check_fails(self, mock_get_client): + def test_posts_comment_when_provider_head_check_fails(self, mock_get_client: Mock) -> None: mock_client = self._create_mock_github_client() mock_client.get_pull_request.side_effect = ApiError("server error", code=500) mock_client.create_comment.return_value = {"id": 99999} @@ -660,7 +679,9 @@ def test_posts_comment_when_provider_head_check_fails(self, mock_get_client): mock_client.create_comment.assert_called_once() @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_github_client") - def test_posts_comment_when_provider_response_has_null_head(self, mock_get_client): + def test_posts_comment_when_provider_response_has_null_head( + self, mock_get_client: Mock + ) -> None: mock_client = self._create_mock_github_client() mock_client.get_pull_request.return_value = {"head": None} mock_client.create_comment.return_value = {"id": 99999} @@ -678,7 +699,9 @@ def test_posts_comment_when_provider_response_has_null_head(self, mock_get_clien mock_client.create_comment.assert_called_once() @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_github_client") - def test_posts_comment_when_provider_response_has_empty_head_sha(self, mock_get_client): + def test_posts_comment_when_provider_response_has_empty_head_sha( + self, mock_get_client: Mock + ) -> None: mock_client = self._create_mock_github_client(head_sha="") mock_client.create_comment.return_value = {"id": 99999} mock_get_client.return_value = mock_client @@ -695,7 +718,7 @@ def test_posts_comment_when_provider_response_has_empty_head_sha(self, mock_get_ mock_client.create_comment.assert_called_once() @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_github_client") - def test_handles_api_error(self, mock_get_client): + def test_handles_api_error(self, mock_get_client: Mock) -> None: mock_client = self._create_mock_github_client() mock_client.create_comment.side_effect = ApiError("rate limited", code=429) mock_get_client.return_value = mock_client @@ -716,7 +739,7 @@ def test_handles_api_error(self, mock_get_client): assert snapshots["error_type"] == "api_error" @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_github_client") - def test_preserves_comment_id_on_update_failure(self, mock_get_client): + def test_preserves_comment_id_on_update_failure(self, mock_get_client: Mock) -> None: self.commit_comparison.extras = { "pr_comments": {"snapshots": {"success": True, "comment_id": "orig_789"}} } @@ -742,7 +765,7 @@ def test_preserves_comment_id_on_update_failure(self, mock_get_client): assert snapshots["comment_id"] == "orig_789" @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_github_client") - def test_no_comment_id_on_create_failure(self, mock_get_client): + def test_no_comment_id_on_create_failure(self, mock_get_client: Mock) -> None: mock_client = self._create_mock_github_client() mock_client.create_comment.side_effect = ApiError("server error", code=500) mock_get_client.return_value = mock_client @@ -763,7 +786,7 @@ def test_no_comment_id_on_create_failure(self, mock_get_client): assert "comment_id" not in snapshots @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_github_client") - def test_permanent_4xx_does_not_reraise(self, mock_get_client): + def test_permanent_4xx_does_not_reraise(self, mock_get_client: Mock) -> None: mock_client = self._create_mock_github_client() mock_client.create_comment.side_effect = ApiError("forbidden", code=403) mock_get_client.return_value = mock_client @@ -782,7 +805,7 @@ def test_permanent_4xx_does_not_reraise(self, mock_get_client): assert snapshots["success"] is False @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_github_client") - def test_skips_when_no_client(self, mock_get_client): + def test_skips_when_no_client(self, mock_get_client: Mock) -> None: mock_get_client.return_value = None post_snapshot_pr_comment_task( @@ -794,7 +817,7 @@ def test_skips_when_no_client(self, mock_get_client): comment_body="body", ) - def test_skips_when_org_not_found(self): + def test_skips_when_org_not_found(self) -> None: post_snapshot_pr_comment_task( organization_id=99999, repo_name="owner/repo", @@ -805,7 +828,9 @@ def test_skips_when_org_not_found(self): ) @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_github_client") - def test_skips_when_commit_comparison_missing_from_locked_rows(self, mock_get_client): + def test_skips_when_commit_comparison_missing_from_locked_rows( + self, mock_get_client: Mock + ) -> None: mock_client = self._create_mock_github_client() mock_get_client.return_value = mock_client @@ -822,7 +847,9 @@ def test_skips_when_commit_comparison_missing_from_locked_rows(self, mock_get_cl mock_client.update_comment.assert_not_called() @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_github_client") - def test_updates_current_head_when_newer_comparison_holds_comment_id(self, mock_get_client): + def test_updates_current_head_when_newer_comparison_holds_comment_id( + self, mock_get_client: Mock + ) -> None: CommitComparison.objects.create( organization_id=self.organization.id, head_sha="c" * 40, @@ -857,7 +884,7 @@ def test_updates_current_head_when_newer_comparison_holds_comment_id(self, mock_ mock_client.create_comment.assert_not_called() @patch("sentry.preprod.vcs.pr_comments.snapshot_tasks.get_github_client") - def test_concurrent_posts_do_not_duplicate_comment(self, mock_get_client): + def test_concurrent_posts_do_not_duplicate_comment(self, mock_get_client: Mock) -> None: cc_b = CommitComparison.objects.create( organization_id=self.organization.id, head_sha="c" * 40, diff --git a/tests/sentry/preprod/vcs/pr_comments/test_tasks.py b/tests/sentry/preprod/vcs/pr_comments/test_tasks.py index 52e790c7941f..571dbcbe1a6d 100644 --- a/tests/sentry/preprod/vcs/pr_comments/test_tasks.py +++ b/tests/sentry/preprod/vcs/pr_comments/test_tasks.py @@ -1,6 +1,7 @@ from __future__ import annotations import uuid +from typing import cast from unittest.mock import Mock, patch import pytest @@ -35,15 +36,15 @@ def setUp(self) -> None: def _create_artifact( self, - with_commit_comparison=True, - pr_number=42, - provider="github", - installable_app_file_id=1, - app_id="com.example.app", - artifact_type=PreprodArtifact.ArtifactType.XCARCHIVE, - build_number=456, - extras=_sentinel, - commit_comparison=None, + with_commit_comparison: bool = True, + pr_number: int | None = 42, + provider: str = "github", + installable_app_file_id: int | None = 1, + app_id: str = "com.example.app", + artifact_type: int = PreprodArtifact.ArtifactType.XCARCHIVE, + build_number: int | None = 456, + extras: object = _sentinel, + commit_comparison: CommitComparison | None = None, ) -> PreprodArtifact: if extras is _sentinel: if artifact_type == PreprodArtifact.ArtifactType.XCARCHIVE: @@ -56,14 +57,14 @@ def _create_artifact( artifact_type=artifact_type, app_id=app_id, build_configuration=self.build_config, - installable_app_file_id=installable_app_file_id, + installable_app_file_id=cast("int", installable_app_file_id), extras=extras, ) PreprodArtifactMobileAppInfo.objects.create( preprod_artifact=artifact, app_name="TestApp", build_version="1.0.0", - build_number=build_number, + build_number=cast("int", build_number), ) if with_commit_comparison: @@ -91,7 +92,7 @@ def _create_artifact( "project__organization", ).get(id=artifact.id) - def _create_repo(self): + def _create_repo(self) -> Repository: return Repository.objects.create( organization_id=self.organization.id, name="owner/repo", @@ -101,7 +102,7 @@ def _create_repo(self): @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") @patch("sentry.preprod.vcs.pr_comments.tasks.format_pr_comment") - def test_creates_comment(self, mock_format, mock_get_client): + def test_creates_comment(self, mock_format: Mock, mock_get_client: Mock) -> None: mock_client = Mock() mock_client.create_comment.return_value = {"id": 99999} mock_get_client.return_value = mock_client @@ -126,7 +127,7 @@ def test_creates_comment(self, mock_format, mock_get_client): @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") @patch("sentry.preprod.vcs.pr_comments.tasks.format_pr_comment") - def test_updates_existing_comment(self, mock_format, mock_get_client): + def test_updates_existing_comment(self, mock_format: Mock, mock_get_client: Mock) -> None: mock_client = Mock() mock_get_client.return_value = mock_client mock_format.return_value = "updated body" @@ -172,7 +173,7 @@ def test_updates_existing_comment(self, mock_format, mock_get_client): mock_client.create_comment.assert_not_called() @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") - def test_skips_when_no_commit_comparison(self, mock_get_client): + def test_skips_when_no_commit_comparison(self, mock_get_client: Mock) -> None: artifact = self._create_artifact(with_commit_comparison=False) create_preprod_pr_comment_task(artifact.id) @@ -180,7 +181,7 @@ def test_skips_when_no_commit_comparison(self, mock_get_client): mock_get_client.assert_not_called() @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") - def test_skips_when_no_pr_number(self, mock_get_client): + def test_skips_when_no_pr_number(self, mock_get_client: Mock) -> None: artifact = self._create_artifact(pr_number=None) create_preprod_pr_comment_task(artifact.id) @@ -188,7 +189,7 @@ def test_skips_when_no_pr_number(self, mock_get_client): mock_get_client.assert_not_called() @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") - def test_skips_when_not_github(self, mock_get_client): + def test_skips_when_not_github(self, mock_get_client: Mock) -> None: mock_get_client.return_value = None artifact = self._create_artifact(provider="gitlab") @@ -197,7 +198,7 @@ def test_skips_when_not_github(self, mock_get_client): mock_get_client.assert_called_once() @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") - def test_skips_when_not_installable(self, mock_get_client): + def test_skips_when_not_installable(self, mock_get_client: Mock) -> None: mock_client = Mock() mock_get_client.return_value = mock_client artifact = self._create_artifact(installable_app_file_id=None, build_number=None) @@ -208,7 +209,7 @@ def test_skips_when_not_installable(self, mock_get_client): mock_client.update_comment.assert_not_called() @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") - def test_skips_when_project_option_disabled(self, mock_get_client): + def test_skips_when_project_option_disabled(self, mock_get_client: Mock) -> None: self.project.update_option( "sentry:preprod_distribution_pr_comments_enabled_by_customer", False ) @@ -219,13 +220,13 @@ def test_skips_when_project_option_disabled(self, mock_get_client): mock_get_client.assert_not_called() @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") - def test_skips_nonexistent_artifact(self, mock_get_client): + def test_skips_nonexistent_artifact(self, mock_get_client: Mock) -> None: create_preprod_pr_comment_task(99999) mock_get_client.assert_not_called() @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") - def test_skips_when_no_github_client(self, mock_get_client): + def test_skips_when_no_github_client(self, mock_get_client: Mock) -> None: mock_get_client.return_value = None artifact = self._create_artifact() @@ -236,7 +237,7 @@ def test_skips_when_no_github_client(self, mock_get_client): @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") @patch("sentry.preprod.vcs.pr_comments.tasks.format_pr_comment") - def test_handles_api_error(self, mock_format, mock_get_client): + def test_handles_api_error(self, mock_format: Mock, mock_get_client: Mock) -> None: mock_client = Mock() mock_client.create_comment.side_effect = ApiError("rate limited", code=429) mock_get_client.return_value = mock_client @@ -255,7 +256,9 @@ def test_handles_api_error(self, mock_format, mock_get_client): @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") @patch("sentry.preprod.vcs.pr_comments.tasks.format_pr_comment") - def test_retry_after_update_failure_uses_update_not_create(self, mock_format, mock_get_client): + def test_retry_after_update_failure_uses_update_not_create( + self, mock_format: Mock, mock_get_client: Mock + ) -> None: """When update_comment fails, the stored comment_id must survive so that the retry calls update_comment again instead of create_comment.""" mock_client = Mock() @@ -307,7 +310,9 @@ def test_retry_after_update_failure_uses_update_not_create(self, mock_format, mo @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") @patch("sentry.preprod.vcs.pr_comments.tasks.format_pr_comment") - def test_retry_after_create_failure_creates_again(self, mock_format, mock_get_client): + def test_retry_after_create_failure_creates_again( + self, mock_format: Mock, mock_get_client: Mock + ) -> None: """When create_comment fails, no comment_id is stored, so the retry calls create_comment again rather than update_comment.""" mock_client = Mock() @@ -351,7 +356,9 @@ def test_retry_after_create_failure_creates_again(self, mock_format, mock_get_cl @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") @patch("sentry.preprod.vcs.pr_comments.tasks.format_pr_comment") - def test_reads_fresh_extras_via_select_for_update(self, mock_format, mock_get_client): + def test_reads_fresh_extras_via_select_for_update( + self, mock_format: Mock, mock_get_client: Mock + ) -> None: """select_for_update gives a fresh DB read, so a comment_id written by a concurrent task between artifact load and row lock is found.""" mock_client = Mock() @@ -394,7 +401,9 @@ def test_reads_fresh_extras_via_select_for_update(self, mock_format, mock_get_cl @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") @patch("sentry.preprod.vcs.pr_comments.tasks.format_pr_comment") - def test_updates_comment_from_previous_commit(self, mock_format, mock_get_client): + def test_updates_comment_from_previous_commit( + self, mock_format: Mock, mock_get_client: Mock + ) -> None: """When a previous commit on the same PR already has a comment_id, a new commit should update that comment instead of creating a new one.""" mock_client = Mock() @@ -455,7 +464,9 @@ def test_updates_comment_from_previous_commit(self, mock_format, mock_get_client @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") @patch("sentry.preprod.vcs.pr_comments.tasks.format_pr_comment") - def test_creates_comment_when_no_sibling_has_comment_id(self, mock_format, mock_get_client): + def test_creates_comment_when_no_sibling_has_comment_id( + self, mock_format: Mock, mock_get_client: Mock + ) -> None: """When multiple commits exist on the same PR but none has a comment_id, a new comment should be created.""" mock_client = Mock() @@ -512,7 +523,7 @@ def test_skips_xcarchive_without_valid_code_signature(self) -> None: # No comment posted — task returns early because artifact is not installable @patch("sentry.preprod.vcs.pr_comments.tasks.get_commit_context_client") - def test_skips_xcarchive_with_app_store_codesigning(self, mock_get_client): + def test_skips_xcarchive_with_app_store_codesigning(self, mock_get_client: Mock) -> None: mock_client = Mock() mock_get_client.return_value = mock_client artifact = self._create_artifact( diff --git a/tests/sentry/preprod/vcs/pr_comments/test_templates.py b/tests/sentry/preprod/vcs/pr_comments/test_templates.py index 361d09e0494a..bbf67d0721d8 100644 --- a/tests/sentry/preprod/vcs/pr_comments/test_templates.py +++ b/tests/sentry/preprod/vcs/pr_comments/test_templates.py @@ -27,12 +27,12 @@ def setUp(self) -> None: def _create_artifact( self, - artifact_type=PreprodArtifact.ArtifactType.XCARCHIVE, - app_id="com.example.app", - app_name="MyApp", - build_version="1.2.3", - build_number=456, - installable_app_file_id=1, + artifact_type: int = PreprodArtifact.ArtifactType.XCARCHIVE, + app_id: str = "com.example.app", + app_name: str = "MyApp", + build_version: str = "1.2.3", + build_number: int = 456, + installable_app_file_id: int = 1, ) -> PreprodArtifact: artifact = PreprodArtifact.objects.create( project=self.project, diff --git a/tests/sentry/preprod/vcs/status_checks/size/test_status_checks_tasks.py b/tests/sentry/preprod/vcs/status_checks/size/test_status_checks_tasks.py index e535f09d9c21..b719bc914785 100644 --- a/tests/sentry/preprod/vcs/status_checks/size/test_status_checks_tasks.py +++ b/tests/sentry/preprod/vcs/status_checks/size/test_status_checks_tasks.py @@ -2,7 +2,8 @@ import uuid from datetime import timedelta -from unittest.mock import Mock, patch +from typing import cast +from unittest.mock import MagicMock, Mock, _patch, patch import responses from django.utils import timezone @@ -34,12 +35,12 @@ def setUp(self) -> None: def _create_preprod_artifact( self, - state=PreprodArtifact.ArtifactState.PROCESSED, - with_commit_comparison=True, - error_message=None, - app_id="com.example.app", - unique_shas=True, - ): + state: int = PreprodArtifact.ArtifactState.PROCESSED, + with_commit_comparison: bool = True, + error_message: str | None = None, + app_id: str = "com.example.app", + unique_shas: bool = True, + ) -> PreprodArtifact: """Helper to create PreprodArtifact with optional CommitComparison.""" preprod_artifact = PreprodArtifact.objects.create( project=self.project, @@ -73,7 +74,9 @@ def _create_preprod_artifact( return preprod_artifact - def _create_working_status_check_setup(self, preprod_artifact): + def _create_working_status_check_setup( + self, preprod_artifact: PreprodArtifact + ) -> tuple[Mock, Mock, _patch[MagicMock], _patch[MagicMock]]: """Helper to mock a working status check setup with GitHub integration.""" # Create repository that matches the commit comparison repository = Repository.objects.create( @@ -99,7 +102,7 @@ def _create_working_status_check_setup(self, preprod_artifact): return mock_client, mock_provider, patcher_client, patcher_provider - def _create_no_api_calls_setup(self): + def _create_no_api_calls_setup(self) -> tuple[Mock, Mock, _patch[MagicMock], _patch[MagicMock]]: """Helper to mock setup for tests that expect no API calls to be made.""" mock_client = Mock() mock_provider = Mock() @@ -220,7 +223,7 @@ def test_create_preprod_status_check_task_repository_no_integration_id(self) -> organization_id=self.organization.id, name="owner/repo", provider="integrations:github", - integration_id=None, + integration_id=cast("int", None), ) _, mock_provider, client_patch, provider_patch = self._create_no_api_calls_setup() @@ -236,8 +239,9 @@ def test_create_preprod_status_check_task_unsupported_provider(self) -> None: """Test task handles unsupported VCS provider gracefully and makes no API calls.""" preprod_artifact = self._create_preprod_artifact() # Change provider to unsupported one (GitLab is not supported) - preprod_artifact.commit_comparison.provider = "gitlab" - preprod_artifact.commit_comparison.save() + commit_comparison = cast("CommitComparison", preprod_artifact.commit_comparison) + commit_comparison.provider = "gitlab" + commit_comparison.save() _, mock_provider, client_patch, provider_patch = self._create_no_api_calls_setup() @@ -295,7 +299,10 @@ def test_create_preprod_status_check_task_all_artifact_states(self) -> None: call_kwargs = mock_provider.create_status_check.call_args.kwargs assert call_kwargs["repo"] == "owner/repo" - assert call_kwargs["sha"] == preprod_artifact.commit_comparison.head_sha + assert ( + call_kwargs["sha"] + == cast("CommitComparison", preprod_artifact.commit_comparison).head_sha + ) assert call_kwargs["title"] == "Size Analysis" # When no rules are configured, all statuses are converted to NEUTRAL. diff --git a/tests/sentry/preprod/vcs/status_checks/size/test_templates.py b/tests/sentry/preprod/vcs/status_checks/size/test_templates.py index 66f4a94a47a7..da14a092db1f 100644 --- a/tests/sentry/preprod/vcs/status_checks/size/test_templates.py +++ b/tests/sentry/preprod/vcs/status_checks/size/test_templates.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + import pytest from sentry.integrations.source_code_management.status_check import StatusCheckStatus @@ -1904,8 +1906,9 @@ def test_triggered_rule_without_build_configuration_unchanged(self) -> None: class MarkdownEscapingTest(StatusCheckTestBase): """Untrusted artifact metadata must be escaped before rendering to Markdown.""" + # justification: factory kwargs vary per test case. def _analyzed_artifact( - self, **kwargs + self, **kwargs: Any ) -> tuple[PreprodArtifact, dict[int, list[PreprodArtifactSizeMetrics]]]: kwargs.setdefault("build_version", "1.0.0") kwargs.setdefault("build_number", 1) diff --git a/tests/sentry/preprod/vcs/status_checks/snapshots/test_tasks.py b/tests/sentry/preprod/vcs/status_checks/snapshots/test_tasks.py index 079c224996f8..173a2cedfa7b 100644 --- a/tests/sentry/preprod/vcs/status_checks/snapshots/test_tasks.py +++ b/tests/sentry/preprod/vcs/status_checks/snapshots/test_tasks.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import timedelta +from typing import Any from unittest.mock import Mock, patch import pytest @@ -28,7 +29,7 @@ class SnapshotTasksTestBase(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.organization = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.organization) @@ -36,12 +37,12 @@ def setUp(self): def _make_artifact_with_comparison( self, - images_changed=0, - images_added=0, - images_removed=0, - images_renamed=0, - state=PreprodSnapshotComparison.State.SUCCESS, - ): + images_changed: int = 0, + images_added: int = 0, + images_removed: int = 0, + images_renamed: int = 0, + state: int = PreprodSnapshotComparison.State.SUCCESS, + ) -> tuple[PreprodArtifact, PreprodSnapshotMetrics, PreprodSnapshotComparison]: artifact = self.create_preprod_artifact( project=self.project, commit_comparison=self.commit_comparison, @@ -82,7 +83,7 @@ def _criteria( @cell_silo_test class SnapshotApprovalPolicyTest(SnapshotTasksTestBase): - def test_disabled_status_checks_have_no_approval_criteria(self): + def test_disabled_status_checks_have_no_approval_criteria(self) -> None: self.project.update_option("sentry:preprod_snapshot_status_checks_enabled", False) policy = get_snapshot_approval_policy(self.project) @@ -95,7 +96,7 @@ def test_disabled_status_checks_have_no_approval_criteria(self): renamed=False, ) - def test_enabled_status_checks_use_configured_approval_criteria(self): + def test_enabled_status_checks_use_configured_approval_criteria(self) -> None: self.project.update_option("sentry:preprod_snapshot_status_checks_enabled", True) self.project.update_option("sentry:preprod_snapshot_status_checks_fail_on_added", True) self.project.update_option("sentry:preprod_snapshot_status_checks_fail_on_removed", False) @@ -115,7 +116,13 @@ def test_enabled_status_checks_use_configured_approval_criteria(self): @cell_silo_test class ComputeSnapshotStatusTest(SnapshotTasksTestBase): - def _status_with_changes_map(self, artifact, metrics, approvals=None, criteria=None): + def _status_with_changes_map( + self, + artifact: PreprodArtifact, + metrics: PreprodSnapshotMetrics, + approvals: dict[int, PreprodComparisonApproval] | None = None, + criteria: SnapshotChangeCriteria | None = None, + ) -> StatusCheckStatus: artifacts = [artifact] metrics_map = {artifact.id: metrics} comparisons_map = {metrics.id: _get_comparison(metrics)} @@ -126,57 +133,57 @@ def _status_with_changes_map(self, artifact, metrics, approvals=None, criteria=N artifacts, metrics_map, comparisons_map, approvals or {}, changes_map ) - def test_images_added_ignored_when_flag_off(self): + def test_images_added_ignored_when_flag_off(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_added=5) assert self._status_with_changes_map(artifact, metrics) == StatusCheckStatus.SUCCESS - def test_images_added_requires_action_when_flag_on(self): + def test_images_added_requires_action_when_flag_on(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_added=5) assert ( self._status_with_changes_map(artifact, metrics, criteria=_criteria(added=True)) == StatusCheckStatus.ACTION_REQUIRED ) - def test_images_removed_ignored_when_flag_off(self): + def test_images_removed_ignored_when_flag_off(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_removed=3) assert ( self._status_with_changes_map(artifact, metrics, criteria=_criteria(removed=False)) == StatusCheckStatus.SUCCESS ) - def test_images_removed_requires_action_by_default(self): + def test_images_removed_requires_action_by_default(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_removed=3) assert self._status_with_changes_map(artifact, metrics) == StatusCheckStatus.ACTION_REQUIRED - def test_images_changed_requires_action_by_default(self): + def test_images_changed_requires_action_by_default(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_changed=2) assert self._status_with_changes_map(artifact, metrics) == StatusCheckStatus.ACTION_REQUIRED - def test_images_changed_ignored_when_flag_off(self): + def test_images_changed_ignored_when_flag_off(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_changed=2) assert ( self._status_with_changes_map(artifact, metrics, criteria=_criteria(changed=False)) == StatusCheckStatus.SUCCESS ) - def test_images_renamed_ignored_by_default(self): + def test_images_renamed_ignored_by_default(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_renamed=1) assert self._status_with_changes_map(artifact, metrics) == StatusCheckStatus.SUCCESS - def test_images_renamed_requires_action_when_flag_on(self): + def test_images_renamed_requires_action_when_flag_on(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_renamed=1) assert ( self._status_with_changes_map(artifact, metrics, criteria=_criteria(renamed=True)) == StatusCheckStatus.ACTION_REQUIRED ) - def test_failed_comparison_fails_without_approval(self): + def test_failed_comparison_fails_without_approval(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison( state=PreprodSnapshotComparison.State.FAILED ) assert self._status_with_changes_map(artifact, metrics) == StatusCheckStatus.FAILURE - def test_failed_comparison_succeeds_when_approved(self): + def test_failed_comparison_succeeds_when_approved(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison( state=PreprodSnapshotComparison.State.FAILED ) @@ -190,11 +197,11 @@ def test_failed_comparison_succeeds_when_approved(self): == StatusCheckStatus.SUCCESS ) - def test_no_changes_succeeds(self): + def test_no_changes_succeeds(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison() assert self._status_with_changes_map(artifact, metrics) == StatusCheckStatus.SUCCESS - def test_failed_comparison_takes_precedence_over_needs_approval(self): + def test_failed_comparison_takes_precedence_over_needs_approval(self) -> None: changed, changed_metrics, changed_comparison = self._make_artifact_with_comparison( images_changed=2 ) @@ -213,7 +220,7 @@ def test_failed_comparison_takes_precedence_over_needs_approval(self): @cell_silo_test class EvaluateSnapshotChangesByArtifactIdTest(SnapshotTasksTestBase): - def test_added_ignored_when_flag_off(self): + def test_added_ignored_when_flag_off(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_added=5) changes_map = evaluate_snapshot_changes_by_artifact_id( [artifact], @@ -223,7 +230,7 @@ def test_added_ignored_when_flag_off(self): ) assert not any(changes_map.values()) - def test_added_detected_when_flag_on(self): + def test_added_detected_when_flag_on(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_added=5) changes_map = evaluate_snapshot_changes_by_artifact_id( [artifact], @@ -233,7 +240,7 @@ def test_added_detected_when_flag_on(self): ) assert changes_map[artifact.id] is True - def test_removed_ignored_when_flag_off(self): + def test_removed_ignored_when_flag_off(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_removed=3) changes_map = evaluate_snapshot_changes_by_artifact_id( [artifact], @@ -243,7 +250,7 @@ def test_removed_ignored_when_flag_off(self): ) assert not any(changes_map.values()) - def test_removed_detected_by_default(self): + def test_removed_detected_by_default(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_removed=3) changes_map = evaluate_snapshot_changes_by_artifact_id( [artifact], @@ -253,7 +260,7 @@ def test_removed_detected_by_default(self): ) assert changes_map[artifact.id] is True - def test_changed_detected_by_default(self): + def test_changed_detected_by_default(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_changed=2) changes_map = evaluate_snapshot_changes_by_artifact_id( [artifact], @@ -263,7 +270,7 @@ def test_changed_detected_by_default(self): ) assert changes_map[artifact.id] is True - def test_changed_ignored_when_flag_off(self): + def test_changed_ignored_when_flag_off(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_changed=2) changes_map = evaluate_snapshot_changes_by_artifact_id( [artifact], @@ -273,7 +280,7 @@ def test_changed_ignored_when_flag_off(self): ) assert not any(changes_map.values()) - def test_renamed_ignored_by_default(self): + def test_renamed_ignored_by_default(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_renamed=1) changes_map = evaluate_snapshot_changes_by_artifact_id( [artifact], @@ -283,7 +290,7 @@ def test_renamed_ignored_by_default(self): ) assert not any(changes_map.values()) - def test_renamed_detected_when_flag_on(self): + def test_renamed_detected_when_flag_on(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison(images_renamed=1) changes_map = evaluate_snapshot_changes_by_artifact_id( [artifact], @@ -296,7 +303,12 @@ def test_renamed_detected_when_flag_on(self): @cell_silo_test class SnapshotStatusCheckWithSkippedTest(SnapshotTasksTestBase): - def _make_comparison_and_format(self, images_changed=0, images_skipped=0, images_unchanged=0): + def _make_comparison_and_format( + self, + images_changed: int = 0, + images_skipped: int = 0, + images_unchanged: int = 0, + ) -> tuple[str, str]: artifact = self.create_preprod_artifact( project=self.project, commit_comparison=self.commit_comparison ) @@ -330,7 +342,7 @@ def _make_comparison_and_format(self, images_changed=0, images_skipped=0, images ) return subtitle, summary - def test_changes_with_skipped_in_subtitle_and_summary(self): + def test_changes_with_skipped_in_subtitle_and_summary(self) -> None: subtitle, summary = self._make_comparison_and_format( images_changed=2, images_skipped=50, images_unchanged=3 ) @@ -341,12 +353,12 @@ def test_changes_with_skipped_in_subtitle_and_summary(self): assert "Skipped" in summary assert "50" in summary - def test_no_changes_with_skipped(self): + def test_no_changes_with_skipped(self) -> None: subtitle, _ = self._make_comparison_and_format(images_skipped=300, images_unchanged=5) assert subtitle == "No changes detected, 300 skipped" - def test_skipped_does_not_require_approval(self): + def test_skipped_does_not_require_approval(self) -> None: artifact, metrics, _ = self._make_artifact_with_comparison() comparison = _get_comparison(metrics) comparison.images_skipped = 100 @@ -375,7 +387,7 @@ def _get_comparison(metrics: PreprodSnapshotMetrics) -> PreprodSnapshotCompariso @cell_silo_test class PostSnapshotStatusCheckTaskTest(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.organization = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.organization) @@ -395,8 +407,9 @@ def setUp(self): state=PreprodArtifact.ArtifactState.PROCESSED, ) - def _call_task(self, **overrides): - defaults = { + # justification: forwards arbitrary task keyword arguments per test case. + def _call_task(self, **overrides: Any) -> None: + defaults: dict[str, Any] = { "preprod_artifact_id": self.artifact.id, "status": StatusCheckStatus.SUCCESS.value, "title": "Snapshots", @@ -413,7 +426,7 @@ def _call_task(self, **overrides): @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") - def test_success(self, mock_get_client, mock_get_provider): + def test_success(self, mock_get_client: Mock, mock_get_provider: Mock) -> None: mock_provider = Mock() mock_provider.create_status_check.return_value = "check_123" mock_get_client.return_value = (Mock(), Mock()) @@ -430,7 +443,9 @@ def test_success(self, mock_get_client, mock_get_provider): @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") - def test_api_error_records_failure_and_reraises(self, mock_get_client, mock_get_provider): + def test_api_error_records_failure_and_reraises( + self, mock_get_client: Mock, mock_get_provider: Mock + ) -> None: mock_provider = Mock() mock_provider.create_status_check.side_effect = ApiError("rate limited", code=429) mock_get_client.return_value = (Mock(), Mock()) @@ -447,7 +462,9 @@ def test_api_error_records_failure_and_reraises(self, mock_get_client, mock_get_ @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") - def test_null_check_id_records_failure(self, mock_get_client, mock_get_provider): + def test_null_check_id_records_failure( + self, mock_get_client: Mock, mock_get_provider: Mock + ) -> None: mock_provider = Mock() mock_provider.create_status_check.return_value = None mock_get_client.return_value = (Mock(), Mock()) @@ -460,17 +477,19 @@ def test_null_check_id_records_failure(self, mock_get_client, mock_get_provider) checks = self.artifact.extras["posted_status_checks"]["snapshots"] assert checks["success"] is False - def test_nonexistent_artifact_returns_early(self): + def test_nonexistent_artifact_returns_early(self) -> None: self._call_task(preprod_artifact_id=99999) @patch(f"{TASK_MODULE}.get_status_check_client") - def test_no_client_returns_early(self, mock_get_client): + def test_no_client_returns_early(self, mock_get_client: Mock) -> None: mock_get_client.return_value = (None, None) self._call_task() @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") - def test_permanent_4xx_does_not_reraise(self, mock_get_client, mock_get_provider): + def test_permanent_4xx_does_not_reraise( + self, mock_get_client: Mock, mock_get_provider: Mock + ) -> None: mock_provider = Mock() mock_provider.create_status_check.side_effect = ApiError("not found", code=404) mock_get_client.return_value = (Mock(), Mock()) @@ -485,7 +504,9 @@ def test_permanent_4xx_does_not_reraise(self, mock_get_client, mock_get_provider @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") - def test_no_provider_returns_early(self, mock_get_client, mock_get_provider): + def test_no_provider_returns_early( + self, mock_get_client: Mock, mock_get_provider: Mock + ) -> None: mock_get_client.return_value = (Mock(), Mock()) mock_get_provider.return_value = None self._call_task() @@ -493,8 +514,8 @@ def test_no_provider_returns_early(self, mock_get_client, mock_get_provider): @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") def test_skips_stale_in_progress_when_comparison_succeeded( - self, mock_get_client, mock_get_provider - ): + self, mock_get_client: Mock, mock_get_provider: Mock + ) -> None: mock_provider = Mock() mock_get_client.return_value = (Mock(), Mock()) mock_get_provider.return_value = mock_provider @@ -523,8 +544,8 @@ def test_skips_stale_in_progress_when_comparison_succeeded( @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") def test_allows_in_progress_when_comparison_still_pending( - self, mock_get_client, mock_get_provider - ): + self, mock_get_client: Mock, mock_get_provider: Mock + ) -> None: mock_provider = Mock() mock_provider.create_status_check.return_value = "check_456" mock_get_client.return_value = (Mock(), Mock()) @@ -554,8 +575,8 @@ def test_allows_in_progress_when_comparison_still_pending( @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") def test_failure_and_action_required_suppressed_when_newer_sibling_succeeded( - self, mock_get_client, mock_get_provider - ): + self, mock_get_client: Mock, mock_get_provider: Mock + ) -> None: mock_provider = Mock() mock_provider.create_status_check.return_value = "check_should_not_post" mock_get_client.return_value = (Mock(), Mock()) @@ -592,8 +613,8 @@ def test_failure_and_action_required_suppressed_when_newer_sibling_succeeded( @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") def test_action_required_suppressed_when_newer_sibling_comparison_failed( - self, mock_get_client, mock_get_provider - ): + self, mock_get_client: Mock, mock_get_provider: Mock + ) -> None: mock_provider = Mock() mock_provider.create_status_check.return_value = "check_should_not_post" mock_get_client.return_value = (Mock(), Mock()) @@ -627,8 +648,8 @@ def test_action_required_suppressed_when_newer_sibling_comparison_failed( @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") def test_failure_posted_when_no_newer_successful_sibling( - self, mock_get_client, mock_get_provider - ): + self, mock_get_client: Mock, mock_get_provider: Mock + ) -> None: mock_provider = Mock() mock_provider.create_status_check.return_value = "check_789" mock_get_client.return_value = (Mock(), Mock()) @@ -641,8 +662,8 @@ def test_failure_posted_when_no_newer_successful_sibling( @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") def test_failure_posted_when_successful_sibling_is_older( - self, mock_get_client, mock_get_provider - ): + self, mock_get_client: Mock, mock_get_provider: Mock + ) -> None: mock_provider = Mock() mock_provider.create_status_check.return_value = "check_older_sibling" mock_get_client.return_value = (Mock(), Mock()) @@ -681,8 +702,8 @@ def test_failure_posted_when_successful_sibling_is_older( @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") def test_failure_posted_when_newer_sibling_still_in_progress( - self, mock_get_client, mock_get_provider - ): + self, mock_get_client: Mock, mock_get_provider: Mock + ) -> None: mock_provider = Mock() mock_provider.create_status_check.return_value = "check_in_progress_sibling" mock_get_client.return_value = (Mock(), Mock()) @@ -719,8 +740,8 @@ def test_failure_posted_when_newer_sibling_still_in_progress( @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") def test_failure_posted_when_newer_success_is_in_different_project( - self, mock_get_client, mock_get_provider - ): + self, mock_get_client: Mock, mock_get_provider: Mock + ) -> None: mock_provider = Mock() mock_provider.create_status_check.return_value = "check_cross_project" mock_get_client.return_value = (Mock(), Mock()) @@ -761,7 +782,7 @@ def test_failure_posted_when_newer_success_is_in_different_project( @cell_silo_test class CreateSnapshotStatusCheckGracePeriodTest(SnapshotTasksTestBase): - def _create_solo_head_artifact(self): + def _create_solo_head_artifact(self) -> PreprodArtifact: commit_comparison = self.create_commit_comparison( head_sha="head123" + "0" * 33, base_sha="base456" + "0" * 33, @@ -790,7 +811,7 @@ def _create_solo_head_artifact(self): return artifact - def _approve(self, artifact): + def _approve(self, artifact: PreprodArtifact) -> None: self.create_preprod_comparison_approval( preprod_artifact=artifact, preprod_feature_type=PreprodComparisonApproval.FeatureType.SNAPSHOTS, @@ -801,8 +822,8 @@ def _approve(self, artifact): @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") def test_missing_base_partially_approved_posts_failure( - self, mock_get_client, mock_get_provider, mock_post_task - ): + self, mock_get_client: Mock, mock_get_provider: Mock, mock_post_task: Mock + ) -> None: mock_get_client.return_value = (Mock(), Mock()) mock_get_provider.return_value = Mock() artifact = self._create_solo_head_artifact() @@ -829,8 +850,12 @@ def test_missing_base_partially_approved_posts_failure( @patch(f"{TASK_MODULE}.get_status_check_client") @patch(f"{TASK_MODULE}.update_preprod_snapshot_vcs") def test_missing_base_approved_posts_success_without_timeout( - self, mock_update_vcs, mock_get_client, mock_get_provider, mock_post_task - ): + self, + mock_update_vcs: Mock, + mock_get_client: Mock, + mock_get_provider: Mock, + mock_post_task: Mock, + ) -> None: mock_get_client.return_value = (Mock(), Mock()) mock_get_provider.return_value = Mock() artifact = self._create_solo_head_artifact() @@ -852,8 +877,8 @@ def test_missing_base_approved_posts_success_without_timeout( @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") def test_missing_base_timeout_approved_posts_success( - self, mock_get_client, mock_get_provider, mock_post_task - ): + self, mock_get_client: Mock, mock_get_provider: Mock, mock_post_task: Mock + ) -> None: mock_get_client.return_value = (Mock(), Mock()) mock_get_provider.return_value = Mock() artifact = self._create_solo_head_artifact() @@ -876,11 +901,11 @@ def test_missing_base_timeout_approved_posts_success( @patch(f"{TASK_MODULE}.update_preprod_snapshot_vcs") def test_missing_base_first_attempt_posts_in_progress( self, - mock_update_vcs, - mock_get_client, - mock_get_provider, - mock_post_task, - ): + mock_update_vcs: Mock, + mock_get_client: Mock, + mock_get_provider: Mock, + mock_post_task: Mock, + ) -> None: mock_get_client.return_value = (Mock(), Mock()) mock_get_provider.return_value = Mock() @@ -906,8 +931,8 @@ def test_missing_base_first_attempt_posts_in_progress( @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") def test_missing_base_timeout_posts_failure( - self, mock_get_client, mock_get_provider, mock_post_task - ): + self, mock_get_client: Mock, mock_get_provider: Mock, mock_post_task: Mock + ) -> None: mock_get_client.return_value = (Mock(), Mock()) mock_get_provider.return_value = Mock() @@ -927,8 +952,8 @@ def test_missing_base_timeout_posts_failure( @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") def test_timeout_with_base_arrived_skips_update( - self, mock_get_client, mock_get_provider, mock_post_task - ): + self, mock_get_client: Mock, mock_get_provider: Mock, mock_post_task: Mock + ) -> None: mock_get_client.return_value = (Mock(), Mock()) mock_get_provider.return_value = Mock() @@ -978,8 +1003,8 @@ class CreateSnapshotStatusCheckTargetUrlTest(SnapshotTasksTestBase): @patch(f"{TASK_MODULE}.get_status_check_provider") @patch(f"{TASK_MODULE}.get_status_check_client") def test_target_url_includes_selected_types( - self, mock_get_client, mock_get_provider, mock_post_task - ): + self, mock_get_client: Mock, mock_get_provider: Mock, mock_post_task: Mock + ) -> None: mock_get_client.return_value = (Mock(), Mock()) mock_get_provider.return_value = Mock() diff --git a/tests/sentry/preprod/vcs/webhooks/test_github_check_run.py b/tests/sentry/preprod/vcs/webhooks/test_github_check_run.py index fbb1c12dbfbb..586f294a150d 100644 --- a/tests/sentry/preprod/vcs/webhooks/test_github_check_run.py +++ b/tests/sentry/preprod/vcs/webhooks/test_github_check_run.py @@ -2,7 +2,8 @@ import uuid from datetime import datetime, timezone -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch from sentry.integrations.github.webhook_types import GithubWebhookType from sentry.models.commitcomparison import CommitComparison @@ -34,9 +35,9 @@ def setUp(self) -> None: def _create_preprod_artifact( self, - state=PreprodArtifact.ArtifactState.PROCESSED, - app_id="com.example.app", - ): + state: int = PreprodArtifact.ArtifactState.PROCESSED, + app_id: str = "com.example.app", + ) -> PreprodArtifact: unique_suffix = str(uuid.uuid4()).replace("-", "")[:8] head_sha = unique_suffix.ljust(40, "a") base_sha = unique_suffix.ljust(40, "b") @@ -68,7 +69,7 @@ def _create_webhook_event( external_id: str, sender_id: int = 12345, sender_login: str = "octocat", - ): + ) -> dict[str, Any]: return { "action": action, "requested_action": {"identifier": identifier}, @@ -165,7 +166,7 @@ def test_ignores_non_approve_size_identifier(self) -> None: ) @patch("sentry.analytics.record") - def test_creates_approval_for_valid_request(self, mock_analytics) -> None: + def test_creates_approval_for_valid_request(self, mock_analytics: MagicMock) -> None: artifact = self._create_preprod_artifact() event = self._create_webhook_event( action="requested_action", diff --git a/tests/sentry/processing_errors/eap/test_producer.py b/tests/sentry/processing_errors/eap/test_producer.py index ee35e41d5dd2..6efd7cc814ac 100644 --- a/tests/sentry/processing_errors/eap/test_producer.py +++ b/tests/sentry/processing_errors/eap/test_producer.py @@ -1,5 +1,6 @@ import uuid -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType @@ -13,7 +14,7 @@ class ProduceProcessingErrorsToEAPTest(TestCase): - def _make_event_data(self, **overrides): + def _make_event_data(self, **overrides: object) -> dict[str, Any]: data = { "event_id": "a" * 32, "timestamp": 1234567890, @@ -24,7 +25,9 @@ def _make_event_data(self, **overrides): @patch("sentry.processing_errors.eap.producer._eap_producer") @patch("sentry.processing_errors.eap.producer.get_topic_definition") - def test_basic_error_produces_trace_item(self, mock_get_topic, mock_producer): + def test_basic_error_produces_trace_item( + self, mock_get_topic: MagicMock, mock_producer: MagicMock + ) -> None: mock_get_topic.return_value = {"real_topic_name": "test-eap-items"} event_data = self._make_event_data() @@ -56,7 +59,9 @@ def test_basic_error_produces_trace_item(self, mock_get_topic, mock_producer): @patch("sentry.processing_errors.eap.producer._eap_producer") @patch("sentry.processing_errors.eap.producer.get_topic_definition") - def test_multiple_errors_produce_multiple_items(self, mock_get_topic, mock_producer): + def test_multiple_errors_produce_multiple_items( + self, mock_get_topic: MagicMock, mock_producer: MagicMock + ) -> None: mock_get_topic.return_value = {"real_topic_name": "test-eap-items"} event_data = self._make_event_data() @@ -72,7 +77,9 @@ def test_multiple_errors_produce_multiple_items(self, mock_get_topic, mock_produ @patch("sentry.processing_errors.eap.producer._eap_producer") @patch("sentry.processing_errors.eap.producer.get_topic_definition") - def test_deterministic_item_id(self, mock_get_topic, mock_producer): + def test_deterministic_item_id( + self, mock_get_topic: MagicMock, mock_producer: MagicMock + ) -> None: mock_get_topic.return_value = {"real_topic_name": "test-eap-items"} codec = get_topic_codec(Topic.SNUBA_ITEMS) @@ -95,7 +102,9 @@ def test_deterministic_item_id(self, mock_get_topic, mock_producer): @patch("sentry.processing_errors.eap.producer._eap_producer") @patch("sentry.processing_errors.eap.producer.get_topic_definition") - def test_optional_fields_included_when_present(self, mock_get_topic, mock_producer): + def test_optional_fields_included_when_present( + self, mock_get_topic: MagicMock, mock_producer: MagicMock + ) -> None: mock_get_topic.return_value = {"real_topic_name": "test-eap-items"} codec = get_topic_codec(Topic.SNUBA_ITEMS) @@ -129,7 +138,9 @@ def test_optional_fields_included_when_present(self, mock_get_topic, mock_produc @patch("sentry.processing_errors.eap.producer._eap_producer") @patch("sentry.processing_errors.eap.producer.get_topic_definition") - def test_optional_fields_omitted_when_absent(self, mock_get_topic, mock_producer): + def test_optional_fields_omitted_when_absent( + self, mock_get_topic: MagicMock, mock_producer: MagicMock + ) -> None: mock_get_topic.return_value = {"real_topic_name": "test-eap-items"} codec = get_topic_codec(Topic.SNUBA_ITEMS) @@ -152,7 +163,7 @@ def test_optional_fields_omitted_when_absent(self, mock_get_topic, mock_producer @patch("sentry.processing_errors.eap.producer.logger") @patch("sentry.processing_errors.eap.producer._eap_producer") - def test_skips_when_no_trace_id(self, mock_producer, mock_logger): + def test_skips_when_no_trace_id(self, mock_producer: MagicMock, mock_logger: MagicMock) -> None: event_data = self._make_event_data() del event_data["contexts"]["trace"]["trace_id"] errors = [{"type": "js_no_source"}] @@ -166,7 +177,7 @@ def test_skips_when_no_trace_id(self, mock_producer, mock_logger): @patch("sentry.processing_errors.eap.producer.logger") @patch("sentry.processing_errors.eap.producer._eap_producer") - def test_skips_when_no_contexts(self, mock_producer, mock_logger): + def test_skips_when_no_contexts(self, mock_producer: MagicMock, mock_logger: MagicMock) -> None: event_data = self._make_event_data() del event_data["contexts"] errors = [{"type": "js_no_source"}] @@ -180,7 +191,9 @@ def test_skips_when_no_contexts(self, mock_producer, mock_logger): @patch("sentry.processing_errors.eap.producer.logger") @patch("sentry.processing_errors.eap.producer._eap_producer") - def test_skips_when_contexts_is_none(self, mock_producer, mock_logger): + def test_skips_when_contexts_is_none( + self, mock_producer: MagicMock, mock_logger: MagicMock + ) -> None: event_data = self._make_event_data(contexts=None) errors = [{"type": "js_no_source"}] @@ -193,7 +206,9 @@ def test_skips_when_contexts_is_none(self, mock_producer, mock_logger): @patch("sentry.processing_errors.eap.producer.logger") @patch("sentry.processing_errors.eap.producer._eap_producer") - def test_skips_when_trace_context_is_none(self, mock_producer, mock_logger): + def test_skips_when_trace_context_is_none( + self, mock_producer: MagicMock, mock_logger: MagicMock + ) -> None: event_data = self._make_event_data(contexts={"trace": None}) errors = [{"type": "js_no_source"}] @@ -206,7 +221,9 @@ def test_skips_when_trace_context_is_none(self, mock_producer, mock_logger): @patch("sentry.processing_errors.eap.producer._eap_producer") @patch("sentry.processing_errors.eap.producer.logger") - def test_error_handling_does_not_raise(self, mock_logger, mock_producer): + def test_error_handling_does_not_raise( + self, mock_logger: MagicMock, mock_producer: MagicMock + ) -> None: mock_producer.produce.side_effect = Exception("Kafka error") event_data = self._make_event_data() @@ -220,7 +237,9 @@ def test_error_handling_does_not_raise(self, mock_logger, mock_producer): @patch("sentry.processing_errors.eap.producer.metrics") @patch("sentry.processing_errors.eap.producer._eap_producer") @patch("sentry.processing_errors.eap.producer.get_topic_definition") - def test_success_metrics(self, mock_get_topic, mock_producer, mock_metrics): + def test_success_metrics( + self, mock_get_topic: MagicMock, mock_producer: MagicMock, mock_metrics: MagicMock + ) -> None: mock_get_topic.return_value = {"real_topic_name": "test-eap-items"} event_data = self._make_event_data() @@ -236,7 +255,7 @@ def test_success_metrics(self, mock_get_topic, mock_producer, mock_metrics): @patch("sentry.processing_errors.eap.producer.metrics") @patch("sentry.processing_errors.eap.producer._eap_producer") - def test_failure_metrics(self, mock_producer, mock_metrics): + def test_failure_metrics(self, mock_producer: MagicMock, mock_metrics: MagicMock) -> None: mock_producer.produce.side_effect = Exception("Kafka error") event_data = self._make_event_data() @@ -251,7 +270,9 @@ def test_failure_metrics(self, mock_producer, mock_metrics): @patch("sentry.processing_errors.eap.producer._eap_producer") @patch("sentry.processing_errors.eap.producer.get_topic_definition") - def test_error_type_defaults_to_unknown(self, mock_get_topic, mock_producer): + def test_error_type_defaults_to_unknown( + self, mock_get_topic: MagicMock, mock_producer: MagicMock + ) -> None: mock_get_topic.return_value = {"real_topic_name": "test-eap-items"} codec = get_topic_codec(Topic.SNUBA_ITEMS) diff --git a/tests/sentry/processing_errors/test_detection.py b/tests/sentry/processing_errors/test_detection.py index bec5c85c6939..8b52a43a8cd5 100644 --- a/tests/sentry/processing_errors/test_detection.py +++ b/tests/sentry/processing_errors/test_detection.py @@ -1,16 +1,20 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from unittest.mock import patch from django.core.cache import cache +from sentry.models.project import Project from sentry.processing_errors.detection import ( DETECTOR_CONFIGS, + DetectorConfig, _cache_key_triggered, detect_processing_issues, ) from sentry.processing_errors.grouptype import SourcemapConfigurationType +from sentry.services.eventstore.models import GroupEvent +from sentry.tasks.post_process import PostProcessJob from sentry.testutils.cases import TestCase from sentry.workflow_engine.models import DetectorState @@ -18,7 +22,12 @@ class _FakeEvent: """Minimal event-like object for testing the detection function.""" - def __init__(self, project, errors=None, platform="javascript"): + def __init__( + self, + project: Project, + errors: list[dict[str, Any] | None] | None = None, + platform: str = "javascript", + ) -> None: self.project = project self.event_id = "fake-event-id" self.data: dict[str, Any] = { @@ -29,14 +38,14 @@ def __init__(self, project, errors=None, platform="javascript"): self.data["errors"] = errors -def _make_job(event, is_reprocessed=False): +def _make_job(event: _FakeEvent, is_reprocessed: bool = False) -> PostProcessJob: return { - "event": event, + "event": cast(GroupEvent, event), "is_reprocessed": is_reprocessed, } -def _sourcemap_config(): +def _sourcemap_config() -> DetectorConfig: return DETECTOR_CONFIGS[0] diff --git a/tests/sentry/processing_errors/test_grouptype.py b/tests/sentry/processing_errors/test_grouptype.py index 5bd769fad437..dd614527da3d 100644 --- a/tests/sentry/processing_errors/test_grouptype.py +++ b/tests/sentry/processing_errors/test_grouptype.py @@ -52,7 +52,7 @@ def create_sourcemap_detector( def make_packet( self, - errors: list | None = None, + errors: list[dict[str, Any]] | None = None, event_id: str = "abc123", platform: str = "javascript", ) -> DataPacket[ProcessingErrorPacketValue]: diff --git a/tests/sentry/processing_errors/test_tasks.py b/tests/sentry/processing_errors/test_tasks.py index 54317e0b9da2..5358f766e443 100644 --- a/tests/sentry/processing_errors/test_tasks.py +++ b/tests/sentry/processing_errors/test_tasks.py @@ -1,7 +1,7 @@ from __future__ import annotations -from datetime import timedelta -from unittest.mock import patch +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch from django.core.cache import cache from django.utils import timezone @@ -22,7 +22,9 @@ class TestResolveStaleSourcemapDetectors(TestCase): - def _create_triggered_detector(self, date_updated=None): + def _create_triggered_detector( + self, date_updated: datetime | None = None + ) -> tuple[Detector, DetectorState]: """Create a detector with a triggered DetectorState.""" condition_group = DataConditionGroup.objects.create( logic_type=DataConditionGroup.Type.ANY, @@ -69,7 +71,7 @@ def _create_triggered_detector(self, date_updated=None): return detector, state @patch("sentry.processing_errors.tasks.produce_occurrence_to_kafka") - def test_resolves_stale_detector(self, mock_produce) -> None: + def test_resolves_stale_detector(self, mock_produce: MagicMock) -> None: stale_time = timezone.now() - timedelta(minutes=STALENESS_THRESHOLD_MINUTES + 5) detector, state = self._create_triggered_detector(date_updated=stale_time) resolve_stale_sourcemap_detectors() @@ -89,7 +91,7 @@ def test_resolves_stale_detector(self, mock_produce) -> None: ] @patch("sentry.processing_errors.tasks.produce_occurrence_to_kafka") - def test_skips_recently_updated(self, mock_produce) -> None: + def test_skips_recently_updated(self, mock_produce: MagicMock) -> None: _detector, state = self._create_triggered_detector() resolve_stale_sourcemap_detectors() state.refresh_from_db() @@ -97,7 +99,7 @@ def test_skips_recently_updated(self, mock_produce) -> None: mock_produce.assert_not_called() @patch("sentry.processing_errors.tasks.produce_occurrence_to_kafka") - def test_skips_already_resolved(self, mock_produce) -> None: + def test_skips_already_resolved(self, mock_produce: MagicMock) -> None: stale_time = timezone.now() - timedelta(minutes=STALENESS_THRESHOLD_MINUTES + 5) _detector, state = self._create_triggered_detector(date_updated=stale_time) diff --git a/tests/sentry/profiles/test_task.py b/tests/sentry/profiles/test_task.py index 4e586b90c9a2..2989444ad688 100644 --- a/tests/sentry/profiles/test_task.py +++ b/tests/sentry/profiles/test_task.py @@ -1,9 +1,10 @@ from __future__ import annotations import zipfile +from collections.abc import Iterator from io import BytesIO from os.path import join -from typing import Any +from typing import Any, cast from unittest import mock from unittest.mock import patch from uuid import UUID @@ -21,6 +22,7 @@ from sentry.models.projectsdk import EventType, ProjectSDK from sentry.models.release import Release from sentry.models.releasefile import ReleaseFile +from sentry.models.team import Team from sentry.profiles.task import ( UnknownProfileTypeException, _calculate_profile_duration_ms, @@ -45,6 +47,7 @@ from sentry.testutils.pytest.fixtures import django_db_all from sentry.testutils.skips import requires_symbolicator from sentry.testutils.thread_leaks.pytest import thread_leak_allowlist +from sentry.users.models.user import User from sentry.utils import json from sentry.utils.outcomes import Outcome @@ -93,47 +96,50 @@ PROGUARD_BUG_SOURCE = b"x" -def load_profile(name): +def load_profile(name: str) -> dict[str, Any]: path = join(PROFILES_FIXTURES_PATH, name) with open(path) as f: - return json.loads(f.read()) + profile = json.loads(f.read()) + if not isinstance(profile, dict): + raise TypeError("Profile fixture is not a JSON object") + return profile @pytest.fixture -def owner(): +def owner() -> User: return Factories.create_user() @pytest.fixture -def organization(owner): +def organization(owner: User) -> Organization: return Factories.create_organization(owner=owner) @pytest.fixture -def team(organization, owner): +def team(organization: Organization, owner: User) -> Team: team = Factories.create_team(organization=organization) Factories.create_team_membership(team=team, user=owner) return team @pytest.fixture -def project(organization, team): +def project(organization: Organization, team: Team) -> Project: return Factories.create_project(organization=organization, teams=[team]) @pytest.fixture -def ios_profile(): +def ios_profile() -> dict[str, Any]: return load_profile("valid_ios_profile.json") @pytest.fixture -def android_profile(): +def android_profile() -> dict[str, Any]: return load_profile("valid_android_profile.json") @pytest.fixture -def sample_v1_profile(): - return json.loads( +def sample_v1_profile() -> dict[str, Any]: + profile = json.loads( """{ "event_id": "41fed0925670468bb0457f61a74688ec", "version": "1", @@ -219,17 +225,22 @@ def sample_v1_profile(): } }""" ) + if not isinstance(profile, dict): + raise TypeError("Profile fixture is not a JSON object") + return profile @pytest.fixture -def sample_v1_profile_without_transaction_timestamps(sample_v1_profile): +def sample_v1_profile_without_transaction_timestamps( + sample_v1_profile: dict[str, Any], +) -> dict[str, Any]: for key in {"relative_start_ns", "relative_end_ns"}: del sample_v1_profile["transaction"][key] return sample_v1_profile -def generate_sample_v2_profile(): - return json.loads( +def generate_sample_v2_profile() -> dict[str, Any]: + profile = json.loads( """{ "event_id": "41fed0925670468bb0457f61a74688ec", "version": "2", @@ -286,15 +297,18 @@ def generate_sample_v2_profile(): } }""" ) + if not isinstance(profile, dict): + raise TypeError("Profile fixture is not a JSON object") + return profile @pytest.fixture -def sample_v2_profile(): +def sample_v2_profile() -> dict[str, Any]: return generate_sample_v2_profile() @pytest.fixture -def sample_v2_profile_long(): +def sample_v2_profile_long() -> dict[str, Any]: profile = generate_sample_v2_profile() samples = profile["profile"]["samples"] samples[len(samples) - 1]["timestamp"] += 300 @@ -302,14 +316,16 @@ def sample_v2_profile_long(): @pytest.fixture -def sample_v2_profile_samples_not_sorted(): +def sample_v2_profile_samples_not_sorted() -> dict[str, Any]: profile = generate_sample_v2_profile() profile["profile"]["samples"][0]["timestamp"] += 300 return profile @django_db_all -def test_normalize_sample_v1_profile(organization, sample_v1_profile) -> None: +def test_normalize_sample_v1_profile( + organization: Organization, sample_v1_profile: dict[str, Any] +) -> None: sample_v1_profile["transaction_tags"] = {"device.class": "1"} _normalize(profile=sample_v1_profile, organization=organization) @@ -320,7 +336,7 @@ def test_normalize_sample_v1_profile(organization, sample_v1_profile) -> None: @django_db_all -def test_normalize_ios_profile(organization, ios_profile) -> None: +def test_normalize_ios_profile(organization: Organization, ios_profile: dict[str, Any]) -> None: ios_profile["transaction_tags"] = {"device.class": "1"} _normalize(profile=ios_profile, organization=organization) @@ -331,7 +347,9 @@ def test_normalize_ios_profile(organization, ios_profile) -> None: @django_db_all -def test_normalize_android_profile(organization, android_profile) -> None: +def test_normalize_android_profile( + organization: Organization, android_profile: dict[str, Any] +) -> None: android_profile["transaction_tags"] = {"device.class": "1"} _normalize(profile=android_profile, organization=organization) @@ -628,7 +646,7 @@ def test_process_symbolicator_results_for_sample_js() -> None: @django_db_all -def test_decode_signature(project, android_profile) -> None: +def test_decode_signature(project: Project, android_profile: dict[str, Any]) -> None: android_profile.update( { "project_id": project.id, @@ -784,11 +802,13 @@ def test_determine_profile_type() -> None: ("sample_v2_profile_samples_not_sorted", 66000), ], ) -def test_calculate_profile_duration(profile, duration_ms, request) -> None: +def test_calculate_profile_duration( + profile: str, duration_ms: int, request: pytest.FixtureRequest +) -> None: assert _calculate_profile_duration_ms(request.getfixturevalue(profile)) == duration_ms -def test_calculate_profile_duration_faulty_version(android_profile) -> None: +def test_calculate_profile_duration_faulty_version(android_profile: dict[str, Any]) -> None: # android trace profiles may arrive with a wrongly set sample version; # the duration must still be calculated from the trace format android_profile["version"] = "2" @@ -800,12 +820,12 @@ def test_calculate_profile_duration_faulty_version(android_profile) -> None: @debug_files_test_both_backends class DeobfuscationViaSymbolicator(TransactionTestCase): @pytest.fixture(autouse=True) - def initialize(self, set_sentry_option, live_server): + def initialize(self, set_sentry_option: Any, live_server: Any) -> Iterator[None]: with set_sentry_option("system.url-prefix", live_server.url): # Run test case yield - def upload_proguard_mapping(self, uuid, mapping_file_content): + def upload_proguard_mapping(self, uuid: str, mapping_file_content: bytes) -> Any: url = reverse( "sentry-api-0-dsym-files", kwargs={ @@ -1275,21 +1295,21 @@ def test_set_frames_platform_android() -> None: ["sample_v1_profile", "sample_v2_profile"], ) def test_process_profile_task_should_emit_profile_duration_outcome( - _process_vroomrs_profile, - _deobfuscate_profile, - _symbolicate_profile, - _track_duration_outcome, - _track_outcome, - profile, - organization, - project, - request, -): + _process_vroomrs_profile: mock.MagicMock, + _deobfuscate_profile: mock.MagicMock, + _symbolicate_profile: mock.MagicMock, + _track_duration_outcome: mock.MagicMock, + _track_outcome: mock.MagicMock, + profile: str | dict[str, Any], + organization: Any, + project: Project, + request: pytest.FixtureRequest, +) -> None: _process_vroomrs_profile.return_value = True _deobfuscate_profile.return_value = True _symbolicate_profile.return_value = True - profile = request.getfixturevalue(profile) + profile = cast("dict[str, Any]", request.getfixturevalue(cast("str", profile))) profile["organization_id"] = organization.id profile["project_id"] = project.id @@ -1321,23 +1341,23 @@ def test_process_profile_task_should_emit_profile_duration_outcome( ["sample_v1_profile", "sample_v2_profile"], ) def test_process_profile_task_should_not_emit_profile_duration_outcome( - _process_vroomrs_profile, - _deobfuscate_profile, - _symbolicate_profile, - _track_duration_outcome, - _track_outcome, - should_emit_profile_duration_outcome, - profile, - organization, - project, - request, -): + _process_vroomrs_profile: mock.MagicMock, + _deobfuscate_profile: mock.MagicMock, + _symbolicate_profile: mock.MagicMock, + _track_duration_outcome: mock.MagicMock, + _track_outcome: mock.MagicMock, + should_emit_profile_duration_outcome: mock.MagicMock, + profile: str | dict[str, Any], + organization: Any, + project: Project, + request: pytest.FixtureRequest, +) -> None: _process_vroomrs_profile.return_value = True _deobfuscate_profile.return_value = True _symbolicate_profile.return_value = True should_emit_profile_duration_outcome.return_value = False - profile = request.getfixturevalue(profile) + profile = cast("dict[str, Any]", request.getfixturevalue(cast("str", profile))) profile["organization_id"] = organization.id profile["project_id"] = project.id @@ -1374,22 +1394,22 @@ def test_process_profile_task_should_not_emit_profile_duration_outcome( ) @django_db_all def test_track_latest_sdk( - get_sdk_index, - _symbolicate_profile, - _process_vroomrs_profile, - profile, - event_type, - organization, - project, - request, -): + get_sdk_index: mock.MagicMock, + _symbolicate_profile: mock.MagicMock, + _process_vroomrs_profile: mock.MagicMock, + profile: str | dict[str, Any], + event_type: EventType, + organization: Any, + project: Project, + request: pytest.FixtureRequest, +) -> None: _process_vroomrs_profile.return_value = True _symbolicate_profile.return_value = True get_sdk_index.return_value = { "sentry.python": {}, } - profile = request.getfixturevalue(profile) + profile = cast("dict[str, Any]", request.getfixturevalue(cast("str", profile))) profile["organization_id"] = organization.id profile["project_id"] = project.id @@ -1419,15 +1439,15 @@ def test_track_latest_sdk( ) @django_db_all def test_unknown_sdk( - get_sdk_index, - _symbolicate_profile, - _process_vroomrs_profile, - platform, - sdk_name, - organization, - project, - request, -): + get_sdk_index: mock.MagicMock, + _symbolicate_profile: mock.MagicMock, + _process_vroomrs_profile: mock.MagicMock, + platform: str, + sdk_name: str, + organization: Any, + project: Project, + request: pytest.FixtureRequest, +) -> None: _process_vroomrs_profile.return_value = True _symbolicate_profile.return_value = True get_sdk_index.return_value = { @@ -1613,18 +1633,18 @@ def test_process_profile_task_skips_attachments_without_feature( ], ) def test_deprecated_sdks( - _process_vroomrs_profile, - _track_outcome, + _process_vroomrs_profile: mock.MagicMock, + _track_outcome: mock.MagicMock, _symbolicate_profile: mock.MagicMock, - profile, - category, - sdk_version, - dropped, - organization, - project, - request, -): - profile = request.getfixturevalue(profile) + profile: str | dict[str, Any], + category: DataCategory, + sdk_version: str, + dropped: bool, + organization: Any, + project: Project, + request: pytest.FixtureRequest, +) -> None: + profile = cast("dict[str, Any]", request.getfixturevalue(cast("str", profile))) profile["organization_id"] = organization.id profile["project_id"] = project.id profile["client_sdk"] = { @@ -1668,18 +1688,18 @@ def test_deprecated_sdks( ], ) def test_rejected_sdks( - _process_vroomrs_profile, - _track_outcome, + _process_vroomrs_profile: mock.MagicMock, + _track_outcome: mock.MagicMock, _symbolicate_profile: mock.MagicMock, - profile, - category, - sdk_version, - dropped, - organization, - project, - request, -): - profile = request.getfixturevalue(profile) + profile: str | dict[str, Any], + category: DataCategory, + sdk_version: str, + dropped: bool, + organization: Any, + project: Project, + request: pytest.FixtureRequest, +) -> None: + profile = cast("dict[str, Any]", request.getfixturevalue(cast("str", profile))) profile["organization_id"] = organization.id profile["project_id"] = project.id profile["client_sdk"] = { @@ -1724,11 +1744,11 @@ def test_process_profile_task_should_flip_project_flag( _process_vroomrs_profile: mock.MagicMock, _deobfuscate_profile: mock.MagicMock, _symbolicate_profile: mock.MagicMock, - profile, - organization, - project, - request, -): + profile: str | dict[str, Any], + organization: Any, + project: Project, + request: pytest.FixtureRequest, +) -> None: with patch( "sentry.receivers.onboarding.record_first_profile", ) as mock_record_first_profile: @@ -1737,7 +1757,7 @@ def test_process_profile_task_should_flip_project_flag( _deobfuscate_profile.return_value = True _symbolicate_profile.return_value = True - profile = request.getfixturevalue(profile) + profile = cast("dict[str, Any]", request.getfixturevalue(cast("str", profile))) profile["organization_id"] = organization.id profile["project_id"] = project.id diff --git a/tests/sentry/projectoptions/test_basic.py b/tests/sentry/projectoptions/test_basic.py index a3ed0f78163e..81d9c0e0ede8 100644 --- a/tests/sentry/projectoptions/test_basic.py +++ b/tests/sentry/projectoptions/test_basic.py @@ -1,18 +1,23 @@ +from __future__ import annotations + from unittest import mock from sentry.models.options.project_option import ProjectOption +from sentry.models.project import Project +from sentry.models.team import Team from sentry.projectoptions import default_manager, defaults, get_well_known_default from sentry.projectoptions.manager import WellKnownProjectOption +from sentry.testutils.factories import Factories from sentry.testutils.pytest.fixtures import django_db_all -def latest_epoch(value): +def latest_epoch(value: int) -> mock._patch[int]: return mock.patch.object(defaults, "LATEST_EPOCH", value) @django_db_all @latest_epoch(10) -def test_defaults(default_project) -> None: +def test_defaults(default_project: Project) -> None: default_manager.register( key="__sentry_test:test-option", epoch_defaults={1: "whatever", 20: "new-value", 42: "latest-value"}, @@ -28,7 +33,7 @@ def test_defaults(default_project) -> None: @django_db_all -def freeze_option(factories, default_team): +def freeze_option(factories: Factories, default_team: Team) -> None: with latest_epoch(42): project = factories.create_project(name="Bar", slug="bar", teams=[default_team]) assert project.get_option("sentry:option-epoch", defaults.LATEST_EPOCH) @@ -64,7 +69,7 @@ def test_loader_version_defaults() -> None: @django_db_all -def test_isset_simple(default_project) -> None: +def test_isset_simple(default_project: Project) -> None: default_manager.register("best_dogs", default="all dogs") assert default_project.get_option("best_dogs") == "all dogs" @@ -76,7 +81,7 @@ def test_isset_simple(default_project) -> None: @django_db_all -def test_isset_differentiates_unset_from_set_to_default(default_project) -> None: +def test_isset_differentiates_unset_from_set_to_default(default_project: Project) -> None: default_manager.register("best_dogs", default="all dogs") assert default_project.get_option("best_dogs") == "all dogs" diff --git a/tests/sentry/quotas/test_base.py b/tests/sentry/quotas/test_base.py index 5b765e6c1a24..065ac3646cb7 100644 --- a/tests/sentry/quotas/test_base.py +++ b/tests/sentry/quotas/test_base.py @@ -142,7 +142,7 @@ def test_check_accept_monitor_checkin(self) -> None: ), ], ) -def test_quotas_to_json(obj, json) -> None: +def test_quotas_to_json(obj: QuotaConfig, json: dict[str, object]) -> None: assert obj.to_json() == json diff --git a/tests/sentry/quotas/test_redis.py b/tests/sentry/quotas/test_redis.py index a8d767552d7d..e61effba85f4 100644 --- a/tests/sentry/quotas/test_redis.py +++ b/tests/sentry/quotas/test_redis.py @@ -4,6 +4,7 @@ from unittest import mock import pytest +import rb from sentry.constants import DataCategory from sentry.quotas.base import ( @@ -23,7 +24,7 @@ class RedisQuotaTest(TestCase): @cached_property - def quota(self): + def quota(self) -> RedisQuota: return RedisQuota() def test_redis_quota_serialize(self) -> None: @@ -270,6 +271,7 @@ def test_refund_defaults(self, mock_get_quotas: mock.MagicMock) -> None: org_id = self.project.organization.pk self.quota.refund(self.project, timestamp=timestamp) + assert isinstance(self.quota.cluster, rb.Cluster) client = self.quota.cluster.get_local_client_for_key(str(self.project.organization.pk)) error_keys = client.keys(f"r:quota:p{{{org_id}}}*:*") @@ -320,6 +322,7 @@ def test_refund_categories(self, mock_get_quotas: mock.MagicMock) -> None: self.quota.refund( self.project, timestamp=timestamp, category=DataCategory.ATTACHMENT, quantity=100 ) + assert isinstance(self.quota.cluster, rb.Cluster) client = self.quota.cluster.get_local_client_for_key(str(self.project.organization.pk)) error_keys = client.keys(f"r:quota:p{{{org_id}}}*:*") diff --git a/tests/sentry/receivers/outbox/test_cell.py b/tests/sentry/receivers/outbox/test_cell.py index dad6bb7f3370..9cf8b774eac5 100644 --- a/tests/sentry/receivers/outbox/test_cell.py +++ b/tests/sentry/receivers/outbox/test_cell.py @@ -1,5 +1,5 @@ import json # noqa: S003 - urllib3 raises stdlib JSONDecodeError, not simplejson's -from typing import Any +from typing import Any, cast from unittest.mock import Mock, patch import orjson @@ -50,7 +50,8 @@ def _create_oi( def _read_config(self, oi: OrganizationIntegration) -> dict[str, Any]: with assume_test_silo_mode(SiloMode.CONTROL): - return OrganizationIntegration.objects.get(id=oi.id).config + # justification: OrganizationIntegration.config stores a JSON object. + return cast("dict[str, Any]", OrganizationIntegration.objects.get(id=oi.id).config) def test_backfills_github_keys(self) -> None: oi = self._create_oi(provider="github", external_id="gh") diff --git a/tests/sentry/receivers/test_analytics.py b/tests/sentry/receivers/test_analytics.py index 0c9785ccd7fc..952199b1ba97 100644 --- a/tests/sentry/receivers/test_analytics.py +++ b/tests/sentry/receivers/test_analytics.py @@ -13,7 +13,7 @@ class CreateDefaultProjectsTest(TestCase): @patch("sentry.analytics.record") - def test_user_created_records_analytics(self, mock_record: MagicMock): + def test_user_created_records_analytics(self, mock_record: MagicMock) -> None: user: User = self.create_user() assert_last_analytics_event( mock_record, diff --git a/tests/sentry/receivers/test_featureadoption.py b/tests/sentry/receivers/test_featureadoption.py index b2b1ee7a8a1b..8c87b402ca1a 100644 --- a/tests/sentry/receivers/test_featureadoption.py +++ b/tests/sentry/receivers/test_featureadoption.py @@ -52,6 +52,7 @@ def test_all_passed_feature_slugs_are_complete(self) -> None: feature_complete = FeatureAdoption.objects.get_by_slug( organization=self.organization, slug="environment_tracking" ) + assert feature_complete is not None assert feature_complete.complete def test_first_event(self) -> None: @@ -64,6 +65,7 @@ def test_first_event(self) -> None: first_event = FeatureAdoption.objects.get_by_slug( organization=self.organization, slug="first_event" ) + assert first_event is not None assert first_event.complete def test_javascript(self) -> None: @@ -71,6 +73,7 @@ def test_javascript(self) -> None: event_processed.send(project=self.project, event=event, sender=type(self.project)) js = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="javascript") + assert js is not None assert js.complete def test_python(self) -> None: @@ -81,6 +84,7 @@ def test_python(self) -> None: event_processed.send(project=self.project, event=event, sender=type(self.project)) python = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="python") + assert python is not None assert python.complete def test_node(self) -> None: @@ -90,6 +94,7 @@ def test_node(self) -> None: event_processed.send(project=self.project, event=event, sender=type(self.project)) node = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="node") + assert node is not None assert node.complete def test_ruby(self) -> None: @@ -99,6 +104,7 @@ def test_ruby(self) -> None: event_processed.send(project=self.project, event=event, sender=type(self.project)) ruby = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="ruby") + assert ruby is not None assert ruby.complete def test_java(self) -> None: @@ -108,6 +114,7 @@ def test_java(self) -> None: event_processed.send(project=self.project, event=event, sender=type(self.project)) java = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="java") + assert java is not None assert java.complete def test_cocoa(self) -> None: @@ -117,6 +124,7 @@ def test_cocoa(self) -> None: event_processed.send(project=self.project, event=event, sender=type(self.project)) cocoa = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="cocoa") + assert cocoa is not None assert cocoa.complete def test_objc(self) -> None: @@ -126,6 +134,7 @@ def test_objc(self) -> None: event_processed.send(project=self.project, event=event, sender=type(self.project)) objc = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="objc") + assert objc is not None assert objc.complete def test_php(self) -> None: @@ -135,6 +144,7 @@ def test_php(self) -> None: event_processed.send(project=self.project, event=event, sender=type(self.project)) php = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="php") + assert php is not None assert php.complete def test_go(self) -> None: @@ -144,6 +154,7 @@ def test_go(self) -> None: event_processed.send(project=self.project, event=event, sender=type(self.project)) go = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="go") + assert go is not None assert go.complete def test_csharp(self) -> None: @@ -154,6 +165,7 @@ def test_csharp(self) -> None: event_processed.send(project=self.project, event=event, sender=type(self.project)) csharp = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="csharp") + assert csharp is not None assert csharp.complete def test_perl(self) -> None: @@ -163,6 +175,7 @@ def test_perl(self) -> None: event_processed.send(project=self.project, event=event, sender=type(self.project)) perl = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="perl") + assert perl is not None assert perl.complete def test_elixir(self) -> None: @@ -173,6 +186,7 @@ def test_elixir(self) -> None: event_processed.send(project=self.project, event=event, sender=type(self.project)) elixir = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="elixir") + assert elixir is not None assert elixir.complete def test_cfml(self) -> None: @@ -182,6 +196,7 @@ def test_cfml(self) -> None: event_processed.send(project=self.project, event=event, sender=type(self.project)) cfml = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="cfml") + assert cfml is not None assert cfml.complete def test_groovy(self) -> None: @@ -192,6 +207,7 @@ def test_groovy(self) -> None: event_processed.send(project=self.project, event=event, sender=type(self.project)) groovy = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="groovy") + assert groovy is not None assert groovy.complete def test_release_tracking(self) -> None: @@ -358,9 +374,11 @@ def test_multiple_events(self) -> None: organization=self.organization, slug="first_event" ) + assert first_event is not None assert first_event.complete js = FeatureAdoption.objects.get_by_slug(organization=self.organization, slug="javascript") + assert js is not None assert js.complete full_event = self.store_event( diff --git a/tests/sentry/receivers/test_onboarding.py b/tests/sentry/receivers/test_onboarding.py index caa05a82f95c..591d8a6e9666 100644 --- a/tests/sentry/receivers/test_onboarding.py +++ b/tests/sentry/receivers/test_onboarding.py @@ -25,6 +25,7 @@ from sentry.analytics.events.project_transferred import ProjectTransferredEvent from sentry.grouping.grouptype import ErrorGroupType from sentry.integrations.analytics import IntegrationAddedEvent +from sentry.integrations.models.integration import Integration from sentry.models.options.organization_option import OrganizationOption from sentry.models.organizationonboardingtask import ( OnboardingTask, @@ -69,7 +70,7 @@ class OrganizationOnboardingTaskTest(TestCase): @assume_test_silo_mode(SiloMode.CONTROL) - def _create_integration(self, provider: str, external_id: int = 9999): + def _create_integration(self, provider: str, external_id: int = 9999) -> Integration: return self.create_provider_integration( provider=provider, name="test", @@ -467,8 +468,8 @@ def test_first_event_with_minified_stack_trace_received( @patch("sentry.analytics.record", wraps=record) def test_analytic_triggered_only_once_if_multiple_events_with_minified_stack_trace_received( - self, record_analytics - ): + self, record_analytics: MagicMock + ) -> None: """ Test that an analytic event is triggered only once when multiple events with minified stack trace are received @@ -663,8 +664,8 @@ def test_first_event_with_sourcemaps_received(self, record_analytics: MagicMock) @patch("sentry.analytics.record", wraps=record) def test_analytic_triggered_only_once_if_multiple_events_with_sourcemaps_received( - self, record_analytics - ): + self, record_analytics: MagicMock + ) -> None: """ Test that an analytic event is triggered only once when multiple events with sourcemaps are received @@ -770,6 +771,7 @@ def test_real_time_notifications_added(self, record_analytics: MagicMock) -> Non ) assert task is not None + assert self.organization.default_owner_id is not None assert_last_analytics_event( record_analytics, IntegrationAddedEvent( @@ -797,6 +799,7 @@ def test_source_code_management_added(self, record_analytics: MagicMock) -> None ) assert task is not None + assert self.organization.default_owner_id is not None assert_last_analytics_event( record_analytics, IntegrationAddedEvent( @@ -824,6 +827,7 @@ def test_perforce_source_code_management_added(self, record_analytics: MagicMock ) assert task is not None + assert self.organization.default_owner_id is not None assert_last_analytics_event( record_analytics, IntegrationAddedEvent( @@ -1021,6 +1025,7 @@ def test_new_onboarding_complete(self, record_analytics: MagicMock) -> None: ) is not None ) + assert self.organization.default_owner_id is not None assert_last_analytics_event( record_analytics, IntegrationAddedEvent( @@ -1104,6 +1109,7 @@ def test_new_onboarding_complete(self, record_analytics: MagicMock) -> None: ) is not None ) + assert self.organization.default_owner_id is not None assert_last_analytics_event( record_analytics, IntegrationAddedEvent( diff --git a/tests/sentry/receivers/test_releases.py b/tests/sentry/receivers/test_releases.py index 907dc0eaf1aa..c1350c5b96c8 100644 --- a/tests/sentry/receivers/test_releases.py +++ b/tests/sentry/receivers/test_releases.py @@ -186,7 +186,7 @@ class ResolvedInCommitTest(TestCase): when a release is created that includes these commits, via update_group_resolutions(). """ - def assertLinkedFromCommitDeferred(self, group, commit): + def assertLinkedFromCommitDeferred(self, group: Group, commit: Commit) -> None: """Assert that a GroupLink and Activity were created, but issue is NOT resolved.""" assert GroupLink.objects.filter( group_id=group.id, linked_type=GroupLink.LinkedType.commit, linked_id=commit.id @@ -207,7 +207,7 @@ def assertLinkedFromCommitDeferred(self, group, commit): # Inbox should NOT be modified assert GroupInbox.objects.filter(group=group).exists() - def assertNotLinkedFromCommit(self, group, commit): + def assertNotLinkedFromCommit(self, group: Group, commit: Commit) -> None: """Assert that no GroupLink exists for this commit.""" assert not GroupLink.objects.filter( group_id=group.id, linked_type=GroupLink.LinkedType.commit, linked_id=commit.id diff --git a/tests/sentry/receivers/test_sentry_apps.py b/tests/sentry/receivers/test_sentry_apps.py index 71cab5477638..d50f497b873a 100644 --- a/tests/sentry/receivers/test_sentry_apps.py +++ b/tests/sentry/receivers/test_sentry_apps.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Mapping +from typing import Any from unittest.mock import MagicMock, patch from sentry.constants import SentryAppInstallationStatus @@ -40,8 +42,8 @@ def setUp(self) -> None: self.login_as(self.user) - def update_issue(self, _data=None): - data = {"status": "resolved"} + def update_issue(self, _data: Mapping[str, object] | None = None) -> None: + data: dict[str, object] = {"status": "resolved"} data.update(_data or {}) self.client.put(self.url, data=data, format="json") @@ -334,7 +336,7 @@ def test_comment_created(self, delay: MagicMock) -> None: def test_comment_updated(self, delay: MagicMock) -> None: note = self.create_comment(self.issue, self.project, self.user) url = f"/api/0/issues/{self.issue.id}/notes/{note.id}/" - data = {"text": "goodbye cruel world"} + data: dict[str, Any] = {"text": "goodbye cruel world"} self.client.put(url, data=data, format="json") data = { "comment_id": note.id, diff --git a/tests/sentry/receivers/test_signals.py b/tests/sentry/receivers/test_signals.py index 795f29197547..0e5a0b6c6ad9 100644 --- a/tests/sentry/receivers/test_signals.py +++ b/tests/sentry/receivers/test_signals.py @@ -95,6 +95,7 @@ def test_issue_resolved_with_default_owner(self, mock_record: MagicMock) -> None resolution_type="now", sender=type(self.project), ) + assert self.organization.default_owner_id is not None assert_last_analytics_event( mock_record, IssueResolvedEvent( @@ -197,6 +198,7 @@ def test_issue_assigned_with_default_owner(self, mock_record: MagicMock) -> None transition_type="manual", sender=type(self.project), ) + assert self.organization.default_owner_id is not None assert_last_analytics_event( mock_record, IssueAssignedEvent( diff --git a/tests/sentry/relay/config/test_experimental.py b/tests/sentry/relay/config/test_experimental.py index d5f9524b7ae1..88b7c399ac13 100644 --- a/tests/sentry/relay/config/test_experimental.py +++ b/tests/sentry/relay/config/test_experimental.py @@ -1,6 +1,6 @@ from datetime import timedelta from time import sleep -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -32,8 +32,8 @@ def test_time_checker_noop_on_invalid_timeout(timeout: int) -> None: @patch("sentry.relay.config.experimental._FEATURE_BUILD_TIMEOUT", timedelta(seconds=1)) @patch("sentry.relay.config.experimental.logger.exception") -def test_add_experimental_config_catches_timeout(mock_logger) -> None: - def dummy(timeout: TimeChecker, *args, **kwargs): +def test_add_experimental_config_catches_timeout(mock_logger: MagicMock) -> None: + def dummy(timeout: TimeChecker, /, *args: object, **kwargs: object) -> None: sleep(1) timeout.check() @@ -49,8 +49,8 @@ def dummy(timeout: TimeChecker, *args, **kwargs): @patch("sentry.relay.config.experimental._FEATURE_BUILD_TIMEOUT", timedelta(seconds=1)) @patch("sentry.relay.config.experimental.logger.exception") -def test_build_safe_config_catches_timeout(mock_logger) -> None: - def dummy(timeout: TimeChecker, *args, **kwargs): +def test_build_safe_config_catches_timeout(mock_logger: MagicMock) -> None: + def dummy(timeout: TimeChecker, /, *args: object, **kwargs: object) -> None: sleep(1) timeout.check() @@ -65,14 +65,14 @@ def dummy(timeout: TimeChecker, *args, **kwargs): def test_build_safe_config_returns_results_from_function_in_args() -> None: - def dummy(*args, **kwargs): + def dummy(*args: object, **kwargs: object) -> tuple[int, int, int]: return 1, 2, 3 result = build_safe_config("key", dummy, 1, 2, 3, 4, 5) assert result == (1, 2, 3) - def dummy2(*args, **kwargs): + def dummy2(*args: object, **kwargs: object) -> tuple[str, None, str]: return "foo", None, "bar" result2 = build_safe_config("key", dummy2) @@ -82,8 +82,8 @@ def dummy2(*args, **kwargs): @patch("sentry.relay.config.experimental._FEATURE_BUILD_TIMEOUT", timedelta(seconds=1)) @patch("sentry.relay.config.experimental.logger.exception") -def test_build_safe_config_returns_none_on_timeout_exception(mock_logger) -> None: - def dummy(timeout: TimeChecker, *args, **kwargs): +def test_build_safe_config_returns_none_on_timeout_exception(mock_logger: MagicMock) -> None: + def dummy(timeout: TimeChecker, /, *args: object, **kwargs: object) -> None: sleep(1) timeout.check() @@ -93,7 +93,7 @@ def dummy(timeout: TimeChecker, *args, **kwargs): def test_build_safe_config_returns_none_on_non_timeout_exception() -> None: - def dummy(*args, **kwargs): + def dummy(*args: object, **kwargs: object) -> None: raise ValueError("foo") result = build_safe_config("key", dummy) diff --git a/tests/sentry/relay/test_config.py b/tests/sentry/relay/test_config.py index 3d64ddd41d2e..a3b06eca7886 100644 --- a/tests/sentry/relay/test_config.py +++ b/tests/sentry/relay/test_config.py @@ -1,6 +1,9 @@ import time +from collections.abc import Callable +from contextlib import AbstractContextManager from copy import deepcopy from datetime import datetime, timedelta, timezone +from typing import Any from unittest import mock from unittest.mock import ANY, MagicMock, patch @@ -24,6 +27,7 @@ from sentry.models.project import Project from sentry.models.projectkey import ProjectKey from sentry.models.projectteam import ProjectTeam +from sentry.models.team import Team from sentry.relay.config import ProjectConfig, TransactionNameRule, get_project_config from sentry.testutils.factories import Factories from sentry.testutils.helpers import Feature @@ -90,7 +94,7 @@ } -def _validate_project_config(config): +def _validate_project_config(config: dict[str, Any]) -> None: # Relay uses a BTreeSet for features: if features := config.get("features"): config["features"] = sorted(features) @@ -114,7 +118,7 @@ def _lowercase_glob_values(node: object) -> None: @django_db_all @cell_silo_test -def test_get_project_config_non_visible(default_project) -> None: +def test_get_project_config_non_visible(default_project: Project) -> None: keys = ProjectKey.objects.filter(project=default_project) default_project.update(status=ObjectStatus.PENDING_DELETION) cfg = get_project_config(default_project, project_keys=keys) @@ -154,7 +158,9 @@ def test_get_project_config(default_project: Project, insta_snapshot: InstaSnaps @cell_silo_test @mock.patch("sentry.relay.config.generate_rules", side_effect=SOME_EXCEPTION) @mock.patch("sentry.relay.config.experimental.logger") -def test_get_experimental_config_dyn_sampling(mock_logger, _, default_project) -> None: +def test_get_experimental_config_dyn_sampling( + mock_logger: MagicMock, _: MagicMock, default_project: Project +) -> None: keys = ProjectKey.objects.filter(project=default_project) with Feature({"organizations:dynamic-sampling": True}): # Does not raise: @@ -172,8 +178,11 @@ def test_get_experimental_config_dyn_sampling(mock_logger, _, default_project) - @pytest.mark.parametrize("has_blacklisted_ips", [False, True]) @pytest.mark.parametrize("has_generic_ip_filter", [False, True]) def test_project_config_uses_filter_features( - default_project, has_custom_filters, has_blacklisted_ips, has_generic_ip_filter -): + default_project: Project, + has_custom_filters: bool, + has_blacklisted_ips: bool, + has_generic_ip_filter: bool, +) -> None: log_messages = ["some log"] trace_metric_names = ["some metric"] error_messages = ["some_error"] @@ -257,8 +266,11 @@ def test_project_config_uses_filter_features( @pytest.mark.parametrize("has_custom_filters", [False, True]) @pytest.mark.parametrize("has_inbound_filters_v2", [False, True]) def test_project_config_custom_inbound_filters_v2( - default_project, factories, has_custom_filters, has_inbound_filters_v2 -): + default_project: Project, + factories: Factories, + has_custom_filters: bool, + has_inbound_filters_v2: bool, +) -> None: active_filter = factories.create_project_custom_inbound_filter( default_project, name="Block old releases", @@ -342,8 +354,8 @@ def test_project_config_exposed_features_raise_exc(default_project: MagicMock) - @patch("sentry.dynamic_sampling.rules.biases.boost_latest_releases_bias.apply_dynamic_factor") @freeze_time("2022-10-21 18:50:25.000000+00:00") def test_project_config_with_all_biases_enabled( - eval_dynamic_factor_lr, default_project, default_team -): + eval_dynamic_factor_lr: MagicMock, default_project: Project, default_team: Team +) -> None: """ Tests that dynamic sampling information return correct uniform rules """ @@ -390,10 +402,10 @@ def test_project_config_with_all_biases_enabled( for release_id in release_ids[1:]: boosted_releases.append((release_id, ts)) - for release, timestamp in boosted_releases: + for release_id, timestamp in boosted_releases: redis_client.hset( f"ds::p:{default_project.id}:boosted_releases", - f"ds::r:{release}:e:prod", + f"ds::r:{release_id}:e:prod", timestamp, ) @@ -525,8 +537,8 @@ def test_project_config_with_all_biases_enabled( @patch("sentry.dynamic_sampling.rules.biases.boost_latest_releases_bias.apply_dynamic_factor") @freeze_time("2022-10-21 18:50:25.000000+00:00") def test_project_config_with_trace_health_checks_enabled( - eval_dynamic_factor_lr, default_project, default_team -): + eval_dynamic_factor_lr: MagicMock, default_project: Project, default_team: Team +) -> None: eval_dynamic_factor_lr.return_value = 1.5 default_project.update_option( @@ -609,8 +621,10 @@ def test_project_config_with_breakdown(default_project: Project) -> None: @cell_silo_test @pytest.mark.parametrize("abnormal_mechanism_rollout", (0, 1)) def test_project_config_with_organizations_metrics_extraction( - default_project, set_sentry_option, abnormal_mechanism_rollout -): + default_project: Project, + set_sentry_option: Callable[[str, object], AbstractContextManager[None]], + abnormal_mechanism_rollout: int, +) -> None: with set_sentry_option( "sentry-metrics.releasehealth.abnormal-mechanism-extraction-rate", abnormal_mechanism_rollout, @@ -627,7 +641,7 @@ def test_project_config_with_organizations_metrics_extraction( @pytest.mark.parametrize("num_clusterer_runs", [9, 10]) @django_db_all -def test_txnames_ready(default_project, num_clusterer_runs) -> None: +def test_txnames_ready(default_project: Project, num_clusterer_runs: int) -> None: with mock.patch( "sentry.relay.config.get_clusterer_meta", return_value={"runs": num_clusterer_runs}, @@ -642,7 +656,7 @@ def test_txnames_ready(default_project, num_clusterer_runs) -> None: @django_db_all @cell_silo_test -def test_project_config_setattr(default_project) -> None: +def test_project_config_setattr(default_project: Project) -> None: project_cfg = ProjectConfig(default_project) with pytest.raises(Exception) as exc_info: project_cfg.foo = "bar" @@ -651,14 +665,14 @@ def test_project_config_setattr(default_project) -> None: @django_db_all @cell_silo_test -def test_project_config_getattr(default_project) -> None: +def test_project_config_getattr(default_project: Project) -> None: project_cfg = ProjectConfig(default_project, foo="bar") assert project_cfg.foo == "bar" @django_db_all @cell_silo_test -def test_project_config_str(default_project) -> None: +def test_project_config_str(default_project: Project) -> None: project_cfg = ProjectConfig(default_project, foo="bar") assert str(project_cfg) == '{"foo":"bar"}' @@ -670,21 +684,21 @@ def test_project_config_str(default_project) -> None: @django_db_all @cell_silo_test -def test_project_config_repr(default_project) -> None: +def test_project_config_repr(default_project: Project) -> None: project_cfg = ProjectConfig(default_project, foo="bar") assert repr(project_cfg) == '(ProjectConfig){"foo":"bar"}' @django_db_all @cell_silo_test -def test_project_config_to_json_string(default_project) -> None: +def test_project_config_to_json_string(default_project: Project) -> None: project_cfg = ProjectConfig(default_project, foo="bar") assert project_cfg.to_json_string() == '{"foo":"bar"}' @django_db_all @cell_silo_test -def test_project_config_get_at_path(default_project) -> None: +def test_project_config_get_at_path(default_project: Project) -> None: project_cfg = ProjectConfig(default_project, a=1, b="The b", foo="bar") assert project_cfg.get_at_path("b") == "The b" assert project_cfg.get_at_path("bb") is None @@ -698,7 +712,7 @@ def test_project_config_get_at_path(default_project) -> None: [True, False], ids=["healthcheck set", "healthcheck not set"], ) -def test_healthcheck_filter(default_project, health_check_set) -> None: +def test_healthcheck_filter(default_project: Project, health_check_set: bool) -> None: """ Tests that the project config properly returns healthcheck filters when the user has enabled healthcheck filters. @@ -720,7 +734,7 @@ def test_healthcheck_filter(default_project, health_check_set) -> None: @django_db_all -def test_desktop_performance_calculate_score(default_project) -> None: +def test_desktop_performance_calculate_score(default_project: Project) -> None: config = get_project_config(default_project).to_dict()["config"] # Set a version field that is returned even though it's optional. @@ -1074,7 +1088,7 @@ def test_desktop_performance_calculate_score(default_project) -> None: @django_db_all -def test_mobile_performance_calculate_score(default_project) -> None: +def test_mobile_performance_calculate_score(default_project: Project) -> None: config = get_project_config(default_project).to_dict()["config"] # Set a version field that is returned even though it's optional. @@ -1418,7 +1432,7 @@ def test_mobile_performance_calculate_score(default_project) -> None: @django_db_all @cell_silo_test -def test_project_config_with_generic_filters(default_project) -> None: +def test_project_config_with_generic_filters(default_project: Project) -> None: config = get_project_config(default_project).to_dict() _validate_project_config(config["config"]) @@ -1429,8 +1443,8 @@ def test_project_config_with_generic_filters(default_project) -> None: @cell_silo_test @mock.patch("sentry.relay.config.get_transaction_names_config") def test_project_config_with_transaction_name_clustering_disabled( - mock_get_transaction_name_config, default_project -): + mock_get_transaction_name_config: MagicMock, default_project: Project +) -> None: mock_get_transaction_name_config.return_value = [ TransactionNameRule( pattern="dummy_rule", @@ -1454,7 +1468,7 @@ def test_project_config_with_transaction_name_clustering_disabled( @django_db_all @cell_silo_test -def test_project_config_trusted_relay_settings_enabled(default_project): +def test_project_config_trusted_relay_settings_enabled(default_project: Project) -> None: default_project.organization.update_option( "sentry:ingest-through-trusted-relays-only", "enabled" ) @@ -1468,7 +1482,7 @@ def test_project_config_trusted_relay_settings_enabled(default_project): @django_db_all @cell_silo_test -def test_project_config_trusted_relay_settings_disabled(default_project): +def test_project_config_trusted_relay_settings_disabled(default_project: Project) -> None: default_project.organization.update_option( "sentry:ingest-through-trusted-relays-only", "disabled" ) @@ -1483,7 +1497,9 @@ def test_project_config_trusted_relay_settings_disabled(default_project): @django_db_all @cell_silo_test @pytest.mark.parametrize("trimming_configs", [{}, {"span": {"maxSize": 17}}]) -def test_project_config_trimming(default_project, trimming_configs): +def test_project_config_trimming( + default_project: Project, trimming_configs: dict[str, object] +) -> None: with patch.object( quotas.backend, "get_trimming_configs", @@ -1503,7 +1519,7 @@ def test_project_config_trimming(default_project, trimming_configs): @django_db_all @cell_silo_test -def test_project_config_skips_the_row_of_a_legacy_list(default_project) -> None: +def test_project_config_skips_the_row_of_a_legacy_list(default_project: Project) -> None: default_project.update_option("sentry:log_messages", ["*DEBUG*"]) Factories.create_project_custom_inbound_filter( default_project, @@ -1536,7 +1552,7 @@ def test_project_config_skips_the_row_of_a_legacy_list(default_project) -> None: @django_db_all @cell_silo_test -def test_project_config_serves_release_comparisons(default_project) -> None: +def test_project_config_serves_release_comparisons(default_project: Project) -> None: comparison = Factories.create_project_custom_inbound_filter( default_project, data_type="all", @@ -1564,7 +1580,9 @@ def test_project_config_serves_release_comparisons(default_project) -> None: @django_db_all @cell_silo_test -def test_project_config_reads_a_legacy_list_at_v2_from_its_row(default_project, factories): +def test_project_config_reads_a_legacy_list_at_v2_from_its_row( + default_project: Project, factories: Factories +) -> None: default_project.update_option("sentry:releases", ["stale"]) default_project.update_option("sentry:error_messages", ["stale"]) default_project.update_option("sentry:log_messages", ["stale"]) diff --git a/tests/sentry/relay/test_datascrubbing.py b/tests/sentry/relay/test_datascrubbing.py index 1624a2062e26..c64292c58c8b 100644 --- a/tests/sentry/relay/test_datascrubbing.py +++ b/tests/sentry/relay/test_datascrubbing.py @@ -3,11 +3,13 @@ import pytest from sentry.models.project import Project -from sentry.relay.datascrubbing import scrub_data +from sentry.relay.datascrubbing import _PIIConfig, scrub_data from sentry.testutils.pytest.fixtures import django_db_all -def merge_pii_configs(prefixes_and_configs): +def merge_pii_configs( + prefixes_and_configs: list[tuple[str, _PIIConfig | None]], +) -> _PIIConfig: from sentry.relay.datascrubbing import _merge_pii_configs as f prefixes_and_configs_bak = copy.deepcopy(prefixes_and_configs) @@ -90,7 +92,7 @@ def test_merge_pii_configs_simple() -> None: def test_merge_pii_configs_rule_references() -> None: - my_rules = { + my_rules: dict[str, object] = { "remove_ips_alias": { "type": "alias", "rule": "@ip", @@ -107,8 +109,14 @@ def test_merge_pii_configs_rule_references() -> None: assert merge_pii_configs( [ - ("o:", {"rules": my_rules, "applications": {"$string": ["remove_ips_and_macs"]}}), - ("p:", {"rules": my_rules, "applications": {"$string": ["remove_ips_alias"]}}), + ( + "o:", + _PIIConfig(rules=my_rules, applications={"$string": ["remove_ips_and_macs"]}), + ), + ( + "p:", + _PIIConfig(rules=my_rules, applications={"$string": ["remove_ips_alias"]}), + ), ] ) == { "applications": {"$string": ["o:remove_ips_and_macs", "p:remove_ips_alias"]}, diff --git a/tests/sentry/relay/test_projectconfig_debounce_cache.py b/tests/sentry/relay/test_projectconfig_debounce_cache.py index 601c7068a7c9..f936f37f2418 100644 --- a/tests/sentry/relay/test_projectconfig_debounce_cache.py +++ b/tests/sentry/relay/test_projectconfig_debounce_cache.py @@ -1,10 +1,18 @@ +from typing import TypedDict + from sentry.relay.projectconfig_debounce_cache.redis import RedisProjectConfigDebounceCache +class _DebounceKwargs(TypedDict): + public_key: str + project_id: None + organization_id: None + + def test_key_lifecycle() -> None: # This lifecycle checks and inserts in one operation. cache = RedisProjectConfigDebounceCache() - kwargs = { + kwargs: _DebounceKwargs = { "public_key": "abc", "project_id": None, "organization_id": None, @@ -23,7 +31,7 @@ def test_split_debounce_lifecycle() -> None: # The lifecycle where checking and inserting is done separately like in the # sentry.tasks.relay.* tasks. cache = RedisProjectConfigDebounceCache() - kwargs = { + kwargs: _DebounceKwargs = { "public_key": "abc", "project_id": None, "organization_id": None, @@ -48,7 +56,7 @@ def test_split_debounce_lifecycle() -> None: def test_default_prefix() -> None: cache = RedisProjectConfigDebounceCache() - kwargs = { + kwargs: _DebounceKwargs = { "public_key": "abc", "project_id": None, "organization_id": None, @@ -64,7 +72,7 @@ def test_default_prefix() -> None: def test_custom_prefix() -> None: cache = RedisProjectConfigDebounceCache(key_prefix="hello:world") - kwargs = { + kwargs: _DebounceKwargs = { "public_key": "abc", "project_id": None, "organization_id": None, diff --git a/tests/sentry/release_health/test_eap_sessions_rollout.py b/tests/sentry/release_health/test_eap_sessions_rollout.py index b5318ff2dd35..282242fd7802 100644 --- a/tests/sentry/release_health/test_eap_sessions_rollout.py +++ b/tests/sentry/release_health/test_eap_sessions_rollout.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone -from typing import Any +from typing import Any, cast from unittest.mock import MagicMock, patch import pytest @@ -143,6 +143,18 @@ def test_with_status_groupby(self) -> None: assert "status" not in group_by_names +def _transform(response: TimeSeriesResponse, **kwargs: Any) -> dict[str, Any]: + # justification: eap_sessions_rollout returns JSON-shaped mappings typed as object. + return cast("dict[str, Any]", _transform_eap_response(response, **kwargs)) + + +def _series_result(metrics_query: MagicMock) -> dict[str, Any]: + result = get_series_eap(metrics_query, org_id=1) + assert result is not None + # justification: eap_sessions_rollout returns JSON-shaped mappings typed as object. + return cast("dict[str, Any]", result) + + def _default_transform_kwargs(**overrides: object) -> dict[str, Any]: """Build default kwargs for _transform_eap_response.""" defaults = { @@ -167,7 +179,7 @@ def test_basic_sum_session(self) -> None: ] ) - result = _transform_eap_response(response, **_default_transform_kwargs()) + result = _transform(response, **_default_transform_kwargs()) assert len(result["groups"]) == 1 group = result["groups"][0] assert group["series"]["sum(session)"] == [100.0] @@ -185,7 +197,7 @@ def test_crash_free_rate(self) -> None: ] ) - result = _transform_eap_response( + result = _transform( response, **_default_transform_kwargs( fields=[_CRASH_FREE_SESSION], @@ -208,7 +220,7 @@ def test_healthy_computation(self) -> None: ] ) - result = _transform_eap_response( + result = _transform( response, **_default_transform_kwargs(raw_groupby=["session.status"]), ) @@ -222,7 +234,7 @@ def test_healthy_computation(self) -> None: def test_empty_response(self) -> None: response = TimeSeriesResponse(result_timeseries=[]) - result = _transform_eap_response(response, **_default_transform_kwargs()) + result = _transform(response, **_default_transform_kwargs()) assert result["groups"] == [] def test_grouped_response(self) -> None: @@ -264,7 +276,7 @@ def test_grouped_response(self) -> None: ) ) - result = _transform_eap_response( + result = _transform( response, **_default_transform_kwargs(raw_groupby=["release"]), ) @@ -361,8 +373,7 @@ def test_returns_result_with_metric_field_names(self, mock_rpc: MagicMock) -> No ) ] - result = get_series_eap(query, org_id=1) - assert result is not None + result = _series_result(query) assert len(result["groups"]) == 1 group = result["groups"][0] assert "sum(session.all)" in group["series"] @@ -388,8 +399,7 @@ def test_remaps_project_groupby(self, mock_rpc: MagicMock) -> None: ) ] - result = get_series_eap(query, org_id=1) - assert result is not None + result = _series_result(query) group = result["groups"][0] assert "project_id" in group["by"] assert "project" not in group["by"] @@ -404,8 +414,7 @@ def test_strips_totals_when_include_totals_false(self, mock_rpc: MagicMock) -> N TimeSeriesResponse(result_timeseries=[_make_timeseries("sum_session_init", [100.0])]) ] - result = get_series_eap(query, org_id=1) - assert result is not None + result = _series_result(query) group = result["groups"][0] assert "totals" not in group assert "series" in group @@ -420,8 +429,7 @@ def test_returns_float_values(self, mock_rpc: MagicMock) -> None: TimeSeriesResponse(result_timeseries=[_make_timeseries("sum_session_init", [5.0])]) ] - result = get_series_eap(query, org_id=1) - assert result is not None + result = _series_result(query) group = result["groups"][0] assert group["series"]["sum(session.all)"] == [5.0] assert isinstance(group["series"]["sum(session.all)"][0], float) @@ -506,7 +514,7 @@ def test_null_eap_result(self, mock_metrics: MagicMock) -> None: } ], } - experimental: dict[str, list[object]] = {"groups": []} + experimental: dict[str, object] = {"groups": []} compare_get_series_results(control, experimental) mock_metrics.incr.assert_called_once_with( "eap_sessions.get_series_compare", diff --git a/tests/sentry/release_health/test_tasks.py b/tests/sentry/release_health/test_tasks.py index cf4d4a83b85e..7f4f7d07b626 100644 --- a/tests/sentry/release_health/test_tasks.py +++ b/tests/sentry/release_health/test_tasks.py @@ -114,6 +114,7 @@ def setUp(self) -> None: }, project_id=self.project.id, ) + assert self.event.group is not None GroupRelease.objects.create( group_id=self.event.group.id, project_id=self.project.id, release_id=self.release.id ) diff --git a/tests/sentry/releases/endpoints/test_organization_release_assemble.py b/tests/sentry/releases/endpoints/test_organization_release_assemble.py index 6371f945690a..8a68ee43bcfc 100644 --- a/tests/sentry/releases/endpoints/test_organization_release_assemble.py +++ b/tests/sentry/releases/endpoints/test_organization_release_assemble.py @@ -1,4 +1,5 @@ from hashlib import sha1 +from typing import Any from unittest.mock import MagicMock, patch from django.core.files.base import ContentFile @@ -188,7 +189,7 @@ def test_assemble_as_artifact_bundle(self, mock_assemble_artifacts: MagicMock) - assert set(response.data["missingChunks"]) == set() # assert that we are uploading as artifact bundle - kwargs = { + kwargs: dict[str, Any] = { "org_id": self.organization.id, "version": self.release.version, "checksum": total_checksum, diff --git a/tests/sentry/releases/endpoints/test_organization_release_file_details.py b/tests/sentry/releases/endpoints/test_organization_release_file_details.py index b812a2f6eb98..c62823be4374 100644 --- a/tests/sentry/releases/endpoints/test_organization_release_file_details.py +++ b/tests/sentry/releases/endpoints/test_organization_release_file_details.py @@ -2,6 +2,7 @@ from io import BytesIO from django.urls import reverse +from rest_framework.response import Response from sentry.models.distribution import Distribution from sentry.models.files.file import File @@ -217,7 +218,7 @@ def test_file_download_as_sentry_app(self) -> None: assert response.status_code == 200, response.content assert b"File contents here" == close_streaming_response(response) - def _get(self, file_id): + def _get(self, file_id: str) -> Response: url = reverse( "sentry-api-0-organization-release-file-details", kwargs={ @@ -227,7 +228,8 @@ def _get(self, file_id): }, ) - return self.client.get(url) + response: Response = self.client.get(url) + return response def test_invalid_id(self) -> None: self.login_as(user=self.user) diff --git a/tests/sentry/releases/endpoints/test_organization_release_health_data.py b/tests/sentry/releases/endpoints/test_organization_release_health_data.py index 50d2a4ba673c..4f9e23564c04 100644 --- a/tests/sentry/releases/endpoints/test_organization_release_health_data.py +++ b/tests/sentry/releases/endpoints/test_organization_release_health_data.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta from functools import partial from typing import Any from unittest import mock @@ -20,6 +20,7 @@ from sentry.snuba.metrics.query import MetricField from sentry.testutils.cases import MetricsAPIBaseTestCase from sentry.testutils.helpers.datetime import freeze_time +from sentry.utils import json from sentry.utils.cursors import Cursor from tests.sentry.api.endpoints.test_organization_metrics import MOCKED_DERIVED_METRICS @@ -55,7 +56,7 @@ def setUp(self) -> None: self.session_error_metric = rh_indexer_record(org_id, SessionMRI.RAW_ERROR.value) @property - def now(self): + def now(self) -> datetime: return MetricsAPIBaseTestCase.MOCK_DATETIME def test_missing_field(self) -> None: @@ -546,7 +547,7 @@ def count_sessions(project_id: int | None) -> int: groups = response.data["groups"] assert len(groups) == 1 - return groups[0]["totals"]["sum(sentry.sessions.session)"] + return int(groups[0]["totals"]["sum(sentry.sessions.session)"]) # Request for entire org gives a counter of two: assert count_sessions(project_id=None) == 2 @@ -1586,7 +1587,7 @@ def setUp(self) -> None: self.tx_user_metric = perf_indexer_record(self.organization.id, "s:transactions/user@none") @property - def now(self): + def now(self) -> datetime: return MetricsAPIBaseTestCase.MOCK_DATETIME @patch("sentry.snuba.metrics.fields.base.DERIVED_METRICS", MOCKED_DERIVED_METRICS) @@ -1597,12 +1598,12 @@ def now(self): @patch("sentry.releases.endpoints.organization_release_health_data.parse_field") def test_derived_metric_incorrectly_defined_as_singular_entity( self, - mocked_parse_field, - mocked_get_public_name_from_mri, - mocked_get_mri_query, - mocked_reverse_mri, - mocked_parse_mri, - ): + mocked_parse_field: mock.MagicMock, + mocked_get_public_name_from_mri: mock.MagicMock, + mocked_get_mri_query: mock.MagicMock, + mocked_reverse_mri: mock.MagicMock, + mocked_parse_mri: mock.MagicMock, + ) -> None: mocked_get_public_name_from_mri.return_value = "crash_free_fake" mocked_get_mri_query.return_value = "crash_free_fake" mocked_reverse_mri.return_value = "crash_free_fake" @@ -2495,7 +2496,7 @@ def test_metric_without_operation_is_not_allowed(self) -> None: interval="1m", status_code=400, ) - result = response.json() + result = json.loads(response.content) assert "detail" in result assert result["detail"] == "You can not use generic metric public field without operation" diff --git a/tests/sentry/releases/endpoints/test_project_release_file_details.py b/tests/sentry/releases/endpoints/test_project_release_file_details.py index cdb7388fc6a2..13e579ac4be6 100644 --- a/tests/sentry/releases/endpoints/test_project_release_file_details.py +++ b/tests/sentry/releases/endpoints/test_project_release_file_details.py @@ -1,8 +1,10 @@ import io from base64 import urlsafe_b64decode, urlsafe_b64encode +from collections.abc import Callable from hashlib import sha1 from django.urls import reverse +from rest_framework.response import Response from sentry.models.distribution import Distribution from sentry.models.files.file import File @@ -197,7 +199,7 @@ def test_file_download_as_sentry_app(self) -> None: assert response.status_code == 200, response.content assert b"File contents here" == close_streaming_response(response) - def _get(self, file_id, postfix=""): + def _get(self, file_id: str, postfix: str = "") -> Response: url = reverse( "sentry-api-0-project-release-file-details", kwargs={ @@ -208,7 +210,8 @@ def _get(self, file_id, postfix=""): }, ) - return self.client.get(url + postfix) + response: Response = self.client.get(url + postfix) + return response def test_invalid_id(self) -> None: # Invalid base64 @@ -372,7 +375,7 @@ def test_delete_archived(self) -> None: assert self.release.count_artifacts() == 2 - url = lambda id: reverse( + url: Callable[[str], str] = lambda id: reverse( "sentry-api-0-project-release-file-details", kwargs={ "organization_id_or_slug": self.organization.slug, diff --git a/tests/sentry/releases/endpoints/test_project_release_files.py b/tests/sentry/releases/endpoints/test_project_release_files.py index c2c6e63e03d7..3aa484434835 100644 --- a/tests/sentry/releases/endpoints/test_project_release_files.py +++ b/tests/sentry/releases/endpoints/test_project_release_files.py @@ -60,7 +60,7 @@ def test_with_archive(self) -> None: assert response.status_code == 200, response.content assert len(response.data) == 0 - def create_release_file(**kwargs): + def create_release_file(**kwargs: object) -> ReleaseFile: name = uuid.uuid4().hex return ReleaseFile.objects.create( organization_id=project.organization_id, diff --git a/tests/sentry/releases/endpoints/test_project_release_stats.py b/tests/sentry/releases/endpoints/test_project_release_stats.py index c2b82fedbfc6..8f520b34b70d 100644 --- a/tests/sentry/releases/endpoints/test_project_release_stats.py +++ b/tests/sentry/releases/endpoints/test_project_release_stats.py @@ -5,6 +5,7 @@ from django.urls import reverse from django.utils import timezone +from sentry.models.project import Project from sentry.models.release import Release from sentry.models.releases.release_project import ReleaseProject from sentry.releases.endpoints.project_release_stats import upsert_missing_release @@ -58,7 +59,7 @@ def test_simple_no_release(self) -> None: class UpsertMissingReleaseTest(TestCase): - def upsert_with_health_data(self, project, version="1.0"): + def upsert_with_health_data(self, project: Project, version: str = "1.0") -> datetime | None: with patch( "sentry.releases.endpoints.project_release_stats.release_health" ) as mock_release_health: diff --git a/tests/sentry/releases/endpoints/test_project_releases.py b/tests/sentry/releases/endpoints/test_project_releases.py index 3e048d56e98c..577309891aa0 100644 --- a/tests/sentry/releases/endpoints/test_project_releases.py +++ b/tests/sentry/releases/endpoints/test_project_releases.py @@ -1,8 +1,10 @@ +from collections.abc import Mapping, Sequence from datetime import UTC, datetime, timedelta from functools import cached_property from django.urls import reverse from rest_framework.exceptions import ErrorDetail +from rest_framework.response import Response from sentry.api.serializers.rest_framework.release import ReleaseWithVersionSerializer from sentry.constants import BAD_RELEASE_CHARS, MAX_VERSION_LENGTH @@ -190,12 +192,12 @@ def setUp(self) -> None: self.env2 = env2 self.env3 = env3 - def make_environment(self, name, project): + def make_environment(self, name: str, project: Project) -> Environment: env = Environment.objects.create(organization_id=project.organization_id, name=name) env.add_project(project) return env - def assert_releases(self, response, releases): + def assert_releases(self, response: Response, releases: Sequence[Release]) -> None: assert response.status_code == 200, response.content assert len(response.data) == len(releases) @@ -203,7 +205,13 @@ def assert_releases(self, response, releases): releases_versions = sorted(r.version for r in releases) assert response_versions == releases_versions - def assert_release_details(self, release, new_issues_count, first_seen, last_seen): + def assert_release_details( + self, + release: Mapping[str, object], + new_issues_count: int, + first_seen: object, + last_seen: object, + ) -> None: assert release["newGroups"] == new_issues_count assert release["firstEvent"] == first_seen assert release["lastEvent"] == last_seen @@ -257,7 +265,9 @@ def test_invalid_environment(self) -> None: self.assert_releases(response, []) def test_new_issues_last_seen_first_seen(self) -> None: - def sort_releases_by_version(releases): + def sort_releases_by_version( + releases: Sequence[Mapping[str, str]], + ) -> list[Mapping[str, str]]: return sorted(releases, key=lambda release: release["version"]) url = reverse( @@ -670,7 +680,7 @@ def test_org_auth_token(self) -> None: class ProjectReleaseCreateCommitPatch(ReleaseCommitPatchTest): @cached_property - def url(self): + def url(self) -> str: return reverse( "sentry-api-0-project-releases", kwargs={ diff --git a/tests/sentry/relocation/tasks/test_process.py b/tests/sentry/relocation/tasks/test_process.py index be100dfe6f6e..afcc7ed33f3f 100644 --- a/tests/sentry/relocation/tasks/test_process.py +++ b/tests/sentry/relocation/tasks/test_process.py @@ -143,7 +143,7 @@ def setUp(self) -> None: self.uuid = str(self.relocation.uuid) @cached_property - def file(self): + def file(self) -> File: with TemporaryDirectory() as tmp_dir: (priv_key_pem, pub_key_pem) = generate_rsa_key_pair() tmp_priv_key_path = Path(tmp_dir).joinpath("key") @@ -191,7 +191,7 @@ def swap_relocation_file( ).getvalue() file.putfile(BytesIO(self.tarball), blob_size=blob_size) - def mock_kms_client(self, fake_kms_client: Mock): + def mock_kms_client(self, fake_kms_client: Mock) -> None: if not hasattr(self, "tarball"): _ = self.file @@ -209,14 +209,14 @@ def mock_kms_client(self, fake_kms_client: Mock): pem=self.pub_key_pem.decode() ) - def mock_cloudbuild_client(self, fake_cloudbuild_client: Mock, status: Build.Status): + def mock_cloudbuild_client(self, fake_cloudbuild_client: Mock, status: Build.Status) -> None: fake_cloudbuild_client.return_value.create_build.return_value = SimpleNamespace( metadata=SimpleNamespace(build=SimpleNamespace(id=uuid4().hex)) ) fake_cloudbuild_client.return_value.get_build.return_value = SimpleNamespace(status=status) - def mock_message_builder(self, fake_message_builder: Mock): + def mock_message_builder(self, fake_message_builder: Mock) -> None: fake_message_builder.return_value.send_async.return_value = Mock() @@ -268,7 +268,7 @@ def test_success_saas_to_saas( uploading_complete_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) @@ -299,7 +299,7 @@ def test_success_self_hosted( uploading_complete_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) @@ -328,7 +328,7 @@ def test_retry_if_attempts_left( uploading_complete_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) @@ -358,7 +358,7 @@ def test_fail_if_no_attempts_left( uploading_complete_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) self.relocation.latest_task = OrderedTask.UPLOADING_START.name @@ -395,7 +395,7 @@ def test_fail_no_org_slug_when_saas_to_saas( uploading_complete_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) @@ -424,7 +424,7 @@ def test_fail_no_org_slug_when_saas_to_saas( @patch("sentry.relocation.tasks.process.CROSS_REGION_EXPORT_TIMEOUT", timedelta(minutes=-1)) def test_fail_due_to_timeout( self, uploading_complete_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) @@ -473,7 +473,7 @@ def test_success( self, preprocessing_scan_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) uploading_complete(self.uuid) @@ -485,7 +485,7 @@ def test_retry_if_attempts_left( self, preprocessing_scan_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: RelocationFile.objects.filter(relocation=self.relocation).delete() self.mock_message_builder(fake_message_builder) @@ -504,7 +504,7 @@ def test_fail_if_no_attempts_left( self, preprocessing_scan_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.relocation.latest_task = OrderedTask.UPLOADING_COMPLETE.name self.relocation.latest_task_attempts = MAX_FAST_TASK_RETRIES self.relocation.save() @@ -539,7 +539,7 @@ def setUp(self) -> None: def test_success_admin_assisted_relocation( self, preprocessing_transfer_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) @@ -565,7 +565,7 @@ def test_success_admin_assisted_relocation( def test_success_self_service_relocation( self, preprocessing_transfer_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: self.mock_kms_client(fake_kms_client) self.relocation.creator_id = self.relocation.owner_id self.relocation.save() @@ -590,7 +590,7 @@ def test_success_self_service_relocation( def test_pause( self, preprocessing_transfer_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) self.relocation.scheduled_pause_at_step = Relocation.Step.PREPROCESSING.value @@ -611,7 +611,7 @@ def test_pause( def test_retry_if_attempts_left( self, preprocessing_transfer_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: RelocationFile.objects.filter(relocation=self.relocation).delete() self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) @@ -632,7 +632,7 @@ def test_retry_if_attempts_left( def test_fail_if_no_attempts_left( self, preprocessing_transfer_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: self.relocation.latest_task = OrderedTask.PREPROCESSING_SCAN.name self.relocation.latest_task_attempts = MAX_FAST_TASK_RETRIES self.relocation.save() @@ -661,7 +661,7 @@ def test_fail_if_no_attempts_left( def test_fail_invalid_tarball( self, preprocessing_transfer_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: file = RelocationFile.objects.get(relocation=self.relocation).file corrupted_tarball_bytes = bytearray(file.getfile().read())[9:] file.putfile(BytesIO(bytes(corrupted_tarball_bytes))) @@ -685,7 +685,7 @@ def test_fail_invalid_tarball( def test_fail_decryption_failure( self, preprocessing_transfer_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: # Add invalid 2-octet UTF-8 sequence to the returned plaintext. self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) @@ -716,7 +716,7 @@ def test_fail_decryption_failure( def test_fail_invalid_json( self, preprocessing_transfer_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: file = RelocationFile.objects.get(relocation=self.relocation).file self.swap_relocation_file_with_data_from_fixture(file, "invalid-user.json") self.mock_message_builder(fake_message_builder) @@ -739,7 +739,7 @@ def test_fail_invalid_json( def test_fail_no_users( self, preprocessing_transfer_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: file = RelocationFile.objects.get(relocation=self.relocation).file self.swap_relocation_file_with_data_from_fixture(file, "single-option.json") self.mock_message_builder(fake_message_builder) @@ -763,7 +763,7 @@ def test_fail_no_users( @patch("sentry.relocation.tasks.process.MAX_USERS_PER_RELOCATION", 1) def test_fail_too_many_users( self, preprocessing_transfer_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) @@ -784,7 +784,7 @@ def test_fail_too_many_users( def test_fail_no_orgs( self, preprocessing_transfer_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: file = RelocationFile.objects.get(relocation=self.relocation).file self.swap_relocation_file_with_data_from_fixture(file, "user-with-minimum-privileges.json") self.mock_message_builder(fake_message_builder) @@ -810,7 +810,7 @@ def test_fail_no_orgs( @patch("sentry.relocation.tasks.process.MAX_ORGS_PER_RELOCATION", 0) def test_fail_too_many_orgs( self, preprocessing_transfer_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) @@ -831,7 +831,7 @@ def test_fail_too_many_orgs( def test_fail_missing_orgs( self, preprocessing_transfer_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: orgs = ["does-not-exist"] relocation = Relocation.objects.get(uuid=self.uuid) relocation.want_org_slugs = orgs @@ -858,7 +858,7 @@ def test_fail_missing_orgs( def test_fail_invalid_org_slug( self, preprocessing_transfer_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: orgs = ["$$##"] relocation = Relocation.objects.get(uuid=self.uuid) relocation.want_org_slugs = orgs @@ -900,7 +900,7 @@ def test_retry_if_attempts_left( self, preprocessing_baseline_config_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: RelocationFile.objects.filter(relocation=self.relocation).delete() self.mock_message_builder(fake_message_builder) @@ -920,7 +920,7 @@ def test_fail_if_no_attempts_left( self, preprocessing_baseline_config_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.relocation.latest_task = OrderedTask.PREPROCESSING_TRANSFER.name self.relocation.latest_task_attempts = MAX_FAST_TASK_RETRIES self.relocation.save() @@ -960,7 +960,7 @@ def test_success( preprocessing_colliding_users_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) @@ -991,7 +991,7 @@ def test_retry_if_attempts_left( preprocessing_colliding_users_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) RelocationFile.objects.filter(relocation=self.relocation).delete() @@ -1017,7 +1017,7 @@ def test_fail_if_no_attempts_left( preprocessing_colliding_users_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock, - ): + ) -> None: self.relocation.latest_task = OrderedTask.PREPROCESSING_BASELINE_CONFIG.name self.relocation.latest_task_attempts = MAX_FAST_TASK_RETRIES self.relocation.save() @@ -1066,7 +1066,7 @@ def setUp(self) -> None: def test_success( self, preprocessing_complete_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) @@ -1094,7 +1094,7 @@ def test_success( def test_retry_if_attempts_left( self, preprocessing_complete_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: RelocationFile.objects.filter(relocation=self.relocation).delete() self.mock_message_builder(fake_message_builder) self.mock_kms_client(fake_kms_client) @@ -1117,7 +1117,7 @@ def test_retry_if_attempts_left( def test_fail_if_no_attempts_left( self, preprocessing_complete_mock: Mock, fake_message_builder: Mock, fake_kms_client: Mock - ): + ) -> None: self.relocation.latest_task = OrderedTask.PREPROCESSING_COLLIDING_USERS.name self.relocation.latest_task_attempts = MAX_FAST_TASK_RETRIES self.relocation.save() @@ -1171,7 +1171,7 @@ def test_success( self, validating_start_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) preprocessing_complete(self.uuid) @@ -1187,7 +1187,7 @@ def test_retry_if_attempts_left( self, validating_start_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.relocation_storage.delete(f"runs/{self.uuid}/conf/cloudbuild.yaml") self.mock_message_builder(fake_message_builder) @@ -1207,7 +1207,7 @@ def test_fail_if_no_attempts_left( self, validating_start_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.relocation.latest_task = OrderedTask.PREPROCESSING_COMPLETE.name self.relocation.latest_task_attempts = MAX_FAST_TASK_RETRIES self.relocation.save() @@ -1234,7 +1234,7 @@ def test_fail_missing_cloudbuild_zip( self, validating_start_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.relocation_storage.delete(f"runs/{self.uuid}/conf/cloudbuild.zip") self.mock_message_builder(fake_message_builder) @@ -1254,7 +1254,7 @@ def test_fail_missing_filter_usernames_file( self, validating_start_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.relocation_storage.delete(f"runs/{self.uuid}/in/filter-usernames.txt") self.mock_message_builder(fake_message_builder) @@ -1274,7 +1274,7 @@ def test_fail_missing_kms_config( self, validating_start_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.relocation_storage.delete(f"runs/{self.uuid}/in/kms-config.json") self.mock_message_builder(fake_message_builder) @@ -1312,7 +1312,7 @@ def test_success( validating_poll_mock: Mock, fake_message_builder: Mock, fake_cloudbuild_client: Mock, - ): + ) -> None: self.mock_cloudbuild_client(fake_cloudbuild_client, Build.Status(Build.Status.QUEUED)) self.mock_message_builder(fake_message_builder) @@ -1336,7 +1336,7 @@ def test_pause( validating_poll_mock: Mock, fake_message_builder: Mock, fake_cloudbuild_client: Mock, - ): + ) -> None: self.mock_cloudbuild_client(fake_cloudbuild_client, Build.Status(Build.Status.QUEUED)) self.mock_message_builder(fake_message_builder) self.relocation.scheduled_pause_at_step = Relocation.Step.VALIDATING.value @@ -1360,7 +1360,7 @@ def test_retry_if_attempts_left( validating_poll_mock: Mock, fake_message_builder: Mock, fake_cloudbuild_client: Mock, - ): + ) -> None: self.mock_cloudbuild_client(fake_cloudbuild_client, Build.Status(Build.Status.QUEUED)) self.mock_message_builder(fake_message_builder) @@ -1384,7 +1384,7 @@ def test_fail_if_no_attempts_left( validating_poll_mock: Mock, fake_message_builder: Mock, fake_cloudbuild_client: Mock, - ): + ) -> None: self.relocation.latest_task = OrderedTask.VALIDATING_START.name self.relocation.latest_task_attempts = MAX_FAST_TASK_RETRIES self.relocation.save() @@ -1410,12 +1410,12 @@ def test_fail_if_max_runs_attempted( validating_poll_mock: Mock, fake_message_builder: Mock, fake_cloudbuild_client: Mock, - ): + ) -> None: for _ in range(3): RelocationValidationAttempt.objects.create( relocation=self.relocation, relocation_validation=self.relocation_validation, - build_id=uuid4().hex, + build_id=uuid4(), ) self.relocation_validation.attempts = 3 @@ -1459,7 +1459,7 @@ def setUp(self) -> None: RelocationValidationAttempt.objects.create( relocation=self.relocation, relocation_validation=self.relocation_validation, - build_id=uuid4().hex, + build_id=uuid4(), ) ) @@ -1469,7 +1469,7 @@ def test_success( validating_complete_mock: Mock, fake_message_builder: Mock, fake_cloudbuild_client: Mock, - ): + ) -> None: self.mock_cloudbuild_client(fake_cloudbuild_client, Build.Status(Build.Status.SUCCESS)) self.mock_message_builder(fake_message_builder) @@ -1492,7 +1492,7 @@ def test_timeout_starts_new_validation_attempt( validating_start_mock: Mock, fake_message_builder: Mock, fake_cloudbuild_client: Mock, - ): + ) -> None: for stat in (Build.Status.TIMEOUT, Build.Status.EXPIRED): validating_start_mock.reset_mock() fake_cloudbuild_client.reset_mock() @@ -1521,7 +1521,7 @@ def test_failure_starts_new_validation_attempt( validating_start_mock: Mock, fake_message_builder: Mock, fake_cloudbuild_client: Mock, - ): + ) -> None: for stat in ( Build.Status.FAILURE, Build.Status.INTERNAL_ERROR, @@ -1553,7 +1553,7 @@ def test_in_progress_retries_poll( validating_poll_mock: Mock, fake_message_builder: Mock, fake_cloudbuild_client: Mock, - ): + ) -> None: for stat in ( Build.Status.QUEUED, Build.Status.PENDING, @@ -1591,7 +1591,7 @@ def test_retry_if_attempts_left( validating_poll_mock: Mock, fake_message_builder: Mock, fake_cloudbuild_client: Mock, - ): + ) -> None: self.mock_cloudbuild_client(fake_cloudbuild_client, Build.Status(Build.Status.QUEUED)) self.mock_message_builder(fake_message_builder) fake_cloudbuild_client.return_value.get_build.side_effect = Exception("Test") @@ -1615,7 +1615,7 @@ def test_fail_if_no_attempts_left( validating_poll_mock: Mock, fake_message_builder: Mock, fake_cloudbuild_client: Mock, - ): + ) -> None: self.relocation.latest_task = OrderedTask.VALIDATING_POLL.name self.relocation.latest_task_attempts = MAX_VALIDATION_POLLS self.relocation.save() @@ -1643,7 +1643,7 @@ def test_fail_if_no_attempts_left( assert relocation.failure_reason == ERR_VALIDATING_INTERNAL -def mock_invalid_finding(storage: Storage, uuid: str): +def mock_invalid_finding(storage: Storage, uuid: str) -> None: storage.save( f"runs/{uuid}/findings/import-baseline-config.json", BytesIO( @@ -1685,7 +1685,7 @@ def setUp(self) -> None: RelocationValidationAttempt.objects.create( relocation=self.relocation, relocation_validation=self.relocation_validation, - build_id=uuid4().hex, + build_id=uuid4(), ) ) @@ -1712,7 +1712,7 @@ def test_valid( self, importing_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) validating_complete(self.uuid, str(self.relocation_validation_attempt.build_id)) @@ -1732,7 +1732,7 @@ def test_invalid( self, importing_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) mock_invalid_finding(self.storage, self.uuid) @@ -1760,7 +1760,7 @@ def test_retry_if_attempts_left( self, importing_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.storage.save( f"runs/{self.uuid}/findings/null.json", @@ -1783,7 +1783,7 @@ def test_fail_if_no_attempts_left( self, importing_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.relocation.latest_task = OrderedTask.VALIDATING_COMPLETE.name self.relocation.latest_task_attempts = MAX_FAST_TASK_RETRIES @@ -2003,7 +2003,7 @@ def setUp(self) -> None: self.imported_org_slug: str = next(iter(imported_orgs.inserted_identifiers.values())) @staticmethod - def noop_relocated_signal_receiver(sender, **kwargs) -> None: + def noop_relocated_signal_receiver(sender: object, **kwargs: object) -> None: raise NotImplementedError def test_success( @@ -2013,7 +2013,7 @@ def test_success( relocation_redeem_promo_code_signal_mock: Mock, relocated_signal_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) assert ( Organization.objects.filter( @@ -2081,7 +2081,7 @@ def test_pause( relocation_redeem_promo_code_signal_mock: Mock, relocated_signal_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.relocation.scheduled_pause_at_step = Relocation.Step.POSTPROCESSING.value self.relocation.save() @@ -2107,7 +2107,7 @@ def test_retry_if_attempts_left( relocation_redeem_promo_code_signal_mock: Mock, relocated_signal_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) relocated_signal_mock.side_effect = [ (self.noop_relocated_signal_receiver, None), @@ -2139,7 +2139,7 @@ def test_fail_if_no_attempts_left( relocation_redeem_promo_code_signal_mock: Mock, relocated_signal_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.relocation.latest_task = OrderedTask.POSTPROCESSING.name self.relocation.latest_task_attempts = MAX_FAST_TASK_RETRIES @@ -2211,7 +2211,7 @@ def test_success( self, notifying_users_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) notifying_unhide(self.uuid) @@ -2230,7 +2230,7 @@ def test_pause( self, notifying_users_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.relocation.scheduled_pause_at_step = Relocation.Step.NOTIFYING.value self.relocation.save() @@ -2293,7 +2293,7 @@ def test_success( self, notifying_owner_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) with patch.object(LostPasswordHash, "send_relocate_account_email") as mock_relocation_email: @@ -2320,7 +2320,7 @@ def test_success_ignore_manually_claimed_users( self, notifying_owner_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: with assume_test_silo_mode(SiloMode.CONTROL): admin: User = User.objects.get(id=self.imported_users["1"], email="admin@example.com") admin.is_unclaimed = False @@ -2352,7 +2352,7 @@ def test_retry_if_attempts_left( self, notifying_owner_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.relocation.want_usernames = ["doesnotexist"] self.relocation.save() @@ -2373,7 +2373,7 @@ def test_fail_if_no_attempts_left( self, notifying_owner_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.relocation.latest_task = OrderedTask.NOTIFYING_USERS.name self.relocation.latest_task_attempts = MAX_FAST_TASK_RETRIES @@ -2422,7 +2422,7 @@ def test_success_admin_assisted_relocation( self, completed_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) notifying_owner(self.uuid) @@ -2443,7 +2443,7 @@ def test_success_self_serve_relocation( self, completed_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) self.relocation.creator_id = self.relocation.owner_id self.relocation.save() @@ -2464,7 +2464,7 @@ def test_retry_if_attempts_left( self, completed_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.mock_message_builder(fake_message_builder) fake_message_builder.return_value.send_async.side_effect = Exception("Test") @@ -2484,7 +2484,7 @@ def test_fail_if_no_attempts_left( self, completed_mock: Mock, fake_message_builder: Mock, - ): + ) -> None: self.relocation.latest_task = OrderedTask.NOTIFYING_OWNER.name self.relocation.latest_task_attempts = MAX_FAST_TASK_RETRIES self.relocation.save() @@ -2552,7 +2552,7 @@ def setUp(self) -> None: for file in files: self.storage.save(f"runs/{self.relocation.uuid}/findings/{file}", BytesIO(b"[]")) - def mock_max_retries(self, fake_cloudbuild_client: Mock, fake_kms_client: Mock): + def mock_max_retries(self, fake_cloudbuild_client: Mock, fake_kms_client: Mock) -> None: fake_cloudbuild_client.return_value.create_build.side_effect = ( [BurstTaskRunnerRetryError("Retry")] * MAX_FAST_TASK_RETRIES ) + [fake_cloudbuild_client.return_value.create_build.return_value] @@ -2578,7 +2578,7 @@ def mock_max_retries(self, fake_cloudbuild_client: Mock, fake_kms_client: Mock): list(fake_kms_client.return_value.get_public_key.side_effect) * 2 ) - def assert_success_database_state(self, org_count: int): + def assert_success_database_state(self, org_count: int) -> None: assert Organization.objects.filter(slug__startswith="testing").count() == org_count + 1 assert RegionImportChunk.objects.filter(import_uuid=self.uuid).count() == 9 @@ -2602,7 +2602,7 @@ def assert_success_database_state(self, org_count: int): "sentry.useremail", ] - def assert_failure_database_state(self, org_count: int): + def assert_failure_database_state(self, org_count: int) -> None: assert Organization.objects.filter(slug__startswith="testing").count() == org_count assert RegionImportChunk.objects.filter(import_uuid=self.uuid).count() == 0 @@ -2610,7 +2610,7 @@ def assert_failure_database_state(self, org_count: int): with assume_test_silo_mode(SiloMode.CONTROL): assert ControlImportChunk.objects.filter(import_uuid=self.uuid).count() == 0 - def assert_success_analytics_record(self, analytics_record_mock: Mock): + def assert_success_analytics_record(self, analytics_record_mock: Mock) -> None: imported_orgs = RegionImportChunk.objects.get( import_uuid=self.uuid, model="sentry.organization" ) @@ -2636,7 +2636,7 @@ def test_valid_no_retries( fake_message_builder: Mock, fake_cloudbuild_client: Mock, fake_kms_client: Mock, - ): + ) -> None: self.mock_cloudbuild_client(fake_cloudbuild_client, Build.Status(Build.Status.SUCCESS)) self.mock_kms_client(fake_kms_client) self.mock_message_builder(fake_message_builder) @@ -2683,7 +2683,7 @@ def test_valid_max_retries( fake_message_builder: Mock, fake_cloudbuild_client: Mock, fake_kms_client: Mock, - ): + ) -> None: self.mock_cloudbuild_client(fake_cloudbuild_client, Build.Status(Build.Status.SUCCESS)) self.mock_kms_client(fake_kms_client) self.mock_max_retries(fake_cloudbuild_client, fake_kms_client) @@ -2736,7 +2736,7 @@ def test_invalid_no_retries( fake_message_builder: Mock, fake_cloudbuild_client: Mock, fake_kms_client: Mock, - ): + ) -> None: self.mock_cloudbuild_client(fake_cloudbuild_client, Build.Status(Build.Status.SUCCESS)) self.mock_kms_client(fake_kms_client) self.mock_message_builder(fake_message_builder) @@ -2784,7 +2784,7 @@ def test_invalid_max_retries( fake_message_builder: Mock, fake_cloudbuild_client: Mock, fake_kms_client: Mock, - ): + ) -> None: self.mock_cloudbuild_client(fake_cloudbuild_client, Build.Status(Build.Status.SUCCESS)) self.mock_kms_client(fake_kms_client) self.mock_max_retries(fake_cloudbuild_client, fake_kms_client) diff --git a/tests/sentry/relocation/tasks/test_transfer.py b/tests/sentry/relocation/tasks/test_transfer.py index 77fe9dd6835d..6b0a863e47f5 100644 --- a/tests/sentry/relocation/tasks/test_transfer.py +++ b/tests/sentry/relocation/tasks/test_transfer.py @@ -1,5 +1,6 @@ -from datetime import timedelta +from datetime import datetime, timedelta from io import BytesIO +from typing import cast from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -31,7 +32,9 @@ TEST_REGIONS = create_test_cells("us", "de") -def create_control_relocation_transfer(organization: Organization, **kwargs): +def create_control_relocation_transfer( + organization: Organization, **kwargs: object +) -> ControlRelocationTransfer: if "relocation_uuid" not in kwargs: kwargs["relocation_uuid"] = uuid4() if "state" not in kwargs: @@ -42,7 +45,9 @@ def create_control_relocation_transfer(organization: Organization, **kwargs): ) -def create_cell_relocation_transfer(organization: Organization, **kwargs): +def create_cell_relocation_transfer( + organization: Organization, **kwargs: object +) -> RegionRelocationTransfer: if "relocation_uuid" not in kwargs: kwargs["relocation_uuid"] = uuid4() if "state" not in kwargs: @@ -77,7 +82,7 @@ def test_due_records(self, mock_process: MagicMock) -> None: assert mock_process.delay.called assert mock_process.delay.call_args[1]["transfer_id"] == transfer.id transfer.refresh_from_db() - assert transfer.scheduled_for > timezone.now() + assert cast(datetime, transfer.scheduled_for) > timezone.now() @patch("sentry.relocation.tasks.transfer.process_relocation_transfer_control") def test_purge_expired(self, mock_process: MagicMock) -> None: @@ -114,7 +119,7 @@ def test_due_records(self, mock_process: MagicMock) -> None: find_relocation_transfer_region() assert mock_process.delay.called transfer.refresh_from_db() - assert transfer.scheduled_for > timezone.now() + assert cast(datetime, transfer.scheduled_for) > timezone.now() @patch("sentry.relocation.tasks.transfer.process_relocation_transfer_region") def test_purge_expired(self, mock_process: MagicMock) -> None: diff --git a/tests/sentry/relocation/test_utils.py b/tests/sentry/relocation/test_utils.py index e18c70d50106..d9a2df3e76bc 100644 --- a/tests/sentry/relocation/test_utils.py +++ b/tests/sentry/relocation/test_utils.py @@ -28,7 +28,7 @@ def setUp(self) -> None: want_org_slugs=["testing"], step=Relocation.Step.UPLOADING.value, ) - self.uuid = self.relocation.uuid + self.uuid = str(self.relocation.uuid) def mock_message_builder(self, fake_message_builder: Mock) -> None: fake_message_builder.return_value.send_async.return_value = MagicMock() diff --git a/tests/sentry/replays/endpoints/test_organization_replay_count.py b/tests/sentry/replays/endpoints/test_organization_replay_count.py index 4d37ddf73864..52893a4aef26 100644 --- a/tests/sentry/replays/endpoints/test_organization_replay_count.py +++ b/tests/sentry/replays/endpoints/test_organization_replay_count.py @@ -108,6 +108,8 @@ def test_simple_b(self) -> None: project_id=self.project.id, ) + assert event_a.group is not None + assert event_c.group is not None query = {"query": f"(issue.id:[{event_a.group.id}, {event_c.group.id}] or abc)"} with self.feature(self.features): response = self.client.get(self.url, query, format="json") @@ -184,6 +186,8 @@ def test_simple_return_ids(self) -> None: project_id=self.project.id, ) + assert event_a.group is not None + assert event_c.group is not None query: dict[str, Any] = { "query": f"issue.id:[{event_a.group.id}, {event_c.group.id}]", "returnIds": True, @@ -340,6 +344,8 @@ def _test_simple_events(self, data_source: str) -> None: project_id=self.project.id, ) + assert event_a.group is not None + assert event_b.group is not None query = { "query": f"issue.id:[{event_a.group.id}, {event_b.group.id}]", "data_source": data_source, @@ -420,6 +426,8 @@ def test_one_replay_multiple_issues(self) -> None: project_id=self.project.id, ) + assert event_a.group is not None + assert event_b.group is not None query = {"query": f"issue.id:[{event_a.group.id}, {event_b.group.id}]"} with self.feature(self.features): response = self.client.get(self.url, query, format="json") @@ -462,6 +470,8 @@ def test_one_replay_same_issue_twice(self) -> None: project_id=self.project.id, ) + assert event_a.group is not None + assert event_b.group is not None query = {"query": f"issue.id:[{event_a.group.id}, {event_b.group.id}]"} with self.feature(self.features): response = self.client.get(self.url, query, format="json") @@ -628,6 +638,7 @@ def test_endpoint_org_hasnt_sent_replays(self) -> None: }, project_id=self.project.id, ) + assert event_a.group is not None query = {"query": f"issue.id:[{event_a.group.id}]"} with self.feature(self.features): @@ -696,6 +707,8 @@ def test_cross_organization_lookups(self) -> None: ) # IDs from both orgs are passed to the endpoint. + assert event_a.group is not None + assert event_b.group is not None query = {"query": f"issue.id:[{event_a.group.id}, {event_b.group.id}]"} with self.feature(self.features): response = self.client.get(self.url, query, format="json") @@ -747,6 +760,7 @@ def test_cross_project_access_denied(self) -> None: ) self.login_as(user=restricted_user) + assert event_b.group is not None query = {"query": f"issue.id:[{event_b.group.id}]"} with self.feature(self.features): response = self.client.get(self.url, query, format="json") diff --git a/tests/sentry/replays/endpoints/test_organization_replay_details.py b/tests/sentry/replays/endpoints/test_organization_replay_details.py index cddec690b831..82c88f5d42f4 100644 --- a/tests/sentry/replays/endpoints/test_organization_replay_details.py +++ b/tests/sentry/replays/endpoints/test_organization_replay_details.py @@ -124,12 +124,14 @@ def test_get_replay_schema(self) -> None: ) ) self.store_replays( - self.mock_event_links( - seq1_timestamp, - self.project.id, - "error", - replay1_id, - "a3a62ef6ac86415b83c2416fc2f76db1", + dict( + self.mock_event_links( + seq1_timestamp, + self.project.id, + "error", + replay1_id, + "a3a62ef6ac86415b83c2416fc2f76db1", + ) ) ) self.store_replays( @@ -195,21 +197,25 @@ def test_get_replay_varying_projects(self) -> None: self.store_replays(mock_replay(seq2_timestamp, project2.id, replay1_id, segment_id=1)) self.store_replays(mock_replay(seq3_timestamp, project2.id, replay1_id, segment_id=2)) self.store_replays( - self.mock_event_links( - seq1_timestamp, - self.project.id, - "error", - replay1_id, - "a3a62ef6ac86415b83c2416fc2f76db1", + dict( + self.mock_event_links( + seq1_timestamp, + self.project.id, + "error", + replay1_id, + "a3a62ef6ac86415b83c2416fc2f76db1", + ) ) ) self.store_replays( - self.mock_event_links( - seq1_timestamp, - project2.id, - "error", - replay1_id, - "e7052fca6e2e406b9dc2d6917932a4c9", + dict( + self.mock_event_links( + seq1_timestamp, + project2.id, + "error", + replay1_id, + "e7052fca6e2e406b9dc2d6917932a4c9", + ) ) ) diff --git a/tests/sentry/replays/endpoints/test_organization_replay_events_meta.py b/tests/sentry/replays/endpoints/test_organization_replay_events_meta.py index 56d2a8adc591..0bbb6c35bacd 100644 --- a/tests/sentry/replays/endpoints/test_organization_replay_events_meta.py +++ b/tests/sentry/replays/endpoints/test_organization_replay_events_meta.py @@ -1,4 +1,5 @@ from datetime import timedelta +from typing import Any from unittest.mock import patch import pytest @@ -61,7 +62,9 @@ def test_simple(self) -> None: with self.feature(self.features): response = self.client.get(self.url, query, format="json") - expected = [ + assert event_a.group is not None + assert event_b.group is not None + expected: list[dict[str, Any]] = [ { "error.type": [], "id": event_id_a, @@ -114,7 +117,7 @@ def test_rage_clicks(self) -> None: response = self.client.get(self.url, query, format="json") assert group_info is not None - expected = [ + expected: list[dict[str, Any]] = [ { "error.type": "", "id": event_id_a, diff --git a/tests/sentry/replays/endpoints/test_organization_replay_index.py b/tests/sentry/replays/endpoints/test_organization_replay_index.py index 22667872c1a8..e88fd1be2628 100644 --- a/tests/sentry/replays/endpoints/test_organization_replay_index.py +++ b/tests/sentry/replays/endpoints/test_organization_replay_index.py @@ -16,6 +16,7 @@ from sentry.replays.validators import ReplaySelectorValidator, ReplayValidator from sentry.search.events.constants import RATE_LIMIT_ERROR_MESSAGE, TIMEOUT_ERROR_MESSAGE from sentry.testutils.cases import APITestCase, ReplaysSnubaTestCase +from sentry.utils import json from sentry.utils.cursors import Cursor from sentry.utils.snuba import QueryMemoryLimitExceeded, QueryTooManySimultaneous @@ -74,7 +75,7 @@ def test_no_projects(self) -> None: response = self.client.get(self.url) assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert "data" in response_data assert response_data["data"] == [] @@ -136,8 +137,14 @@ def test_get_replays(self) -> None: ) ) self.store_replays( - self.mock_event_links( - seq1_timestamp, project.id, "error", replay1_id, "a3a62ef6ac86415b83c2416fc2f76db1" + dict( + self.mock_event_links( + seq1_timestamp, + project.id, + "error", + replay1_id, + "a3a62ef6ac86415b83c2416fc2f76db1", + ) ) ) @@ -145,7 +152,7 @@ def test_get_replays(self) -> None: response = self.client.get(self.url) assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert "data" in response_data assert len(response_data["data"]) == 1 @@ -209,7 +216,7 @@ def test_get_replays_viewed(self) -> None: response = self.client.get(self.url + query) assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert "data" in response_data, query assert len(response_data["data"]) == 2, query @@ -285,7 +292,7 @@ def test_get_replays_browse_screen_fields(self) -> None: response = self.client.get(self.url + qstr) assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert "data" in response_data assert len(response_data["data"]) == 1 @@ -338,7 +345,7 @@ def test_get_replays_tags_field(self) -> None: response = self.client.get(self.url + "?field=tags") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert "data" in response_data assert len(response_data["data"]) == 1 @@ -386,7 +393,7 @@ def test_get_replays_minimum_field_set(self) -> None: ) assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert "data" in response_data assert len(response_data["data"]) == 1 @@ -422,14 +429,14 @@ def test_get_replays_filter_environment(self) -> None: response = self.client.get(self.url + "?environment=development") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert "data" in response_data assert response_data["data"][0]["id"] == replay1_id response = self.client.get(self.url + "?environment=production") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert "data" in response_data assert response_data["data"][0]["id"] == replay2_id @@ -451,13 +458,13 @@ def test_get_replays_started_at_sorted(self) -> None: with self.feature(self.features): # Latest first. response = self.client.get(self.url + "?orderBy=-started_at") - response_data = response.json() + response_data = json.loads(response.content) assert response_data["data"][0]["id"] == replay2_id assert response_data["data"][1]["id"] == replay1_id # Earlist first. response = self.client.get(self.url + "?orderBy=started_at") - response_data = response.json() + response_data = json.loads(response.content) assert response_data["data"][0]["id"] == replay1_id assert response_data["data"][1]["id"] == replay2_id @@ -479,13 +486,13 @@ def test_get_replays_finished_at_sorted(self) -> None: with self.feature(self.features): # Latest first. response = self.client.get(self.url + "?orderBy=-finished_at") - response_data = response.json() + response_data = json.loads(response.content) assert response_data["data"][0]["id"] == replay2_id assert response_data["data"][1]["id"] == replay1_id # Earlist first. response = self.client.get(self.url + "?orderBy=finished_at") - response_data = response.json() + response_data = json.loads(response.content) assert response_data["data"][0]["id"] == replay1_id assert response_data["data"][1]["id"] == replay2_id @@ -509,13 +516,13 @@ def test_get_replays_duration_sorted(self) -> None: # Smallest duration first. response = self.client.get(self.url + "?orderBy=duration") assert response.status_code == 200, response - response_data = response.json() + response_data = json.loads(response.content) assert response_data["data"][0]["id"] == replay1_id assert response_data["data"][1]["id"] == replay2_id # Largest duration first. response = self.client.get(self.url + "?orderBy=-duration") - response_data = response.json() + response_data = json.loads(response.content) assert response_data["data"][0]["id"] == replay2_id assert response_data["data"][1]["id"] == replay1_id @@ -534,11 +541,11 @@ def test_get_replays_duration_sorted_tiebreaker(self) -> None: with self.feature(self.features): response = self.client.get(self.url + "?orderBy=duration") assert response.status_code == 200, response - asc_order = [r["id"] for r in response.json()["data"]] + asc_order = [r["id"] for r in json.loads(response.content)["data"]] response = self.client.get(self.url + "?orderBy=-duration") assert response.status_code == 200, response - desc_order = [r["id"] for r in response.json()["data"]] + desc_order = [r["id"] for r in json.loads(response.content)["data"]] assert desc_order == list(reversed(asc_order)) assert set(asc_order) == set(replay_ids) @@ -566,7 +573,7 @@ def test_get_replays_pagination(self) -> None: cursor=Cursor(0, 0), per_page=1, ) - response_data = response.json() + response_data = json.loads(response.content) assert "data" in response_data assert len(response_data["data"]) == 1 assert response_data["data"][0]["id"] == replay2_id @@ -579,7 +586,7 @@ def test_get_replays_pagination(self) -> None: cursor=Cursor(0, 1), per_page=1, ) - response_data = response.json() + response_data = json.loads(response.content) assert "data" in response_data assert len(response_data["data"]) == 1 assert response_data["data"][0]["id"] == replay1_id @@ -593,7 +600,7 @@ def test_get_replays_pagination(self) -> None: per_page=1, ) - response_data = response.json() + response_data = json.loads(response.content) assert "data" in response_data assert len(response_data["data"]) == 0 link_header = response.headers["Link"] @@ -662,26 +669,50 @@ def test_get_replays_user_filters(self) -> None: ) ) self.store_replays( - self.mock_event_links(seq1_timestamp, project.id, "fatal", replay1_id, uuid.uuid4().hex) + dict( + self.mock_event_links( + seq1_timestamp, project.id, "fatal", replay1_id, uuid.uuid4().hex + ) + ) ) self.store_replays( - self.mock_event_links(seq1_timestamp, project.id, "fatal", replay1_id, uuid.uuid4().hex) + dict( + self.mock_event_links( + seq1_timestamp, project.id, "fatal", replay1_id, uuid.uuid4().hex + ) + ) ) self.store_replays( - self.mock_event_links( - seq1_timestamp, project.id, "error", replay1_id, "a3a62ef6ac86415b83c2416fc2f76db1" + dict( + self.mock_event_links( + seq1_timestamp, + project.id, + "error", + replay1_id, + "a3a62ef6ac86415b83c2416fc2f76db1", + ) ) ) self.store_replays( - self.mock_event_links( - seq1_timestamp, project.id, "warning", replay1_id, uuid.uuid4().hex + dict( + self.mock_event_links( + seq1_timestamp, project.id, "warning", replay1_id, uuid.uuid4().hex + ) ) ) self.store_replays( - self.mock_event_links(seq1_timestamp, project.id, "info", replay1_id, uuid.uuid4().hex) + dict( + self.mock_event_links( + seq1_timestamp, project.id, "info", replay1_id, uuid.uuid4().hex + ) + ) ) self.store_replays( - self.mock_event_links(seq1_timestamp, project.id, "debug", replay1_id, uuid.uuid4().hex) + dict( + self.mock_event_links( + seq1_timestamp, project.id, "debug", replay1_id, uuid.uuid4().hex + ) + ) ) self.store_replays( mock_replay_viewed( @@ -818,8 +849,8 @@ def test_get_replays_user_filters(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") - assert response.status_code == 200, (query, response.json()) - response_data = response.json() + assert response.status_code == 200, (query, json.loads(response.content)) + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, (query, response.json()) # Test all queries as a single AND expression. @@ -827,7 +858,7 @@ def test_get_replays_user_filters(self) -> None: response = self.client.get(self.url + f"?query={all_queries}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, "all queries" missing_uuid = "f8a783a4261a4b559f108c3721fc05cc" @@ -893,8 +924,8 @@ def test_get_replays_user_filters(self) -> None: ] for query in null_queries: response = self.client.get(self.url + f"?field=id&query={query}") - assert response.status_code == 200, (query, response.json()) - response_data = response.json() + assert response.status_code == 200, (query, json.loads(response.content)) + response_data = json.loads(response.content) assert len(response_data["data"]) == 0, (query, response.json()) def test_get_replays_user_sorts(self) -> None: @@ -1008,26 +1039,38 @@ def test_get_replays_user_sorts(self) -> None: ) ) self.store_replays( - self.mock_event_links( - seq1_timestamp, project2.id, "fatal", replay1_id, uuid.uuid4().hex + dict( + self.mock_event_links( + seq1_timestamp, project2.id, "fatal", replay1_id, uuid.uuid4().hex + ) ) ) self.store_replays( - self.mock_event_links( - seq1_timestamp, project2.id, "error", replay1_id, uuid.uuid4().hex + dict( + self.mock_event_links( + seq1_timestamp, project2.id, "error", replay1_id, uuid.uuid4().hex + ) ) ) self.store_replays( - self.mock_event_links( - seq1_timestamp, project2.id, "warning", replay1_id, uuid.uuid4().hex + dict( + self.mock_event_links( + seq1_timestamp, project2.id, "warning", replay1_id, uuid.uuid4().hex + ) ) ) self.store_replays( - self.mock_event_links(seq1_timestamp, project2.id, "info", replay1_id, uuid.uuid4().hex) + dict( + self.mock_event_links( + seq1_timestamp, project2.id, "info", replay1_id, uuid.uuid4().hex + ) + ) ) self.store_replays( - self.mock_event_links( - seq1_timestamp, project2.id, "debug", replay1_id, uuid.uuid4().hex + dict( + self.mock_event_links( + seq1_timestamp, project2.id, "debug", replay1_id, uuid.uuid4().hex + ) ) ) @@ -1395,7 +1438,7 @@ def test_get_replays_filter_clicks(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200, query - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query queries = [ @@ -1423,7 +1466,7 @@ def test_get_replays_filter_clicks(self) -> None: for query in queries: response = self.client.get(self.url + f"?query={query}") assert response.status_code == 200, query - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 0, query def test_get_replays_filter_taps(self) -> None: @@ -1469,7 +1512,7 @@ def test_get_replays_filter_taps(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200, query - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query negation_queries = [ @@ -1481,7 +1524,7 @@ def test_get_replays_filter_taps(self) -> None: for query in negation_queries: response = self.client.get(self.url + f"?query={query}") assert response.status_code == 200, query - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 0, query def test_get_replays_click_fields(self) -> None: @@ -1526,7 +1569,7 @@ def test_get_replays_click_fields(self) -> None: with self.feature(self.features): response = self.client.get(self.url + "?field=clicks") assert response.status_code == 200, response.content - response_data = response.json() + response_data = json.loads(response.content) assert response_data["data"] == [ { "clicks": [ @@ -1756,7 +1799,7 @@ def test_get_replays_filter_clicks_non_click_rows(self) -> None: for query in success_queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query # These tests demonstrate what happens when you match a click value on non-click row. @@ -1773,7 +1816,7 @@ def test_get_replays_filter_clicks_non_click_rows(self) -> None: for query in failure_queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 0, query # The following section tests the valid branches of the condition classes. @@ -1800,7 +1843,7 @@ def test_query_branches_string_conditions(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query def test_query_branches_click_scalar_conditions(self) -> None: @@ -1830,7 +1873,7 @@ def test_query_branches_click_scalar_conditions(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query def test_query_branches_click_array_conditions(self) -> None: @@ -1860,7 +1903,7 @@ def test_query_branches_click_array_conditions(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query def test_query_branches_array_of_string_conditions(self) -> None: @@ -1887,7 +1930,7 @@ def test_query_branches_array_of_string_conditions(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query def test_query_branches_segment_names_conditions(self) -> None: @@ -1919,7 +1962,7 @@ def test_query_branches_segment_names_conditions(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query def test_query_branches_integer_conditions(self) -> None: @@ -1932,8 +1975,14 @@ def test_query_branches_integer_conditions(self) -> None: self.store_replays(mock_replay(seq1_timestamp, project.id, replay1_id)) self.store_replays(mock_replay(seq2_timestamp, project.id, replay1_id, error_ids=[])) self.store_replays( - self.mock_event_links( - seq1_timestamp, project.id, "error", replay1_id, "a3a62ef6ac86415b83c2416fc2f76db1" + dict( + self.mock_event_links( + seq1_timestamp, + project.id, + "error", + replay1_id, + "a3a62ef6ac86415b83c2416fc2f76db1", + ) ) ) @@ -1951,7 +2000,7 @@ def test_query_branches_integer_conditions(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query def test_query_branches_error_ids_conditions(self) -> None: @@ -1977,7 +2026,7 @@ def test_query_branches_error_ids_conditions(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query def test_query_branches_uuid_conditions(self) -> None: @@ -2003,7 +2052,7 @@ def test_query_branches_uuid_conditions(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query def test_query_branches_string_uuid_conditions(self) -> None: @@ -2028,7 +2077,7 @@ def test_query_branches_string_uuid_conditions(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query def test_query_branches_ip_address_conditions(self) -> None: @@ -2051,7 +2100,7 @@ def test_query_branches_ip_address_conditions(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query def test_query_invalid_ipv4_addresses(self) -> None: @@ -2180,7 +2229,7 @@ def test_query_branches_computed_activity_conditions(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query def test_query_scalar_optimization_multiple_varying(self) -> None: @@ -2198,7 +2247,7 @@ def test_query_scalar_optimization_multiple_varying(self) -> None: with self.feature(self.features): response = self.client.get(self.url + "?field=id&query=urls:apple urls:microsoft") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1 def test_query_scalar_optimization_varying_with_tags(self) -> None: @@ -2261,7 +2310,7 @@ def test_get_replays_missing_segment_0(self) -> None: response = self.client.get(self.url) assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert "data" in response_data assert len(response_data["data"]) == 0 @@ -2281,10 +2330,10 @@ def test_new_errors_column(self) -> None: ) self.store_replays(mock_replay(seq2_timestamp, project.id, replay1_id, error_ids=[])) self.store_replays( - self.mock_event_links(seq1_timestamp, project.id, "error", replay1_id, uid1) + dict(self.mock_event_links(seq1_timestamp, project.id, "error", replay1_id, uid1)) ) self.store_replays( - self.mock_event_links(seq1_timestamp, project.id, "fatal", replay1_id, uid2) + dict(self.mock_event_links(seq1_timestamp, project.id, "fatal", replay1_id, uid2)) ) with self.feature(self.features): queries = [ @@ -2297,7 +2346,7 @@ def test_new_errors_column(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&field=error_ids&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query assert len(response_data["data"][0]["error_ids"]) == 2, query @@ -2305,7 +2354,7 @@ def test_new_errors_column(self) -> None: self.url + f"?field=id&field=error_ids&query=error_id:{uid3}" ) assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 0, query def test_warnings_column(self) -> None: @@ -2321,7 +2370,7 @@ def test_warnings_column(self) -> None: self.store_replays(mock_replay(seq1_timestamp, project.id, replay1_id)) self.store_replays(mock_replay(seq2_timestamp, project.id, replay1_id)) self.store_replays( - self.mock_event_links(seq1_timestamp, project.id, "warning", replay1_id, uid1) + dict(self.mock_event_links(seq1_timestamp, project.id, "warning", replay1_id, uid1)) ) with self.feature(self.features): @@ -2334,7 +2383,7 @@ def test_warnings_column(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&field=warning_ids&query={query}") assert response.status_code == 200, query - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query assert len(response_data["data"][0]["warning_ids"]) == 1, query @@ -2342,7 +2391,7 @@ def test_warnings_column(self) -> None: self.url + f"?field=id&field=warning_ids&query=warning_id:{uid2}" ) assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 0, query def test_infos_column(self) -> None: @@ -2359,10 +2408,10 @@ def test_infos_column(self) -> None: self.store_replays(mock_replay(seq1_timestamp, project.id, replay1_id)) self.store_replays(mock_replay(seq2_timestamp, project.id, replay1_id)) self.store_replays( - self.mock_event_links(seq1_timestamp, project.id, "info", replay1_id, uid1) + dict(self.mock_event_links(seq1_timestamp, project.id, "info", replay1_id, uid1)) ) self.store_replays( - self.mock_event_links(seq1_timestamp, project.id, "debug", replay1_id, uid2) + dict(self.mock_event_links(seq1_timestamp, project.id, "debug", replay1_id, uid2)) ) with self.feature(self.features): queries = [ @@ -2375,13 +2424,13 @@ def test_infos_column(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&field=info_ids&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query assert len(response_data["data"][0]["info_ids"]) == 2, query response = self.client.get(self.url + f"?field=id&field=info_ids&query=info_id:{uid3}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 0, query def test_exp_query_branches_error_ids_conditions(self) -> None: @@ -2410,7 +2459,7 @@ def test_exp_query_branches_error_ids_conditions(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&field=error_ids&query={query}") assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query assert len(response_data["data"][0]["error_ids"]) == 1, query @@ -2429,26 +2478,46 @@ def test_event_id_count_columns(self) -> None: self.store_replays(mock_replay(seq1_timestamp, project.id, replay1_id)) self.store_replays(mock_replay(seq2_timestamp, project.id, replay1_id)) self.store_replays( - self.mock_event_links(seq1_timestamp, project.id, "fatal", replay1_id, uuid.uuid4().hex) + dict( + self.mock_event_links( + seq1_timestamp, project.id, "fatal", replay1_id, uuid.uuid4().hex + ) + ) ) self.store_replays( - self.mock_event_links(seq1_timestamp, project.id, "error", replay1_id, uuid.uuid4().hex) + dict( + self.mock_event_links( + seq1_timestamp, project.id, "error", replay1_id, uuid.uuid4().hex + ) + ) ) self.store_replays( - self.mock_event_links( - seq1_timestamp, project.id, "warning", replay1_id, uuid.uuid4().hex + dict( + self.mock_event_links( + seq1_timestamp, project.id, "warning", replay1_id, uuid.uuid4().hex + ) ) ) self.store_replays( - self.mock_event_links(seq1_timestamp, project.id, "info", replay1_id, uuid.uuid4().hex) + dict( + self.mock_event_links( + seq1_timestamp, project.id, "info", replay1_id, uuid.uuid4().hex + ) + ) ) self.store_replays( - self.mock_event_links(seq1_timestamp, project.id, "debug", replay1_id, uuid.uuid4().hex) + dict( + self.mock_event_links( + seq1_timestamp, project.id, "debug", replay1_id, uuid.uuid4().hex + ) + ) ) self.store_replays( - self.mock_event_links( - seq1_timestamp, project.id, "debug", other_replay, uuid.uuid4().hex + dict( + self.mock_event_links( + seq1_timestamp, project.id, "debug", other_replay, uuid.uuid4().hex + ) ) ) @@ -2457,7 +2526,7 @@ def test_event_id_count_columns(self) -> None: self.url + f"?field=id&field=count_warnings&field=count_infos&query=id:{replay1_id}" ) assert response.status_code == 200 - response_data = response.json() + response_data = json.loads(response.content) assert response_data["data"][0]["count_warnings"] == 1 def test_non_empty_string_scalar(self) -> None: @@ -2484,7 +2553,7 @@ def test_non_empty_string_scalar(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200, query - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 1, query # If we explicitly negate dist's filled value we should also ignore empty @@ -2497,7 +2566,7 @@ def test_non_empty_string_scalar(self) -> None: for query in queries: response = self.client.get(self.url + f"?field=id&query={query}") assert response.status_code == 200, query - response_data = response.json() + response_data = json.loads(response.content) assert len(response_data["data"]) == 0, query def test_get_replays_preferred_source(self) -> None: diff --git a/tests/sentry/replays/endpoints/test_project_replay_details.py b/tests/sentry/replays/endpoints/test_project_replay_details.py index 14ae2a3b2a21..f144569719e3 100644 --- a/tests/sentry/replays/endpoints/test_project_replay_details.py +++ b/tests/sentry/replays/endpoints/test_project_replay_details.py @@ -133,12 +133,14 @@ def test_get_replay_schema(self) -> None: ) ) self.store_replays( - self.mock_event_links( - seq1_timestamp, - self.project.id, - "error", - replay1_id, - "a3a62ef6ac86415b83c2416fc2f76db1", + dict( + self.mock_event_links( + seq1_timestamp, + self.project.id, + "error", + replay1_id, + "a3a62ef6ac86415b83c2416fc2f76db1", + ) ) ) diff --git a/tests/sentry/replays/endpoints/test_project_replay_viewed_by.py b/tests/sentry/replays/endpoints/test_project_replay_viewed_by.py index a521e7b89e32..1dae486a8be9 100644 --- a/tests/sentry/replays/endpoints/test_project_replay_viewed_by.py +++ b/tests/sentry/replays/endpoints/test_project_replay_viewed_by.py @@ -167,6 +167,7 @@ def test_get_replay_viewed_by_user_in_other_org(self) -> None: other_org_member = self.create_member( organization=self.create_organization(), user=self.create_user() ) + assert other_org_member.user_id is not None seq1_timestamp = datetime.datetime.now() - datetime.timedelta(seconds=10) seq2_timestamp = datetime.datetime.now() - datetime.timedelta(seconds=5) self.store_replays(mock_replay(seq1_timestamp, self.project.id, self.replay_id)) diff --git a/tests/sentry/replays/endpoints/test_replay_granular_permissions.py b/tests/sentry/replays/endpoints/test_replay_granular_permissions.py index 0f14808fc3e9..9eebea2da002 100644 --- a/tests/sentry/replays/endpoints/test_replay_granular_permissions.py +++ b/tests/sentry/replays/endpoints/test_replay_granular_permissions.py @@ -382,6 +382,7 @@ def test_sentry_app_with_event_read_scope_has_access(self) -> None: user=self.user_with_access, ) + assert installation.api_token is not None url = f"/api/0/organizations/{self.organization.slug}/replays/" response = self.client.get( url, @@ -410,6 +411,7 @@ def test_sentry_app_without_event_read_scope_denied(self) -> None: user=self.user_with_access, ) + assert installation.api_token is not None url = f"/api/0/organizations/{self.organization.slug}/replays/" response = self.client.get( url, @@ -435,6 +437,7 @@ def test_sentry_app_with_event_read_empty_allowlist_has_access(self) -> None: user=self.user_with_access, ) + assert installation.api_token is not None url = f"/api/0/organizations/{self.organization.slug}/replays/" response = self.client.get( url, diff --git a/tests/sentry/replays/lib/test_event_linking.py b/tests/sentry/replays/lib/test_event_linking.py index 83c09a042d1a..1a70278f9efa 100644 --- a/tests/sentry/replays/lib/test_event_linking.py +++ b/tests/sentry/replays/lib/test_event_linking.py @@ -20,4 +20,4 @@ def test_event_link_types(self) -> None: stored = transform_event_for_linking_payload(replay_id, event) # make sure snuba 200s which means that the payload was successfully written to clickhouse # (method will raise if it doesnt) - self.store_replays(stored) + self.store_replays(dict(stored)) diff --git a/tests/sentry/replays/unit/consumers/test_recording.py b/tests/sentry/replays/unit/consumers/test_recording.py index 2bc01bbf1e08..62aa9efe8c17 100644 --- a/tests/sentry/replays/unit/consumers/test_recording.py +++ b/tests/sentry/replays/unit/consumers/test_recording.py @@ -1,11 +1,16 @@ import zlib -from typing import Any +from collections.abc import Callable +from typing import Any, cast from unittest import mock import msgpack import pytest from google.api_core.exceptions import BadGateway, InternalServerError +# justification: google.api_core does not annotate its exception constructors. +_InternalServerError = cast("Callable[[str], InternalServerError]", InternalServerError) +_BadGateway = cast("Callable[[str], BadGateway]", BadGateway) + from sentry.replays.consumers.recording import ( DropSilently, commit_message, @@ -525,7 +530,7 @@ def test_process_message_no_headers() -> None: assert process_message(kafka_message) is None -@pytest.mark.parametrize("error", [InternalServerError("500"), BadGateway("502")]) +@pytest.mark.parametrize("error", [_InternalServerError("500"), _BadGateway("502")]) def test_commit_message_reraises_transient_server_errors(error: Exception) -> None: """A 500/502 that outlasts the upload retries fails the task so it can be retried.""" with ( diff --git a/tests/sentry/replays/unit/test_ingest.py b/tests/sentry/replays/unit/test_ingest.py index eaad9f85246d..b07af7813cc4 100644 --- a/tests/sentry/replays/unit/test_ingest.py +++ b/tests/sentry/replays/unit/test_ingest.py @@ -86,12 +86,13 @@ def test_process_recording_event_with_video() -> None: # Verify the filedata is packed replay video # We can verify by unpacking it unpacked_video, unpacked_rrweb = unpack(zlib.decompress(result.filedata)) - assert unpacked_rrweb == payload - assert unpacked_video == video_data + assert bytes(unpacked_rrweb) == payload + assert unpacked_video is not None + assert bytes(unpacked_video) == video_data def test_parse_replay_events_empty() -> None: - (result, trace_items) = parse_replay_events( + parsed = parse_replay_events( { "context": { "key_id": 1, @@ -110,6 +111,8 @@ def test_parse_replay_events_empty() -> None: }, True, ) + assert parsed is not None + result, trace_items = parsed assert result == ParsedEventMeta([], [], [], [], [], [], [], []) assert trace_items == [] @@ -140,8 +143,9 @@ def test_parse_replay_events_invalid_json() -> None: def test_pack_replay_video() -> None: result = pack_replay_video(b"hello", b"world") video, rrweb = unpack(zlib.decompress(result)) - assert rrweb == b"hello" - assert video == b"world" + assert bytes(rrweb) == b"hello" + assert video is not None + assert bytes(video) == b"world" @pytest.mark.parametrize( diff --git a/tests/sentry/replays/unit/usecases/test_pack.py b/tests/sentry/replays/unit/usecases/test_pack.py index d2a28a2a8730..b5f488a05894 100644 --- a/tests/sentry/replays/unit/usecases/test_pack.py +++ b/tests/sentry/replays/unit/usecases/test_pack.py @@ -17,12 +17,20 @@ def test_pack_rrweb_video() -> None: def test_unpack_rrweb() -> None: - assert unpack(pack(b"hello", None)) == (None, b"hello") + video, rrweb = unpack(pack(b"hello", None)) + assert video is None + assert bytes(rrweb) == b"hello" def test_unpack_rrweb_video() -> None: - assert unpack(pack(b"hello", b"world")) == (b"world", b"hello") + video, rrweb = unpack(pack(b"hello", b"world")) + assert video is not None + assert bytes(video) == b"world" + assert bytes(rrweb) == b"hello" x = b"\x00" * 1_000_000 y = b"\xff" * 1_000_000 - assert unpack(pack(x, y)) == (y, x) + video, rrweb = unpack(pack(x, y)) + assert video is not None + assert bytes(video) == y + assert bytes(rrweb) == x diff --git a/tests/sentry/replays/usecases/test_summarize.py b/tests/sentry/replays/usecases/test_summarize.py index c526cdf764f9..fc50e5958947 100644 --- a/tests/sentry/replays/usecases/test_summarize.py +++ b/tests/sentry/replays/usecases/test_summarize.py @@ -1374,6 +1374,8 @@ def _mutate(event: dict[str, Any], path: tuple[str, ...], replacement: Any) -> d del node[path[-1]] else: node[path[-1]] = replacement + if not isinstance(mutated, dict): + raise TypeError("Mutated replay payload is not a JSON object") return mutated diff --git a/tests/sentry/rules/actions/test_create_ticket_utils.py b/tests/sentry/rules/actions/test_create_ticket_utils.py index 6de4eb4ab008..d8493dbaaa71 100644 --- a/tests/sentry/rules/actions/test_create_ticket_utils.py +++ b/tests/sentry/rules/actions/test_create_ticket_utils.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, patch from sentry.integrations.mixins.issues import IssueBasicIntegration @@ -16,7 +17,10 @@ def test_build_description(self) -> None: installation.get_group_description.return_value = "Test description" workflow_id = 123 - description = build_description(self.event, workflow_id, installation, generate_footer) + assert self.event.group is not None + description = build_description( + self.event.for_group(self.event.group), workflow_id, installation, generate_footer + ) expected_url = f"/organizations/{self.organization.slug}/monitors/alerts/{workflow_id}/" assert ( @@ -35,8 +39,8 @@ def setUp(self) -> None: self.installation.get_group_title.return_value = "Test title" self.installation.get_group_description.return_value = "Test description" - def _create_issue(self, origin: NotificationOrigin) -> dict: - data: dict = {} + def _create_issue(self, origin: NotificationOrigin) -> dict[str, Any]: + data: dict[str, Any] = {} future = RuleFuture( context=NotificationActionContext(origin=origin, action_id=1, project=self.project), kwargs={ @@ -53,6 +57,7 @@ def _create_issue(self, origin: NotificationOrigin) -> dict: ), patch("sentry.rules.actions.integrations.create_ticket.utils.create_link"), ): + assert self.event.group is not None create_issue(self.event.for_group(self.event.group), [future]) return data @@ -80,6 +85,7 @@ def test_missing_workflow_id_logs_warning(self) -> None: data = self._create_issue(origin) assert data["description"] == "Test description" + assert self.event.group is not None mock_logger.warning.assert_called_once_with( "%s.rule_trigger.missing_workflow_id", "github", diff --git a/tests/sentry/rules/actions/test_notify_event.py b/tests/sentry/rules/actions/test_notify_event.py index 75ce69c824a8..aab0628029e0 100644 --- a/tests/sentry/rules/actions/test_notify_event.py +++ b/tests/sentry/rules/actions/test_notify_event.py @@ -9,7 +9,7 @@ class NotifyEventActionTest(RuleTestCase): rule_cls = NotifyEventAction def test_noop(self) -> None: - event = self.get_event() + event = self.get_group_event() rule = self.get_rule() results = list(rule.after(event=event)) assert len(results) == 0 diff --git a/tests/sentry/rules/actions/test_notify_event_sentry_app.py b/tests/sentry/rules/actions/test_notify_event_sentry_app.py index 492eafd8f54e..b8e9151e1721 100644 --- a/tests/sentry/rules/actions/test_notify_event_sentry_app.py +++ b/tests/sentry/rules/actions/test_notify_event_sentry_app.py @@ -1,3 +1,4 @@ +from typing import Any, cast from unittest.mock import MagicMock, patch import pytest @@ -18,6 +19,11 @@ class NotifyEventSentryAppActionTest(RuleTestCase): rule_cls = NotifyEventSentryAppAction + + def get_rule(self, **kwargs: Any) -> NotifyEventSentryAppAction: + # justification: RuleTestCase types rule_cls as EventAction, losing the concrete action. + return cast(NotifyEventSentryAppAction, super().get_rule(**kwargs)) + schema_data = [ {"name": "title", "value": "Squid Game"}, {"name": "summary", "value": "circle triangle square"}, @@ -28,7 +34,7 @@ def create_schema(self) -> None: self.schema = {"elements": [self.create_alert_rule_action_schema()]} def test_applies_correctly_for_sentry_apps(self) -> None: - event = self.get_event() + event = self.get_group_event() self.app = self.create_sentry_app( organization=event.organization, diff --git a/tests/sentry/rules/actions/test_notify_event_service.py b/tests/sentry/rules/actions/test_notify_event_service.py index 91e1c7e4d8f1..e6477cb568b9 100644 --- a/tests/sentry/rules/actions/test_notify_event_service.py +++ b/tests/sentry/rules/actions/test_notify_event_service.py @@ -1,4 +1,4 @@ -from typing import cast +from typing import Any, cast from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -106,8 +106,12 @@ def test_applies_correctly_for_legacy_webhooks_aci(self) -> None: class NotifyEventServiceSentryAppActionTest(RuleTestCase, BaseWorkflowTest): rule_cls = NotifyEventServiceAction + def get_rule(self, **kwargs: Any) -> NotifyEventServiceAction: + # justification: RuleTestCase types rule_cls as EventAction, losing the concrete action. + return cast(NotifyEventServiceAction, super().get_rule(**kwargs)) + def test_applies_correctly_for_sentry_apps(self) -> None: - event = self.get_event() + event = self.get_group_event() self.create_sentry_app( organization=event.organization, name="Test Application", is_alertable=True @@ -165,7 +169,7 @@ def setUp(self) -> None: self.incident_serialized_response = cast(IncidentSerializerResponse, {}) self.notification_context = NotificationContext( id=1, - sentry_app_id=self.sentry_app.id, + sentry_app_id=str(self.sentry_app.id), ) self.notification_uuid = str(uuid4()) diff --git a/tests/sentry/rules/conditions/test_event_attribute.py b/tests/sentry/rules/conditions/test_event_attribute.py index a6ec0891e0e9..c445db31747c 100644 --- a/tests/sentry/rules/conditions/test_event_attribute.py +++ b/tests/sentry/rules/conditions/test_event_attribute.py @@ -1,10 +1,14 @@ +from typing import cast + +from sentry.rules.actions.base import EventAction from sentry.rules.conditions.event_attribute import EventAttributeCondition from sentry.testutils.cases import RuleTestCase from sentry.workflow_engine.handlers.condition.utils.match import MatchType class EventAttributeConditionTest(RuleTestCase): - rule_cls = EventAttributeCondition + # justification: RuleTestCase types rule_cls for actions; conditions and filters share the harness. + rule_cls = cast("type[EventAction]", EventAttributeCondition) def test_render_label(self) -> None: rule = self.get_rule(data={"match": MatchType.EQUAL, "attribute": "\xc3", "value": "\xc4"}) diff --git a/tests/sentry/rules/conditions/test_level_event.py b/tests/sentry/rules/conditions/test_level_event.py index fedc1ccb2ade..ef0ecefa86ee 100644 --- a/tests/sentry/rules/conditions/test_level_event.py +++ b/tests/sentry/rules/conditions/test_level_event.py @@ -1,10 +1,14 @@ +from typing import cast + +from sentry.rules.actions.base import EventAction from sentry.rules.conditions.level import LevelCondition from sentry.testutils.cases import RuleTestCase from sentry.workflow_engine.handlers.condition.utils.match import MatchType class LevelConditionTest(RuleTestCase): - rule_cls = LevelCondition + # justification: RuleTestCase types rule_cls for actions; conditions and filters share the harness. + rule_cls = cast("type[EventAction]", LevelCondition) def test_render_label(self) -> None: rule = self.get_rule(data={"match": MatchType.EQUAL, "level": "30"}) diff --git a/tests/sentry/rules/conditions/test_tagged_event.py b/tests/sentry/rules/conditions/test_tagged_event.py index f27cad9ba554..31c83d5d98e8 100644 --- a/tests/sentry/rules/conditions/test_tagged_event.py +++ b/tests/sentry/rules/conditions/test_tagged_event.py @@ -1,10 +1,14 @@ +from typing import cast + +from sentry.rules.actions.base import EventAction from sentry.rules.conditions.tagged_event import TaggedEventCondition from sentry.testutils.cases import RuleTestCase from sentry.workflow_engine.handlers.condition.utils.match import MatchType class TaggedEventConditionTest(RuleTestCase): - rule_cls = TaggedEventCondition + # justification: RuleTestCase types rule_cls for actions; conditions and filters share the harness. + rule_cls = cast("type[EventAction]", TaggedEventCondition) def test_render_label(self) -> None: rule = self.get_rule(data={"match": MatchType.EQUAL, "key": "\xc3", "value": "\xc4"}) diff --git a/tests/sentry/rules/filters/test_assigned_to.py b/tests/sentry/rules/filters/test_assigned_to.py index 157999d9243c..020f859a2fce 100644 --- a/tests/sentry/rules/filters/test_assigned_to.py +++ b/tests/sentry/rules/filters/test_assigned_to.py @@ -1,9 +1,13 @@ +from typing import cast + +from sentry.rules.actions.base import EventAction from sentry.rules.filters.assigned_to import AssignedToFilter from sentry.testutils.cases import RuleTestCase class AssignedToFilterTest(RuleTestCase): - rule_cls = AssignedToFilter + # justification: RuleTestCase types rule_cls for actions; conditions and filters share the harness. + rule_cls = cast("type[EventAction]", AssignedToFilter) def test_render_label_team_in_org(self) -> None: rule = self.get_rule(data={"targetType": "Team", "targetIdentifier": self.team.id}) diff --git a/tests/sentry/runner/commands/test_cleanup.py b/tests/sentry/runner/commands/test_cleanup.py index 024ce0d78660..adf5636f1ca2 100644 --- a/tests/sentry/runner/commands/test_cleanup.py +++ b/tests/sentry/runner/commands/test_cleanup.py @@ -432,8 +432,8 @@ def test_agentic_triage_links_survive_with_null_seer_run(self) -> None: assert not SeerRun.objects.filter(id=run.id).exists() shard.refresh_from_db() result.refresh_from_db() - assert shard.seer_run_id is None - assert result.result_seer_run_id is None + assert not shard.seer_run_id + assert not result.result_seer_run_id class PartitionValidationTest(TestCase): diff --git a/tests/sentry/runner/commands/test_configoptions.py b/tests/sentry/runner/commands/test_configoptions.py index 25471a904865..da5007e0caca 100644 --- a/tests/sentry/runner/commands/test_configoptions.py +++ b/tests/sentry/runner/commands/test_configoptions.py @@ -3,6 +3,7 @@ from unittest import mock import pytest +from click.testing import Result from django.conf import settings from django.test import override_settings @@ -99,7 +100,7 @@ def assert_not_set() -> None: assert not options.isset("map_option") assert not options.isset("list_option") - def assert_output(rv): + def assert_output(rv: Result) -> None: assert rv.exit_code == 2, rv.output # The script produces log lines when DRIFT is detected. This diff --git a/tests/sentry/runner/commands/test_createflag.py b/tests/sentry/runner/commands/test_createflag.py index 84aae7726f25..472361533230 100644 --- a/tests/sentry/runner/commands/test_createflag.py +++ b/tests/sentry/runner/commands/test_createflag.py @@ -11,7 +11,10 @@ def parse_generated_yaml(output: str) -> dict[str, Any]: split_output = output.split("=== GENERATED YAML ===\n") assert len(split_output) == 2 - return yaml.safe_load(split_output[1]) + generated = yaml.safe_load(split_output[1]) + if not isinstance(generated, dict): + raise TypeError("Generated flag YAML is not a mapping") + return generated class TestCreateFlag(CliTestCase): diff --git a/tests/sentry/runner/commands/test_migrate_debugfiles_to_objectstore.py b/tests/sentry/runner/commands/test_migrate_debugfiles_to_objectstore.py index 501dd3abab99..595c43292cf8 100644 --- a/tests/sentry/runner/commands/test_migrate_debugfiles_to_objectstore.py +++ b/tests/sentry/runner/commands/test_migrate_debugfiles_to_objectstore.py @@ -38,9 +38,9 @@ def test_migrates_legacy_debug_files_end_to_end(self) -> None: first.refresh_from_db() second.refresh_from_db() - assert first.file_id is None - assert second.file_id is None - assert first.storage_path is not None - assert second.storage_path is not None + assert not first.file_id + assert not second.file_id + assert first.storage_path + assert second.storage_path assert not File.objects.filter(id=first_file_id).exists() assert not File.objects.filter(id=second_file_id).exists() diff --git a/tests/sentry/runner/commands/test_migrations.py b/tests/sentry/runner/commands/test_migrations.py index eb6bb904244b..0bc5b3374d5f 100644 --- a/tests/sentry/runner/commands/test_migrations.py +++ b/tests/sentry/runner/commands/test_migrations.py @@ -1,8 +1,9 @@ from __future__ import annotations +from typing import Any from unittest.mock import patch -from click.testing import CliRunner +from click.testing import CliRunner, Result from django.db import connections, router from django.test import override_settings @@ -10,7 +11,7 @@ from sentry.testutils.cases import TransactionTestCase -def filter_queries(needle: str, queries: list) -> list[str]: +def filter_queries(needle: str, queries: list[str]) -> list[str]: return list(filter(lambda sql: needle in sql, queries)) @@ -22,7 +23,7 @@ class MigrationsRunTest(TransactionTestCase): def runner(self) -> CliRunner: return CliRunner() - def invoke(self, *args, **kwargs): + def invoke(self, *args: str, **kwargs: Any) -> Result: return self.runner.invoke(self.command, args, obj={}, **kwargs) def test_index_creation(self) -> None: diff --git a/tests/sentry/runner/test_initializer.py b/tests/sentry/runner/test_initializer.py index 3fd8b59c9b4d..1d475f4695e6 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,16 +42,21 @@ def settings(): @pytest.fixture -def config_yml(tmpdir): - return tmpdir.join("config.yml") +def config_yml(tmpdir: LocalPath) -> Path: + return Path(tmpdir.join(Path("config.yml"))) -def _assert_settings_warnings(warninfo, expected): - actual = {(w.message.setting, w.message.replacement) for w in warninfo} +def _assert_settings_warnings( + warninfo: pytest.WarningsRecorder, expected: set[tuple[str, str]] +) -> None: + actual = set() + for w in warninfo: + assert isinstance(w.message, DeprecatedSettingWarning) + actual.add((w.message.setting, w.message.replacement)) assert actual == expected -def test_bootstrap_options_simple(settings, config_yml) -> None: +def test_bootstrap_options_simple(settings: types.SimpleNamespace, config_yml: Path) -> None: "Config options are specified in both places, but config.yml should prevail" settings.SECRET_KEY = "xxx" settings.EMAIL_BACKEND = "xxx" @@ -63,7 +70,7 @@ def test_bootstrap_options_simple(settings, config_yml) -> None: settings.EMAIL_SUBJECT_PREFIX = "xxx" settings.SENTRY_OPTIONS = {"something.else": True} - config_yml.write( + config_yml.write_text( """\ foo.bar: my-foo-bar system.secret-key: my-system-secret-key @@ -76,7 +83,8 @@ def test_bootstrap_options_simple(settings, config_yml) -> None: mail.use-ssl: false mail.from: my-mail-from mail.subject-prefix: my-mail-subject-prefix -""" +""", + encoding="utf-8", ) bootstrap_options(settings, str(config_yml)) @@ -106,17 +114,17 @@ def test_bootstrap_options_simple(settings, config_yml) -> None: assert settings.EMAIL_SUBJECT_PREFIX == "my-mail-subject-prefix" -def test_bootstrap_options_malformed_yml(settings, config_yml) -> None: - config_yml.write("1") +def test_bootstrap_options_malformed_yml(settings: types.SimpleNamespace, config_yml: Path) -> None: + config_yml.write_text("1", encoding="utf-8") with pytest.raises(ConfigurationError): bootstrap_options(settings, str(config_yml)) - config_yml.write("{{{") + config_yml.write_text("{{{", encoding="utf-8") with pytest.raises(ConfigurationError): bootstrap_options(settings, str(config_yml)) -def test_bootstrap_options_no_config(settings) -> None: +def test_bootstrap_options_no_config(settings: types.SimpleNamespace) -> None: "No config file should gracefully extract values out of settings" settings.SECRET_KEY = "my-system-secret-key" settings.EMAIL_BACKEND = "my-mail-backend" @@ -161,7 +169,7 @@ def test_bootstrap_options_no_config(settings) -> None: } -def test_bootstrap_options_no_config_only_sentry_options(settings) -> None: +def test_bootstrap_options_no_config_only_sentry_options(settings: types.SimpleNamespace) -> None: "SENTRY_OPTIONS is only declared, but should be promoted into settings" settings.SENTRY_OPTIONS = { "system.secret-key": "my-system-secret-key", @@ -189,13 +197,13 @@ def test_bootstrap_options_no_config_only_sentry_options(settings) -> None: assert settings.EMAIL_SUBJECT_PREFIX == "my-mail-subject-prefix" -def test_bootstrap_options_mail_aliases(settings) -> None: +def test_bootstrap_options_mail_aliases(settings: types.SimpleNamespace) -> None: settings.SENTRY_OPTIONS = {"mail.backend": "dummy"} bootstrap_options(settings) assert settings.EMAIL_BACKEND == "alias-for-dummy" -def test_bootstrap_options_mail_defaults(settings) -> None: +def test_bootstrap_options_mail_defaults(settings: types.SimpleNamespace) -> None: settings.EMAIL_BACKEND = DEAD settings.EMAIL_SUBJECT_PREFIX = DEAD settings.SENTRY_EMAIL_BACKEND_ALIASES = SENTRY_EMAIL_BACKEND_ALIASES @@ -208,7 +216,9 @@ def test_bootstrap_options_mail_defaults(settings) -> None: @pytest.mark.parametrize("prefix", ["[Custom]", ""]) -def test_bootstrap_options_preserves_explicit_mail_prefix(settings, prefix) -> None: +def test_bootstrap_options_preserves_explicit_mail_prefix( + settings: types.SimpleNamespace, prefix: str +) -> None: settings.EMAIL_SUBJECT_PREFIX = prefix with pytest.warns(DeprecatedSettingWarning): @@ -219,7 +229,9 @@ def test_bootstrap_options_preserves_explicit_mail_prefix(settings, prefix) -> N @pytest.mark.parametrize("prefix", ["[Custom]", ""]) @pytest.mark.parametrize("source", ["SENTRY_OPTIONS", "SENTRY_DEFAULT_OPTIONS"]) -def test_bootstrap_options_promotes_mail_prefix(settings, prefix, source) -> None: +def test_bootstrap_options_promotes_mail_prefix( + settings: types.SimpleNamespace, prefix: str, source: str +) -> None: settings.EMAIL_SUBJECT_PREFIX = DEAD setattr(settings, source, {"mail.subject-prefix": prefix}) @@ -230,29 +242,29 @@ def test_bootstrap_options_promotes_mail_prefix(settings, prefix, source) -> Non @pytest.mark.parametrize("prefix", ["[Custom]", ""]) def test_bootstrap_options_config_mail_prefix_precedes_settings( - settings, config_yml, prefix + settings: types.SimpleNamespace, config_yml: Path, prefix: str ) -> None: settings.EMAIL_SUBJECT_PREFIX = "[Setting]" settings.SENTRY_OPTIONS["mail.subject-prefix"] = "[Option]" - config_yml.write(f"mail.subject-prefix: '{prefix}'\n") + config_yml.write_text(f"mail.subject-prefix: '{prefix}'\n", encoding="utf-8") bootstrap_options(settings, str(config_yml)) assert settings.EMAIL_SUBJECT_PREFIX == prefix -def test_bootstrap_options_missing_file(settings) -> None: +def test_bootstrap_options_missing_file(settings: types.SimpleNamespace) -> None: bootstrap_options(settings, "this-file-does-not-exist-xxxxxxxxxxxxxx.yml") assert settings.SENTRY_OPTIONS == {} -def test_bootstrap_options_empty_file(settings, config_yml) -> None: - config_yml.write("") +def test_bootstrap_options_empty_file(settings: types.SimpleNamespace, config_yml: Path) -> None: + config_yml.write_text("", encoding="utf-8") bootstrap_options(settings, str(config_yml)) assert settings.SENTRY_OPTIONS == {} -def test_apply_legacy_settings(settings) -> None: +def test_apply_legacy_settings(settings: types.SimpleNamespace) -> None: settings.ALLOWED_HOSTS = [] settings.SENTRY_USE_QUEUE = True settings.SENTRY_ALLOW_REGISTRATION = True @@ -307,7 +319,7 @@ def test_apply_legacy_settings(settings) -> None: ) -def test_initialize_app(settings) -> None: +def test_initialize_app(settings: types.SimpleNamespace) -> None: "Just a sanity check of the full initialization process" settings.SENTRY_OPTIONS = {"system.secret-key": "secret-key"} bootstrap_options(settings) @@ -321,7 +333,7 @@ def test_validate_outbox_config_includes_group_action_log_outbox() -> None: validate_cell_outbox.assert_any_call(GroupActionLogOutbox._meta.label) -def test_require_secret_key(settings) -> None: +def test_require_secret_key(settings: types.SimpleNamespace) -> None: assert "system.secret-key" not in settings.SENTRY_OPTIONS with pytest.raises(ConfigurationError): apply_legacy_settings(settings) @@ -342,46 +354,54 @@ def test_bind_cache_to_option_store_with_options_cache() -> None: assert default_store.cache == caches["options"] -def test_self_hosted_filestore_config_yml_promoted(settings, config_yml) -> None: +def test_self_hosted_filestore_config_yml_promoted( + settings: types.SimpleNamespace, config_yml: Path +) -> None: """config.yml filestore keys are promoted to Django settings on self-hosted.""" settings.SENTRY_SELF_HOSTED = True settings.SENTRY_FILE_STORAGE_BACKEND = "filesystem" settings.SENTRY_FILE_STORAGE_CONFIG = {} - config_yml.write("filestore.backend: gcs\nfilestore.options:\n bucket_name: my-bucket\n") + config_yml.write_text( + "filestore.backend: gcs\nfilestore.options:\n bucket_name: my-bucket\n", encoding="utf-8" + ) bootstrap_options(settings, str(config_yml)) assert settings.SENTRY_FILE_STORAGE_BACKEND == "gcs" assert settings.SENTRY_FILE_STORAGE_CONFIG == {"bucket_name": "my-bucket"} -def test_non_self_hosted_filestore_config_yml_not_promoted(settings, config_yml) -> None: +def test_non_self_hosted_filestore_config_yml_not_promoted( + settings: types.SimpleNamespace, config_yml: Path +) -> None: """config.yml filestore keys are not promoted to Django settings on SaaS.""" settings.SENTRY_SELF_HOSTED = False settings.SENTRY_FILE_STORAGE_BACKEND = "filesystem" settings.SENTRY_FILE_STORAGE_CONFIG = {} - config_yml.write("filestore.backend: gcs\nfilestore.options:\n bucket_name: my-bucket\n") + config_yml.write_text( + "filestore.backend: gcs\nfilestore.options:\n bucket_name: my-bucket\n", encoding="utf-8" + ) bootstrap_options(settings, str(config_yml)) assert settings.SENTRY_FILE_STORAGE_BACKEND == "filesystem" assert settings.SENTRY_FILE_STORAGE_CONFIG == {} -def test_migrated_options_promoted(settings, config_yml) -> None: +def test_migrated_options_promoted(settings: types.SimpleNamespace, config_yml: Path) -> None: """Configured values for migrated options reach their settings in every mode.""" settings.SENTRY_SLACK_SIGNING_SECRET = "" settings.SENTRY_GITHUB_APP_WEBHOOK_SECRET = "" settings.SENTRY_OPTIONS = {"github-app.webhook-secret": "webhook-secret"} - config_yml.write("slack.signing-secret: signing-secret\n") + config_yml.write_text("slack.signing-secret: signing-secret\n", encoding="utf-8") bootstrap_options(settings, str(config_yml)) assert settings.SENTRY_SLACK_SIGNING_SECRET == "signing-secret" assert settings.SENTRY_GITHUB_APP_WEBHOOK_SECRET == "webhook-secret" -def test_migrated_options_defaults_not_promoted(settings) -> None: +def test_migrated_options_defaults_not_promoted(settings: types.SimpleNamespace) -> None: """A registered option default never replaces a directly configured setting.""" settings.SENTRY_SLACK_SIGNING_SECRET = "signing-secret" settings.SENTRY_DEFAULT_OPTIONS = {"slack.signing-secret": ""} @@ -392,7 +412,7 @@ def test_migrated_options_defaults_not_promoted(settings) -> None: assert "slack.signing-secret" not in settings.SENTRY_OPTIONS -def test_single_organization_reuses_github_app_secret(settings) -> None: +def test_single_organization_reuses_github_app_secret(settings: types.SimpleNamespace) -> None: """Single organization SSO uses the GitHub integration app secret setting.""" settings.SENTRY_SINGLE_ORGANIZATION = True settings.SENTRY_GITHUB_APP_CLIENT_SECRET = "app-secret" @@ -403,7 +423,9 @@ def test_single_organization_reuses_github_app_secret(settings) -> None: assert settings.GITHUB_API_SECRET == "app-secret" -def test_single_organization_keeps_option_github_secret_remap(settings) -> None: +def test_single_organization_keeps_option_github_secret_remap( + settings: types.SimpleNamespace, +) -> None: """With the option key configured, the single organization remap decides the SSO secret.""" settings.SENTRY_SINGLE_ORGANIZATION = True settings.SENTRY_OPTIONS = { @@ -421,7 +443,7 @@ def test_single_organization_keeps_option_github_secret_remap(settings) -> None: assert settings.SENTRY_GITHUB_APP_CLIENT_SECRET == "app-secret" -def test_self_hosted_validate_options_skips_migrated_keys(settings) -> None: +def test_self_hosted_validate_options_skips_migrated_keys(settings: types.SimpleNamespace) -> None: """validate_options does not warn about migrated option keys on self-hosted.""" settings.SENTRY_SELF_HOSTED = True settings.SENTRY_OPTIONS = {"filestore.backend": "gcs", "mail.list-namespace": "example.com"} @@ -540,7 +562,12 @@ def test_bind_cache_to_option_store_without_options_cache() -> None: DEPLOYMENT_OPTION_CASES, ) def test_deployment_options_promote_explicit_values( - settings, config_yml, self_hosted, key, setting_name, value + settings: types.SimpleNamespace, + config_yml: Path, + self_hosted: bool, + key: str, + setting_name: str, + value: object, ) -> None: from yaml import safe_dump @@ -550,7 +577,7 @@ def test_deployment_options_promote_explicit_values( bootstrap_options(settings) assert getattr(settings, setting_name) == value - config_yml.write(safe_dump({key: value})) + config_yml.write_text(safe_dump({key: value}), encoding="utf-8") bootstrap_options(config_settings, str(config_yml)) assert getattr(config_settings, setting_name) == value @@ -558,7 +585,7 @@ def test_deployment_options_promote_explicit_values( @pytest.mark.parametrize("self_hosted", [False, True]) @pytest.mark.parametrize("key, setting_name, value", DEPLOYMENT_OPTION_CASES) def test_deployment_options_ignore_defaults_and_null( - settings, self_hosted, key, setting_name, value + settings: types.SimpleNamespace, self_hosted: bool, key: str, setting_name: str, value: object ) -> None: settings.SENTRY_SELF_HOSTED = self_hosted setattr(settings, setting_name, value) @@ -570,7 +597,7 @@ def test_deployment_options_ignore_defaults_and_null( assert getattr(settings, setting_name) == value -def test_single_organization_reuses_github_app_client_id(settings) -> None: +def test_single_organization_reuses_github_app_client_id(settings: types.SimpleNamespace) -> None: settings.SENTRY_SINGLE_ORGANIZATION = True settings.SENTRY_GITHUB_APP_CLIENT_SECRET = "" settings.SENTRY_GITHUB_APP_CLIENT_ID = "app-client-id" @@ -581,7 +608,9 @@ def test_single_organization_reuses_github_app_client_id(settings) -> None: assert settings.GITHUB_APP_ID == "app-client-id" -def test_single_organization_keeps_option_github_client_id_remap(settings) -> None: +def test_single_organization_keeps_option_github_client_id_remap( + settings: types.SimpleNamespace, +) -> None: settings.SENTRY_SINGLE_ORGANIZATION = True settings.SENTRY_GITHUB_APP_CLIENT_SECRET = "" settings.SENTRY_OPTIONS = { @@ -607,7 +636,7 @@ def test_single_organization_keeps_option_github_client_id_remap(settings) -> No ], ) def test_single_organization_direct_app_setting_survives_login_reverse_mapping( - settings, key, setting_name, login_setting + settings: types.SimpleNamespace, key: str, setting_name: str, login_setting: str ) -> None: settings.SENTRY_SINGLE_ORGANIZATION = True settings.SENTRY_GITHUB_APP_CLIENT_SECRET = "app-secret" @@ -651,7 +680,14 @@ def test_single_organization_direct_app_setting_survives_login_reverse_mapping( ], ) def test_single_organization_config_app_key_retains_remap_precedence( - settings, config_yml, modern_value, option_value, expected_app, key, setting_name, login_setting + settings: types.SimpleNamespace, + config_yml: Path, + modern_value: str, + option_value: str | None, + expected_app: str, + key: str, + setting_name: str, + login_setting: str, ) -> None: from yaml import safe_dump @@ -659,7 +695,7 @@ def test_single_organization_config_app_key_retains_remap_precedence( settings.SENTRY_GITHUB_APP_CLIENT_SECRET = "" setattr(settings, setting_name, modern_value) setattr(settings, login_setting, "login-value") - config_yml.write(safe_dump({key: option_value})) + config_yml.write_text(safe_dump({key: option_value}), encoding="utf-8") with ( pytest.warns(DeprecatedSettingWarning), @@ -677,7 +713,9 @@ def test_single_organization_config_app_key_retains_remap_precedence( assert getattr(settings, login_setting) == "login-value" -def test_single_organization_bootstrap_reuses_paired_direct_app_credentials(settings) -> None: +def test_single_organization_bootstrap_reuses_paired_direct_app_credentials( + settings: types.SimpleNamespace, +) -> None: settings.SENTRY_SINGLE_ORGANIZATION = True settings.SENTRY_GITHUB_APP_CLIENT_ID = "app-client-id" settings.SENTRY_GITHUB_APP_CLIENT_SECRET = "app-client-secret" @@ -702,7 +740,7 @@ def test_single_organization_bootstrap_reuses_paired_direct_app_credentials(sett ], ) def test_single_organization_modern_app_pair_preserves_empty_partner( - settings, app_id, app_secret, expected_login + settings: types.SimpleNamespace, app_id: str, app_secret: str, expected_login: tuple[str, str] ) -> None: settings.SENTRY_SINGLE_ORGANIZATION = True settings.SENTRY_GITHUB_APP_CLIENT_ID = app_id @@ -741,7 +779,12 @@ def test_single_organization_modern_app_pair_preserves_empty_partner( @pytest.mark.parametrize("value", ["", "deployment-secret"]) @pytest.mark.parametrize("self_hosted", [False, True]) def test_explicit_deployment_secret_is_not_replaced_by_legacy_alias( - settings, old_name, key, setting_name, value, self_hosted + settings: types.SimpleNamespace, + old_name: str, + key: str, + setting_name: str, + value: object, + self_hosted: bool, ) -> None: settings.SENTRY_SELF_HOSTED = self_hosted settings.SENTRY_OPTIONS = {"system.secret-key": "test-system-secret"} @@ -765,7 +808,7 @@ def test_explicit_deployment_secret_is_not_replaced_by_legacy_alias( ], ) def test_original_option_precedes_explicit_deployment_setting_and_alias( - settings, old_name, key, setting_name + settings: types.SimpleNamespace, old_name: str, key: str, setting_name: str ) -> None: settings.SENTRY_OPTIONS = {"system.secret-key": "test-system-secret", key: "option-secret"} settings.SENTRY_CONFIGURED_OPTION_SETTINGS = frozenset({setting_name}) @@ -780,7 +823,9 @@ def test_original_option_precedes_explicit_deployment_setting_and_alias( @pytest.mark.django_db -def test_google_legacy_reader_preserves_alias_with_explicit_modern_setting(settings) -> None: +def test_google_legacy_reader_preserves_alias_with_explicit_modern_setting( + settings: types.SimpleNamespace, +) -> None: settings.SENTRY_OPTIONS = {"system.secret-key": "test-system-secret"} settings.GOOGLE_CLIENT_ID = "legacy-client" settings.SENTRY_AUTH_GOOGLE_CLIENT_ID = "modern-client" @@ -803,7 +848,7 @@ def test_google_legacy_reader_preserves_alias_with_explicit_modern_setting(setti ], ) def test_untracked_legacy_secret_alias_retains_precedence( - settings, old_name, key, setting_name + settings: types.SimpleNamespace, old_name: str, key: str, setting_name: str ) -> None: settings.SENTRY_OPTIONS = {"system.secret-key": "test-system-secret"} setattr(settings, old_name, "legacy-secret") @@ -825,7 +870,9 @@ def test_untracked_legacy_secret_alias_retains_precedence( ], ) def test_single_org_preserves_original_or_explicit_empty_app_secret( - settings, configured_options, configured_settings + settings: types.SimpleNamespace, + configured_options: dict[str, str], + configured_settings: frozenset[str], ) -> None: settings.SENTRY_SINGLE_ORGANIZATION = True settings.SENTRY_OPTIONS = configured_options @@ -880,7 +927,13 @@ def test_single_org_preserves_original_or_explicit_empty_app_secret( ) @pytest.mark.parametrize("self_hosted", [False, True]) def test_explicit_empty_or_false_deployment_alias_target_is_preserved( - settings, old_name, key, setting_name, value, legacy_value, self_hosted + settings: types.SimpleNamespace, + old_name: str, + key: str, + setting_name: str, + value: object, + legacy_value: object, + self_hosted: bool, ) -> None: settings.SENTRY_SELF_HOSTED = self_hosted settings.SENTRY_OPTIONS = {"system.secret-key": "test-system-secret"} @@ -904,7 +957,7 @@ def test_explicit_empty_or_false_deployment_alias_target_is_preserved( ], ) def test_single_org_original_app_option_precedes_modern_and_login_setting( - settings, key, setting_name, login_setting + settings: types.SimpleNamespace, key: str, setting_name: str, login_setting: str ) -> None: settings.SENTRY_SINGLE_ORGANIZATION = True settings.SENTRY_CONFIGURED_OPTION_SETTINGS = frozenset({setting_name}) @@ -929,7 +982,9 @@ def test_single_org_original_app_option_precedes_modern_and_login_setting( assert getattr(settings, login_setting) == "original-option" -def test_single_org_explicit_both_empty_app_pair_is_not_backfilled(settings) -> None: +def test_single_org_explicit_both_empty_app_pair_is_not_backfilled( + settings: types.SimpleNamespace, +) -> None: settings.SENTRY_SINGLE_ORGANIZATION = True settings.SENTRY_CONFIGURED_OPTION_SETTINGS = frozenset( {"SENTRY_GITHUB_APP_CLIENT_ID", "SENTRY_GITHUB_APP_CLIENT_SECRET"} @@ -966,7 +1021,9 @@ def test_single_org_explicit_both_empty_app_pair_is_not_backfilled(settings) -> ("", ""), ], ) -def test_single_org_owned_pair_selects_sso_including_empty(settings, app_id, app_secret) -> None: +def test_single_org_owned_pair_selects_sso_including_empty( + settings: types.SimpleNamespace, app_id: str, app_secret: str +) -> None: settings.SENTRY_SINGLE_ORGANIZATION = True settings.SENTRY_CONFIGURED_OPTION_SETTINGS = frozenset( {"SENTRY_GITHUB_APP_CLIENT_ID", "SENTRY_GITHUB_APP_CLIENT_SECRET"} @@ -984,7 +1041,9 @@ def test_single_org_owned_pair_selects_sso_including_empty(settings, app_id, app @pytest.mark.parametrize("app_secret", ["", "app-secret"]) -def test_single_org_owned_secret_selects_sso_including_empty(settings, app_secret) -> None: +def test_single_org_owned_secret_selects_sso_including_empty( + settings: types.SimpleNamespace, app_secret: str +) -> None: settings.SENTRY_SINGLE_ORGANIZATION = True settings.SENTRY_CONFIGURED_OPTION_SETTINGS = frozenset({"SENTRY_GITHUB_APP_CLIENT_SECRET"}) settings.SENTRY_GITHUB_APP_CLIENT_SECRET = app_secret @@ -995,7 +1054,7 @@ def test_single_org_owned_secret_selects_sso_including_empty(settings, app_secre assert settings.GITHUB_API_SECRET == app_secret -def test_single_org_original_secret_selects_owned_sso(settings) -> None: +def test_single_org_original_secret_selects_owned_sso(settings: types.SimpleNamespace) -> None: settings.SENTRY_SINGLE_ORGANIZATION = True settings.SENTRY_CONFIGURED_OPTION_SETTINGS = frozenset({"SENTRY_GITHUB_APP_CLIENT_SECRET"}) settings.SENTRY_GITHUB_APP_CLIENT_SECRET = "modern-secret" diff --git a/tests/sentry/scm/endpoints/test_scm_rpc.py b/tests/sentry/scm/endpoints/test_scm_rpc.py index 4f195fb49076..cf89d67bba4a 100644 --- a/tests/sentry/scm/endpoints/test_scm_rpc.py +++ b/tests/sentry/scm/endpoints/test_scm_rpc.py @@ -1,10 +1,12 @@ from __future__ import annotations import unittest.mock +from collections.abc import Mapping +from typing import Any import requests import responses -from django.test import override_settings +from django.test import Client, override_settings from django.urls import reverse from scm import Secret from scm.actions import create_branch @@ -19,13 +21,15 @@ from sentry.utils import json -def assert_coded_error(response, status_code: int, code: str): +# justification: Django test responses may be JSON or streaming responses. +def assert_coded_error(response: Any, status_code: int, code: str) -> None: assert response.headers["Content-Type"] == "application/json" assert response.status_code == status_code, response.content assert response.json()["errors"][0]["code"] == code -def assert_streaming_coded_error(response, status_code: int, code: str): +# justification: response wraps an untyped streaming test client. +def assert_streaming_coded_error(response: Any, status_code: int, code: str) -> None: assert response.headers["Content-Type"] == "application/json" assert response.status_code == status_code, response.content assert json.loads(b"".join(response.streaming_content))["errors"][0]["code"] == code @@ -40,10 +44,11 @@ class DjangoTestClientSessionAdapter: Django responses to requests.Response objects. """ - def __init__(self, client): + def __init__(self, client: Client) -> None: self._client = client - def _convert(self, django_response): + # justification: Django's test client returns a monkey-patched response wrapper. + def _convert(self, django_response: Any) -> requests.Response: resp = requests.Response() resp.status_code = django_response.status_code if hasattr(django_response, "streaming_content"): @@ -55,10 +60,16 @@ def _convert(self, django_response): resp.encoding = "utf-8" return resp - def get(self, url, headers=None): + def get(self, url: str, headers: Mapping[str, str] | None = None) -> requests.Response: return self._convert(self._client.get(url, headers=headers)) - def post(self, url, data=None, headers=None, stream=False): + def post( + self, + url: str, + data: object = None, + headers: Mapping[str, str] | None = None, + stream: bool = False, + ) -> requests.Response: # `stream` is accepted to match the Session protocol; _convert reads the whole body either way. h = dict(headers) if headers else {} content_type = h.pop("Content-Type", "application/octet-stream") @@ -102,7 +113,7 @@ def setUp(self) -> None: } @responses.activate - def test_end_to_end(self): + def test_end_to_end(self) -> None: responses.add( responses.POST, "https://api.github.com/repos/test-org/test-repo/git/refs", @@ -115,15 +126,15 @@ def test_end_to_end(self): assert result["data"]["sha"] == "123" assert len(responses.calls) == 1 - def test_does_not_retry_integration_proxy_get_requests(self): + def test_does_not_retry_integration_proxy_get_requests(self) -> None: self._assert_does_not_retry_integration_proxy_requests("GET") - def test_does_not_retry_integration_proxy_post_requests(self): + def test_does_not_retry_integration_proxy_post_requests(self) -> None: self._assert_does_not_retry_integration_proxy_requests("POST") @responses.activate @unittest.mock.patch.object(IntegrationProxyClient, "_use_proxy_url_for_tests", True) - def _assert_does_not_retry_integration_proxy_requests(self, method): + def _assert_does_not_retry_integration_proxy_requests(self, method: str) -> None: responses.add( method, "http://controlserver/api/0/internal/integration-proxy/", @@ -158,11 +169,11 @@ def _assert_does_not_retry_integration_proxy_requests(self, method): assert_streaming_coded_error(response, 503, "resource_service_unavailable") assert len(responses.calls) == 1 - def test_get(self): + def test_get(self) -> None: response = self.client.get(self.url, headers=self.default_headers) assert response.status_code == 200, response.content - def test_get_fetches_the_integration_once_per_request(self): + def test_get_fetches_the_integration_once_per_request(self) -> None: integration = self.create_integration( organization=self.organization, provider="github_enterprise", @@ -197,24 +208,24 @@ def test_get_fetches_the_integration_once_per_request(self): assert service.get_integration.call_count == 2 @override_settings(SCM_RPC_SHARED_SECRET=[]) - def test_get_no_secrets_set(self): + def test_get_no_secrets_set(self) -> None: response = self.client.get(self.url, headers=self.default_headers) assert response.status_code == 401, response.content - def test_get_invalid_headers(self): + def test_get_invalid_headers(self) -> None: assert_coded_error( self.client.get(self.url, headers={}), 400, "rpc_malformed_request_headers" ) - def test_get_missing_auth(self): + def test_get_missing_auth(self) -> None: assert_coded_error( self.client.get(self.url, headers={**self.default_headers, "Authorization": ""}), 401, "rpc_invalid_grant", ) - def test_get_invalid_secret(self): - def sig(secret): + def test_get_invalid_secret(self) -> None: + def sig(secret: str) -> str: return f"rpcsignature {sign_get(Secret(secret), self.organization.id, self.repo.id)}" # Succeeds with correct secret. @@ -228,19 +239,19 @@ def sig(secret): response = self.client.get(self.url, headers={**headers, "Authorization": sig("s")}) assert_coded_error(response, 401, "rpc_invalid_grant") - def test_post_invalid_headers(self): + def test_post_invalid_headers(self) -> None: assert_streaming_coded_error( self.client.post(self.url, headers={}), 400, "rpc_malformed_request_headers" ) - def test_post_missing_auth(self): + def test_post_missing_auth(self) -> None: assert_streaming_coded_error( self.client.post(self.url, headers={**self.default_headers, "Authorization": ""}), 401, "rpc_invalid_grant", ) - def test_post_invalid_secret(self): + def test_post_invalid_secret(self) -> None: assert_streaming_coded_error( self.client.post( self.url, diff --git a/tests/sentry/scm/integration/test_rate_limit_integration.py b/tests/sentry/scm/integration/test_rate_limit_integration.py index bf0b530e8f07..1282d3d64584 100644 --- a/tests/sentry/scm/integration/test_rate_limit_integration.py +++ b/tests/sentry/scm/integration/test_rate_limit_integration.py @@ -1,11 +1,16 @@ +from __future__ import annotations + +from typing import Any + from django.conf import settings +from redis import Redis from sentry.scm.private.rate_limit import RedisRateLimitProvider, total_limit_key, usage_count_key from sentry.testutils.cases import TestCase from sentry.utils import redis -def _client(): +def _client() -> Redis[Any]: return redis.redis_clusters.get(settings.SENTRY_SCM_REDIS_CLUSTER) 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/scm/unit/test_rate_limit.py b/tests/sentry/scm/unit/test_rate_limit.py index 73cc160c8af9..b1059abdf558 100644 --- a/tests/sentry/scm/unit/test_rate_limit.py +++ b/tests/sentry/scm/unit/test_rate_limit.py @@ -8,25 +8,27 @@ def __init__(self, get_and_set_return: tuple[int | None, int], accounted_usage: self._get_and_set_return = get_and_set_return self._accounted_usage = accounted_usage self.accounted_keys: list[str] = [] - self.set_kvs: dict = {} + self.set_kvs: dict[str, tuple[int, int | None]] = {} self.get_and_set_calls: list[tuple[str, str, int]] = [] - def get_and_set_rate_limit(self, total_key, usage_key, expiration): + def get_and_set_rate_limit( + self, total_key: str, usage_key: str, expiration: int + ) -> tuple[int | None, int]: self.get_and_set_calls.append((total_key, usage_key, expiration)) return self._get_and_set_return - def get_accounted_usage(self, keys): + def get_accounted_usage(self, keys: list[str]) -> int: self.accounted_keys.extend(keys) return self._accounted_usage - def set_key_values(self, kvs): + def set_key_values(self, kvs: dict[str, tuple[int, int | None]]) -> None: self.set_kvs.update(kvs) def make_limiter( get_and_set_return: tuple[int | None, int] = (None, 0), accounted_usage: int = 0, - referrer_allocation: dict | None = None, + referrer_allocation: dict[str, float] | None = None, get_time_in_seconds: Callable[[], int] = lambda: 73, resource_windows: dict[str, int] | None = None, ) -> tuple[DynamicRateLimiter, MockRateLimitProvider]: @@ -50,14 +52,16 @@ def __init__(self) -> None: self.capacities: dict[str, int] = {} self.usage: dict[str, int] = {} - def get_and_set_rate_limit(self, total_key, usage_key, expiration): + def get_and_set_rate_limit( + self, total_key: str, usage_key: str, expiration: int + ) -> tuple[int | None, int]: self.usage[usage_key] = self.usage.get(usage_key, 0) + 1 return (self.capacities.get(total_key), self.usage[usage_key]) - def get_accounted_usage(self, keys): + def get_accounted_usage(self, keys: list[str]) -> int: return sum(self.usage.get(key, 0) for key in keys) - def set_key_values(self, kvs): + def set_key_values(self, kvs: dict[str, tuple[int, int | None]]) -> None: for key, (value, _) in kvs.items(): self.capacities[key] = value diff --git a/tests/sentry/scm/unit/test_stream.py b/tests/sentry/scm/unit/test_stream.py index 5d10dbf8dbee..cdb19a9782dc 100644 --- a/tests/sentry/scm/unit/test_stream.py +++ b/tests/sentry/scm/unit/test_stream.py @@ -1,31 +1,38 @@ from sentry.scm.private.event_stream import SourceCodeManagerEventStream +from sentry.scm.types import ( + CheckRunEvent, + CheckSuiteEvent, + CommentEvent, + PullRequestEvent, + PullRequestReviewEvent, +) def test_listener_registration() -> None: scm = SourceCodeManagerEventStream() @scm.listen_for("check_run") - def cr_listener(event): + def cr_listener(event: CheckRunEvent) -> None: pass @scm.listen_for("comment") - def comment_listener(event): + def comment_listener(event: CommentEvent) -> None: pass @scm.listen_for("pull_request") - def pr_listener(event): + def pr_listener(event: PullRequestEvent) -> None: pass @scm.listen_for("pull_request") - def pr_listener_2(event): + def pr_listener_2(event: PullRequestEvent) -> None: pass @scm.listen_for("check_suite") - def cs_listener(event): + def cs_listener(event: CheckSuiteEvent) -> None: pass @scm.listen_for("pull_request_review") - def prr_listener(event): + def prr_listener(event: PullRequestReviewEvent) -> None: pass # Assert our scm instance had listeners registered to it. diff --git a/tests/sentry/scm/unit/test_stream_producer.py b/tests/sentry/scm/unit/test_stream_producer.py index bb0e86a13e05..a482295db2c5 100644 --- a/tests/sentry/scm/unit/test_stream_producer.py +++ b/tests/sentry/scm/unit/test_stream_producer.py @@ -6,9 +6,9 @@ def test_produce_to_scm_stream() -> None: - metrics = [] + metrics: list[tuple[str, int, dict[str, str]]] = [] - def record_count(k, a, t): + def record_count(k: str, a: int, t: dict[str, str]) -> None: metrics.append((k, a, t)) event: SubscriptionEvent = { @@ -29,9 +29,9 @@ def record_count(k, a, t): def test_produce_to_scm_stream_unsupported_provider() -> None: - metrics = [] + metrics: list[tuple[str, int, dict[str, str]]] = [] - def record_count(k, a, t): + def record_count(k: str, a: int, t: dict[str, str]) -> None: metrics.append((k, a, t)) event: SubscriptionEvent = { @@ -58,13 +58,13 @@ def record_count(k, a, t): def test_produce_to_scm_stream_invalid_payload() -> None: - metrics = [] - reported_exception = None + metrics: list[tuple[str, int, dict[str, str]]] = [] + reported_exception: Exception | None = None - def record_count(k, a, t): + def record_count(k: str, a: int, t: dict[str, str]) -> None: metrics.append((k, a, t)) - def report_error(e): + def report_error(e: Exception) -> None: nonlocal reported_exception reported_exception = e diff --git a/tests/sentry/search/eap/test_ourlogs.py b/tests/sentry/search/eap/test_ourlogs.py index 834a359fe52e..610a94c5d4a4 100644 --- a/tests/sentry/search/eap/test_ourlogs.py +++ b/tests/sentry/search/eap/test_ourlogs.py @@ -1,3 +1,4 @@ +from typing import Any from unittest import TestCase import pytest @@ -630,7 +631,7 @@ def test_count_default_argument() -> None: ("min", Function.FUNCTION_MIN), ], ) -def test_monoid_functions(function_name, proto_function) -> None: +def test_monoid_functions(function_name: str, proto_function: Function.ValueType) -> None: resolver = SearchResolver( params=SnubaParams(), config=SearchResolverConfig(), definitions=OURLOG_DEFINITIONS ) @@ -671,7 +672,7 @@ def test_monoid_functions(function_name, proto_function) -> None: }, ], ) -def test_attribute_search(test_case) -> None: +def test_attribute_search(test_case: dict[str, Any]) -> None: attribute_definition = test_case["attribute_definition"] search_term = test_case["search_term"] expected_value = test_case["expected_value"] diff --git a/tests/sentry/search/eap/test_search_executor.py b/tests/sentry/search/eap/test_search_executor.py index 8d4a14cda12b..944d51df2cf8 100644 --- a/tests/sentry/search/eap/test_search_executor.py +++ b/tests/sentry/search/eap/test_search_executor.py @@ -10,7 +10,7 @@ class TestSearchFiltersToQueryString: - def test_all_operator_types(self): + def test_all_operator_types(self) -> None: cases = [ (SearchFilter(SearchKey("level"), "=", SearchValue("error")), "level:error"), (SearchFilter(SearchKey("level"), "!=", SearchValue("error")), "!level:error"), @@ -44,7 +44,7 @@ def test_all_operator_types(self): f"Failed for operator {sf.operator}" ) - def test_value_formatting(self): + def test_value_formatting(self) -> None: dt = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) cases = [ # Wildcards pass through as-is @@ -89,7 +89,7 @@ def test_value_formatting(self): for sf, expected in cases: assert search_filters_to_query_string([sf]) == expected - def test_has_and_not_has_filters(self): + def test_has_and_not_has_filters(self) -> None: # has:user.email → parsed as op=!=, value="" has_filter = SearchFilter(SearchKey("user.email"), "!=", SearchValue("")) assert search_filters_to_query_string([has_filter]) == "has:user.email" @@ -98,7 +98,7 @@ def test_has_and_not_has_filters(self): not_has_filter = SearchFilter(SearchKey("user.email"), "=", SearchValue("")) assert search_filters_to_query_string([not_has_filter]) == "!has:user.email" - def test_skipped_filters_are_dropped(self): + def test_skipped_filters_are_dropped(self) -> None: filters = [ SearchFilter(SearchKey("event.type"), "=", SearchValue("error")), SearchFilter(SearchKey("release.stage"), "=", SearchValue("adopted")), @@ -111,7 +111,7 @@ def test_skipped_filters_are_dropped(self): ] assert search_filters_to_query_string(filters) == "" - def test_aggregation_filters_translated(self): + def test_aggregation_filters_translated(self) -> None: dt = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) cases = [ ( @@ -136,7 +136,7 @@ def test_aggregation_filters_translated(self): f"Failed for {sf.key.name}:{sf.operator}{sf.value.raw_value}" ) - def test_error_unhandled_translation(self): + def test_error_unhandled_translation(self) -> None: # error.unhandled:1 → looking for unhandled → !error.handled:1 assert ( search_filters_to_query_string( @@ -159,11 +159,11 @@ def test_error_unhandled_translation(self): == "error.handled:1" ) - def test_error_main_thread_key_translated(self): + def test_error_main_thread_key_translated(self) -> None: filters = [SearchFilter(SearchKey("error.main_thread"), "=", SearchValue("1"))] assert search_filters_to_query_string(filters) == "exception_main_thread:1" - def test_realistic_mixed_query(self): + def test_realistic_mixed_query(self) -> None: filters = [ SearchFilter(SearchKey("level"), "=", SearchValue("error")), SearchFilter(SearchKey("error.unhandled"), "=", SearchValue("1")), diff --git a/tests/sentry/search/events/builder/test_metrics.py b/tests/sentry/search/events/builder/test_metrics.py index 10d7e4eead64..07aa0c4387cc 100644 --- a/tests/sentry/search/events/builder/test_metrics.py +++ b/tests/sentry/search/events/builder/test_metrics.py @@ -66,7 +66,7 @@ def test_limit_validation(self) -> None: def test_granularity(self) -> None: # Need to pick granularity based on the period - def get_granularity(start, end): + def get_granularity(start: datetime.datetime, end: datetime.datetime) -> int: params = { "organization_id": self.organization.id, "project_id": self.projects, @@ -74,7 +74,7 @@ def get_granularity(start, end): "end": end, } query = MetricsQueryBuilder(params) - return query.granularity.granularity + return int(query.granularity.granularity) # If we're doing atleast day and its midnight we should use the daily bucket start = datetime.datetime(2015, 5, 18, 0, 0, 0, tzinfo=timezone.utc) @@ -129,7 +129,7 @@ def get_granularity(start, end): def test_granularity_boundaries(self) -> None: # Need to pick granularity based on the period - def get_granularity(start, end): + def get_granularity(start: datetime.datetime, end: datetime.datetime) -> int: params = { "organization_id": self.organization.id, "project_id": self.projects, @@ -137,7 +137,7 @@ def get_granularity(start, end): "end": end, } query = MetricsQueryBuilder(params) - return query.granularity.granularity + return int(query.granularity.granularity) # See resolve_granularity on the MQB to see what these boundaries are @@ -210,7 +210,7 @@ def test_missing_function(self) -> None: class TimeseriesMetricQueryBuilderTest(MetricBuilderBaseTest): def test_granularity(self) -> None: # Need to pick granularity based on the period and interval for timeseries - def get_granularity(start, end, interval): + def get_granularity(start: datetime.datetime, end: datetime.datetime, interval: int) -> int: query = TimeseriesMetricQueryBuilder( { "organization_id": self.organization.id, @@ -220,7 +220,7 @@ def get_granularity(start, end, interval): }, interval=interval, ) - return query.granularity.granularity + return int(query.granularity.granularity) # If we're doing atleast day and its midnight we should use the daily bucket start = datetime.datetime(2015, 5, 18, 0, 0, 0, tzinfo=timezone.utc) diff --git a/tests/sentry/search/events/builder/test_profile_functions.py b/tests/sentry/search/events/builder/test_profile_functions.py index 3eb0b1a806e7..d935da0df37a 100644 --- a/tests/sentry/search/events/builder/test_profile_functions.py +++ b/tests/sentry/search/events/builder/test_profile_functions.py @@ -6,23 +6,24 @@ from snuba_sdk.function import Function from sentry.search.events.builder.profile_functions import ProfileFunctionsQueryBuilder +from sentry.search.events.types import ParamsType from sentry.snuba.dataset import Dataset from sentry.testutils.factories import Factories from sentry.testutils.pytest.fixtures import django_db_all @pytest.fixture -def now(): +def now() -> datetime: return datetime(2022, 10, 31, 0, 0, tzinfo=timezone.utc) @pytest.fixture -def today(now): +def today(now: datetime) -> datetime: return now.replace(hour=0, minute=0, second=0, microsecond=0) @pytest.fixture -def params(now, today): +def params(now: datetime, today: datetime) -> ParamsType: organization = Factories.create_organization() team = Factories.create_team(organization=organization) project1 = Factories.create_project(organization=organization, teams=[team]) @@ -79,7 +80,7 @@ def params(now, today): ], ) @django_db_all -def test_where(params, search, condition) -> None: +def test_where(params: ParamsType, search: str, condition: Condition) -> None: builder = ProfileFunctionsQueryBuilder( Dataset.Functions, params, diff --git a/tests/sentry/search/events/test_fields.py b/tests/sentry/search/events/test_fields.py index 98b8fc388da2..33eafd62bbad 100644 --- a/tests/sentry/search/events/test_fields.py +++ b/tests/sentry/search/events/test_fields.py @@ -8,6 +8,7 @@ from sentry.search.events.fields import ( COMBINATORS, FUNCTIONS, + Combinator, FunctionDetails, get_json_meta_type, parse_arguments, @@ -15,6 +16,7 @@ parse_function, resolve_datetime64, ) +from sentry.search.events.types import SelectType from sentry.snuba.dataset import Dataset @@ -110,9 +112,12 @@ ), ], ) -def test_get_json_meta_type(field_alias, snuba_type, function, expected) -> None: +def test_get_json_meta_type( + field_alias: str, snuba_type: str, function: FunctionDetails | None, expected: str +) -> None: qb = UnresolvedQuery(dataset=Dataset.Discover, params={}) - qb.function_alias_map[field_alias] = function + if function is not None: + qb.function_alias_map[field_alias] = function assert get_json_meta_type(field_alias, snuba_type, qb) == expected, field_alias @@ -191,7 +196,7 @@ def test_get_json_meta_type(field_alias, snuba_type, function, expected) -> None ), ], ) -def test_parse_function(function, expected) -> None: +def test_parse_function(function: str, expected: tuple[str, list[str], str | None]) -> None: assert parse_function(function) == expected @@ -210,7 +215,7 @@ def test_parse_function(function, expected) -> None: ("count_if", 'a, b,","', ["a", "b", '","']), ], ) -def test_parse_arguments(function, columns, result) -> None: +def test_parse_arguments(function: str, columns: str, result: list[str]) -> None: assert parse_arguments(function, columns) == result @@ -223,11 +228,11 @@ def test_parse_arguments(function, columns, result) -> None: pytest.param("func_array", ("func_array", None), id="does not accept snake case"), ], ) -def test_parse_combinator(function, expected) -> None: +def test_parse_combinator(function: str, expected: tuple[str, str | None]) -> None: assert parse_combinator(function) == expected -def resolve_snql_fieldlist(fields): +def resolve_snql_fieldlist(fields: list[str]) -> list[SelectType]: return UnresolvedQuery(Dataset.Discover, {}).resolve_select(fields, []) @@ -291,16 +296,17 @@ def resolve_snql_fieldlist(fields): ), ], ) -def test_range_funtions(field, expected) -> None: +def test_range_funtions(field: str, expected: SelectType) -> None: fields = resolve_snql_fieldlist([field]) assert len(fields) == 1 assert fields[0] == expected @pytest.mark.parametrize("combinator", COMBINATORS) -def test_combinator_names_are_reserved(combinator) -> None: +def test_combinator_names_are_reserved(combinator: Combinator) -> None: fields = UnresolvedQuery(dataset=Dataset.Discover, params={}) for function in fields.function_converter: + assert combinator.kind is not None assert not function.endswith(combinator.kind), ( f"Cannot name function `{function}` because `-{combinator.kind}` suffix is reserved for combinators" ) @@ -344,5 +350,7 @@ def test_combinator_names_are_reserved(combinator) -> None: ), ], ) -def test_resolve_datetime64(value, expected) -> None: +def test_resolve_datetime64( + value: datetime | str | float | None, expected: Function | None +) -> None: assert resolve_datetime64(value) == expected diff --git a/tests/sentry/search/events/test_filter.py b/tests/sentry/search/events/test_filter.py index b0a9760c86eb..13be9b1bcb99 100644 --- a/tests/sentry/search/events/test_filter.py +++ b/tests/sentry/search/events/test_filter.py @@ -1,5 +1,6 @@ import re import unittest +from typing import Any from unittest.mock import patch import pytest @@ -33,7 +34,7 @@ from sentry.testutils.cases import TestCase -def with_type(type, argument): +def with_type(type: Any, argument: Any) -> Any: argument.get_type = lambda *_: type return argument @@ -132,18 +133,20 @@ def test_default_result_type(self) -> None: def test_result_type_fn(self) -> None: fn = DiscoverFunction("fn", transform="", result_type_fn=lambda *_: None) - assert fn.get_result_type("fn()", []) is None + assert fn.get_result_type("fn()", {}) is None fn = DiscoverFunction("fn", transform="", result_type_fn=lambda *_: "number") - assert fn.get_result_type("fn()", []) == "number" + assert fn.get_result_type("fn()", {}) == "number" fn = DiscoverFunction( "fn", required_args=[with_type("number", FunctionArg("arg1"))], transform="", - result_type_fn=lambda args, columns: args[0].get_type(columns[0]), + result_type_fn=lambda args, columns: args[0].get_type( + str(next(iter(columns.values()))) + ), ) - assert fn.get_result_type("fn()", ["arg1"]) == "number" + assert fn.get_result_type("fn()", {"arg1": "arg1"}) == "number" def test_private_function(self) -> None: fn = DiscoverFunction("fn", transform="", result_type_fn=lambda *_: None, private=True) @@ -157,21 +160,21 @@ def test_private_function(self) -> None: class BaseSemverConverterTest(TestCase): key: str - def converter(self, *args, **kwargs): + def converter(self, *args: Any, **kwargs: Any) -> Any: raise NotImplementedError def run_test( self, - operator, - version, - expected_operator, - expected_releases, - organization_id=None, - project_id=None, - ): + operator: str, + version: str | list[str], + expected_operator: str, + expected_releases: list[str], + organization_id: int | None = None, + project_id: int | list[int] | None = None, + ) -> None: organization_id = organization_id if organization_id else self.organization.id filter = SearchFilter(SearchKey(self.key), operator, SearchValue(version)) - params = {} + params: dict[str, int | list[int]] = {} if organization_id: params["organization_id"] = organization_id if project_id: @@ -185,7 +188,7 @@ def run_test( class SemverFilterConverterTest(BaseSemverConverterTest): key = SEMVER_ALIAS - def converter(self, *args, **kwargs): + def converter(self, *args: Any, **kwargs: Any) -> Any: return _semver_filter_converter(*args, **kwargs) def test_invalid_params(self) -> None: @@ -345,7 +348,7 @@ def test_projects(self) -> None: class SemverPackageFilterConverterTest(BaseSemverConverterTest): key = SEMVER_PACKAGE_ALIAS - def converter(self, *args, **kwargs): + def converter(self, *args: Any, **kwargs: Any) -> Any: return _semver_package_filter_converter(*args, **kwargs) def test_invalid_params(self) -> None: @@ -395,7 +398,7 @@ def test_in(self) -> None: class SemverBuildFilterConverterTest(BaseSemverConverterTest): key = SEMVER_BUILD_ALIAS - def converter(self, *args, **kwargs): + def converter(self, *args: Any, **kwargs: Any) -> Any: return _semver_build_filter_converter(*args, **kwargs) def test_invalid_params(self) -> None: @@ -426,7 +429,7 @@ def test(self) -> None: class ParseSemverTest(unittest.TestCase): - def run_test(self, version: str, operator: str, expected: SemverFilter): + def run_test(self, version: str, operator: str, expected: SemverFilter) -> None: semver_filter = parse_semver(version, operator) assert semver_filter == expected @@ -464,36 +467,36 @@ def test_wildcard(self) -> None: self.run_test("1.X", "=", SemverFilter("exact", [1])) -def _cond(lhs, op, rhs): +def _cond(lhs: str, op: str | Op, rhs: Any) -> Condition: return Condition(lhs=Column(name=lhs), op=op, rhs=rhs) -def _email(x): +def _email(x: str) -> Condition: return _cond("email", Op.EQ, x) -def _message(x): +def _message(x: str) -> Condition: return Condition( lhs=Function("positionCaseInsensitive", [Column("message"), x]), op=Op.NEQ, rhs=0 ) -def _tag(key, value, op=None): +def _tag(key: str, value: Any, op: str | Op | None = None) -> Condition: if op is None: op = Op.IN if isinstance(value, list) else Op.EQ return Condition(lhs=Function("ifNull", [Column(f"tags[{key}]"), ""]), op=op, rhs=value) -def _ntag(key, value): +def _ntag(key: str, value: Any) -> Condition: op = Op.NOT_IN if isinstance(value, list) else Op.NEQ return _tag(key, value, op=op) -def _count(op, x): +def _count(op: str | Op, x: Any) -> Condition: return Condition(lhs=Function("count", [], "count"), op=op, rhs=x) -def _project(x): +def _project(x: Any) -> Condition: return _cond("project_id", Op.EQ, x) @@ -1174,7 +1177,9 @@ def _project(x): ), ], ) -def test_snql_boolean_search(description, query, expected_where, expected_having) -> None: +def test_snql_boolean_search( + description: str, query: str, expected_where: list[Any], expected_having: list[Any] +) -> None: dataset = Dataset.Discover params: ParamsType = {"project_id": [1]} query_filter = UnresolvedQuery( diff --git a/tests/sentry/search/grammar/test_sql_grammar.py b/tests/sentry/search/grammar/test_sql_grammar.py index fa6ecb534409..6c9a7b2ea951 100644 --- a/tests/sentry/search/grammar/test_sql_grammar.py +++ b/tests/sentry/search/grammar/test_sql_grammar.py @@ -35,7 +35,7 @@ ), ], ) -def test_search_filter_to_query_string(query, expected) -> None: +def test_search_filter_to_query_string(query: str, expected: ParsedQuery) -> None: parsed_query = parse_sql_query(query) assert parsed_query == expected, parsed_query diff --git a/tests/sentry/search/test_utils.py b/tests/sentry/search/test_utils.py index fce8b17b3aca..ffd7bcc8c2ac 100644 --- a/tests/sentry/search/test_utils.py +++ b/tests/sentry/search/test_utils.py @@ -1,4 +1,5 @@ from datetime import UTC, datetime, timedelta +from typing import Any import pytest from django.utils import timezone @@ -171,16 +172,16 @@ def test_get_numeric_field_value_invalid() -> None: class ParseQueryTest(APITestCase, SnubaTestCase): @property - def rpc_user(self): + def rpc_user(self) -> RpcUser | None: return user_service.get_user(user_id=self.user.id) @property - def current_rpc_user(self): + def current_rpc_user(self) -> RpcUser: # This doesn't include useremails. Used in filters # where the current user is passed back return serialize_rpc_user(self.user) - def parse_query(self, query): + def parse_query(self, query: str) -> dict[str, Any]: return parse_query([self.project], query, self.user, []) def test_simple(self) -> None: diff --git a/tests/sentry/seer/agent/test_agent_client.py b/tests/sentry/seer/agent/test_agent_client.py index 9d3557b5074b..f4663b4cc670 100644 --- a/tests/sentry/seer/agent/test_agent_client.py +++ b/tests/sentry/seer/agent/test_agent_client.py @@ -1,4 +1,5 @@ from datetime import timedelta +from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest @@ -44,7 +45,7 @@ def _mock_run_response(self, run_id: int = 123) -> MagicMock: return mock_response @patch("sentry.seer.agent.client.has_seer_access_with_detail") - def test_client_init_checks_access(self, mock_access): + def test_client_init_checks_access(self, mock_access: MagicMock) -> None: """Test that client initialization checks base Seer access and raises on denial""" mock_access.return_value = (False, "Feature flag not enabled") @@ -53,7 +54,7 @@ def test_client_init_checks_access(self, mock_access): assert "Feature flag not enabled" in str(exc_info.value) @patch("sentry.seer.agent.client.has_seer_access_with_detail") - def test_client_init_succeeds_with_access(self, mock_access): + def test_client_init_succeeds_with_access(self, mock_access: MagicMock) -> None: """Test that client initialization succeeds with proper access""" mock_access.return_value = (True, None) @@ -62,7 +63,7 @@ def test_client_init_succeeds_with_access(self, mock_access): assert client.user == self.user @patch("sentry.seer.agent.client.has_seer_access_with_detail") - def test_client_init_raises_when_coding_option_disabled(self, mock_access): + def test_client_init_raises_when_coding_option_disabled(self, mock_access: MagicMock) -> None: """Test that client initialization raises SeerPermissionError when enable_coding is True but org option is disabled""" mock_access.return_value = (True, None) self.organization.update_option("sentry:enable_seer_coding", False) @@ -71,7 +72,7 @@ def test_client_init_raises_when_coding_option_disabled(self, mock_access): SeerAgentClient(self.organization, self.user, enable_coding=True) @patch("sentry.seer.agent.client.has_seer_access_with_detail") - def test_client_init_succeeds_when_coding_option_not_set(self, mock_access): + def test_client_init_succeeds_when_coding_option_not_set(self, mock_access: MagicMock) -> None: """Test that client initialization succeeds when enable_coding is True and org option is not set (interpreted as True by default)""" mock_access.return_value = (True, None) client = SeerAgentClient(self.organization, self.user, enable_coding=True) @@ -80,7 +81,9 @@ def test_client_init_succeeds_when_coding_option_not_set(self, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") - def test_start_run_basic(self, mock_collect_context, mock_post, mock_access): + def test_start_run_basic( + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """Test starting a new run collects user context""" mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} @@ -107,7 +110,9 @@ def test_start_run_basic(self, mock_collect_context, mock_post, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @with_feature("organizations:seer-agent-enable-assisted-query-code-mode") - def test_start_run_enables_assisted_query_code_mode(self, mock_post, mock_access): + def test_start_run_enables_assisted_query_code_mode( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response() @@ -120,7 +125,9 @@ def test_start_run_enables_assisted_query_code_mode(self, mock_post, mock_access @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @with_feature("organizations:investigations") - def test_start_run_enables_investigation_tools(self, mock_post, mock_access): + def test_start_run_enables_investigation_tools( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response() self.organization.flags.allow_joinleave = True @@ -134,7 +141,9 @@ def test_start_run_enables_investigation_tools(self, mock_post, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") - def test_start_run_withholds_investigation_tools_without_feature(self, mock_post, mock_access): + def test_start_run_withholds_investigation_tools_without_feature( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response() self.organization.flags.allow_joinleave = True @@ -150,8 +159,8 @@ def test_start_run_withholds_investigation_tools_without_feature(self, mock_post @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @with_feature("organizations:investigations") def test_start_run_withholds_investigation_tools_without_open_membership( - self, mock_post, mock_access - ): + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response() self.organization.flags.allow_joinleave = False @@ -166,7 +175,9 @@ def test_start_run_withholds_investigation_tools_without_open_membership( @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @with_feature("organizations:investigations") - def test_start_run_investigation_tools_are_opt_in(self, mock_post, mock_access): + def test_start_run_investigation_tools_are_opt_in( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response() self.organization.flags.allow_joinleave = True @@ -181,7 +192,9 @@ def test_start_run_investigation_tools_are_opt_in(self, mock_post, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @override_options({"seer.explorer.context-engine-rollout": 1.0}) - def test_start_run_context_engine_follows_rollout(self, mock_post, mock_access): + def test_start_run_context_engine_follows_rollout( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response() @@ -194,7 +207,9 @@ def test_start_run_context_engine_follows_rollout(self, mock_post, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @override_options({"seer.explorer.context-engine-rollout": 1.0}) - def test_start_run_force_ce_overrides_rollout(self, mock_post, mock_access): + def test_start_run_force_ce_overrides_rollout( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """force_ce=False keeps the context engine off even at full rollout.""" mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response() @@ -208,7 +223,9 @@ def test_start_run_force_ce_overrides_rollout(self, mock_post, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") - def test_start_run_with_request(self, mock_collect_context, mock_post, mock_access): + def test_start_run_with_request( + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """Test starting a new run passes request object to collect_user_org_context""" mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} @@ -224,7 +241,9 @@ def test_start_run_with_request(self, mock_collect_context, mock_post, mock_acce @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") - def test_start_run_with_optional_params(self, mock_post, mock_access): + def test_start_run_with_optional_params( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """Test starting a run with optional parameters""" mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response(run_id=789) @@ -238,7 +257,7 @@ def test_start_run_with_optional_params(self, mock_post, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") - def test_start_run_http_error(self, mock_post, mock_access): + def test_start_run_http_error(self, mock_post: MagicMock, mock_access: MagicMock) -> None: """Test that HTTP errors are propagated""" mock_access.return_value = (True, None) mock_post.return_value.status = 500 @@ -249,7 +268,9 @@ def test_start_run_http_error(self, mock_post, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") - def test_start_run_raises_when_seer_rejects_creation(self, mock_post, mock_access): + def test_start_run_raises_when_seer_rejects_creation( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """A 4xx from Seer marks the run FAILED during the synchronous drain, so start_run raises instead of returning an unmirrored run.""" mock_access.return_value = (True, None) @@ -262,7 +283,9 @@ def test_start_run_raises_when_seer_rejects_creation(self, mock_post, mock_acces @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") - def test_start_run_with_categories(self, mock_collect_context, mock_post, mock_access): + def test_start_run_with_categories( + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """Test starting a run with category fields""" mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} @@ -284,8 +307,8 @@ def test_start_run_with_categories(self, mock_collect_context, mock_post, mock_a @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") def test_start_run_defaults_code_review_disabled( - self, mock_collect_context, mock_post, mock_access - ): + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} mock_post.return_value = self._mock_run_response() @@ -300,8 +323,8 @@ def test_start_run_defaults_code_review_disabled( @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") def test_start_run_passes_code_review_enabled( - self, mock_collect_context, mock_post, mock_access - ): + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} mock_post.return_value = self._mock_run_response() @@ -313,7 +336,9 @@ def test_start_run_passes_code_review_enabled( assert body["agent_run_options"]["code_review_enabled"] is True @patch("sentry.seer.agent.client.has_seer_access_with_detail") - def test_client_init_leaves_pr_ctx_tools_gate_to_the_caller(self, mock_access): + def test_client_init_leaves_pr_ctx_tools_gate_to_the_caller( + self, mock_access: MagicMock + ) -> None: """No iteration flag is set: the client still honours what it was passed.""" mock_access.return_value = (True, None) @@ -324,8 +349,8 @@ def test_client_init_leaves_pr_ctx_tools_gate_to_the_caller(self, mock_access): @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") def test_start_run_defaults_pr_context_tools_disabled( - self, mock_collect_context, mock_post, mock_access - ): + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} mock_post.return_value = self._mock_run_response() @@ -340,8 +365,8 @@ def test_start_run_defaults_pr_context_tools_disabled( @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") def test_start_run_passes_enable_pr_context_tools( - self, mock_collect_context, mock_post, mock_access - ): + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} mock_post.return_value = self._mock_run_response() @@ -357,8 +382,8 @@ def test_start_run_passes_enable_pr_context_tools( @patch("sentry.seer.agent.client.collect_user_org_context") @with_feature("organizations:seer-explorer-embeds") def test_start_run_includes_embed_widgets_by_default( - self, mock_collect_context, mock_post, mock_access - ): + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} mock_post.return_value = self._mock_run_response() @@ -374,8 +399,8 @@ def test_start_run_includes_embed_widgets_by_default( @patch("sentry.seer.agent.client.collect_user_org_context") @with_feature("organizations:seer-explorer-embeds") def test_start_run_excludes_embed_widgets_when_disabled( - self, mock_collect_context, mock_post, mock_access - ): + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} mock_post.return_value = self._mock_run_response() @@ -390,8 +415,8 @@ def test_start_run_excludes_embed_widgets_when_disabled( @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") def test_code_mode_grants_embed_widgets_without_the_embeds_flag( - self, mock_collect_context, mock_post, mock_access - ): + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """Code Mode ships the embed surface, so a run using it renders widgets whether or not the org holds the embeds flag. Gating on the flag alone would leave those runs emitting plain text where the rest of the product shows a widget.""" @@ -409,8 +434,8 @@ def test_code_mode_grants_embed_widgets_without_the_embeds_flag( @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") def test_no_embed_widgets_without_code_mode_or_the_flag( - self, mock_collect_context, mock_post, mock_access - ): + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} mock_post.return_value = self._mock_run_response() @@ -425,8 +450,8 @@ def test_no_embed_widgets_without_code_mode_or_the_flag( @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") def test_enable_embeds_false_still_wins_over_code_mode( - self, mock_collect_context, mock_post, mock_access - ): + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """`enable_embeds=False` is the hard opt-out for surfaces that cannot render Markdoc, such as Slack, where the tags would leak as raw text.""" mock_access.return_value = (True, None) @@ -447,7 +472,9 @@ def test_enable_embeds_false_still_wins_over_code_mode( @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") @with_feature("organizations:seer-explorer-embeds") - def test_continue_run_includes_embed_widgets_by_default(self, mock_post, mock_access): + def test_continue_run_includes_embed_widgets_by_default( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response() self.create_seer_run(organization=self.organization, seer_run_state_id=123) @@ -461,7 +488,9 @@ def test_continue_run_includes_embed_widgets_by_default(self, mock_post, mock_ac @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") @with_feature("organizations:seer-explorer-embeds") - def test_continue_run_excludes_embed_widgets_when_disabled(self, mock_post, mock_access): + def test_continue_run_excludes_embed_widgets_when_disabled( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response() self.create_seer_run(organization=self.organization, seer_run_state_id=123) @@ -473,7 +502,7 @@ def test_continue_run_excludes_embed_widgets_when_disabled(self, mock_post, mock assert "embed_widgets" not in body["agent_run_options"] @patch("sentry.seer.agent.client.has_seer_access_with_detail") - def test_init_category_key_only_raises_error(self, mock_access): + def test_init_category_key_only_raises_error(self, mock_access: MagicMock) -> None: """Test that ValueError is raised when only category_key is provided""" mock_access.return_value = (True, None) @@ -483,7 +512,7 @@ def test_init_category_key_only_raises_error(self, mock_access): SeerAgentClient(self.organization, self.user, category_key="bug-fixer") @patch("sentry.seer.agent.client.has_seer_access_with_detail") - def test_init_category_value_only_raises_error(self, mock_access): + def test_init_category_value_only_raises_error(self, mock_access: MagicMock) -> None: """Test that ValueError is raised when only category_value is provided""" mock_access.return_value = (True, None) @@ -493,7 +522,7 @@ def test_init_category_value_only_raises_error(self, mock_access): SeerAgentClient(self.organization, self.user, category_value="issue-123") @patch("sentry.seer.agent.client.has_seer_access_with_detail") - def test_client_init_with_intelligence_level(self, mock_access): + def test_client_init_with_intelligence_level(self, mock_access: MagicMock) -> None: """Test that intelligence_level is stored""" mock_access.return_value = (True, None) @@ -501,7 +530,7 @@ def test_client_init_with_intelligence_level(self, mock_access): assert client.intelligence_level == "high" @patch("sentry.seer.agent.client.has_seer_access_with_detail") - def test_client_init_default_intelligence_level(self, mock_access): + def test_client_init_default_intelligence_level(self, mock_access: MagicMock) -> None: """Test that intelligence_level defaults to 'medium'""" mock_access.return_value = (True, None) @@ -512,8 +541,8 @@ def test_client_init_default_intelligence_level(self, mock_access): @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") def test_start_run_includes_intelligence_level( - self, mock_collect_context, mock_post, mock_access - ): + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """Test that intelligence_level is included in the payload""" mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} @@ -527,7 +556,7 @@ def test_start_run_includes_intelligence_level( assert body["intelligence_level"] == "low" @patch("sentry.seer.agent.client.has_seer_access_with_detail") - def test_client_init_with_max_iterations(self, mock_access): + def test_client_init_with_max_iterations(self, mock_access: MagicMock) -> None: """Test that max_iterations is stored""" mock_access.return_value = (True, None) @@ -535,7 +564,7 @@ def test_client_init_with_max_iterations(self, mock_access): assert client.max_iterations == 3 @patch("sentry.seer.agent.client.has_seer_access_with_detail") - def test_client_init_default_max_iterations(self, mock_access): + def test_client_init_default_max_iterations(self, mock_access: MagicMock) -> None: """Test that max_iterations defaults to None""" mock_access.return_value = (True, None) @@ -545,7 +574,9 @@ def test_client_init_default_max_iterations(self, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") - def test_start_run_includes_max_iterations(self, mock_collect_context, mock_post, mock_access): + def test_start_run_includes_max_iterations( + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """Test that max_iterations is included in the payload when set""" mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} @@ -562,8 +593,8 @@ def test_start_run_includes_max_iterations(self, mock_collect_context, mock_post @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") def test_start_run_excludes_max_iterations_when_none( - self, mock_collect_context, mock_post, mock_access - ): + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """Test that max_iterations is not included in the payload when None""" mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} @@ -578,7 +609,7 @@ def test_start_run_excludes_max_iterations_when_none( @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") - def test_continue_run_basic(self, mock_post, mock_access): + def test_continue_run_basic(self, mock_post: MagicMock, mock_access: MagicMock) -> None: """Test continuing an existing run""" mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response(run_id=456) @@ -596,7 +627,9 @@ def test_continue_run_basic(self, mock_post, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") @with_feature("organizations:seer-agent-enable-assisted-query-code-mode") - def test_continue_run_enables_assisted_query_code_mode(self, mock_post, mock_access): + def test_continue_run_enables_assisted_query_code_mode( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response(run_id=456) self.create_seer_run(organization=self.organization, seer_run_state_id=456) @@ -612,8 +645,12 @@ def test_continue_run_enables_assisted_query_code_mode(self, mock_post, mock_acc @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") def test_continue_run_attaches_org_connections_without_user( - self, mock_post, mock_access, mock_conns, mock_available - ): + self, + mock_post: MagicMock, + mock_access: MagicMock, + mock_conns: MagicMock, + mock_available: MagicMock, + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response(run_id=1) mock_conns.return_value = [ @@ -638,7 +675,9 @@ def test_continue_run_attaches_org_connections_without_user( @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") - def test_continue_run_with_all_params(self, mock_post, mock_access): + def test_continue_run_with_all_params( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """Test continuing a run with all optional parameters""" mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response(run_id=789) @@ -654,7 +693,9 @@ def test_continue_run_with_all_params(self, mock_post, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") - def test_continue_run_passes_enable_pr_context_tools(self, mock_post, mock_access): + def test_continue_run_passes_enable_pr_context_tools( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response(run_id=789) self.create_seer_run(organization=self.organization, seer_run_state_id=789) @@ -668,7 +709,9 @@ def test_continue_run_passes_enable_pr_context_tools(self, mock_post, mock_acces @with_feature("organizations:seer-explorer-allow-bash-mode") @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") - def test_continue_run_passes_write_pull_request_and_bash_mode(self, mock_post, mock_access): + def test_continue_run_passes_write_pull_request_and_bash_mode( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response(run_id=789) self.create_seer_run(organization=self.organization, seer_run_state_id=789) @@ -689,7 +732,9 @@ def test_continue_run_passes_write_pull_request_and_bash_mode(self, mock_post, m @with_feature("organizations:investigations") @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") - def test_continue_run_passes_investigation_tools(self, mock_post, mock_access): + def test_continue_run_passes_investigation_tools( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response(run_id=789) self.create_seer_run(organization=self.organization, seer_run_state_id=789) @@ -704,7 +749,7 @@ def test_continue_run_passes_investigation_tools(self, mock_post, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") - def test_continue_run_http_error(self, mock_post, mock_access): + def test_continue_run_http_error(self, mock_post: MagicMock, mock_access: MagicMock) -> None: """Test that HTTP errors are propagated""" mock_access.return_value = (True, None) mock_post.return_value.status = 500 @@ -716,7 +761,9 @@ def test_continue_run_http_error(self, mock_post, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") - def test_continue_run_missing_mirror_raises(self, mock_post, mock_access): + def test_continue_run_missing_mirror_raises( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response(run_id=999) @@ -729,7 +776,9 @@ def test_continue_run_missing_mirror_raises(self, mock_post, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") - def test_continue_run_bumps_last_triggered_at(self, mock_post, mock_access): + def test_continue_run_bumps_last_triggered_at( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_post.return_value = self._mock_run_response(run_id=456) @@ -744,7 +793,7 @@ def test_continue_run_bumps_last_triggered_at(self, mock_post, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.fetch_run_status") - def test_get_run_immediate(self, mock_fetch, mock_access): + def test_get_run_immediate(self, mock_fetch: MagicMock, mock_access: MagicMock) -> None: """Test getting run status without waiting""" mock_access.return_value = (True, None) mock_state = SeerRunState( @@ -766,7 +815,7 @@ def test_get_run_immediate(self, mock_fetch, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.poll_until_done") - def test_get_run_with_blocking(self, mock_poll, mock_access): + def test_get_run_with_blocking(self, mock_poll: MagicMock, mock_access: MagicMock) -> None: """Test getting run status with polling""" mock_access.return_value = (True, None) mock_state = SeerRunState( @@ -788,7 +837,7 @@ def test_get_run_with_blocking(self, mock_poll, mock_access): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.fetch_run_status") - def test_get_run_http_error(self, mock_fetch, mock_access): + def test_get_run_http_error(self, mock_fetch: MagicMock, mock_access: MagicMock) -> None: """Test that HTTP errors are propagated""" mock_access.return_value = (True, None) mock_fetch.side_effect = SeerApiError("API Error", 500) @@ -839,7 +888,9 @@ def setUp(self) -> None: @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") - def test_start_run_with_artifact_schema(self, mock_collect_context, mock_post, mock_access): + def test_start_run_with_artifact_schema( + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """Test that artifact key and schema are serialized and sent to API""" mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} @@ -869,7 +920,9 @@ class IssueAnalysis(BaseModel): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") - def test_start_run_artifact_schema_requires_key(self, mock_post, mock_access): + def test_start_run_artifact_schema_requires_key( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """Test that artifact_schema without artifact_key raises ValueError""" mock_access.return_value = (True, None) @@ -885,7 +938,9 @@ class IssueAnalysis(BaseModel): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") @patch("sentry.seer.agent.client.collect_user_org_context") - def test_continue_run_with_artifact_schema(self, mock_collect_context, mock_post, mock_access): + def test_continue_run_with_artifact_schema( + self, mock_collect_context: MagicMock, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """Test continuing a run with a new artifact key and schema""" mock_access.return_value = (True, None) mock_collect_context.return_value = {"user_id": self.user.id} @@ -914,7 +969,9 @@ class Solution(BaseModel): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.make_agent_chat_request") - def test_continue_run_artifact_schema_requires_key(self, mock_post, mock_access): + def test_continue_run_artifact_schema_requires_key( + self, mock_post: MagicMock, mock_access: MagicMock + ) -> None: """Test that artifact_schema without artifact_key raises ValueError""" mock_access.return_value = (True, None) @@ -929,7 +986,9 @@ class Solution(BaseModel): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.fetch_run_status") - def test_get_run_with_artifacts_on_blocks(self, mock_fetch, mock_access): + def test_get_run_with_artifacts_on_blocks( + self, mock_fetch: MagicMock, mock_access: MagicMock + ) -> None: """Test that artifacts on blocks are returned and can be retrieved typed""" from sentry.seer.agent.client_models import Artifact, MemoryBlock, Message @@ -977,7 +1036,9 @@ class BugReport(BaseModel): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.fetch_run_status") - def test_get_artifact_returns_none_when_missing(self, mock_fetch, mock_access): + def test_get_artifact_returns_none_when_missing( + self, mock_fetch: MagicMock, mock_access: MagicMock + ) -> None: """Test that get_artifact returns None for missing or pending artifacts""" from sentry.seer.agent.client_models import Artifact, MemoryBlock, Message @@ -1018,7 +1079,9 @@ class MySchema(BaseModel): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.fetch_run_status") - def test_get_run_with_multiple_artifacts_on_blocks(self, mock_fetch, mock_access): + def test_get_run_with_multiple_artifacts_on_blocks( + self, mock_fetch: MagicMock, mock_access: MagicMock + ) -> None: """Test retrieving multiple artifacts from blocks in a multi-step run""" from sentry.seer.agent.client_models import Artifact, MemoryBlock, Message @@ -1083,7 +1146,9 @@ class Solution(BaseModel): @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.fetch_run_status") - def test_get_artifacts_returns_latest_version(self, mock_fetch, mock_access): + def test_get_artifacts_returns_latest_version( + self, mock_fetch: MagicMock, mock_access: MagicMock + ) -> None: """Test that get_artifacts returns the latest version when artifact is updated""" from sentry.seer.agent.client_models import Artifact, MemoryBlock, Message @@ -1146,7 +1211,9 @@ def setUp(self) -> None: @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.fetch_run_status") @patch("sentry.seer.agent.client.make_agent_update_request") - def test_push_changes_sends_correct_payload(self, mock_post, mock_fetch, mock_access): + def test_push_changes_sends_correct_payload( + self, mock_post: MagicMock, mock_fetch: MagicMock, mock_access: MagicMock + ) -> None: """Test that push_changes sends correct payload""" mock_access.return_value = (True, None) mock_post.return_value = MagicMock(status=200) @@ -1188,8 +1255,12 @@ def test_push_changes_sends_correct_payload(self, mock_post, mock_fetch, mock_ac @patch("sentry.seer.agent.client.make_agent_update_request") @patch("sentry.seer.agent.client.time.sleep") def test_push_changes_polls_until_complete( - self, mock_sleep, mock_post, mock_fetch, mock_access - ): + self, + mock_sleep: MagicMock, + mock_post: MagicMock, + mock_fetch: MagicMock, + mock_access: MagicMock, + ) -> None: """Test that push_changes polls until PR creation completes""" mock_access.return_value = (True, None) mock_post.return_value = MagicMock(status=200) @@ -1227,7 +1298,14 @@ def test_push_changes_polls_until_complete( @patch("sentry.seer.agent.client.make_agent_update_request") @patch("sentry.seer.agent.client.time.sleep") @patch("sentry.seer.agent.client.time.time") - def test_push_changes_timeout(self, mock_time, mock_sleep, mock_post, mock_fetch, mock_access): + def test_push_changes_timeout( + self, + mock_time: MagicMock, + mock_sleep: MagicMock, + mock_post: MagicMock, + mock_fetch: MagicMock, + mock_access: MagicMock, + ) -> None: """Test that push_changes raises TimeoutError after timeout""" mock_access.return_value = (True, None) mock_post.return_value = MagicMock(status=200) @@ -1243,11 +1321,11 @@ def test_push_changes_timeout(self, mock_time, mock_sleep, mock_post, mock_fetch mock_time.side_effect = [0, 0, 200] # Exceeds 120s timeout # get_option call in client init interferes with the mock time.time() - patch it - self.organization.get_option = MagicMock(return_value=True) - client = SeerAgentClient(self.organization, self.user, enable_coding=True) + with patch.object(self.organization, "get_option", return_value=True): + client = SeerAgentClient(self.organization, self.user, enable_coding=True) - with pytest.raises(TimeoutError, match="PR creation timed out"): - client.push_changes(123, poll_timeout=120.0) + with pytest.raises(TimeoutError, match="PR creation timed out"): + client.push_changes(123, poll_timeout=120.0) class TestSeerRunStateCodeChanges(TestCase): @@ -1430,7 +1508,9 @@ def _mock_chat_response(self, run_id: int = 123, **flags: bool | None) -> MagicM @patch("sentry.seer.agent.client.dispatch_explorer_index_projects") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") - def test_triggers_indexing_when_explorer_index_missing(self, mock_chat, mock_dispatch): + def test_triggers_indexing_when_explorer_index_missing( + self, mock_chat: MagicMock, mock_dispatch: MagicMock + ) -> None: mock_chat.return_value = self._mock_chat_response(has_explorer_index=False) mock_dispatch.return_value = iter([]) project = self.create_project(organization=self.organization) @@ -1451,8 +1531,12 @@ def test_triggers_indexing_when_explorer_index_missing(self, mock_chat, mock_dis @patch("sentry.seer.agent.client.dispatch_explorer_index_projects") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") def test_triggers_indexing_when_org_project_context_missing( - self, mock_chat, mock_dispatch, mock_build_service_map, mock_index_knowledge - ): + self, + mock_chat: MagicMock, + mock_dispatch: MagicMock, + mock_build_service_map: MagicMock, + mock_index_knowledge: MagicMock, + ) -> None: mock_chat.return_value = self._mock_chat_response(has_org_project_context=False) mock_dispatch.return_value = iter([]) project = self.create_project(organization=self.organization) @@ -1475,7 +1559,9 @@ def test_triggers_indexing_when_org_project_context_missing( @patch("sentry.seer.agent.client.dispatch_explorer_index_projects") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") - def test_excludes_projects_without_transactions_from_batch(self, mock_chat, mock_dispatch): + def test_excludes_projects_without_transactions_from_batch( + self, mock_chat: MagicMock, mock_dispatch: MagicMock + ) -> None: mock_chat.return_value = self._mock_chat_response(has_explorer_index=False) mock_dispatch.return_value = iter([]) project_with_txns = self.create_project(organization=self.organization) @@ -1493,7 +1579,9 @@ def test_excludes_projects_without_transactions_from_batch(self, mock_chat, mock @patch("sentry.seer.agent.client.dispatch_explorer_index_projects") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") - def test_skips_indexing_when_index_present(self, mock_chat, mock_dispatch): + def test_skips_indexing_when_index_present( + self, mock_chat: MagicMock, mock_dispatch: MagicMock + ) -> None: mock_chat.return_value = self._mock_chat_response( has_explorer_index=True, has_org_project_context=True ) @@ -1506,7 +1594,9 @@ def test_skips_indexing_when_index_present(self, mock_chat, mock_dispatch): @patch("sentry.seer.agent.client.dispatch_explorer_index_projects") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") - def test_skips_indexing_when_flags_absent_from_response(self, mock_chat, mock_dispatch): + def test_skips_indexing_when_flags_absent_from_response( + self, mock_chat: MagicMock, mock_dispatch: MagicMock + ) -> None: mock_chat.return_value = self._mock_chat_response() client = SeerAgentClient(self.organization, self.user) @@ -1520,8 +1610,12 @@ def test_skips_indexing_when_flags_absent_from_response(self, mock_chat, mock_di @patch("sentry.seer.agent.client.dispatch_explorer_index_projects") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") def test_skips_indexing_when_killswitch_on( - self, mock_chat, mock_dispatch, mock_build_service_map, mock_index_knowledge - ): + self, + mock_chat: MagicMock, + mock_dispatch: MagicMock, + mock_build_service_map: MagicMock, + mock_index_knowledge: MagicMock, + ) -> None: mock_chat.return_value = self._mock_chat_response( has_explorer_index=False, has_org_project_context=False ) @@ -1544,7 +1638,9 @@ def test_skips_indexing_when_killswitch_on( @patch("sentry.seer.agent.client.dispatch_explorer_index_projects") @patch("sentry.receivers.outbox.cell.make_agent_chat_request") - def test_skips_indexing_when_no_projects_with_transactions(self, mock_chat, mock_dispatch): + def test_skips_indexing_when_no_projects_with_transactions( + self, mock_chat: MagicMock, mock_dispatch: MagicMock + ) -> None: mock_chat.return_value = self._mock_chat_response(has_explorer_index=False) self.create_project(organization=self.organization) @@ -1568,7 +1664,9 @@ def _outbox_for(self, run: SeerRun) -> CellOutbox | None: @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") - def test_flush_false_enqueues_without_dispatch(self, mock_request, _mock_access) -> None: + def test_flush_false_enqueues_without_dispatch( + self, mock_request: MagicMock, _mock_access: MagicMock + ) -> None: context: UserOrgContext = { "org_slug": self.organization.slug, "all_org_projects": [{"id": 1, "slug": "project", "repos": []}], @@ -1601,7 +1699,9 @@ def test_flush_false_enqueues_without_dispatch(self, mock_request, _mock_access) @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") - def test_feature_run_enqueues_proxy_headers(self, mock_request, _mock_access) -> None: + def test_feature_run_enqueues_proxy_headers( + self, mock_request: MagicMock, _mock_access: MagicMock + ) -> None: proxy_headers = {"X-Viewer-Context": "signed-viewer-context"} client = SeerAgentClient(self.organization, self.user) run = client.start_feature_run( @@ -1620,7 +1720,9 @@ def test_feature_run_enqueues_proxy_headers(self, mock_request, _mock_access) -> @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") - def test_creates_agent_run_mirror(self, mock_request, _mock_access) -> None: + def test_creates_agent_run_mirror( + self, mock_request: MagicMock, _mock_access: MagicMock + ) -> None: client = SeerAgentClient(self.organization, self.user) run = client.start_feature_run( feature_id="night_shift", @@ -1639,7 +1741,7 @@ def test_creates_agent_run_mirror(self, mock_request, _mock_access) -> None: @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") - def test_truncates_long_title(self, mock_request, _mock_access) -> None: + def test_truncates_long_title(self, mock_request: MagicMock, _mock_access: MagicMock) -> None: client = SeerAgentClient(self.organization, self.user) run = client.start_feature_run( feature_id="night_shift", @@ -1656,7 +1758,7 @@ def test_truncates_long_title(self, mock_request, _mock_access) -> None: @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") def test_creates_agent_run_mirror_extras_default_to_empty( - self, mock_request, _mock_access + self, mock_request: MagicMock, _mock_access: MagicMock ) -> None: client = SeerAgentClient(self.organization, self.user) run = client.start_feature_run( @@ -1675,7 +1777,7 @@ def test_creates_agent_run_mirror_extras_default_to_empty( @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") def test_on_run_created_still_called_alongside_agent_run_mirror( - self, mock_request, _mock_access + self, mock_request: MagicMock, _mock_access: MagicMock ) -> None: linked: list[SeerRun] = [] @@ -1694,7 +1796,9 @@ def test_on_run_created_still_called_alongside_agent_run_mirror( @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") - def test_flush_true_dispatches_inline_and_mirrors(self, mock_request, _mock_access) -> None: + def test_flush_true_dispatches_inline_and_mirrors( + self, mock_request: MagicMock, _mock_access: MagicMock + ) -> None: mock_request.return_value = Mock(status=200, json=Mock(return_value={"run_id": 4242})) client = SeerAgentClient(self.organization, self.user) @@ -1713,7 +1817,7 @@ def test_flush_true_dispatches_inline_and_mirrors(self, mock_request, _mock_acce @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") def test_flush_true_dispatch_failure_marks_failed_and_raises( - self, mock_request, _mock_access + self, mock_request: MagicMock, _mock_access: MagicMock ) -> None: mock_request.return_value = Mock(status=400) @@ -1740,7 +1844,9 @@ def test_access_gate_blocks_dispatch(self) -> None: @patch("sentry.receivers.outbox.cell.make_feature_run_request") @with_feature("organizations:seer-added") @override_options({"seer.explorer.context-engine-rollout": 1.0}) - def test_inherits_context_engine_from_org(self, mock_request, _mock_access) -> None: + def test_inherits_context_engine_from_org( + self, mock_request: MagicMock, _mock_access: MagicMock + ) -> None: client = SeerAgentClient(self.organization, self.user) run = client.start_feature_run( feature_id="night_shift", @@ -1758,7 +1864,9 @@ def test_inherits_context_engine_from_org(self, mock_request, _mock_access) -> N @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") @with_feature("organizations:seer-agent-source-code-search") - def test_inherits_frontend_code_search_from_org(self, mock_request, _mock_access) -> None: + def test_inherits_frontend_code_search_from_org( + self, mock_request: MagicMock, _mock_access: MagicMock + ) -> None: client = SeerAgentClient(self.organization, self.user) run = client.start_feature_run( feature_id="night_shift", @@ -1776,7 +1884,9 @@ def test_inherits_frontend_code_search_from_org(self, mock_request, _mock_access @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") @with_feature("organizations:seer-agent-enable-assisted-query-code-mode") - def test_inherits_assisted_query_code_mode_from_org(self, mock_request, _mock_access) -> None: + def test_inherits_assisted_query_code_mode_from_org( + self, mock_request: MagicMock, _mock_access: MagicMock + ) -> None: client = SeerAgentClient(self.organization, self.user) run = client.start_feature_run( feature_id="night_shift", @@ -1794,7 +1904,7 @@ def test_inherits_assisted_query_code_mode_from_org(self, mock_request, _mock_ac @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") @with_feature("organizations:seer-explorer-allow-bash-mode") - def test_forwards_bash_mode(self, mock_request, _mock_access) -> None: + def test_forwards_bash_mode(self, mock_request: MagicMock, _mock_access: MagicMock) -> None: client = SeerAgentClient(self.organization, self.user, enable_bash_mode=True) run = client.start_feature_run( feature_id="night_shift", @@ -1811,7 +1921,9 @@ def test_forwards_bash_mode(self, mock_request, _mock_access) -> None: @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") - def test_omits_bash_mode_without_org_flag(self, mock_request, _mock_access) -> None: + def test_omits_bash_mode_without_org_flag( + self, mock_request: MagicMock, _mock_access: MagicMock + ) -> None: client = SeerAgentClient(self.organization, self.user, enable_bash_mode=True) run = client.start_feature_run( feature_id="night_shift", @@ -1829,7 +1941,7 @@ def test_omits_bash_mode_without_org_flag(self, mock_request, _mock_access) -> N @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") def test_agent_run_options_use_defaults_without_org_flags( - self, mock_request, _mock_access + self, mock_request: MagicMock, _mock_access: MagicMock ) -> None: client = SeerAgentClient(self.organization, self.user) run = client.start_feature_run( @@ -1854,7 +1966,9 @@ def setUp(self) -> None: @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.seer.agent.client.make_feature_run_request") - def test_dispatches_against_existing_mirror(self, mock_request, _mock_access) -> None: + def test_dispatches_against_existing_mirror( + self, mock_request: MagicMock, _mock_access: MagicMock + ) -> None: mock_request.return_value = Mock(status=200) stale = timezone.now() - timedelta(days=10) run = self.create_seer_run( @@ -1892,7 +2006,9 @@ def test_dispatches_against_existing_mirror(self, mock_request, _mock_access) -> @with_feature("organizations:seer-explorer-allow-bash-mode") @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.seer.agent.client.make_feature_run_request") - def test_merges_client_and_caller_options(self, mock_request, _mock_access) -> None: + def test_merges_client_and_caller_options( + self, mock_request: MagicMock, _mock_access: MagicMock + ) -> None: mock_request.return_value = Mock(status=200) run = self.create_seer_run(organization=self.organization, seer_run_state_id=456) client = SeerAgentClient(self.organization, self.user, enable_bash_mode=True) @@ -1915,7 +2031,7 @@ def test_merges_client_and_caller_options(self, mock_request, _mock_access) -> N @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.seer.agent.client.make_feature_run_request") - def test_http_error_raises(self, mock_request, _mock_access) -> None: + def test_http_error_raises(self, mock_request: MagicMock, _mock_access: MagicMock) -> None: mock_request.return_value = Mock(status=500) run = self.create_seer_run(organization=self.organization, seer_run_state_id=456) client = SeerAgentClient(self.organization, self.user) @@ -1945,7 +2061,9 @@ def _client(self) -> SeerAgentClient: category_value=self.CATEGORY_VALUE, ) - def _agent_run(self, state_id: int | None, *, source: str = "autofix", **kwargs): + def _agent_run( + self, state_id: int | None, *, source: str = "autofix", **kwargs: Any + ) -> SeerAgentRun: kwargs.setdefault("user_id", self.user.id) run = self.create_seer_run(seer_run_state_id=state_id, **kwargs) return self.create_seer_agent_run( @@ -1955,7 +2073,9 @@ def _agent_run(self, state_id: int | None, *, source: str = "autofix", **kwargs) ) @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) - def test_latest_run_selects_matching_most_recently_triggered(self, _mock_access) -> None: + def test_latest_run_selects_matching_most_recently_triggered( + self, _mock_access: MagicMock + ) -> None: stale = self._agent_run( 1, last_triggered_at=timezone.now() - timedelta(days=10), @@ -1975,7 +2095,7 @@ def test_latest_run_selects_matching_most_recently_triggered(self, _mock_access) assert result != stale @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) - def test_latest_run_can_ignore_current_user(self, _mock_access) -> None: + def test_latest_run_can_ignore_current_user(self, _mock_access: MagicMock) -> None: other_user = self.create_user() run = self._agent_run(5, user_id=other_user.id) client = self._client() @@ -1984,7 +2104,7 @@ def test_latest_run_can_ignore_current_user(self, _mock_access) -> None: assert client.latest_run(only_current_user=False) == run @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) - def test_latest_run_filters_by_group_id(self, _mock_access) -> None: + def test_latest_run_filters_by_group_id(self, _mock_access: MagicMock) -> None: group = self.create_group() run = self.create_seer_run(seer_run_state_id=1, user_id=self.user.id) self.create_seer_agent_run(run, source="autofix", group=group) @@ -1994,13 +2114,15 @@ def test_latest_run_filters_by_group_id(self, _mock_access) -> None: assert client.latest_run(group_id=group.id + 999) is None @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) - def test_latest_run_skips_null_state_id(self, _mock_access) -> None: + def test_latest_run_skips_null_state_id(self, _mock_access: MagicMock) -> None: self._agent_run(None) assert self._client().latest_run() is None @patch("sentry.seer.agent.client.fetch_run_status") @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) - def test_fetch_latest_run_state_fetches_remote(self, _mock_access, mock_fetch) -> None: + def test_fetch_latest_run_state_fetches_remote( + self, _mock_access: MagicMock, mock_fetch: MagicMock + ) -> None: self._agent_run(4242) mock_fetch.return_value = MagicMock(run_id=4242, status="completed") client = self._client() @@ -2017,7 +2139,9 @@ def test_fetch_latest_run_state_fetches_remote(self, _mock_access, mock_fetch) - @patch("sentry.seer.agent.client.fetch_run_status") @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) - def test_fetch_latest_run_state_none_when_no_run(self, _mock_access, mock_fetch) -> None: + def test_fetch_latest_run_state_none_when_no_run( + self, _mock_access: MagicMock, mock_fetch: MagicMock + ) -> None: assert self._client().fetch_latest_run_state() is None mock_fetch.assert_not_called() @@ -2030,7 +2154,7 @@ def setUp(self) -> None: self.user = self.create_user() self.organization = self.create_organization(owner=self.user) - def _result_by_provider(self, result: list[dict]) -> dict[str, dict]: + def _result_by_provider(self, result: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: return {entry["provider_key"]: entry for entry in result} def _dismiss_provider(self, provider_type: str) -> None: diff --git a/tests/sentry/seer/agent/test_client_models_failure_reason.py b/tests/sentry/seer/agent/test_client_models_failure_reason.py index a640014d74c5..83b6774b60fe 100644 --- a/tests/sentry/seer/agent/test_client_models_failure_reason.py +++ b/tests/sentry/seer/agent/test_client_models_failure_reason.py @@ -7,10 +7,12 @@ from __future__ import annotations +from typing import Any + from sentry.seer.agent.client_models import SeerRunState -def _state(**kwargs) -> SeerRunState: +def _state(**kwargs: Any) -> SeerRunState: return SeerRunState( run_id=1, blocks=[], @@ -19,22 +21,22 @@ def _state(**kwargs) -> SeerRunState: ) -def test_failure_reason_is_parsed_from_seer(): +def test_failure_reason_is_parsed_from_seer() -> None: assert _state(status="error", failure_reason="timeout").failure_reason == "timeout" -def test_failure_reason_defaults_to_none_for_old_seer(): +def test_failure_reason_defaults_to_none_for_old_seer() -> None: assert _state(status="error").failure_reason is None -def test_unclassified_failures_are_still_failures(): +def test_unclassified_failures_are_still_failures() -> None: # Most failures leave failure_reason unset, so status is what says a run failed. state = _state(status="error") assert state.status == "error" assert state.failure_reason is None -def test_unknown_failure_reason_still_parses(): +def test_unknown_failure_reason_still_parses() -> None: # A reason added on the seer side must not fail validation mid-deploy. assert _state(status="error", failure_reason="brand_new_reason").failure_reason == ( "brand_new_reason" diff --git a/tests/sentry/seer/agent/test_client_models_structured_content.py b/tests/sentry/seer/agent/test_client_models_structured_content.py index 052a2bfd9935..836d7c60b43b 100644 --- a/tests/sentry/seer/agent/test_client_models_structured_content.py +++ b/tests/sentry/seer/agent/test_client_models_structured_content.py @@ -10,7 +10,7 @@ from sentry.seer.agent.client_models import SeerRunState, ToolResult -def test_structured_content_is_parsed_from_seer(): +def test_structured_content_is_parsed_from_seer() -> None: result = ToolResult( tool_call_id="t1", tool_call_function="sentry_api_execute", @@ -24,12 +24,12 @@ def test_structured_content_is_parsed_from_seer(): } -def test_structured_content_defaults_to_none_for_old_seer(): +def test_structured_content_defaults_to_none_for_old_seer() -> None: result = ToolResult(tool_call_id="t1", tool_call_function="todo_write", content="{}") assert result.structuredContent is None -def test_structured_content_round_trips_to_the_frontend_dict(): +def test_structured_content_round_trips_to_the_frontend_dict() -> None: payload = {"navigation": [{"kind": "get_trace_waterfall", "params": {"trace_id": "abc"}}]} result = ToolResult( tool_call_id="t1", @@ -42,7 +42,7 @@ def test_structured_content_round_trips_to_the_frontend_dict(): assert result.dict()["structuredContent"] == payload -def test_in_flight_tool_activity_reaches_the_frontend(): +def test_in_flight_tool_activity_reaches_the_frontend() -> None: live_calls = [{"id": 1, "kind": "api", "title": "Searching issues"}] progress = [{"token": "call-1", "progress": 1, "message": "Searching issues"}] state = SeerRunState( diff --git a/tests/sentry/seer/agent/test_client_utils.py b/tests/sentry/seer/agent/test_client_utils.py index aa1686be6f1b..4ae4c4aeed99 100644 --- a/tests/sentry/seer/agent/test_client_utils.py +++ b/tests/sentry/seer/agent/test_client_utils.py @@ -566,7 +566,7 @@ def test_nul_in_body_does_not_break_outbox_insert(self) -> None: class FetchRunStatusesTest(TestCase): _PATCH = "sentry.seer.agent.client_utils.make_signed_seer_api_request" - def _response(self, status: int, payload: dict) -> mock.Mock: + def _response(self, status: int, payload: dict[str, Any]) -> mock.Mock: response = mock.Mock(status=status) response.json.return_value = payload return response diff --git a/tests/sentry/seer/agent/test_coding_agent_handoff.py b/tests/sentry/seer/agent/test_coding_agent_handoff.py index eab9f3f223b2..917e0d08b491 100644 --- a/tests/sentry/seer/agent/test_coding_agent_handoff.py +++ b/tests/sentry/seer/agent/test_coding_agent_handoff.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -7,7 +8,11 @@ from sentry.integrations.coding_agent.models import CodingAgentLaunchRequest from sentry.integrations.cursor.integration import CursorAgentIntegration -from sentry.seer.agent.coding_agent_handoff import _resolve_client, launch_coding_agents +from sentry.seer.agent.coding_agent_handoff import ( + CodingAgentHandoffFailure, + _resolve_client, + launch_coding_agents, +) from sentry.seer.autofix.utils import CodingAgentProviderType, CodingAgentState from sentry.seer.models import SeerApiError, SeerRepoDefinition from sentry.seer.models.run import SeerRunCodingAgentHandoff @@ -59,7 +64,7 @@ def setUp(self) -> None: @patch("sentry.seer.agent.coding_agent_handoff.store_coding_agent_states_to_seer") @patch("sentry.seer.agent.coding_agent_handoff.validate_and_get_integration") - def test_successful_launch(self, mock_validate, mock_store): + def test_successful_launch(self, mock_validate: MagicMock, mock_store: MagicMock) -> None: """Test successful coding agent launch.""" state = _state(agent_url="https://cursor.sh/agent") installation = FakeCodingAgentInstallation(state) @@ -89,8 +94,8 @@ def test_successful_launch(self, mock_validate, mock_store): @patch("sentry.seer.agent.coding_agent_handoff.store_coding_agent_states_to_seer") @patch("sentry.seer.agent.coding_agent_handoff.validate_and_get_integration") def test_handoff_row_created_per_repo_before_seer_store( - self, mock_validate, mock_store, mock_create_handoff - ): + self, mock_validate: MagicMock, mock_store: MagicMock, mock_create_handoff: MagicMock + ) -> None: call_order: list[str] = [] mock_create_handoff.side_effect = lambda *a, **k: call_order.append("create_handoff") mock_store.side_effect = lambda *a, **k: call_order.append("store_to_seer") @@ -115,8 +120,8 @@ def test_handoff_row_created_per_repo_before_seer_store( @patch("sentry.seer.agent.coding_agent_handoff.store_coding_agent_states_to_seer") @patch("sentry.seer.agent.coding_agent_handoff.validate_and_get_integration") def test_successful_launch_persists_seer_run_coding_agent_handoff_row( - self, mock_validate, mock_store - ): + self, mock_validate: MagicMock, mock_store: MagicMock + ) -> None: seer_run = self.create_seer_run(self.organization, seer_run_state_id=self.run_id) installation = FakeCodingAgentInstallation( _state("agent-123", agent_url="https://cursor.sh/agent") @@ -141,7 +146,9 @@ def test_successful_launch_persists_seer_run_coding_agent_handoff_row( @patch("sentry.seer.agent.coding_agent_handoff.store_coding_agent_states_to_seer") @patch("sentry.seer.agent.coding_agent_handoff.validate_and_get_integration") - def test_leaves_handoffs_alone_when_seer_storage_errors(self, mock_validate, mock_store): + def test_leaves_handoffs_alone_when_seer_storage_errors( + self, mock_validate: MagicMock, mock_store: MagicMock + ) -> None: self.create_seer_run(self.organization, seer_run_state_id=self.run_id) mock_store.side_effect = SeerApiError("Seer unavailable", status=503) installation = FakeCodingAgentInstallation(_state("agent-123")) @@ -163,7 +170,9 @@ def test_leaves_handoffs_alone_when_seer_storage_errors(self, mock_validate, moc @patch("sentry.seer.agent.coding_agent_handoff.store_coding_agent_states_to_seer") @patch("sentry.seer.agent.coding_agent_handoff.validate_and_get_integration") - def test_launch_raises_value_error(self, mock_validate, mock_store): + def test_launch_raises_value_error( + self, mock_validate: MagicMock, mock_store: MagicMock + ) -> None: """Test that ValueError from integration launch is handled as failure.""" installation = FakeCodingAgentInstallation(ValueError("Invalid repository name format")) mock_validate.return_value = (None, installation) @@ -184,7 +193,9 @@ def test_launch_raises_value_error(self, mock_validate, mock_store): @patch("sentry.seer.agent.coding_agent_handoff.store_coding_agent_states_to_seer") @patch("sentry.seer.agent.coding_agent_handoff.validate_and_get_integration") - def test_multiple_repos_partial_failure(self, mock_validate, mock_store): + def test_multiple_repos_partial_failure( + self, mock_validate: MagicMock, mock_store: MagicMock + ) -> None: """Test handling of partial failures across multiple repos.""" installation = FakeCodingAgentInstallation( _state("agent-1"), @@ -208,7 +219,9 @@ def test_multiple_repos_partial_failure(self, mock_validate, mock_store): @patch("sentry.seer.agent.coding_agent_handoff.store_coding_agent_states_to_seer") @patch("sentry.seer.agent.coding_agent_handoff.validate_and_get_integration") - def test_branch_name_is_sanitized(self, mock_validate, mock_store): + def test_branch_name_is_sanitized( + self, mock_validate: MagicMock, mock_store: MagicMock + ) -> None: """Test that branch name is sanitized before launch.""" installation = FakeCodingAgentInstallation(_state("agent-1")) mock_validate.return_value = (None, installation) @@ -230,10 +243,10 @@ def test_branch_name_is_sanitized(self, mock_validate, mock_store): @patch("sentry.seer.agent.coding_agent_handoff.github_copilot_identity_service") def test_copilot_not_licensed_403_returns_github_copilot_not_licensed_failure_type( self, - mock_identity_service, - mock_copilot_client_class, - mock_store, - ): + mock_identity_service: MagicMock, + mock_copilot_client_class: MagicMock, + mock_store: MagicMock, + ) -> None: """Test that Copilot 403 'not licensed' errors return github_copilot_not_licensed failure_type. When GitHub Copilot returns a 403 with "not licensed to use Copilot", the user's @@ -264,7 +277,12 @@ def test_copilot_not_licensed_403_returns_github_copilot_not_licensed_failure_ty assert failure["failure_type"] == "github_copilot_not_licensed" assert "Copilot license" in failure["error_message"] - def _copilot_permissions_failure(self, mock_copilot_client_class, mock_identity_service, repo): + def _copilot_permissions_failure( + self, + mock_copilot_client_class: MagicMock, + mock_identity_service: MagicMock, + repo: Any, + ) -> CodingAgentHandoffFailure: mock_identity_service.get_access_token_for_user.return_value = "test-token" mock_client_instance = MagicMock() mock_copilot_client_class.return_value = mock_client_instance @@ -292,10 +310,10 @@ def _copilot_permissions_failure(self, mock_copilot_client_class, mock_identity_ @patch("sentry.seer.agent.coding_agent_handoff.github_copilot_identity_service") def test_permissions_403_names_the_installation_that_owns_the_repo( self, - mock_identity_service, - mock_copilot_client_class, - mock_store, - ): + mock_identity_service: MagicMock, + mock_copilot_client_class: MagicMock, + mock_store: MagicMock, + ) -> None: self.create_integration( organization=self.organization, provider="github", @@ -328,10 +346,10 @@ def test_permissions_403_names_the_installation_that_owns_the_repo( @patch("sentry.seer.agent.coding_agent_handoff.github_copilot_identity_service") def test_permissions_403_uses_personal_namespace_for_user_installations( self, - mock_identity_service, - mock_copilot_client_class, - mock_store, - ): + mock_identity_service: MagicMock, + mock_copilot_client_class: MagicMock, + mock_store: MagicMock, + ) -> None: owning = self.create_integration( organization=self.organization, provider="github", @@ -356,10 +374,10 @@ def test_permissions_403_uses_personal_namespace_for_user_installations( @patch("sentry.seer.agent.coding_agent_handoff.github_copilot_identity_service") def test_permissions_403_omits_installation_when_repo_has_no_integration( self, - mock_identity_service, - mock_copilot_client_class, - mock_store, - ): + mock_identity_service: MagicMock, + mock_copilot_client_class: MagicMock, + mock_store: MagicMock, + ) -> None: self.create_integration( organization=self.organization, provider="github", @@ -382,10 +400,10 @@ def test_permissions_403_omits_installation_when_repo_has_no_integration( @patch("sentry.seer.agent.coding_agent_handoff.github_copilot_identity_service") def test_permissions_403_omits_installation_from_another_organization( self, - mock_identity_service, - mock_copilot_client_class, - mock_store, - ): + mock_identity_service: MagicMock, + mock_copilot_client_class: MagicMock, + mock_store: MagicMock, + ) -> None: other_org = self.create_organization() foreign = self.create_integration( organization=other_org, @@ -407,8 +425,8 @@ def test_permissions_403_omits_installation_from_another_organization( @patch("sentry.seer.agent.coding_agent_handoff.store_coding_agent_states_to_seer") @patch("sentry.seer.agent.coding_agent_handoff.validate_and_get_integration") def test_verify_branch_error_returns_cursor_github_access_failure_type( - self, mock_validate, mock_store - ): + self, mock_validate: MagicMock, mock_store: MagicMock + ) -> None: """Test that a 400 ApiError with 'Failed to verify existence of branch' returns cursor_github_access failure_type. When Cursor returns a 400 with this error, the Cursor GitHub App hasn't been @@ -450,7 +468,7 @@ def setUp(self) -> None: self.organization = self.create_organization() @patch(f"{MOCK_HANDOFF_PATH}.validate_and_get_integration") - def test_returns_installation_for_cursor(self, mock_validate): + def test_returns_installation_for_cursor(self, mock_validate: MagicMock) -> None: mock_installation = MagicMock() mock_validate.return_value = (None, mock_installation) @@ -463,7 +481,7 @@ def test_returns_installation_for_cursor(self, mock_validate): mock_validate.assert_called_once_with(self.organization, 1) @patch(f"{MOCK_HANDOFF_PATH}.validate_and_get_integration") - def test_returns_installation_for_claude_code(self, mock_validate): + def test_returns_installation_for_claude_code(self, mock_validate: MagicMock) -> None: mock_installation = MagicMock() mock_validate.return_value = (None, mock_installation) @@ -475,7 +493,7 @@ def test_returns_installation_for_claude_code(self, mock_validate): assert installation is mock_installation @patch(f"{MOCK_HANDOFF_PATH}.github_copilot_identity_service") - def test_returns_client_for_github_copilot(self, mock_identity_service): + def test_returns_client_for_github_copilot(self, mock_identity_service: MagicMock) -> None: mock_identity_service.get_access_token_for_user.return_value = "test-token" client, installation = _resolve_client( @@ -487,7 +505,9 @@ def test_returns_client_for_github_copilot(self, mock_identity_service): mock_identity_service.get_access_token_for_user.assert_called_once_with(user_id=1) @patch(f"{MOCK_HANDOFF_PATH}.github_copilot_identity_service") - def test_raises_permission_denied_when_no_copilot_token(self, mock_identity_service): + def test_raises_permission_denied_when_no_copilot_token( + self, mock_identity_service: MagicMock + ) -> None: mock_identity_service.get_access_token_for_user.return_value = None with pytest.raises(PermissionDenied): @@ -495,7 +515,7 @@ def test_raises_permission_denied_when_no_copilot_token(self, mock_identity_serv self.organization, integration_id=None, provider="github_copilot", user_id=1 ) - def test_raises_permission_denied_when_copilot_no_user_id(self): + def test_raises_permission_denied_when_copilot_no_user_id(self) -> None: with pytest.raises(PermissionDenied): _resolve_client( self.organization, integration_id=None, provider="github_copilot", user_id=None diff --git a/tests/sentry/seer/agent/test_context_engine_utils.py b/tests/sentry/seer/agent/test_context_engine_utils.py index cac12eec72fe..f1fae4e204a9 100644 --- a/tests/sentry/seer/agent/test_context_engine_utils.py +++ b/tests/sentry/seer/agent/test_context_engine_utils.py @@ -1,5 +1,6 @@ from datetime import UTC, datetime, timedelta from unittest import mock +from unittest.mock import MagicMock from sentry.seer.agent.context_engine_utils import ( get_event_counts_for_org_projects, @@ -208,11 +209,11 @@ def test_returns_empty_for_no_projects(self) -> None: assert get_sdk_names_for_org_projects([], self.start, self.end) == {} @mock.patch(MOCK_RUN_TABLE, return_value={"data": []}) - def test_returns_empty_when_no_data(self, _mock): + def test_returns_empty_when_no_data(self, _mock: MagicMock) -> None: assert get_sdk_names_for_org_projects([self.project], self.start, self.end) == {} @mock.patch(MOCK_RUN_TABLE, side_effect=Exception("eap error")) - def test_returns_empty_on_query_exception(self, _mock): + def test_returns_empty_on_query_exception(self, _mock: MagicMock) -> None: assert get_sdk_names_for_org_projects([self.project], self.start, self.end) == {} def test_returns_sdk_name_by_project(self) -> None: diff --git a/tests/sentry/seer/agent/test_custom_tool_utils.py b/tests/sentry/seer/agent/test_custom_tool_utils.py index f57b73ad7ae0..11f79941b022 100644 --- a/tests/sentry/seer/agent/test_custom_tool_utils.py +++ b/tests/sentry/seer/agent/test_custom_tool_utils.py @@ -2,6 +2,8 @@ Tests for custom tool utilities. """ +from typing import Any + import pytest from pydantic import BaseModel, Field @@ -53,7 +55,7 @@ def get_description(cls) -> str: return "A test tool" @classmethod - def execute(cls, organization, params: CustomToolParams) -> str: + def execute(cls, organization: Any, params: CustomToolParams) -> str: return params.message * params.count @@ -65,7 +67,7 @@ def get_description(cls) -> str: return "Test tool with default parameter" @classmethod - def execute(cls, organization, params: ToolWithDefaultParams) -> str: + def execute(cls, organization: Any, params: ToolWithDefaultParams) -> str: return params.value + params.suffix @@ -77,7 +79,7 @@ def get_description(cls) -> str: return "Tool that returns wrong type" @classmethod - def execute(cls, organization, params: EmptyParams) -> str: + def execute(cls, organization: Any, params: EmptyParams) -> str: return 123 # type: ignore[return-value] # Returns wrong type to test runtime validation @@ -89,7 +91,7 @@ def get_description(cls) -> str: return "Fetches user information" @classmethod - def execute(cls, organization, params: GetUserInfoParams) -> str: + def execute(cls, organization: Any, params: GetUserInfoParams) -> str: return f"User {params.user_id}" @@ -101,7 +103,7 @@ def get_description(cls) -> str: return "Search application logs" @classmethod - def execute(cls, organization, params: SearchLogsParams) -> str: + def execute(cls, organization: Any, params: SearchLogsParams) -> str: return f"Found logs for: {params.query}" @@ -113,7 +115,7 @@ def get_description(cls) -> str: return "Process a list of items" @classmethod - def execute(cls, organization, params: ProcessItemsParams) -> str: + def execute(cls, organization: Any, params: ProcessItemsParams) -> str: return "Processed" @@ -136,7 +138,7 @@ def get_description(cls) -> str: return "Nested tool" @classmethod - def execute(cls, organization, params: EmptyParams) -> str: + def execute(cls, organization: Any, params: EmptyParams) -> str: return "test" with pytest.raises(ValueError) as cm: @@ -246,7 +248,7 @@ def get_description(cls) -> str: return "Tool without params_model" @classmethod - def execute(cls, organization, params: BaseModel) -> str: + def execute(cls, organization: Any, params: BaseModel) -> str: return "test" assert "must define a params_model" in str(cm.value) diff --git a/tests/sentry/seer/agent/test_embed_widgets.py b/tests/sentry/seer/agent/test_embed_widgets.py index 293fb0667607..8efd8bdbcdcc 100644 --- a/tests/sentry/seer/agent/test_embed_widgets.py +++ b/tests/sentry/seer/agent/test_embed_widgets.py @@ -23,7 +23,7 @@ class GetEmbedWidgetsTest(TestCase): - def test_no_flags_returns_all_widgets(self): + def test_no_flags_returns_all_widgets(self) -> None: with patch( "sentry.seer.agent.embed_widgets._WIDGETS", [_UNFLAGGED_WIDGET], @@ -32,7 +32,7 @@ def test_no_flags_returns_all_widgets(self): assert widgets == [_UNFLAGGED_WIDGET] - def test_flagged_widget_excluded_without_flag(self): + def test_flagged_widget_excluded_without_flag(self) -> None: with patch( "sentry.seer.agent.embed_widgets._WIDGETS", [_UNFLAGGED_WIDGET, _FLAGGED_WIDGET], @@ -43,7 +43,7 @@ def test_flagged_widget_excluded_without_flag(self): assert names == {"timestamp"} @with_feature(_FLAG) - def test_flagged_widget_included_with_flag(self): + def test_flagged_widget_included_with_flag(self) -> None: with patch( "sentry.seer.agent.embed_widgets._WIDGETS", [_UNFLAGGED_WIDGET, _FLAGGED_WIDGET], @@ -54,7 +54,7 @@ def test_flagged_widget_included_with_flag(self): assert names == {"timestamp", "flagged"} @with_feature(_FLAG) - def test_flagged_widget_excluded_without_organization(self): + def test_flagged_widget_excluded_without_organization(self) -> None: # A widget's flag can't be evaluated without an org, so it is dropped even # when the flag would otherwise be enabled. with patch( @@ -66,7 +66,7 @@ def test_flagged_widget_excluded_without_organization(self): names = {w["name"] for w in widgets} assert names == {"timestamp"} - def test_multi_flagged_widget_excluded_without_any_flag(self): + def test_multi_flagged_widget_excluded_without_any_flag(self) -> None: with patch( "sentry.seer.agent.embed_widgets._WIDGETS", [_UNFLAGGED_WIDGET, _MULTI_FLAGGED_WIDGET], @@ -77,7 +77,7 @@ def test_multi_flagged_widget_excluded_without_any_flag(self): assert names == {"timestamp"} @with_feature(_OTHER_FLAG) - def test_multi_flagged_widget_included_with_one_flag(self): + def test_multi_flagged_widget_included_with_one_flag(self) -> None: # Any one flag in the list is enough: an org holds whichever flag its # plan grants, never the whole list. with patch( diff --git a/tests/sentry/seer/agent/test_index_data.py b/tests/sentry/seer/agent/test_index_data.py index 6eecb073bed8..98b548e706e9 100644 --- a/tests/sentry/seer/agent/test_index_data.py +++ b/tests/sentry/seer/agent/test_index_data.py @@ -1,5 +1,6 @@ import uuid from datetime import timedelta +from typing import Any from unittest import mock import orjson @@ -239,7 +240,9 @@ def test_get_profiles_for_trace(self) -> None: with mock.patch("sentry.seer.agent.utils.get_from_profiling_service") as mock_service: # Mock profile service responses for both transaction and continuous profiles - def mock_service_response(method, path, *args, **kwargs): + def mock_service_response( + method: str, path: str, *args: Any, **kwargs: Any + ) -> mock.Mock: if f"profiles/{profile1_id}" in path: response = mock.Mock() response.status = 200 @@ -434,7 +437,9 @@ def test_get_profiles_for_trace_aggregates_duplicate_profiles(self) -> None: # Mock the external profiling service calls with mock.patch("sentry.seer.agent.utils.get_from_profiling_service") as mock_service: # Mock profile service response - def mock_service_response(method, path, *args, **kwargs): + def mock_service_response( + method: str, path: str, *args: Any, **kwargs: Any + ) -> mock.Mock: response = mock.Mock() response.status = 200 response.data = orjson.dumps( @@ -561,7 +566,9 @@ def test_get_profiles_for_trace_aggregates_continuous_profiles(self) -> None: # Mock the external profiling service calls with mock.patch("sentry.seer.agent.utils.get_from_profiling_service") as mock_service: # Mock profile service response for continuous profiles (/chunks endpoint) - def mock_service_response(method, path, *args, **kwargs): + def mock_service_response( + method: str, path: str, *args: Any, **kwargs: Any + ) -> mock.Mock: response = mock.Mock() response.status = 200 response.data = orjson.dumps( @@ -685,8 +692,13 @@ def test_get_profiles_for_trace_uses_aggregated_timestamps(self) -> None: with mock.patch("sentry.seer.agent.index_data.fetch_profile_data") as mock_fetch: # Mock to capture the timestamps passed to fetch_profile_data def capture_and_return( - profile_id, organization_id, project_id, start_ts, end_ts, is_continuous - ): + profile_id: str, + organization_id: int, + project_id: int, + start_ts: Any, + end_ts: Any, + is_continuous: bool, + ) -> dict[str, Any]: captured_timestamps["start_ts"] = start_ts captured_timestamps["end_ts"] = end_ts captured_timestamps["profile_id"] = profile_id @@ -743,7 +755,7 @@ def capture_and_return( class TestGetIssuesForTransaction(APITransactionTestCase, SpanTestCase, SharedSnubaMixin): @property - def backend(self): + def backend(self) -> EventsDatasetSnubaSearchBackend: return EventsDatasetSnubaSearchBackend() def setUp(self) -> None: @@ -792,10 +804,14 @@ def test_get_issues_for_transaction(self) -> None: project_id=self.project.id, ) + assert event1.group is not None + assert event2.group is not None + assert event3.group is not None groups = [event1.group, event2.group, event3.group] for group in groups: latest_event = group.get_latest_event() + assert latest_event is not None transaction_tag = latest_event.get_tag("transaction") assert transaction_tag == transaction_name, ( f"Expected transaction tag '{transaction_name}', got '{transaction_tag}'" @@ -845,6 +861,7 @@ def test_get_issues_for_transaction_populates_short_id_and_project(self) -> None issue = result.issues[0] group = event.group + assert group is not None assert issue.short_id == group.qualified_short_id assert issue.project == group.project_id @@ -954,11 +971,15 @@ def test_get_issues_for_transaction_with_quotes(self) -> None: ) # Verify both events were stored with correct transaction names + assert event1.group is not None latest_event1 = event1.group.get_latest_event() + assert latest_event1 is not None transaction_tag1 = latest_event1.get_tag("transaction") assert transaction_tag1 == transaction_name_quotes + assert event2.group is not None latest_event2 = event2.group.get_latest_event() + assert latest_event2 is not None transaction_tag2 = latest_event2.get_tag("transaction") assert transaction_tag2 == transaction_name_in @@ -972,6 +993,7 @@ def test_get_issues_for_transaction_with_quotes(self) -> None: assert len(result1.issues) == 1 issue1 = result1.issues[0] + assert event1.group is not None assert issue1.id == event1.group.id assert issue1.transaction == transaction_name_quotes @@ -985,5 +1007,6 @@ def test_get_issues_for_transaction_with_quotes(self) -> None: assert len(result2.issues) == 1 issue2 = result2.issues[0] + assert event2.group is not None assert issue2.id == event2.group.id assert issue2.transaction == transaction_name_in diff --git a/tests/sentry/seer/agent/test_service_map_utils.py b/tests/sentry/seer/agent/test_service_map_utils.py index b0c65cc054e4..dd338c49c66c 100644 --- a/tests/sentry/seer/agent/test_service_map_utils.py +++ b/tests/sentry/seer/agent/test_service_map_utils.py @@ -5,13 +5,15 @@ for the agent service map feature. """ +from collections.abc import Iterator from datetime import datetime, timedelta, timezone -from typing import Any +from typing import Any, cast from unittest import mock from uuid import uuid4 import pytest +from sentry.models.project import Project from sentry.search.events.types import SnubaParams from sentry.seer.agent.service_map_utils import ( _build_nodes, @@ -27,7 +29,7 @@ from sentry.viewer_context import Actor, ActorType, ViewerContext, get_viewer_context -def _make_snuba_params(organization, projects): +def _make_snuba_params(organization: Any, projects: Any) -> SnubaParams: end = datetime.now(timezone.utc) start = end - timedelta(hours=24) return SnubaParams(start=start, end=end, projects=projects, organization=organization) @@ -138,7 +140,9 @@ def test_handles_no_projects(self) -> None: @mock.patch("sentry.tasks.seer.context_engine_index._send_to_seer") @mock.patch("sentry.tasks.seer.context_engine_index._query_service_dependencies") - def test_complete_workflow(self, mock_dependencies, mock_send): + def test_complete_workflow( + self, mock_dependencies: mock.MagicMock, mock_send: mock.MagicMock + ) -> None: org = self.create_organization() project1 = self.create_project(organization=org) project2 = self.create_project(organization=org) @@ -173,7 +177,9 @@ def send_to_seer(*args: object, **kwargs: object) -> None: @mock.patch("sentry.tasks.seer.context_engine_index._build_nodes") @mock.patch("sentry.tasks.seer.context_engine_index._query_service_dependencies") - def test_handles_no_nodes(self, mock_dependencies, mock_build_nodes): + def test_handles_no_nodes( + self, mock_dependencies: mock.MagicMock, mock_build_nodes: mock.MagicMock + ) -> None: org = self.create_organization() self.create_project(organization=org) @@ -187,7 +193,7 @@ def test_handles_no_nodes(self, mock_dependencies, mock_build_nodes): mock_send.assert_not_called() @mock.patch("sentry.tasks.seer.context_engine_index._query_service_dependencies") - def test_handles_exception(self, mock_dependencies): + def test_handles_exception(self, mock_dependencies: mock.MagicMock) -> None: org = self.create_organization() mock_dependencies.side_effect = Exception("Test error") @@ -201,7 +207,7 @@ class TestQueryServiceDependenciesPhase2(TestCase): """Unit tests for Phase 2 fallback scan for uncovered projects""" @mock.patch("sentry.seer.agent.service_map_utils.Spans.run_table_query") - def test_phase2_triggered_for_uncovered_projects(self, mock_query): + def test_phase2_triggered_for_uncovered_projects(self, mock_query: mock.MagicMock) -> None: org = self.create_organization() project_covered = self.create_project(organization=org) project_uncovered = self.create_project(organization=org) @@ -217,9 +223,9 @@ def test_phase2_triggered_for_uncovered_projects(self, mock_query): } ] # Phase 2 returns empty - phase2_data: list[dict] = [] + phase2_data: list[dict[str, Any]] = [] # Phase 3 parent resolution returns empty - phase3_data: list[dict] = [] + phase3_data: list[dict[str, Any]] = [] mock_query.side_effect = [ {"data": phase1_data}, @@ -241,7 +247,9 @@ def test_phase2_triggered_for_uncovered_projects(self, mock_query): assert "has:parent_span" not in phase2_call_kwargs["query_string"] @mock.patch("sentry.seer.agent.service_map_utils.Spans.run_table_query") - def test_phase2_not_triggered_when_all_projects_covered(self, mock_query): + def test_phase2_not_triggered_when_all_projects_covered( + self, mock_query: mock.MagicMock + ) -> None: org = self.create_organization() project = self.create_project(organization=org) snuba_params = _make_snuba_params(org, [project]) @@ -256,7 +264,7 @@ def test_phase2_not_triggered_when_all_projects_covered(self, mock_query): } ] # Phase 3 parent resolution returns empty - phase3_data: list[dict] = [] + phase3_data: list[dict[str, Any]] = [] mock_query.side_effect = [ {"data": phase1_data}, @@ -273,7 +281,7 @@ def test_phase2_not_triggered_when_all_projects_covered(self, mock_query): assert "is_transaction" not in phase3_call_kwargs["query_string"] @mock.patch("sentry.seer.agent.service_map_utils.Spans.run_table_query") - def test_phase1_uses_has_parent_span_filter(self, mock_query): + def test_phase1_uses_has_parent_span_filter(self, mock_query: mock.MagicMock) -> None: org = self.create_organization() project = self.create_project(organization=org) snuba_params = _make_snuba_params(org, [project]) @@ -299,15 +307,21 @@ class TestQueryServiceDependenciesIntegration(SnubaTestCase, SpanTestCase): """Integration tests for cross-project dependency extraction using real Snuba data""" @pytest.fixture(autouse=True) - def setup_test_data(self): + def setup_test_data(self) -> Iterator[None]: self.ten_mins_ago = before_now(minutes=10) yield - def _snuba_params(self): + def _snuba_params(self) -> SnubaParams: projects = list(self.organization.project_set.all()) return _make_snuba_params(self.organization, projects) - def _verify_edge(self, edges, source_slug, target_slug, expected_count=None): + def _verify_edge( + self, + edges: Any, + source_slug: str, + target_slug: str, + expected_count: int | None = None, + ) -> dict[str, Any]: """Verify specific edge exists with expected properties""" matching = [ e @@ -319,9 +333,12 @@ def _verify_edge(self, edges, source_slug, target_slug, expected_count=None): ) if expected_count: assert matching[0]["count"] == expected_count - return matching[0] + # justification: service map edges are decoded JSON objects. + return cast("dict[str, Any]", matching[0]) - def _create_service_topology(self, topology_map, start_ts): + def _create_service_topology( + self, topology_map: Any, start_ts: Any + ) -> tuple[dict[str, Project], list[dict[str, Any]]]: """ Create multi-project topology from map. @@ -802,11 +819,11 @@ class TestClassifyServiceRolesIntegration(SnubaTestCase, SpanTestCase): """Integration tests for service role classification with real topologies""" @pytest.fixture(autouse=True) - def setup_test_data(self): + def setup_test_data(self) -> Iterator[None]: self.ten_mins_ago = before_now(minutes=10) yield - def _snuba_params(self): + def _snuba_params(self) -> SnubaParams: projects = list(self.organization.project_set.all()) return _make_snuba_params(self.organization, projects) @@ -1034,7 +1051,7 @@ class TestBuildServiceMapIntegration(SnubaTestCase, SpanTestCase): """End-to-end integration tests for the complete service map pipeline""" @pytest.fixture(autouse=True) - def setup_test_data(self): + def setup_test_data(self) -> Iterator[None]: self.ten_mins_ago = before_now(minutes=10) yield diff --git a/tests/sentry/seer/agent/test_tools.py b/tests/sentry/seer/agent/test_tools.py index 0a6466b7bfee..772c19e896e3 100644 --- a/tests/sentry/seer/agent/test_tools.py +++ b/tests/sentry/seer/agent/test_tools.py @@ -1,7 +1,7 @@ import uuid from datetime import UTC, datetime, timedelta from typing import Any, Literal -from unittest.mock import ANY, Mock, patch +from unittest.mock import ANY, MagicMock, Mock, patch import pytest from django.core.cache import cache @@ -16,6 +16,7 @@ from sentry.issues.grouptype import ProfileFileIOGroupType from sentry.issues.ownership.grammar import Matcher, Owner, Rule, dump_schema from sentry.models.activity import Activity +from sentry.models.commit import Commit from sentry.models.group import Group from sentry.models.groupassignee import GroupAssignee from sentry.models.groupowner import GroupOwner, GroupOwnerType, SuspectCommitStrategy @@ -411,7 +412,7 @@ def test_spans_query_nonexistent_organization(self) -> None: assert table_result is None @patch("sentry.seer.agent.tools.client.get") - def test_spans_table_query_error_handling(self, mock_client_get): + def test_spans_table_query_error_handling(self, mock_client_get: MagicMock) -> None: """Test error handling for API errors: 400 errors return error dict, non-400 errors are re-raised""" # Test 400 error with dict body containing detail error_detail_msg = "Invalid query: field 'invalid_field' does not exist" @@ -800,7 +801,7 @@ def setUp(self) -> None: def _test_get_trace_waterfall(self, use_short_id: bool) -> None: transaction_name = "api/users/profile" trace_id = uuid.uuid4().hex - spans: list[dict] = [] + spans: list[dict[str, Any]] = [] for i in range(5): # Create a span tree for this trace span = self.create_span( @@ -826,7 +827,7 @@ def _test_get_trace_waterfall(self, use_short_id: bool) -> None: seen_span_ids = [] root_span_ids = [] - def check_span(s): + def check_span(s: Any) -> None: if "parent_span_id" not in s: # Not a span. return @@ -872,7 +873,7 @@ def test_get_trace_waterfall_wrong_project(self) -> None: other_org = self.create_organization() other_project = self.create_project(organization=other_org) - spans: list[dict] = [] + spans: list[dict[str, Any]] = [] for i in range(2): span = self.create_span( { @@ -899,7 +900,7 @@ def test_get_trace_waterfall_sliding_window_second_period(self) -> None: trace_id = uuid.uuid4().hex twenty_days_ago = before_now(days=20) - spans: list[dict] = [] + spans: list[dict[str, Any]] = [] for i in range(3): span = self.create_span( { @@ -927,7 +928,7 @@ def test_get_trace_waterfall_sliding_window_last_full_fidelity_window(self) -> N trace_id = uuid.uuid4().hex twenty_seven_days_ago = before_now(days=27) - spans: list[dict] = [] + spans: list[dict[str, Any]] = [] for i in range(3): span = self.create_span( { @@ -955,7 +956,7 @@ def test_get_trace_waterfall_sliding_window_beyond_limit(self) -> None: trace_id = uuid.uuid4().hex one_hundred_days_ago = before_now(days=100) - spans: list[dict] = [] + spans: list[dict[str, Any]] = [] for i in range(3): span = self.create_span( { @@ -1215,12 +1216,14 @@ class _SentryEventData(BaseModel): """ title: str - entries: list[dict] + entries: list[dict[str, Any]] groupID: str | None tags: list[dict[str, str | None]] | None = None -def _validate_event_timeseries(timeseries: dict, expected_total: int | None = None): +def _validate_event_timeseries( + timeseries: dict[str, Any], expected_total: int | None = None +) -> None: assert isinstance(timeseries, dict) assert "count()" in timeseries assert "data" in timeseries["count()"] @@ -1244,12 +1247,12 @@ def _validate_event_timeseries(timeseries: dict, expected_total: int | None = No class TestGetIssueDetails(APITransactionTestCase, SnubaTestCase, SearchIssueTestMixin): """Tests for get_issue_details — fetching issue-level metadata, timeseries, tags, and activity.""" - def _make_error_event(self, timestamp=None): + def _make_error_event(self, timestamp: Any = None) -> Event: data = load_data("python", timestamp=timestamp or before_now(minutes=5)) data["exception"] = {"values": [{"type": "Exception", "value": "Test exception"}]} return self.store_event(data=data, project_id=self.project.id) - def _assert_issue_response_shape(self, result: IssueDetailsResponse): + def _assert_issue_response_shape(self, result: IssueDetailsResponse) -> None: assert isinstance(result["issue"], dict) _IssueMetadata.parse_obj(result["issue"]) assert isinstance(result["event_timeseries"], dict | None) @@ -1264,7 +1267,7 @@ def _assert_issue_response_shape(self, result: IssueDetailsResponse): @patch("sentry.seer.agent.tools._get_issue_event_timeseries") @patch("sentry.seer.agent.tools.get_all_tags_overview") - def test_by_numeric_issue_id(self, mock_tags, mock_ts): + def test_by_numeric_issue_id(self, mock_tags: MagicMock, mock_ts: MagicMock) -> None: """Fetching by numeric issue_id returns issue metadata.""" mock_ts.return_value = ({"count()": {"data": []}}, "6h", "15m") mock_tags.return_value = {"tags_overview": [{"key": "env", "top_values": []}]} @@ -1287,7 +1290,7 @@ def test_by_numeric_issue_id(self, mock_tags, mock_ts): @patch("sentry.seer.agent.tools._get_issue_event_timeseries") @patch("sentry.seer.agent.tools.get_all_tags_overview") - def test_by_qualified_short_id(self, mock_tags, mock_ts): + def test_by_qualified_short_id(self, mock_tags: MagicMock, mock_ts: MagicMock) -> None: """Fetching by qualified short ID (PROJECT-123) resolves the correct group.""" mock_ts.return_value = ({"count()": {"data": []}}, "6h", "15m") mock_tags.return_value = {"tags_overview": []} @@ -1295,6 +1298,7 @@ def test_by_qualified_short_id(self, mock_tags, mock_ts): event = self._make_error_event() group = event.group assert isinstance(group, Group) + assert group.qualified_short_id is not None result = get_issue_details( organization_id=self.organization.id, @@ -1307,11 +1311,12 @@ def test_by_qualified_short_id(self, mock_tags, mock_ts): assert result["project_id"] == group.project_id assert result["project_slug"] == group.project.slug - def test_by_qualified_short_id_scoped_to_project_slug(self): + def test_by_qualified_short_id_scoped_to_project_slug(self) -> None: """A short ID is only resolvable within the project_slug-scoped projects.""" event = self._make_error_event() group = event.group assert isinstance(group, Group) + assert group.qualified_short_id is not None other_project = self.create_project(organization=self.organization, name="other project") @@ -1327,7 +1332,7 @@ def test_by_qualified_short_id_scoped_to_project_slug(self): @patch("sentry.seer.agent.tools._get_issue_event_timeseries") @patch("sentry.seer.agent.tools.get_all_tags_overview") - def test_timeseries_values_forwarded(self, mock_tags, mock_ts): + def test_timeseries_values_forwarded(self, mock_tags: MagicMock, mock_ts: MagicMock) -> None: """Timeseries data and its period/interval metadata are included in the response.""" mock_ts.return_value = ({"count()": {"data": [[1000, [{"count": 3}]]]}}, "24h", "1h") mock_tags.return_value = {"tags_overview": []} @@ -1348,7 +1353,7 @@ def test_timeseries_values_forwarded(self, mock_tags, mock_ts): @patch("sentry.seer.agent.tools._get_issue_event_timeseries") @patch("sentry.seer.agent.tools.get_all_tags_overview") - def test_timeseries_start_end_forwarded(self, mock_tags, mock_ts): + def test_timeseries_start_end_forwarded(self, mock_tags: MagicMock, mock_ts: MagicMock) -> None: """start/end strings are parsed and forwarded to _get_issue_event_timeseries.""" mock_ts.return_value = ({"count()": {"data": []}}, "7d", "6h") mock_tags.return_value = {"tags_overview": []} @@ -1373,7 +1378,9 @@ def test_timeseries_start_end_forwarded(self, mock_tags, mock_ts): @patch("sentry.seer.agent.tools._get_issue_event_timeseries") @patch("sentry.seer.agent.tools.get_all_tags_overview") - def test_timeseries_failure_returns_nulls(self, mock_tags, mock_ts): + def test_timeseries_failure_returns_nulls( + self, mock_tags: MagicMock, mock_ts: MagicMock + ) -> None: """If _get_issue_event_timeseries raises, timeseries fields are None rather than propagating.""" mock_ts.side_effect = Exception("snuba down") mock_tags.return_value = {"tags_overview": []} @@ -1396,7 +1403,7 @@ def test_timeseries_failure_returns_nulls(self, mock_tags, mock_ts): @patch("sentry.seer.agent.tools._get_issue_event_timeseries") @patch("sentry.seer.agent.tools.get_all_tags_overview") - def test_tags_overview_forwarded(self, mock_tags, mock_ts): + def test_tags_overview_forwarded(self, mock_tags: MagicMock, mock_ts: MagicMock) -> None: """tags_overview from get_all_tags_overview is included in the response.""" mock_ts.return_value = ({"count()": {"data": []}}, "6h", "15m") expected_tags = {"tags_overview": [{"key": "browser", "top_values": ["Chrome"]}]} @@ -1415,7 +1422,7 @@ def test_tags_overview_forwarded(self, mock_tags, mock_ts): @patch("sentry.seer.agent.tools._get_issue_event_timeseries") @patch("sentry.seer.agent.tools.get_all_tags_overview") - def test_tags_failure_returns_none(self, mock_tags, mock_ts): + def test_tags_failure_returns_none(self, mock_tags: MagicMock, mock_ts: MagicMock) -> None: """If get_all_tags_overview raises, tags_overview is None rather than propagating.""" mock_ts.return_value = ({"count()": {"data": []}}, "6h", "15m") mock_tags.side_effect = Exception("tags unavailable") @@ -1438,7 +1445,7 @@ def test_tags_failure_returns_none(self, mock_tags, mock_ts): @patch("sentry.seer.agent.tools._get_issue_event_timeseries") @patch("sentry.seer.agent.tools.get_all_tags_overview") - def test_user_activity_included(self, mock_tags, mock_ts): + def test_user_activity_included(self, mock_tags: MagicMock, mock_ts: MagicMock) -> None: """Activity records for the group are serialized and included.""" mock_ts.return_value = ({"count()": {"data": []}}, "6h", "15m") mock_tags.return_value = {"tags_overview": []} @@ -1467,7 +1474,9 @@ def test_user_activity_included(self, mock_tags, mock_ts): @patch("sentry.seer.agent.tools._get_issue_event_timeseries") @patch("sentry.seer.agent.tools.get_all_tags_overview") - def test_user_attributed_seer_actions_are_included(self, mock_tags, mock_ts): + def test_user_attributed_seer_actions_are_included( + self, mock_tags: MagicMock, mock_ts: MagicMock + ) -> None: mock_ts.return_value = ({"count()": {"data": []}}, "6h", "15m") mock_tags.return_value = {"tags_overview": []} @@ -1503,7 +1512,7 @@ def test_user_attributed_seer_actions_are_included(self, mock_tags, mock_ts): @patch("sentry.seer.agent.tools._get_issue_event_timeseries") @patch("sentry.seer.agent.tools.get_all_tags_overview") - def test_assigned_user_included(self, mock_tags, mock_ts): + def test_assigned_user_included(self, mock_tags: MagicMock, mock_ts: MagicMock) -> None: """Issue metadata includes assignedTo when a user is assigned.""" mock_ts.return_value = ({"count()": {"data": []}}, "6h", "15m") mock_tags.return_value = {"tags_overview": []} @@ -1529,7 +1538,7 @@ def test_assigned_user_included(self, mock_tags, mock_ts): @patch("sentry.seer.agent.tools._get_issue_event_timeseries") @patch("sentry.seer.agent.tools.get_all_tags_overview") - def test_assigned_team_included(self, mock_tags, mock_ts): + def test_assigned_team_included(self, mock_tags: MagicMock, mock_ts: MagicMock) -> None: """Issue metadata includes assignedTo when a team is assigned.""" mock_ts.return_value = ({"count()": {"data": []}}, "6h", "15m") mock_tags.return_value = {"tags_overview": []} @@ -1557,7 +1566,7 @@ def test_assigned_team_included(self, mock_tags, mock_ts): @patch("sentry.seer.agent.tools._get_issue_event_timeseries") @patch("sentry.seer.agent.tools.get_all_tags_overview") - def test_no_projects_returns_none(self, mock_tags, mock_ts): + def test_no_projects_returns_none(self, mock_tags: MagicMock, mock_ts: MagicMock) -> None: """Returns None when project_slug doesn't match any project.""" mock_ts.return_value = ({"count()": {"data": []}}, "6h", "15m") mock_tags.return_value = {"tags_overview": []} @@ -1583,12 +1592,12 @@ class TestGetIssueCommitters(APITransactionTestCase, SnubaTestCase, SearchIssueT (broader pool shipped around first-seen). """ - def _make_error_event(self): + def _make_error_event(self) -> Event: data = load_data("python", timestamp=before_now(minutes=5)) data["exception"] = {"values": [{"type": "Exception", "value": "Test exception"}]} return self.store_event(data=data, project_id=self.project.id) - def _add_suspect_commit(self, group): + def _add_suspect_commit(self, group: Any) -> Commit: repo = self.create_repo(project=self.project, name="getsentry/sentry") author = self.create_commit_author(organization_id=self.organization.id, user=self.user) commit = self.create_commit(repo=repo, author=author, message="fix: the failing function") @@ -1605,7 +1614,7 @@ def _add_suspect_commit(self, group): ) return commit - def _make_blame_event(self, author_email): + def _make_blame_event(self, author_email: str) -> Group: """Store an event whose stacktrace frames are blamed on a release commit.""" repo = self.create_repo(project=self.project, name="getsentry/sentry") release = self.create_release(project=self.project, version="blame-v1") @@ -1643,7 +1652,7 @@ def _make_blame_event(self, author_email): ) return group - def _make_event_with_multiple_release_commits(self, author_email): + def _make_event_with_multiple_release_commits(self, author_email: str) -> Group: """Store an event linked to a release shipping several commits with differing numbers of file changes, to exercise the per-commit file-change-count mapping. """ @@ -1691,7 +1700,7 @@ def _make_event_with_multiple_release_commits(self, author_email): ) return group - def test_returns_suspect_commits(self): + def test_returns_suspect_commits(self) -> None: event = self._make_error_event() group = event.group assert isinstance(group, Group) @@ -1712,10 +1721,11 @@ def test_returns_suspect_commits(self): assert suspect[0]["commits"][0]["id"] == commit.key assert suspect[0]["commits"][0]["suspectCommitType"] == "via SCM integration" - def test_resolves_by_qualified_short_id(self): + def test_resolves_by_qualified_short_id(self) -> None: event = self._make_error_event() group = event.group assert isinstance(group, Group) + assert group.qualified_short_id is not None self._add_suspect_commit(group) result = get_issue_committers( @@ -1726,7 +1736,7 @@ def test_resolves_by_qualified_short_id(self): assert result is not None assert len(result["suspect_commits"]) == 1 - def test_returns_file_committers_from_stacktrace_blame(self): + def test_returns_file_committers_from_stacktrace_blame(self) -> None: group = self._make_blame_event(self.user.email) result = get_issue_committers( @@ -1743,7 +1753,9 @@ def test_returns_file_committers_from_stacktrace_blame(self): assert commit["score"] is not None @patch("sentry.seer.agent.tools._get_recommended_event", return_value=None) - def test_stack_commits_fall_back_to_latest_event_without_span(self, mock_recommended): + def test_stack_commits_fall_back_to_latest_event_without_span( + self, mock_recommended: MagicMock + ) -> None: """When no span-bearing recommended event exists (the common case for error issues), frame blame falls back to the issue's latest event so stack_commits is still populated instead of silently empty.""" @@ -1760,7 +1772,7 @@ def test_stack_commits_fall_back_to_latest_event_without_span(self, mock_recomme assert stack_commits[0]["author"]["email"] == self.user.email mock_recommended.assert_called_once() - def test_returns_release_commits_in_window(self): + def test_returns_release_commits_in_window(self) -> None: group = self._make_blame_event(self.user.email) result = get_issue_committers( @@ -1776,7 +1788,7 @@ def test_returns_release_commits_in_window(self): assert commit["files_changed_count"] == 1 assert commit["is_merge_commit"] is False - def test_release_commits_file_change_counts_are_per_commit(self): + def test_release_commits_file_change_counts_are_per_commit(self) -> None: """Regression test: with multiple candidate commits, the per-commit ``files_changed_count`` is built from ``values_list("commit_id").annotate(n=Count("id"))`` passed to ``dict()``. @@ -1802,7 +1814,7 @@ def test_release_commits_file_change_counts_are_per_commit(self): merge_commit = next(c for c in release if c["id"] == "c" * 40) assert merge_commit["is_merge_commit"] is True - def test_no_commit_data_returns_empty_lists(self): + def test_no_commit_data_returns_empty_lists(self) -> None: event = self._make_error_event() group = event.group assert isinstance(group, Group) @@ -1817,7 +1829,7 @@ def test_no_commit_data_returns_empty_lists(self): assert result["suspect_commits"] == [] assert result["release_commits"] == [] - def test_returns_none_when_issue_does_not_exist(self): + def test_returns_none_when_issue_does_not_exist(self) -> None: result = get_issue_committers( organization_id=self.organization.id, issue_id="123456789", @@ -1869,7 +1881,7 @@ def _set_ownership( ) cache.delete(ProjectOwnership.get_cache_key(self.project.id)) - def test_returns_user_owner(self): + def test_returns_user_owner(self) -> None: group = self._make_event_on_path("src/app/checkout.py") self._set_ownership( [Rule(Matcher("path", "*checkout.py"), [Owner("user", self.user.email)])] @@ -1892,7 +1904,7 @@ def test_returns_user_owner(self): assert "*checkout.py" in result["matched_rules"] assert result["auto_assignment"] is False - def test_returns_team_owner(self): + def test_returns_team_owner(self) -> None: group = self._make_event_on_path("src/app/billing.py") self._set_ownership([Rule(Matcher("path", "*billing.py"), [Owner("team", self.team.slug)])]) @@ -1909,8 +1921,9 @@ def test_returns_team_owner(self): assert owners[0]["email"] is None assert owners[0]["username"] is None - def test_resolves_by_qualified_short_id(self): + def test_resolves_by_qualified_short_id(self) -> None: group = self._make_event_on_path("src/app/checkout.py") + assert group.qualified_short_id is not None self._set_ownership( [Rule(Matcher("path", "*checkout.py"), [Owner("user", self.user.email)])] ) @@ -1924,7 +1937,7 @@ def test_resolves_by_qualified_short_id(self): assert len(result["owners"]) == 1 assert result["owners"][0]["email"] == self.user.email - def test_auto_assignment_flag_reflects_project_setting(self): + def test_auto_assignment_flag_reflects_project_setting(self) -> None: group = self._make_event_on_path("src/app/checkout.py") self._set_ownership( [Rule(Matcher("path", "*checkout.py"), [Owner("user", self.user.email)])], @@ -1939,7 +1952,7 @@ def test_auto_assignment_flag_reflects_project_setting(self): assert result is not None assert result["auto_assignment"] is True - def test_no_matching_rule_returns_empty_owners(self): + def test_no_matching_rule_returns_empty_owners(self) -> None: group = self._make_event_on_path("src/app/checkout.py") # Rule targets a different file, so nothing covers the failing frame. self._set_ownership( @@ -1955,7 +1968,7 @@ def test_no_matching_rule_returns_empty_owners(self): assert result["owners"] == [] assert result["matched_rules"] == [] - def test_no_ownership_config_returns_empty_owners(self): + def test_no_ownership_config_returns_empty_owners(self) -> None: group = self._make_event_on_path("src/app/checkout.py") result = get_issue_ownership( @@ -1968,7 +1981,7 @@ def test_no_ownership_config_returns_empty_owners(self): assert result["matched_rules"] == [] assert result["auto_assignment"] is False - def test_returns_none_when_issue_does_not_exist(self): + def test_returns_none_when_issue_does_not_exist(self) -> None: result = get_issue_ownership( organization_id=self.organization.id, issue_id="123456789", @@ -1980,7 +1993,7 @@ class TestGetTeamMembers(APITestCase): """Tests for get_team_members — expands a team into its active member users so the agent can drill from a team-level owner (from get_issue_ownership) down to people.""" - def test_returns_active_members(self): + def test_returns_active_members(self) -> None: dev = self.create_user(email="dev@example.com", username="dev") lead = self.create_user(email="lead@example.com", username="lead") team = self.create_team(organization=self.organization, members=[dev, lead]) @@ -2001,7 +2014,7 @@ def test_returns_active_members(self): assert all(m["slug"] is None for m in members) assert all(m["name"] for m in members) - def test_member_without_email_retains_username(self): + def test_member_without_email_retains_username(self) -> None: user = self.create_user(email="", username="sso-user") team = self.create_team(organization=self.organization, members=[user]) @@ -2014,7 +2027,7 @@ def test_member_without_email_retains_username(self): assert result["members"][0]["email"] == "" assert result["members"][0]["username"] == "sso-user" - def test_empty_team_returns_no_members(self): + def test_empty_team_returns_no_members(self) -> None: team = self.create_team(organization=self.organization, members=[]) result = get_team_members( @@ -2026,14 +2039,14 @@ def test_empty_team_returns_no_members(self): assert result["team_slug"] == team.slug assert result["members"] == [] - def test_returns_none_for_unknown_team(self): + def test_returns_none_for_unknown_team(self) -> None: result = get_team_members( organization_id=self.organization.id, team_slug="no-such-team", ) assert result is None - def test_does_not_leak_team_from_other_org(self): + def test_does_not_leak_team_from_other_org(self) -> None: other_org = self.create_organization() other_team = self.create_team(organization=other_org, members=[self.create_user()]) @@ -2043,7 +2056,7 @@ def test_does_not_leak_team_from_other_org(self): ) assert result is None - def test_excludes_non_active_team(self): + def test_excludes_non_active_team(self) -> None: from sentry.models.team import TeamStatus team = self.create_team( @@ -2082,7 +2095,7 @@ def _create_assignment_activity( datetime=when, ) - def test_returns_active_project_members_with_recent_assignees_first(self): + def test_returns_active_project_members_with_recent_assignees_first(self) -> None: alice = self.create_user(email="alice@example.com", name="Alice") bob = self.create_user(email="bob@example.com", name="Bob") carol = self.create_user(email="carol@example.com", name="Carol") @@ -2140,7 +2153,7 @@ def test_returns_active_project_members_with_recent_assignees_first(self): {"id": dana.id, "username": dana.username}, ] - def test_defaults_to_three_and_randomizes_members_without_assignment_history(self): + def test_defaults_to_three_and_randomizes_members_without_assignment_history(self) -> None: users = [ self.create_user(email=f"user-{index}@example.com", name=f"User {index}") for index in range(4) @@ -2175,7 +2188,7 @@ def test_defaults_to_three_and_randomizes_members_without_assignment_history(sel sample_rate=1.0, ) - def test_returns_none_for_project_outside_organization(self): + def test_returns_none_for_project_outside_organization(self) -> None: other_organization = self.create_organization() other_project = self.create_project(organization=other_organization) @@ -2186,7 +2199,7 @@ def test_returns_none_for_project_outside_organization(self): assert result is None - def test_rejects_invalid_limit(self): + def test_rejects_invalid_limit(self) -> None: for limit in (0, 21, True): with self.subTest(limit=limit), pytest.raises(BadRequest): get_project_members( @@ -2197,7 +2210,7 @@ def test_rejects_invalid_limit(self): class TestGetGroupAssignees(APITestCase): - def test_returns_active_user_assignees_by_group_id(self): + def test_returns_active_user_assignees_by_group_id(self) -> None: second_user = self.create_user(email="second@example.com") self.create_member(user=second_user, organization=self.organization) first_group = self.create_group(project=self.project) @@ -2236,7 +2249,9 @@ def test_returns_active_user_assignees_by_group_id(self): } @patch("sentry.seer.agent.tools.user_service.get_many") - def test_skips_user_service_when_no_groups_have_user_assignees(self, get_many): + def test_skips_user_service_when_no_groups_have_user_assignees( + self, get_many: MagicMock + ) -> None: team_group = self.create_group(project=self.project) GroupAssignee.objects.create( project=self.project, @@ -2252,7 +2267,7 @@ def test_skips_user_service_when_no_groups_have_user_assignees(self, get_many): assert result["assignees"] == {} get_many.assert_not_called() - def test_excludes_groups_from_other_organizations(self): + def test_excludes_groups_from_other_organizations(self) -> None: other_organization = self.create_organization() other_project = self.create_project(organization=other_organization) other_group = self.create_group(project=other_project) @@ -2271,7 +2286,7 @@ def test_excludes_groups_from_other_organizations(self): assert result["assignees"] == {} - def test_rejects_more_than_one_hundred_groups(self): + def test_rejects_more_than_one_hundred_groups(self) -> None: with pytest.raises(BadRequest): get_group_assignees( organization_id=self.organization.id, @@ -2284,7 +2299,7 @@ class TestGetEventDetails( ): """Tests for get_event_details — fetching event data by event_id or recommended event by issue_id.""" - def _make_error_event(self, timestamp=None, trace_id=None): + def _make_error_event(self, timestamp: Any = None, trace_id: str | None = None) -> Event: data = load_data("python", timestamp=timestamp or before_now(minutes=5)) data["exception"] = {"values": [{"type": "Exception", "value": "Test exception"}]} if trace_id: @@ -2295,8 +2310,8 @@ def _make_error_event(self, timestamp=None, trace_id=None): return self.store_event(data=data, project_id=self.project.id) def _assert_event_response_shape( - self, result, expected_event_id: str | None, expected_trace_id: Any = ANY - ): + self, result: Any, expected_event_id: str | None, expected_trace_id: Any = ANY + ) -> None: assert result is not None assert result["project_id"] == self.project.id assert result["project_slug"] == self.project.slug @@ -2504,7 +2519,7 @@ def test_by_event_id_occurrence(self) -> None: # --- fetch by issue_id (recommended event) --- @patch("sentry.seer.agent.tools._get_recommended_event") - def test_by_issue_id_numeric(self, mock_recommended): + def test_by_issue_id_numeric(self, mock_recommended: MagicMock) -> None: """Fetching by numeric issue_id calls _get_recommended_event with the correct group and org.""" event = self._make_error_event() group = event.group @@ -2526,7 +2541,7 @@ def test_by_issue_id_numeric(self, mock_recommended): ) @patch("sentry.seer.agent.tools._get_recommended_event") - def test_by_issue_id_qualified_short_id(self, mock_recommended): + def test_by_issue_id_qualified_short_id(self, mock_recommended: MagicMock) -> None: """Qualified short ID (e.g. PROJECT-1) resolves to the correct group before calling _get_recommended_event.""" event = self._make_error_event() group = event.group @@ -2548,7 +2563,7 @@ def test_by_issue_id_qualified_short_id(self, mock_recommended): ) @patch("sentry.seer.agent.tools._get_recommended_event") - def test_by_issue_id_with_time_range(self, mock_recommended): + def test_by_issue_id_with_time_range(self, mock_recommended: MagicMock) -> None: """start/end strings are parsed into datetimes and forwarded to _get_recommended_event.""" event = self._make_error_event(timestamp=before_now(days=5)) group = event.group @@ -2574,7 +2589,7 @@ def test_by_issue_id_with_time_range(self, mock_recommended): assert args[3] == parse_iso_timestamp(end) @patch("sentry.seer.agent.tools._get_recommended_event") - def test_by_issue_id_no_events_returns_none(self, mock_recommended): + def test_by_issue_id_no_events_returns_none(self, mock_recommended: MagicMock) -> None: """Returns None when _get_recommended_event returns None.""" event = self._make_error_event() group = event.group @@ -2669,7 +2684,7 @@ def test_returns_none_when_no_data(self, mock_execute: Mock) -> None: assert result is None @patch("sentry.seer.agent.tools.client") - def test_no_start_end_uses_group_date_range(self, mock_api_client): + def test_no_start_end_uses_group_date_range(self, mock_api_client: MagicMock) -> None: """Selects resolution based on group lifespan and passes correct start/end to the API.""" for stats_period, interval in EVENT_TIMESERIES_RESOLUTIONS: mock_api_client.get = Mock(side_effect=client.get) @@ -2727,7 +2742,7 @@ def test_no_start_end_uses_group_date_range(self, mock_api_client): group.delete() @patch("sentry.seer.agent.tools.client") - def test_with_start_end_uses_explicit_range(self, mock_api_client): + def test_with_start_end_uses_explicit_range(self, mock_api_client: MagicMock) -> None: """Selects resolution based on explicit start/end and passes correct params to the API.""" for stats_period, interval in EVENT_TIMESERIES_RESOLUTIONS: mock_api_client.get = Mock(side_effect=client.get) @@ -3281,8 +3296,8 @@ def setUp(self) -> None: @patch("sentry.seer.agent.tools._convert_profile_to_execution_tree") @patch("sentry.seer.agent.tools.fetch_profile_data") def test_rpc_get_profile_flamegraph_finds_transaction_profile( - self, mock_fetch_profile, mock_convert_tree - ): + self, mock_fetch_profile: MagicMock, mock_convert_tree: MagicMock + ) -> None: """Test finding transaction profile via profile.id with wildcard query""" profile_id_8char = "a1b2c3d4" full_profile_id = profile_id_8char + "e5f6789012345678901234567" @@ -3312,8 +3327,8 @@ def test_rpc_get_profile_flamegraph_finds_transaction_profile( @patch("sentry.seer.agent.tools._convert_profile_to_execution_tree") @patch("sentry.seer.agent.tools.fetch_profile_data") def test_rpc_get_profile_flamegraph_finds_continuous_profile( - self, mock_fetch_profile, mock_convert_tree - ): + self, mock_fetch_profile: MagicMock, mock_convert_tree: MagicMock + ) -> None: """Test finding continuous profile via profiler.id with wildcard query""" profiler_id_8char = "b1c2d3e4" full_profiler_id = profiler_id_8char + "f5a6b7c8d9e0f1a2b3c4d5e6" @@ -3349,8 +3364,8 @@ def test_rpc_get_profile_flamegraph_finds_continuous_profile( @patch("sentry.seer.agent.tools._convert_profile_to_execution_tree") @patch("sentry.seer.agent.tools.fetch_profile_data") def test_rpc_get_profile_flamegraph_aggregates_timestamps_across_spans( - self, mock_fetch_profile, mock_convert_tree - ): + self, mock_fetch_profile: MagicMock, mock_convert_tree: MagicMock + ) -> None: """Test that min/max timestamps are aggregated across multiple spans with same profile""" profile_id_8char = "c1d2e3f4" full_profile_id = profile_id_8char + "a5b6c7d8e9f0a1b2c3d4e5f6" @@ -3392,8 +3407,8 @@ def test_rpc_get_profile_flamegraph_aggregates_timestamps_across_spans( @patch("sentry.seer.agent.tools._convert_profile_to_execution_tree") @patch("sentry.seer.agent.tools.fetch_profile_data") def test_rpc_get_profile_flamegraph_sliding_window_finds_old_profile( - self, mock_fetch_profile, mock_convert_tree - ): + self, mock_fetch_profile: MagicMock, mock_convert_tree: MagicMock + ) -> None: """Test that sliding 14-day windows can find profiles from 20 days ago""" profile_id_8char = "d1e2f3a4" full_profile_id = profile_id_8char + "b5c6d7e8f9a0b1c2d3e4f5a6" @@ -3421,7 +3436,9 @@ def test_rpc_get_profile_flamegraph_sliding_window_finds_old_profile( @patch("sentry.seer.agent.tools._convert_profile_to_execution_tree") @patch("sentry.seer.agent.tools.fetch_profile_data") - def test_rpc_get_profile_flamegraph_full_32char_id(self, mock_fetch_profile, mock_convert_tree): + def test_rpc_get_profile_flamegraph_full_32char_id( + self, mock_fetch_profile: MagicMock, mock_convert_tree: MagicMock + ) -> None: """Test with full 32-character profile ID (no wildcard needed)""" full_profile_id = "e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6" @@ -3478,7 +3495,7 @@ class _ReplayMetadataResponse(BaseModel): trace_ids: list[str] error_ids: list[str] environment: str | None - tags: dict[str, list[str]] | list + tags: dict[str, list[str]] | list[Any] user: dict[str, Any] sdk: dict[str, Any] os: dict[str, Any] diff --git a/tests/sentry/seer/assisted_query/test_traces_tools.py b/tests/sentry/seer/assisted_query/test_traces_tools.py index db4a647dbcca..eb83a298b2d2 100644 --- a/tests/sentry/seer/assisted_query/test_traces_tools.py +++ b/tests/sentry/seer/assisted_query/test_traces_tools.py @@ -1,11 +1,18 @@ +from collections.abc import Mapping +from typing import cast from unittest.mock import MagicMock, patch from sentry.seer.assisted_query.traces_tools import get_attribute_names from sentry.testutils.cases import TestCase -def _attribute(name, attr_type="string", source="user", context=None): - attribute = { +def _attribute( + name: str, + attr_type: str = "string", + source: str = "user", + context: Mapping[str, object] | None = None, +) -> dict[str, object]: + attribute: dict[str, object] = { "key": name, "name": name, "attributeType": attr_type, @@ -22,14 +29,20 @@ def setUp(self) -> None: self.org = self.create_organization() self.project = self.create_project(organization=self.org) - def _mock_responses(self, mock_client_cls, string_attrs, number_attrs=None): + def _mock_responses( + self, + mock_client_cls: MagicMock, + string_attrs: list[dict[str, object]], + number_attrs: list[dict[str, object]] | None = None, + ) -> MagicMock: """Stub the two per-type calls get_attribute_names makes (string, then number).""" mock_client = mock_client_cls.return_value string_response, number_response = MagicMock(), MagicMock() string_response.data = string_attrs number_response.data = number_attrs or [] mock_client.get.side_effect = [string_response, number_response] - return mock_client + # justification: the patched client class returns a MagicMock instance. + return cast("MagicMock", mock_client) @patch("sentry.seer.assisted_query.traces_tools.ApiClient") def test_custom_context_goes_to_custom_fields(self, mock_client_cls: MagicMock) -> None: diff --git a/tests/sentry/seer/autofix/pr_iteration/test_cap_exhausted.py b/tests/sentry/seer/autofix/pr_iteration/test_cap_exhausted.py index 712130be25bd..b97c99070925 100644 --- a/tests/sentry/seer/autofix/pr_iteration/test_cap_exhausted.py +++ b/tests/sentry/seer/autofix/pr_iteration/test_cap_exhausted.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, patch from sentry.seer.agent.client_models import RepoPRState, SeerRunState @@ -40,8 +41,11 @@ def _event(head_sha: str = HEAD_SHA) -> GithubCheckSuiteEvent: def _pull_request_result( - *, state: str = "open", merged: bool = False, assignees: list[dict] | None = None -) -> dict: + *, + state: str = "open", + merged: bool = False, + assignees: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: return { "data": {"state": state, "merged": merged}, "raw": {"headers": None, "data": {"assignees": assignees or []}}, @@ -77,9 +81,10 @@ def _resolved(self, *, commit_sha: str = HEAD_SHA) -> CheckSuiteAutofixRun: repository=self.repo, run_state=run_state, pr_id=555, group_id=1 ) - def _marker(self) -> dict | None: + def _marker(self) -> dict[str, Any] | None: self.seer_run.refresh_from_db() - return (self.seer_run.extras or {}).get(CAP_EXHAUSTED_EXTRA, {}).get(REPO_NAME) + marker = (self.seer_run.extras or {}).get(CAP_EXHAUSTED_EXTRA, {}).get(REPO_NAME) + return marker if isinstance(marker, dict) else None @patch(f"{CAP_EXHAUSTED_PATH}.scm_actions") @patch("sentry.scm.factory.new", return_value=MagicMock()) diff --git a/tests/sentry/seer/autofix/pr_iteration/test_check_suite.py b/tests/sentry/seer/autofix/pr_iteration/test_check_suite.py index ace38946eccd..c1b109867c93 100644 --- a/tests/sentry/seer/autofix/pr_iteration/test_check_suite.py +++ b/tests/sentry/seer/autofix/pr_iteration/test_check_suite.py @@ -1,9 +1,11 @@ from inspect import signature +from typing import Any from unittest.mock import ANY, MagicMock, patch import orjson import pytest from scm.helpers import iter_all_pages +from scm.types import BuildConclusion, CheckSuiteAction from sentry.scm.types import CheckSuiteEvent from sentry.seer.agent.client_models import MemoryBlock, Message, RepoPRState, SeerRunState @@ -54,7 +56,7 @@ OWN_REPO_ID = 123 -def own_repo_pr(pr_id: int) -> dict: +def own_repo_pr(pr_id: int) -> dict[str, Any]: """A ``pull_requests`` entry as GitHub sends it: based in the suite's own repo. GitHub always carries ``base.repo``, so a fixture without it is not a payload @@ -70,10 +72,10 @@ def own_repo_pr(pr_id: int) -> dict: def check_suite_event( - raw: dict | None = None, + raw: dict[str, Any] | None = None, *, - action="completed", - conclusion="failure", + action: CheckSuiteAction = "completed", + conclusion: BuildConclusion = "failure", installation_id: int | None = None, ) -> CheckSuiteEvent: return CheckSuiteEvent( @@ -103,11 +105,15 @@ def setUp(self) -> None: self.create_seer_run(organization=self.organization, seer_run_state_id=67890) def _event( - self, raw: dict | None = None, *, action="completed", conclusion="failure" + self, + raw: dict[str, Any] | None = None, + *, + action: CheckSuiteAction = "completed", + conclusion: BuildConclusion = "failure", ) -> CheckSuiteEvent: return check_suite_event(raw, action=action, conclusion=conclusion) - def _raw(self, *, pull_requests: list[dict] | None = None) -> dict: + def _raw(self, *, pull_requests: list[dict[str, Any]] | None = None) -> dict[str, Any]: return { "check_suite": { "id": 1, @@ -1008,7 +1014,7 @@ def setUp(self) -> None: organization=self.organization, seer_run_state_id=67890, user_id=self.user.id ) - def _resolved(self, *, blocks: list | None = None) -> ResolvedGreenCheckSuite: + def _resolved(self, *, blocks: list[MemoryBlock] | None = None) -> ResolvedGreenCheckSuite: run_state = SeerRunState( run_id=67890, blocks=blocks or [], @@ -1104,7 +1110,7 @@ def test_true_when_hard_cap_reached(self, mock_inspect: MagicMock) -> None: class ResolveCheckSuiteAutofixRunTest(TestCase): def _event( - self, *, pull_requests: list[dict], repository_id: int | None = None + self, *, pull_requests: list[dict[str, Any]], repository_id: int | None = None ) -> GithubCheckSuiteEvent: return GithubCheckSuiteEvent.parse_obj( { @@ -1456,7 +1462,7 @@ def test_empty_after_parse_iteration_counts_as_automated(self) -> None: class SweepCheckRunsCostTest(TestCase): """The sweep's GitHub cost is the thing we budget for, so pin it down.""" - def _page(self, runs: list[dict]) -> dict: + def _page(self, runs: list[dict[str, Any]]) -> dict[str, Any]: return { "data": runs, "type": "github", @@ -1464,7 +1470,9 @@ def _page(self, runs: list[dict]) -> dict: "meta": {"next_cursor": "2"}, } - def _run(self, *, status: str = "completed", conclusion: str | None = "success") -> dict: + def _run( + self, *, status: str = "completed", conclusion: str | None = "success" + ) -> dict[str, Any]: return {"id": 1, "name": "test", "status": status, "conclusion": conclusion} @patch(f"{CHECK_SUITES_PATH}.ListCheckRunsForRefProtocol", object) @@ -1544,7 +1552,7 @@ def test_records_the_request_count_as_the_cost( ) -def _live_pr_result(head_sha: str = "abc") -> dict: +def _live_pr_result(head_sha: str = "abc") -> dict[str, Any]: return { "data": {"state": "open", "merged": False, "head": {"sha": head_sha}}, "raw": {"headers": None, "data": {}}, diff --git a/tests/sentry/seer/autofix/pr_iteration/test_emit.py b/tests/sentry/seer/autofix/pr_iteration/test_emit.py index 1d71998b3ebb..82c827b950a8 100644 --- a/tests/sentry/seer/autofix/pr_iteration/test_emit.py +++ b/tests/sentry/seer/autofix/pr_iteration/test_emit.py @@ -32,6 +32,7 @@ from sentry.seer.autofix.pr_iteration.logs import LogCtxIteration, PrIterationLogContext from sentry.seer.autofix.pr_iteration.pause import PauseReason from sentry.seer.autofix.steps import AutofixStep +from sentry.seer.models.run import SeerRunPrIteration from sentry.testutils.cases import TestCase from sentry.testutils.helpers.analytics import assert_last_analytics_event from sentry.testutils.helpers.datetime import freeze_time @@ -163,7 +164,7 @@ def _complete( outcome=outcome, ) - def _open_rows(self) -> list: + def _open_rows(self) -> list[SeerRunPrIteration]: return open_iterations(self.seer_run) def test_opening_stores_the_row_without_emitting(self) -> None: diff --git a/tests/sentry/seer/autofix/pr_iteration/test_feedback_queue.py b/tests/sentry/seer/autofix/pr_iteration/test_feedback_queue.py index 3b4a9eab7962..18886b035c14 100644 --- a/tests/sentry/seer/autofix/pr_iteration/test_feedback_queue.py +++ b/tests/sentry/seer/autofix/pr_iteration/test_feedback_queue.py @@ -40,7 +40,7 @@ def get_pull_request(self, *args: Any, **kwargs: Any) -> Any: ... def create_pull_request_comment(self, *args: Any, **kwargs: Any) -> Any: ... -def _run_state(*, repo_pr_states=None) -> SeerRunState: +def _run_state(*, repo_pr_states: dict[str, RepoPRState] | None = None) -> SeerRunState: return SeerRunState( run_id=1, blocks=[], @@ -50,7 +50,7 @@ def _run_state(*, repo_pr_states=None) -> SeerRunState: ) -def _check_suite_event() -> dict: +def _check_suite_event() -> dict[str, Any]: return { "check_suite": { "id": 1, @@ -257,7 +257,7 @@ def setUp(self) -> None: ) self.group = self.create_group(project=self.project) - def _event(self, body: str) -> dict: + def _event(self, body: str) -> dict[str, Any]: return { "action": "created", "comment": { diff --git a/tests/sentry/seer/autofix/pr_iteration/test_mention.py b/tests/sentry/seer/autofix/pr_iteration/test_mention.py index 241f095866b0..b13e8de93f90 100644 --- a/tests/sentry/seer/autofix/pr_iteration/test_mention.py +++ b/tests/sentry/seer/autofix/pr_iteration/test_mention.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, patch from sentry.seer.autofix.pr_iteration.feedback import Feedback @@ -19,9 +20,9 @@ def setUp(self) -> None: external_id="123", name="owner/repo", ) - self.integration = MagicMock(id=42, provider="github") + self.integration: Any = MagicMock(id=42, provider="github") - def _event(self, body: str = "@sentry fix it", action: str = "created") -> dict: + def _event(self, body: str = "@sentry fix it", action: str = "created") -> dict[str, Any]: return { "action": action, "comment": { @@ -33,7 +34,7 @@ def _event(self, body: str = "@sentry fix it", action: str = "created") -> dict: "issue": {"number": 7, "pull_request": {"url": "https://example.com/pulls/7"}}, } - def _call(self, event: dict): + def _call(self, event: dict[str, Any]) -> None: return handle_issue_comment_for_autofix_iteration( event=event, organization=self.organization, diff --git a/tests/sentry/seer/autofix/pr_iteration/test_missing_permissions.py b/tests/sentry/seer/autofix/pr_iteration/test_missing_permissions.py index ade8d3e08462..6c68f8611129 100644 --- a/tests/sentry/seer/autofix/pr_iteration/test_missing_permissions.py +++ b/tests/sentry/seer/autofix/pr_iteration/test_missing_permissions.py @@ -149,26 +149,34 @@ def _run(self, state: SeerRunState) -> bool: log_ctx=_log_ctx(state), ) - def test_allows_iteration_when_nothing_missing(self, mock_get_perms, mock_delay) -> None: + def test_allows_iteration_when_nothing_missing( + self, mock_get_perms: MagicMock, mock_delay: MagicMock + ) -> None: mock_get_perms.return_value = {} assert self._run(_state(getsentry__sentry=7)) is False mock_delay.assert_not_called() - def test_only_checks_repos_with_a_pr(self, mock_get_perms, _mock_delay) -> None: + def test_only_checks_repos_with_a_pr( + self, mock_get_perms: MagicMock, _mock_delay: MagicMock + ) -> None: mock_get_perms.return_value = {} self._run(_state(getsentry__sentry=7, getsentry__seer=None)) assert mock_get_perms.call_args[0][1] == [REPO_NAME] - def test_allows_iteration_when_the_check_raises(self, mock_get_perms, mock_delay) -> None: + def test_allows_iteration_when_the_check_raises( + self, mock_get_perms: MagicMock, mock_delay: MagicMock + ) -> None: mock_get_perms.side_effect = ValueError("boom") assert self._run(_state(getsentry__sentry=7)) is False mock_delay.assert_not_called() - def test_blocks_and_queues_a_comment(self, mock_get_perms, mock_delay) -> None: + def test_blocks_and_queues_a_comment( + self, mock_get_perms: MagicMock, mock_delay: MagicMock + ) -> None: mock_get_perms.return_value = {REPO_NAME: _perms(repository_id=123)} assert self._run(_state(getsentry__sentry=7)) is True @@ -183,7 +191,7 @@ def test_blocks_and_queues_a_comment(self, mock_get_perms, mock_delay) -> None: repository_id=123, ) - def test_queues_per_repo(self, mock_get_perms, mock_delay) -> None: + def test_queues_per_repo(self, mock_get_perms: MagicMock, mock_delay: MagicMock) -> None: mock_get_perms.return_value = {REPO_NAME: _perms(), OTHER_REPO_NAME: _perms()} assert self._run(_state(getsentry__sentry=7, getsentry__seer=9)) is True @@ -194,7 +202,9 @@ def test_queues_per_repo(self, mock_get_perms, mock_delay) -> None: OTHER_REPO_NAME, } - def test_skips_the_queue_once_the_marker_is_set(self, mock_get_perms, mock_delay) -> None: + def test_skips_the_queue_once_the_marker_is_set( + self, mock_get_perms: MagicMock, mock_delay: MagicMock + ) -> None: mock_get_perms.return_value = {REPO_NAME: _perms()} self.seer_run.update( extras={MISSING_PERMISSIONS_EXTRA: {REPO_NAME: {"missing_tiers": ["pr_iteration"]}}} @@ -203,7 +213,9 @@ def test_skips_the_queue_once_the_marker_is_set(self, mock_get_perms, mock_delay assert self._run(_state(getsentry__sentry=7)) is True mock_delay.assert_not_called() - def test_blocks_without_queueing_when_no_seer_run(self, mock_get_perms, mock_delay) -> None: + def test_blocks_without_queueing_when_no_seer_run( + self, mock_get_perms: MagicMock, mock_delay: MagicMock + ) -> None: self.seer_run.delete() mock_get_perms.return_value = {REPO_NAME: _perms()} @@ -219,7 +231,9 @@ def _open_row(self) -> None: ) @freeze_time("2024-01-01 00:00:00") - def test_records_the_outcome_the_first_time_it_blocks(self, mock_get_perms, mock_delay) -> None: + def test_records_the_outcome_the_first_time_it_blocks( + self, mock_get_perms: MagicMock, mock_delay: MagicMock + ) -> None: mock_get_perms.return_value = {REPO_NAME: _perms(repository_id=123)} self._open_row() @@ -239,7 +253,7 @@ def test_records_the_outcome_the_first_time_it_blocks(self, mock_get_perms, mock assert self._run(_state(getsentry__sentry=7)) is True def test_does_not_re_record_the_outcome_for_the_same_iteration( - self, mock_get_perms, mock_delay + self, mock_get_perms: MagicMock, mock_delay: MagicMock ) -> None: mock_get_perms.return_value = {REPO_NAME: _perms(repository_id=123)} self._open_row() @@ -277,7 +291,7 @@ def _post( log_ctx=_log_ctx(_state(getsentry__sentry=7)), ) - def test_comments_and_marks(self, mock_get_perms) -> None: + def test_comments_and_marks(self, mock_get_perms: MagicMock) -> None: mock_get_perms.return_value = {REPO_NAME: _perms(repository_id=123)} actions = self._stub_scm() @@ -300,7 +314,7 @@ def test_comments_and_marks(self, mock_get_perms) -> None: assert f"/settings/installations/{INTEGRATION_ID}/permissions/update" in body assert self.mock_make_scm.call_args[0] == (self.organization.id, 123) - def test_comment_body_is_generic_and_lists_no_features(self, mock_get_perms) -> None: + def test_comment_body_is_generic_and_lists_no_features(self, mock_get_perms: MagicMock) -> None: mock_get_perms.return_value = {REPO_NAME: _perms()} actions = self._stub_scm() @@ -313,7 +327,9 @@ def test_comment_body_is_generic_and_lists_no_features(self, mock_get_perms) -> assert "following features" not in body assert "Seer Autofix" not in body - def test_warns_when_more_than_the_pr_iteration_tier_is_missing(self, mock_get_perms) -> None: + def test_warns_when_more_than_the_pr_iteration_tier_is_missing( + self, mock_get_perms: MagicMock + ) -> None: mock_get_perms.return_value = { REPO_NAME: _perms(permissions=_MISSING_AUTOFIX_AND_PR_ITERATION) } @@ -331,7 +347,9 @@ def test_warns_when_more_than_the_pr_iteration_tier_is_missing(self, mock_get_pe assert len(unexpected) == 1 assert unexpected[0].__dict__["missing_tiers"] == ["pr_iteration", "autofix_pull_requests"] - def test_no_warning_when_only_the_pr_iteration_tier_is_missing(self, mock_get_perms) -> None: + def test_no_warning_when_only_the_pr_iteration_tier_is_missing( + self, mock_get_perms: MagicMock + ) -> None: mock_get_perms.return_value = {REPO_NAME: _perms(permissions=_ONLY_PR_ITERATION_MISSING)} actions = self._stub_scm() @@ -346,7 +364,7 @@ def test_no_warning_when_only_the_pr_iteration_tier_is_missing(self, mock_get_pe assert marker["missing_tiers"] == ["pr_iteration"] assert marker["pr_id"] == "4242" - def test_stays_silent_once_marked(self, mock_get_perms) -> None: + def test_stays_silent_once_marked(self, mock_get_perms: MagicMock) -> None: mock_get_perms.return_value = {REPO_NAME: _perms()} actions = self._stub_scm() self.seer_run.update( @@ -359,7 +377,9 @@ def test_stays_silent_once_marked(self, mock_get_perms) -> None: actions.create_pull_request_comment.assert_not_called() - def test_records_the_ids_resolved_here_not_the_ones_queued(self, mock_get_perms) -> None: + def test_records_the_ids_resolved_here_not_the_ones_queued( + self, mock_get_perms: MagicMock + ) -> None: mock_get_perms.return_value = { REPO_NAME: _perms(integration_id=INTEGRATION_ID, repository_id=123) } @@ -378,7 +398,7 @@ def test_records_the_ids_resolved_here_not_the_ones_queued(self, mock_get_perms) self._post(integration_id=INTEGRATION_ID + 1, queued_repository_id=456) def test_comments_and_logs_when_the_repo_was_re_pointed_while_queued( - self, mock_get_perms + self, mock_get_perms: MagicMock ) -> None: mock_get_perms.return_value = {REPO_NAME: _perms(repository_id=123)} actions = self._stub_scm() @@ -392,7 +412,7 @@ def test_comments_and_logs_when_the_repo_was_re_pointed_while_queued( assert logs.records[0].__dict__["queued_repository_id"] == 456 assert logs.records[0].__dict__["repository_id"] == 123 - def test_stays_silent_when_nothing_is_missing(self, mock_get_perms) -> None: + def test_stays_silent_when_nothing_is_missing(self, mock_get_perms: MagicMock) -> None: mock_get_perms.return_value = {} actions = self._stub_scm() @@ -402,7 +422,9 @@ def test_stays_silent_when_nothing_is_missing(self, mock_get_perms) -> None: self.seer_run.refresh_from_db() assert get_missing_permissions_marker(self.seer_run, REPO_NAME) is None - def test_no_second_comment_when_a_racing_task_marks_first(self, mock_get_perms) -> None: + def test_no_second_comment_when_a_racing_task_marks_first( + self, mock_get_perms: MagicMock + ) -> None: mock_get_perms.return_value = {REPO_NAME: _perms()} actions = self._stub_scm() @@ -416,7 +438,9 @@ def _mark(run: SeerRun) -> None: actions.create_pull_request_comment.assert_not_called() - def test_stays_silent_when_run_deleted_before_marker_write(self, mock_get_perms) -> None: + def test_stays_silent_when_run_deleted_before_marker_write( + self, mock_get_perms: MagicMock + ) -> None: mock_get_perms.return_value = {REPO_NAME: _perms()} actions = self._stub_scm() @@ -425,7 +449,7 @@ def test_stays_silent_when_run_deleted_before_marker_write(self, mock_get_perms) actions.create_pull_request_comment.assert_not_called() - def test_no_marker_when_the_comment_fails(self, mock_get_perms) -> None: + def test_no_marker_when_the_comment_fails(self, mock_get_perms: MagicMock) -> None: mock_get_perms.return_value = {REPO_NAME: _perms()} actions = self._stub_scm() actions.create_pull_request_comment.side_effect = Exception("nope") @@ -435,7 +459,7 @@ def test_no_marker_when_the_comment_fails(self, mock_get_perms) -> None: self.seer_run.refresh_from_db() assert get_missing_permissions_marker(self.seer_run, REPO_NAME) is None - def test_no_marker_when_the_provider_cannot_comment(self, mock_get_perms) -> None: + def test_no_marker_when_the_provider_cannot_comment(self, mock_get_perms: MagicMock) -> None: mock_get_perms.return_value = {REPO_NAME: _perms()} actions = self._stub_scm() self.mock_make_scm.return_value = object() @@ -446,7 +470,9 @@ def test_no_marker_when_the_provider_cannot_comment(self, mock_get_perms) -> Non self.seer_run.refresh_from_db() assert get_missing_permissions_marker(self.seer_run, REPO_NAME) is None - def test_raises_for_the_task_to_retry_when_the_lock_is_held(self, mock_get_perms) -> None: + def test_raises_for_the_task_to_retry_when_the_lock_is_held( + self, mock_get_perms: MagicMock + ) -> None: mock_get_perms.return_value = {REPO_NAME: _perms()} actions = self._stub_scm() lock = MagicMock() @@ -458,7 +484,7 @@ def test_raises_for_the_task_to_retry_when_the_lock_is_held(self, mock_get_perms actions.create_pull_request_comment.assert_not_called() - def test_stays_silent_when_no_seer_run(self, mock_get_perms) -> None: + def test_stays_silent_when_no_seer_run(self, mock_get_perms: MagicMock) -> None: self.seer_run.delete() mock_get_perms.return_value = {REPO_NAME: _perms()} actions = self._stub_scm() @@ -500,7 +526,7 @@ def setUp(self) -> None: ) def test_blocked_tag_unions_tiers_across_repos( - self, mock_get_perms, _mock_delay, mock_incr + self, mock_get_perms: MagicMock, _mock_delay: MagicMock, mock_incr: MagicMock ) -> None: mock_get_perms.return_value = { REPO_NAME: _perms(1, permissions=_MISSING_AUTOFIX_AND_PR_ITERATION), @@ -529,7 +555,9 @@ def setUp(self) -> None: organization=self.organization, seer_run_state_id=RUN_ID, user_id=self.user.id ) - def test_commented_tag_is_per_repo(self, mock_get_perms, mock_incr) -> None: + def test_commented_tag_is_per_repo( + self, mock_get_perms: MagicMock, mock_incr: MagicMock + ) -> None: mock_get_perms.return_value = { REPO_NAME: _perms(permissions=_MISSING_AUTOFIX_AND_PR_ITERATION) } diff --git a/tests/sentry/seer/autofix/pr_iteration/test_pause.py b/tests/sentry/seer/autofix/pr_iteration/test_pause.py index ef09e3c79b49..a0bbf5dc8d81 100644 --- a/tests/sentry/seer/autofix/pr_iteration/test_pause.py +++ b/tests/sentry/seer/autofix/pr_iteration/test_pause.py @@ -56,7 +56,7 @@ def _enqueue(self, run_id: int = RUN_ID) -> None: run_state=run_state, ) - def _marker(self) -> dict | None: + def _marker(self) -> dict[str, object] | None: self.seer_run.refresh_from_db() return (self.seer_run.extras or {}).get(PAUSED_EXTRA) diff --git a/tests/sentry/seer/autofix/pr_iteration/test_pr_state.py b/tests/sentry/seer/autofix/pr_iteration/test_pr_state.py index 3a328ac8c5cc..f3b63a84ccf4 100644 --- a/tests/sentry/seer/autofix/pr_iteration/test_pr_state.py +++ b/tests/sentry/seer/autofix/pr_iteration/test_pr_state.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, patch from sentry.seer.agent.client_models import RepoPRState, SeerRunState @@ -8,7 +9,7 @@ class _ScmStub: - def get_pull_request(self, *args, **kwargs): ... + def get_pull_request(self, *args: Any, **kwargs: Any) -> None: ... class IterationPrsAnyForeignHeadTest(TestCase): diff --git a/tests/sentry/seer/autofix/pr_iteration/test_ready_for_review.py b/tests/sentry/seer/autofix/pr_iteration/test_ready_for_review.py index 2b6de900bf44..ee20e2408f0a 100644 --- a/tests/sentry/seer/autofix/pr_iteration/test_ready_for_review.py +++ b/tests/sentry/seer/autofix/pr_iteration/test_ready_for_review.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, patch import orjson @@ -41,7 +42,7 @@ def _pull_request_result( draft: bool = True, state: str = "open", merged: bool = False, -) -> dict: +) -> dict[str, Any]: return { "data": {"state": state, "merged": merged, "head": {"sha": head_sha}}, "raw": {"headers": None, "data": {"draft": draft}}, @@ -50,7 +51,7 @@ def _pull_request_result( } -def _green_event(raw: dict | None = None) -> CheckSuiteEvent: +def _green_event(raw: dict[str, Any] | None = None) -> CheckSuiteEvent: if raw is None: raw = { "check_suite": { @@ -228,9 +229,10 @@ def _resolved(self, *, commit_sha: str = HEAD_SHA, group_id: int = 1) -> CheckSu repository=self.repo, run_state=run_state, pr_id=555, group_id=group_id ) - def _marker(self) -> dict | None: + def _marker(self) -> dict[str, Any] | None: self.seer_run.refresh_from_db() - return (self.seer_run.extras or {}).get(READY_FOR_REVIEW_EXTRA, {}).get(REPO_NAME) + marker = (self.seer_run.extras or {}).get(READY_FOR_REVIEW_EXTRA, {}).get(REPO_NAME) + return marker if isinstance(marker, dict) else None @patch(f"{READY_FOR_REVIEW_PATH}.MarkPullRequestDraftStateProtocol", object) @patch(f"{READY_FOR_REVIEW_PATH}.scm_actions") diff --git a/tests/sentry/seer/autofix/pr_iteration/test_review_request.py b/tests/sentry/seer/autofix/pr_iteration/test_review_request.py index aa8374064e2a..18417e8ecd2e 100644 --- a/tests/sentry/seer/autofix/pr_iteration/test_review_request.py +++ b/tests/sentry/seer/autofix/pr_iteration/test_review_request.py @@ -36,7 +36,7 @@ PR_NUMBER = 42 -def _green_event(raw: dict | None = None) -> CheckSuiteEvent: +def _green_event(raw: dict[str, Any] | None = None) -> CheckSuiteEvent: if raw is None: raw = { "check_suite": { @@ -71,11 +71,11 @@ def _pull_request_result( *, state: str = "open", merged: bool = False, - requested_reviewers: list[dict] | None = None, + requested_reviewers: list[dict[str, Any]] | None = None, author: str | None = None, head_sha: str = HEAD_SHA, draft: bool = True, -) -> dict: +) -> dict[str, Any]: raw: dict[str, Any] = {"requested_reviewers": requested_reviewers or [], "draft": draft} if author is not None: raw["user"] = {"login": author} @@ -149,9 +149,10 @@ def _resolved(self, *, commit_sha: str = HEAD_SHA) -> CheckSuiteAutofixRun: repository=self.repo, run_state=run_state, pr_id=555, group_id=1 ) - def _marker(self, extra_key: str = REVIEW_REQUESTS_EXTRA) -> dict | None: + def _marker(self, extra_key: str = REVIEW_REQUESTS_EXTRA) -> dict[str, Any] | None: self.seer_run.refresh_from_db() - return (self.seer_run.extras or {}).get(extra_key, {}).get(REPO_NAME) + marker = (self.seer_run.extras or {}).get(extra_key, {}).get(REPO_NAME) + return marker if isinstance(marker, dict) else None @patch(f"{REVIEW_REQUEST_PATH}.scm_actions") @patch("sentry.scm.factory.new", return_value=MagicMock()) @@ -560,9 +561,10 @@ def _resolved(self) -> CheckSuiteAutofixRun: repository=self.repo, run_state=run_state, pr_id=555, group_id=1 ) - def _marker(self, extra_key: str = REVIEW_REQUESTS_EXTRA) -> dict | None: + def _marker(self, extra_key: str = REVIEW_REQUESTS_EXTRA) -> dict[str, Any] | None: self.seer_run.refresh_from_db() - return (self.seer_run.extras or {}).get(extra_key, {}).get(REPO_NAME) + marker = (self.seer_run.extras or {}).get(extra_key, {}).get(REPO_NAME) + return marker if isinstance(marker, dict) else None def test_requests_top_candidate_and_records_provenance( self, diff --git a/tests/sentry/seer/autofix/pr_iteration/test_reviewer_candidates.py b/tests/sentry/seer/autofix/pr_iteration/test_reviewer_candidates.py index 90e1f0aba9a7..75390fa0c226 100644 --- a/tests/sentry/seer/autofix/pr_iteration/test_reviewer_candidates.py +++ b/tests/sentry/seer/autofix/pr_iteration/test_reviewer_candidates.py @@ -1,5 +1,5 @@ from datetime import timedelta -from typing import Any +from typing import Any, NoReturn from unittest.mock import MagicMock, patch from django.utils import timezone @@ -27,7 +27,7 @@ PR_NUMBER = 42 -def _files_page(files: list[dict]) -> dict: +def _files_page(files: list[dict[str, Any]]) -> dict[str, Any]: return { "data": files, "type": "github", @@ -36,7 +36,7 @@ def _files_page(files: list[dict]) -> dict: } -def _commits_page(raw_commits: list[dict]) -> dict: +def _commits_page(raw_commits: list[dict[str, Any]]) -> dict[str, Any]: return { "data": [], "type": "github", @@ -49,10 +49,10 @@ class _FakeScm: """Satisfies the runtime protocol checks; the patched ``scm_actions`` intercepts every call, so the methods never actually run.""" - def get_pull_request_files(self, *args, **kwargs): + def get_pull_request_files(self, *args: Any, **kwargs: Any) -> NoReturn: raise NotImplementedError - def get_commits_by_path(self, *args, **kwargs): + def get_commits_by_path(self, *args: Any, **kwargs: Any) -> NoReturn: raise NotImplementedError @@ -82,7 +82,9 @@ def _collect(self, scm: Any = None, **kwargs: Any) -> list[ReviewerCandidate]: **kwargs, ) - def _create_suspect_commit(self, *, external_id: str | None, user_id: int | None = None): + def _create_suspect_commit( + self, *, external_id: str | None, user_id: int | None = None + ) -> None: author = self.create_commit_author( organization_id=self.organization.id, email="suspect@example.com" ) diff --git a/tests/sentry/seer/autofix/pr_iteration/test_sweep.py b/tests/sentry/seer/autofix/pr_iteration/test_sweep.py index 5fa7b2eb89cf..a0c9d5170420 100644 --- a/tests/sentry/seer/autofix/pr_iteration/test_sweep.py +++ b/tests/sentry/seer/autofix/pr_iteration/test_sweep.py @@ -100,7 +100,7 @@ def _fail(self, reason: str, *, iteration_id: int | None = None) -> None: iteration_id=iteration_id, ) - def _open_rows(self) -> list: + def _open_rows(self) -> list[SeerRunPrIteration]: return open_iterations(self.seer_run) def _sweep(self) -> SweepResult: diff --git a/tests/sentry/seer/autofix/pr_iteration/test_types.py b/tests/sentry/seer/autofix/pr_iteration/test_types.py index 69a93fe7089b..8646a76cf796 100644 --- a/tests/sentry/seer/autofix/pr_iteration/test_types.py +++ b/tests/sentry/seer/autofix/pr_iteration/test_types.py @@ -1,4 +1,5 @@ from datetime import timedelta +from typing import Any, Literal from unittest.mock import MagicMock, patch from uuid import UUID @@ -41,8 +42,8 @@ CHECK_SUITES_PATH = "sentry.seer.autofix.pr_iteration.check_suites" -def _check_suite_event(*, updated_at: str | None = "2024-01-01T00:00:00Z") -> dict: - check_suite: dict = { +def _check_suite_event(*, updated_at: str | None = "2024-01-01T00:00:00Z") -> dict[str, Any]: + check_suite: dict[str, Any] = { "id": 1, "head_sha": "abc", "check_runs_url": "https://github.com/owner/repo/check-runs", @@ -56,7 +57,12 @@ def _check_suite_event(*, updated_at: str | None = "2024-01-01T00:00:00Z") -> di } -def _run_state(*, blocks=None, repo_pr_states=None, status="completed") -> SeerRunState: +def _run_state( + *, + blocks: list[MemoryBlock] | None = None, + repo_pr_states: dict[str, RepoPRState] | None = None, + status: Literal["processing", "completed", "error", "awaiting_user_input"] = "completed", +) -> SeerRunState: return SeerRunState( run_id=1, blocks=blocks or [], @@ -76,7 +82,7 @@ def _autofix_run(*, repo: MagicMock | None = None) -> CheckSuiteAutofixRun: def _check_suite_source( - event: dict | None = None, + event: dict[str, Any] | None = None, *, autofix_run: CheckSuiteAutofixRun | None = None, ) -> CheckSuiteFeedbackSource: @@ -618,7 +624,9 @@ def test_later_with_negative_timedelta_returns_zero(self) -> None: class CheckSuiteLogFieldsTest(TestCase): - def _event(self, *, head_sha="abc", repo_name="owner/repo", conclusion="failure") -> dict: + def _event( + self, *, head_sha: str = "abc", repo_name: str = "owner/repo", conclusion: str = "failure" + ) -> dict[str, Any]: return { "check_suite": { "id": 1, @@ -657,7 +665,7 @@ class CheckSuiteShouldTriggerStaleHeadTest(TestCase): STALE = TriggerDecision(task=None, reason="stale_head") - def _event(self, *, head_sha="abc", repo_name="owner/repo") -> dict: + def _event(self, *, head_sha: str = "abc", repo_name: str = "owner/repo") -> dict[str, Any]: return { "check_suite": { "id": 1, @@ -716,12 +724,12 @@ class CheckSuiteShouldConsumeTest(TestCase): def _event( self, *, - head_sha="abc", - repo_name="owner/repo", + head_sha: str = "abc", + repo_name: str = "owner/repo", updated_at: str | None = "2024-01-01T00:00:00Z", suite_id: int = 1, - ) -> dict: - check_suite: dict = { + ) -> dict[str, Any]: + check_suite: dict[str, Any] = { "id": suite_id, "head_sha": head_sha, "check_runs_url": "https://github.com/owner/repo/check-runs", diff --git a/tests/sentry/seer/autofix/test_autofix.py b/tests/sentry/seer/autofix/test_autofix.py index 334e0656c497..a6fb52e66bcb 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -334,7 +334,9 @@ def setUp(self) -> None: project_id=self.project.id, ) - self.group = event2.group + group = event2.group + assert group is not None + self.group = group def test_get_all_tags_overview_basic(self) -> None: """Test basic functionality of getting all tags overview with real data.""" diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index 25edbd68cb5f..d88a960875ac 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -1,3 +1,4 @@ +from typing import Any, cast from unittest.mock import MagicMock, patch import pytest @@ -7,6 +8,7 @@ from sentry.analytics.events.autofix_events import AiAutofixSolutionCompletedEvent from sentry.constants import DataCategory from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.seer.agent.client_models import ( Artifact, MemoryBlock, @@ -40,7 +42,7 @@ def mirror_autofix_run( - test: TestCase, group, run_id: int, *, run: SeerRun | None = None + test: TestCase, group: Group, run_id: int, *, run: SeerRun | None = None ) -> SeerRun: """Attach ``run_id`` to ``group`` so continuation can load the local mirror.""" if run is None: @@ -53,11 +55,13 @@ def mirror_autofix_run( def test_legacy_night_shift_referrer() -> None: - assert AutofixReferrer.NIGHT_SHIFT is AutofixReferrer.AGENTIC_TRIAGE + assert AutofixReferrer.NIGHT_SHIFT.value == AutofixReferrer.AGENTIC_TRIAGE.value assert referrer_map[SeerAutomationSource.NIGHT_SHIFT] is AutofixReferrer.AGENTIC_TRIAGE -def _make_scm_mock(*, get_repository=None, get_branch=None): +def _make_scm_mock( + *, get_repository: dict[str, Any] | None = None, get_branch: dict[str, Any] | None = None +) -> Any: # justification: dynamically built SCM stand-in """Build an SCM mock that satisfies the runtime_checkable Get*Protocol checks. MagicMock attributes are invisible to ``inspect.getattr_static``, which Python 3.12's @@ -314,13 +318,13 @@ def _make_run_state(self, group_id: int | None = None, run_id: int = 67890) -> S @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") def test_code_changes_sends_started_webhook( self, - mock_feature, - mock_process_autofix_updates, - mock_broadcast, - mock_has_access, - mock_check_quota, - mock_metrics_incr, - ): + mock_feature: MagicMock, + mock_process_autofix_updates: MagicMock, + mock_broadcast: MagicMock, + mock_has_access: MagicMock, + mock_check_quota: MagicMock, + mock_metrics_incr: MagicMock, + ) -> None: """Records the coding activity before handing the started update to the operator.""" mock_feature.return_value = self.create_seer_run( organization=self.group.organization, seer_run_state_id=12345 @@ -356,13 +360,13 @@ def assert_activity_exists(**_kwargs: object) -> None: @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") def test_root_cause_routes_to_seer_feature( self, - mock_client_class, - mock_feature, - mock_scm_new, - mock_broadcast, - mock_check_quota, - mock_record_run, - ): + mock_client_class: MagicMock, + mock_feature: MagicMock, + mock_scm_new: MagicMock, + mock_broadcast: MagicMock, + mock_check_quota: MagicMock, + mock_record_run: MagicMock, + ) -> None: """A new root-cause run dispatches the Autofix feature, emits the started webhook, and returns the feature run.""" feature_run = self.create_seer_run( @@ -411,7 +415,9 @@ def test_root_cause_routes_to_seer_feature( @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") - def test_solution_routes_to_seer_feature(self, mock_feature, mock_broadcast): + def test_solution_routes_to_seer_feature( + self, mock_feature: MagicMock, mock_broadcast: MagicMock + ) -> None: existing_run = self.create_seer_run( organization=self.group.organization, seer_run_state_id=777 ) @@ -448,7 +454,9 @@ def test_solution_routes_to_seer_feature(self, mock_feature, mock_broadcast): @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") - def test_solution_without_prior_run_routes_to_seer_feature(self, mock_feature, mock_broadcast): + def test_solution_without_prior_run_routes_to_seer_feature( + self, mock_feature: MagicMock, mock_broadcast: MagicMock + ) -> None: feature_run = self.create_seer_run( organization=self.group.organization, seer_run_state_id=777, @@ -478,8 +486,12 @@ def test_solution_without_prior_run_routes_to_seer_feature(self, mock_feature, m @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") def test_code_changes_routes_to_seer_feature( - self, mock_feature, mock_broadcast, mock_has_access, mock_record_activity - ): + self, + mock_feature: MagicMock, + mock_broadcast: MagicMock, + mock_has_access: MagicMock, + mock_record_activity: MagicMock, + ) -> None: existing_run = self.create_seer_run( organization=self.group.organization, seer_run_state_id=777 ) @@ -524,8 +536,12 @@ def test_code_changes_routes_to_seer_feature( @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") def test_continued_solution_attributes_actor( - self, mock_feature, mock_broadcast, mock_has_access, mock_record_activity - ): + self, + mock_feature: MagicMock, + mock_broadcast: MagicMock, + mock_has_access: MagicMock, + mock_record_activity: MagicMock, + ) -> None: existing_run = self.create_seer_run( organization=self.group.organization, seer_run_state_id=777 ) @@ -555,8 +571,12 @@ def test_continued_solution_attributes_actor( @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") def test_root_cause_without_actor_is_unattributed( - self, mock_feature, mock_broadcast, mock_has_access, mock_record_activity - ): + self, + mock_feature: MagicMock, + mock_broadcast: MagicMock, + mock_has_access: MagicMock, + mock_record_activity: MagicMock, + ) -> None: mock_feature.return_value = self.create_seer_run( organization=self.group.organization, seer_run_state_id=777, @@ -574,8 +594,8 @@ def test_root_cause_without_actor_is_unattributed( @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") def test_code_changes_without_prior_run_routes_to_seer_feature( - self, mock_feature, mock_broadcast - ): + self, mock_feature: MagicMock, mock_broadcast: MagicMock + ) -> None: feature_run = self.create_seer_run( organization=self.group.organization, seer_run_state_id=777, @@ -602,8 +622,8 @@ def test_code_changes_without_prior_run_routes_to_seer_feature( @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") def test_pr_iteration_routes_to_seer_feature( - self, mock_client_class, mock_feature, mock_broadcast - ): + self, mock_client_class: MagicMock, mock_feature: MagicMock, mock_broadcast: MagicMock + ) -> None: author = SeerCommitAuthor(name="Mona", email="1+octocat@users.noreply.github.com") mock_client_class.return_value.get_run.return_value = _state_with_blocks( [_iteration_block(1)], @@ -661,7 +681,9 @@ def test_pr_iteration_routes_to_seer_feature( @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") - def test_pr_iteration_requires_existing_pr(self, mock_client_class, mock_feature): + def test_pr_iteration_requires_existing_pr( + self, mock_client_class: MagicMock, mock_feature: MagicMock + ) -> None: mock_client_class.return_value.get_run.return_value = _state_with_blocks( [], group_id=self.group.id ) @@ -684,7 +706,7 @@ def test_pr_iteration_requires_existing_pr(self, mock_client_class, mock_feature @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") @patch("sentry.quotas.backend.check_seer_quota", return_value=False) - def test_when_no_quota(self, mock_check_quota, mock_feature): + def test_when_no_quota(self, mock_check_quota: MagicMock, mock_feature: MagicMock) -> None: with pytest.raises(NoSeerQuotaException): trigger_autofix_agent( group=self.group, @@ -701,7 +723,9 @@ def test_when_no_quota(self, mock_check_quota, mock_feature): @patch("sentry.quotas.backend.check_seer_quota", return_value=True) @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") - def test_free_cohort_skips_quota_check(self, mock_feature, mock_broadcast, mock_check_quota): + def test_free_cohort_skips_quota_check( + self, mock_feature: MagicMock, mock_broadcast: MagicMock, mock_check_quota: MagicMock + ) -> None: mock_feature.return_value = self.create_seer_run( organization=self.group.organization, seer_run_state_id=12345 ) @@ -724,8 +748,8 @@ def test_free_cohort_skips_quota_check(self, mock_feature, mock_broadcast, mock_ @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") def test_allow_free_cohort_still_checks_quota_for_non_cohort_org( - self, mock_feature, mock_broadcast, mock_check_quota - ): + self, mock_feature: MagicMock, mock_broadcast: MagicMock, mock_check_quota: MagicMock + ) -> None: mock_feature.return_value = self.create_seer_run( organization=self.group.organization, seer_run_state_id=12345 ) @@ -747,8 +771,8 @@ def test_allow_free_cohort_still_checks_quota_for_non_cohort_org( @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") def test_continued_run_permitted_with_no_remaining_budget( - self, mock_feature, mock_broadcast, mock_check_quota - ): + self, mock_feature: MagicMock, mock_broadcast: MagicMock, mock_check_quota: MagicMock + ) -> None: seer_run = mirror_autofix_run(self, self.group, 67890) mock_feature.return_value = seer_run @@ -766,8 +790,8 @@ def test_continued_run_permitted_with_no_remaining_budget( @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") def test_continued_run_requires_matching_group( - self, mock_client_class, mock_feature, mock_broadcast - ): + self, mock_client_class: MagicMock, mock_feature: MagicMock, mock_broadcast: MagicMock + ) -> None: other_group = self.create_group(project=self.project) mirror_autofix_run(self, other_group, 67890) mock_client = MagicMock() @@ -788,7 +812,9 @@ def test_continued_run_requires_matching_group( @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") - def test_continued_run_rejects_when_not_mirrored(self, mock_client_class, mock_feature): + def test_continued_run_rejects_when_not_mirrored( + self, mock_client_class: MagicMock, mock_feature: MagicMock + ) -> None: mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = self._make_run_state() @@ -807,8 +833,8 @@ def test_continued_run_rejects_when_not_mirrored(self, mock_client_class, mock_f @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") def test_continued_run_rejects_foreign_project_even_when_metadata_matches( - self, mock_client_class, mock_feature - ): + self, mock_client_class: MagicMock, mock_feature: MagicMock + ) -> None: """Seer echoing this issue's group id must not authorize another project's run.""" other_project = self.create_project(organization=self.group.organization) other_group = self.create_group(project=other_project) @@ -837,7 +863,7 @@ def test_continued_run_rejects_foreign_project_even_when_metadata_matches( mock_feature.assert_not_called() @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") - def test_solution_rejects_run_from_another_project(self, mock_feature): + def test_solution_rejects_run_from_another_project(self, mock_feature: MagicMock) -> None: other_project = self.create_project(organization=self.group.organization) other_group = self.create_group(project=other_project) foreign_run = self.create_seer_run( @@ -903,13 +929,13 @@ def setUp(self) -> None: @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") def test_counts_success( self, - mock_feature, - mock_client_class, - mock_incr, - mock_check_quota, - mock_record_run, - mock_broadcast, - ): + mock_feature: MagicMock, + mock_client_class: MagicMock, + mock_incr: MagicMock, + mock_check_quota: MagicMock, + mock_record_run: MagicMock, + mock_broadcast: MagicMock, + ) -> None: mock_feature.return_value = self.create_seer_run( organization=self.group.organization, seer_run_state_id=123 ) @@ -925,8 +951,13 @@ def test_counts_success( ] def test_counts_declined_when_no_quota( - self, mock_client_class, mock_incr, mock_check_quota, mock_record_run, mock_broadcast - ): + self, + mock_client_class: MagicMock, + mock_incr: MagicMock, + mock_check_quota: MagicMock, + mock_record_run: MagicMock, + mock_broadcast: MagicMock, + ) -> None: mock_check_quota.return_value = False with pytest.raises(NoSeerQuotaException): @@ -947,13 +978,13 @@ def test_counts_declined_when_no_quota( @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_feature") def test_counts_error_when_seer_rejects_run( self, - mock_feature, - mock_client_class, - mock_incr, - mock_check_quota, - mock_record_run, - mock_broadcast, - ): + mock_feature: MagicMock, + mock_client_class: MagicMock, + mock_incr: MagicMock, + mock_check_quota: MagicMock, + mock_record_run: MagicMock, + mock_broadcast: MagicMock, + ) -> None: mock_feature.side_effect = SeerApiError("Seer request failed", 500) with pytest.raises(SeerApiError): @@ -997,7 +1028,7 @@ def test_returns_none_without_repos(self) -> None: assert _build_repo_pins(self.group, AutofixReferrer.UNKNOWN) is None @patch("sentry.scm.factory.new") - def test_builds_base_shas_using_default_branch(self, mock_scm_new): + def test_builds_base_shas_using_default_branch(self, mock_scm_new: MagicMock) -> None: self._make_repo_and_projectrepo() mock_scm = _make_scm_mock( get_repository={"data": {"default_branch": "main"}}, @@ -1019,7 +1050,7 @@ def test_builds_base_shas_using_default_branch(self, mock_scm_new): mock_scm.get_branch.assert_called_once_with("main") @patch("sentry.scm.factory.new") - def test_uses_branch_name_override(self, mock_scm_new): + def test_uses_branch_name_override(self, mock_scm_new: MagicMock) -> None: self._make_repo_and_projectrepo(branch_name="release/v2") mock_scm = _make_scm_mock(get_branch={"data": {"sha": "abc"}}) mock_scm_new.return_value = mock_scm @@ -1040,7 +1071,9 @@ def test_uses_branch_name_override(self, mock_scm_new): @patch("sentry.seer.autofix.autofix_agent.logger") @patch("sentry.scm.factory.new") - def test_skips_repo_when_scm_raises(self, mock_scm_new, mock_logger): + def test_skips_repo_when_scm_raises( + self, mock_scm_new: MagicMock, mock_logger: MagicMock + ) -> None: self._make_repo_and_projectrepo() mock_scm_new.side_effect = Exception("boom") @@ -1048,7 +1081,7 @@ def test_skips_repo_when_scm_raises(self, mock_scm_new, mock_logger): mock_logger.exception.assert_called_once() @patch("sentry.scm.factory.new") - def test_skips_repo_without_resolvable_branch(self, mock_scm_new): + def test_skips_repo_without_resolvable_branch(self, mock_scm_new: MagicMock) -> None: self._make_repo_and_projectrepo() mock_scm = _make_scm_mock(get_repository={"data": {"default_branch": None}}) mock_scm_new.return_value = mock_scm @@ -1057,7 +1090,7 @@ def test_skips_repo_without_resolvable_branch(self, mock_scm_new): mock_scm.get_branch.assert_not_called() @patch("sentry.scm.factory.new") - def test_includes_only_repos_with_resolved_sha(self, mock_scm_new): + def test_includes_only_repos_with_resolved_sha(self, mock_scm_new: MagicMock) -> None: self._make_repo_and_projectrepo(name="repo-ok", external_id="1") self._make_repo_and_projectrepo(name="repo-bad", external_id="2") @@ -1144,7 +1177,7 @@ def _make_handoff(self, *, auto_create_pr: bool) -> None: self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", auto_create_pr) @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") - def test_trigger_coding_agent_handoff_success(self, mock_client_class): + def test_trigger_coding_agent_handoff_success(self, mock_client_class: MagicMock) -> None: """Test successful coding agent handoff.""" mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -1187,7 +1220,9 @@ def test_trigger_coding_agent_handoff_success(self, mock_client_class): ) @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") - def test_trigger_coding_agent_handoff_includes_linked_linear_issue(self, mock_client_class): + def test_trigger_coding_agent_handoff_includes_linked_linear_issue( + self, mock_client_class: MagicMock + ) -> None: mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = self._make_run_state() @@ -1212,7 +1247,9 @@ def test_trigger_coding_agent_handoff_includes_linked_linear_issue(self, mock_cl @patch("sentry.seer.autofix.analytics.analytics.record") @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") - def test_trigger_coding_agent_handoff_records_referrer(self, mock_client_class, mock_record): + def test_trigger_coding_agent_handoff_records_referrer( + self, mock_client_class: MagicMock, mock_record: MagicMock + ) -> None: mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = self._make_run_state() @@ -1233,7 +1270,7 @@ def test_trigger_coding_agent_handoff_records_referrer(self, mock_client_class, assert event.referrer == AutofixReferrer.SLACK.value @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") - def test_trigger_coding_agent_handoff_no_repos(self, mock_client_class): + def test_trigger_coding_agent_handoff_no_repos(self, mock_client_class: MagicMock) -> None: """Test handoff with no repositories in preferences returns failure.""" mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -1250,7 +1287,9 @@ def test_trigger_coding_agent_handoff_no_repos(self, mock_client_class): mock_client.launch_coding_agents.assert_not_called() @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") - def test_trigger_coding_agent_handoff_rejects_run_from_different_group(self, mock_client_class): + def test_trigger_coding_agent_handoff_rejects_run_from_different_group( + self, mock_client_class: MagicMock + ) -> None: other_group = self.create_group(project=self.project) SeerAgentRun.objects.filter(run__seer_run_state_id=123).update(group_id=other_group.id) mock_client = MagicMock() @@ -1270,7 +1309,9 @@ def test_trigger_coding_agent_handoff_rejects_run_from_different_group(self, moc mock_client.launch_coding_agents.assert_not_called() @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") - def test_trigger_coding_agent_handoff_generates_prompt_from_artifacts(self, mock_client_class): + def test_trigger_coding_agent_handoff_generates_prompt_from_artifacts( + self, mock_client_class: MagicMock + ) -> None: """Test that prompt is generated from run state artifacts.""" mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -1308,7 +1349,9 @@ def test_trigger_coding_agent_handoff_generates_prompt_from_artifacts(self, mock assert "Add TTL to cache" in prompt @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") - def test_trigger_coding_agent_handoff_uses_group_title_for_branch(self, mock_client_class): + def test_trigger_coding_agent_handoff_uses_group_title_for_branch( + self, mock_client_class: MagicMock + ) -> None: """Test that branch_name_base is the group title behind a seer/ prefix.""" mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -1335,8 +1378,8 @@ def test_trigger_coding_agent_handoff_uses_group_title_for_branch(self, mock_cli @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") def test_trigger_coding_agent_handoff_fetches_auto_create_pr_from_preferences( - self, mock_client_class - ): + self, mock_client_class: MagicMock + ) -> None: """Test that auto_create_pr is fetched from project preferences.""" mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -1359,7 +1402,9 @@ def test_trigger_coding_agent_handoff_fetches_auto_create_pr_from_preferences( assert call_kwargs["auto_create_pr"] is True @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") - def test_trigger_coding_agent_handoff_defaults_auto_create_pr_false(self, mock_client_class): + def test_trigger_coding_agent_handoff_defaults_auto_create_pr_false( + self, mock_client_class: MagicMock + ) -> None: """Test that auto_create_pr defaults to False when automation_handoff not configured.""" mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -1382,7 +1427,9 @@ def test_trigger_coding_agent_handoff_defaults_auto_create_pr_false(self, mock_c assert call_kwargs["auto_create_pr"] is False @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") - def test_trigger_coding_agent_handoff_uses_auto_create_pr_override(self, mock_client_class): + def test_trigger_coding_agent_handoff_uses_auto_create_pr_override( + self, mock_client_class: MagicMock + ) -> None: """Test that manual handoff can force PR creation independent of automation settings.""" mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -1406,7 +1453,9 @@ def test_trigger_coding_agent_handoff_uses_auto_create_pr_override(self, mock_cl assert call_kwargs["auto_create_pr"] is True @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") - def test_trigger_coding_agent_handoff_filters_to_relevant_repo(self, mock_client_class): + def test_trigger_coding_agent_handoff_filters_to_relevant_repo( + self, mock_client_class: MagicMock + ) -> None: """Test that only the repo named in relevant_repo is passed to launch_coding_agents.""" mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -1437,8 +1486,8 @@ def test_trigger_coding_agent_handoff_filters_to_relevant_repo(self, mock_client @patch("sentry.seer.autofix.autofix_agent.logger") @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") def test_trigger_coding_agent_handoff_falls_back_to_first_repo_when_no_relevant_repo( - self, mock_client_class, mock_logger - ): + self, mock_client_class: MagicMock, mock_logger: MagicMock + ) -> None: """Test that when relevant_repo is absent, first configured repo is used and a warning is logged.""" mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -1471,8 +1520,8 @@ def test_trigger_coding_agent_handoff_falls_back_to_first_repo_when_no_relevant_ @patch("sentry.seer.autofix.autofix_agent.logger") @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") def test_trigger_coding_agent_handoff_falls_back_when_relevant_repo_doesnt_match( - self, mock_client_class, mock_logger - ): + self, mock_client_class: MagicMock, mock_logger: MagicMock + ) -> None: """Test that when relevant_repo doesn't match any configured repo, first repo is used.""" mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -1509,7 +1558,7 @@ def test_trigger_coding_agent_handoff_falls_back_when_relevant_repo_doesnt_match }, ) - def test_raises_permission_denied_when_coding_disabled(self): + def test_raises_permission_denied_when_coding_disabled(self) -> None: self.organization.update_option("sentry:enable_seer_coding", False) with pytest.raises(PermissionDenied, match="Code generation is disabled"): @@ -1522,8 +1571,8 @@ def test_raises_permission_denied_when_coding_disabled(self): @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") def test_trigger_coding_agent_handoff_keeps_branch_name_from_preferences_when_set( - self, mock_client_class - ): + self, mock_client_class: MagicMock + ) -> None: """Test that branch_name from preferences is used as-is when already set.""" mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -1544,8 +1593,8 @@ def test_trigger_coding_agent_handoff_keeps_branch_name_from_preferences_when_se @patch("sentry.seer.autofix.autofix_agent._resolve_default_branch", return_value="main") @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") def test_trigger_coding_agent_handoff_resolves_default_branch_when_empty( - self, mock_client_class, mock_resolve - ): + self, mock_client_class: MagicMock, mock_resolve: MagicMock + ) -> None: mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = self._make_run_state() @@ -1568,11 +1617,16 @@ def test_trigger_coding_agent_handoff_resolves_default_branch_when_empty( class TestTriggerPushChanges(TestCase): """Tests for trigger_push_changes function.""" - def setUp(self): + def setUp(self) -> None: super().setUp() self.group = self.create_group(project=self.project) - def _push(self, mock_post, features=None, **kwargs): + def _push( + self, + mock_post: MagicMock, + features: dict[str, bool] | None = None, + **kwargs: Any, + ) -> dict[str, Any]: """Push with a minimal run state and return the payload sent to Seer.""" mirror_autofix_run(self, self.group, 123) mock_post.return_value = MagicMock(status=200) @@ -1594,7 +1648,8 @@ def _push(self, mock_post, features=None, **kwargs): **kwargs, ) - return mock_post.call_args[0][0]["payload"] + # justification: the mock records the request payload as its first argument. + return cast("dict[str, Any]", mock_post.call_args[0][0]["payload"]) def _fixes_line(self) -> str: issue_url = self.group.get_absolute_url(params={"seerDrawer": "true"}) @@ -1610,7 +1665,7 @@ def _iteration_help(self) -> str: "\n`@sentry stop iterating`: Autofix stops iterating on this run" ) - def test_raises_permission_denied_when_coding_disabled(self): + def test_raises_permission_denied_when_coding_disabled(self) -> None: self.organization.update_option("sentry:enable_seer_coding", False) with pytest.raises(PermissionDenied, match="Code generation is disabled"): @@ -1621,7 +1676,9 @@ def test_raises_permission_denied_when_coding_disabled(self): ) @patch("sentry.seer.agent.client.make_agent_update_request") - def test_passes_pr_description_suffix_ready_state_and_author(self, mock_post): + def test_passes_pr_description_suffix_ready_state_and_author( + self, mock_post: MagicMock + ) -> None: author = SeerCommitAuthor(name="Mona", email="1+octocat@users.noreply.github.com") payload = self._push(mock_post, author=author) @@ -1631,7 +1688,7 @@ def test_passes_pr_description_suffix_ready_state_and_author(self, mock_post): assert payload["author"] == author @patch("sentry.seer.agent.client.make_agent_update_request") - def test_opens_as_draft_when_review_request_enabled(self, mock_post): + def test_opens_as_draft_when_review_request_enabled(self, mock_post: MagicMock) -> None: payload = self._push( mock_post, features={ @@ -1643,7 +1700,7 @@ def test_opens_as_draft_when_review_request_enabled(self, mock_post): assert "author" not in payload @patch("sentry.seer.agent.client.make_agent_update_request") - def test_pr_description_suffix_includes_linear_issue(self, mock_post): + def test_pr_description_suffix_includes_linear_issue(self, mock_post: MagicMock) -> None: self.create_platform_external_issue( group=self.group, service_type="linear", @@ -1659,7 +1716,7 @@ def test_pr_description_suffix_includes_linear_issue(self, mock_post): ) @patch("sentry.seer.agent.client.make_agent_update_request") - def test_pr_description_suffix_linear_alphanumeric_prefix(self, mock_post): + def test_pr_description_suffix_linear_alphanumeric_prefix(self, mock_post: MagicMock) -> None: self.create_platform_external_issue( group=self.group, service_type="linear", @@ -1676,7 +1733,9 @@ def test_pr_description_suffix_linear_alphanumeric_prefix(self, mock_post): @patch("sentry.seer.autofix.autofix_agent.is_free_cohort_org", return_value=True) @patch("sentry.seer.agent.client.make_agent_update_request") - def test_pr_description_suffix_automated_free_cohort_run(self, mock_post, _mock_free_cohort): + def test_pr_description_suffix_automated_free_cohort_run( + self, mock_post: MagicMock, _mock_free_cohort: MagicMock + ) -> None: self.create_seer_run( organization=self.organization, seer_run_state_id=123, @@ -1695,7 +1754,7 @@ def test_pr_description_suffix_automated_free_cohort_run(self, mock_post, _mock_ ) @patch("sentry.seer.agent.client.make_agent_update_request") - def test_pr_description_suffix_automated_paid_run(self, mock_post): + def test_pr_description_suffix_automated_paid_run(self, mock_post: MagicMock) -> None: self.create_seer_run( organization=self.organization, seer_run_state_id=123, @@ -1714,7 +1773,9 @@ def test_pr_description_suffix_automated_paid_run(self, mock_post): ) @patch("sentry.seer.agent.client.make_agent_update_request") - def test_pr_description_suffix_manual_run_has_no_automation_note(self, mock_post): + def test_pr_description_suffix_manual_run_has_no_automation_note( + self, mock_post: MagicMock + ) -> None: self.create_seer_run( organization=self.organization, seer_run_state_id=123, @@ -1729,7 +1790,9 @@ def test_pr_description_suffix_manual_run_has_no_automation_note(self, mock_post class TestAutofixFunnelAnalytics: @patch("sentry.seer.autofix.analytics.metrics.incr") @patch("sentry.seer.autofix.analytics.analytics.record") - def test_records_solution_completion(self, mock_record, mock_metrics_incr) -> None: + def test_records_solution_completion( + self, mock_record: MagicMock, mock_metrics_incr: MagicMock + ) -> None: event = AiAutofixSolutionCompletedEvent( organization_id=1, project_id=1, diff --git a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py index 65c2d7bf69fd..01efbe54ce71 100644 --- a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py @@ -1,4 +1,4 @@ -from typing import TypedDict +from typing import Any, Literal, TypedDict from unittest.mock import MagicMock, patch from sentry.analytics.events.pr_iteration_events import ( @@ -62,7 +62,7 @@ from sentry.seer.autofix.steps import AutofixStep from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.models import AutofixHandoffPoint, SeerAutomationHandoffConfiguration -from sentry.seer.models.run import SeerRunMilestone, SeerRunMilestoneType +from sentry.seer.models.run import SeerRun, SeerRunMilestone, SeerRunMilestoneType from sentry.seer.models.seer_api_models import SeerApiError from sentry.sentry_apps.utils.webhooks import SeerActionType from sentry.testutils.cases import TestCase @@ -74,12 +74,12 @@ def run_state( - run_id=123, + run_id: int = 123, blocks: list[MemoryBlock] | None = None, - metadata=None, - status="completed", - failure_reason=None, -): + metadata: dict[str, Any] | None = None, + status: Literal["processing", "completed", "error", "awaiting_user_input"] = "completed", + failure_reason: str | None = None, +) -> SeerRunState: return SeerRunState( run_id=run_id, blocks=blocks if blocks is not None else [], @@ -229,7 +229,7 @@ def test_get_current_step_none(self) -> None: assert step is None assert referrer is None - def test_get_current_step_extracts_referrer(self): + def test_get_current_step_extracts_referrer(self) -> None: """Returns the referrer from message metadata.""" state = run_state( blocks=[root_cause_memory_block(referrer=AutofixReferrer.ON_COMPLETION_HOOK.value)] @@ -238,7 +238,7 @@ def test_get_current_step_extracts_referrer(self): assert step == AutofixStep.ROOT_CAUSE assert referrer == AutofixReferrer.ON_COMPLETION_HOOK - def test_get_current_step_extracts_referrer_from_latest_block(self): + def test_get_current_step_extracts_referrer_from_latest_block(self) -> None: """Returns the referrer from the most recent block with step metadata.""" state = run_state( blocks=[ @@ -250,7 +250,7 @@ def test_get_current_step_extracts_referrer_from_latest_block(self): assert step == AutofixStep.SOLUTION assert referrer == AutofixReferrer.ON_COMPLETION_HOOK - def test_get_current_step_invalid_referrer_returns_none(self): + def test_get_current_step_invalid_referrer_returns_none(self) -> None: """Returns None referrer when referrer value is not a valid AutofixReferrer.""" state = run_state(blocks=[root_cause_memory_block(referrer="not_a_valid_referrer")]) step, referrer = get_current_step(state) @@ -288,9 +288,9 @@ def setUp(self) -> None: def _create_run( self, seer_run_state_id: int, - extras: dict | None = None, + extras: dict[str, Any] | None = None, source: str = "autofix_rca", - ): + ) -> SeerRun: run = self.create_seer_run( organization=self.organization, type="feature_run", @@ -361,7 +361,7 @@ def test_group_and_referrer_ignores_other_feature_runs(self) -> None: @patch("sentry.seer.autofix.on_completion_hook.fetch_run_status") @patch("sentry.seer.autofix.on_completion_hook.trigger_autofix_agent") def test_referrer_falls_back_to_the_mirror_when_state_carries_a_group_id( - self, mock_trigger, mock_fetch_run_status + self, mock_trigger: MagicMock, mock_fetch_run_status: MagicMock ) -> None: self._create_run( 123, @@ -380,7 +380,9 @@ def test_referrer_falls_back_to_the_mirror_when_state_carries_a_group_id( assert mock_trigger.call_args.kwargs["referrer"] == AutofixReferrer.AGENTIC_TRIAGE @patch("sentry.seer.autofix.on_completion_hook.trigger_autofix_agent") - def test_state_metadata_takes_precedence_over_the_run_mirror(self, mock_trigger) -> None: + def test_state_metadata_takes_precedence_over_the_run_mirror( + self, mock_trigger: MagicMock + ) -> None: """A legacy run carries its own stopping point; the mirror must not override it. Here state says stop at root cause while the mirror says continue.""" self._create_run(123, extras={"stopping_point": AutofixStoppingPoint.CODE_CHANGES.value}) @@ -407,21 +409,23 @@ def setUp(self) -> None: self.group = self.create_group(project=self.project) @patch("sentry.seer.autofix.on_completion_hook.trigger_autofix_agent") - def test_maybe_continue_pipeline_no_metadata(self, mock_trigger): + def test_maybe_continue_pipeline_no_metadata(self, mock_trigger: MagicMock) -> None: """Does not continue when metadata is missing.""" state = run_state(blocks=[root_cause_memory_block()]) AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state, self.group) mock_trigger.assert_not_called() @patch("sentry.seer.autofix.on_completion_hook.trigger_autofix_agent") - def test_maybe_continue_pipeline_no_stopping_point_in_metadata(self, mock_trigger): + def test_maybe_continue_pipeline_no_stopping_point_in_metadata( + self, mock_trigger: MagicMock + ) -> None: """Does not continue when stopping_point is missing from metadata.""" state = run_state(blocks=[root_cause_memory_block()], metadata={"group_id": self.group.id}) AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state, self.group) mock_trigger.assert_not_called() @patch("sentry.seer.autofix.on_completion_hook.trigger_autofix_agent") - def test_maybe_continue_pipeline_at_stopping_point(self, mock_trigger): + def test_maybe_continue_pipeline_at_stopping_point(self, mock_trigger: MagicMock) -> None: """Does not continue when current step matches stopping point.""" state = run_state( blocks=[root_cause_memory_block()], @@ -434,7 +438,7 @@ def test_maybe_continue_pipeline_at_stopping_point(self, mock_trigger): mock_trigger.assert_not_called() @patch("sentry.seer.autofix.on_completion_hook.trigger_autofix_agent") - def test_maybe_continue_pipeline_continues_to_next_step(self, mock_trigger): + def test_maybe_continue_pipeline_continues_to_next_step(self, mock_trigger: MagicMock) -> None: """Continues to next step when not at stopping point.""" # No handoff configured - should continue with normal pipeline. state = run_state( @@ -452,7 +456,9 @@ def test_maybe_continue_pipeline_continues_to_next_step(self, mock_trigger): assert call_kwargs["run_id"] == 123 @patch("sentry.seer.autofix.on_completion_hook.trigger_push_changes") - def test_maybe_continue_pipeline_pushes_changes_for_open_pr(self, mock_push_changes): + def test_maybe_continue_pipeline_pushes_changes_for_open_pr( + self, mock_push_changes: MagicMock + ) -> None: """Pushes changes when stopping_point is open_pr and code_changes completed.""" state = run_state( blocks=[ @@ -477,8 +483,8 @@ def test_maybe_continue_pipeline_pushes_changes_for_open_pr(self, mock_push_chan @patch("sentry.seer.autofix.pr_iteration.completion.consume_queued_feedback") @patch("sentry.seer.autofix.pr_iteration.completion.trigger_push_changes") def test_pr_iteration_does_not_consume_feedback_when_just_pushed( - self, mock_push_changes, mock_consume - ): + self, mock_push_changes: MagicMock, mock_consume: MagicMock + ) -> None: """A push that just landed waits for the next hook pass to consume feedback.""" state = run_state( blocks=[pr_iteration_memory_block()], @@ -496,7 +502,9 @@ def test_pr_iteration_does_not_consume_feedback_when_just_pushed( @patch("sentry.seer.autofix.pr_iteration.completion.consume_queued_feedback") @patch("sentry.seer.autofix.pr_iteration.completion.trigger_push_changes") - def test_pr_iteration_push_forwards_stored_commit_author(self, mock_push_changes, mock_consume): + def test_pr_iteration_push_forwards_stored_commit_author( + self, mock_push_changes: MagicMock, mock_consume: MagicMock + ) -> None: """An iteration's push is attributed to the author stored on its opening block.""" block = pr_iteration_memory_block() state = run_state(blocks=[block], metadata={"group_id": self.group.id}) @@ -519,8 +527,8 @@ def test_pr_iteration_push_forwards_stored_commit_author(self, mock_push_changes @patch("sentry.seer.autofix.pr_iteration.completion.consume_queued_feedback") @patch("sentry.seer.autofix.pr_iteration.completion.trigger_push_changes") def test_pr_iteration_consumes_feedback_on_the_hand_back_pass( - self, mock_push_changes, mock_consume - ): + self, mock_push_changes: MagicMock, mock_consume: MagicMock + ) -> None: """Seeing a prior push's changes already synced is what consumes feedback.""" state = run_state( blocks=[pr_iteration_memory_block(commit_sha="synced-sha")], @@ -541,8 +549,8 @@ def test_pr_iteration_consumes_feedback_on_the_hand_back_pass( @patch("sentry.seer.autofix.pr_iteration.completion.consume_queued_feedback") @patch("sentry.seer.autofix.pr_iteration.completion.trigger_push_changes") def test_pr_iteration_consumes_feedback_when_no_code_changes( - self, mock_push_changes, mock_consume - ): + self, mock_push_changes: MagicMock, mock_consume: MagicMock + ) -> None: """There was never anything to push, so nothing landing is expected.""" state = run_state( blocks=[ @@ -569,7 +577,9 @@ def test_pr_iteration_consumes_feedback_when_no_code_changes( mock_consume.assert_called_once() @patch("sentry.seer.autofix.on_completion_hook.trigger_push_changes") - def test_push_changes_skips_when_all_unsynced_repos_errored(self, mock_push_changes): + def test_push_changes_skips_when_all_unsynced_repos_errored( + self, mock_push_changes: MagicMock + ) -> None: """Does not re-push when every un-synced repo is already in pr_creation_status='error'.""" state = run_state( blocks=[ @@ -594,7 +604,9 @@ def test_push_changes_skips_when_all_unsynced_repos_errored(self, mock_push_chan mock_push_changes.assert_not_called() @patch("sentry.seer.autofix.on_completion_hook.trigger_push_changes") - def test_push_changes_pushes_when_any_unsynced_repo_not_errored(self, mock_push_changes): + def test_push_changes_pushes_when_any_unsynced_repo_not_errored( + self, mock_push_changes: MagicMock + ) -> None: """Still pushes when a repo with diffs has no PR state yet (e.g. newly added).""" state = run_state( blocks=[ @@ -648,7 +660,7 @@ def setUp(self) -> None: self.project = self.create_project(organization=self.organization) self.group = self.create_group(project=self.project) - def _pr_state(self, commit_sha: str | None, **kwargs) -> dict[str, RepoPRState]: + def _pr_state(self, commit_sha: str | None, **kwargs: object) -> dict[str, RepoPRState]: return { "test-repo": RepoPRState( repo_name="test-repo", @@ -705,13 +717,17 @@ def _webhook(self, state: SeerRunState) -> None: ) @patch(f"{HOOK_PATH}.broadcast_webhooks_for_organization.delay") - def test_a_pass_that_is_still_owed_a_push_does_not_emit_webhook(self, mock_broadcast): + def test_a_pass_that_is_still_owed_a_push_does_not_emit_webhook( + self, mock_broadcast: MagicMock + ) -> None: self._webhook(self._unsynced()) mock_broadcast.assert_not_called() @patch(f"{HOOK_PATH}.broadcast_webhooks_for_organization.delay") - def test_the_pass_where_the_changes_landed_emits_iteration_completed(self, mock_broadcast): + def test_the_pass_where_the_changes_landed_emits_iteration_completed( + self, mock_broadcast: MagicMock + ) -> None: self._webhook(self._synced()) assert ( @@ -720,7 +736,9 @@ def test_the_pass_where_the_changes_landed_emits_iteration_completed(self, mock_ ) @patch(f"{HOOK_PATH}.broadcast_webhooks_for_organization.delay") - def test_a_terminally_errored_repo_still_emits_the_webhook(self, mock_broadcast): + def test_a_terminally_errored_repo_still_emits_the_webhook( + self, mock_broadcast: MagicMock + ) -> None: """We stop waiting for a sync a failed PR creation will never deliver.""" state = self._unsynced() state.repo_pr_states = self._pr_state("stale-sha") @@ -731,7 +749,9 @@ def test_a_terminally_errored_repo_still_emits_the_webhook(self, mock_broadcast) mock_broadcast.assert_called_once() @patch(f"{HOOK_PATH}.broadcast_webhooks_for_organization.delay") - def test_an_iteration_without_pr_states_does_not_take_down_the_hook(self, mock_broadcast): + def test_an_iteration_without_pr_states_does_not_take_down_the_hook( + self, mock_broadcast: MagicMock + ) -> None: """Was an assert, which took the rest of the hook -- push included -- with it.""" state = run_state( blocks=[pr_iteration_memory_block()], metadata={"group_id": self.group.id} @@ -743,7 +763,9 @@ def test_an_iteration_without_pr_states_does_not_take_down_the_hook(self, mock_b @patch(f"{COMPLETION_PATH}.consume_queued_feedback") @patch(f"{COMPLETION_PATH}.trigger_push_changes") - def test_no_pr_states_does_not_open_a_new_pr(self, mock_push, mock_consume): + def test_no_pr_states_does_not_open_a_new_pr( + self, mock_push: MagicMock, mock_consume: MagicMock + ) -> None: """Webhook return is not enough: execute() still reaches the pipeline.""" state = run_state( blocks=[pr_iteration_memory_block()], metadata={"group_id": self.group.id} @@ -755,7 +777,7 @@ def test_no_pr_states_does_not_open_a_new_pr(self, mock_push, mock_consume): mock_consume.assert_not_called() @patch(f"{COMPLETION_PATH}.trigger_push_changes") - def test_a_pass_that_pushes(self, mock_push): + def test_a_pass_that_pushes(self, mock_push: MagicMock) -> None: state = self._unsynced() pushed = self._push(state) @@ -764,7 +786,7 @@ def test_a_pass_that_pushes(self, mock_push): mock_push.assert_called_once() @patch(f"{COMPLETION_PATH}.trigger_push_changes") - def test_the_hand_back_pass_does_not_push_again(self, mock_push): + def test_the_hand_back_pass_does_not_push_again(self, mock_push: MagicMock) -> None: """Already synced is the previous push landing, not the agent idling.""" state = self._synced() @@ -774,7 +796,7 @@ def test_the_hand_back_pass_does_not_push_again(self, mock_push): mock_push.assert_not_called() @patch(f"{COMPLETION_PATH}.trigger_push_changes") - def test_an_iteration_that_changed_nothing_does_not_push(self, mock_push): + def test_an_iteration_that_changed_nothing_does_not_push(self, mock_push: MagicMock) -> None: """The run's cumulative diff is not the question -- the *latest* iteration's own blocks are, so a prior iteration's patches (still on the merged diff) must not make this one look like it pushed.""" @@ -811,7 +833,7 @@ def test_an_iteration_that_changed_nothing_does_not_push(self, mock_push): mock_push.assert_not_called() @patch(f"{COMPLETION_PATH}.trigger_push_changes") - def test_a_repo_whose_pr_creation_errored_stops_the_push(self, mock_push): + def test_a_repo_whose_pr_creation_errored_stops_the_push(self, mock_push: MagicMock) -> None: """Re-pushing into it would re-fire this hook in a loop.""" state = self._unsynced() state.repo_pr_states["test-repo"].pr_creation_status = "error" @@ -836,8 +858,13 @@ def _github_repo(self, name: str = "test-repo", external_id: str = "1") -> None: @patch(f"{PR_STATE_PATH}.make_scm") @patch(f"{COMPLETION_PATH}.trigger_push_changes") def test_a_closed_pr_stops_the_push( - self, mock_push, mock_make_scm, mock_get_pull_request, mock_pause, mock_incr - ): + self, + mock_push: MagicMock, + mock_make_scm: MagicMock, + mock_get_pull_request: MagicMock, + mock_pause: MagicMock, + mock_incr: MagicMock, + ) -> None: """Closing the PR is the stop signal; pushing into it would talk past it.""" self._github_repo() mock_get_pull_request.return_value = {"data": {"state": "closed"}} @@ -853,7 +880,9 @@ def test_a_closed_pr_stops_the_push( @patch(f"{PR_STATE_PATH}.scm_actions.get_pull_request") @patch(f"{PR_STATE_PATH}.make_scm") @patch(f"{COMPLETION_PATH}.trigger_push_changes") - def test_an_open_pr_still_pushes(self, mock_push, mock_make_scm, mock_get_pull_request): + def test_an_open_pr_still_pushes( + self, mock_push: MagicMock, mock_make_scm: MagicMock, mock_get_pull_request: MagicMock + ) -> None: self._github_repo() mock_get_pull_request.return_value = {"data": {"state": "open"}} @@ -867,8 +896,8 @@ def test_an_open_pr_still_pushes(self, mock_push, mock_make_scm, mock_get_pull_r @patch(f"{PR_STATE_PATH}.make_scm") @patch(f"{COMPLETION_PATH}.trigger_push_changes") def test_one_closed_pr_stops_a_multi_repo_push( - self, mock_push, mock_make_scm, mock_get_pull_request - ): + self, mock_push: MagicMock, mock_make_scm: MagicMock, mock_get_pull_request: MagicMock + ) -> None: """A push serves every repo at once, so it cannot skip just the closed one.""" self._github_repo() self._github_repo("other-repo", external_id="2") @@ -897,8 +926,8 @@ def test_one_closed_pr_stops_a_multi_repo_push( @patch(f"{PR_STATE_PATH}.make_scm") @patch(f"{COMPLETION_PATH}.trigger_push_changes") def test_a_pr_we_cannot_read_still_pushes( - self, mock_push, mock_make_scm, mock_get_pull_request - ): + self, mock_push: MagicMock, mock_make_scm: MagicMock, mock_get_pull_request: MagicMock + ) -> None: """A transient read failure must not silently drop the iteration's changes.""" self._github_repo() @@ -908,7 +937,7 @@ def test_a_pr_we_cannot_read_still_pushes( mock_push.assert_called_once() @patch(f"{COMPLETION_PATH}.trigger_push_changes", side_effect=ValueError("boom")) - def test_a_failed_push_is_swallowed(self, mock_push): + def test_a_failed_push_is_swallowed(self, mock_push: MagicMock) -> None: state = self._unsynced() pushed = self._push(state) @@ -919,7 +948,9 @@ def test_a_failed_push_is_swallowed(self, mock_push): f"{COMPLETION_PATH}.consume_queued_feedback", ) @patch(f"{COMPLETION_PATH}.trigger_push_changes", side_effect=ValueError("boom")) - def test_a_failed_push_does_not_consume_queued_feedback(self, mock_push, mock_consume): + def test_a_failed_push_does_not_consume_queued_feedback( + self, mock_push: MagicMock, mock_consume: MagicMock + ) -> None: """A broken push should not hand queued feedback off as if it landed.""" AutofixOnCompletionHook._maybe_continue_pipeline( self.organization, 123, self._unsynced(), self.group @@ -928,7 +959,7 @@ def test_a_failed_push_does_not_consume_queued_feedback(self, mock_push, mock_co mock_consume.assert_not_called() @patch(f"{COMPLETION_PATH}.consume_queued_autofix_feedback.apply_async") - def test_the_hand_back_to_the_queue_schedules_the_drain(self, mock_apply): + def test_the_hand_back_to_the_queue_schedules_the_drain(self, mock_apply: MagicMock) -> None: state = self._synced() continue_pr_iteration( @@ -942,7 +973,9 @@ def test_the_hand_back_to_the_queue_schedules_the_drain(self, mock_apply): assert task_kwargs["trigger_source"] == ConsumeTriggerSource.COMPLETION @patch(f"{COMPLETION_PATH}.complete_pr_iteration_details") - def test_no_pull_request_reaches_completion_details_as_that_outcome(self, mock_complete): + def test_no_pull_request_reaches_completion_details_as_that_outcome( + self, mock_complete: MagicMock + ) -> None: state = run_state( blocks=[pr_iteration_memory_block()], metadata={"group_id": self.group.id} ) @@ -954,7 +987,9 @@ def test_no_pull_request_reaches_completion_details_as_that_outcome(self, mock_c assert mock_complete.call_args.kwargs["outcome"] == PrIterationOutcome.NO_PULL_REQUEST.value @patch(f"{COMPLETION_PATH}.complete_pr_iteration_details") - def test_already_synced_reaches_completion_details_as_that_outcome(self, mock_complete): + def test_already_synced_reaches_completion_details_as_that_outcome( + self, mock_complete: MagicMock + ) -> None: continue_pr_iteration( self.organization, self.group, 123, self._synced(), AutofixReferrer.ON_COMPLETION_HOOK ) @@ -964,8 +999,8 @@ def test_already_synced_reaches_completion_details_as_that_outcome(self, mock_co @patch(f"{COMPLETION_PATH}.complete_pr_iteration_details") @patch(f"{COMPLETION_PATH}.trigger_push_changes") def test_a_terminally_errored_repo_reaches_completion_details_as_that_outcome( - self, mock_push, mock_complete - ): + self, mock_push: MagicMock, mock_complete: MagicMock + ) -> None: state = self._unsynced() state.repo_pr_states["test-repo"].pr_creation_status = "error" @@ -1027,12 +1062,12 @@ def _branch_moved_push( ) return outcome, mock_client_cls.return_value.continue_feature_run.called - def test_a_branch_moved_push_retries_up_to_the_retry_cap(self): + def test_a_branch_moved_push_retries_up_to_the_retry_cap(self) -> None: state = self._branch_moved(turns=MAX_BRANCH_MOVED_RETRIES) assert self._branch_moved_push(state) == (Retrying.BRANCH_MOVED, True) - def test_a_branch_moved_push_past_the_retry_cap_ends_the_iteration_as_errored(self): + def test_a_branch_moved_push_past_the_retry_cap_ends_the_iteration_as_errored(self) -> None: state = self._branch_moved(turns=MAX_BRANCH_MOVED_RETRIES + 1) assert self._branch_moved_push(state) == (PrIterationOutcome.PR_CREATION_ERRORED, False) @@ -1061,16 +1096,16 @@ def _complete( events = [call.kwargs["event_name"] for call in mock_broadcast.call_args_list] return continue_feature_run.called, SeerActionType.ITERATION_COMPLETED.value in events - def test_a_branch_moved_retry_holds_the_webhook(self): + def test_a_branch_moved_retry_holds_the_webhook(self) -> None: """The retry turn finishes the iteration, and its completion emits the webhook.""" assert self._complete(self._branch_moved()) == (True, False) - def test_a_branch_moved_push_past_the_retry_cap_emits_the_webhook(self): + def test_a_branch_moved_push_past_the_retry_cap_emits_the_webhook(self) -> None: state = self._branch_moved(turns=MAX_BRANCH_MOVED_RETRIES + 1) assert self._complete(state) == (False, True) - def test_a_branch_moved_retry_that_seer_refuses_emits_the_webhook(self): + def test_a_branch_moved_retry_that_seer_refuses_emits_the_webhook(self) -> None: state = self._branch_moved() assert self._complete(state, seer_error=SeerApiError("unavailable", 503)) == (True, True) @@ -1079,21 +1114,21 @@ def test_a_branch_moved_retry_that_seer_refuses_emits_the_webhook(self): @patch(f"{PR_STATE_PATH}.scm_actions.get_pull_request") @patch(f"{PR_STATE_PATH}.make_scm") def test_a_branch_moved_push_on_a_closed_pr_emits_the_webhook( - self, mock_make_scm, mock_get_pull_request - ): + self, mock_make_scm: MagicMock, mock_get_pull_request: MagicMock + ) -> None: self._github_repo() mock_get_pull_request.return_value = {"data": {"state": "closed"}} assert self._complete(self._branch_moved()) == (False, True) - def test_an_automated_iteration_retries_with_pr_iteration_turned_off(self): + def test_an_automated_iteration_retries_with_pr_iteration_turned_off(self) -> None: """A retry finishes the iteration already running, so the iteration gates don't apply.""" self.project.update_option("sentry:seer_pr_iteration", False) state = self._branch_moved(feedback=self._bot_feedback()) assert self._branch_moved_push(state) == (Retrying.BRANCH_MOVED, True) - def test_a_push_refused_because_the_branch_moved_retries_the_iteration_pinned(self): + def test_a_push_refused_because_the_branch_moved_retries_the_iteration_pinned(self) -> None: run = self.create_seer_run(organization=self.organization, seer_run_state_id=123) self.create_seer_agent_run( run=run, source=LEGACY_FEATURE_ID, group=self.group, project=self.project @@ -1130,8 +1165,8 @@ def test_a_push_refused_because_the_branch_moved_retries_the_iteration_pinned(se @patch(f"{PR_STATE_PATH}.scm_actions.get_pull_request") @patch(f"{PR_STATE_PATH}.make_scm") def test_a_closed_pr_stops_a_branch_moved_retry( - self, mock_make_scm, mock_get_pull_request, mock_pause - ): + self, mock_make_scm: MagicMock, mock_get_pull_request: MagicMock, mock_pause: MagicMock + ) -> None: self._github_repo() mock_get_pull_request.return_value = {"data": {"state": "closed"}} state = self._branch_moved() @@ -1152,7 +1187,7 @@ def test_a_closed_pr_stops_a_branch_moved_retry( assert mock_pause.call_args.kwargs["reason"] == PauseReason.PR_CLOSED mock_client_cls.return_value.continue_feature_run.assert_not_called() - def test_a_branch_moved_retry_that_seer_refuses_ends_the_iteration_as_errored(self): + def test_a_branch_moved_retry_that_seer_refuses_ends_the_iteration_as_errored(self) -> None: run = self.create_seer_run(organization=self.organization, seer_run_state_id=123) self.create_seer_agent_run( run=run, source=LEGACY_FEATURE_ID, group=self.group, project=self.project @@ -1181,8 +1216,12 @@ def test_a_branch_moved_retry_that_seer_refuses_ends_the_iteration_as_errored(se @patch(f"{COMPLETION_PATH}.trigger_push_changes") @patch(f"{HOOK_PATH}.fetch_run_status") def test_a_finished_branch_moved_push_pauses_as_an_error( - self, mock_fetch, mock_push, mock_consume, mock_complete - ): + self, + mock_fetch: MagicMock, + mock_push: MagicMock, + mock_consume: MagicMock, + mock_complete: MagicMock, + ) -> None: self.create_seer_run(organization=self.organization, seer_run_state_id=123) state = self._unsynced() state.repo_pr_states["test-repo"] = RepoPRState.parse_obj( @@ -1210,7 +1249,9 @@ def test_a_finished_branch_moved_push_pauses_as_an_error( @patch(f"{COMPLETION_PATH}.complete_pr_iteration_details") @patch(f"{COMPLETION_PATH}.pause_pr_iteration") - def test_another_push_error_does_not_pause_as_branch_moved(self, mock_pause, mock_complete): + def test_another_push_error_does_not_pause_as_branch_moved( + self, mock_pause: MagicMock, mock_complete: MagicMock + ) -> None: state = self._unsynced() repo_state = state.repo_pr_states["test-repo"] repo_state.pr_creation_status = "error" @@ -1232,8 +1273,8 @@ def test_another_push_error_does_not_pause_as_branch_moved(self, mock_pause, moc @patch(f"{COMPLETION_PATH}.complete_pr_iteration_details") @patch(f"{COMPLETION_PATH}.trigger_push_changes", side_effect=ValueError("boom")) def test_a_failed_push_reaches_completion_details_as_that_outcome( - self, mock_push, mock_complete - ): + self, mock_push: MagicMock, mock_complete: MagicMock + ) -> None: continue_pr_iteration( self.organization, self.group, 123, self._unsynced(), AutofixReferrer.ON_COMPLETION_HOOK ) @@ -1309,8 +1350,8 @@ def _triggered_iteration(self) -> int: @patch("sentry.analytics.record") @patch(f"{HOOK_PATH}.fetch_run_status") def test_a_failed_iteration_records_its_batch_under_the_failure_reason( - self, mock_fetch, mock_record - ): + self, mock_fetch: MagicMock, mock_record: MagicMock + ) -> None: iteration_id = self._triggered_iteration() mock_fetch.return_value = self._errored( [pr_iteration_memory_block(iteration_id=iteration_id)], @@ -1343,8 +1384,8 @@ def test_a_failed_iteration_records_its_batch_under_the_failure_reason( @patch(f"{COMPLETION_PATH}.trigger_push_changes") @patch(f"{HOOK_PATH}.fetch_run_status") def test_a_failed_iteration_pauses_instead_of_pushing( - self, mock_fetch, mock_push, mock_consume - ): + self, mock_fetch: MagicMock, mock_push: MagicMock, mock_consume: MagicMock + ) -> None: self.create_seer_run( organization=self.organization, seer_run_state_id=123, user_id=self.user.id ) @@ -1364,8 +1405,8 @@ def test_a_failed_iteration_pauses_instead_of_pushing( @patch(f"{COMPLETION_PATH}.trigger_push_changes") @patch(f"{HOOK_PATH}.fetch_run_status") def test_a_failed_iteration_without_a_run_row_still_stops( - self, mock_fetch, mock_push, mock_consume - ): + self, mock_fetch: MagicMock, mock_push: MagicMock, mock_consume: MagicMock + ) -> None: mock_fetch.return_value = self._errored([pr_iteration_memory_block()]) AutofixOnCompletionHook.execute(self.organization, 123) @@ -1375,7 +1416,9 @@ def test_a_failed_iteration_without_a_run_row_still_stops( @patch(f"{HOOK_PATH}.AutofixOnCompletionHook._maybe_continue_pipeline") @patch(f"{HOOK_PATH}.fetch_run_status") - def test_a_failed_iteration_does_not_reach_the_pipeline(self, mock_fetch, mock_continue): + def test_a_failed_iteration_does_not_reach_the_pipeline( + self, mock_fetch: MagicMock, mock_continue: MagicMock + ) -> None: self.create_seer_run(organization=self.organization, seer_run_state_id=123) mock_fetch.return_value = self._errored([pr_iteration_memory_block()]) @@ -1387,8 +1430,8 @@ def test_a_failed_iteration_does_not_reach_the_pipeline(self, mock_fetch, mock_c @patch(f"{COMPLETION_PATH}.complete_pr_iteration_details") @patch(f"{HOOK_PATH}.fetch_run_status") def test_a_failed_run_on_another_step_stops_at_the_guard( - self, mock_fetch, mock_complete, mock_incr - ): + self, mock_fetch: MagicMock, mock_complete: MagicMock, mock_incr: MagicMock + ) -> None: self.create_seer_run(organization=self.organization, seer_run_state_id=123) mock_fetch.return_value = run_state( blocks=[root_cause_memory_block()], @@ -1407,7 +1450,9 @@ def test_a_failed_run_on_another_step_stops_at_the_guard( @patch(f"{HOOK_PATH}.continue_pr_iteration") @patch(f"{HOOK_PATH}.fetch_run_status") - def test_a_completed_iteration_still_reaches_the_pipeline(self, mock_fetch, mock_continue): + def test_a_completed_iteration_still_reaches_the_pipeline( + self, mock_fetch: MagicMock, mock_continue: MagicMock + ) -> None: self.create_seer_run(organization=self.organization, seer_run_state_id=123) mock_fetch.return_value = run_state( blocks=[pr_iteration_memory_block()], @@ -1422,8 +1467,8 @@ def test_a_completed_iteration_still_reaches_the_pipeline(self, mock_fetch, mock @patch(f"{COMPLETION_PATH}.complete_pr_iteration_details") @patch(f"{HOOK_PATH}.fetch_run_status") def test_a_failed_run_reaches_completion_details_under_its_reason( - self, mock_fetch, mock_complete - ): + self, mock_fetch: MagicMock, mock_complete: MagicMock + ) -> None: for failure_reason, expected in ( ("timeout", PrIterationOutcome.TIMEOUT.value), ("stalled", PrIterationOutcome.STALLED.value), @@ -1442,7 +1487,9 @@ def test_a_failed_run_reaches_completion_details_under_its_reason( @patch(f"{COMPLETION_PATH}.complete_pr_iteration_details") @patch(f"{HOOK_PATH}.fetch_run_status") - def test_a_run_awaiting_user_input_is_not_treated_as_a_failure(self, mock_fetch, mock_complete): + def test_a_run_awaiting_user_input_is_not_treated_as_a_failure( + self, mock_fetch: MagicMock, mock_complete: MagicMock + ) -> None: """Pausing here would abandon feedback the agent can still act on.""" mock_fetch.return_value = run_state( blocks=[pr_iteration_memory_block(commit_sha="iteration-sha")], @@ -1482,9 +1529,8 @@ def setUp(self) -> None: self.group = self.create_group(project=self.project) @patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay") - def test_send_step_webhook_artifact_types(self, mock_broadcast): + def test_send_step_webhook_artifact_types(self, mock_broadcast: MagicMock) -> None: """Tests webhook sending for all artifact-based step types.""" - state = MagicMock() run_id = 123 seer_run = self.create_seer_run(organization=self.organization, seer_run_state_id=run_id) @@ -1525,7 +1571,7 @@ class TestCaseDict(TypedDict): ) @patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay") - def test_send_step_webhook_coding(self, mock_broadcast): + def test_send_step_webhook_coding(self, mock_broadcast: MagicMock) -> None: """Sends coding_completed webhook when file patches exist.""" state = run_state( blocks=[ @@ -1550,12 +1596,12 @@ def test_send_step_webhook_coding(self, mock_broadcast): @patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay") def test_send_step_webhook_pr_iteration( self, - mock_broadcast, - mock_has_access, - mock_process_autofix_updates, - mock_metrics_incr, - mock_analytics, - ): + mock_broadcast: MagicMock, + mock_has_access: MagicMock, + mock_process_autofix_updates: MagicMock, + mock_metrics_incr: MagicMock, + mock_analytics: MagicMock, + ) -> None: mock_has_access.return_value = True def assert_activity_exists(**_kwargs: object) -> None: @@ -1621,8 +1667,8 @@ def assert_activity_exists(**_kwargs: object) -> None: @patch("sentry.seer.autofix.analytics.analytics.record") @patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay") def test_send_step_webhook_pr_iteration_does_not_emit_pr_created( - self, mock_broadcast, mock_analytics - ): + self, mock_broadcast: MagicMock, mock_analytics: MagicMock + ) -> None: state = run_state( blocks=[ code_changes_memory_block(), @@ -1662,7 +1708,9 @@ def test_send_step_webhook_pr_iteration_does_not_emit_pr_created( assert "ai.autofix.pr_created.completed" not in event_names @patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay") - def test_send_step_webhook_pr_iteration_skips_until_synced(self, mock_broadcast): + def test_send_step_webhook_pr_iteration_skips_until_synced( + self, mock_broadcast: MagicMock + ) -> None: """Does not emit iteration_completed until the pushed PR is synced to the iteration.""" state = run_state( blocks=[ @@ -1691,7 +1739,7 @@ def test_send_step_webhook_pr_iteration_skips_until_synced(self, mock_broadcast) mock_broadcast.assert_not_called() @patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay") - def test_send_step_webhook_no_artifacts_no_webhook(self, mock_broadcast): + def test_send_step_webhook_no_artifacts_no_webhook(self, mock_broadcast: MagicMock) -> None: """Does not send webhook when no artifacts or file patches exist.""" block = MemoryBlock( id="block_empty", @@ -1704,7 +1752,7 @@ def test_send_step_webhook_no_artifacts_no_webhook(self, mock_broadcast): mock_broadcast.assert_not_called() - def _pr_created_state(self): + def _pr_created_state(self) -> SeerRunState: state = run_state(blocks=[code_changes_memory_block()]) state.repo_pr_states = { "test-repo": RepoPRState( @@ -1720,7 +1768,9 @@ def _pr_created_state(self): @patch("sentry.seer.autofix.on_completion_hook.emit_pr_ready_for_review") @patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay") - def test_failed_pr_creation_does_not_emit_pr_activities(self, mock_broadcast, mock_emit): + def test_failed_pr_creation_does_not_emit_pr_activities( + self, mock_broadcast: MagicMock, mock_emit: MagicMock + ) -> None: state = run_state(blocks=[code_changes_memory_block()]) state.repo_pr_states = { "test-repo": RepoPRState( @@ -1744,7 +1794,7 @@ def test_failed_pr_creation_does_not_emit_pr_activities(self, mock_broadcast, mo @patch("sentry.seer.autofix.on_completion_hook.emit_pr_ready_for_review") @patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay") - def test_pr_is_ready_on_open(self, mock_broadcast, mock_emit): + def test_pr_is_ready_on_open(self, mock_broadcast: MagicMock, mock_emit: MagicMock) -> None: state = self._pr_created_state() with patch( "sentry.seer.autofix.on_completion_hook.SeerAutofixOperator.has_access", @@ -1768,7 +1818,9 @@ def test_pr_is_ready_on_open(self, mock_broadcast, mock_emit): @patch("sentry.seer.autofix.on_completion_hook.emit_pr_ready_for_review") @patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay") - def test_draft_pr_is_not_ready_on_open(self, mock_broadcast, mock_emit): + def test_draft_pr_is_not_ready_on_open( + self, mock_broadcast: MagicMock, mock_emit: MagicMock + ) -> None: state = self._pr_created_state() with self.feature(REVIEW_REQUEST_FLAG): AutofixOnCompletionHook._send_step_webhook(self.organization, 123, state, self.group) @@ -1810,7 +1862,7 @@ def _make_handoff_config( @patch("sentry.seer.autofix.on_completion_hook.get_automation_handoff") def test_get_handoff_config_returns_none_when_not_root_cause_step( - self, mock_get_handoff + self, mock_get_handoff: MagicMock ) -> None: """Returns None without reading preferences when current step is not ROOT_CAUSE.""" result = AutofixOnCompletionHook._get_handoff_config_if_applicable( @@ -1824,7 +1876,7 @@ def test_get_handoff_config_returns_none_when_not_root_cause_step( @patch("sentry.seer.autofix.on_completion_hook.get_automation_handoff") def test_get_handoff_config_returns_none_when_stopping_at_root_cause( - self, mock_get_handoff + self, mock_get_handoff: MagicMock ) -> None: """Returns None without reading preferences when stopping point is ROOT_CAUSE.""" result = AutofixOnCompletionHook._get_handoff_config_if_applicable( @@ -1859,7 +1911,9 @@ def test_get_handoff_config_returns_config_when_applicable(self) -> None: assert result == expected_handoff_config @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") - def test_maybe_continue_pipeline_triggers_handoff_when_configured(self, mock_trigger_handoff): + def test_maybe_continue_pipeline_triggers_handoff_when_configured( + self, mock_trigger_handoff: MagicMock + ) -> None: """Triggers handoff instead of continuing pipeline when handoff is configured.""" self._make_handoff_config() mock_trigger_handoff.return_value = {"successes": [], "failures": []} @@ -1885,7 +1939,9 @@ def test_maybe_continue_pipeline_triggers_handoff_when_configured(self, mock_tri ) @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") - def test_trigger_coding_agent_handoff_clears_preference_on_not_found(self, mock_trigger): + def test_trigger_coding_agent_handoff_clears_preference_on_not_found( + self, mock_trigger: MagicMock + ) -> None: """When IntegrationNotFound is raised, automation_handoff is cleared from preferences.""" from sentry.seer.autofix.coding_agent import IntegrationNotFound @@ -1904,7 +1960,7 @@ def test_trigger_coding_agent_handoff_clears_preference_on_not_found(self, mock_ assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") - def test_trigger_coding_agent_handoff_calls_function(self, mock_trigger): + def test_trigger_coding_agent_handoff_calls_function(self, mock_trigger: MagicMock) -> None: """Test _trigger_coding_agent_handoff calls the trigger function correctly.""" mock_trigger.return_value = { "successes": [{"repo": "owner/repo"}], @@ -1936,8 +1992,11 @@ class AutofixOnCompletionHookTest(TestCase): @patch("sentry.seer.autofix.on_completion_hook.AutofixOnCompletionHook._send_step_webhook") @patch("sentry.seer.autofix.on_completion_hook.fetch_run_status") def test_passes_viewer_actor_to_step_webhook( - self, mock_fetch_run_status, mock_send_webhook, mock_continue_pipeline - ): + self, + mock_fetch_run_status: MagicMock, + mock_send_webhook: MagicMock, + mock_continue_pipeline: MagicMock, + ) -> None: group = self.create_group(project=self.project) mock_fetch_run_status.return_value = run_state( blocks=[code_changes_memory_block()], @@ -1957,8 +2016,8 @@ def test_passes_viewer_actor_to_step_webhook( @patch("sentry.seer.autofix.on_completion_hook.fetch_run_status") @patch("sentry.seer.autofix.on_completion_hook.trigger_autofix_agent") def test_next_step_not_triggered_when_coding_disabled( - self, mock_trigger_autofix, mock_fetch_run_status - ): + self, mock_trigger_autofix: MagicMock, mock_fetch_run_status: MagicMock + ) -> None: """Test that next step is not triggered if next step is CODE_CHANGES and sentry:enable_seer_coding is disabled.""" self.organization.update_option("sentry:enable_seer_coding", False) group = self.create_group(project=self.project) @@ -1982,8 +2041,8 @@ def test_next_step_not_triggered_when_coding_disabled( @patch("sentry.seer.autofix.on_completion_hook.fetch_run_status") @patch("sentry.seer.autofix.on_completion_hook.trigger_autofix_agent") def test_next_step_triggered_when_coding_enabled( - self, mock_trigger_autofix, mock_fetch_run_status - ): + self, mock_trigger_autofix: MagicMock, mock_fetch_run_status: MagicMock + ) -> None: """Test that next step IS triggered when next step is CODE_CHANGES and sentry:enable_seer_coding is enabled.""" self.organization.update_option("sentry:enable_seer_coding", True) group = self.create_group(project=self.project) @@ -2024,8 +2083,11 @@ def test_next_step_triggered_when_coding_enabled( @patch("sentry.seer.autofix.on_completion_hook.AutofixOnCompletionHook._send_step_webhook") @patch("sentry.seer.autofix.on_completion_hook.fetch_run_status") def test_error_status_skips_success_path( - self, mock_fetch_run_status, mock_send_webhook, mock_continue_pipeline - ): + self, + mock_fetch_run_status: MagicMock, + mock_send_webhook: MagicMock, + mock_continue_pipeline: MagicMock, + ) -> None: mock_fetch_run_status.return_value = run_state( status="error", failure_reason="timeout", @@ -2043,8 +2105,11 @@ def test_error_status_skips_success_path( @patch("sentry.seer.autofix.on_completion_hook.AutofixOnCompletionHook._send_step_webhook") @patch("sentry.seer.autofix.on_completion_hook.fetch_run_status") def test_unclassified_error_still_skips_success_path( - self, mock_fetch_run_status, mock_send_webhook, mock_continue_pipeline - ): + self, + mock_fetch_run_status: MagicMock, + mock_send_webhook: MagicMock, + mock_continue_pipeline: MagicMock, + ) -> None: mock_fetch_run_status.return_value = run_state( status="error", metadata={"group_id": 1}, @@ -2122,7 +2187,7 @@ def _state_with( self, sources: list[PrCommentSource], *, - status: str = "completed", + status: Literal["processing", "completed", "error", "awaiting_user_input"] = "completed", commit_sha: str = "synced-sha", repo_pr_states: dict[str, RepoPRState] | None = None, ) -> SeerRunState: @@ -2151,7 +2216,9 @@ def _reaction_outcomes(self, mock_incr: MagicMock) -> list[str]: @patch(f"{REACT_PATH}.make_scm") @patch(f"{REACT_PATH}._add_comment_reaction") - def test_reacts_hooray_on_top_level_and_review_comments(self, mock_react, mock_make_scm): + def test_reacts_hooray_on_top_level_and_review_comments( + self, mock_react: MagicMock, mock_make_scm: MagicMock + ) -> None: scm = MagicMock() mock_make_scm.return_value = scm state = self._state_with([self._top_level_source(111), self._review_source(222)]) @@ -2170,7 +2237,7 @@ def test_reacts_hooray_on_top_level_and_review_comments(self, mock_react, mock_m @patch(f"{REACT_PATH}.make_scm") @patch(f"{REACT_PATH}._add_comment_reaction") - def test_noop_on_error_status(self, mock_react, mock_make_scm): + def test_noop_on_error_status(self, mock_react: MagicMock, mock_make_scm: MagicMock) -> None: state = self._state_with([self._top_level_source(), self._review_source()], status="error") self._run(state) mock_react.assert_not_called() @@ -2180,8 +2247,12 @@ def test_noop_on_error_status(self, mock_react, mock_make_scm): @patch(f"{HOOK_PATH}.fetch_run_status") @patch(f"{HOOK_PATH}.react_to_completed_iteration") def test_noop_when_step_not_pr_iteration( - self, mock_react, mock_fetch_run_status, mock_send_webhook, mock_continue_pipeline - ): + self, + mock_react: MagicMock, + mock_fetch_run_status: MagicMock, + mock_send_webhook: MagicMock, + mock_continue_pipeline: MagicMock, + ) -> None: group = self.create_group(project=self.project) mock_fetch_run_status.return_value = run_state( blocks=[solution_memory_block()], metadata={"group_id": group.id} @@ -2191,7 +2262,7 @@ def test_noop_when_step_not_pr_iteration( @patch(f"{REACT_PATH}.make_scm") @patch(f"{REACT_PATH}._add_comment_reaction") - def test_noop_when_not_synced(self, mock_react, mock_make_scm): + def test_noop_when_not_synced(self, mock_react: MagicMock, mock_make_scm: MagicMock) -> None: # PR commit sha differs from the pushed state, so changes aren't synced yet. state = self._state_with( [self._top_level_source()], @@ -2205,7 +2276,9 @@ def test_noop_when_not_synced(self, mock_react, mock_make_scm): @patch(f"{REACT_PATH}.make_scm") @patch(f"{REACT_PATH}._add_comment_reaction") - def test_multi_repo_skips_source_without_repo_name(self, mock_react, mock_make_scm): + def test_multi_repo_skips_source_without_repo_name( + self, mock_react: MagicMock, mock_make_scm: MagicMock + ) -> None: scm = MagicMock() mock_make_scm.return_value = scm legacy_source = GithubPrCommentFeedbackSource( @@ -2232,7 +2305,9 @@ def test_multi_repo_skips_source_without_repo_name(self, mock_react, mock_make_s @patch(f"{REACT_PATH}.make_scm") @patch(f"{REACT_PATH}._add_comment_reaction") - def test_skips_reaction_when_pr_number_missing(self, mock_react, mock_make_scm): + def test_skips_reaction_when_pr_number_missing( + self, mock_react: MagicMock, mock_make_scm: MagicMock + ) -> None: # A repo can be synced without a pr_number (e.g. after a repo rename). # Reacting with an invalid ``0`` would just fail and capture noise, so # the source is skipped instead. @@ -2253,7 +2328,9 @@ def test_skips_reaction_when_pr_number_missing(self, mock_react, mock_make_scm): @patch(f"{REACT_PATH}.make_scm") @patch(f"{REACT_PATH}._add_comment_reaction") - def test_skips_reaction_when_repo_name_ambiguous(self, mock_react, mock_make_scm): + def test_skips_reaction_when_repo_name_ambiguous( + self, mock_react: MagicMock, mock_make_scm: MagicMock + ) -> None: # The same slug can exist under multiple providers in one org; rather than # guess and react on the wrong repo, the source is skipped. self.create_repo( @@ -2274,8 +2351,12 @@ def test_skips_reaction_when_repo_name_ambiguous(self, mock_react, mock_make_scm @patch(f"{REACT_PATH}.make_scm") @patch(f"{REACT_PATH}._add_comment_reaction") def test_deletes_own_eyes_on_both_comment_types( - self, mock_react, mock_make_scm, mock_delete_eyes, mock_sensitive - ): + self, + mock_react: MagicMock, + mock_make_scm: MagicMock, + mock_delete_eyes: MagicMock, + mock_sensitive: MagicMock, + ) -> None: scm = MagicMock() mock_make_scm.return_value = scm state = self._state_with([self._top_level_source(111), self._review_source(222)]) @@ -2300,8 +2381,12 @@ def test_deletes_own_eyes_on_both_comment_types( @patch(f"{REACT_PATH}.make_scm") @patch(f"{REACT_PATH}._add_comment_reaction") def test_skips_eyes_delete_for_rate_limit_sensitive_org( - self, mock_react, mock_make_scm, mock_delete_eyes, mock_sensitive - ): + self, + mock_react: MagicMock, + mock_make_scm: MagicMock, + mock_delete_eyes: MagicMock, + mock_sensitive: MagicMock, + ) -> None: scm = MagicMock() mock_make_scm.return_value = scm state = self._state_with([self._top_level_source(111), self._review_source(222)]) @@ -2319,8 +2404,13 @@ def test_skips_eyes_delete_for_rate_limit_sensitive_org( @patch(f"{REACT_PATH}.make_scm") @patch(f"{REACT_PATH}._add_comment_reaction") def test_reacts_hooray_on_review_body( - self, mock_react, mock_make_scm, mock_review_react, mock_delete_eyes, mock_sensitive - ): + self, + mock_react: MagicMock, + mock_make_scm: MagicMock, + mock_review_react: MagicMock, + mock_delete_eyes: MagicMock, + mock_sensitive: MagicMock, + ) -> None: scm = MagicMock() mock_make_scm.return_value = scm state = self._state_with([self._review_body_source()]) @@ -2338,8 +2428,12 @@ def test_reacts_hooray_on_review_body( @patch(f"{REACT_PATH}._add_review_reaction") @patch(f"{REACT_PATH}.make_scm") def test_skips_review_body_eyes_delete_for_rate_limit_sensitive_org( - self, mock_make_scm, mock_review_react, mock_delete_eyes, mock_sensitive - ): + self, + mock_make_scm: MagicMock, + mock_review_react: MagicMock, + mock_delete_eyes: MagicMock, + mock_sensitive: MagicMock, + ) -> None: mock_make_scm.return_value = MagicMock() state = self._state_with([self._review_body_source()]) @@ -2354,8 +2448,13 @@ def test_skips_review_body_eyes_delete_for_rate_limit_sensitive_org( @patch(f"{REACT_PATH}._add_review_reaction") @patch(f"{REACT_PATH}.make_scm") def test_skips_review_body_reaction_when_iteration_made_no_changes( - self, mock_make_scm, mock_review_react, mock_delete_eyes, mock_sensitive, mock_incr - ): + self, + mock_make_scm: MagicMock, + mock_review_react: MagicMock, + mock_delete_eyes: MagicMock, + mock_sensitive: MagicMock, + mock_incr: MagicMock, + ) -> None: scm = MagicMock() mock_make_scm.return_value = scm state = self._state_with([self._review_body_source()]) @@ -2370,7 +2469,9 @@ def test_skips_review_body_reaction_when_iteration_made_no_changes( @patch(f"{REACT_PATH}._add_review_reaction") @patch(f"{REACT_PATH}.make_scm") - def test_skips_review_body_without_review_id(self, mock_make_scm, mock_review_react): + def test_skips_review_body_without_review_id( + self, mock_make_scm: MagicMock, mock_review_react: MagicMock + ) -> None: state = self._state_with([GithubPrReviewBodyFeedbackSource(body="no id")]) self._run(state) @@ -2383,8 +2484,13 @@ def test_skips_review_body_without_review_id(self, mock_make_scm, mock_review_re @patch(f"{REACT_PATH}.make_scm") @patch(f"{REACT_PATH}._add_comment_reaction") def test_skips_review_reaction_when_iteration_made_no_changes( - self, mock_react, mock_make_scm, mock_delete_eyes, mock_sensitive, mock_incr - ): + self, + mock_react: MagicMock, + mock_make_scm: MagicMock, + mock_delete_eyes: MagicMock, + mock_sensitive: MagicMock, + mock_incr: MagicMock, + ) -> None: # The iteration committed nothing, so only the top-level comment gets :tada:. state = self._state_with([self._top_level_source(111), self._review_source(222)]) state.blocks[0].merged_file_patches = [] @@ -2405,7 +2511,9 @@ def test_skips_review_reaction_when_iteration_made_no_changes( @patch(f"{REACT_PATH}.make_scm") @patch(f"{REACT_PATH}._add_comment_reaction") - def test_skips_review_reaction_when_repo_ambiguous_multi_repo(self, mock_react, mock_make_scm): + def test_skips_review_reaction_when_repo_ambiguous_multi_repo( + self, mock_react: MagicMock, mock_make_scm: MagicMock + ) -> None: # Review-comment sources don't carry ``repo_name``; with more than one repo # in the run their repo can't be inferred, so the reaction is skipped. scm = MagicMock() @@ -2441,13 +2549,13 @@ def _recorded_milestones(self) -> set[str]: ) ) - def _run_hook(self, state) -> None: + def _run_hook(self, state: SeerRunState) -> None: AutofixOnCompletionHook._send_step_webhook( self.organization, self.run_id, state, self.group ) @patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay") - def test_reconciles_milestones_from_state(self, mock_broadcast): + def test_reconciles_milestones_from_state(self, mock_broadcast: MagicMock) -> None: # The hook wires state through to reconcile_milestones; firing twice on the # same state is idempotent, matching webhook redelivery. state = run_state( @@ -2467,7 +2575,7 @@ def test_reconciles_milestones_from_state(self, mock_broadcast): assert SeerRunMilestone.objects.filter(seer_run=self.seer_run).count() == 3 @patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay") - def test_skips_reconcile_when_no_step_completed(self, mock_broadcast): + def test_skips_reconcile_when_no_step_completed(self, mock_broadcast: MagicMock) -> None: # A block with a reached artifact but no step metadata would derive # ROOT_CAUSE, but the hook returns before reconcile when no step matches. block = MemoryBlock( diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 3b87bdbb44d4..8cde0ec2ba3d 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -1,5 +1,5 @@ from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import pytest from django.test import override_settings @@ -10,6 +10,7 @@ ObjectStatus, ) from sentry.models.options.project_option import ProjectOption +from sentry.models.organization import Organization from sentry.models.project import Project from sentry.models.projectrepository import ProjectRepository from sentry.seer.autofix.constants import ( @@ -179,7 +180,7 @@ def test_returns_false_when_scanner_automation_disabled_and_not_always_trigger(s assert result is False @patch("sentry.quotas.backend.check_seer_quota") - def test_returns_false_when_no_budget_available(self, mock_has_budget): + def test_returns_false_when_no_budget_available(self, mock_has_budget: MagicMock) -> None: """Test returns False when organization has no available budget for scanner.""" self.project.update_option("sentry:seer_scanner_automation", True) mock_has_budget.return_value = False @@ -192,7 +193,7 @@ def test_returns_false_when_no_budget_available(self, mock_has_budget): ) @patch("sentry.quotas.backend.check_seer_quota") - def test_returns_true_when_all_conditions_met(self, mock_has_budget): + def test_returns_true_when_all_conditions_met(self, mock_has_budget: MagicMock) -> None: """Test returns True when all eligibility conditions are met.""" self.project.update_option("sentry:seer_scanner_automation", True) @@ -208,8 +209,8 @@ def test_returns_true_when_all_conditions_met(self, mock_has_budget): @patch("sentry.quotas.backend.check_seer_quota") def test_returns_true_when_issue_type_always_triggers( self, - mock_has_budget, - ): + mock_has_budget: MagicMock, + ) -> None: """Test returns True when issue type has always_trigger_seer_automation even if scanner automation is disabled.""" # Disable scanner automation self.project.update_option("sentry:seer_scanner_automation", False) @@ -230,15 +231,15 @@ def setUp(self) -> None: super().setUp() self.organization = self.create_organization(name="test-org") - def tearDown(self): + def tearDown(self) -> None: super().tearDown() cache.delete(f"seer:seat-based-tier:{self.organization.id}") @patch("sentry.seer.autofix.utils.features.has") - def test_returns_true_when_seat_based_seer_enabled(self, mock_features_has): + def test_returns_true_when_seat_based_seer_enabled(self, mock_features_has: MagicMock) -> None: """Test returns True when seat-based-seer-enabled feature flag is enabled and caches the result.""" - def features_side_effect(flag, org): + def features_side_effect(flag: str, org: Organization) -> bool: if flag == "organizations:seat-based-seer-enabled": return True return False @@ -283,7 +284,7 @@ def setUp(self) -> None: organization=self.organization, provider="github", external_id="github:1" ) - def test_returns_true_when_repos_exist(self): + def test_returns_true_when_repos_exist(self) -> None: repo = self.create_repo( project=self.project, provider="integrations:github", @@ -295,10 +296,10 @@ def test_returns_true_when_repos_exist(self): assert has_project_connected_repos(self.organization, self.project) is True - def test_returns_false_when_no_repos(self): + def test_returns_false_when_no_repos(self) -> None: assert has_project_connected_repos(self.organization, self.project) is False - def test_returns_false_when_only_inactive_repos(self): + def test_returns_false_when_only_inactive_repos(self) -> None: repo = self.create_repo( project=self.project, provider="integrations:github", @@ -312,7 +313,7 @@ def test_returns_false_when_only_inactive_repos(self): assert has_project_connected_repos(self.organization, self.project) is False - def test_returns_true_when_at_least_one_active_repo(self): + def test_returns_true_when_at_least_one_active_repo(self) -> None: disabled_repo = self.create_repo( project=self.project, provider="integrations:github", @@ -334,7 +335,7 @@ def test_returns_true_when_at_least_one_active_repo(self): assert has_project_connected_repos(self.organization, self.project) is True - def test_returns_true_via_project_repository_fk(self): + def test_returns_true_via_project_repository_fk(self) -> None: repo = self.create_repo( project=self.project, provider="integrations:github", @@ -970,7 +971,7 @@ def test_excludes_other_organizations(self) -> None: class ReadPreferenceFromSentryDbBaseTest(TestCase): __test__ = False - def setUp(self): + def setUp(self) -> None: self.organization = self.create_organization() self.project = self.create_project(organization=self.organization) self.integration = self.create_integration( @@ -991,7 +992,7 @@ def setUp(self): name="test-org/other-repo", ) - def test_unconfigured_project_returns_default_preference(self): + def test_unconfigured_project_returns_default_preference(self) -> None: result = read_preference_from_sentry_db(self.project) assert result is not None assert result.repositories == [] @@ -999,7 +1000,7 @@ def test_unconfigured_project_returns_default_preference(self): assert result.automation_handoff is None assert result.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF - def test_project_with_repos_only(self): + def test_project_with_repos_only(self) -> None: spr = self.create_seer_project_repository( project=self.project, repository=self.repo, @@ -1038,7 +1039,7 @@ def test_project_with_repos_only(self): assert result.automated_run_stopping_point == "code_changes" assert result.automation_handoff is None - def test_reads_via_project_repository_fk(self): + def test_reads_via_project_repository_fk(self) -> None: pr = ProjectRepository.objects.create(project=self.project, repository=self.repo) SeerProjectRepository.objects.create( project_repository=pr, @@ -1049,7 +1050,7 @@ def test_reads_via_project_repository_fk(self): assert len(result.repositories) == 1 assert result.repositories[0].branch_name == "main" - def test_autofix_automation_tuning_default(self): + def test_autofix_automation_tuning_default(self) -> None: self.create_seer_project_repository( project=self.project, repository=self.repo, branch_name="main" ) @@ -1058,7 +1059,7 @@ def test_autofix_automation_tuning_default(self): assert result is not None assert result.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF - def test_autofix_automation_tuning_explicit(self): + def test_autofix_automation_tuning_explicit(self) -> None: self.create_seer_project_repository( project=self.project, repository=self.repo, branch_name="main" ) @@ -1070,7 +1071,7 @@ def test_autofix_automation_tuning_explicit(self): assert result is not None assert result.autofix_automation_tuning == AutofixAutomationTuningSettings.MEDIUM - def test_autofix_automation_tuning_alone_creates_preference(self): + def test_autofix_automation_tuning_alone_creates_preference(self) -> None: self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.HIGH ) @@ -1080,7 +1081,7 @@ def test_autofix_automation_tuning_alone_creates_preference(self): assert result.autofix_automation_tuning == AutofixAutomationTuningSettings.HIGH assert result.repositories == [] - def test_project_with_stopping_point_only(self): + def test_project_with_stopping_point_only(self) -> None: self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") result = read_preference_from_sentry_db(self.project) @@ -1089,7 +1090,7 @@ def test_project_with_stopping_point_only(self): assert result.repositories == [] assert result.automation_handoff is None - def test_project_with_handoff_only(self): + def test_project_with_handoff_only(self) -> None: self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") self.project.update_option( "sentry:seer_automation_handoff_target", "cursor_background_agent" @@ -1105,7 +1106,7 @@ def test_project_with_handoff_only(self): assert result.automation_handoff.integration_id == 42 assert result.automation_handoff.auto_create_pr is True - def test_project_with_repos_and_options(self): + def test_project_with_repos_and_options(self) -> None: self.create_seer_project_repository( project=self.project, repository=self.repo, @@ -1133,7 +1134,7 @@ def test_project_with_repos_and_options(self): assert result.automation_handoff.integration_id == 42 assert result.automation_handoff.auto_create_pr is False - def test_excludes_other_projects_data(self): + def test_excludes_other_projects_data(self) -> None: other_project = self.create_project(organization=self.organization) other_repo = self.create_repo( project=other_project, @@ -1158,14 +1159,14 @@ def test_excludes_other_projects_data(self): assert result.repositories[0].name == "test-repo" assert result.automated_run_stopping_point == "root_cause" - def test_partial_handoff_returns_none_handoff(self): + def test_partial_handoff_returns_none_handoff(self) -> None: self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") result = read_preference_from_sentry_db(self.project) assert result is not None assert result.automation_handoff is None - def test_excludes_invalid_repo_names(self): + def test_excludes_invalid_repo_names(self) -> None: bad_repo = self.create_repo( project=self.project, provider="integrations:github", @@ -1185,7 +1186,7 @@ def test_excludes_invalid_repo_names(self): assert len(result.repositories) == 1 assert result.repositories[0].name == "test-repo" - def test_excludes_inactive_repos(self): + def test_excludes_inactive_repos(self) -> None: self.create_seer_project_repository( project=self.project, repository=self.repo, branch_name="main" ) @@ -1237,7 +1238,7 @@ def test_includes_repos_without_seer_config(self) -> None: class BulkReadPreferencesFromSentryDbBaseTest(TestCase): __test__ = False - def setUp(self): + def setUp(self) -> None: self.organization = self.create_organization() self.project1 = self.create_project(organization=self.organization) self.project2 = self.create_project(organization=self.organization) @@ -1260,11 +1261,11 @@ def setUp(self): name="test-org/other-repo", ) - def test_empty_project_ids_returns_empty(self): + def test_empty_project_ids_returns_empty(self) -> None: result = bulk_read_preferences_from_sentry_db(self.organization.id, []) assert result == {} - def test_unconfigured_project_returns_default_preference(self): + def test_unconfigured_project_returns_default_preference(self) -> None: result = bulk_read_preferences_from_sentry_db(self.organization.id, [self.project1.id]) pref = result[self.project1.id] assert pref is not None @@ -1273,7 +1274,7 @@ def test_unconfigured_project_returns_default_preference(self): assert pref.automation_handoff is None assert pref.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF - def test_returns_correct_preferences(self): + def test_returns_correct_preferences(self) -> None: self.create_seer_project_repository( project=self.project1, repository=self.repo, branch_name="main" ) @@ -1309,7 +1310,7 @@ def test_returns_correct_preferences(self): assert pref2.automation_handoff.integration_id == 99 assert pref2.automation_handoff.auto_create_pr is False - def test_autofix_automation_tuning_populated(self): + def test_autofix_automation_tuning_populated(self) -> None: self.create_seer_project_repository( project=self.project1, repository=self.repo, branch_name="main" ) @@ -1329,7 +1330,7 @@ def test_autofix_automation_tuning_populated(self): assert pref2 is not None assert pref2.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF - def test_autofix_automation_tuning_defaults_to_off(self): + def test_autofix_automation_tuning_defaults_to_off(self) -> None: self.create_seer_project_repository( project=self.project1, repository=self.repo, branch_name="main" ) @@ -1340,7 +1341,7 @@ def test_autofix_automation_tuning_defaults_to_off(self): assert pref is not None assert pref.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF - def test_wrong_organization_excluded(self): + def test_wrong_organization_excluded(self) -> None: other_org = self.create_organization() self.create_seer_project_repository( project=self.project1, repository=self.repo, branch_name="main" @@ -1349,7 +1350,7 @@ def test_wrong_organization_excluded(self): result = bulk_read_preferences_from_sentry_db(other_org.id, [self.project1.id]) assert result == {} - def test_excludes_inactive_repos(self): + def test_excludes_inactive_repos(self) -> None: self.create_seer_project_repository( project=self.project1, repository=self.repo, branch_name="main" ) @@ -1411,12 +1412,12 @@ def test_includes_repos_without_seer_config(self) -> None: class TestGetOrgDefaultSeerAutomationHandoff(TestCase): - def test_defaults(self): + def test_defaults(self) -> None: stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) assert stopping_point == "code_changes" assert handoff is None - def test_respects_org_stopping_point_option(self): + def test_respects_org_stopping_point_option(self) -> None: self.organization.update_option("sentry:default_automated_run_stopping_point", "open_pr") self.organization.update_option("sentry:auto_open_prs", True) @@ -1424,7 +1425,7 @@ def test_respects_org_stopping_point_option(self): assert stopping_point == "open_pr" assert handoff is None - def test_seer_agent_auto_open_prs_forces_open_pr(self): + def test_seer_agent_auto_open_prs_forces_open_pr(self) -> None: self.organization.update_option( "sentry:default_automated_run_stopping_point", "code_changes" ) @@ -1434,14 +1435,14 @@ def test_seer_agent_auto_open_prs_forces_open_pr(self): assert stopping_point == "open_pr" assert handoff is None - def test_seer_agent_no_auto_open_prs_caps_open_pr_to_code_changes(self): + def test_seer_agent_no_auto_open_prs_caps_open_pr_to_code_changes(self) -> None: self.organization.update_option("sentry:default_automated_run_stopping_point", "open_pr") stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) assert stopping_point == "code_changes" assert handoff is None - def test_external_agent_returns_handoff_config(self): + def test_external_agent_returns_handoff_config(self) -> None: self.organization.update_option( "sentry:seer_default_coding_agent", "cursor_background_agent" ) @@ -1455,7 +1456,7 @@ def test_external_agent_returns_handoff_config(self): assert handoff.integration_id == 42 assert handoff.auto_create_pr is False - def test_external_agent_auto_open_prs_sets_auto_create_pr(self): + def test_external_agent_auto_open_prs_sets_auto_create_pr(self) -> None: self.organization.update_option("sentry:seer_default_coding_agent", "claude_code_agent") self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 99) self.organization.update_option("sentry:auto_open_prs", True) @@ -1464,7 +1465,7 @@ def test_external_agent_auto_open_prs_sets_auto_create_pr(self): assert handoff is not None assert handoff.auto_create_pr is True - def test_external_agent_auto_open_prs_does_not_override_stopping_point(self): + def test_external_agent_auto_open_prs_does_not_override_stopping_point(self) -> None: self.organization.update_option( "sentry:default_automated_run_stopping_point", "code_changes" ) @@ -1478,7 +1479,7 @@ def test_external_agent_auto_open_prs_does_not_override_stopping_point(self): assert stopping_point == "code_changes" assert handoff is not None - def test_external_agent_without_integration_id_falls_back_to_seer(self): + def test_external_agent_without_integration_id_falls_back_to_seer(self) -> None: self.organization.update_option( "sentry:seer_default_coding_agent", "cursor_background_agent" ) @@ -1488,7 +1489,7 @@ def test_external_agent_without_integration_id_falls_back_to_seer(self): assert stopping_point == "open_pr" assert handoff is None - def test_seer_coding_agent_treated_as_no_external_agent(self): + def test_seer_coding_agent_treated_as_no_external_agent(self) -> None: self.organization.update_option("sentry:seer_default_coding_agent", "seer") self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) self.organization.update_option("sentry:auto_open_prs", True) @@ -1497,7 +1498,7 @@ def test_seer_coding_agent_treated_as_no_external_agent(self): assert stopping_point == "open_pr" assert handoff is None - def test_invalid_stopping_point_falls_back_to_default(self): + def test_invalid_stopping_point_falls_back_to_default(self) -> None: self.organization.update_option( "sentry:default_automated_run_stopping_point", "invalid_point" ) @@ -1777,7 +1778,7 @@ def setUp(self) -> None: external_id="222", ) - def test_creates_project_repos(self): + def test_creates_project_repos(self) -> None: add_seer_project_repos( self.project, [ @@ -1795,7 +1796,7 @@ def test_creates_project_repos(self): ) assert pr2.branch_name is None - def test_creates_branch_overrides(self): + def test_creates_branch_overrides(self) -> None: add_seer_project_repos( self.project, [ @@ -1816,7 +1817,7 @@ def test_creates_branch_overrides(self): assert overrides[0].tag_value == "prod" assert overrides[0].branch_name == "release" - def test_upserts_existing_repo(self): + def test_upserts_existing_repo(self) -> None: existing = self.create_seer_project_repository( self.project, repository=self.repo1, branch_name="old" ) @@ -1854,7 +1855,7 @@ def test_upserts_existing_repo(self): assert overrides[0].tag_value == "prod" assert overrides[0].branch_name == "release" - def test_returns_created_ids(self): + def test_returns_created_ids(self) -> None: created_ids = add_seer_project_repos(self.project, [{"repository_id": self.repo1.id}]) assert created_ids == list( SeerProjectRepository.objects.filter( @@ -1880,7 +1881,7 @@ def setUp(self) -> None: external_id="222", ) - def test_replaces_existing_repos(self): + def test_replaces_existing_repos(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) replace_all_seer_project_repos( @@ -1898,7 +1899,7 @@ def test_replaces_existing_repos(self): ) assert pr2.branch_name == "develop" - def test_clears_all_when_empty(self): + def test_clears_all_when_empty(self) -> None: project_repo = self.create_seer_project_repository(self.project, repository=self.repo1) SeerProjectRepositoryBranchOverride.objects.create( seer_project_repository=project_repo, @@ -1916,7 +1917,7 @@ def test_clears_all_when_empty(self): seer_project_repository=project_repo ).exists() - def test_creates_branch_overrides(self): + def test_creates_branch_overrides(self) -> None: replace_all_seer_project_repos( self.project, [ @@ -1933,7 +1934,7 @@ def test_creates_branch_overrides(self): ) assert project_repo.branch_overrides.count() == 1 - def test_clears_old_branch_overrides(self): + def test_clears_old_branch_overrides(self) -> None: project_repo = self.create_seer_project_repository(self.project, repository=self.repo1) SeerProjectRepositoryBranchOverride.objects.create( seer_project_repository=project_repo, @@ -1949,7 +1950,7 @@ def test_clears_old_branch_overrides(self): ) assert project_repo.branch_overrides.count() == 0 - def test_preserves_inactive_repos(self): + def test_preserves_inactive_repos(self) -> None: inactive_repo = self.create_repo( project=self.project, name="getsentry/inactive", diff --git a/tests/sentry/seer/autofix/test_coding_agent.py b/tests/sentry/seer/autofix/test_coding_agent.py index 393d5e9e0120..d2f0450e59bb 100644 --- a/tests/sentry/seer/autofix/test_coding_agent.py +++ b/tests/sentry/seer/autofix/test_coding_agent.py @@ -1,4 +1,6 @@ +from contextlib import AbstractContextManager from datetime import UTC, datetime +from typing import Any from unittest.mock import MagicMock, patch from sentry.integrations.claude_code.utils import ClaudeSessionEvent, ClaudeSessionEventStatus @@ -65,7 +67,9 @@ def get_pr_from_branch(self, owner: str, repo: str, head_ref: str) -> GithubPull return self.pr_from_branch -def _patch_github_copilot_client(fake_client: FakeGithubCopilotAgentClient): +def _patch_github_copilot_client( + fake_client: FakeGithubCopilotAgentClient, +) -> AbstractContextManager[dict[str, MagicMock]]: return patch.multiple( GithubCopilotAgentClient, __init__=lambda self, *args, **kwargs: None, @@ -147,7 +151,7 @@ def test_poll_skips_completed_agents(self) -> None: poll_github_copilot_agents(autofix_state, user_id=self.user.id) @patch("sentry.seer.autofix.coding_agent.github_copilot_identity_service") - def test_poll_returns_early_when_no_user_token(self, mock_identity_service): + def test_poll_returns_early_when_no_user_token(self, mock_identity_service: MagicMock) -> None: """Test that polling returns early when user has no access token""" mock_identity_service.get_access_token_for_user.return_value = None @@ -170,7 +174,9 @@ def test_poll_returns_early_when_no_user_token(self, mock_identity_service): @patch(MOCK_SYNC_STATUS_PATH) @patch("sentry.seer.autofix.coding_agent.github_copilot_identity_service") - def test_poll_updates_state_when_pr_created(self, mock_identity_service, mock_sync_status): + def test_poll_updates_state_when_pr_created( + self, mock_identity_service: MagicMock, mock_sync_status: MagicMock + ) -> None: """Test that polling updates agent state when a PR is found""" mock_identity_service.get_access_token_for_user.return_value = "test_token" @@ -228,8 +234,8 @@ def test_poll_updates_state_when_pr_created(self, mock_identity_service, mock_sy @patch(MOCK_SYNC_STATUS_PATH) @patch("sentry.seer.autofix.coding_agent.github_copilot_identity_service") def test_poll_falls_back_to_branch_when_global_id_empty( - self, mock_identity_service, mock_sync_status - ): + self, mock_identity_service: MagicMock, mock_sync_status: MagicMock + ) -> None: """When the Copilot API returns an empty global_id, resolve the PR via the head branch.""" mock_identity_service.get_access_token_for_user.return_value = "test_token" @@ -282,8 +288,8 @@ def test_poll_falls_back_to_branch_when_global_id_empty( @patch(MOCK_SYNC_STATUS_PATH) @patch("sentry.seer.autofix.coding_agent.github_copilot_identity_service") def test_poll_marks_completed_without_pr_when_unresolved( - self, mock_identity_service, mock_sync_status - ): + self, mock_identity_service: MagicMock, mock_sync_status: MagicMock + ) -> None: """A completed task flips to COMPLETED even when no PR can be resolved.""" mock_identity_service.get_access_token_for_user.return_value = "test_token" @@ -325,8 +331,11 @@ def test_poll_marks_completed_without_pr_when_unresolved( @patch(MOCK_SYNC_STATUS_PATH) @patch("sentry.seer.autofix.coding_agent.github_copilot_identity_service") def test_poll_attributes_pr_when_task_complete( - self, mock_identity_service, mock_sync_status, mock_attribute - ): + self, + mock_identity_service: MagicMock, + mock_sync_status: MagicMock, + mock_attribute: MagicMock, + ) -> None: """A completed Copilot task with a PR is attributed to the Copilot agent, and both Seer's state and the Sentry-side SeerRunCodingAgentHandoff row are synced in one call.""" mock_identity_service.get_access_token_for_user.return_value = "test_token" @@ -389,8 +398,11 @@ def test_poll_attributes_pr_when_task_complete( @patch(MOCK_SYNC_STATUS_PATH) @patch("sentry.seer.autofix.coding_agent.github_copilot_identity_service") def test_poll_does_not_attribute_when_task_not_done( - self, mock_identity_service, mock_sync_status, mock_attribute - ): + self, + mock_identity_service: MagicMock, + mock_sync_status: MagicMock, + mock_attribute: MagicMock, + ) -> None: """A PR seen while the task is still running is not attributed yet.""" mock_identity_service.get_access_token_for_user.return_value = "test_token" @@ -433,7 +445,9 @@ def test_poll_does_not_attribute_when_task_not_done( @patch(MOCK_SYNC_STATUS_PATH) @patch("sentry.seer.autofix.coding_agent.github_copilot_identity_service") - def test_poll_marks_agent_failed_on_error_status(self, mock_identity_service, mock_sync_status): + def test_poll_marks_agent_failed_on_error_status( + self, mock_identity_service: MagicMock, mock_sync_status: MagicMock + ) -> None: """Test that polling marks agent as failed when task status is error""" mock_identity_service.get_access_token_for_user.return_value = "test_token" @@ -466,8 +480,8 @@ def test_poll_marks_agent_failed_on_error_status(self, mock_identity_service, mo @patch(MOCK_SYNC_STATUS_PATH) @patch("sentry.seer.autofix.coding_agent.github_copilot_identity_service") def test_poll_marks_completed_when_pr_resolution_errors( - self, mock_identity_service, mock_sync_status - ): + self, mock_identity_service: MagicMock, mock_sync_status: MagicMock + ) -> None: """A GitHub API error during PR resolution must not block the terminal status update.""" mock_identity_service.get_access_token_for_user.return_value = "test_token" @@ -509,8 +523,8 @@ def test_poll_marks_completed_when_pr_resolution_errors( @patch(MOCK_SYNC_STATUS_PATH) @patch("sentry.seer.autofix.coding_agent.github_copilot_identity_service") def test_poll_marks_failed_even_when_branch_has_pr( - self, mock_identity_service, mock_sync_status - ): + self, mock_identity_service: MagicMock, mock_sync_status: MagicMock + ) -> None: """A failed/timed_out task is marked FAILED even if a PR exists on the head branch.""" mock_identity_service.get_access_token_for_user.return_value = "test_token" @@ -553,8 +567,8 @@ def test_poll_marks_failed_even_when_branch_has_pr( @patch(MOCK_SYNC_STATUS_PATH) @patch("sentry.seer.autofix.coding_agent.github_copilot_identity_service") def test_poll_keeps_running_status_when_task_not_done( - self, mock_identity_service, mock_sync_status - ): + self, mock_identity_service: MagicMock, mock_sync_status: MagicMock + ) -> None: """Test that polling keeps RUNNING status when task is still in progress""" mock_identity_service.get_access_token_for_user.return_value = "test_token" @@ -597,7 +611,9 @@ def test_poll_keeps_running_status_when_task_not_done( @patch(MOCK_SYNC_STATUS_PATH) @patch("sentry.seer.autofix.coding_agent.github_copilot_identity_service") - def test_poll_handles_api_exception(self, mock_identity_service, mock_sync_status): + def test_poll_handles_api_exception( + self, mock_identity_service: MagicMock, mock_sync_status: MagicMock + ) -> None: """Test that polling handles exceptions gracefully""" mock_identity_service.get_access_token_for_user.return_value = "test_token" @@ -649,7 +665,7 @@ def _make_agent_event(text: str) -> ClaudeSessionEvent: class FakeClaudeCodeClient: def __init__( self, - events: list[dict] | None = None, + events: list[dict[str, Any]] | None = None, result: CodingAgentResult | None = None, ) -> None: self.events = events or [] @@ -657,7 +673,7 @@ def __init__( self.list_session_events_calls: list[str] = [] self.build_result_from_session_calls: list[dict[str, str | None]] = [] - def list_session_events(self, agent_id: str) -> list[dict]: + def list_session_events(self, agent_id: str) -> list[dict[str, Any]]: self.list_session_events_calls.append(agent_id) return self.events @@ -801,7 +817,9 @@ def _create_autofix_state_with_agents( ) def _create_claude_agent( - self, agent_id="claude-session-123", status=CodingAgentStatus.RUNNING + self, + agent_id: str = "claude-session-123", + status: CodingAgentStatus = CodingAgentStatus.RUNNING, ) -> CodingAgentState: return CodingAgentState( id=agent_id, @@ -812,7 +830,7 @@ def _create_claude_agent( integration_id=self.integration_id, ) - def _mock_integration(self, mock_integration_service): + def _mock_integration(self, mock_integration_service: MagicMock) -> MagicMock: mock_integration = MagicMock() mock_integration.metadata = { "api_key": "sk-ant-test", @@ -823,13 +841,13 @@ def _mock_integration(self, mock_integration_service): return mock_integration @patch(MOCK_INTEGRATION_SERVICE_PATH) - def test_skips_when_no_coding_agents(self, mock_integration_service): + def test_skips_when_no_coding_agents(self, mock_integration_service: MagicMock) -> None: autofix_state = self._create_autofix_state_with_agents({}) poll_claude_code_agents(autofix_state=autofix_state) mock_integration_service.get_integration.assert_not_called() @patch(MOCK_INTEGRATION_SERVICE_PATH) - def test_skips_non_claude_agents(self, mock_integration_service): + def test_skips_non_claude_agents(self, mock_integration_service: MagicMock) -> None: agents = { "cursor-agent-123": CodingAgentState( id="cursor-agent-123", @@ -844,7 +862,7 @@ def test_skips_non_claude_agents(self, mock_integration_service): mock_integration_service.get_integration.assert_not_called() @patch(MOCK_INTEGRATION_SERVICE_PATH) - def test_skips_completed_agents(self, mock_integration_service): + def test_skips_completed_agents(self, mock_integration_service: MagicMock) -> None: agents = { "claude-session-123": self._create_claude_agent(status=CodingAgentStatus.COMPLETED), } @@ -853,7 +871,7 @@ def test_skips_completed_agents(self, mock_integration_service): mock_integration_service.get_integration.assert_not_called() @patch(MOCK_INTEGRATION_SERVICE_PATH) - def test_skips_failed_agents(self, mock_integration_service): + def test_skips_failed_agents(self, mock_integration_service: MagicMock) -> None: agents = { "claude-session-123": self._create_claude_agent(status=CodingAgentStatus.FAILED), } @@ -865,8 +883,11 @@ def test_skips_failed_agents(self, mock_integration_service): @patch(MOCK_CLIENT_CLASS_PATH) @patch(MOCK_INTEGRATION_SERVICE_PATH) def test_polls_running_agent_and_updates_completed( - self, mock_integration_service, mock_import_string, mock_sync_status - ): + self, + mock_integration_service: MagicMock, + mock_import_string: MagicMock, + mock_sync_status: MagicMock, + ) -> None: self._mock_integration(mock_integration_service) fake_client = FakeClaudeCodeClient( events=[ @@ -908,8 +929,12 @@ def test_polls_running_agent_and_updates_completed( @patch(MOCK_CLIENT_CLASS_PATH) @patch(MOCK_INTEGRATION_SERVICE_PATH) def test_attributes_pr_on_completion( - self, mock_integration_service, mock_import_string, mock_sync_status, mock_attribute - ): + self, + mock_integration_service: MagicMock, + mock_import_string: MagicMock, + mock_sync_status: MagicMock, + mock_attribute: MagicMock, + ) -> None: """A completed Claude session with a PR is attributed to the Claude agent, and both Seer's state and the Sentry-side SeerRunCodingAgentHandoff row are synced in one call.""" self._mock_integration(mock_integration_service) @@ -961,8 +986,12 @@ def test_attributes_pr_on_completion( @patch(MOCK_CLIENT_CLASS_PATH) @patch(MOCK_INTEGRATION_SERVICE_PATH) def test_does_not_attribute_when_no_pr_url( - self, mock_integration_service, mock_import_string, mock_sync_status, mock_attribute - ): + self, + mock_integration_service: MagicMock, + mock_import_string: MagicMock, + mock_sync_status: MagicMock, + mock_attribute: MagicMock, + ) -> None: """A completed session without a PR is not attributed.""" self._mock_integration(mock_integration_service) fake_client = FakeClaudeCodeClient( @@ -986,8 +1015,11 @@ def test_does_not_attribute_when_no_pr_url( @patch(MOCK_CLIENT_CLASS_PATH) @patch(MOCK_INTEGRATION_SERVICE_PATH) def test_marks_failed_when_no_pr_url( - self, mock_integration_service, mock_import_string, mock_sync_status - ): + self, + mock_integration_service: MagicMock, + mock_import_string: MagicMock, + mock_sync_status: MagicMock, + ) -> None: self._mock_integration(mock_integration_service) fake_client = FakeClaudeCodeClient( events=[ @@ -1012,8 +1044,11 @@ def test_marks_failed_when_no_pr_url( @patch(MOCK_CLIENT_CLASS_PATH) @patch(MOCK_INTEGRATION_SERVICE_PATH) def test_no_update_when_status_unchanged( - self, mock_integration_service, mock_import_string, mock_sync_status - ): + self, + mock_integration_service: MagicMock, + mock_import_string: MagicMock, + mock_sync_status: MagicMock, + ) -> None: self._mock_integration(mock_integration_service) # Last event is session.status_running — agent is already RUNNING, no update needed fake_client = FakeClaudeCodeClient(events=[{"type": ClaudeSessionEventStatus.RUNNING}]) @@ -1029,8 +1064,11 @@ def test_no_update_when_status_unchanged( @patch(MOCK_CLIENT_CLASS_PATH) @patch(MOCK_INTEGRATION_SERVICE_PATH) def test_no_update_when_events_empty( - self, mock_integration_service, mock_import_string, mock_sync_status - ): + self, + mock_integration_service: MagicMock, + mock_import_string: MagicMock, + mock_sync_status: MagicMock, + ) -> None: self._mock_integration(mock_integration_service) fake_client = FakeClaudeCodeClient(events=[]) mock_import_string.return_value = lambda **kwargs: fake_client @@ -1045,8 +1083,11 @@ def test_no_update_when_events_empty( @patch(MOCK_CLIENT_CLASS_PATH) @patch(MOCK_INTEGRATION_SERVICE_PATH) def test_updates_pending_to_running_on_non_idle_event( - self, mock_integration_service, mock_import_string, mock_sync_status - ): + self, + mock_integration_service: MagicMock, + mock_import_string: MagicMock, + mock_sync_status: MagicMock, + ) -> None: self._mock_integration(mock_integration_service) fake_client = FakeClaudeCodeClient(events=[{"type": "agent.message", "content": []}]) mock_import_string.return_value = lambda **kwargs: fake_client @@ -1066,8 +1107,11 @@ def test_updates_pending_to_running_on_non_idle_event( @patch(MOCK_CLIENT_CLASS_PATH) @patch(MOCK_INTEGRATION_SERVICE_PATH) def test_stays_pending_on_status_rescheduling_event( - self, mock_integration_service, mock_import_string, mock_sync_status - ): + self, + mock_integration_service: MagicMock, + mock_import_string: MagicMock, + mock_sync_status: MagicMock, + ) -> None: self._mock_integration(mock_integration_service) fake_client = FakeClaudeCodeClient(events=[{"type": ClaudeSessionEventStatus.RESCHEDULING}]) mock_import_string.return_value = lambda **kwargs: fake_client @@ -1082,8 +1126,11 @@ def test_stays_pending_on_status_rescheduling_event( @patch(MOCK_CLIENT_CLASS_PATH) @patch(MOCK_INTEGRATION_SERVICE_PATH) def test_updates_running_to_pending_on_rescheduling_event( - self, mock_integration_service, mock_import_string, mock_sync_status - ): + self, + mock_integration_service: MagicMock, + mock_import_string: MagicMock, + mock_sync_status: MagicMock, + ) -> None: """A RESCHEDULING event on a RUNNING session must sync both Seer's state and the Sentry-side handoff row back to pending, in one call.""" self._mock_integration(mock_integration_service) @@ -1104,8 +1151,11 @@ def test_updates_running_to_pending_on_rescheduling_event( @patch(MOCK_CLIENT_CLASS_PATH) @patch(MOCK_INTEGRATION_SERVICE_PATH) def test_uses_correct_integration_per_agent( - self, mock_integration_service, mock_get_client_class, mock_sync_status - ): + self, + mock_integration_service: MagicMock, + mock_get_client_class: MagicMock, + mock_sync_status: MagicMock, + ) -> None: integration_a = MagicMock() integration_a.metadata = { "api_key": "sk-ant-aaa", @@ -1125,7 +1175,7 @@ def test_uses_correct_integration_per_agent( clients: dict[str, FakeClaudeCodeClient] = {} - def make_client(**kwargs): + def make_client(**kwargs: str) -> FakeClaudeCodeClient: client = FakeClaudeCodeClient(events=[{"type": ClaudeSessionEventStatus.RUNNING}]) clients[kwargs["api_key"]] = client return client @@ -1162,8 +1212,11 @@ def make_client(**kwargs): @patch(MOCK_CLIENT_CLASS_PATH) @patch(MOCK_INTEGRATION_SERVICE_PATH) def test_caches_client_for_same_integration( - self, mock_integration_service, mock_import_string, mock_sync_status - ): + self, + mock_integration_service: MagicMock, + mock_import_string: MagicMock, + mock_sync_status: MagicMock, + ) -> None: self._mock_integration(mock_integration_service) fake_client = FakeClaudeCodeClient(events=[{"type": ClaudeSessionEventStatus.RUNNING}]) mock_import_string.return_value = lambda **kwargs: fake_client diff --git a/tests/sentry/seer/autofix/test_commit_author.py b/tests/sentry/seer/autofix/test_commit_author.py index 40abad05978b..a5a4b4c72f67 100644 --- a/tests/sentry/seer/autofix/test_commit_author.py +++ b/tests/sentry/seer/autofix/test_commit_author.py @@ -2,8 +2,10 @@ from django.contrib.auth.models import AnonymousUser +from sentry.integrations.models.external_actor import ExternalActor from sentry.integrations.types import ExternalProviders from sentry.seer.autofix.commit_author import ( + SeerCommitAuthor, commit_author_for_feedback, commit_author_for_github_actor, commit_author_for_user, @@ -19,6 +21,8 @@ ) from sentry.seer.autofix.pr_iteration.feedback_sources.user_ui import UserUIFeedbackSource from sentry.testutils.cases import TestCase +from sentry.users.models.user import User +from sentry.users.services.user import RpcUser OCTOCAT_EMAIL = "583231+octocat@users.noreply.github.com" LOGIN_ONLY_EMAIL = "octocat@users.noreply.github.com" @@ -65,7 +69,7 @@ def setUp(self) -> None: super().setUp() self.actor = self.create_user(name="Mona Lisa") - def _link_github(self, external_id: str | None = "583231"): + def _link_github(self, external_id: str | None = "583231") -> ExternalActor: return self.create_external_user( user=self.actor, organization=self.organization, @@ -77,7 +81,7 @@ def _link_github(self, external_id: str | None = "583231"): ), ) - def _for_user(self, user): + def _for_user(self, user: User | RpcUser | AnonymousUser | None) -> SeerCommitAuthor | None: return commit_author_for_user(user, self.organization.id, referrer="test") def _assert_outcome(self, mock_metrics: MagicMock, expected: str) -> None: @@ -126,7 +130,7 @@ def test_user_resolves_via_external_actor(self) -> None: } @patch("sentry.seer.autofix.commit_author.get_github_username_for_user", return_value="octocat") - def test_user_resolved_by_login_only_has_no_id(self, mock_lookup) -> None: + def test_user_resolved_by_login_only_has_no_id(self, mock_lookup: MagicMock) -> None: # The CommitAuthor email fallback yields a login with no ExternalActor row. assert self._for_user(self.actor) == { "name": "Mona Lisa", @@ -165,7 +169,7 @@ def test_user_without_github_identity(self) -> None: "sentry.seer.autofix.commit_author.get_github_username_for_user", side_effect=Exception("boom"), ) - def test_user_lookup_exception(self, mock_lookup) -> None: + def test_user_lookup_exception(self, mock_lookup: MagicMock) -> None: assert self._for_user(self.actor) is None def test_feedback_single_github_commenter(self) -> None: diff --git a/tests/sentry/seer/autofix/test_github_perms.py b/tests/sentry/seer/autofix/test_github_perms.py index df67be70e62e..bcadcc841eac 100644 --- a/tests/sentry/seer/autofix/test_github_perms.py +++ b/tests/sentry/seer/autofix/test_github_perms.py @@ -1,5 +1,6 @@ from __future__ import annotations +from contextlib import AbstractContextManager from unittest import mock from sentry.constants import ObjectStatus @@ -122,7 +123,7 @@ def _set_last_refresh_at(self, last_refresh_at: str) -> None: metadata={**self.integration.metadata, "last_refresh_at": last_refresh_at} ) - def _refresh_to(self, permissions: dict[str, str]): + def _refresh_to(self, permissions: dict[str, str]) -> AbstractContextManager[mock.MagicMock]: """Stand in for the token mint, which rewrites both permissions and stamp. A side_effect rather than a return_value: the row has to still be stale diff --git a/tests/sentry/seer/autofix/test_issue_search.py b/tests/sentry/seer/autofix/test_issue_search.py index bb1e504e79d9..734a0c25d25c 100644 --- a/tests/sentry/seer/autofix/test_issue_search.py +++ b/tests/sentry/seer/autofix/test_issue_search.py @@ -3,6 +3,7 @@ from django.utils import timezone from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.models.pullrequest import PullRequestLifecycleState from sentry.seer.autofix.issue_search import autofix_state_filter from sentry.testutils.cases import TestCase @@ -19,11 +20,9 @@ def setUp(self) -> None: def _matching_group_ids(self, values: list[str]) -> set[int]: q = autofix_state_filter(values, [self.project], recency_window=RECENCY_WINDOW) - from sentry.models.group import Group - return set(Group.objects.filter(q, project=self.project).values_list("id", flat=True)) - def _create_merged_pr_run(self, group): + def _create_merged_pr_run(self, group: Group) -> None: run = self.create_seer_run(organization=self.organization) self.create_seer_agent_run(run, source="autofix", project=self.project, group=group) repo = self.create_repo(self.project, name="getsentry/sentry") diff --git a/tests/sentry/seer/autofix/test_issue_summary.py b/tests/sentry/seer/autofix/test_issue_summary.py index eca3814febd0..122cee1a24c1 100644 --- a/tests/sentry/seer/autofix/test_issue_summary.py +++ b/tests/sentry/seer/autofix/test_issue_summary.py @@ -45,8 +45,8 @@ class TriggerAutofixTaskTest(TestCase): @patch("sentry.seer.autofix.issue_summary.SeerAutofixOperator.has_access", return_value=False) @patch("sentry.seer.autofix.issue_summary.trigger_autofix_agent", return_value=123) def test_post_process_kickoff_creates_system_activity( - self, _mock_trigger_autofix, _mock_operator_access - ): + self, _mock_trigger_autofix: MagicMock, _mock_operator_access: MagicMock + ) -> None: group = self.create_group() with capture_action_log() as action_log: @@ -99,7 +99,7 @@ def test_first_assignment_does_not_record_scanner_usage( mock_record_seer_run.assert_not_called() @patch("sentry.seer.autofix.issue_summary._call_seer") - def test_get_issue_summary_with_existing_summary(self, mock_call_seer): + def test_get_issue_summary_with_existing_summary(self, mock_call_seer: MagicMock) -> None: existing_summary = { "group_id": str(self.group.id), "event_id": "existing-event", @@ -146,8 +146,8 @@ def test_get_issue_summary_is_unavailable_when_self_hosted(self) -> None: @patch("sentry.seer.autofix.issue_summary._call_seer") @patch("sentry.seer.autofix.issue_summary._get_event") def test_get_issue_summary_without_existing_summary( - self, mock_get_event, mock_call_seer, mock_get_trace_tree - ): + self, mock_get_event: MagicMock, mock_call_seer: MagicMock, mock_get_trace_tree: MagicMock + ) -> None: event = Mock( event_id="test_event_id", data="test_event_data", @@ -230,7 +230,7 @@ def test_call_seer_integration( assert cache.get(f"ai-group-summary-v2:{self.group.id}") == expected_summary.dict() @patch("sentry.seer.autofix.issue_summary.get_or_generate_issue_summary") - def test_get_issue_summary_cache_write_read(self, mock_get_issue_summary): + def test_get_issue_summary_cache_write_read(self, mock_get_issue_summary: MagicMock) -> None: # First request to populate the cache mock_get_event = Mock() mock_call_seer = Mock() @@ -276,7 +276,9 @@ def test_get_issue_summary_cache_write_read(self, mock_get_issue_summary): mock_call_seer.assert_not_called() @patch("sentry.seer.autofix.issue_summary._generate_summary") - def test_get_issue_summary_concurrent_wait_for_lock(self, mock_generate_summary): + def test_get_issue_summary_concurrent_wait_for_lock( + self, mock_generate_summary: MagicMock + ) -> None: """Test that a second request waits for the lock and reads from cache.""" cache_key = f"ai-group-summary-v2:{self.group.id}" @@ -288,7 +290,7 @@ def test_get_issue_summary_concurrent_wait_for_lock(self, mock_generate_summary) ) cache_key = f"ai-group-summary-v2:{self.group.id}" - def side_effect_generate(*args, **kwargs): + def side_effect_generate(*args: object, **kwargs: object) -> IssueSummary: # Simulate work time.sleep(0.3) # Write to cache before returning (simulates behavior after lock release) @@ -300,7 +302,7 @@ def side_effect_generate(*args, **kwargs): results = {} exceptions = {} - def target(req_id): + def target(req_id: int) -> None: try: results[req_id] = get_or_generate_issue_summary(self.group, self.user) except Exception as e: @@ -338,7 +340,9 @@ def target(req_id): assert cache.get(cache_key) == generated_summary.dict() @patch("sentry.seer.autofix.issue_summary._generate_summary") - def test_get_issue_summary_concurrent_force_event_id_bypasses_lock(self, mock_generate_summary): + def test_get_issue_summary_concurrent_force_event_id_bypasses_lock( + self, mock_generate_summary: MagicMock + ) -> None: """Test that force_event_id bypasses lock waiting.""" # Mock summary generation forced_summary = IssueSummary( @@ -400,10 +404,10 @@ def test_call_seer_raises_exception_when_endpoint_fails(self, mock_request: Magi @patch("sentry.utils.locking.lock.Lock.blocking_acquire") def test_get_issue_summary_lock_timeout( self, - mock_blocking_acquire, - mock_generate_summary_core, - mock_cache_get, - ): + mock_blocking_acquire: MagicMock, + mock_generate_summary_core: MagicMock, + mock_cache_get: MagicMock, + ) -> None: """A lock timeout remains a domain exception for background-task retries.""" # Simulate lock acquisition always failing with the specific exception mock_blocking_acquire.side_effect = UnableToAcquireLock @@ -534,13 +538,13 @@ def test_get_event_provided( @patch("sentry.seer.autofix.issue_summary._get_event") def test_summary_and_automation_for_web_vitals_issue( self, - mock_get_event, - mock_call_seer, - mock_get_trace_tree, - mock_record_seer_run, - mock_generate_fixability_score, - mock_trigger_autofix_task, - ): + mock_get_event: MagicMock, + mock_call_seer: MagicMock, + mock_get_trace_tree: MagicMock, + mock_record_seer_run: MagicMock, + mock_generate_fixability_score: MagicMock, + mock_trigger_autofix_task: MagicMock, + ) -> None: mock_fixability_response = SummarizeIssueResponse( group_id=str(self.group.id), headline="some headline", @@ -611,8 +615,8 @@ def test_summary_and_automation_for_web_vitals_issue( @patch("sentry.seer.autofix.issue_summary.get_trace_tree_for_event") def test_get_issue_summary_handles_trace_tree_errors( self, - mock_get_trace_tree, - ): + mock_get_trace_tree: MagicMock, + ) -> None: mock_get_trace_tree.side_effect = Exception("boom") event = Mock(event_id="test_event_id", datetime=datetime.datetime.now()) @@ -646,11 +650,11 @@ def test_get_issue_summary_handles_trace_tree_errors( @patch("sentry.seer.autofix.issue_summary._get_event") def test_get_issue_summary_caches_without_running_automation( self, - mock_get_event, - mock_call_seer, - mock_get_trace_tree, - mock_run_automation, - ): + mock_get_event: MagicMock, + mock_call_seer: MagicMock, + mock_get_trace_tree: MagicMock, + mock_run_automation: MagicMock, + ) -> None: event = Mock( event_id="test_event_id", data="test_event_data", @@ -732,11 +736,11 @@ def test_get_issue_summary_does_not_run_post_process_automation( @patch("sentry.seer.autofix.issue_summary._get_event") def test_experiment_flag_calls_seer_twice( self, - mock_get_event, - mock_call_seer, - mock_get_trace_tree, - mock_run_automation, - ): + mock_get_event: MagicMock, + mock_call_seer: MagicMock, + mock_get_trace_tree: MagicMock, + mock_run_automation: MagicMock, + ) -> None: """When the experiment flag is on, _call_seer is called twice: control and experimental.""" event = Mock( event_id="test_event_id", @@ -786,11 +790,11 @@ def test_experiment_flag_calls_seer_twice( @patch("sentry.seer.autofix.issue_summary._get_event") def test_experiment_flag_off_calls_seer_once( self, - mock_get_event, - mock_call_seer, - mock_get_trace_tree, - mock_run_automation, - ): + mock_get_event: MagicMock, + mock_call_seer: MagicMock, + mock_get_trace_tree: MagicMock, + mock_run_automation: MagicMock, + ) -> None: """When the experiment flag is off, only one call to _call_seer is made.""" event = Mock( event_id="test_event_id", @@ -827,11 +831,11 @@ def test_experiment_flag_off_calls_seer_once( @patch("sentry.seer.autofix.issue_summary._get_event") def test_experiment_failure_does_not_affect_regular_flow( self, - mock_get_event, - mock_call_seer, - mock_get_trace_tree, - mock_run_automation, - ): + mock_get_event: MagicMock, + mock_call_seer: MagicMock, + mock_get_trace_tree: MagicMock, + mock_run_automation: MagicMock, + ) -> None: """If the experimental call fails, the control result is still cached and returned.""" event = Mock( event_id="test_event_id", @@ -880,7 +884,7 @@ class TestGetStoppingPointFromFixability: (1.0, AutofixStoppingPoint.OPEN_PR), ], ) - def test_stopping_point_mapping(self, score, expected): + def test_stopping_point_mapping(self, score: float, expected: AutofixStoppingPoint) -> None: assert _get_stopping_point_from_fixability(score) == expected @@ -901,8 +905,13 @@ def setUp(self) -> None: @patch("sentry.quotas.backend.check_seer_quota", return_value=True) @patch("sentry.seer.autofix.issue_summary._generate_fixability_score") def test_high_fixability_code_changes( - self, mock_gen, mock_budget, mock_rate, mock_trigger, mock_seat_based_tier - ): + self, + mock_gen: MagicMock, + mock_budget: MagicMock, + mock_rate: MagicMock, + mock_trigger: MagicMock, + mock_seat_based_tier: MagicMock, + ) -> None: self.project.update_option("sentry:autofix_automation_tuning", "always") mock_gen.return_value = SummarizeIssueResponse( group_id=str(self.group.id), @@ -924,8 +933,13 @@ def test_high_fixability_code_changes( @patch("sentry.quotas.backend.check_seer_quota", return_value=True) @patch("sentry.seer.autofix.issue_summary._generate_fixability_score") def test_medium_fixability_solution( - self, mock_gen, mock_budget, mock_rate, mock_trigger, mock_seat_based_tier - ): + self, + mock_gen: MagicMock, + mock_budget: MagicMock, + mock_rate: MagicMock, + mock_trigger: MagicMock, + mock_seat_based_tier: MagicMock, + ) -> None: self.project.update_option("sentry:autofix_automation_tuning", "always") mock_gen.return_value = SummarizeIssueResponse( group_id=str(self.group.id), @@ -947,11 +961,11 @@ def test_medium_fixability_solution( @patch("sentry.seer.autofix.issue_summary.is_group_eligible_for_automation", return_value=True) def test_without_seat_based_tier( self, - mock_triggering, - mock_rate, - mock_trigger, - mock_seat_based_tier, - ): + mock_triggering: MagicMock, + mock_rate: MagicMock, + mock_trigger: MagicMock, + mock_seat_based_tier: MagicMock, + ) -> None: mock_seat_based_tier.return_value = False run_automation(self.group, self.user, self.event, SeerAutomationSource.POST_PROCESS) @@ -961,8 +975,8 @@ def test_without_seat_based_tier( @patch("sentry.seer.autofix.issue_summary._trigger_autofix_task.delay") @patch("sentry.seer.autofix.issue_summary.is_group_eligible_for_automation", return_value=False) def test_skips_when_group_is_ineligible( - self, mock_eligible, mock_trigger, mock_seat_based_tier - ): + self, mock_eligible: MagicMock, mock_trigger: MagicMock, mock_seat_based_tier: MagicMock + ) -> None: run_automation(self.group, self.user, self.event, SeerAutomationSource.POST_PROCESS) mock_trigger.assert_not_called() @@ -1027,7 +1041,12 @@ class TestApplyUserPreferenceUpperBound: ), ], ) - def test_upper_bound_combinations(self, fixability, user_pref, expected): + def test_upper_bound_combinations( + self, + fixability: AutofixStoppingPoint | None, + user_pref: str | None, + expected: AutofixStoppingPoint, + ) -> None: result = _apply_user_preference_upper_bound(fixability, user_pref) assert result == expected @@ -1050,12 +1069,12 @@ def setUp(self) -> None: @patch("sentry.seer.autofix.issue_summary._generate_fixability_score") def test_user_preference_limits_high_fixability( self, - mock_gen, - mock_budget, - mock_rate, - mock_trigger, - mock_seat_based_tier, - ): + mock_gen: MagicMock, + mock_budget: MagicMock, + mock_rate: MagicMock, + mock_trigger: MagicMock, + mock_seat_based_tier: MagicMock, + ) -> None: """High fixability (OPEN_PR) limited by user preference (SOLUTION)""" self.project.update_option("sentry:autofix_automation_tuning", "always") self.project.update_option("sentry:seer_automated_run_stopping_point", "solution") @@ -1083,12 +1102,12 @@ def test_user_preference_limits_high_fixability( @patch("sentry.seer.autofix.issue_summary._generate_fixability_score") def test_fixability_limits_permissive_user_preference( self, - mock_gen, - mock_budget, - mock_rate, - mock_trigger, - mock_seat_based_tier, - ): + mock_gen: MagicMock, + mock_budget: MagicMock, + mock_rate: MagicMock, + mock_trigger: MagicMock, + mock_seat_based_tier: MagicMock, + ) -> None: """Medium fixability (ROOT_CAUSE) used despite user allowing OPEN_PR""" self.project.update_option("sentry:autofix_automation_tuning", "always") self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") @@ -1115,7 +1134,7 @@ def setUp(self) -> None: self.group = self.create_group() @patch("sentry.seer.autofix.issue_summary._generate_fixability_score") - def test_returns_existing_score_without_regenerating(self, mock_generate): + def test_returns_existing_score_without_regenerating(self, mock_generate: MagicMock) -> None: """Test that existing fixability score is returned without calling Seer.""" # Set an existing fixability score self.group.update(seer_fixability_score=0.75) @@ -1127,7 +1146,7 @@ def test_returns_existing_score_without_regenerating(self, mock_generate): mock_generate.assert_not_called() @patch("sentry.seer.autofix.issue_summary._generate_fixability_score") - def test_generates_and_updates_score_when_missing(self, mock_generate): + def test_generates_and_updates_score_when_missing(self, mock_generate: MagicMock) -> None: """Test that fixability score is generated and saved when missing.""" mock_generate.return_value = SummarizeIssueResponse( group_id=str(self.group.id), @@ -1147,7 +1166,7 @@ def test_generates_and_updates_score_when_missing(self, mock_generate): mock_generate.assert_called_once() @patch("sentry.seer.autofix.issue_summary._generate_fixability_score") - def test_force_generate_regenerates_existing_score(self, mock_generate): + def test_force_generate_regenerates_existing_score(self, mock_generate: MagicMock) -> None: """Test that force_generate=True regenerates score even if one exists.""" # Set an existing score self.group.update(seer_fixability_score=0.50) @@ -1170,7 +1189,7 @@ def test_force_generate_regenerates_existing_score(self, mock_generate): mock_generate.assert_called_once() @patch("sentry.seer.autofix.issue_summary.make_signed_seer_api_request") - def test_reads_summary_from_cache_and_passes_to_seer(self, mock_request): + def test_reads_summary_from_cache_and_passes_to_seer(self, mock_request: MagicMock) -> None: """Test that summary is read from cache and included in the API payload sent to Seer.""" mock_response = Mock() mock_response.status = 200 @@ -1222,7 +1241,7 @@ def test_reads_summary_from_cache_and_passes_to_seer(self, mock_request): assert summary_payload["possible_cause"] == "Test cause" @patch("sentry.seer.autofix.issue_summary.make_signed_seer_api_request") - def test_no_summary_in_cache_calls_seer_without_summary(self, mock_request): + def test_no_summary_in_cache_calls_seer_without_summary(self, mock_request: MagicMock) -> None: """Test that when cache is empty, fixability is called without summary (Seer DB fallback).""" mock_response = Mock() mock_response.status = 200 @@ -1259,7 +1278,9 @@ def setUp(self) -> None: @patch("sentry.seer.autofix.issue_summary.is_seer_autotriggered_autofix_rate_limited") @patch("sentry.quotas.backend.check_seer_quota") @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_returns_true_when_all_checks_pass(self, mock_fixability, mock_quota, mock_rate_limit): + def test_returns_true_when_all_checks_pass( + self, mock_fixability: MagicMock, mock_quota: MagicMock, mock_rate_limit: MagicMock + ) -> None: mock_fixability.return_value = 0.80 mock_quota.return_value = True mock_rate_limit.return_value = False @@ -1270,13 +1291,13 @@ def test_returns_true_when_all_checks_pass(self, mock_fixability, mock_quota, mo @override_settings(SENTRY_SELF_HOSTED=True) @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_returns_false_without_seer_access(self, mock_fixability): + def test_returns_false_without_seer_access(self, mock_fixability: MagicMock) -> None: assert is_group_eligible_for_automation(self.group) is False mock_fixability.assert_not_called() @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_returns_false_when_live_autofix_run_exists(self, mock_fixability): + def test_returns_false_when_live_autofix_run_exists(self, mock_fixability: MagicMock) -> None: run = self.create_seer_run( organization=self.organization, mirror_status="live", @@ -1291,8 +1312,8 @@ def test_returns_false_when_live_autofix_run_exists(self, mock_fixability): @patch("sentry.quotas.backend.check_seer_quota") @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") def test_returns_true_when_only_failed_autofix_run_exists( - self, mock_fixability, mock_quota, mock_rate_limit - ): + self, mock_fixability: MagicMock, mock_quota: MagicMock, mock_rate_limit: MagicMock + ) -> None: run = self.create_seer_run( organization=self.organization, mirror_status="failed", @@ -1306,7 +1327,7 @@ def test_returns_true_when_only_failed_autofix_run_exists( assert is_group_eligible_for_automation(self.group) is True @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_returns_false_when_not_fixable(self, mock_fixability): + def test_returns_false_when_not_fixable(self, mock_fixability: MagicMock) -> None: mock_fixability.return_value = 0.20 self.group.times_seen = 10 self.group.times_seen_pending = 0 @@ -1317,7 +1338,9 @@ def test_returns_false_when_not_fixable(self, mock_fixability): @patch("sentry.quotas.backend.check_seer_quota") @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_returns_false_when_no_budget(self, mock_fixability, mock_quota): + def test_returns_false_when_no_budget( + self, mock_fixability: MagicMock, mock_quota: MagicMock + ) -> None: mock_fixability.return_value = 0.80 mock_quota.return_value = False self.group.times_seen = 10 @@ -1328,7 +1351,9 @@ def test_returns_false_when_no_budget(self, mock_fixability, mock_quota): @patch("sentry.seer.autofix.issue_summary.is_seer_autotriggered_autofix_rate_limited") @patch("sentry.quotas.backend.check_seer_quota") @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_returns_false_when_rate_limited(self, mock_fixability, mock_quota, mock_rate_limit): + def test_returns_false_when_rate_limited( + self, mock_fixability: MagicMock, mock_quota: MagicMock, mock_rate_limit: MagicMock + ) -> None: mock_fixability.return_value = 0.80 mock_quota.return_value = True mock_rate_limit.return_value = True @@ -1346,21 +1371,27 @@ def setUp(self) -> None: self.group = self.create_group() @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_default_preference_limits_stopping_point(self, mock_fixability, mock_seat_based_tier): + def test_default_preference_limits_stopping_point( + self, mock_fixability: MagicMock, mock_seat_based_tier: MagicMock + ) -> None: """Unset preference falls back to the well-known default (code_changes).""" mock_fixability.return_value = 0.80 assert get_automation_stopping_point(self.group) == AutofixStoppingPoint.CODE_CHANGES @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_user_preference_limits_stopping_point(self, mock_fixability, mock_seat_based_tier): + def test_user_preference_limits_stopping_point( + self, mock_fixability: MagicMock, mock_seat_based_tier: MagicMock + ) -> None: mock_fixability.return_value = 0.80 self.group.project.update_option("sentry:seer_automated_run_stopping_point", "solution") assert get_automation_stopping_point(self.group) == AutofixStoppingPoint.SOLUTION @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") - def test_low_fixability_returns_root_cause(self, mock_fixability, mock_seat_based_tier): + def test_low_fixability_returns_root_cause( + self, mock_fixability: MagicMock, mock_seat_based_tier: MagicMock + ) -> None: mock_fixability.return_value = 0.50 self.group.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") diff --git a/tests/sentry/seer/autofix/test_on_completion_hook.py b/tests/sentry/seer/autofix/test_on_completion_hook.py index 344798a63330..3093f5eddc95 100644 --- a/tests/sentry/seer/autofix/test_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_on_completion_hook.py @@ -2,7 +2,7 @@ from collections.abc import Sequence from typing import Literal -from unittest.mock import patch +from unittest.mock import MagicMock, patch from sentry.seer.agent.client_models import ( AgentFilePatch, @@ -41,7 +41,7 @@ def setUp(self) -> None: self.group = self.create_group(project=self.project) @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") - def test_not_found_clears_automation_handoff(self, mock_trigger) -> None: + def test_not_found_clears_automation_handoff(self, mock_trigger: MagicMock) -> None: mock_trigger.side_effect = IntegrationNotFound("Integration not found") self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") @@ -144,12 +144,12 @@ def setUp(self) -> None: def _run(self, state: SeerRunState) -> None: record_failed_tool_calls(self.organization, self.group, state) - def test_no_failed_tools(self, mock_metrics) -> None: + def test_no_failed_tools(self, mock_metrics: MagicMock) -> None: self._run(_state([_iteration_block(0, failed=False)])) mock_metrics.incr.assert_not_called() - def test_skips_non_pr_iteration(self, mock_metrics) -> None: + def test_skips_non_pr_iteration(self, mock_metrics: MagicMock) -> None: state = _state( [ MemoryBlock( @@ -180,7 +180,7 @@ def test_skips_non_pr_iteration(self, mock_metrics) -> None: mock_record.assert_not_called() - def test_records_latest_iteration_failures(self, mock_metrics) -> None: + def test_records_latest_iteration_failures(self, mock_metrics: MagicMock) -> None: state = _state( [ _iteration_block(0, failed=True, function="old_tool"), @@ -196,7 +196,7 @@ def test_records_latest_iteration_failures(self, mock_metrics) -> None: sample_rate=1.0, ) - def test_counts_duplicate_tool_failures(self, mock_metrics) -> None: + def test_counts_duplicate_tool_failures(self, mock_metrics: MagicMock) -> None: state = _state( [ _iteration_block( @@ -213,7 +213,7 @@ def test_counts_duplicate_tool_failures(self, mock_metrics) -> None: sample_rate=1.0, ) - def test_skips_after_changes_are_pushed(self, mock_metrics) -> None: + def test_skips_after_changes_are_pushed(self, mock_metrics: MagicMock) -> None: """The hook re-fires once the push lands; do not score the same failures again.""" self._run( _state( @@ -226,7 +226,7 @@ def test_skips_after_changes_are_pushed(self, mock_metrics) -> None: mock_metrics.incr.assert_not_called() - def test_records_before_push(self, mock_metrics) -> None: + def test_records_before_push(self, mock_metrics: MagicMock) -> None: self._run( _state( [ @@ -252,7 +252,7 @@ def test_records_before_push(self, mock_metrics) -> None: COMPLETION_PATH = "sentry.seer.autofix.pr_iteration.completion" -def _step_checkpoints(mock_metrics) -> list[str]: +def _step_checkpoints(mock_metrics: MagicMock) -> list[str]: return [ call.kwargs["tags"]["checkpoint"] for call in mock_metrics.incr.call_args_list @@ -260,7 +260,7 @@ def _step_checkpoints(mock_metrics) -> list[str]: ] -def _step_feedback_kinds(mock_metrics) -> list[str]: +def _step_feedback_kinds(mock_metrics: MagicMock) -> list[str]: return [ call.kwargs["tags"].get("feedback_kind") for call in mock_metrics.incr.call_args_list @@ -327,7 +327,9 @@ def _run(self, state: SeerRunState) -> None: self.organization, self.group, 1, state, AutofixReferrer.GITHUB_PR_COMMENT ) - def test_an_accepted_push_counts_the_code_change(self, mock_metrics, _mock_complete) -> None: + def test_an_accepted_push_counts_the_code_change( + self, mock_metrics: MagicMock, _mock_complete: MagicMock + ) -> None: with patch(f"{COMPLETION_PATH}.pr_iteration_push_outcome", return_value=None): self._run(_unsynced_state(feedback=_manual_feedback())) @@ -342,7 +344,9 @@ def test_an_accepted_push_counts_the_code_change(self, mock_metrics, _mock_compl sample_rate=1.0, ) - def test_the_re_fire_counts_the_iteration_only(self, mock_metrics, _mock_complete) -> None: + def test_the_re_fire_counts_the_iteration_only( + self, mock_metrics: MagicMock, _mock_complete: MagicMock + ) -> None: with ( patch( f"{COMPLETION_PATH}.pr_iteration_push_outcome", @@ -365,25 +369,29 @@ def _complete_iteration(self, state: SeerRunState) -> None: self._run(state) def test_the_iteration_is_tagged_by_its_feedback_kind( - self, mock_metrics, _mock_complete + self, mock_metrics: MagicMock, _mock_complete: MagicMock ) -> None: self._complete_iteration(_synced_state(_manual_feedback())) assert _step_feedback_kinds(mock_metrics) == ["manual"] - def test_an_iteration_of_both_kinds_is_tagged_mixed(self, mock_metrics, _mock_complete) -> None: + def test_an_iteration_of_both_kinds_is_tagged_mixed( + self, mock_metrics: MagicMock, _mock_complete: MagicMock + ) -> None: self._complete_iteration(_synced_state([*_manual_feedback(), _check_suite_feedback()])) assert _step_feedback_kinds(mock_metrics) == ["mixed"] def test_an_iteration_with_no_feedback_is_tagged_unknown( - self, mock_metrics, _mock_complete + self, mock_metrics: MagicMock, _mock_complete: MagicMock ) -> None: self._complete_iteration(_synced_state()) assert _step_feedback_kinds(mock_metrics) == ["unknown"] - def test_an_errored_run_counts_nothing(self, mock_metrics, _mock_complete) -> None: + def test_an_errored_run_counts_nothing( + self, mock_metrics: MagicMock, _mock_complete: MagicMock + ) -> None: with ( patch(f"{COMPLETION_PATH}.pause_pr_iteration", return_value=True), patch(f"{HOOK_PATH}.fetch_run_status", return_value=_unsynced_state(status="error")), @@ -393,7 +401,7 @@ def test_an_errored_run_counts_nothing(self, mock_metrics, _mock_complete) -> No assert _step_checkpoints(mock_metrics) == [] def test_no_code_changes_counts_the_no_change_branch( - self, mock_metrics, _mock_complete + self, mock_metrics: MagicMock, _mock_complete: MagicMock ) -> None: with patch(f"{COMPLETION_PATH}.consume_queued_feedback"): self._run( @@ -414,7 +422,9 @@ def test_no_code_changes_counts_the_no_change_branch( sample_rate=1.0, ) - def test_an_iteration_never_force_pushes(self, _mock_metrics, _mock_complete) -> None: + def test_an_iteration_never_force_pushes( + self, _mock_metrics: MagicMock, _mock_complete: MagicMock + ) -> None: with ( patch(f"{COMPLETION_PATH}.iteration_prs_any_closed", return_value=False), patch(f"{COMPLETION_PATH}.trigger_push_changes") as mock_push, @@ -423,7 +433,9 @@ def test_an_iteration_never_force_pushes(self, _mock_metrics, _mock_complete) -> assert mock_push.call_args.kwargs["allow_force_push"] is False - def test_a_failed_push_counts_the_start_only(self, mock_metrics, _mock_complete) -> None: + def test_a_failed_push_counts_the_start_only( + self, mock_metrics: MagicMock, _mock_complete: MagicMock + ) -> None: with ( patch(f"{COMPLETION_PATH}.iteration_prs_any_closed", return_value=False), patch( diff --git a/tests/sentry/seer/autofix/test_pr_iteration_webhook.py b/tests/sentry/seer/autofix/test_pr_iteration_webhook.py index 001078b95a15..41252c848daf 100644 --- a/tests/sentry/seer/autofix/test_pr_iteration_webhook.py +++ b/tests/sentry/seer/autofix/test_pr_iteration_webhook.py @@ -35,9 +35,9 @@ def setUp(self) -> None: external_id="123", name="owner/repo", ) - self.integration = MagicMock(id=42, provider="github") + self.integration: Any = MagicMock(id=42, provider="github") - def _event(self, body: str = "@sentry fix it", action: str = "created") -> dict: + def _event(self, body: str = "@sentry fix it", action: str = "created") -> dict[str, Any]: return { "action": action, "comment": { @@ -49,7 +49,7 @@ def _event(self, body: str = "@sentry fix it", action: str = "created") -> dict: "issue": {"number": 7, "pull_request": {"url": "https://example.com/pulls/7"}}, } - def _call(self, event: dict): + def _call(self, event: dict[str, Any]) -> None: return handle_issue_comment_for_autofix_iteration( event=event, organization=self.organization, @@ -171,7 +171,7 @@ def _agent_state(self) -> SeerRunState: def _call( self, - comment: dict | None = None, + comment: dict[str, Any] | None = None, source_type: GithubPrCommentFeedbackType = "github-pr-comment", ) -> None: comment = self.comment if comment is None else comment diff --git a/tests/sentry/seer/code_review/test_preflight.py b/tests/sentry/seer/code_review/test_preflight.py index a4c734c887c9..190deac96334 100644 --- a/tests/sentry/seer/code_review/test_preflight.py +++ b/tests/sentry/seer/code_review/test_preflight.py @@ -27,7 +27,7 @@ def setUp(self) -> None: provider="github", external_id="github:123", ) - self.integration = serialize_integration(integration) + self.rpc_integration = serialize_integration(integration) self.external_identifier = "user123" def _create_service( @@ -37,7 +37,7 @@ def _create_service( return CodeReviewPreflightService( organization=self.organization, repo=self.repo, - integration=self.integration, + integration=self.rpc_integration, pr_author_external_id=( external_identifier if external_identifier is not None else self.external_identifier ), @@ -85,7 +85,7 @@ def test_allowed_when_beta_org_has_repo_settings_enabled(self) -> None: self.create_organization_contributor( organization=self.organization, - integration=self.integration, + integration=self.rpc_integration, external_identifier=self.external_identifier, ) @@ -123,7 +123,7 @@ def test_denied_when_seer_added_only_org_even_with_repo_settings_enabled(self) - self.create_organization_contributor( organization=self.organization, - integration=self.integration, + integration=self.rpc_integration, external_identifier=self.external_identifier, ) @@ -148,7 +148,7 @@ def test_allowed_when_seer_added_and_code_review_beta_org_has_repo_settings(self self.create_organization_contributor( organization=self.organization, - integration=self.integration, + integration=self.rpc_integration, external_identifier=self.external_identifier, ) @@ -196,7 +196,7 @@ def test_allowed_when_seat_based_org_has_repo_settings_enabled( self.create_organization_contributor( organization=self.organization, - integration=self.integration, + integration=self.rpc_integration, external_identifier=self.external_identifier, ) @@ -228,7 +228,7 @@ def test_returns_repo_settings_when_allowed(self, mock_check_quota: MagicMock) - self.create_organization_contributor( organization=self.organization, - integration=self.integration, + integration=self.rpc_integration, external_identifier=self.external_identifier, ) @@ -261,7 +261,7 @@ def test_uses_repo_settings_when_has_both_code_review_beta_and_seat_based_featur self.create_organization_contributor( organization=self.organization, - integration=self.integration, + integration=self.rpc_integration, external_identifier=self.external_identifier, ) @@ -312,7 +312,7 @@ def test_denied_when_integration_missing_hostname( ) self.create_organization_contributor( organization=self.organization, - integration=self.integration, + integration=self.rpc_integration, external_identifier=self.external_identifier, ) @@ -333,7 +333,7 @@ def test_denied_when_missing_external_identifier(self) -> None: service = CodeReviewPreflightService( organization=self.organization, repo=self.repo, - integration=self.integration, + integration=self.rpc_integration, pr_author_external_id=None, ) result = service.check() @@ -366,7 +366,7 @@ def test_denied_when_quota_check_fails(self, mock_check_quota: MagicMock) -> Non self.create_organization_contributor( organization=self.organization, - integration=self.integration, + integration=self.rpc_integration, external_identifier=self.external_identifier, ) @@ -396,7 +396,7 @@ def test_checks_seats_when_both_code_review_beta_and_seat_based_features_are_ena self.create_organization_contributor( organization=self.organization, - integration=self.integration, + integration=self.rpc_integration, external_identifier=self.external_identifier, ) @@ -424,7 +424,7 @@ def test_denied_when_pr_author_is_excluded(self, mock_check_quota: MagicMock) -> self.create_organization_contributor( organization=self.organization, - integration=self.integration, + integration=self.rpc_integration, external_identifier=self.external_identifier, alias="dependabot[bot]", ) diff --git a/tests/sentry/seer/code_review/test_utils.py b/tests/sentry/seer/code_review/test_utils.py index 0b3538e995c7..515bf4637e87 100644 --- a/tests/sentry/seer/code_review/test_utils.py +++ b/tests/sentry/seer/code_review/test_utils.py @@ -491,7 +491,8 @@ def setUp(self) -> None: name="test-owner/test-repo", provider="integrations:github", ) - self.repo.integration_id = 99999 + self.integration_id = 99999 + self.repo.integration_id = self.integration_id self.repo.save() def test_extract_from_pull_request_event(self) -> None: @@ -501,7 +502,7 @@ def test_extract_from_pull_request_event(self) -> None: github_event="pull_request", organization_id=self.organization.id, organization_slug=self.organization.slug, - integration_id=self.repo.integration_id, + integration_id=self.integration_id, ) assert result["scm_owner"] == "baxterthehacker" @@ -520,7 +521,7 @@ def test_extract_from_check_run_event(self) -> None: github_event="check_run", organization_id=self.organization.id, organization_slug=self.organization.slug, - integration_id=self.repo.integration_id, + integration_id=self.integration_id, ) assert result["scm_owner"] == "getsentry" @@ -539,7 +540,7 @@ def test_extract_from_check_run_completed_event(self) -> None: github_event="check_run", organization_id=self.organization.id, organization_slug=self.organization.slug, - integration_id=self.repo.integration_id, + integration_id=self.integration_id, ) assert result["scm_owner"] == "getsentry" @@ -579,7 +580,7 @@ def test_extract_from_issue_comment_event(self) -> None: github_event="issue_comment", organization_id=self.organization.id, organization_slug=self.organization.slug, - integration_id=self.repo.integration_id, + integration_id=self.integration_id, ) assert result["scm_owner"] == "comment-owner" @@ -605,7 +606,7 @@ def test_comment_url_takes_precedence_over_pr_url(self) -> None: github_event="issue_comment", organization_id=self.organization.id, organization_slug=self.organization.slug, - integration_id=self.repo.integration_id, + integration_id=self.integration_id, ) assert result["scm_event_url"] == "https://github.com/owner/repo/pull/1#issuecomment-999" @@ -622,7 +623,7 @@ def test_check_run_url_takes_precedence_over_pr_url(self) -> None: github_event="check_run", organization_id=self.organization.id, organization_slug=self.organization.slug, - integration_id=self.repo.integration_id, + integration_id=self.integration_id, ) assert result["scm_event_url"] == "https://github.com/owner/repo/runs/123" @@ -638,7 +639,7 @@ def test_issue_pr_fallback_for_event_url(self) -> None: github_event="pull_request", organization_id=self.organization.id, organization_slug=self.organization.slug, - integration_id=self.repo.integration_id, + integration_id=self.integration_id, ) assert result["scm_event_url"] == "https://github.com/owner/repo/pull/5" @@ -655,7 +656,7 @@ def test_issue_pr_does_not_override_existing_event_url(self) -> None: github_event="check_run", organization_id=self.organization.id, organization_slug=self.organization.slug, - integration_id=self.repo.integration_id, + integration_id=self.integration_id, ) assert result["scm_event_url"] == "https://github.com/owner/repo/runs/999" @@ -667,7 +668,7 @@ def test_empty_event_returns_no_none_values(self) -> None: github_event="pull_request", organization_id=self.organization.id, organization_slug=self.organization.slug, - integration_id=self.repo.integration_id, + integration_id=self.integration_id, ) # Event-derived keys are absent when event is empty @@ -691,7 +692,7 @@ def test_missing_repository_owner_omits_owner_keys(self) -> None: github_event="pull_request", organization_id=self.organization.id, organization_slug=self.organization.slug, - integration_id=self.repo.integration_id, + integration_id=self.integration_id, ) assert "scm_owner" not in result @@ -713,7 +714,7 @@ def test_missing_html_urls_omits_event_url(self) -> None: github_event="pull_request", organization_id=self.organization.id, organization_slug=self.organization.slug, - integration_id=self.repo.integration_id, + integration_id=self.integration_id, ) assert result["scm_owner"] == "test-owner" @@ -731,7 +732,7 @@ def test_issue_without_pull_request_link_returns_comment_url(self) -> None: github_event="issue_comment", organization_id=self.organization.id, organization_slug=self.organization.slug, - integration_id=self.repo.integration_id, + integration_id=self.integration_id, ) assert result["scm_event_url"] == "https://github.com/owner/repo/issues/42#comment" @@ -750,7 +751,7 @@ def test_repository_with_full_name(self) -> None: github_event="pull_request", organization_id=self.organization.id, organization_slug=self.organization.slug, - integration_id=self.repo.integration_id, + integration_id=self.integration_id, ) assert result["scm_owner"] == "my-org" @@ -769,7 +770,7 @@ def test_repository_without_full_name(self) -> None: github_event="pull_request", organization_id=self.organization.id, organization_slug=self.organization.slug, - integration_id=self.repo.integration_id, + integration_id=self.integration_id, ) assert result["scm_owner"] == "solo-owner" diff --git a/tests/sentry/seer/code_review/webhooks/test_merge_request.py b/tests/sentry/seer/code_review/webhooks/test_merge_request.py index 8988cd37ab0d..ea9e9d85ba42 100644 --- a/tests/sentry/seer/code_review/webhooks/test_merge_request.py +++ b/tests/sentry/seer/code_review/webhooks/test_merge_request.py @@ -13,6 +13,7 @@ MERGE_REQUEST_OPENED_EVENT, GitLabTestCase, ) +from sentry.integrations.services.integration.serial import serialize_integration from sentry.models.organization import Organization from sentry.models.repositorysettings import CodeReviewTrigger from sentry.organizations.services.organization.model import RpcOrganization @@ -29,6 +30,8 @@ def _make_event(action: str = "open", **overrides: object) -> dict[str, Any]: event = orjson.loads(MERGE_REQUEST_OPENED_EVENT) + if not isinstance(event, dict): + raise TypeError("Merge request event is not a JSON object") event["object_attributes"]["action"] = action # GitLab sends "changes" as a top-level payload field; everything else here # (oldrev, draft, work_in_progress, last_commit, ...) lives in object_attributes. @@ -152,7 +155,7 @@ def _call_handler(self, event: dict[str, Any]) -> None: event=event, organization=_rpc_org(self.organization), repo=self.repo, - integration=self.integration, + integration=serialize_integration(self.integration), ) @@ -719,6 +722,8 @@ def test_distinct_commits_are_not_deduped(self) -> None: def _make_note_event(**overrides: object) -> dict[str, Any]: """Build a mutable copy of MERGE_REQUEST_NOTE_EVENT with optional overrides.""" event = orjson.loads(MERGE_REQUEST_NOTE_EVENT) + if not isinstance(event, dict): + raise TypeError("Merge request event is not a JSON object") for key, value in overrides.items(): event["object_attributes"][key] = value return event @@ -778,7 +783,7 @@ def _call_handler(self, event: dict[str, Any]) -> None: name=self.organization.name, ), repo=self.repo, - integration=self.integration, + integration=serialize_integration(self.integration), ) @with_feature("organizations:code-review-beta") diff --git a/tests/sentry/seer/code_review/webhooks/test_seat_tracking.py b/tests/sentry/seer/code_review/webhooks/test_seat_tracking.py index 5e7b0add88d2..ed30c8a0d03c 100644 --- a/tests/sentry/seer/code_review/webhooks/test_seat_tracking.py +++ b/tests/sentry/seer/code_review/webhooks/test_seat_tracking.py @@ -5,6 +5,7 @@ from fixtures.gitlab import MERGE_REQUEST_OPENED_EVENT, GitLabTestCase from sentry.integrations.gitlab.webhooks import MergeEventWebhook +from sentry.integrations.services.integration.serial import serialize_integration from sentry.models.organization import Organization from sentry.organizations.services.organization.model import RpcOrganization from sentry.seer.code_review.webhooks.merge_request import handle_merge_request_event @@ -30,6 +31,8 @@ def test_processor_runs_before_code_review_handler() -> None: def _make_event(action: str = "open", **overrides: object) -> dict[str, Any]: event = orjson.loads(MERGE_REQUEST_OPENED_EVENT) + if not isinstance(event, dict): + raise TypeError("Merge request event is not a JSON object") event["object_attributes"]["action"] = action for key, value in overrides.items(): event["object_attributes"][key] = value @@ -51,7 +54,7 @@ def _call(self, event: dict[str, Any] | None = None) -> None: event=event if event is not None else _make_event(), organization=self.rpc_organization, repo=self.repo, - integration=self.integration, + integration=serialize_integration(self.integration), ) @patch("sentry.seer.code_review.webhooks.seat_tracking.track_contributor_seat") @@ -192,7 +195,7 @@ def test_success(self, mock_record: Any) -> None: event=_make_event(), organization=self.rpc_organization, repo=self.repo, - integration=self.integration, + integration=serialize_integration(self.integration), ) mock_record.assert_called_once() @@ -213,7 +216,7 @@ def test_is_opened_false_for_non_open_action(self, mock_record: Any) -> None: event=_make_event(action="update"), organization=self.rpc_organization, repo=self.repo, - integration=self.integration, + integration=serialize_integration(self.integration), ) assert mock_record.call_args.kwargs["is_opened"] is False @@ -235,7 +238,7 @@ def test_missing_author_id(self, mock_record: Any) -> None: event=event, organization=self.rpc_organization, repo=self.repo, - integration=self.integration, + integration=serialize_integration(self.integration), ) mock_record.assert_not_called() @@ -247,7 +250,7 @@ def test_missing_iid(self, mock_record: Any) -> None: event=event, organization=self.rpc_organization, repo=self.repo, - integration=self.integration, + integration=serialize_integration(self.integration), ) mock_record.assert_not_called() @@ -261,7 +264,7 @@ def test_no_call_when_actor_is_not_author(self, mock_record: Any) -> None: event=event, organization=self.rpc_organization, repo=self.repo, - integration=self.integration, + integration=serialize_integration(self.integration), ) mock_record.assert_not_called() @@ -273,6 +276,6 @@ def test_missing_actor_id(self, mock_record: Any) -> None: event=event, organization=self.rpc_organization, repo=self.repo, - integration=self.integration, + integration=serialize_integration(self.integration), ) mock_record.assert_not_called() diff --git a/tests/sentry/seer/endpoints/test_admin_agentic_triage_trigger.py b/tests/sentry/seer/endpoints/test_admin_agentic_triage_trigger.py index 626e4def9392..34045c55ac19 100644 --- a/tests/sentry/seer/endpoints/test_admin_agentic_triage_trigger.py +++ b/tests/sentry/seer/endpoints/test_admin_agentic_triage_trigger.py @@ -1,5 +1,7 @@ from unittest.mock import patch +from rest_framework.response import Response + from sentry.testutils.cases import APITestCase @@ -13,7 +15,7 @@ def setUp(self) -> None: self.organization = self.create_organization(owner=self.user) self.login_as(user=self.user) - def get_response(self, *args, **params): + def get_response(self, *args: object, **params: object) -> Response: with patch("sentry.api.permissions.is_active_staff", return_value=True): return super().get_response(*args, **params) diff --git a/tests/sentry/seer/endpoints/test_admin_autofix_retry.py b/tests/sentry/seer/endpoints/test_admin_autofix_retry.py index f3014c4475b0..5d3fc7abc0b6 100644 --- a/tests/sentry/seer/endpoints/test_admin_autofix_retry.py +++ b/tests/sentry/seer/endpoints/test_admin_autofix_retry.py @@ -1,6 +1,9 @@ from typing import Any from unittest.mock import MagicMock, patch +from rest_framework.response import Response + +from sentry.models.organization import Organization from sentry.seer.agent.client_models import MemoryBlock, Message, RepoPRState, SeerRunState from sentry.seer.autofix.constants import AutofixReferrer from sentry.seer.autofix.feature.models import FEATURE_ID @@ -53,7 +56,7 @@ def setUp(self) -> None: self.project = self.create_project(organization=self.organization) self.group = self.create_group(project=self.project) - def get_response(self, *args, **params): + def get_response(self, *args: object, **params: object) -> Response: with patch("sentry.api.permissions.is_active_staff", return_value=True): return super().get_response(*args, **params) @@ -120,7 +123,7 @@ def test_reports_per_run_results(self, mock_fetch: MagicMock, mock_trigger: Magi ), } - def fetch(run_id, organization): + def fetch(run_id: int, organization: Organization) -> SeerRunState: if run_id == 7: raise Exception("seer down") return states[run_id] diff --git a/tests/sentry/seer/endpoints/test_group_ai_autofix.py b/tests/sentry/seer/endpoints/test_group_ai_autofix.py index 4bbc652c9e0f..4a23f5da698a 100644 --- a/tests/sentry/seer/endpoints/test_group_ai_autofix.py +++ b/tests/sentry/seer/endpoints/test_group_ai_autofix.py @@ -1,6 +1,7 @@ import re import uuid -from unittest.mock import ANY, Mock, call, patch +from typing import Any +from unittest.mock import ANY, MagicMock, Mock, call, patch from django.test import override_settings @@ -10,6 +11,7 @@ from sentry.issues.action_log import SYSTEM_ACTOR, ActionSource, action_context_scope from sentry.issues.action_log.types import GroupActionActor, TriggerAutofixAction from sentry.models.activity import Activity +from sentry.models.group import Group from sentry.seer.agent.client_models import ( Artifact, CodingAgentState, @@ -33,6 +35,7 @@ from sentry.seer.autofix.steps import AutofixStep from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.models import SeerPermissionError +from sentry.seer.models.run import SeerRun from sentry.testutils.cases import APITestCase, SnubaTestCase from sentry.testutils.helpers.action_log import capture_action_log from sentry.testutils.helpers.features import with_feature @@ -54,7 +57,8 @@ AGENT_TOKEN_FLAG = "organizations:seer-agent-token-flow" -def _user_context_length_calls(mock_distribution: Mock) -> list: +# justification: mock call records are dynamically typed. +def _user_context_length_calls(mock_distribution: Mock) -> list[Any]: return [ c for c in mock_distribution.call_args_list @@ -90,7 +94,7 @@ def setUp(self) -> None: self.addCleanup(self._repos_patcher.stop) @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_get_returns_state(self, mock_get_explorer_state): + def test_get_returns_state(self, mock_get_explorer_state: MagicMock) -> None: group = self.create_group() mock_get_explorer_state.return_value = None @@ -101,7 +105,9 @@ def test_get_returns_state(self, mock_get_explorer_state): mock_get_explorer_state.assert_called_once_with(group.organization, group.id) @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_get_forbidden_when_ai_features_hidden(self, mock_get_explorer_state): + def test_get_forbidden_when_ai_features_hidden( + self, mock_get_explorer_state: MagicMock + ) -> None: group = self.create_group() self.organization.update_option("sentry:hide_ai_features", True) @@ -113,7 +119,7 @@ def test_get_forbidden_when_ai_features_hidden(self, mock_get_explorer_state): mock_get_explorer_state.assert_not_called() @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_get_includes_sentry_run_id(self, mock_get_explorer_state): + def test_get_includes_sentry_run_id(self, mock_get_explorer_state: MagicMock) -> None: group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=888) mock_get_explorer_state.return_value = SeerRunState( @@ -132,7 +138,9 @@ def test_get_includes_sentry_run_id(self, mock_get_explorer_state): assert response.data["autofix"]["failure_reason"] is None @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_get_returns_failure_reason_when_run_errors(self, mock_get_explorer_state): + def test_get_returns_failure_reason_when_run_errors( + self, mock_get_explorer_state: MagicMock + ) -> None: group = self.create_group() mock_get_explorer_state.return_value = SeerRunState( run_id=888, @@ -149,7 +157,7 @@ def test_get_returns_failure_reason_when_run_errors(self, mock_get_explorer_stat assert response.data["autofix"]["failure_reason"] == "timeout" @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_get_reports_pr_iteration_paused(self, mock_get_explorer_state): + def test_get_reports_pr_iteration_paused(self, mock_get_explorer_state: MagicMock) -> None: group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=888) mock_get_explorer_state.return_value = SeerRunState( @@ -177,7 +185,9 @@ def test_get_reports_pr_iteration_paused(self, mock_get_explorer_state): assert response.data["autofix"]["pr_iteration_pause_reason"] == "user_stop" @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_get_reports_an_errored_pause_apart_from_a_user_stop(self, mock_get_explorer_state): + def test_get_reports_an_errored_pause_apart_from_a_user_stop( + self, mock_get_explorer_state: MagicMock + ) -> None: group = self.create_group() self.create_seer_run(organization=self.organization, seer_run_state_id=888) mock_get_explorer_state.return_value = SeerRunState( @@ -198,7 +208,9 @@ def test_get_reports_an_errored_pause_apart_from_a_user_stop(self, mock_get_expl assert response.data["autofix"]["pr_iteration_pause_reason"] == "run_errored" @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_get_reads_a_reasonless_marker_as_a_user_stop(self, mock_get_explorer_state): + def test_get_reads_a_reasonless_marker_as_a_user_stop( + self, mock_get_explorer_state: MagicMock + ) -> None: """Markers written before the reason existed came from `@sentry` stop.""" group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=888) @@ -218,7 +230,9 @@ def test_get_reads_a_reasonless_marker_as_a_user_stop(self, mock_get_explorer_st assert response.data["autofix"]["pr_iteration_pause_reason"] == "user_stop" @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_get_reports_pr_iteration_not_paused_without_mirror_row(self, mock_get_explorer_state): + def test_get_reports_pr_iteration_not_paused_without_mirror_row( + self, mock_get_explorer_state: MagicMock + ) -> None: """Legacy runs predating SeerRun mirroring have no row to hold the marker.""" group = self.create_group() mock_get_explorer_state.return_value = SeerRunState( @@ -236,7 +250,7 @@ def test_get_reports_pr_iteration_not_paused_without_mirror_row(self, mock_get_e assert response.data["autofix"]["pr_iteration_paused"] is False @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_get_llm_format_adds_formatted_field(self, mock_get_explorer_state): + def test_get_llm_format_adds_formatted_field(self, mock_get_explorer_state: MagicMock) -> None: group = self.create_group() mock_get_explorer_state.return_value = SeerRunState( run_id=888, @@ -284,7 +298,7 @@ def test_get_llm_format_adds_formatted_field(self, mock_get_explorer_state): assert "## Solution" in content @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_get_handles_block_with_null_metadata(self, mock_get_explorer_state): + def test_get_handles_block_with_null_metadata(self, mock_get_explorer_state: MagicMock) -> None: group = self.create_group() mock_get_explorer_state.return_value = SeerRunState( run_id=888, @@ -306,7 +320,9 @@ def test_get_handles_block_with_null_metadata(self, mock_get_explorer_state): assert response.data["autofix"]["blocks"][0]["message"]["metadata"] is None @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_get_passes_through_merged_patch_code_url(self, mock_get_explorer_state): + def test_get_passes_through_merged_patch_code_url( + self, mock_get_explorer_state: MagicMock + ) -> None: group = self.create_group() code_url = "https://github.com/owner/repo/blob/9f2c4e1a/src/app.py" mock_get_explorer_state.return_value = SeerRunState.parse_obj( @@ -347,8 +363,8 @@ def test_get_passes_through_merged_patch_code_url(self, mock_get_explorer_state) @patch("sentry.seer.autofix.pr_iteration.ui_state.get_blocked_pr_iteration_permissions") @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") def test_get_no_warnings_when_no_missing_permissions( - self, mock_get_explorer_state, mock_get_perms - ): + self, mock_get_explorer_state: MagicMock, mock_get_perms: MagicMock + ) -> None: group = self.create_group() mock_get_explorer_state.return_value = SeerRunState( run_id=888, @@ -367,7 +383,9 @@ def test_get_no_warnings_when_no_missing_permissions( @patch("sentry.seer.autofix.pr_iteration.ui_state.get_blocked_pr_iteration_permissions") @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_get_returns_github_permission_warnings(self, mock_get_explorer_state, mock_get_perms): + def test_get_returns_github_permission_warnings( + self, mock_get_explorer_state: MagicMock, mock_get_perms: MagicMock + ) -> None: group = self.create_group() mock_get_explorer_state.return_value = SeerRunState( run_id=888, @@ -407,8 +425,8 @@ def test_get_returns_github_permission_warnings(self, mock_get_explorer_state, m @patch("sentry.seer.autofix.pr_iteration.ui_state.peek_queued_autofix_feedback") @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") def test_actionable_feedback_reads_the_consume_decision( - self, mock_get_explorer_state, mock_peek, mock_get_perms - ): + self, mock_get_explorer_state: MagicMock, mock_peek: MagicMock, mock_get_perms: MagicMock + ) -> None: """``should_consume`` returns a Decision, which is always truthy; only ``.ok`` says whether the feedback would actually have been used.""" group = self.create_group() @@ -442,7 +460,7 @@ def test_actionable_feedback_reads_the_consume_decision( assert mock_get_perms.call_args.kwargs["has_actionable_feedback"] is ok @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_triggers_autofix_agent(self, mock_trigger_explorer): + def test_post_triggers_autofix_agent(self, mock_trigger_explorer: MagicMock) -> None: group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=123) mock_trigger_explorer.return_value = run @@ -466,12 +484,17 @@ def _mint_agent_token(self) -> str: format="json", ) assert response.status_code == 200, response.content - return response.data["token"] + token = response.data["token"] + if not isinstance(token, str): + raise TypeError("Agent token endpoint returned no token") + return token @override_settings(SEER_API_SHARED_SECRET=AGENT_TOKEN_SECRET) @with_feature(AGENT_TOKEN_FLAG) @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_agent_token_runs_analysis_without_approval(self, mock_trigger_explorer): + def test_post_agent_token_runs_analysis_without_approval( + self, mock_trigger_explorer: MagicMock + ) -> None: group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=123) mock_trigger_explorer.return_value = run @@ -490,7 +513,7 @@ def test_post_agent_token_runs_analysis_without_approval(self, mock_trigger_expl @override_settings(SEER_API_SHARED_SECRET=AGENT_TOKEN_SECRET) @with_feature(AGENT_TOKEN_FLAG) @patch("sentry.seer.endpoints.group_ai_autofix.trigger_push_changes") - def test_post_agent_token_open_pr_requires_approval(self, mock_push_changes): + def test_post_agent_token_open_pr_requires_approval(self, mock_push_changes: MagicMock) -> None: group = self.create_group() token = self._mint_agent_token() @@ -510,8 +533,8 @@ def test_post_agent_token_open_pr_requires_approval(self, mock_push_changes): @patch("sentry.seer.endpoints.group_ai_autofix.trigger_push_changes") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_seer_write_token_runs_analysis_but_cannot_open_pr( - self, mock_trigger_explorer, mock_push_changes - ): + self, mock_trigger_explorer: MagicMock, mock_push_changes: MagicMock + ) -> None: group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=123) mock_trigger_explorer.return_value = run @@ -554,8 +577,8 @@ def _run_state(self, run_id: int, stopping_point: str | None) -> SeerRunState: @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_seer_write_token_handoff_stopping_point_requires_event_write( - self, mock_trigger_explorer - ): + self, mock_trigger_explorer: MagicMock + ) -> None: # Root cause headed past itself can hand off to an external coding agent on completion. group = self.create_group() bearer = self._seer_write_bearer() @@ -578,8 +601,8 @@ def test_post_seer_write_token_handoff_stopping_point_requires_event_write( @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_seer_write_token_continuing_run_gates_on_stored_stopping_point( - self, mock_trigger_explorer, mock_run_state - ): + self, mock_trigger_explorer: MagicMock, mock_run_state: MagicMock + ) -> None: # The request names no stopping point, but the run keeps the one it started with. group = self.create_group() self.create_seer_run(organization=self.organization, seer_run_state_id=123) @@ -607,8 +630,8 @@ def test_post_seer_write_token_continuing_run_gates_on_stored_stopping_point( @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_seer_write_token_continues_analysis_without_event_write( - self, mock_trigger_explorer, mock_run_state - ): + self, mock_trigger_explorer: MagicMock, mock_run_state: MagicMock + ) -> None: # Steps past root cause on a run that stops at code changes never leave Sentry. group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=123) @@ -628,7 +651,7 @@ def test_post_seer_write_token_continues_analysis_without_event_write( assert mock_trigger_explorer.call_count == 2 @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_forbidden_when_ai_features_hidden(self, mock_trigger_explorer): + def test_post_forbidden_when_ai_features_hidden(self, mock_trigger_explorer: MagicMock) -> None: group = self.create_group() self.organization.update_option("sentry:hide_ai_features", True) @@ -646,8 +669,8 @@ def test_post_forbidden_when_ai_features_hidden(self, mock_trigger_explorer): @patch("sentry.seer.endpoints.group_ai_autofix.metrics.distribution") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_user_context_at_limit_is_accepted_and_measured( - self, mock_trigger_explorer, mock_distribution - ): + self, mock_trigger_explorer: MagicMock, mock_distribution: MagicMock + ) -> None: group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=123) mock_trigger_explorer.return_value = run @@ -672,8 +695,8 @@ def test_post_user_context_at_limit_is_accepted_and_measured( @patch("sentry.seer.endpoints.group_ai_autofix.metrics.distribution") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_user_context_over_limit_is_rejected_and_measured( - self, mock_trigger_explorer, mock_distribution - ): + self, mock_trigger_explorer: MagicMock, mock_distribution: MagicMock + ) -> None: group = self.create_group() self.login_as(user=self.user) @@ -696,8 +719,8 @@ def test_post_user_context_over_limit_is_rejected_and_measured( @patch("sentry.seer.endpoints.group_ai_autofix.metrics.distribution") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_without_user_context_records_no_length( - self, mock_trigger_explorer, mock_distribution - ): + self, mock_trigger_explorer: MagicMock, mock_distribution: MagicMock + ) -> None: group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=123) mock_trigger_explorer.return_value = run @@ -713,7 +736,7 @@ def test_post_without_user_context_records_no_length( assert _user_context_length_calls(mock_distribution) == [] @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_kickoff_requires_scm_integration(self, mock_trigger_explorer): + def test_post_kickoff_requires_scm_integration(self, mock_trigger_explorer: MagicMock) -> None: group = self.create_group() self.mock_has_scm.return_value = False @@ -729,7 +752,7 @@ def test_post_kickoff_requires_scm_integration(self, mock_trigger_explorer): mock_trigger_explorer.assert_not_called() @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_kickoff_requires_project_repos(self, mock_trigger_explorer): + def test_post_kickoff_requires_project_repos(self, mock_trigger_explorer: MagicMock) -> None: group = self.create_group() self.mock_has_repos.return_value = False @@ -745,7 +768,9 @@ def test_post_kickoff_requires_project_repos(self, mock_trigger_explorer): mock_trigger_explorer.assert_not_called() @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_kickoff_legacy_seer_org_allowed_without_setup(self, mock_trigger_explorer): + def test_post_kickoff_legacy_seer_org_allowed_without_setup( + self, mock_trigger_explorer: MagicMock + ) -> None: """Legacy seer-added orgs skip the setup gate, mirroring AutofixContent.""" group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=777) @@ -767,7 +792,7 @@ def test_post_kickoff_legacy_seer_org_allowed_without_setup(self, mock_trigger_e mock_trigger_explorer.assert_called_once() @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_kickoff_returns_sentry_run_id(self, mock_trigger_explorer): + def test_post_kickoff_returns_sentry_run_id(self, mock_trigger_explorer: MagicMock) -> None: group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=777) mock_trigger_explorer.return_value = run @@ -783,8 +808,8 @@ def test_post_kickoff_returns_sentry_run_id(self, mock_trigger_explorer): @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_continue_with_sentry_run_id_resolves_to_numeric_id( - self, mock_trigger_explorer, mock_run_state - ): + self, mock_trigger_explorer: MagicMock, mock_run_state: MagicMock + ) -> None: group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=555) mock_trigger_explorer.return_value = run @@ -810,8 +835,8 @@ def test_post_continue_with_sentry_run_id_resolves_to_numeric_id( @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_continue_with_numeric_run_id_still_works( - self, mock_trigger_explorer, mock_run_state - ): + self, mock_trigger_explorer: MagicMock, mock_run_state: MagicMock + ) -> None: """The legacy numeric run_id field keeps working unchanged.""" group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=321) @@ -838,8 +863,8 @@ def test_post_continue_with_numeric_run_id_still_works( @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_continue_while_processing_returns_409_with_the_run_in_flight( - self, mock_trigger_explorer, mock_run_state - ): + self, mock_trigger_explorer: MagicMock, mock_run_state: MagicMock + ) -> None: """A step posted while one is still running is refused, and names the live run. Even a truncating re-run stays out: it would delete the blocks the live @@ -870,7 +895,9 @@ def test_post_continue_while_processing_returns_409_with_the_run_in_flight( @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_kickoff_does_not_look_up_a_run(self, mock_trigger_explorer, mock_run_state): + def test_post_kickoff_does_not_look_up_a_run( + self, mock_trigger_explorer: MagicMock, mock_run_state: MagicMock + ) -> None: """Without a run id there is nothing to be in flight, so no lookup is made.""" group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=777) @@ -886,7 +913,9 @@ def test_post_kickoff_does_not_look_up_a_run(self, mock_trigger_explorer, mock_r mock_trigger_explorer.assert_called_once() @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_continue_with_unknown_sentry_run_id_returns_404(self, mock_trigger_explorer): + def test_post_continue_with_unknown_sentry_run_id_returns_404( + self, mock_trigger_explorer: MagicMock + ) -> None: group = self.create_group() self.login_as(user=self.user) @@ -900,7 +929,9 @@ def test_post_continue_with_unknown_sentry_run_id_returns_404(self, mock_trigger mock_trigger_explorer.assert_not_called() @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_continue_with_garbage_sentry_run_id_returns_400(self, mock_trigger_explorer): + def test_post_continue_with_garbage_sentry_run_id_returns_400( + self, mock_trigger_explorer: MagicMock + ) -> None: group = self.create_group() self.login_as(user=self.user) @@ -914,7 +945,7 @@ def test_post_continue_with_garbage_sentry_run_id_returns_400(self, mock_trigger mock_trigger_explorer.assert_not_called() @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_from_mcp_defaults_referrer_to_mcp(self, mock_trigger_explorer): + def test_post_from_mcp_defaults_referrer_to_mcp(self, mock_trigger_explorer: MagicMock) -> None: """A request from the Sentry MCP server defaults the referrer to api.mcp.""" group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=123) @@ -935,7 +966,9 @@ def test_post_from_mcp_defaults_referrer_to_mcp(self, mock_trigger_explorer): assert mock_trigger_explorer.call_args.kwargs["referrer"] == AutofixReferrer.MCP @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_explicit_referrer_overrides_mcp_default(self, mock_trigger_explorer): + def test_post_explicit_referrer_overrides_mcp_default( + self, mock_trigger_explorer: MagicMock + ) -> None: """An explicitly supplied referrer takes precedence over the MCP default.""" group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=123) @@ -953,7 +986,9 @@ def test_post_explicit_referrer_overrides_mcp_default(self, mock_trigger_explore assert mock_trigger_explorer.call_args.kwargs["referrer"] == AutofixReferrer.WEB @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_seer_explorer_referrer_is_recognized(self, mock_trigger_explorer): + def test_post_seer_explorer_referrer_is_recognized( + self, mock_trigger_explorer: MagicMock + ) -> None: """Seer Explorer's autofix lib sends this literal, so it must not degrade to UNKNOWN.""" group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=123) @@ -970,7 +1005,7 @@ def test_post_seer_explorer_referrer_is_recognized(self, mock_trigger_explorer): assert mock_trigger_explorer.call_args.kwargs["referrer"] == AutofixReferrer.SEER_EXPLORER @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_stopping_point(self, mock_trigger_explorer): + def test_stopping_point(self, mock_trigger_explorer: MagicMock) -> None: """Stopping point forces the step to be root_cause""" group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=123) @@ -1000,7 +1035,9 @@ def test_stopping_point(self, mock_trigger_explorer): @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_insert_index_passed_through(self, mock_trigger_explorer, mock_run_state): + def test_insert_index_passed_through( + self, mock_trigger_explorer: MagicMock, mock_run_state: MagicMock + ) -> None: """POST passes insert_index to trigger_autofix_agent for retry-from-step.""" group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=123) @@ -1036,7 +1073,9 @@ def test_insert_index_passed_through(self, mock_trigger_explorer, mock_run_state @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") - def test_insert_index_rejected_when_pr_exists(self, mock_run_state, mock_trigger_explorer): + def test_insert_index_rejected_when_pr_exists( + self, mock_run_state: MagicMock, mock_trigger_explorer: MagicMock + ) -> None: """A re-run is refused once the run has opened a PR.""" group = self.create_group() mock_run_state.return_value = SeerRunState( @@ -1060,8 +1099,8 @@ def test_insert_index_rejected_when_pr_exists(self, mock_run_state, mock_trigger @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") def test_insert_index_allowed_when_pr_creation_failed( - self, mock_run_state, mock_trigger_explorer - ): + self, mock_run_state: MagicMock, mock_trigger_explorer: MagicMock + ) -> None: """A push that failed stranded no PR, so the re-run is allowed.""" group = self.create_group() mock_trigger_explorer.return_value = self.create_seer_run( @@ -1094,8 +1133,8 @@ def test_insert_index_allowed_when_pr_creation_failed( @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") def test_insert_index_rejected_when_push_failed_onto_open_pr( - self, mock_run_state, mock_trigger_explorer - ): + self, mock_run_state: MagicMock, mock_trigger_explorer: MagicMock + ) -> None: """A push onto an already-open PR can fail, and that PR is still stranded.""" group = self.create_group() mock_run_state.return_value = SeerRunState( @@ -1125,7 +1164,9 @@ def test_insert_index_rejected_when_push_failed_onto_open_pr( @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") - def test_insert_index_unknown_run_returns_404(self, mock_run_state, mock_trigger_explorer): + def test_insert_index_unknown_run_returns_404( + self, mock_run_state: MagicMock, mock_trigger_explorer: MagicMock + ) -> None: """The run-state lookup surfaces an unknown run as 404, not 403.""" group = self.create_group() mock_run_state.side_effect = SeerPermissionError("Unknown run id for group") @@ -1142,7 +1183,9 @@ def test_insert_index_unknown_run_returns_404(self, mock_run_state, mock_trigger @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient.get_run") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_rejects_run_from_another_project(self, mock_trigger, mock_get_run): + def test_post_rejects_run_from_another_project( + self, mock_trigger: MagicMock, mock_get_run: MagicMock + ) -> None: """A member keeps an issue they can access and swaps run_id for another project's run.""" group = self.create_group() other_project = self.create_project(organization=self.organization) @@ -1169,8 +1212,8 @@ def test_post_rejects_run_from_another_project(self, mock_trigger, mock_get_run) @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") def test_insert_index_rejected_when_coding_agent_exists( - self, mock_run_state, mock_trigger_explorer - ): + self, mock_run_state: MagicMock, mock_trigger_explorer: MagicMock + ) -> None: """A re-run is refused once the run has handed off to a coding agent.""" group = self.create_group() mock_run_state.return_value = SeerRunState( @@ -1202,7 +1245,7 @@ def test_insert_index_rejected_when_coding_agent_exists( mock_trigger_explorer.assert_not_called() @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_kickoff_emits_trigger_autofix_action(self, mock_trigger): + def test_kickoff_emits_trigger_autofix_action(self, mock_trigger: MagicMock) -> None: # A kickoff (no run_id) records the action. group = self.create_group() mock_trigger.return_value = self.create_seer_run( @@ -1232,11 +1275,11 @@ def test_kickoff_emits_trigger_autofix_action(self, mock_trigger): ] ) @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_kickoff_creates_trigger_autofix_activity(self, mock_trigger): + def test_kickoff_creates_trigger_autofix_activity(self, mock_trigger: MagicMock) -> None: group = self.create_group() run = self.create_seer_run(organization=self.organization, seer_run_state_id=123) - def trigger(*_args, **_kwargs): + def trigger(*_args: object, **_kwargs: object) -> SeerRun: with action_context_scope(ActionSource.SEER_EXPLORER, SYSTEM_ACTOR): self.create_group_activity( group=group, @@ -1274,7 +1317,9 @@ def trigger(*_args, **_kwargs): @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_advancing_existing_run_skips_action(self, mock_trigger, mock_run_state): + def test_advancing_existing_run_skips_action( + self, mock_trigger: MagicMock, mock_run_state: MagicMock + ) -> None: # Advancing an existing run (run_id provided) is steering, not a new trigger. group = self.create_group() mock_trigger.return_value = self.create_seer_run( @@ -1300,7 +1345,7 @@ def test_advancing_existing_run_skips_action(self, mock_trigger, mock_run_state) action_log.assert_not_logged(TriggerAutofixAction, group_id=group.id) @patch("sentry.seer.endpoints.group_ai_autofix.trigger_coding_agent_handoff") - def test_coding_agent_handoff_skips_action(self, mock_handoff): + def test_coding_agent_handoff_skips_action(self, mock_handoff: MagicMock) -> None: # The handoff path returns before the built-in-step branch, so it must not log. mock_handoff.return_value = {"successes": [], "failures": []} group = self.create_group() @@ -1320,7 +1365,13 @@ def test_coding_agent_handoff_skips_action(self, mock_handoff): @patch("sentry.seer.autofix.pr_iteration.manual_trigger.enqueue_autofix_feedback") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") @patch("sentry.seer.autofix.pr_iteration.manual_trigger.get_autofix_run_state") - def test_pr_iteration(self, mock_run_state, mock_trigger_explorer, mock_enqueue, mock_consume): + def test_pr_iteration( + self, + mock_run_state: MagicMock, + mock_trigger_explorer: MagicMock, + mock_enqueue: MagicMock, + mock_consume: MagicMock, + ) -> None: group = self.create_group() self.create_seer_run(organization=self.organization, seer_run_state_id=123) mock_run_state.return_value = SeerRunState( @@ -1351,7 +1402,7 @@ def test_pr_iteration(self, mock_run_state, mock_trigger_explorer, mock_enqueue, assert mock_consume.call_args.kwargs["source"] == ConsumeTriggerSource.UI_CONSUME @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_pr_iteration_requires_run_id(self, mock_trigger_explorer): + def test_pr_iteration_requires_run_id(self, mock_trigger_explorer: MagicMock) -> None: group = self.create_group() self.login_as(user=self.user) @@ -1366,7 +1417,9 @@ def test_pr_iteration_requires_run_id(self, mock_trigger_explorer): @patch("sentry.seer.autofix.pr_iteration.manual_trigger.enqueue_autofix_feedback") @patch("sentry.seer.autofix.pr_iteration.manual_trigger.get_autofix_run_state") - def test_pr_iteration_requires_existing_pr(self, mock_run_state, mock_enqueue): + def test_pr_iteration_requires_existing_pr( + self, mock_run_state: MagicMock, mock_enqueue: MagicMock + ) -> None: group = self.create_group() mock_run_state.return_value = SeerRunState( run_id=123, @@ -1389,7 +1442,9 @@ def test_pr_iteration_requires_existing_pr(self, mock_run_state, mock_enqueue): @patch("sentry.seer.autofix.pr_iteration.manual_trigger.enqueue_autofix_feedback") @patch("sentry.seer.autofix.pr_iteration.manual_trigger.get_autofix_run_state") - def test_pr_iteration_rejected_when_paused(self, mock_run_state, mock_enqueue): + def test_pr_iteration_rejected_when_paused( + self, mock_run_state: MagicMock, mock_enqueue: MagicMock + ) -> None: group = self.create_group() self.create_seer_run( organization=group.organization, seer_run_state_id=123, user_id=self.user.id @@ -1422,8 +1477,8 @@ def test_pr_iteration_rejected_when_paused(self, mock_run_state, mock_enqueue): @patch("sentry.seer.autofix.pr_iteration.manual_trigger.enqueue_autofix_feedback") @patch("sentry.seer.autofix.pr_iteration.manual_trigger.get_autofix_run_state") def test_pr_iteration_allowed_when_push_failed_onto_open_pr( - self, mock_run_state, mock_enqueue, mock_consume - ): + self, mock_run_state: MagicMock, mock_enqueue: MagicMock, mock_consume: MagicMock + ) -> None: """The failed push is the thing to iterate out of, so the PR still counts.""" group = self.create_group() self.create_seer_run(organization=self.organization, seer_run_state_id=123) @@ -1455,7 +1510,9 @@ def test_pr_iteration_allowed_when_push_failed_onto_open_pr( @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_continue_unknown_run_returns_404(self, mock_trigger_explorer, mock_run_state): + def test_post_continue_unknown_run_returns_404( + self, mock_trigger_explorer: MagicMock, mock_run_state: MagicMock + ) -> None: mock_trigger_explorer.side_effect = SeerPermissionError("Unknown run id for group") mock_run_state.return_value = SeerRunState( run_id=123, @@ -1477,7 +1534,7 @@ def test_post_continue_unknown_run_returns_404(self, mock_trigger_explorer, mock mock_trigger_explorer.assert_called_once() @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_post_returns_402_when_no_seer_quota(self, mock_trigger_explorer): + def test_post_returns_402_when_no_seer_quota(self, mock_trigger_explorer: MagicMock) -> None: """POST returns 402 Payment Required when quota check fails.""" mock_trigger_explorer.side_effect = NoSeerQuotaException() group = self.create_group() @@ -1512,7 +1569,9 @@ def test_post_coding_agent_handoff_errors_with_both_provider_and_integration_id( assert response.data["detail"] == "Cannot specify both integration_id and provider" @patch("sentry.seer.endpoints.group_ai_autofix.trigger_coding_agent_handoff") - def test_post_coding_agent_handoff_unknown_run_returns_404(self, mock_handoff): + def test_post_coding_agent_handoff_unknown_run_returns_404( + self, mock_handoff: MagicMock + ) -> None: mock_handoff.side_effect = SeerPermissionError("Unknown run id for group") group = self.create_group() @@ -1532,7 +1591,9 @@ def test_post_coding_agent_handoff_unknown_run_returns_404(self, mock_handoff): assert mock_handoff.call_args.kwargs["referrer"] == AutofixReferrer.GROUP_AUTOFIX_ENDPOINT @patch("sentry.seer.endpoints.group_ai_autofix.trigger_coding_agent_handoff") - def test_post_coding_agent_handoff_auto_creates_pr_by_default(self, mock_handoff): + def test_post_coding_agent_handoff_auto_creates_pr_by_default( + self, mock_handoff: MagicMock + ) -> None: mock_handoff.return_value = {"successes": [{"repo_name": "owner/repo"}], "failures": []} group = self.create_group() @@ -1558,7 +1619,9 @@ def test_post_coding_agent_handoff_auto_creates_pr_by_default(self, mock_handoff auto_create_pr=True, ) - def _mock_open_pr_requests(self, mock_update_request, mock_state_request, group) -> None: + def _mock_open_pr_requests( + self, mock_update_request: MagicMock, mock_state_request: MagicMock, group: Group + ) -> None: mock_update_response = Mock() mock_update_response.status = 200 mock_update_request.return_value = mock_update_response @@ -1582,7 +1645,9 @@ def _mock_open_pr_requests(self, mock_update_request, mock_state_request, group) @patch("sentry.seer.agent.client_utils.make_agent_state_request") @patch("sentry.seer.agent.client.make_agent_update_request") - def test_open_pr(self, mock_explorer_update_request, mock_explorer_state_request): + def test_open_pr( + self, mock_explorer_update_request: MagicMock, mock_explorer_state_request: MagicMock + ) -> None: self.login_as(user=self.user) group = self.create_group() run = self.create_seer_run(organization=group.organization, seer_run_state_id=123) @@ -1612,8 +1677,8 @@ def test_open_pr(self, mock_explorer_update_request, mock_explorer_state_request @patch("sentry.seer.agent.client_utils.make_agent_state_request") @patch("sentry.seer.agent.client.make_agent_update_request") def test_open_pr_with_repo_name_and_commit_author( - self, mock_explorer_update_request, mock_explorer_state_request - ): + self, mock_explorer_update_request: MagicMock, mock_explorer_state_request: MagicMock + ) -> None: self.login_as(user=self.user) group = self.create_group() run = self.create_seer_run(organization=group.organization, seer_run_state_id=123) @@ -1661,7 +1726,7 @@ def test_open_pr_no_run_id(self) -> None: assert response.data["detail"] == "run_id is required for open_pr" @patch("sentry.seer.agent.client_utils.make_agent_state_request") - def test_open_pr_permission_error(self, mock_explorer_state_request): + def test_open_pr_permission_error(self, mock_explorer_state_request: MagicMock) -> None: self.login_as(user=self.user) group = self.create_group() @@ -1690,7 +1755,7 @@ def test_open_pr_permission_error(self, mock_explorer_state_request): assert response.status_code == 404, response.data - def test_open_pr_coding_disabled(self): + def test_open_pr_coding_disabled(self) -> None: self.login_as(user=self.user) group = self.create_group() self.organization.update_option("sentry:enable_seer_coding", False) @@ -1740,7 +1805,7 @@ def _queued_feedback(self) -> QueuedAutofixFeedback: ) @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_get_sets_validator_headers(self, mock_get_explorer_state): + def test_get_sets_validator_headers(self, mock_get_explorer_state: MagicMock) -> None: mock_get_explorer_state.return_value = self._run_state() response = self.client.get(self._get_url(self.group.id), format="json") @@ -1750,7 +1815,7 @@ def test_get_sets_validator_headers(self, mock_get_explorer_state): assert response["Cache-Control"] == "private, no-cache" @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_identical_requests_share_an_etag(self, mock_get_explorer_state): + def test_identical_requests_share_an_etag(self, mock_get_explorer_state: MagicMock) -> None: mock_get_explorer_state.return_value = self._run_state() first = self.client.get(self._get_url(self.group.id), format="json") @@ -1759,7 +1824,9 @@ def test_identical_requests_share_an_etag(self, mock_get_explorer_state): assert first["ETag"] == second["ETag"] @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_matched_validator_returns_304_without_a_body(self, mock_get_explorer_state): + def test_matched_validator_returns_304_without_a_body( + self, mock_get_explorer_state: MagicMock + ) -> None: mock_get_explorer_state.return_value = self._run_state() first = self.client.get(self._get_url(self.group.id), format="json") @@ -1773,7 +1840,9 @@ def test_matched_validator_returns_304_without_a_body(self, mock_get_explorer_st assert response["Cache-Control"] == "private, no-cache" @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_stale_validator_returns_the_full_body(self, mock_get_explorer_state): + def test_stale_validator_returns_the_full_body( + self, mock_get_explorer_state: MagicMock + ) -> None: mock_get_explorer_state.return_value = self._run_state() current = self.client.get(self._get_url(self.group.id), format="json")["ETag"] @@ -1788,7 +1857,9 @@ def test_stale_validator_returns_the_full_body(self, mock_get_explorer_state): @patch("sentry.seer.autofix.pr_iteration.ui_state.peek_queued_autofix_feedback") @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_queued_feedback_changes_the_etag(self, mock_get_explorer_state, mock_peek): + def test_queued_feedback_changes_the_etag( + self, mock_get_explorer_state: MagicMock, mock_peek: MagicMock + ) -> None: mock_get_explorer_state.return_value = self._run_state() mock_peek.return_value = [] empty = self.client.get(self._get_url(self.group.id), format="json") @@ -1800,7 +1871,7 @@ def test_queued_feedback_changes_the_etag(self, mock_get_explorer_state, mock_pe assert queued["ETag"] != empty["ETag"] @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_llm_format_has_its_own_etag(self, mock_get_explorer_state): + def test_llm_format_has_its_own_etag(self, mock_get_explorer_state: MagicMock) -> None: mock_get_explorer_state.return_value = self._run_state() with self.feature("organizations:issue-standardized-markdown-for-llm"): @@ -1814,7 +1885,7 @@ def test_llm_format_has_its_own_etag(self, mock_get_explorer_state): @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") @override_options({"api.conditional_get.enabled": False}) - def test_disabled_option_sets_no_validator(self, mock_get_explorer_state): + def test_disabled_option_sets_no_validator(self, mock_get_explorer_state: MagicMock) -> None: mock_get_explorer_state.return_value = self._run_state() response = self.client.get(self._get_url(self.group.id), format="json") diff --git a/tests/sentry/seer/endpoints/test_group_autofix_setup_check.py b/tests/sentry/seer/endpoints/test_group_autofix_setup_check.py index 4da1b709880e..6eeb3f931e7f 100644 --- a/tests/sentry/seer/endpoints/test_group_autofix_setup_check.py +++ b/tests/sentry/seer/endpoints/test_group_autofix_setup_check.py @@ -284,6 +284,7 @@ def test_missing_integration(self) -> None: # Note: has_project_connected_repos is not called when integration check fails with assume_test_silo_mode(SiloMode.CONTROL): + assert self.organization_integration is not None self.organization_integration.delete() group = self.create_group() diff --git a/tests/sentry/seer/endpoints/test_issue_view_title_generate.py b/tests/sentry/seer/endpoints/test_issue_view_title_generate.py index 715930bf1852..05025c0d5faf 100644 --- a/tests/sentry/seer/endpoints/test_issue_view_title_generate.py +++ b/tests/sentry/seer/endpoints/test_issue_view_title_generate.py @@ -2,6 +2,7 @@ import orjson from django.test import override_settings +from rest_framework.response import Response from urllib3.response import HTTPResponse from sentry.models.apitoken import ApiToken @@ -23,13 +24,14 @@ def _create_token(self, scope: str) -> ApiToken: with assume_test_silo_mode(SiloMode.CONTROL): return ApiToken.objects.create(user=self.user, scope_list=[scope]) - def _post_with_token(self, token: ApiToken, query: str = "is:unresolved"): - return self.client.post( + def _post_with_token(self, token: ApiToken, query: str = "is:unresolved") -> Response: + response: Response = self.client.post( self.url, data={"query": query}, format="json", HTTP_AUTHORIZATION=f"Bearer {token.token}", ) + return response @override_settings(SEER_API_SHARED_SECRET="viewer-context-test-secret") @patch("sentry.seer.signed_seer_api.seer_autofix_default_connection_pool.urlopen") diff --git a/tests/sentry/seer/endpoints/test_organization_agent_approve.py b/tests/sentry/seer/endpoints/test_organization_agent_approve.py index c2ea4e7c7700..d17863f99795 100644 --- a/tests/sentry/seer/endpoints/test_organization_agent_approve.py +++ b/tests/sentry/seer/endpoints/test_organization_agent_approve.py @@ -1,7 +1,12 @@ from __future__ import annotations +from collections.abc import Sequence +from typing import Any + from django.test import override_settings +from rest_framework.response import Response +from sentry.models.organization import Organization from sentry.seer import agent_token from sentry.seer.models.agent_write_grant import SeerAgentWriteGrant from sentry.testutils.cases import APITestCase @@ -19,24 +24,34 @@ def setUp(self) -> None: self.member = self.create_user() self.create_member(user=self.member, organization=self.org, role="member") - def _url(self, organization=None): + def _url(self, organization: Organization | None = None) -> str: return f"/api/0/organizations/{(organization or self.org).slug}/agent/approve/" - def _post(self, *, scopes, session_id="s1", feature_enabled=True, **kwargs): + # justification: forwarded to APIClient.post as extra request options. + def _post( + self, + *, + scopes: Sequence[str], + session_id: str = "s1", + feature_enabled: bool = True, + **kwargs: Any, + ) -> Response: if feature_enabled: with self.feature(agent_token.FEATURE_FLAG): - return self.client.post( + response: Response[Any] = self.client.post( self._url(), data={"sessionId": session_id, "scopes": list(scopes)}, format="json", **kwargs, ) - return self.client.post( + return response + response = self.client.post( self._url(), data={"sessionId": session_id, "scopes": list(scopes)}, format="json", **kwargs, ) + return response # ----- happy path ----- diff --git a/tests/sentry/seer/endpoints/test_organization_agent_token.py b/tests/sentry/seer/endpoints/test_organization_agent_token.py index 49560da78e55..729668f6b37c 100644 --- a/tests/sentry/seer/endpoints/test_organization_agent_token.py +++ b/tests/sentry/seer/endpoints/test_organization_agent_token.py @@ -1,13 +1,13 @@ from __future__ import annotations import re -from collections.abc import Callable +from collections.abc import Callable, Sequence from contextlib import ExitStack, nullcontext from dataclasses import dataclass from datetime import timedelta from enum import StrEnum from types import SimpleNamespace -from typing import Any +from typing import Any, cast from unittest.mock import patch from urllib.parse import urlencode from uuid import uuid4 @@ -35,6 +35,7 @@ from sentry.issues.endpoints.group_tags import GroupTagsEndpoint from sentry.models.custominboundfilter import CustomInboundFilter from sentry.models.eventattachment import EventAttachment +from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember from sentry.organizations.services.organization import organization_service from sentry.replays.lib.storage import FilestoreBlob, RecordingSegmentStorageMeta @@ -78,7 +79,7 @@ def setUp(self) -> None: self.org = self.create_organization() self.path = f"/api/0/organizations/{self.org.slug}/" - def _chat_authorization(self): + def _chat_authorization(self) -> SuperuserAccess: self.login_as(self.employee, superuser=True) resolved_contexts: list[ViewerContext] = [] with ( @@ -106,7 +107,7 @@ def capture_context(*_args: Any, **kwargs: Any) -> Any: assert grant is not None return grant - def _mint(self, grant): + def _mint(self, grant: SuperuserAccess | None) -> Response: viewer = encode_viewer_context( ViewerContext( actor=Actor(type=ActorType.USER, actor_id=self.employee.id, superuser=grant), @@ -114,17 +115,21 @@ def _mint(self, grant): ), key=SECRET, ) - return APIClient().post( + response: Response = APIClient().post( f"/api/0/organizations/{self.org.slug}/agent/token/", {"sessionId": "123"}, format="json", HTTP_X_VIEWER_CONTEXT=viewer, ) + return response - def _read(self, token, path=None): - return APIClient().get(path or self.path, HTTP_AUTHORIZATION=f"Bearer {token}") + def _read(self, token: str, path: str | None = None) -> Response: + response: Response = APIClient().get( + path or self.path, HTTP_AUTHORIZATION=f"Bearer {token}" + ) + return response - def test_chat_to_mint_to_read_without_membership(self): + def test_chat_to_mint_to_read_without_membership(self) -> None: assert not OrganizationMember.objects.filter( organization=self.org, user_id=self.employee.id ).exists() @@ -145,12 +150,12 @@ def test_chat_to_mint_to_read_without_membership(self): assert claims["superuser"] == {"expires_at": grant.expires_at} assert claims["exp"] <= grant.expires_at - def test_identity_alone_does_not_delegate_superuser(self): + def test_identity_alone_does_not_delegate_superuser(self) -> None: self._chat_authorization() with self.feature(FLAG): assert self._mint(None).status_code == 403 - def test_nonmember_superuser_project_indexes_are_org_bound(self): + def test_nonmember_superuser_project_indexes_are_org_bound(self) -> None: assert not OrganizationMember.objects.filter( organization=self.org, user_id=self.employee.id ).exists() @@ -169,7 +174,7 @@ def test_nonmember_superuser_project_indexes_are_org_bound(self): assert {int(item["id"]) for item in global_response.data} == {project.id} assert {int(item["id"]) for item in org_response.data} == {project.id} - def test_elevated_browser_can_also_mint_a_usable_token(self): + def test_elevated_browser_can_also_mint_a_usable_token(self) -> None: self.login_as(self.employee, superuser=True) with self.feature(FLAG): minted = self.client.post( @@ -178,7 +183,7 @@ def test_elevated_browser_can_also_mint_a_usable_token(self): assert minted.status_code == 200, minted.content assert self._read(minted.data["token"]).status_code == 200 - def test_elevated_member_keeps_approved_agent_writes(self): + def test_elevated_member_keeps_approved_agent_writes(self) -> None: self.create_member(user=self.employee, organization=self.org, role="owner") self.create_seer_agent_write_grant( organization=self.org, user=self.employee, session_id="123", scope_list=["org:write"] @@ -196,12 +201,12 @@ def test_elevated_member_keeps_approved_agent_writes(self): client.credentials(HTTP_AUTHORIZATION=f"Bearer {minted.data['token']}") assert client.put(self.path, {}, format="json").status_code == 200 - def test_invalid_expiry_cannot_mint(self): + def test_invalid_expiry_cannot_mint(self) -> None: self._chat_authorization() with self.feature(FLAG): assert self._mint(SuperuserAccess(expires_at=True)).status_code == 401 - def test_expired_elevation_cannot_mint_or_be_used(self): + def test_expired_elevation_cannot_mint_or_be_used(self) -> None: grant = self._chat_authorization() with self.feature(FLAG): minted = self._mint(grant) @@ -210,16 +215,16 @@ def test_expired_elevation_cannot_mint_or_be_used(self): assert self._mint(grant).status_code == 401 assert self._read(minted.data["token"]).status_code == 401 - def test_current_user_privilege_is_rechecked_without_cache_clear(self): + def test_current_user_privilege_is_rechecked_without_cache_clear(self) -> None: self._assert_revoked({"is_superuser": False}) - def test_inactive_user_cannot_use_elevation(self): + def test_inactive_user_cannot_use_elevation(self) -> None: self._assert_revoked({"is_active": False}) - def test_suspended_user_cannot_use_elevation(self): + def test_suspended_user_cannot_use_elevation(self) -> None: self._assert_revoked({"is_suspended": True}) - def _assert_revoked(self, changes): + def _assert_revoked(self, changes: dict[str, object]) -> None: grant = self._chat_authorization() with self.feature(FLAG): minted = self._mint(grant) @@ -229,15 +234,15 @@ def _assert_revoked(self, changes): assert self._mint(grant).status_code == 403 assert self._read(minted.data["token"]).status_code == 401 - def test_delegated_token_cannot_write_remint_or_cross_org(self): + def test_delegated_token_cannot_write_remint_or_cross_org(self) -> None: self._assert_read_only_delegated_token() - def test_writable_browser_cannot_delegate_agent_writes(self): + def test_writable_browser_cannot_delegate_agent_writes(self) -> None: self.add_user_permission(self.employee, "superuser.write") with self.options({"superuser.read-write.ga-rollout": True}): self._assert_read_only_delegated_token() - def _assert_read_only_delegated_token(self): + def _assert_read_only_delegated_token(self) -> None: grant = self._chat_authorization() other_org = self.create_organization() self.create_seer_agent_write_grant( @@ -258,7 +263,7 @@ def _assert_read_only_delegated_token(self): ) assert client.get(f"/api/0/organizations/{other_org.slug}/").status_code == 403 - def test_customer_policy_is_rechecked(self): + def test_customer_policy_is_rechecked(self) -> None: grant = self._chat_authorization() with self.feature(FLAG): minted = self._mint(grant) @@ -268,7 +273,7 @@ def test_customer_policy_is_rechecked(self): assert self._read(minted.data["token"]).status_code == 401 @override_settings(VALIDATE_SUPERUSER_ACCESS_CATEGORY_AND_REASON=True) - def test_delegation_requires_existing_org_approval(self): + def test_delegation_requires_existing_org_approval(self) -> None: with self.settings(VALIDATE_SUPERUSER_ACCESS_CATEGORY_AND_REASON=False): self.login_as(self.employee, superuser=True) request = self.make_request(user=self.employee) @@ -303,7 +308,7 @@ def test_delegation_requires_existing_org_approval(self): assert grant is not None assert self._mint(grant).status_code == 200 - def test_delegation_does_not_renew_idle_expiry(self): + def test_delegation_does_not_renew_idle_expiry(self) -> None: self.login_as(self.employee, superuser=True) request = self.make_request(user=self.employee) request.auth = None @@ -368,6 +373,17 @@ def _declared_scopes(view: type[Any], method: str) -> frozenset[str]: return frozenset(scopes) +def _discover_endpoints( + enumerator: CustomEndpointEnumerator, +) -> list[tuple[str, str, str, Any]]: + # justification: drf-spectacular does not annotate EndpointEnumerator._get_api_endpoints. + discover = cast( + "Callable[[object, str], list[tuple[str, str, str, Any]]]", + enumerator._get_api_endpoints, + ) + return discover(enumerator.patterns, "") + + class _CollectionSafeEndpointEnumerator(CustomEndpointEnumerator): """Inspect declared HTTP methods without constructing every endpoint view.""" @@ -398,7 +414,7 @@ def _public_get_endpoints() -> tuple[PublicGetEndpoint, ...]: """ enumerator = _CollectionSafeEndpointEnumerator() endpoints: dict[tuple[str, str], PublicGetEndpoint] = {} - discovered = enumerator._get_api_endpoints(enumerator.patterns, "") + discovered = _discover_endpoints(enumerator) for path, _path_regex, method, callback in discovered: view = callback.view_class status = view.publish_status.get(method) @@ -439,7 +455,7 @@ def _public_mutation_endpoints() -> tuple[PublicMutationEndpoint, ...]: """Return every canonical public non-GET operation and its declared scopes.""" enumerator = _CollectionSafeEndpointEnumerator() endpoints: dict[tuple[str, str], PublicMutationEndpoint] = {} - discovered = enumerator._get_api_endpoints(enumerator.patterns, "") + discovered = _discover_endpoints(enumerator) for path, _path_regex, method, callback in discovered: view = callback.view_class status = view.publish_status.get(method) @@ -684,10 +700,11 @@ def setUp(self) -> None: self.owner = self.create_user() self.org = self.create_organization(owner=self.owner) - def _mint(self, **data): - return self.client.post( + def _mint(self, **data: object) -> Response: + response: Response = self.client.post( f"/api/0/organizations/{self.org.slug}/agent/token/", data=data, format="json" ) + return response def _assert_user_global_get_denied(self, endpoint: PublicGetEndpoint) -> None: self.login_as(self.owner) @@ -705,7 +722,13 @@ def _assert_user_global_get_denied(self, endpoint: PublicGetEndpoint) -> None: assert agent_response.status_code == 403, agent_response.content assert "insufficient_scope" not in agent_response.get("WWW-Authenticate", "") - def _grant(self, *, organization=None, session_id="s1", scopes=("org:write",)): + def _grant( + self, + *, + organization: Organization | None = None, + session_id: str = "s1", + scopes: Sequence[str] = ("org:write",), + ) -> SeerAgentWriteGrant: return self.create_seer_agent_write_grant( organization=organization or self.org, user=self.owner, @@ -1145,6 +1168,7 @@ def _resource(self, name: str) -> Any: if name in self._matrix_resources: return self._matrix_resources[name] + resource: Any if name == "dashboard": resource = self.create_dashboard(organization=self.org, created_by=self.owner) elif name == "custom_inbound_filter": @@ -1180,6 +1204,7 @@ def _resource(self, name: str) -> Any: data=b"permission matrix attachment", ) attachment_file = EventAttachment.putfile(self.project.id, cached_attachment) + assert cached_attachment.name is not None resource = EventAttachment.objects.create( event_id=event.event_id, project_id=self.project.id, @@ -1500,7 +1525,7 @@ def _path_value( ) return str(detector.id) if placeholder == "environment": - return self._resource("environment").name + return str(self._resource("environment").name) if placeholder == "issue_id": return str(self._resource("group").id) if placeholder == "integration_id": @@ -1522,9 +1547,9 @@ def _path_value( if placeholder == "artifact_id": return str(self._resource("preprod_artifact").id) if placeholder == "key_id": - return self._resource("project_key").public_key + return str(self._resource("project_key").public_key) if placeholder == "version": - return self._resource("release").version + return str(self._resource("release").version) if placeholder == "file_id": return str(self._resource("release_file").id) if placeholder == "repo_id": @@ -1557,13 +1582,13 @@ def _path_value( return str(self._resource("custom_inbound_filter").id) return "browser-extensions" if placeholder == "event_id": - return self._resource("event").event_id + return str(self._resource("event").event_id) if placeholder == "job_id": return str(self._resource("replay_deletion_job").id) if placeholder == "query_id": return str(self._resource("saved_query")["id"]) if placeholder == "replay_id": - return self._resource("replay") + return str(self._resource("replay")) if placeholder == "segment_id": self._resource("replay_recording_segment") return "0" @@ -1768,7 +1793,9 @@ def _mint_agent_token( assert approved_scopes <= set(response.data["scopes"]), response.data if requested_scopes is not None: assert set(response.data["scopes"]) <= requested_scopes, response.data - return response.data["token"] + token = response.data["token"] + assert isinstance(token, str) + return token @staticmethod def _listed_ids(response: Response, *, nested_organization: bool = False) -> set[int]: @@ -2277,14 +2304,16 @@ def _request( user_token_scopes: frozenset[str] | None = None, ) -> Response: if authentication is MatrixAuthentication.SESSION: - return self.client.get(path) + session_response: Response = self.client.get(path) + return session_response client = APIClient() if authentication is MatrixAuthentication.USER_TOKEN: - return client.get( + user_token_response: Response = client.get( path, HTTP_AUTHORIZATION=f"Bearer {self._user_token(user_token_scopes)}", ) + return user_token_response if authentication is MatrixAuthentication.VIEWER_CONTEXT: viewer_context = encode_viewer_context( ViewerContext( @@ -2294,15 +2323,20 @@ def _request( ), key=SECRET, ) - return client.get(path, HTTP_X_VIEWER_CONTEXT=viewer_context) + viewer_response: Response = client.get(path, HTTP_X_VIEWER_CONTEXT=viewer_context) + return viewer_response if authentication is MatrixAuthentication.SERVICE_ACCOUNT_VIEWER_CONTEXT: - return client.get(path, HTTP_X_VIEWER_CONTEXT=self._service_account_context) + service_account_response: Response = client.get( + path, HTTP_X_VIEWER_CONTEXT=self._service_account_context + ) + return service_account_response if authentication in { MatrixAuthentication.AGENT_TOKEN, MatrixAuthentication.SCOPED_DOWN_AGENT_TOKEN, }: assert agent_bearer is not None - return client.get(path, HTTP_AUTHORIZATION=f"Bearer {agent_bearer}") + agent_response: Response = client.get(path, HTTP_AUTHORIZATION=f"Bearer {agent_bearer}") + return agent_response raise AssertionError(f"unsupported authentication {authentication}") def _mutation_request( @@ -2354,12 +2388,13 @@ def _mutation_request( ), } request_format = "multipart" - return request_method( + response: Response = request_method( path, data=request_payload, format=request_format, **headers, ) + return response def _rolled_back_mutation_request( self, @@ -2710,7 +2745,7 @@ def _mark_service_account_gap[F: Callable[..., None]](test: F, test_id: str) -> gap = SERVICE_ACCOUNT_KNOWN_GAPS.get(test_id) if gap is None: return test - return pytest.mark.xfail(strict=True, reason=gap.value)(test) + return cast("F", pytest.mark.xfail(strict=True, reason=gap.value)(test)) def _install_public_get_matrix_tests() -> None: diff --git a/tests/sentry/seer/endpoints/test_organization_events_anomalies.py b/tests/sentry/seer/endpoints/test_organization_events_anomalies.py index e3ac630e7225..54adf0bacb8f 100644 --- a/tests/sentry/seer/endpoints/test_organization_events_anomalies.py +++ b/tests/sentry/seer/endpoints/test_organization_events_anomalies.py @@ -50,7 +50,7 @@ def setUp(self) -> None: super().setUp() self.enterContext(override_settings(SEER_API_SHARED_SECRET="viewer-context-test-secret")) - def get_test_data(self, project_id: int) -> dict: + def get_test_data(self, project_id: int) -> dict[str, object]: return { "project_id": str(project_id), # UI provides project_id as str "config": self.config, diff --git a/tests/sentry/seer/endpoints/test_organization_seer_agent_chat.py b/tests/sentry/seer/endpoints/test_organization_seer_agent_chat.py index b172e22d6751..f9cf4b9eadf8 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_agent_chat.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_agent_chat.py @@ -122,7 +122,7 @@ def test_post_with_empty_query_returns_400(self) -> None: @with_feature("organizations:seer-explorer-code-mode-tools") @patch("sentry.seer.endpoints.organization_seer_agent_chat.SeerAgentClient") - def test_code_mode_feature_selects_only(self, mock_client_class: MagicMock): + def test_code_mode_feature_selects_only(self, mock_client_class: MagicMock) -> None: """The flag means Code Mode instead of the classic tools, not alongside them. Running both gives the agent two ways to do everything and it mixes them, so the @@ -139,7 +139,7 @@ def test_code_mode_feature_selects_only(self, mock_client_class: MagicMock): @with_feature("organizations:seer-explorer-code-mode-tools") @patch("sentry.seer.endpoints.organization_seer_agent_chat.SeerAgentClient") - def test_frontend_override_still_selects_the_mode(self, mock_client_class: MagicMock): + def test_frontend_override_still_selects_the_mode(self, mock_client_class: MagicMock) -> None: """The override is how the two surfaces get compared, so it outranks the flag.""" mock_client = MagicMock() mock_client.start_run.return_value = MagicMock(seer_run_state_id=456, uuid=uuid.uuid4()) @@ -153,7 +153,7 @@ def test_frontend_override_still_selects_the_mode(self, mock_client_class: Magic assert mock_client_class.call_args.kwargs["enable_code_mode_tools"] == "off" @patch("sentry.seer.endpoints.organization_seer_agent_chat.SeerAgentClient") - def test_no_code_mode_feature_stays_off(self, mock_client_class: MagicMock): + def test_no_code_mode_feature_stays_off(self, mock_client_class: MagicMock) -> None: mock_client = MagicMock() mock_client.start_run.return_value = MagicMock(seer_run_state_id=456, uuid=uuid.uuid4()) mock_client_class.return_value = mock_client @@ -165,7 +165,7 @@ def test_no_code_mode_feature_stays_off(self, mock_client_class: MagicMock): @with_feature("organizations:ask-seer-create-pr") @patch("sentry.seer.endpoints.organization_seer_agent_chat.SeerAgentClient") - def test_create_pr_flag_enables_pr_tools(self, mock_client_class: MagicMock): + def test_create_pr_flag_enables_pr_tools(self, mock_client_class: MagicMock) -> None: self.organization.update_option("sentry:enable_seer_coding", True) mock_client = MagicMock() mock_client.start_run.return_value = MagicMock(seer_run_state_id=456, uuid=uuid.uuid4()) @@ -179,7 +179,7 @@ def test_create_pr_flag_enables_pr_tools(self, mock_client_class: MagicMock): assert mock_client_class.call_args.kwargs["enable_bash_mode"] is True @patch("sentry.seer.endpoints.organization_seer_agent_chat.SeerAgentClient") - def test_requests_investigation_tools(self, mock_client_class: MagicMock): + def test_requests_investigation_tools(self, mock_client_class: MagicMock) -> None: mock_client = MagicMock() mock_client.start_run.return_value = MagicMock(seer_run_state_id=456, uuid=uuid.uuid4()) mock_client_class.return_value = mock_client @@ -192,7 +192,7 @@ def test_requests_investigation_tools(self, mock_client_class: MagicMock): @with_feature("organizations:ask-seer-create-pr") @patch("sentry.seer.endpoints.organization_seer_agent_chat.SeerAgentClient") - def test_create_pr_flag_respects_seer_coding_option(self, mock_client_class: MagicMock): + def test_create_pr_flag_respects_seer_coding_option(self, mock_client_class: MagicMock) -> None: self.organization.update_option("sentry:enable_seer_coding", False) mock_client = MagicMock() mock_client.start_run.return_value = MagicMock(seer_run_state_id=456, uuid=uuid.uuid4()) @@ -204,7 +204,7 @@ def test_create_pr_flag_respects_seer_coding_option(self, mock_client_class: Mag assert mock_client_class.call_args.kwargs["enable_coding"] is False @patch("sentry.seer.endpoints.organization_seer_agent_chat.SeerAgentClient") - def test_post_new_conversation_calls_client(self, mock_client_class: MagicMock): + def test_post_new_conversation_calls_client(self, mock_client_class: MagicMock) -> None: run_uuid = uuid.uuid4() mock_client = MagicMock() mock_client.start_run.return_value = MagicMock(seer_run_state_id=456, uuid=run_uuid) @@ -260,7 +260,7 @@ def test_post_forwards_page_location(self, mock_client_class: MagicMock) -> None assert mock_client.start_run.call_args.kwargs["page_location"] == page_location @patch("sentry.seer.endpoints.organization_seer_agent_chat.SeerAgentClient") - def test_post_forwards_sent_at(self, mock_client_class: MagicMock): + def test_post_forwards_sent_at(self, mock_client_class: MagicMock) -> None: mock_client = MagicMock() mock_client.start_run.return_value = MagicMock(seer_run_state_id=1, uuid=uuid.uuid4()) mock_client_class.return_value = mock_client @@ -341,7 +341,7 @@ def test_post_chat_prompt_continues_run_without_page_context( ) @patch("sentry.seer.endpoints.organization_seer_agent_chat.SeerAgentClient") - def test_post_rejects_oversized_sent_at(self, mock_client_class: MagicMock): + def test_post_rejects_oversized_sent_at(self, mock_client_class: MagicMock) -> None: """Unparsed passthrough values are bounded at the edge.""" mock_client = MagicMock() mock_client.start_run.return_value = MagicMock(seer_run_state_id=1, uuid=uuid.uuid4()) @@ -356,7 +356,7 @@ def test_post_rejects_oversized_sent_at(self, mock_client_class: MagicMock): @patch("sentry.seer.endpoints.organization_seer_agent_chat.SeerAgentClient") def test_post_new_conversation_coding_requires_create_pr_flag( self, mock_client_class: MagicMock - ): + ) -> None: self.organization.update_option("sentry:enable_seer_coding", True) mock_client = MagicMock() mock_client.start_run.return_value = MagicMock(seer_run_state_id=456) @@ -896,8 +896,8 @@ def setUp(self) -> None: @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.collect_user_org_context") def test_override_ce_enable_false_sets_context_engine_disabled( - self, mock_collect_context, mock_access, mock_chat_request - ): + self, mock_collect_context: MagicMock, mock_access: MagicMock, mock_chat_request: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_collect_context.return_value = {} mock_response = MagicMock() @@ -917,8 +917,8 @@ def test_override_ce_enable_false_sets_context_engine_disabled( @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.collect_user_org_context") def test_override_ce_enable_true_sets_context_engine_enabled( - self, mock_collect_context, mock_access, mock_chat_request - ): + self, mock_collect_context: MagicMock, mock_access: MagicMock, mock_chat_request: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_collect_context.return_value = {} mock_response = MagicMock() @@ -938,8 +938,8 @@ def test_override_ce_enable_true_sets_context_engine_enabled( @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.collect_user_org_context") def test_override_ce_enable_ignored_without_feature_flag( - self, mock_collect_context, mock_access, mock_chat_request - ): + self, mock_collect_context: MagicMock, mock_access: MagicMock, mock_chat_request: MagicMock + ) -> None: mock_access.return_value = (True, None) mock_collect_context.return_value = {} mock_response = MagicMock() @@ -971,19 +971,19 @@ class TestCodeModeSerializerField: (None, None), ], ) - def test_valid_values(self, input_val, expected): + def test_valid_values(self, input_val: object, expected: object) -> None: data = {"query": "test", "override_code_mode_enable": input_val} serializer = SeerAgentChatSerializer(data=data) assert serializer.is_valid(), serializer.errors assert serializer.validated_data["override_code_mode_enable"] == expected - def test_omitted_defaults_to_none(self): + def test_omitted_defaults_to_none(self) -> None: data = {"query": "test"} serializer = SeerAgentChatSerializer(data=data) assert serializer.is_valid(), serializer.errors assert serializer.validated_data["override_code_mode_enable"] is None - def test_invalid_string_rejected(self): + def test_invalid_string_rejected(self) -> None: data = {"query": "test", "override_code_mode_enable": "invalid"} serializer = SeerAgentChatSerializer(data=data) assert not serializer.is_valid() diff --git a/tests/sentry/seer/endpoints/test_organization_seer_agent_update.py b/tests/sentry/seer/endpoints/test_organization_seer_agent_update.py index c6f5fe230efe..e7bbf057e94c 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_agent_update.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_agent_update.py @@ -1,3 +1,4 @@ +from typing import cast from unittest.mock import MagicMock, patch import orjson @@ -317,7 +318,7 @@ def setUp(self) -> None: organization=self.organization, seer_run_state_id=123, user_id=self.user.id ) - def _sent_payload(self, mock_request: MagicMock, payload_type: str) -> dict: + def _sent_payload(self, mock_request: MagicMock, payload_type: str) -> dict[str, object]: mock_request.return_value.status = 200 mock_request.return_value.json.return_value = {} spoofed = {"name": "Someone Else", "email": "victim@example.com"} @@ -327,7 +328,8 @@ def _sent_payload(self, mock_request: MagicMock, payload_type: str) -> dict: ) assert response.status_code == status.HTTP_202_ACCEPTED - return orjson.loads(mock_request.call_args[0][2])["payload"] + # justification: the Seer request body serializes its payload as a JSON object. + return cast("dict[str, object]", orjson.loads(mock_request.call_args[0][2])["payload"]) @patch("sentry.seer.endpoints.organization_seer_agent_update.has_seer_agent_access_with_detail") @patch("sentry.seer.endpoints.organization_seer_agent_update.make_signed_seer_api_request") diff --git a/tests/sentry/seer/endpoints/test_organization_seer_autofix_overview.py b/tests/sentry/seer/endpoints/test_organization_seer_autofix_overview.py index 98d22a39b1d5..3d7126eaef78 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_autofix_overview.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_autofix_overview.py @@ -1,8 +1,11 @@ from collections.abc import Sequence +from typing import Any, TypeVar, cast from unittest import mock +from unittest.mock import MagicMock from django.db import connections from django.test.utils import CaptureQueriesContext +from rest_framework.response import Response from sentry import search from sentry.api.serializers.models.group_stream import StreamGroupSerializerSnuba @@ -16,9 +19,11 @@ PullRequestStatusRequest, PullRequestStatusResult, ) -from sentry.models.pullrequest import PullRequestLifecycleState +from sentry.models.group import Group +from sentry.models.pullrequest import PullRequest, PullRequestLifecycleState +from sentry.seer.agent.client_models import SeerRunState from sentry.seer.milestones import reconcile_milestones -from sentry.seer.models.run import SeerRunMilestoneType +from sentry.seer.models.run import SeerRun, SeerRunMilestoneType from sentry.testutils.cases import APITestCase, SnubaTestCase from sentry.testutils.helpers.datetime import before_now from sentry.types.group import PriorityLevel @@ -28,6 +33,8 @@ "integration_service.get_integration" ) +_ClientT = TypeVar("_ClientT", bound=PullRequestStatusClient) + class PullRequestStatusClientFake(PullRequestStatusClient): def __init__( @@ -57,8 +64,8 @@ def get_pull_request_statuses( } -def _root_cause_state(description, headline=None): - from sentry.seer.agent.client_models import Artifact, MemoryBlock, Message, SeerRunState +def _root_cause_state(description: object, headline: object = None) -> SeerRunState: + from sentry.seer.agent.client_models import Artifact, MemoryBlock, Message data = {"one_line_description": description} if headline is not None: @@ -81,17 +88,24 @@ def _root_cause_state(description, headline=None): class OrganizationSeerAutofixOverviewTest(APITestCase, SnubaTestCase): endpoint = "sentry-api-0-organization-seer-autofix-overview" - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(self.user) - def _run_for_group(self, group, description, headline=None): + def _run_for_group(self, group: Group, description: object, headline: object = None) -> SeerRun: run = self.create_seer_run(organization=self.organization) self.create_seer_agent_run(run, source="autofix", group=group, project=group.project) reconcile_milestones(run, _root_cause_state(description, headline)) return run - def _group_with_events(self, fingerprint, *, events, users=1, minutes_ago=1): + def _group_with_events( + self, + fingerprint: object, + *, + events: int, + users: int = 1, + minutes_ago: float = 1, + ) -> Group: group = None for i in range(events): event = self.store_event( @@ -106,7 +120,7 @@ def _group_with_events(self, fingerprint, *, events, users=1, minutes_ago=1): assert group is not None return group - def test_root_cause_run_grouped_under_root_cause_milestone(self): + def test_root_cause_run_grouped_under_root_cause_milestone(self) -> None: group = self.create_group() self._run_for_group(group, "the boom") resp = self.get_success_response(self.organization.slug) @@ -117,17 +131,17 @@ def test_root_cause_run_grouped_under_root_cause_milestone(self): assert runs[0]["rootCause"]["headline"] is None assert runs[0]["proposedFix"] is None - def test_root_cause_exposes_headline(self): + def test_root_cause_exposes_headline(self) -> None: group = self.create_group() self._run_for_group(group, "the boom", headline="Checkout crashes on empty cart") resp = self.get_success_response(self.organization.slug) runs = resp.data["runsByMilestone"][SeerRunMilestoneType.ROOT_CAUSE] assert runs[0]["rootCause"]["headline"] == "Checkout crashes on empty cart" - def _root_cause_short_ids(self, resp): + def _root_cause_short_ids(self, resp: Response) -> list[str]: return [r["shortId"] for r in resp.data["runsByMilestone"][SeerRunMilestoneType.ROOT_CAUSE]] - def test_default_sort_orders_by_seer_recent_activity(self): + def test_default_sort_orders_by_seer_recent_activity(self) -> None: older = self.create_group() newer = self.create_group() run_old = self._run_for_group(older, "old") @@ -142,7 +156,7 @@ def test_default_sort_orders_by_seer_recent_activity(self): older.qualified_short_id, ] - def test_invalid_sort_falls_back_to_default(self): + def test_invalid_sort_falls_back_to_default(self) -> None: group = self.create_group() self._run_for_group(group, "boom") @@ -150,7 +164,7 @@ def test_invalid_sort_falls_back_to_default(self): assert self._root_cause_short_ids(resp) == [group.qualified_short_id] - def test_sort_events_orders_by_event_count(self): + def test_sort_events_orders_by_event_count(self) -> None: low = self._group_with_events("low", events=1) high = self._group_with_events("high", events=5) self._run_for_group(low, "low") @@ -163,7 +177,7 @@ def test_sort_events_orders_by_event_count(self): low.qualified_short_id, ] - def test_sort_users_orders_by_user_count(self): + def test_sort_users_orders_by_user_count(self) -> None: few = self._group_with_events("few", events=4, users=1) many = self._group_with_events("many", events=4, users=4) self._run_for_group(few, "few") @@ -176,7 +190,7 @@ def test_sort_users_orders_by_user_count(self): few.qualified_short_id, ] - def test_sort_issue_orders_by_most_recent_event(self): + def test_sort_issue_orders_by_most_recent_event(self) -> None: stale = self._group_with_events("stale", events=1, minutes_ago=60) fresh = self._group_with_events("fresh", events=1, minutes_ago=1) self._run_for_group(stale, "stale") @@ -192,7 +206,7 @@ def test_sort_issue_orders_by_most_recent_event(self): @mock.patch( "sentry.seer.endpoints.organization_seer_autofix_overview._MAX_RUNS_PER_MILESTONE", 1 ) - def test_events_sort_selects_correct_run_before_cap(self): + def test_events_sort_selects_correct_run_before_cap(self) -> None: # The high-event issue is the OLDEST seer run, so a cap-then-sort impl would drop it. high = self._group_with_events("high", events=5) low = self._group_with_events("low", events=1) @@ -205,7 +219,7 @@ def test_events_sort_selects_correct_run_before_cap(self): assert self._root_cause_short_ids(resp) == [high.qualified_short_id] - def test_sort_recommended_orders_by_fixability_boost(self): + def test_sort_recommended_orders_by_fixability_boost(self) -> None: plain = self._group_with_events("plain", events=1) fixable = self._group_with_events("fixable", events=1) fixable.update(seer_fixability_score=1.0) @@ -222,7 +236,7 @@ def test_sort_recommended_orders_by_fixability_boost(self): plain.qualified_short_id, ] - def test_sort_recommended_keeps_candidates_without_window_events_sorted_last(self): + def test_sort_recommended_keeps_candidates_without_window_events_sorted_last(self) -> None: # `stale` has no events inside the requested window, so it is absent from # the scored search results and must be appended last rather than dropped. stale = self._group_with_events("stale", events=1, minutes_ago=120) @@ -241,7 +255,7 @@ def test_sort_recommended_keeps_candidates_without_window_events_sorted_last(sel stale.qualified_short_id, ] - def test_sort_recommended_uses_v2_scorer_and_viewer_actor(self): + def test_sort_recommended_uses_v2_scorer_and_viewer_actor(self) -> None: group = self._group_with_events("boom", events=1) self._run_for_group(group, "boom") @@ -258,7 +272,7 @@ def test_sort_recommended_uses_v2_scorer_and_viewer_actor(self): assert mock_query.call_args.kwargs["sort_by"] == "recommended_v2" assert mock_query.call_args.kwargs["actor"].id == self.user.id - def test_sort_recommended_without_experimental_flag_uses_v1_scorer(self): + def test_sort_recommended_without_experimental_flag_uses_v1_scorer(self) -> None: group = self._group_with_events("boom", events=1) self._run_for_group(group, "boom") @@ -271,7 +285,7 @@ def test_sort_recommended_without_experimental_flag_uses_v1_scorer(self): assert mock_query.call_count == 1 assert mock_query.call_args.kwargs["sort_by"] == "recommended" - def test_issue_sort_raises_paginator_cap_to_candidate_count(self): + def test_issue_sort_raises_paginator_cap_to_candidate_count(self) -> None: # Without the max_limit override the paginator silently caps at 100 and # candidates beyond it would sort last; assert the endpoint raises it. low = self._group_with_events("low", events=1) @@ -289,8 +303,8 @@ def test_issue_sort_raises_paginator_cap_to_candidate_count(self): assert mock_query.call_args.kwargs["limit"] == 2 assert mock_query.call_args.kwargs["paginator_options"] == {"max_limit": 2} - def _solution_state(self, rc, sol): - from sentry.seer.agent.client_models import Artifact, MemoryBlock, Message, SeerRunState + def _solution_state(self, rc: object, sol: object) -> SeerRunState: + from sentry.seer.agent.client_models import Artifact, MemoryBlock, Message return SeerRunState( run_id=1, @@ -309,7 +323,7 @@ def _solution_state(self, rc, sol): updated_at="2026-02-10T00:00:00Z", ) - def test_solution_run_grouped_under_solution_milestone_with_both_texts(self): + def test_solution_run_grouped_under_solution_milestone_with_both_texts(self) -> None: group = self.create_group() run = self.create_seer_run(organization=self.organization) self.create_seer_agent_run(run, source="autofix", group=group, project=group.project) @@ -322,7 +336,7 @@ def test_solution_run_grouped_under_solution_milestone_with_both_texts(self): assert runs[0]["rootCause"]["oneLineDescription"] == "rc text" assert runs[0]["proposedFix"]["oneLineSummary"] == "fix text" - def _code_changes_state(self, rc): + def _code_changes_state(self, rc: object) -> SeerRunState: from sentry.seer.agent.client_models import ( AgentFilePatch, Artifact, @@ -331,7 +345,6 @@ def _code_changes_state(self, rc): Hunk, MemoryBlock, Message, - SeerRunState, ) patch = AgentFilePatch( @@ -387,13 +400,13 @@ def _code_changes_state(self, rc): updated_at="2026-02-10T00:00:00Z", ) - def _run_with_code_changes(self, group): + def _run_with_code_changes(self, group: Group) -> SeerRun: run = self.create_seer_run(organization=self.organization) self.create_seer_agent_run(run, source="autofix", group=group, project=group.project) reconcile_milestones(run, self._code_changes_state("rc text")) return run - def test_code_changes_returns_generated_diffs(self): + def test_code_changes_returns_generated_diffs(self) -> None: group = self.create_group() self._run_with_code_changes(group) @@ -414,7 +427,7 @@ def test_code_changes_returns_generated_diffs(self): assert lines[1]["line_type"] == "+" assert lines[1]["value"] == "new" - def test_run_without_code_changes_has_empty_list(self): + def test_run_without_code_changes_has_empty_list(self) -> None: group = self.create_group() self._run_for_group(group, "no changes yet") @@ -423,7 +436,7 @@ def test_run_without_code_changes_has_empty_list(self): assert len(runs) == 1 assert runs[0]["codeChanges"] == [] - def test_code_changes_excluded_once_a_pull_request_exists(self): + def test_code_changes_excluded_once_a_pull_request_exists(self) -> None: group = self.create_group() run = self.create_seer_run(organization=self.organization) self.create_seer_agent_run(run, source="autofix", group=group, project=group.project) @@ -436,7 +449,7 @@ def test_code_changes_excluded_once_a_pull_request_exists(self): assert len(runs) == 1 assert runs[0]["codeChanges"] == [] - def test_only_latest_run_per_group_is_shown(self): + def test_only_latest_run_per_group_is_shown(self) -> None: group = self.create_group() self._run_for_group(group, "old run") self._run_for_group(group, "new run") @@ -445,7 +458,7 @@ def test_only_latest_run_per_group_is_shown(self): assert len(runs) == 1 assert runs[0]["rootCause"]["oneLineDescription"] == "new run" - def test_inaccessible_project_is_excluded(self): + def test_inaccessible_project_is_excluded(self) -> None: other_org = self.create_organization() other_project = self.create_project(organization=other_org) other_group = self.create_group(project=other_project) @@ -455,7 +468,7 @@ def test_inaccessible_project_is_excluded(self): resp = self.get_success_response(self.organization.slug) assert resp.data["runsByMilestone"][SeerRunMilestoneType.ROOT_CAUSE] == [] - def test_deleted_group_id_is_tolerated(self): + def test_deleted_group_id_is_tolerated(self) -> None: group = self.create_group() self._run_for_group(group, "gone") group.delete() @@ -463,7 +476,7 @@ def test_deleted_group_id_is_tolerated(self): # Stale group id points at a deleted group; run is skipped, no 500. assert resp.data["runsByMilestone"][SeerRunMilestoneType.ROOT_CAUSE] == [] - def test_run_includes_nested_issue_object(self): + def test_run_includes_nested_issue_object(self) -> None: group = self.create_group(priority=PriorityLevel.HIGH) self._run_for_group(group, "the boom") resp = self.get_success_response(self.organization.slug) @@ -474,7 +487,7 @@ def test_run_includes_nested_issue_object(self): assert issue["assignedTo"] is None assert issue["owners"] == [] - def test_issue_stats_absent_issues_no_snuba_query(self): + def test_issue_stats_absent_issues_no_snuba_query(self) -> None: group = self.create_group() self._run_for_group(group, "boom") with mock.patch( @@ -489,7 +502,7 @@ def test_issue_stats_absent_issues_no_snuba_query(self): assert issue["userCount"] is None assert issue["lastSeen"] is None - def test_issue_stats_expand_requests_snuba_stats(self): + def test_issue_stats_expand_requests_snuba_stats(self) -> None: group = self.create_group() self._run_for_group(group, "boom") with mock.patch( @@ -500,8 +513,15 @@ def test_issue_stats_expand_requests_snuba_stats(self): assert "stats" not in serializer.call_args.kwargs["collapse"] def _pull_request_for_run( - self, group, run, *, key="123", name="getsentry/sentry", integration_id=123, **updates - ): + self, + group: Group, + run: SeerRun, + *, + key: str = "123", + name: str = "getsentry/sentry", + integration_id: int = 123, + **updates: Any, + ) -> PullRequest: repo = self.create_repo( project=group.project, name=name, @@ -516,7 +536,7 @@ def _pull_request_for_run( self.create_seer_run_pull_request(run=run, pull_request=pull_request) return pull_request - def _run_with_pull_request_milestone(self, group): + def _run_with_pull_request_milestone(self, group: Group) -> SeerRun: run = self.create_seer_run(organization=self.organization) self.create_seer_agent_run(run, source="autofix", group=group, project=group.project) state = self._code_changes_state("rc text") @@ -524,7 +544,7 @@ def _run_with_pull_request_milestone(self, group): reconcile_milestones(run, state) return run - def _set_provider_client(self, mock_get_integration, client): + def _set_provider_client(self, mock_get_integration: MagicMock, client: _ClientT) -> _ClientT: installation = mock.Mock() installation.get_client.return_value = client integration = mock.Mock() @@ -532,22 +552,35 @@ def _set_provider_client(self, mock_get_integration, client): mock_get_integration.return_value = integration return client - def _pull_requests(self, *, expand=None): + def _pull_requests(self, *, expand: str | list[str] | None = None) -> list[dict[str, object]]: kwargs = {"qs_params": {"expand": expand}} if expand is not None else {} resp = self.get_success_response(self.organization.slug, **kwargs) run_data = resp.data["runsByMilestone"][SeerRunMilestoneType.ROOT_CAUSE][0] - return run_data["pullRequests"] - - def _issue_project(self, resp, milestone=SeerRunMilestoneType.ROOT_CAUSE): - return resp.data["runsByMilestone"][milestone][0]["issue"]["project"] + # justification: the overview serializer emits pull request payloads. + return cast("list[dict[str, object]]", run_data["pullRequests"]) + + def _issue_project( + self, resp: Response, milestone: SeerRunMilestoneType = SeerRunMilestoneType.ROOT_CAUSE + ) -> dict[str, object]: + # justification: the overview serializer emits the nested project payload. + return cast( + "dict[str, object]", + resp.data["runsByMilestone"][milestone][0]["issue"]["project"], + ) - def _projects_by_id(self, resp, milestone=SeerRunMilestoneType.ROOT_CAUSE): - return { - r["issue"]["project"]["id"]: r["issue"]["project"] - for r in resp.data["runsByMilestone"][milestone] - } + def _projects_by_id( + self, resp: Response, milestone: SeerRunMilestoneType = SeerRunMilestoneType.ROOT_CAUSE + ) -> dict[str, dict[str, object]]: + # justification: the overview serializer emits nested project payloads. + return cast( + "dict[str, dict[str, object]]", + { + r["issue"]["project"]["id"]: r["issue"]["project"] + for r in resp.data["runsByMilestone"][milestone] + }, + ) - def test_scm_info_marks_project_with_github_repo_eligible(self): + def test_scm_info_marks_project_with_github_repo_eligible(self) -> None: group = self.create_group() self._run_for_group(group, "boom") repo = self.create_repo(self.project, provider="integrations:github") @@ -559,7 +592,7 @@ def test_scm_info_marks_project_with_github_repo_eligible(self): assert project.get("hasReposConnected") is True assert project.get("hasNonGithubRepo") is False - def test_project_without_repos_is_not_eligible(self): + def test_project_without_repos_is_not_eligible(self) -> None: group = self.create_group() self._run_for_group(group, "boom") @@ -569,7 +602,7 @@ def test_project_without_repos_is_not_eligible(self): assert project["hasReposConnected"] is False assert project["hasNonGithubRepo"] is False - def test_non_github_repo_flags_has_non_github(self): + def test_non_github_repo_flags_has_non_github(self) -> None: group = self.create_group() self._run_for_group(group, "boom") repo = self.create_repo(self.project, provider="integrations:gitlab") @@ -581,7 +614,7 @@ def test_non_github_repo_flags_has_non_github(self): assert project["hasReposConnected"] is True assert project["hasNonGithubRepo"] is True - def test_eligibility_absent_without_scm_info_expand(self): + def test_eligibility_absent_without_scm_info_expand(self) -> None: group = self.create_group() self._run_for_group(group, "boom") repo = self.create_repo(self.project, provider="integrations:github") @@ -593,7 +626,7 @@ def test_eligibility_absent_without_scm_info_expand(self): assert "hasReposConnected" not in project assert "hasNonGithubRepo" not in project - def test_github_enterprise_repo_is_github(self): + def test_github_enterprise_repo_is_github(self) -> None: group = self.create_group() self._run_for_group(group, "boom") repo = self.create_repo(self.project, provider="integrations:github_enterprise") @@ -605,7 +638,7 @@ def test_github_enterprise_repo_is_github(self): assert project["hasReposConnected"] is True assert project["hasNonGithubRepo"] is False - def test_mixed_github_and_gitlab_repos(self): + def test_mixed_github_and_gitlab_repos(self) -> None: group = self.create_group() self._run_for_group(group, "boom") gh = self.create_repo(self.project, provider="integrations:github") @@ -619,7 +652,7 @@ def test_mixed_github_and_gitlab_repos(self): assert project["hasReposConnected"] is True assert project["hasNonGithubRepo"] is True - def test_inactive_repo_is_not_connected(self): + def test_inactive_repo_is_not_connected(self) -> None: group = self.create_group() self._run_for_group(group, "boom") repo = self.create_repo(self.project, provider="integrations:github") @@ -632,7 +665,7 @@ def test_inactive_repo_is_not_connected(self): assert project["hasReposConnected"] is False assert project["hasNonGithubRepo"] is False - def test_eligibility_is_keyed_per_project(self): + def test_eligibility_is_keyed_per_project(self) -> None: eligible_group = self.create_group() self._run_for_group(eligible_group, "eligible") repo = self.create_repo(self.project, provider="integrations:github") @@ -648,7 +681,7 @@ def test_eligibility_is_keyed_per_project(self): assert projects[str(self.project.id)]["hasReposConnected"] is True assert projects[str(other_project.id)]["hasReposConnected"] is False - def test_repo_eligibility_is_one_query_regardless_of_project_count(self): + def test_repo_eligibility_is_one_query_regardless_of_project_count(self) -> None: for i in range(3): project = self.create_project(organization=self.organization) group = self.create_group(project=project) @@ -662,10 +695,10 @@ def test_repo_eligibility_is_one_query_regardless_of_project_count(self): repo_queries = [q for q in ctx.captured_queries if "seer_projectrepository" in q["sql"]] assert len(repo_queries) == 1 - def _project_config_by_id(self, resp): + def _project_config_by_id(self, resp: Response) -> dict[str, dict[str, object]]: return {entry["id"]: entry for entry in resp.data["projectConfig"]} - def test_project_config_absent_without_expand(self): + def test_project_config_absent_without_expand(self) -> None: group = self.create_group() self._run_for_group(group, "boom") @@ -673,7 +706,7 @@ def test_project_config_absent_without_expand(self): assert "projectConfig" not in resp.data - def test_project_config_returned_with_expand(self): + def test_project_config_returned_with_expand(self) -> None: group = self.create_group() self._run_for_group(group, "boom") @@ -689,7 +722,7 @@ def test_project_config_returned_with_expand(self): "hasNonGithubRepo": False, } - def test_project_config_includes_project_without_runs(self): + def test_project_config_includes_project_without_runs(self) -> None: repo = self.create_repo(self.project, provider="integrations:github") self.create_seer_project_repository(project=self.project, repository=repo) @@ -701,7 +734,7 @@ def test_project_config_includes_project_without_runs(self): config = self._project_config_by_id(resp) assert config[str(self.project.id)]["hasReposConnected"] is True - def test_project_config_reflects_repo_connection_per_project(self): + def test_project_config_reflects_repo_connection_per_project(self) -> None: connected = self.create_project(organization=self.organization) repo = self.create_repo(connected, provider="integrations:github") self.create_seer_project_repository(project=connected, repository=repo) @@ -715,7 +748,7 @@ def test_project_config_reflects_repo_connection_per_project(self): assert config[str(connected.id)]["hasReposConnected"] is True assert config[str(unconnected.id)]["hasReposConnected"] is False - def test_project_config_respects_project_filter(self): + def test_project_config_respects_project_filter(self) -> None: selected = self.create_project(organization=self.organization) other = self.create_project(organization=self.organization) @@ -728,7 +761,7 @@ def test_project_config_respects_project_filter(self): assert set(config) == {str(selected.id)} assert str(other.id) not in config - def test_project_config_scopes_to_member_projects_by_default(self): + def test_project_config_scopes_to_member_projects_by_default(self) -> None: org = self.create_organization(owner=self.create_user()) member = self.create_user() my_team = self.create_team(organization=org) @@ -744,7 +777,7 @@ def test_project_config_scopes_to_member_projects_by_default(self): assert str(mine.id) in config assert str(theirs.id) not in config - def test_project_config_eligibility_is_one_query(self): + def test_project_config_eligibility_is_one_query(self) -> None: for _ in range(3): project = self.create_project(organization=self.organization) repo = self.create_repo(project, provider="integrations:github") @@ -756,7 +789,7 @@ def test_project_config_eligibility_is_one_query(self): repo_queries = [q for q in ctx.captured_queries if "seer_projectrepository" in q["sql"]] assert len(repo_queries) == 1 - def test_scm_info_and_project_config_share_one_eligibility_query(self): + def test_scm_info_and_project_config_share_one_eligibility_query(self) -> None: group = self.create_group() self._run_for_group(group, "boom") repo = self.create_repo(self.project, provider="integrations:github") @@ -773,7 +806,7 @@ def test_scm_info_and_project_config_share_one_eligibility_query(self): assert self._project_config_by_id(resp)[str(self.project.id)]["hasReposConnected"] is True @mock.patch(_INTEGRATION_SERVICE) - def test_run_includes_pull_requests(self, mock_get_integration): + def test_run_includes_pull_requests(self, mock_get_integration: MagicMock) -> None: group = self.create_group() run = self._run_for_group(group, "boom") pull_request = self._pull_request_for_run( @@ -804,7 +837,9 @@ def test_run_includes_pull_requests(self, mock_get_integration): ] @mock.patch(_INTEGRATION_SERVICE) - def test_open_pull_request_includes_changed_files(self, mock_get_integration): + def test_open_pull_request_includes_changed_files( + self, mock_get_integration: MagicMock + ) -> None: group = self.create_group() run = self._run_for_group(group, "boom") self._pull_request_for_run(group, run, state=PullRequestLifecycleState.OPEN, draft=False) @@ -846,7 +881,9 @@ def test_open_pull_request_includes_changed_files(self, mock_get_integration): assert client.requested_include_files == [True] @mock.patch(_INTEGRATION_SERVICE) - def test_open_pull_request_includes_checks_and_review(self, mock_get_integration): + def test_open_pull_request_includes_checks_and_review( + self, mock_get_integration: MagicMock + ) -> None: group = self.create_group() run = self._run_for_group(group, "boom") self._pull_request_for_run(group, run, state=PullRequestLifecycleState.OPEN, draft=False) @@ -870,7 +907,9 @@ def test_open_pull_request_includes_checks_and_review(self, mock_get_integration assert client.requested_keys == ["123"] @mock.patch(_INTEGRATION_SERVICE) - def test_failing_pull_request_lists_failed_checks(self, mock_get_integration): + def test_failing_pull_request_lists_failed_checks( + self, mock_get_integration: MagicMock + ) -> None: group = self.create_group() run = self._run_for_group(group, "boom") self._pull_request_for_run(group, run, state=PullRequestLifecycleState.OPEN, draft=False) @@ -902,7 +941,9 @@ def test_failing_pull_request_lists_failed_checks(self, mock_get_integration): ] @mock.patch(_INTEGRATION_SERVICE) - def test_merged_pull_request_skips_provider_fetch(self, mock_get_integration): + def test_merged_pull_request_skips_provider_fetch( + self, mock_get_integration: MagicMock + ) -> None: group = self.create_group() run = self._run_for_group(group, "boom") self._pull_request_for_run( @@ -918,7 +959,9 @@ def test_merged_pull_request_skips_provider_fetch(self, mock_get_integration): assert client.requested_keys == [] @mock.patch(_INTEGRATION_SERVICE) - def test_provider_failure_degrades_to_null_statuses(self, mock_get_integration): + def test_provider_failure_degrades_to_null_statuses( + self, mock_get_integration: MagicMock + ) -> None: group = self.create_group() run = self._run_for_group(group, "boom") self._pull_request_for_run(group, run, state=PullRequestLifecycleState.OPEN, draft=False) @@ -933,7 +976,7 @@ def test_provider_failure_degrades_to_null_statuses(self, mock_get_integration): assert pull_requests[0]["checksStatus"] is None assert pull_requests[0]["reviewStatus"] is None - def test_unregistered_repository_provider_yields_null_url(self): + def test_unregistered_repository_provider_yields_null_url(self) -> None: group = self.create_group() run = self._run_for_group(group, "boom") repo = self.create_repo( @@ -957,7 +1000,9 @@ def test_unregistered_repository_provider_yields_null_url(self): assert pull_requests[0]["url"] is None @mock.patch(_INTEGRATION_SERVICE) - def test_non_ascii_digit_key_does_not_poison_integration_batch(self, mock_get_integration): + def test_non_ascii_digit_key_does_not_poison_integration_batch( + self, mock_get_integration: MagicMock + ) -> None: group = self.create_group() run = self._run_for_group(group, "boom") repo = self.create_repo( @@ -988,7 +1033,9 @@ def test_non_ascii_digit_key_does_not_poison_integration_batch(self, mock_get_in assert pull_requests[0]["checksStatus"] == "success" @mock.patch(_INTEGRATION_SERVICE) - def test_pull_request_on_inactive_repo_is_not_enriched(self, mock_get_integration): + def test_pull_request_on_inactive_repo_is_not_enriched( + self, mock_get_integration: MagicMock + ) -> None: group = self.create_group() run = self._run_for_group(group, "boom") repo = self.create_repo( @@ -1019,14 +1066,14 @@ def test_pull_request_on_inactive_repo_is_not_enriched(self, mock_get_integratio assert pull_requests[0]["checksStatus"] is None assert pull_requests[0]["repoName"] is None - def test_run_without_pull_requests_has_empty_list(self): + def test_run_without_pull_requests_has_empty_list(self) -> None: group = self.create_group() self._run_for_group(group, "boom") resp = self.get_success_response(self.organization.slug) run_data = resp.data["runsByMilestone"][SeerRunMilestoneType.ROOT_CAUSE][0] assert run_data["pullRequests"] == [] - def test_closed_pull_request_is_excluded_from_the_list(self): + def test_closed_pull_request_is_excluded_from_the_list(self) -> None: group = self.create_group() run = self._run_for_group(group, "boom") self._pull_request_for_run(group, run, state=PullRequestLifecycleState.CLOSED) @@ -1034,7 +1081,7 @@ def test_closed_pull_request_is_excluded_from_the_list(self): run_data = resp.data["runsByMilestone"][SeerRunMilestoneType.ROOT_CAUSE][0] assert run_data["pullRequests"] == [] - def test_unenriched_pull_request_is_kept_in_the_list(self): + def test_unenriched_pull_request_is_kept_in_the_list(self) -> None: group = self.create_group() run = self._run_for_group(group, "boom") pull_request = self._pull_request_for_run(group, run, state=None) @@ -1042,14 +1089,14 @@ def test_unenriched_pull_request_is_kept_in_the_list(self): run_data = resp.data["runsByMilestone"][SeerRunMilestoneType.ROOT_CAUSE][0] assert [pr["id"] for pr in run_data["pullRequests"]] == [str(pull_request.id)] - def test_run_with_only_closed_pull_request_is_hidden(self): + def test_run_with_only_closed_pull_request_is_hidden(self) -> None: group = self.create_group() run = self._run_with_pull_request_milestone(group) self._pull_request_for_run(group, run, state=PullRequestLifecycleState.CLOSED) resp = self.get_success_response(self.organization.slug) assert resp.data["runsByMilestone"][SeerRunMilestoneType.HAS_PULL_REQUEST] == [] - def test_run_with_only_unenriched_pull_request_is_shown(self): + def test_run_with_only_unenriched_pull_request_is_shown(self) -> None: group = self.create_group() run = self._run_with_pull_request_milestone(group) self._pull_request_for_run(group, run, state=None) @@ -1057,7 +1104,7 @@ def test_run_with_only_unenriched_pull_request_is_shown(self): runs = resp.data["runsByMilestone"][SeerRunMilestoneType.HAS_PULL_REQUEST] assert len(runs) == 1 - def test_run_with_open_and_closed_pull_requests_shows_only_open(self): + def test_run_with_open_and_closed_pull_requests_shows_only_open(self) -> None: group = self.create_group() run = self._run_with_pull_request_milestone(group) self._pull_request_for_run(group, run, state=PullRequestLifecycleState.CLOSED) @@ -1078,7 +1125,7 @@ def test_run_with_open_and_closed_pull_requests_shows_only_open(self): @mock.patch( "sentry.seer.endpoints.organization_seer_autofix_overview._MAX_RUNS_PER_MILESTONE", 1 ) - def test_closed_pull_request_run_is_dropped_before_the_cap(self): + def test_closed_pull_request_run_is_dropped_before_the_cap(self) -> None: open_group = self.create_group() open_run = self._run_with_pull_request_milestone(open_group) open_run.update(last_triggered_at=before_now(minutes=5)) @@ -1105,7 +1152,7 @@ def test_closed_pull_request_run_is_dropped_before_the_cap(self): assert len(runs) == 1 assert [pr["id"] for pr in runs[0]["pullRequests"]] == [str(open_pr.id)] - def test_runs_outside_stats_period_are_excluded(self): + def test_runs_outside_stats_period_are_excluded(self) -> None: recent = self.create_group() self._run_for_group(recent, "recent boom") old = self.create_group() @@ -1120,7 +1167,7 @@ def test_runs_outside_stats_period_are_excluded(self): @mock.patch( "sentry.seer.endpoints.organization_seer_autofix_overview._MAX_RUNS_PER_MILESTONE", 2 ) - def test_section_is_capped_at_max_runs_per_milestone(self): + def test_section_is_capped_at_max_runs_per_milestone(self) -> None: for i in range(3): self._run_for_group(self.create_group(), f"boom {i}") @@ -1131,7 +1178,7 @@ def test_section_is_capped_at_max_runs_per_milestone(self): @mock.patch( "sentry.seer.endpoints.organization_seer_autofix_overview._MAX_RUNS_PER_MILESTONE", 2 ) - def test_truncated_milestones_reports_capped_sections(self): + def test_truncated_milestones_reports_capped_sections(self) -> None: for i in range(3): self._run_for_group(self.create_group(), f"boom {i}") @@ -1139,7 +1186,7 @@ def test_truncated_milestones_reports_capped_sections(self): assert resp.data["truncatedMilestones"] == [SeerRunMilestoneType.ROOT_CAUSE] - def test_truncated_milestones_empty_when_under_cap(self): + def test_truncated_milestones_empty_when_under_cap(self) -> None: self._run_for_group(self.create_group(), "boom") resp = self.get_success_response(self.organization.slug) @@ -1147,7 +1194,9 @@ def test_truncated_milestones_empty_when_under_cap(self): assert resp.data["truncatedMilestones"] == [] @mock.patch(_INTEGRATION_SERVICE) - def test_both_expands_enrich_pull_request_and_request_stats(self, mock_get_integration): + def test_both_expands_enrich_pull_request_and_request_stats( + self, mock_get_integration: MagicMock + ) -> None: group = self.create_group() run = self._run_for_group(group, "boom") self._pull_request_for_run(group, run, state=PullRequestLifecycleState.OPEN, draft=False) @@ -1171,21 +1220,25 @@ class OrganizationSeerAutofixOverviewStatusExpandTest(APITestCase, SnubaTestCase endpoint = "sentry-api-0-organization-seer-autofix-overview" _FETCH = "sentry.seer.endpoints.organization_seer_autofix_overview.fetch_run_statuses" - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(self.user) - def _run_for_group(self, group, description, state_id): + def _run_for_group(self, group: Group, description: object, state_id: int | None) -> SeerRun: run = self.create_seer_run(organization=self.organization) self.create_seer_agent_run(run, source="autofix", group=group, project=group.project) reconcile_milestones(run, _root_cause_state(description)) run.update(seer_run_state_id=state_id) return run - def _root_cause_runs(self, resp): - return resp.data["runsByMilestone"][SeerRunMilestoneType.ROOT_CAUSE] + def _root_cause_runs(self, resp: Response) -> list[dict[str, object]]: + # justification: the overview serializer emits run payloads per milestone. + return cast( + "list[dict[str, object]]", + resp.data["runsByMilestone"][SeerRunMilestoneType.ROOT_CAUSE], + ) - def test_status_absent_without_expand(self): + def test_status_absent_without_expand(self) -> None: group = self.create_group() self._run_for_group(group, "boom", state_id=101) with mock.patch(self._FETCH) as m: @@ -1193,7 +1246,7 @@ def test_status_absent_without_expand(self): assert m.call_count == 0 assert self._root_cause_runs(resp)[0]["status"] is None - def test_expand_status_attaches_status_by_state_id(self): + def test_expand_status_attaches_status_by_state_id(self) -> None: group = self.create_group() run = self._run_for_group(group, "boom", state_id=101) with mock.patch(self._FETCH, return_value={101: "processing"}) as m: @@ -1203,7 +1256,7 @@ def test_expand_status_attaches_status_by_state_id(self): assert serialized["seerRunId"] == str(run.uuid) assert serialized["status"] == "processing" - def test_expand_status_run_without_state_id_gets_null(self): + def test_expand_status_run_without_state_id_gets_null(self) -> None: group = self.create_group() self._run_for_group(group, "boom", state_id=None) with mock.patch(self._FETCH, return_value={}) as m: diff --git a/tests/sentry/seer/endpoints/test_organization_seer_autofix_scm_info.py b/tests/sentry/seer/endpoints/test_organization_seer_autofix_scm_info.py index 48f71ffe95ac..62adad1e0736 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_autofix_scm_info.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_autofix_scm_info.py @@ -1,7 +1,11 @@ from __future__ import annotations from collections.abc import Sequence +from typing import Any from unittest import mock +from unittest.mock import MagicMock + +from rest_framework.response import Response from sentry.integrations.source_code_management.status_check import ( AggregateChecksStatus, @@ -11,7 +15,11 @@ PullRequestStatusRequest, PullRequestStatusResult, ) -from sentry.models.pullrequest import PullRequestLifecycleState +from sentry.models.group import Group +from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.models.pullrequest import PullRequest, PullRequestLifecycleState +from sentry.seer.models.run import SeerRun from sentry.testutils.cases import APITestCase _INTEGRATION_SERVICE = ( @@ -38,11 +46,13 @@ def get_pull_request_statuses( class OrganizationSeerAutofixScmInfoTest(APITestCase): endpoint = "sentry-api-0-organization-seer-autofix-scm-info" - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(self.user) - def _set_provider_client(self, mock_get_integration, client): + def _set_provider_client( + self, mock_get_integration: MagicMock, client: PullRequestStatusClientFake + ) -> PullRequestStatusClientFake: installation = mock.Mock() installation.get_client.return_value = client integration = mock.Mock() @@ -50,7 +60,13 @@ def _set_provider_client(self, mock_get_integration, client): mock_get_integration.return_value = integration return client - def _run(self, *, organization=None, project=None, source="autofix"): + def _run( + self, + *, + organization: Organization | None = None, + project: Project | None = None, + source: str = "autofix", + ) -> tuple[SeerRun, Group]: organization = organization or self.organization project = project or self.project group = self.create_group(project=project) @@ -58,7 +74,14 @@ def _run(self, *, organization=None, project=None, source="autofix"): self.create_seer_agent_run(run, source=source, group=group, project=project) return run, group - def _add_pr(self, run, group, *, key="123", updates=None): + def _add_pr( + self, + run: SeerRun, + group: Group, + *, + key: str = "123", + updates: dict[str, Any] | None = None, + ) -> PullRequest: repo = self.create_repo( project=group.project, name="getsentry/sentry", @@ -73,11 +96,11 @@ def _add_pr(self, run, group, *, key="123", updates=None): self.create_seer_run_pull_request(run=run, pull_request=pr) return pr - def _get(self, run_ids): + def _get(self, run_ids: str | list[str]) -> Response: return self.get_success_response(self.organization.slug, qs_params={"runIds": run_ids}) @mock.patch(_INTEGRATION_SERVICE) - def test_returns_scm_info_for_run(self, mock_get_integration): + def test_returns_scm_info_for_run(self, mock_get_integration: MagicMock) -> None: run, group = self._run() pr = self._add_pr(run, group) client = self._set_provider_client( @@ -128,21 +151,21 @@ def test_returns_scm_info_for_run(self, mock_get_integration): } } - def test_run_without_prs_returns_empty_list(self): + def test_run_without_prs_returns_empty_list(self) -> None: run, _ = self._run() resp = self._get(str(run.uuid)) assert resp.data["scmInfoByRunId"] == {str(run.uuid): {"pullRequests": []}} - def test_no_run_ids_returns_empty(self): + def test_no_run_ids_returns_empty(self) -> None: resp = self._get([]) assert resp.data == {"scmInfoByRunId": {}} - def test_invalid_run_ids_returns_400(self): + def test_invalid_run_ids_returns_400(self) -> None: self.get_error_response( self.organization.slug, qs_params={"runIds": "not-a-uuid"}, status_code=400 ) - def test_omits_runs_from_other_orgs(self): + def test_omits_runs_from_other_orgs(self) -> None: other_org = self.create_organization(owner=self.create_user()) other_project = self.create_project(organization=other_org) other_run, other_group = self._run(organization=other_org, project=other_project) @@ -150,7 +173,7 @@ def test_omits_runs_from_other_orgs(self): resp = self._get(str(other_run.uuid)) assert resp.data["scmInfoByRunId"] == {} - def test_omits_runs_from_inaccessible_same_org_projects(self): + def test_omits_runs_from_inaccessible_same_org_projects(self) -> None: org = self.create_organization(owner=self.create_user()) member = self.create_user() my_team = self.create_team(organization=org) @@ -165,7 +188,7 @@ def test_omits_runs_from_inaccessible_same_org_projects(self): resp = self.get_success_response(org.slug, qs_params={"runIds": str(run.uuid)}) assert resp.data["scmInfoByRunId"] == {} - def test_omits_non_autofix_source_runs(self): + def test_omits_non_autofix_source_runs(self) -> None: run, group = self._run(source="explorer") self._add_pr(run, group) resp = self._get(str(run.uuid)) diff --git a/tests/sentry/seer/endpoints/test_organization_seer_chat_suggestions.py b/tests/sentry/seer/endpoints/test_organization_seer_chat_suggestions.py index 7c21702f545b..0a62925f647d 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_chat_suggestions.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_chat_suggestions.py @@ -185,12 +185,17 @@ def test_returns_403_without_agent_access(self, mock_run_oneshot: MagicMock) -> self.get_error_response(self.organization.slug, status_code=403, **PAYLOAD) mock_run_oneshot.assert_not_called() - def _can_edit_node_type(self, mock_run_oneshot: MagicMock, route: str, **route_params: str): + def _can_edit_node_type( + self, mock_run_oneshot: MagicMock, route: str, **route_params: str + ) -> str | None: mock_run_oneshot.return_value = {"suggestions": []} self.get_success_response( self.organization.slug, **{**PAYLOAD, "route": route, "route_params": route_params} ) - return mock_run_oneshot.call_args.args[1]["can_edit_node_type"] + can_edit = mock_run_oneshot.call_args.args[1]["can_edit_node_type"] + if can_edit is not None and not isinstance(can_edit, str): + raise TypeError("Seer chat suggestion carries no editability") + return can_edit @patch("sentry.seer.endpoints.organization_seer_chat_suggestions.run_oneshot") def test_passes_dashboard_edit_access_when_user_can_edit( diff --git a/tests/sentry/seer/endpoints/test_organization_seer_onboarding_check.py b/tests/sentry/seer/endpoints/test_organization_seer_onboarding_check.py index afc512f73f9e..2cd97729f28b 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_onboarding_check.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_onboarding_check.py @@ -1,5 +1,9 @@ from __future__ import annotations +from typing import Any + +from rest_framework.response import Response + from sentry.constants import ObjectStatus from sentry.seer.endpoints.organization_seer_onboarding_check import ( has_supported_scm_integration, @@ -239,9 +243,11 @@ def setUp(self) -> None: self.login_as(user=self.user) self.project = self.create_project(organization=self.organization) - def get_response(self, organization_slug, **kwargs): + # justification: forwarded to APIClient.get as extra request options. + def get_response(self, organization_slug: str, **kwargs: Any) -> Response: url = f"/api/0/organizations/{organization_slug}/seer/onboarding-check/" - return self.client.get(url, format="json", **kwargs) + response: Response[Any] = self.client.get(url, format="json", **kwargs) + return response def test_nothing_configured(self) -> None: response = self.get_response(self.organization.slug) diff --git a/tests/sentry/seer/endpoints/test_organization_seer_runs.py b/tests/sentry/seer/endpoints/test_organization_seer_runs.py index e228c66065db..2115b9f9d131 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_runs.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_runs.py @@ -1,6 +1,6 @@ from collections.abc import Mapping from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch import orjson from django.test import override_settings @@ -465,7 +465,9 @@ def fake_oneshot( @override_settings(SEER_API_SHARED_SECRET="viewer-context-test-secret") @with_feature("organizations:seer-run-questions") @patch("sentry.seer.signed_seer_api.seer_autofix_default_connection_pool.urlopen") - def test_user_question_propagates_request_context_to_seer(self, mock_urlopen) -> None: + def test_user_question_propagates_request_context_to_seer( + self, mock_urlopen: MagicMock + ) -> None: run = self.create_seer_run( organization=self.organization, user_id=self.user.id, diff --git a/tests/sentry/seer/endpoints/test_organization_seer_setup_check.py b/tests/sentry/seer/endpoints/test_organization_seer_setup_check.py index ffa08b6cfa17..ad2c8b3b5855 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_setup_check.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_setup_check.py @@ -1,7 +1,11 @@ from __future__ import annotations +from typing import Any from unittest.mock import MagicMock, patch +from rest_framework.response import Response + +from sentry.constants import DataCategory from sentry.testutils.cases import APITestCase, SnubaTestCase @@ -12,9 +16,11 @@ def setUp(self) -> None: super().setUp() self.login_as(user=self.user) - def get_response(self, organization_slug, **kwargs): + # justification: forwarded to APIClient.get as extra request options. + def get_response(self, organization_slug: str, **kwargs: Any) -> Response: url = f"/api/0/organizations/{organization_slug}/seer/setup-check/" - return self.client.get(url, format="json", **kwargs) + response: Response[Any] = self.client.get(url, format="json", **kwargs) + return response class OrganizationSeerSetupCheckSuccessTest(OrganizationSeerSetupCheckTestBase): @@ -53,9 +59,7 @@ def test_no_autofix_quota(self, mock_has_budget: MagicMock) -> None: Test when the organization has no autofix quota available. """ - def side_effect(org_id, data_category): - from sentry.constants import DataCategory - + def side_effect(org_id: int, data_category: DataCategory) -> bool: if data_category == DataCategory.SEER_AUTOFIX: return False return True # Scanner quota available @@ -76,9 +80,7 @@ def test_no_scanner_quota(self, mock_has_budget: MagicMock) -> None: Test when the organization has no scanner quota available. """ - def side_effect(org_id, data_category): - from sentry.constants import DataCategory - + def side_effect(org_id: int, data_category: DataCategory) -> bool: if data_category == DataCategory.SEER_SCANNER: return False return True # Autofix quota available diff --git a/tests/sentry/seer/endpoints/test_organization_seer_workflow_configs.py b/tests/sentry/seer/endpoints/test_organization_seer_workflow_configs.py index 3048c99ac972..f243a1f4d2d3 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_workflow_configs.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_workflow_configs.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +from typing import Any from django.db import connections, router from django.test import override_settings @@ -21,6 +22,7 @@ from sentry.testutils.helpers import parse_link_header from sentry.testutils.helpers.features import with_feature from sentry.users.models.user import User +from sentry.utils import json @override_settings(SENTRY_SELF_HOSTED=False) @@ -163,7 +165,7 @@ def test_last_run_uses_each_configs_latest_invocation(self) -> None: assert "lastRunStatus" not in response.data["configs"][2] assert ( - datetime.fromisoformat(response.json()["configs"][0]["dateLastRun"]) + datetime.fromisoformat(json.loads(response.content)["configs"][0]["dateLastRun"]) == latest.date_added ) filtered = self.get_success_response(self.organization.slug, id=config.id) @@ -244,7 +246,8 @@ def test_scope_only_includes_accessible_active_projects(self) -> None: assert len(response.data["configs"]) == 1 assert response.data["configs"][0]["projects"] == [accessible.id] - def create_config(self, **kwargs) -> SeerWorkflowConfig: + # justification: forwarded to the factory's untyped **kwargs. + def create_config(self, **kwargs: Any) -> SeerWorkflowConfig: return Factories.create_seer_workflow_config( self.organization, strategy=SeerWorkflowStrategy.AGENTIC_TRIAGE_V2, **kwargs ) @@ -502,7 +505,8 @@ def selected_project_ids(self, config: SeerWorkflowConfig) -> list[int]: ) ) - def create_config(self, **kwargs) -> SeerWorkflowConfig: + # justification: forwarded to the factory's untyped **kwargs. + def create_config(self, **kwargs: Any) -> SeerWorkflowConfig: return Factories.create_seer_workflow_config( self.organization, strategy=SeerWorkflowStrategy.AGENTIC_TRIAGE_V2, **kwargs ) diff --git a/tests/sentry/seer/endpoints/test_organization_seer_workflow_runs.py b/tests/sentry/seer/endpoints/test_organization_seer_workflow_runs.py index d0f7b082622c..06047c9a116c 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_workflow_runs.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_workflow_runs.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from typing import Any from unittest.mock import patch from django.db import connections, router @@ -31,6 +32,7 @@ from sentry.testutils.factories import Factories from sentry.testutils.helpers import parse_link_header from sentry.testutils.helpers.features import with_feature +from sentry.utils import json @override_settings(SENTRY_SELF_HOSTED=False) @@ -133,7 +135,7 @@ def test_exposes_only_allowlisted_error_fields_from_extras(self) -> None: ) response = self.get_success_response(self.organization.slug, config=self.config.id) - (serialized_run,) = response.json()["runs"] + (serialized_run,) = json.loads(response.content)["runs"] (serialized_execution,) = serialized_run["executions"] assert serialized_run["status"] == serialized_execution["status"] == "failed" assert serialized_run["error"] == error @@ -156,7 +158,7 @@ def test_missing_or_malformed_errors_use_safe_fallback(self) -> None: run.update(extras={"error": error}) execution.update(extras={"error": error}) response = self.get_success_response(self.organization.slug, config=self.config.id) - (serialized_run,) = response.json()["runs"] + (serialized_run,) = json.loads(response.content)["runs"] assert serialized_run["error"] == { "code": "unknown", "message": "No error details are available.", @@ -189,7 +191,7 @@ def test_non_error_records_omit_stale_errors(self) -> None: ) response = self.get_success_response(self.organization.slug, config=self.config.id) - runs = response.json()["runs"] + runs = json.loads(response.content)["runs"] assert [run["status"] for run in runs] == [None, "complete", "running", "queued"] for run_data in runs: assert "error" not in run_data @@ -395,7 +397,9 @@ def test_execution_serialization_batches_database_reads(self) -> None: assert [len(run["executions"]) for run in result["runs"]] == [2, 2, 2] assert result["runs"][0]["executions"][0]["seerRunId"] is not None - def create_run(self, *, project_ids: Sequence[int] | None = None, **kwargs) -> SeerWorkflowRun: + def create_run( + self, *, project_ids: Sequence[int] | None = None, **kwargs: object + ) -> SeerWorkflowRun: kwargs.setdefault("status", SeerWorkflowRunStatus.QUEUED) kwargs.setdefault( "extras", @@ -527,7 +531,7 @@ def test_callback_for_deleted_user_marks_run_failed(self) -> None: assert agent_run.extras["status"] == "failed" assert agent_run.extras["error"] == "The triggering user no longer exists." - def trigger(self): + def trigger(self) -> SeerAgentRun: with self.feature("organizations:seer-workflows-monitor-cleanup"): response = self.get_success_response( self.organization.slug, strategy="duplicate_monitors", status_code=202 @@ -536,7 +540,8 @@ def trigger(self): run__workflow_execution__run_id=response.data["runId"] ) - def result(self): + # justification: run result payload is decoded JSON. + def result(self) -> dict[str, Any]: return { "schema_version": 1, "data": { diff --git a/tests/sentry/seer/endpoints/test_project_seer_repos.py b/tests/sentry/seer/endpoints/test_project_seer_repos.py index 9e0f9009561e..7afb415ede4d 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_repos.py +++ b/tests/sentry/seer/endpoints/test_project_seer_repos.py @@ -21,7 +21,7 @@ class ProjectSeerRepoGetBaseTest(APITestCase): endpoint = "sentry-api-0-project-seer-repo" - def detail_url(self, repo_id): + def detail_url(self, repo_id: int) -> str: return reverse( self.endpoint, kwargs={ @@ -31,7 +31,7 @@ def detail_url(self, repo_id): }, ) - def reverse_url(self): + def reverse_url(self) -> str: return self.detail_url(self.repo1.id) def setUp(self) -> None: @@ -47,7 +47,7 @@ def setUp(self) -> None: integration_id=self.integration.id, ) - def test_get_repo(self): + def test_get_repo(self) -> None: project_repo = self.create_seer_project_repository( self.project, repository=self.repo1, branch_name="main", instructions="hello" ) @@ -68,29 +68,29 @@ def test_get_repo(self): assert response.data["branchOverrides"][0]["tagValue"] == "production" assert response.data["branchOverrides"][0]["branchName"] == "release" - def test_not_connected_repo_returns_404(self): + def test_not_connected_repo_returns_404(self) -> None: self.get_error_response(status_code=404) - def test_inactive_repo_returns_404(self): + def test_inactive_repo_returns_404(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.repo1.status = ObjectStatus.HIDDEN self.repo1.save() self.get_error_response(status_code=404) - def test_nonexistent_repo_returns_404(self): + def test_nonexistent_repo_returns_404(self) -> None: self.repo1.id = 99999 # reverse_url builds the URL from repo1.id; no repo has this ID. self.get_error_response(status_code=404) - def test_unsupported_provider_returns_404(self): + def test_unsupported_provider_returns_404(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.repo1.provider = "integrations:bitbucket" self.repo1.save() self.get_error_response(status_code=404) - def test_other_project_repo_returns_404(self): + def test_other_project_repo_returns_404(self) -> None: other_project = self.create_project(organization=self.organization) self.create_seer_project_repository(other_project, repository=self.repo1) @@ -139,7 +139,7 @@ class ProjectSeerRepoPutBaseTest(APITestCase): endpoint = "sentry-api-0-project-seer-repo" method = "put" - def reverse_url(self): + def reverse_url(self) -> str: return reverse( self.endpoint, kwargs={ @@ -162,19 +162,19 @@ def setUp(self) -> None: integration_id=self.integration.id, ) - def test_update_branch_name(self): + def test_update_branch_name(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1, branch_name="main") response = self.get_success_response(branchName="develop") assert response.data["branchName"] == "develop" - def test_update_instructions(self): + def test_update_instructions(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) response = self.get_success_response(instructions="new instructions") assert response.data["instructions"] == "new instructions" - def test_update_branch_overrides(self): + def test_update_branch_overrides(self) -> None: pr = self.create_seer_project_repository(self.project, repository=self.repo1) SeerProjectRepositoryBranchOverride.objects.create( seer_project_repository=pr, @@ -200,7 +200,7 @@ def test_update_branch_overrides(self): == 1 ) - def test_partial_update_preserves_other_fields(self): + def test_partial_update_preserves_other_fields(self) -> None: self.create_seer_project_repository( self.project, repository=self.repo1, branch_name="main", instructions="original" ) @@ -209,7 +209,7 @@ def test_partial_update_preserves_other_fields(self): assert response.data["branchName"] == "develop" assert response.data["instructions"] == "original" - def test_partial_update_preserves_branch_overrides(self): + def test_partial_update_preserves_branch_overrides(self) -> None: project_repo = self.create_seer_project_repository( self.project, repository=self.repo1, branch_name="main" ) @@ -226,7 +226,7 @@ def test_partial_update_preserves_branch_overrides(self): assert response.data["branchOverrides"][0]["tagName"] == "env" assert response.data["branchOverrides"][0]["branchName"] == "release" - def test_clear_branch_overrides(self): + def test_clear_branch_overrides(self) -> None: project_repo = self.create_seer_project_repository(self.project, repository=self.repo1) SeerProjectRepositoryBranchOverride.objects.create( seer_project_repository=project_repo, @@ -244,41 +244,41 @@ def test_clear_branch_overrides(self): == 0 ) - def test_set_null_branch_name(self): + def test_set_null_branch_name(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1, branch_name="main") response = self.get_success_response(branchName=None) assert response.data["branchName"] is None - def test_not_connected_returns_404(self): + def test_not_connected_returns_404(self) -> None: self.get_error_response(branchName="main", status_code=404) - def test_inactive_repo_returns_404(self): + def test_inactive_repo_returns_404(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.repo1.status = ObjectStatus.PENDING_DELETION self.repo1.save() self.get_error_response(branchName="develop", status_code=404) - def test_unsupported_provider_returns_404(self): + def test_unsupported_provider_returns_404(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.repo1.provider = "integrations:bitbucket" self.repo1.save() self.get_error_response(branchName="develop", status_code=404) - def test_empty_body_returns_400(self): + def test_empty_body_returns_400(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.get_error_response(status_code=400) - def test_other_project_repo_returns_404(self): + def test_other_project_repo_returns_404(self) -> None: other_project = self.create_project(organization=self.organization) self.create_seer_project_repository(other_project, repository=self.repo1) self.get_error_response(branchName="develop", status_code=404) - def test_duplicate_branch_overrides_returns_400(self): + def test_duplicate_branch_overrides_returns_400(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.get_error_response( @@ -289,7 +289,7 @@ def test_duplicate_branch_overrides_returns_400(self): status_code=400, ) - def test_branch_overrides_does_not_bump_date_updated(self): + def test_branch_overrides_does_not_bump_date_updated(self) -> None: project_repo = self.create_seer_project_repository( self.project, repository=self.repo1, branch_name="main" ) @@ -354,7 +354,7 @@ class ProjectSeerRepoDeleteSeatBasedBillingTest(APITestCase): endpoint = "sentry-api-0-project-seer-repo" method = "delete" - def reverse_url(self): + def reverse_url(self) -> str: return reverse( self.endpoint, kwargs={ @@ -374,7 +374,7 @@ def setUp(self) -> None: external_id="111", ) - def test_delete_repo(self): + def test_delete_repo(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.get_success_response() @@ -383,7 +383,7 @@ def test_delete_repo(self): project_repository__repository=self.repo1, ).exists() - def test_cascades_branch_overrides(self): + def test_cascades_branch_overrides(self) -> None: project_repo = self.create_seer_project_repository(self.project, repository=self.repo1) SeerProjectRepositoryBranchOverride.objects.create( seer_project_repository=project_repo, @@ -395,24 +395,24 @@ def test_cascades_branch_overrides(self): self.get_success_response() assert SeerProjectRepositoryBranchOverride.objects.count() == 0 - def test_not_connected_repo_returns_404(self): + def test_not_connected_repo_returns_404(self) -> None: self.get_error_response(status_code=404) - def test_inactive_repo_returns_404(self): + def test_inactive_repo_returns_404(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.repo1.status = ObjectStatus.HIDDEN self.repo1.save() self.get_error_response(status_code=404) - def test_unsupported_provider_returns_404(self): + def test_unsupported_provider_returns_404(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.repo1.provider = "integrations:bitbucket" self.repo1.save() self.get_error_response(status_code=404) - def test_other_project_repo_returns_404(self): + def test_other_project_repo_returns_404(self) -> None: other_project = self.create_project(organization=self.organization) self.create_seer_project_repository(other_project, repository=self.repo1) @@ -429,7 +429,7 @@ class ProjectSeerRepoDeleteTokenBasedBillingTest(APITestCase): endpoint = "sentry-api-0-project-seer-repo" method = "delete" - def reverse_url(self): + def reverse_url(self) -> str: return reverse( self.endpoint, kwargs={ @@ -468,7 +468,7 @@ class ProjectSeerReposGetBaseTest(APITestCase): endpoint = "sentry-api-0-project-seer-repos" - def reverse_url(self): + def reverse_url(self) -> str: return reverse( self.endpoint, kwargs={ @@ -497,11 +497,11 @@ def setUp(self) -> None: integration_id=self.integration.id, ) - def test_empty(self): + def test_empty(self) -> None: response = self.get_success_response() assert len(response.data) == 0 - def test_returns_connected_repos(self): + def test_returns_connected_repos(self) -> None: self.create_seer_project_repository( self.project, repository=self.repo1, @@ -528,7 +528,7 @@ def test_returns_connected_repos(self): assert project_repo_relay["branchName"] is None assert project_repo_relay["instructions"] is None - def test_gitlab_repo_uses_display_name(self): + def test_gitlab_repo_uses_display_name(self) -> None: """The settings serializer's owner/name are display-only and must mirror repo.name (GitLab's name_with_namespace), the same value the repo picker shows. The URL-safe config['path'] is used only when building SCM links @@ -555,7 +555,7 @@ def test_gitlab_repo_uses_display_name(self): assert repo_data["name"] == " My Project" assert f"{repo_data['owner']}/{repo_data['name']}" == gitlab_repo.name - def test_excludes_inactive_repos(self): + def test_excludes_inactive_repos(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.repo1.status = ObjectStatus.HIDDEN self.repo1.save() @@ -563,7 +563,7 @@ def test_excludes_inactive_repos(self): response = self.get_success_response() assert len(response.data) == 0 - def test_returns_branch_overrides(self): + def test_returns_branch_overrides(self) -> None: project_repo = self.create_seer_project_repository(self.project, repository=self.repo1) SeerProjectRepositoryBranchOverride.objects.create( seer_project_repository=project_repo, @@ -579,7 +579,7 @@ def test_returns_branch_overrides(self): assert branch_overrides["tagValue"] == "production" assert branch_overrides["branchName"] == "release" - def test_search_by_name(self): + def test_search_by_name(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.create_seer_project_repository(self.project, repository=self.repo2) @@ -587,7 +587,7 @@ def test_search_by_name(self): assert len(response.data) == 1 assert response.data[0]["name"] == "relay" - def test_search_by_name_exclude(self): + def test_search_by_name_exclude(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.create_seer_project_repository(self.project, repository=self.repo2) @@ -595,7 +595,7 @@ def test_search_by_name_exclude(self): assert len(response.data) == 1 assert response.data[0]["name"] == "sentry" - def test_search_by_provider(self): + def test_search_by_provider(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.create_seer_project_repository(self.project, repository=self.repo2) @@ -605,7 +605,7 @@ def test_search_by_provider(self): response = self.get_success_response(qs_params={"query": "provider:integrations:github"}) assert len(response.data) == 2 - def test_sort_by_name_ascending(self): + def test_sort_by_name_ascending(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.create_seer_project_repository(self.project, repository=self.repo2) @@ -613,7 +613,7 @@ def test_sort_by_name_ascending(self): names = [r["name"] for r in response.data] assert names == ["relay", "sentry"] - def test_sort_by_name_descending(self): + def test_sort_by_name_descending(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.create_seer_project_repository(self.project, repository=self.repo2) @@ -621,7 +621,7 @@ def test_sort_by_name_descending(self): names = [r["name"] for r in response.data] assert names == ["sentry", "relay"] - def test_sort_by_provider(self): + def test_sort_by_provider(self) -> None: gitlab_repo = self.create_repo( project=self.project, name="getsentry/other", @@ -640,14 +640,14 @@ def test_sort_by_provider(self): providers = [r["provider"] for r in response.data] assert providers == sorted(providers, reverse=True) - def test_invalid_sort_field(self): + def test_invalid_sort_field(self) -> None: response = self.get_error_response(qs_params={"sortBy": "invalid"}, status_code=400) assert "Invalid sortBy" in response.data["detail"] - def test_invalid_search_query(self): + def test_invalid_search_query(self) -> None: self.get_error_response(qs_params={"query": "invalid:field:value"}, status_code=400) - def test_excludes_unsupported_providers(self): + def test_excludes_unsupported_providers(self) -> None: unsupported_repo = self.create_repo( project=self.project, name="getsentry/other", @@ -661,7 +661,7 @@ def test_excludes_unsupported_providers(self): assert len(response.data) == 1 assert response.data[0]["name"] == "sentry" - def test_other_project_repo_not_returned(self): + def test_other_project_repo_not_returned(self) -> None: other_project = self.create_project(organization=self.organization) self.create_seer_project_repository(other_project, repository=self.repo1) @@ -717,7 +717,7 @@ class ProjectSeerReposPostBaseTest(APITestCase): endpoint = "sentry-api-0-project-seer-repos" method = "post" - def reverse_url(self): + def reverse_url(self) -> str: return reverse( self.endpoint, kwargs={ @@ -746,7 +746,7 @@ def setUp(self) -> None: integration_id=self.integration.id, ) - def test_add_repos(self): + def test_add_repos(self) -> None: self.get_success_response( repos=[ { @@ -768,7 +768,7 @@ def test_add_repos(self): assert by_repo_id[self.repo1.id].instructions == "run tests" assert by_repo_id[self.repo2.id].branch_name is None - def test_add_repos_with_branch_overrides(self): + def test_add_repos_with_branch_overrides(self) -> None: self.get_success_response( repos=[ { @@ -793,15 +793,15 @@ def test_add_repos_with_branch_overrides(self): assert len(overrides) == 1 assert overrides[0].branch_name == "release" - def test_empty_repos_returns_400(self): + def test_empty_repos_returns_400(self) -> None: response = self.get_error_response(repos=[], status_code=400) assert "repos must not be empty" in response.data["detail"] - def test_invalid_repo_id_returns_400(self): + def test_invalid_repo_id_returns_400(self) -> None: response = self.get_error_response(repos=[{"repositoryId": 99999}], status_code=400) assert "Invalid repository IDs" in response.data["detail"] - def test_other_org_repo_returns_400(self): + def test_other_org_repo_returns_400(self) -> None: other_org = self.create_organization(owner=self.user) other_repo = Repository.objects.create( organization_id=other_org.id, name="other/repo", provider="github", external_id="999" @@ -809,7 +809,7 @@ def test_other_org_repo_returns_400(self): self.get_error_response(repos=[{"repositoryId": other_repo.id}], status_code=400) - def test_unsupported_provider_returns_400(self): + def test_unsupported_provider_returns_400(self) -> None: unsupported_repo = Repository.objects.create( organization_id=self.organization.id, name="getsentry/unsupported", @@ -819,7 +819,7 @@ def test_unsupported_provider_returns_400(self): self.get_error_response(repos=[{"repositoryId": unsupported_repo.id}], status_code=400) - def test_upsert_existing_repo(self): + def test_upsert_existing_repo(self) -> None: self.create_seer_project_repository( self.project, repository=self.repo1, branch_name="old-branch" ) @@ -835,13 +835,13 @@ def test_upsert_existing_repo(self): ) assert project_repo.branch_name == "new-branch" - def test_inactive_repo_returns_400(self): + def test_inactive_repo_returns_400(self) -> None: self.repo1.status = ObjectStatus.HIDDEN self.repo1.save() self.get_error_response(repos=[{"repositoryId": self.repo1.id}], status_code=400) - def test_duplicate_branch_override_returns_400(self): + def test_duplicate_branch_override_returns_400(self) -> None: self.get_error_response( repos=[ { @@ -863,10 +863,10 @@ def test_duplicate_branch_override_returns_400(self): status_code=400, ) - def test_missing_repository_id_returns_400(self): + def test_missing_repository_id_returns_400(self) -> None: self.get_error_response(repos=[{"branchName": "main"}], status_code=400) - def test_duplicate_repository_ids_returns_400(self): + def test_duplicate_repository_ids_returns_400(self) -> None: self.get_error_response( repos=[ {"repositoryId": self.repo1.id}, @@ -907,7 +907,7 @@ class ProjectSeerReposPutTest(APITestCase): endpoint = "sentry-api-0-project-seer-repos" method = "put" - def reverse_url(self): + def reverse_url(self) -> str: return reverse( self.endpoint, kwargs={ @@ -932,7 +932,7 @@ def setUp(self) -> None: external_id="222", ) - def test_replace_all_repos(self): + def test_replace_all_repos(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.get_success_response( @@ -950,7 +950,7 @@ def test_replace_all_repos(self): ) assert new_repo.branch_name == "develop" - def test_put_creates_audit_log_entry(self): + def test_put_creates_audit_log_entry(self) -> None: repos = [ { "repositoryId": self.repo1.id, @@ -994,7 +994,7 @@ def test_put_creates_audit_log_entry(self): f"replaced active Seer repository configurations for project {self.project.slug}" ) - def test_invalid_put_does_not_create_audit_log_entry(self): + def test_invalid_put_does_not_create_audit_log_entry(self) -> None: with outbox_runner(): self.get_error_response(repos=[{"repositoryId": 99999}], status_code=400) @@ -1004,7 +1004,7 @@ def test_invalid_put_does_not_create_audit_log_entry(self): event=audit_log.get_event_id("SEER_PROJECT_REPOSITORIES_REPLACE"), ).exists() - def test_replace_with_empty_clears_all(self): + def test_replace_with_empty_clears_all(self) -> None: self.create_seer_project_repository(self.project, repository=self.repo1) self.create_seer_project_repository(self.project, repository=self.repo2) @@ -1023,10 +1023,10 @@ def test_replace_with_empty_clears_all(self): == 0 ) - def test_replace_invalid_repo_returns_400(self): + def test_replace_invalid_repo_returns_400(self) -> None: self.get_error_response(repos=[{"repositoryId": 99999}], status_code=400) - def test_replace_with_branch_overrides(self): + def test_replace_with_branch_overrides(self) -> None: self.get_success_response( repos=[ { @@ -1049,7 +1049,7 @@ def test_replace_with_branch_overrides(self): ) assert project_repo.branch_overrides.count() == 1 - def test_replace_unsupported_provider_returns_400(self): + def test_replace_unsupported_provider_returns_400(self) -> None: unsupported_repo = Repository.objects.create( organization_id=self.organization.id, name="getsentry/unsupported", @@ -1059,7 +1059,7 @@ def test_replace_unsupported_provider_returns_400(self): self.get_error_response(repos=[{"repositoryId": unsupported_repo.id}], status_code=400) - def test_other_org_repo_returns_400(self): + def test_other_org_repo_returns_400(self) -> None: other_org = self.create_organization(owner=self.user) other_repo = Repository.objects.create( organization_id=other_org.id, name="other/repo", provider="github", external_id="999" diff --git a/tests/sentry/seer/endpoints/test_project_seer_settings.py b/tests/sentry/seer/endpoints/test_project_seer_settings.py index 990dc135f77c..e07e8c85944e 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_settings.py +++ b/tests/sentry/seer/endpoints/test_project_seer_settings.py @@ -1,9 +1,11 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from django.urls import reverse from sentry.constants import ObjectStatus from sentry.models.options.project_option import ProjectOption +from sentry.models.project import Project +from sentry.models.repository import Repository from sentry.seer.autofix.constants import AutofixAutomationTuningSettings from sentry.seer.models import AutofixHandoffPoint from sentry.testutils.cases import APITestCase @@ -34,7 +36,7 @@ def setUp(self) -> None: }, ) - def create_seer_eligible_repo(self, project, name): + def create_seer_eligible_repo(self, project: Project, name: str) -> Repository: """Create a repo that Seer can use under both seat-based and token-based billing.""" return self.create_repo( project=project, @@ -44,7 +46,7 @@ def create_seer_eligible_repo(self, project, name): external_id=name, ) - def test_get_returns_defaults(self, mock_is_seat_based) -> None: + def test_get_returns_defaults(self, mock_is_seat_based: MagicMock) -> None: """A project with no options set should return defaults.""" response = self.client.get(self.url) @@ -62,7 +64,7 @@ def test_get_returns_defaults(self, mock_is_seat_based) -> None: "reposCount": 0, } - def test_get_returns_configured_project_options(self, mock_is_seat_based) -> None: + def test_get_returns_configured_project_options(self, mock_is_seat_based: MagicMock) -> None: """A project with explicit options should reflect them in the response.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM @@ -78,7 +80,7 @@ def test_get_returns_configured_project_options(self, mock_is_seat_based) -> Non assert response.data["automationTuning"] == "medium" assert response.data["scannerAutomation"] is False - def test_get_external_agent_with_integration_id(self, mock_is_seat_based) -> None: + def test_get_external_agent_with_integration_id(self, mock_is_seat_based: MagicMock) -> None: """A project with an external handoff should return the agent, integration ID, and autoCreatePr from the handoff config.""" self.project.update_option( @@ -96,7 +98,7 @@ def test_get_external_agent_with_integration_id(self, mock_is_seat_based) -> Non assert response.data["integrationId"] == "42" assert response.data["autoCreatePr"] is False - def test_get_external_agent_with_auto_create_pr(self, mock_is_seat_based) -> None: + def test_get_external_agent_with_auto_create_pr(self, mock_is_seat_based: MagicMock) -> None: """autoCreatePr should reflect the handoff config value.""" self.project.update_option( "sentry:seer_automation_handoff_target", "cursor_background_agent" @@ -112,7 +114,7 @@ def test_get_external_agent_with_auto_create_pr(self, mock_is_seat_based) -> Non assert response.status_code == 200 assert response.data["autoCreatePr"] is True - def test_get_stopping_point_off_when_tuning_off(self, mock_is_seat_based) -> None: + def test_get_stopping_point_off_when_tuning_off(self, mock_is_seat_based: MagicMock) -> None: """stoppingPoint should be 'off' when tuning is OFF.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.OFF @@ -125,7 +127,7 @@ def test_get_stopping_point_off_when_tuning_off(self, mock_is_seat_based) -> Non assert response.data["stoppingPoint"] == "off" assert response.data["automationTuning"] == "off" - def test_get_stopping_point_when_tuning_on(self, mock_is_seat_based) -> None: + def test_get_stopping_point_when_tuning_on(self, mock_is_seat_based: MagicMock) -> None: """When tuning is not OFF, stoppingPoint should reflect the stored value.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM @@ -138,7 +140,7 @@ def test_get_stopping_point_when_tuning_on(self, mock_is_seat_based) -> None: assert response.data["stoppingPoint"] == "root_cause" assert response.data["automationTuning"] == "medium" - def test_get_repos_count(self, mock_is_seat_based) -> None: + def test_get_repos_count(self, mock_is_seat_based: MagicMock) -> None: """reposCount should reflect active repos Seer can use.""" repo1 = self.create_seer_eligible_repo(self.project, "owner/repo-1") repo2 = self.create_seer_eligible_repo(self.project, "owner/repo-2") @@ -150,7 +152,7 @@ def test_get_repos_count(self, mock_is_seat_based) -> None: assert response.status_code == 200 assert response.data["reposCount"] == 2 - def test_get_repos_count_excludes_inactive_repos(self, mock_is_seat_based) -> None: + def test_get_repos_count_excludes_inactive_repos(self, mock_is_seat_based: MagicMock) -> None: """Repos with non-active status should not be counted.""" active_repo = self.create_seer_eligible_repo(self.project, "owner/active") disabled_repo = self.create_seer_eligible_repo(self.project, "owner/deleted") @@ -164,7 +166,7 @@ def test_get_repos_count_excludes_inactive_repos(self, mock_is_seat_based) -> No assert response.status_code == 200 assert response.data["reposCount"] == 1 - def test_put_returns_updated_settings(self, mock_is_seat_based) -> None: + def test_put_returns_updated_settings(self, mock_is_seat_based: MagicMock) -> None: """PUT response should contain the full updated settings object.""" response = self.client.put( self.url, @@ -180,7 +182,7 @@ def test_put_returns_updated_settings(self, mock_is_seat_based) -> None: assert "scannerAutomation" in response.data assert "reposCount" in response.data - def test_put_external_agent_with_valid_integration(self, mock_is_seat_based) -> None: + def test_put_external_agent_with_valid_integration(self, mock_is_seat_based: MagicMock) -> None: """Valid external agent + integrationId should succeed and reflect in response.""" integration = self.create_integration( organization=self.organization, external_id="ext", provider="github" @@ -195,14 +197,14 @@ def test_put_external_agent_with_valid_integration(self, mock_is_seat_based) -> assert response.data["agent"] == "cursor_background_agent" assert response.data["integrationId"] == str(integration.id) - def test_put_scanner_automation(self, mock_is_seat_based) -> None: + def test_put_scanner_automation(self, mock_is_seat_based: MagicMock) -> None: """PUT scannerAutomation should update and return the new value.""" response = self.client.put(self.url, data={"scannerAutomation": False}, format="json") assert response.status_code == 200 assert response.data["scannerAutomation"] is False - def test_put_pr_iteration(self, mock_is_seat_based) -> None: + def test_put_pr_iteration(self, mock_is_seat_based: MagicMock) -> None: response = self.client.put(self.url, data={"prIteration": False}, format="json") assert response.status_code == 200 @@ -218,13 +220,15 @@ def test_put_pr_iteration(self, mock_is_seat_based) -> None: project=self.project, key="sentry:seer_pr_iteration" ).exists() - def test_put_stopping_point(self, mock_is_seat_based) -> None: + def test_put_stopping_point(self, mock_is_seat_based: MagicMock) -> None: response = self.client.put(self.url, data={"stoppingPoint": "open_pr"}, format="json") assert response.status_code == 200 assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" - def test_put_stopping_point_open_pr_syncs_auto_create_pr(self, mock_is_seat_based) -> None: + def test_put_stopping_point_open_pr_syncs_auto_create_pr( + self, mock_is_seat_based: MagicMock + ) -> None: """Setting stoppingPoint to open_pr should also set auto_create_pr to True.""" response = self.client.put(self.url, data={"stoppingPoint": "open_pr"}, format="json") @@ -232,7 +236,9 @@ def test_put_stopping_point_open_pr_syncs_auto_create_pr(self, mock_is_seat_base assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - def test_put_stopping_point_non_open_pr_clears_auto_create_pr(self, mock_is_seat_based) -> None: + def test_put_stopping_point_non_open_pr_clears_auto_create_pr( + self, mock_is_seat_based: MagicMock + ) -> None: """Setting stoppingPoint to non-open_pr should clear auto_create_pr.""" self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) @@ -243,7 +249,7 @@ def test_put_stopping_point_non_open_pr_clears_auto_create_pr(self, mock_is_seat assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False def test_put_legacy_stopping_point_does_not_sync_auto_create_pr( - self, mock_is_seat_based + self, mock_is_seat_based: MagicMock ) -> None: """Legacy: changing stoppingPoint should not touch auto_create_pr.""" mock_is_seat_based.return_value = False @@ -254,7 +260,7 @@ def test_put_legacy_stopping_point_does_not_sync_auto_create_pr( assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False - def test_put_legacy_auto_create_pr(self, mock_is_seat_based) -> None: + def test_put_legacy_auto_create_pr(self, mock_is_seat_based: MagicMock) -> None: """autoCreatePr should update the handoff option directly.""" mock_is_seat_based.return_value = False @@ -263,7 +269,9 @@ def test_put_legacy_auto_create_pr(self, mock_is_seat_based) -> None: assert response.status_code == 200 assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - def test_put_legacy_auto_create_pr_preserves_stopping_point(self, mock_is_seat_based) -> None: + def test_put_legacy_auto_create_pr_preserves_stopping_point( + self, mock_is_seat_based: MagicMock + ) -> None: """autoCreatePr should not change the stored stopping_point.""" mock_is_seat_based.return_value = False @@ -274,7 +282,7 @@ def test_put_legacy_auto_create_pr_preserves_stopping_point(self, mock_is_seat_b assert response.status_code == 200 assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "root_cause" - def test_put_automation_tuning(self, mock_is_seat_based) -> None: + def test_put_automation_tuning(self, mock_is_seat_based: MagicMock) -> None: """Seat-based: automationTuning accepts off and medium.""" response = self.client.put(self.url, data={"automationTuning": "off"}, format="json") assert response.status_code == 200 @@ -290,12 +298,14 @@ def test_put_automation_tuning(self, mock_is_seat_based) -> None: == AutofixAutomationTuningSettings.MEDIUM ) - def test_put_automation_tuning_rejects_granular(self, mock_is_seat_based) -> None: + def test_put_automation_tuning_rejects_granular(self, mock_is_seat_based: MagicMock) -> None: """Seat-based: granular tuning values like 'high' should be rejected.""" response = self.client.put(self.url, data={"automationTuning": "high"}, format="json") assert response.status_code == 400 - def test_put_legacy_automation_tuning_allows_granular(self, mock_is_seat_based) -> None: + def test_put_legacy_automation_tuning_allows_granular( + self, mock_is_seat_based: MagicMock + ) -> None: """Legacy: automationTuning should set tuning directly.""" mock_is_seat_based.return_value = False response = self.client.put(self.url, data={"automationTuning": "high"}, format="json") @@ -306,19 +316,21 @@ def test_put_legacy_automation_tuning_allows_granular(self, mock_is_seat_based) == AutofixAutomationTuningSettings.HIGH ) - def test_put_requires_at_least_one_update_field(self, mock_is_seat_based) -> None: + def test_put_requires_at_least_one_update_field(self, mock_is_seat_based: MagicMock) -> None: """Sending no update fields should return 400.""" response = self.client.put(self.url, data={}, format="json") assert response.status_code == 400 - def test_put_requires_integration_id_for_external_agent(self, mock_is_seat_based) -> None: + def test_put_requires_integration_id_for_external_agent( + self, mock_is_seat_based: MagicMock + ) -> None: """External agent without integrationId should return 400.""" response = self.client.put( self.url, data={"agent": "cursor_background_agent"}, format="json" ) assert response.status_code == 400 - def test_put_rejects_integration_id_without_agent(self, mock_is_seat_based) -> None: + def test_put_rejects_integration_id_without_agent(self, mock_is_seat_based: MagicMock) -> None: """integrationId without agent should return 400.""" integration = self.create_integration( organization=self.organization, external_id="valid", provider="github" @@ -326,7 +338,9 @@ def test_put_rejects_integration_id_without_agent(self, mock_is_seat_based) -> N response = self.client.put(self.url, data={"integrationId": integration.id}, format="json") assert response.status_code == 400 - def test_put_rejects_integration_id_with_seer_agent(self, mock_is_seat_based) -> None: + def test_put_rejects_integration_id_with_seer_agent( + self, mock_is_seat_based: MagicMock + ) -> None: """integrationId with agent=seer should return 400.""" integration = self.create_integration( organization=self.organization, external_id="valid", provider="github" @@ -336,7 +350,7 @@ def test_put_rejects_integration_id_with_seer_agent(self, mock_is_seat_based) -> ) assert response.status_code == 400 - def test_put_rejects_integration_id_from_other_org(self, mock_is_seat_based) -> None: + def test_put_rejects_integration_id_from_other_org(self, mock_is_seat_based: MagicMock) -> None: """An integration ID that doesn't belong to this org should return 400.""" other_org = self.create_organization() integration = self.create_integration( @@ -350,22 +364,24 @@ def test_put_rejects_integration_id_from_other_org(self, mock_is_seat_based) -> ) assert response.status_code == 400 - def test_put_seer_agent_does_not_require_integration_id(self, mock_is_seat_based) -> None: + def test_put_seer_agent_does_not_require_integration_id( + self, mock_is_seat_based: MagicMock + ) -> None: """agent=seer should not require integrationId.""" response = self.client.put(self.url, data={"agent": "seer"}, format="json") assert response.status_code == 200 - def test_put_rejects_invalid_agent(self, mock_is_seat_based) -> None: + def test_put_rejects_invalid_agent(self, mock_is_seat_based: MagicMock) -> None: """An unrecognized agent value should return 400.""" response = self.client.put(self.url, data={"agent": "invalid"}, format="json") assert response.status_code == 400 - def test_put_rejects_invalid_stopping_point(self, mock_is_seat_based) -> None: + def test_put_rejects_invalid_stopping_point(self, mock_is_seat_based: MagicMock) -> None: """An unrecognized stoppingPoint value should return 400.""" response = self.client.put(self.url, data={"stoppingPoint": "invalid"}, format="json") assert response.status_code == 400 - def test_put_creates_audit_log_entry(self, mock_is_seat_based) -> None: + def test_put_creates_audit_log_entry(self, mock_is_seat_based: MagicMock) -> None: """PUT should create an audit log entry with the project ID.""" from sentry.models.auditlogentry import AuditLogEntry from sentry.silo.base import SiloMode @@ -477,7 +493,7 @@ def setUp(self) -> None: kwargs={"organization_id_or_slug": self.organization.slug}, ) - def create_seer_eligible_repo(self, project, name): + def create_seer_eligible_repo(self, project: Project, name: str) -> Repository: """Create a repo that Seer can use under both seat-based and token-based billing.""" return self.create_repo( project=project, @@ -487,7 +503,7 @@ def create_seer_eligible_repo(self, project, name): external_id=name, ) - def test_get_returns_defaults(self, mock_is_seat_based) -> None: + def test_get_returns_defaults(self, mock_is_seat_based: MagicMock) -> None: """Projects with no options set should return default values.""" response = self.client.get(self.url) @@ -506,7 +522,7 @@ def test_get_returns_defaults(self, mock_is_seat_based) -> None: "reposCount": 0, } - def test_get_returns_configured_project_options(self, mock_is_seat_based) -> None: + def test_get_returns_configured_project_options(self, mock_is_seat_based: MagicMock) -> None: """Projects with explicit option values should reflect those in the response.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM @@ -522,7 +538,7 @@ def test_get_returns_configured_project_options(self, mock_is_seat_based) -> Non assert response.data[0]["automationTuning"] == "medium" assert response.data[0]["scannerAutomation"] is False - def test_get_external_agent_with_integration_id(self, mock_is_seat_based) -> None: + def test_get_external_agent_with_integration_id(self, mock_is_seat_based: MagicMock) -> None: """A project configured with an external handoff should return the agent, integration ID, and autoCreatePr from the handoff config.""" self.project.update_option( @@ -540,7 +556,7 @@ def test_get_external_agent_with_integration_id(self, mock_is_seat_based) -> Non assert response.data[0]["integrationId"] == "42" assert response.data[0]["autoCreatePr"] is False - def test_get_external_agent_with_auto_create_pr(self, mock_is_seat_based) -> None: + def test_get_external_agent_with_auto_create_pr(self, mock_is_seat_based: MagicMock) -> None: """autoCreatePr should reflect the handoff config value.""" self.project.update_option( "sentry:seer_automation_handoff_target", "cursor_background_agent" @@ -556,7 +572,7 @@ def test_get_external_agent_with_auto_create_pr(self, mock_is_seat_based) -> Non assert response.status_code == 200 assert response.data[0]["autoCreatePr"] is True - def test_get_stopping_point_off_when_tuning_off(self, mock_is_seat_based) -> None: + def test_get_stopping_point_off_when_tuning_off(self, mock_is_seat_based: MagicMock) -> None: """When tuning is OFF, stoppingPoint should be 'off' regardless of the stored seer_automated_run_stopping_point value.""" self.project.update_option( @@ -570,7 +586,7 @@ def test_get_stopping_point_off_when_tuning_off(self, mock_is_seat_based) -> Non assert response.data[0]["stoppingPoint"] == "off" assert response.data[0]["automationTuning"] == "off" - def test_get_stopping_point_when_tuning_on(self, mock_is_seat_based) -> None: + def test_get_stopping_point_when_tuning_on(self, mock_is_seat_based: MagicMock) -> None: """When tuning is not OFF, stoppingPoint should reflect the stored value.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM @@ -583,7 +599,7 @@ def test_get_stopping_point_when_tuning_on(self, mock_is_seat_based) -> None: assert response.data[0]["stoppingPoint"] == "root_cause" assert response.data[0]["automationTuning"] == "medium" - def test_get_repos_count(self, mock_is_seat_based) -> None: + def test_get_repos_count(self, mock_is_seat_based: MagicMock) -> None: """reposCount should reflect the number of active repos Seer can use.""" repo1 = self.create_seer_eligible_repo(self.project, "owner/repo-1") repo2 = self.create_seer_eligible_repo(self.project, "owner/repo-2") @@ -595,7 +611,7 @@ def test_get_repos_count(self, mock_is_seat_based) -> None: assert response.status_code == 200 assert response.data[0]["reposCount"] == 2 - def test_get_repos_count_excludes_inactive_repos(self, mock_is_seat_based) -> None: + def test_get_repos_count_excludes_inactive_repos(self, mock_is_seat_based: MagicMock) -> None: """Repos with non-active status should not be counted.""" active_repo = self.create_seer_eligible_repo(self.project, "owner/active") disabled_repo = self.create_seer_eligible_repo(self.project, "owner/deleted") @@ -609,7 +625,7 @@ def test_get_repos_count_excludes_inactive_repos(self, mock_is_seat_based) -> No assert response.status_code == 200 assert response.data[0]["reposCount"] == 1 - def test_get_only_returns_accessible_projects(self, mock_is_seat_based) -> None: + def test_get_only_returns_accessible_projects(self, mock_is_seat_based: MagicMock) -> None: """Response should only include projects the user has access to.""" self.organization.flags.allow_joinleave = False self.organization.save() @@ -629,7 +645,7 @@ def test_get_only_returns_accessible_projects(self, mock_is_seat_based) -> None: assert len(project_ids) == 1 assert str(inaccessible_project.id) not in project_ids - def test_get_paginates_results(self, mock_is_seat_based) -> None: + def test_get_paginates_results(self, mock_is_seat_based: MagicMock) -> None: """Results should be paginated with Link headers indicating next/previous.""" for i in range(5): self.create_project(organization=self.organization, slug=f"paginate-{i}") @@ -644,7 +660,7 @@ def test_get_paginates_results(self, mock_is_seat_based) -> None: assert 'rel="previous"; results="true"' in response2.headers["Link"] assert 'rel="next"; results="false"' in response2.headers["Link"] - def test_get_sort_by_name(self, mock_is_seat_based) -> None: + def test_get_sort_by_name(self, mock_is_seat_based: MagicMock) -> None: """sortBy=name should order by project slug.""" project_b = self.create_project(organization=self.organization, slug="banana") project_a = self.create_project(organization=self.organization, slug="apple") @@ -655,7 +671,7 @@ def test_get_sort_by_name(self, mock_is_seat_based) -> None: slugs = [r["projectSlug"] for r in response.data] assert slugs.index(project_a.slug) < slugs.index(project_b.slug) - def test_get_sort_by_repos_count(self, mock_is_seat_based) -> None: + def test_get_sort_by_repos_count(self, mock_is_seat_based: MagicMock) -> None: """sortBy=reposCount should order by the number of repos Seer can use.""" project1 = self.create_project(organization=self.organization) for i in range(2): @@ -669,7 +685,7 @@ def test_get_sort_by_repos_count(self, mock_is_seat_based) -> None: ids = [r["projectId"] for r in response.data] assert ids.index(str(project2.id)) < ids.index(str(project1.id)) - def test_get_sort_by_agent(self, mock_is_seat_based) -> None: + def test_get_sort_by_agent(self, mock_is_seat_based: MagicMock) -> None: """sortBy=agent should order alphabetically by agent alias.""" project_seer = self.create_project(organization=self.organization) @@ -688,7 +704,7 @@ def test_get_sort_by_agent(self, mock_is_seat_based) -> None: assert ids.index(str(project_claude.id)) < ids.index(str(project_cursor.id)) assert ids.index(str(project_cursor.id)) < ids.index(str(project_seer.id)) - def test_get_sort_by_stopping_point(self, mock_is_seat_based) -> None: + def test_get_sort_by_stopping_point(self, mock_is_seat_based: MagicMock) -> None: """sortBy=stoppingPoint should order by hierarchy rank (off < root_cause < code_changes < open_pr).""" project_open_pr = self.create_project(organization=self.organization) project_open_pr.update_option( @@ -711,19 +727,19 @@ def test_get_sort_by_stopping_point(self, mock_is_seat_based) -> None: assert ids.index(str(self.project.id)) < ids.index(str(project_root_cause.id)) assert ids.index(str(project_root_cause.id)) < ids.index(str(project_open_pr.id)) - def test_get_sort_by_invalid_field_returns_400(self, mock_is_seat_based) -> None: + def test_get_sort_by_invalid_field_returns_400(self, mock_is_seat_based: MagicMock) -> None: """An unrecognized sortBy value should return 400.""" response = self.client.get(self.url, {"sortBy": "invalid"}) assert response.status_code == 400 - def test_get_filter_empty_results(self, mock_is_seat_based) -> None: + def test_get_filter_empty_results(self, mock_is_seat_based: MagicMock) -> None: """A filter that matches nothing should return an empty list.""" response = self.client.get(self.url, {"query": "id:999999999"}) assert response.status_code == 200 assert response.data == [] - def test_get_filter_by_free_text_name(self, mock_is_seat_based) -> None: + def test_get_filter_by_free_text_name(self, mock_is_seat_based: MagicMock) -> None: """Free text query should match against both name and slug.""" project1 = self.create_project( organization=self.organization, name="", slug="matching-slug" @@ -742,7 +758,7 @@ def test_get_filter_by_free_text_name(self, mock_is_seat_based) -> None: assert str(project2.id) in ids assert str(project3.id) not in ids - def test_get_filter_by_id(self, mock_is_seat_based) -> None: + def test_get_filter_by_id(self, mock_is_seat_based: MagicMock) -> None: """id:N should return only the project with that ID.""" self.create_project(organization=self.organization) project = self.create_project(organization=self.organization) @@ -753,7 +769,7 @@ def test_get_filter_by_id(self, mock_is_seat_based) -> None: ids = [r["projectId"] for r in response.data] assert ids == [str(project.id)] - def test_get_filter_by_id_list(self, mock_is_seat_based) -> None: + def test_get_filter_by_id_list(self, mock_is_seat_based: MagicMock) -> None: """id:[N,M] should return only the projects with those IDs.""" project1 = self.create_project(organization=self.organization) project2 = self.create_project(organization=self.organization) @@ -765,7 +781,7 @@ def test_get_filter_by_id_list(self, mock_is_seat_based) -> None: ids = [r["projectId"] for r in response.data] assert sorted(ids) == sorted([str(project1.id), str(project2.id)]) - def test_get_filter_by_repos_count(self, mock_is_seat_based) -> None: + def test_get_filter_by_repos_count(self, mock_is_seat_based: MagicMock) -> None: """reposCount with numeric operators.""" project1 = self.create_project(organization=self.organization) for i in range(2): @@ -784,7 +800,7 @@ def test_get_filter_by_repos_count(self, mock_is_seat_based) -> None: assert str(project2.id) in ids assert str(project1.id) not in ids - def test_get_filter_by_stopping_point(self, mock_is_seat_based) -> None: + def test_get_filter_by_stopping_point(self, mock_is_seat_based: MagicMock) -> None: """stoppingPoint filter should account for tuning state.""" project1 = self.create_project(organization=self.organization) project1.update_option( @@ -803,7 +819,7 @@ def test_get_filter_by_stopping_point(self, mock_is_seat_based) -> None: assert str(project1.id) in ids assert str(self.project.id) not in ids - def test_get_filter_by_agent_seer(self, mock_is_seat_based) -> None: + def test_get_filter_by_agent_seer(self, mock_is_seat_based: MagicMock) -> None: """agent:seer should return projects with no handoff target (NULL).""" project1 = self.create_project(organization=self.organization) project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -815,7 +831,7 @@ def test_get_filter_by_agent_seer(self, mock_is_seat_based) -> None: assert str(self.project.id) in ids assert str(project1.id) not in ids - def test_get_filter_by_agent_external(self, mock_is_seat_based) -> None: + def test_get_filter_by_agent_external(self, mock_is_seat_based: MagicMock) -> None: """agent:cursor_background_agent should return projects with cursor handoff target.""" project1 = self.create_project(organization=self.organization) project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -827,7 +843,7 @@ def test_get_filter_by_agent_external(self, mock_is_seat_based) -> None: assert str(project1.id) in ids assert str(self.project.id) not in ids - def test_get_filter_negation(self, mock_is_seat_based) -> None: + def test_get_filter_negation(self, mock_is_seat_based: MagicMock) -> None: """!agent:seer should exclude projects with no handoff target.""" project1 = self.create_project(organization=self.organization) project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -839,7 +855,7 @@ def test_get_filter_negation(self, mock_is_seat_based) -> None: assert str(project1.id) in ids assert str(self.project.id) not in ids - def test_get_multiple_filters(self, mock_is_seat_based) -> None: + def test_get_multiple_filters(self, mock_is_seat_based: MagicMock) -> None: """Combining multiple filters should intersect the results.""" project1 = self.create_project(organization=self.organization) project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -857,13 +873,13 @@ def test_get_multiple_filters(self, mock_is_seat_based) -> None: ids = [r["projectId"] for r in response.data] assert ids == [str(project1.id)] - def test_get_invalid_search_query_returns_400(self, mock_is_seat_based) -> None: + def test_get_invalid_search_query_returns_400(self, mock_is_seat_based: MagicMock) -> None: """A malformed search query should return 400 with detail.""" response = self.client.get(self.url, {"query": "bogusKey:value"}) assert response.status_code == 400 assert "detail" in response.data - def test_put_updates_all_projects(self, mock_is_seat_based) -> None: + def test_put_updates_all_projects(self, mock_is_seat_based: MagicMock) -> None: """Empty query should update all accessible projects.""" project2 = self.create_project(organization=self.organization) @@ -873,7 +889,7 @@ def test_put_updates_all_projects(self, mock_is_seat_based) -> None: assert self.project.get_option("sentry:seer_scanner_automation") is False assert project2.get_option("sentry:seer_scanner_automation") is False - def test_put_applies_to_filtered_projects_only(self, mock_is_seat_based) -> None: + def test_put_applies_to_filtered_projects_only(self, mock_is_seat_based: MagicMock) -> None: """The query parameter should scope which projects get updated.""" project2 = self.create_project(organization=self.organization) project2.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -888,7 +904,7 @@ def test_put_applies_to_filtered_projects_only(self, mock_is_seat_based) -> None assert project2.get_option("sentry:seer_scanner_automation") is False assert self.project.get_option("sentry:seer_scanner_automation") is True - def test_put_updates_settings(self, mock_is_seat_based) -> None: + def test_put_updates_settings(self, mock_is_seat_based: MagicMock) -> None: """Bulk update with multiple seer agent fields should apply all of them.""" project2 = self.create_project(organization=self.organization) @@ -912,7 +928,7 @@ def test_put_updates_settings(self, mock_is_seat_based) -> None: ) assert p.get_option("sentry:seer_scanner_automation") is False - def test_put_updates_settings_with_external_agent(self, mock_is_seat_based) -> None: + def test_put_updates_settings_with_external_agent(self, mock_is_seat_based: MagicMock) -> None: """Bulk update with external agent fields should set agent, integration, and stopping point.""" project2 = self.create_project(organization=self.organization) integration = self.create_integration( @@ -939,7 +955,7 @@ def test_put_updates_settings_with_external_agent(self, mock_is_seat_based) -> N assert p.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" assert p.get_option("sentry:seer_scanner_automation") is True - def test_put_legacy_updates_settings(self, mock_is_seat_based) -> None: + def test_put_legacy_updates_settings(self, mock_is_seat_based: MagicMock) -> None: """Legacy: bulk update with granular tuning, auto_create_pr, and external agent.""" mock_is_seat_based.return_value = False project2 = self.create_project(organization=self.organization) @@ -970,14 +986,14 @@ def test_put_legacy_updates_settings(self, mock_is_seat_based) -> None: ) assert p.get_option("sentry:seer_automation_handoff_integration_id") == integration.id - def test_put_invalid_search_query_returns_400(self, mock_is_seat_based) -> None: + def test_put_invalid_search_query_returns_400(self, mock_is_seat_based: MagicMock) -> None: """A malformed query value should return 400.""" response = self.client.put( self.url, data={"query": "invalidKey:value", "scannerAutomation": False}, format="json" ) assert response.status_code == 400 - def test_put_creates_audit_log_entry(self, mock_is_seat_based) -> None: + def test_put_creates_audit_log_entry(self, mock_is_seat_based: MagicMock) -> None: """Bulk update should create an audit log entry with project count and IDs.""" from sentry.models.auditlogentry import AuditLogEntry from sentry.silo.base import SiloMode diff --git a/tests/sentry/seer/endpoints/test_seer_rpc.py b/tests/sentry/seer/endpoints/test_seer_rpc.py index 8005c81c17e7..76d1ab26a7d2 100644 --- a/tests/sentry/seer/endpoints/test_seer_rpc.py +++ b/tests/sentry/seer/endpoints/test_seer_rpc.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone from time import time from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch from uuid import uuid4 import orjson @@ -71,7 +71,7 @@ def _get_path(method_name: str) -> str: kwargs={"method_name": method_name}, ) - def auth_header(self, path: str, data: dict | str) -> str: + def auth_header(self, path: str, data: dict[str, object] | str) -> str: if isinstance(data, dict): data = orjson.dumps(data).decode() signature = generate_request_signature(path, data.encode()) @@ -222,7 +222,7 @@ def setUp(self) -> None: self.organization = self.create_organization(owner=self.user) @pytest.fixture(autouse=True) - def inject_fixtures(self, caplog: pytest.LogCaptureFixture): + def inject_fixtures(self, caplog: pytest.LogCaptureFixture) -> None: self._caplog = caplog def test_get_attributes_for_span(self) -> None: @@ -256,7 +256,7 @@ def test_get_attributes_for_span(self) -> None: @responses.activate @override_settings(SEER_GHE_ENCRYPT_KEY=TEST_FERNET_KEY) @patch("sentry.integrations.github_enterprise.client.get_jwt", return_value="jwt_token_1") - def test_get_github_enterprise_integration_config(self, mock_get_jwt) -> None: + def test_get_github_enterprise_integration_config(self, mock_get_jwt: MagicMock) -> None: """Test when organization has github enterprise integration""" installation_id = 1234 @@ -403,7 +403,7 @@ def test_get_github_enterprise_integration_config_disabled_integration(self) -> @override_settings(SEER_GHE_ENCRYPT_KEY="invalid") @patch("sentry.integrations.github_enterprise.client.get_jwt", return_value="jwt_token_1") def test_get_github_enterprise_integration_config_invalid_encrypt_key( - self, mock_get_jwt + self, mock_get_jwt: MagicMock ) -> None: installation_id = 1234 private_key = "private_key_1" @@ -491,7 +491,7 @@ def test_send_seer_webhook_organization_inactive(self) -> None: } @patch("sentry.sentry_apps.tasks.sentry_apps.broadcast_webhooks_for_organization.delay") - def test_send_seer_webhook_success(self, mock_delay) -> None: + def test_send_seer_webhook_success(self, mock_delay: MagicMock) -> None: """Test that send_seer_webhook successfully enqueues webhook when all conditions are met""" from sentry.seer.endpoints.seer_rpc import send_seer_webhook @@ -510,7 +510,7 @@ def test_send_seer_webhook_success(self, mock_delay) -> None: ) @patch("sentry.sentry_apps.tasks.sentry_apps.broadcast_webhooks_for_organization.delay") - def test_send_seer_webhook_all_valid_event_names(self, mock_delay) -> None: + def test_send_seer_webhook_all_valid_event_names(self, mock_delay: MagicMock) -> None: """Test that send_seer_webhook works with all valid seer event names""" from sentry.seer.endpoints.seer_rpc import send_seer_webhook from sentry.sentry_apps.event_types import SentryAppEventType @@ -536,7 +536,7 @@ def test_send_seer_webhook_all_valid_event_names(self, mock_delay) -> None: @patch("sentry.seer.endpoints.seer_rpc.process_autofix_updates") @patch("sentry.sentry_apps.tasks.sentry_apps.broadcast_webhooks_for_organization.delay") def test_send_seer_webhook_operator_no_feature_flag( - self, mock_broadcast, mock_process_autofix_updates + self, mock_broadcast: MagicMock, mock_process_autofix_updates: MagicMock ) -> None: """Slack workflows flag should not affect broadcasting the webhooks.""" from sentry.seer.endpoints.seer_rpc import send_seer_webhook @@ -554,7 +554,9 @@ def test_send_seer_webhook_operator_no_feature_flag( @patch("sentry.seer.endpoints.seer_rpc.process_autofix_updates") @patch("sentry.sentry_apps.tasks.sentry_apps.broadcast_webhooks_for_organization.delay") - def test_send_seer_webhook_operator(self, mock_broadcast, mock_process_autofix_updates) -> None: + def test_send_seer_webhook_operator( + self, mock_broadcast: MagicMock, mock_process_autofix_updates: MagicMock + ) -> None: from sentry.seer.endpoints.seer_rpc import send_seer_webhook group = self.create_group(project=self.project) diff --git a/tests/sentry/seer/endpoints/test_superuser_viewer_context.py b/tests/sentry/seer/endpoints/test_superuser_viewer_context.py index 31d1a977839e..908f67c1def7 100644 --- a/tests/sentry/seer/endpoints/test_superuser_viewer_context.py +++ b/tests/sentry/seer/endpoints/test_superuser_viewer_context.py @@ -98,7 +98,9 @@ def test_read_only_browser_elevation_cannot_write(self) -> None: def test_read_write_browser_elevation_cannot_write(self) -> None: self._assert_read_only_elevation(["superuser.write"], 200) - def _assert_read_only_elevation(self, permissions, browser_write_status) -> None: + def _assert_read_only_elevation( + self, permissions: list[str], browser_write_status: int + ) -> None: for permission in permissions: self.add_user_permission(self.employee, permission) with self.options({"superuser.read-write.ga-rollout": True}): diff --git a/tests/sentry/seer/entrypoints/slack/test_investigation_status.py b/tests/sentry/seer/entrypoints/slack/test_investigation_status.py index fa24067432ab..afe0b6658eb0 100644 --- a/tests/sentry/seer/entrypoints/slack/test_investigation_status.py +++ b/tests/sentry/seer/entrypoints/slack/test_investigation_status.py @@ -1,5 +1,5 @@ from datetime import UTC, datetime -from typing import Any +from typing import Any, cast from sentry.notifications.platform.templates.seer import SeerInvestigationStatus from sentry.seer.entrypoints.slack.investigation_status import build_investigation_status @@ -44,7 +44,8 @@ def setUp(self) -> None: ) def _snapshot(self) -> dict[str, Any]: - return self.investigation.source["snapshot"] + # justification: the investigation source carries the snapshot payload. + return cast("dict[str, Any]", self.investigation.source["snapshot"]) def _build( self, hypotheses: list[dict[str, Any]], updated_at: datetime | None = None diff --git a/tests/sentry/seer/entrypoints/slack/test_mention.py b/tests/sentry/seer/entrypoints/slack/test_mention.py index 1729bc9134bf..8b911baee884 100644 --- a/tests/sentry/seer/entrypoints/slack/test_mention.py +++ b/tests/sentry/seer/entrypoints/slack/test_mention.py @@ -120,11 +120,11 @@ def test_ignores_metric_alert_urls(self) -> None: class ExtractBlockTextTest(TestCase): - def test_section_block(self): + def test_section_block(self) -> None: block = SectionBlock(text="hello *world*").to_dict() assert _extract_block_text(block) == "hello *world*" - def test_section_block_with_fields(self): + def test_section_block_with_fields(self) -> None: block = SectionBlock( fields=[ {"type": "mrkdwn", "text": "*Priority*"}, @@ -135,21 +135,21 @@ def test_section_block_with_fields(self): assert "*Priority*" in result assert "High" in result - def test_context_block(self): + def test_context_block(self) -> None: block = ContextBlock( elements=[{"type": "mrkdwn", "text": "Project: test-project"}] ).to_dict() assert _extract_block_text(block) == "Project: test-project" - def test_header_block(self): + def test_header_block(self) -> None: block = HeaderBlock(text="My Header").to_dict() assert _extract_block_text(block) == "My Header" - def test_markdown_block(self): + def test_markdown_block(self) -> None: block = MarkdownBlock(text="markdown content").to_dict() assert _extract_block_text(block) == "markdown content" - def test_rich_text_with_text_and_link(self): + def test_rich_text_with_text_and_link(self) -> None: block = RichTextBlock( elements=[ RichTextSectionElement( @@ -166,7 +166,7 @@ def test_rich_text_with_text_and_link(self): result = _extract_block_text(block) assert result == "Check " - def test_rich_text_link_without_label(self): + def test_rich_text_link_without_label(self) -> None: block = RichTextBlock( elements=[ RichTextSectionElement( @@ -178,7 +178,7 @@ def test_rich_text_link_without_label(self): ).to_dict() assert _extract_block_text(block) == "" - def test_rich_text_with_user_and_channel(self): + def test_rich_text_with_user_and_channel(self) -> None: block = RichTextBlock( elements=[ RichTextSectionElement( @@ -193,7 +193,7 @@ def test_rich_text_with_user_and_channel(self): ).to_dict() assert _extract_block_text(block) == "Hey <@U123> check <#C456>" - def test_rich_text_with_emoji(self): + def test_rich_text_with_emoji(self) -> None: block = RichTextBlock( elements=[ RichTextSectionElement( @@ -206,10 +206,10 @@ def test_rich_text_with_emoji(self): ).to_dict() assert _extract_block_text(block) == ":wave: hello" - def test_divider_returns_empty(self): + def test_divider_returns_empty(self) -> None: assert _extract_block_text(DividerBlock().to_dict()) == "" - def test_actions_block_returns_empty(self): + def test_actions_block_returns_empty(self) -> None: block = { "type": "actions", "elements": [{"type": "button", "text": {"type": "plain_text", "text": "Click"}}], @@ -218,7 +218,7 @@ def test_actions_block_returns_empty(self): class ExtractTextFromBlocksTest(TestCase): - def test_multiple_blocks(self): + def test_multiple_blocks(self) -> None: blocks = [ HeaderBlock(text="Alert Title").to_dict(), SectionBlock(text="Something broke").to_dict(), @@ -228,7 +228,7 @@ def test_multiple_blocks(self): result = _extract_text_from_blocks(blocks) assert result == "Alert Title\nSomething broke\nProject: my-project" - def test_sentry_alert_like_blocks(self): + def test_sentry_alert_like_blocks(self) -> None: blocks = [ SectionBlock( text=":red_circle: " @@ -295,7 +295,7 @@ def test_preserves_urls_in_text(self) -> None: result = build_thread_context(messages) assert "" in result - def test_prefers_blocks_over_text(self): + def test_prefers_blocks_over_text(self) -> None: messages = [ { "user": "U123", @@ -308,7 +308,7 @@ def test_prefers_blocks_over_text(self): assert result == "<@U123>: rich block content" assert "fallback text" not in result - def test_falls_back_to_text_when_blocks_empty(self): + def test_falls_back_to_text_when_blocks_empty(self) -> None: messages = [ { "user": "U123", @@ -320,7 +320,7 @@ def test_falls_back_to_text_when_blocks_empty(self): result = build_thread_context(messages) assert result == "<@U123>: fallback text" - def test_falls_back_to_text_when_blocks_have_no_text(self): + def test_falls_back_to_text_when_blocks_have_no_text(self) -> None: messages = [ { "user": "U123", @@ -332,7 +332,7 @@ def test_falls_back_to_text_when_blocks_have_no_text(self): result = build_thread_context(messages) assert result == "<@U123>: fallback text" - def test_extracts_links_from_rich_text_blocks(self): + def test_extracts_links_from_rich_text_blocks(self) -> None: messages = [ { "user": "U123", @@ -359,7 +359,7 @@ def test_extracts_links_from_rich_text_blocks(self): assert "https://sentry.io/organizations/test-org/issues/999/" in result assert "this issue" in result - def test_mixed_block_and_text_messages(self): + def test_mixed_block_and_text_messages(self) -> None: messages: list[Mapping[str, Any]] = [ { "user": "U123", @@ -376,7 +376,7 @@ def test_mixed_block_and_text_messages(self): result = build_thread_context(messages) assert result == "<@U123>: alert from blocks\n<@U456>: plain text reply" - def test_markdown_block_in_seer_response(self): + def test_markdown_block_in_seer_response(self) -> None: messages = [ { "user": "UBOT", @@ -388,7 +388,7 @@ def test_markdown_block_in_seer_response(self): result = build_thread_context(messages) assert result == "<@UBOT>: Here is the Seer analysis..." - def test_extracts_attachments_when_no_blocks_or_text(self): + def test_extracts_attachments_when_no_blocks_or_text(self) -> None: messages = [ { "user": "UBOT", @@ -410,7 +410,7 @@ def test_extracts_attachments_when_no_blocks_or_text(self): ) assert "invalid literal for int()" in result - def test_includes_both_top_level_text_and_attachments(self): + def test_includes_both_top_level_text_and_attachments(self) -> None: messages = [ { "user": "U123", @@ -424,7 +424,7 @@ def test_includes_both_top_level_text_and_attachments(self): assert "top level message" in result assert "attachment body" in result - def test_extracts_attachments_when_text_empty(self): + def test_extracts_attachments_when_text_empty(self) -> None: messages = [ { "user": "UBOT", @@ -438,7 +438,7 @@ def test_extracts_attachments_when_text_empty(self): class ExtractAttachmentTextTest(TestCase): - def test_legacy_attachment_with_title_and_text(self): + def test_legacy_attachment_with_title_and_text(self) -> None: attachment = { "title": "ValueError: invalid input", "title_link": "https://sentry.io/organizations/test-org/issues/456/", @@ -451,17 +451,17 @@ def test_legacy_attachment_with_title_and_text(self): ) assert "invalid literal for int()" in result - def test_legacy_attachment_title_without_link(self): + def test_legacy_attachment_title_without_link(self) -> None: attachment = {"title": "Just a title", "text": "body"} result = _extract_attachment_text(attachment) assert result == "Just a title\nbody" - def test_legacy_attachment_with_pretext(self): + def test_legacy_attachment_with_pretext(self) -> None: attachment = {"pretext": "Heads up:", "text": "something happened"} result = _extract_attachment_text(attachment) assert result == "Heads up:\nsomething happened" - def test_legacy_attachment_with_fields(self): + def test_legacy_attachment_with_fields(self) -> None: attachment = { "title": "Alert fired", "fields": [ @@ -474,11 +474,11 @@ def test_legacy_attachment_with_fields(self): assert "Project: backend" in result assert "Environment: prod" in result - def test_legacy_attachment_field_value_only(self): + def test_legacy_attachment_field_value_only(self) -> None: attachment = {"fields": [{"value": "no title here"}]} assert _extract_attachment_text(attachment) == "no title here" - def test_attachment_with_blocks_prefers_blocks(self): + def test_attachment_with_blocks_prefers_blocks(self) -> None: attachment = { "fallback": "should not be used", "text": "should not be used", @@ -486,7 +486,7 @@ def test_attachment_with_blocks_prefers_blocks(self): } assert _extract_attachment_text(attachment) == "block content" - def test_message_unfurl_attachment_uses_message_blocks(self): + def test_message_unfurl_attachment_uses_message_blocks(self) -> None: # Slack message-unfurl attachments embed the source message's blocks # one level deeper, in ``message_blocks[0].message.blocks``. attachment = { @@ -508,7 +508,7 @@ def test_message_unfurl_attachment_uses_message_blocks(self): } assert _extract_attachment_text(attachment) == "rich body" - def test_message_unfurl_falls_back_to_text_when_message_blocks_empty(self): + def test_message_unfurl_falls_back_to_text_when_message_blocks_empty(self) -> None: attachment = { "is_msg_unfurl": True, "channel_id": "C0", @@ -518,27 +518,27 @@ def test_message_unfurl_falls_back_to_text_when_message_blocks_empty(self): } assert _extract_attachment_text(attachment) == "flat fallback" - def test_attachment_falls_back_to_fallback_field(self): + def test_attachment_falls_back_to_fallback_field(self) -> None: attachment = {"fallback": "Sentry alert: ValueError in backend"} assert _extract_attachment_text(attachment) == "Sentry alert: ValueError in backend" - def test_attachment_skips_fallback_when_other_fields_present(self): + def test_attachment_skips_fallback_when_other_fields_present(self) -> None: attachment = {"text": "main body", "fallback": "ignored"} assert _extract_attachment_text(attachment) == "main body" - def test_empty_attachment(self): + def test_empty_attachment(self) -> None: assert _extract_attachment_text({}) == "" class ExtractTextFromAttachmentsTest(TestCase): - def test_multiple_attachments_joined(self): + def test_multiple_attachments_joined(self) -> None: attachments: list[Mapping[str, Any]] = [ {"text": "first"}, {"text": "second"}, ] assert _extract_text_from_attachments(attachments) == "first\nsecond" - def test_skips_empty_attachments(self): + def test_skips_empty_attachments(self) -> None: attachments: list[Mapping[str, Any]] = [ {"text": "first"}, {}, @@ -546,7 +546,7 @@ def test_skips_empty_attachments(self): ] assert _extract_text_from_attachments(attachments) == "first\nsecond" - def test_skips_non_dict_entries(self): + def test_skips_non_dict_entries(self) -> None: attachments: list[Any] = [{"text": "first"}, "not-a-dict", {"text": "second"}] assert _extract_text_from_attachments(attachments) == "first\nsecond" diff --git a/tests/sentry/seer/entrypoints/slack/test_slack.py b/tests/sentry/seer/entrypoints/slack/test_slack.py index b087ffaa2a5d..5820211315a9 100644 --- a/tests/sentry/seer/entrypoints/slack/test_slack.py +++ b/tests/sentry/seer/entrypoints/slack/test_slack.py @@ -1,14 +1,15 @@ from datetime import UTC, datetime from typing import Any -from unittest.mock import ANY, Mock, patch +from unittest.mock import ANY, MagicMock, Mock, patch import pytest -from slack_sdk.errors import SlackApiError from slack_sdk.models.blocks import LinkButtonElement, SectionBlock from slack_sdk.web import SlackResponse from fixtures.seer.webhooks import MOCK_RUN_ID, MOCK_SEER_WEBHOOKS +from sentry.integrations.slack.integration import SlackIntegration from sentry.integrations.slack.message_builder.types import SlackAction +from sentry.integrations.slack.utils.errors import TypedSlackApiError from sentry.integrations.types import EventLifecycleOutcome from sentry.investigations.models import InvestigationOrchestrationRun from sentry.investigations.models.orchestration import ( @@ -96,6 +97,11 @@ def setUp(self) -> None: value=AutofixStoppingPoint.ROOT_CAUSE.value, ) + def _get_installation(self) -> SlackIntegration: + installation = self.integration.get_installation(organization_id=self.organization.id) + assert isinstance(installation, SlackIntegration) + return installation + def _create_update( self, current_point: AutofixStoppingPoint = AutofixStoppingPoint.ROOT_CAUSE, @@ -121,7 +127,9 @@ def test_has_access(self) -> None: assert SlackAutofixEntrypoint.has_access(self.organization) @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_ephemeral_message") - def test_on_trigger_autofix_error(self, mock_send_threaded_ephemeral_message): + def test_on_trigger_autofix_error( + self, mock_send_threaded_ephemeral_message: MagicMock + ) -> None: ep = self._get_entrypoint() ep.on_trigger_autofix_error(error="Test error") mock_send_threaded_ephemeral_message.assert_called_with( @@ -132,7 +140,7 @@ def test_on_trigger_autofix_error(self, mock_send_threaded_ephemeral_message): ) @patch("sentry.integrations.slack.integration.SlackIntegration.update_message") - def test_on_trigger_autofix_success(self, mock_update_message): + def test_on_trigger_autofix_success(self, mock_update_message: MagicMock) -> None: self.slack_request.data = { "message": { "ts": self.thread_ts, @@ -145,13 +153,15 @@ def test_on_trigger_autofix_success(self, mock_update_message): mock_update_message.assert_called_once() @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_ephemeral_message") - def test_on_trigger_handoff_error(self, mock_send_threaded_ephemeral_message): + def test_on_trigger_handoff_error( + self, mock_send_threaded_ephemeral_message: MagicMock + ) -> None: ep = self._get_entrypoint() ep.on_trigger_handoff_error(error="boom") mock_send_threaded_ephemeral_message.assert_called_once() @patch("sentry.integrations.slack.integration.SlackIntegration.update_message") - def test_on_trigger_handoff_success(self, mock_update_message): + def test_on_trigger_handoff_success(self, mock_update_message: MagicMock) -> None: self.slack_request.data = { "message": {"ts": self.thread_ts, "text": "Issue notification", "blocks": []} } @@ -169,7 +179,7 @@ def test_on_trigger_handoff_success(self, mock_update_message): ) @patch("sentry.seer.autofix.utils.read_preference_from_sentry_db") - def test_create_autofix_cache_payload(self, mock_read_pref) -> None: + def test_create_autofix_cache_payload(self, mock_read_pref: MagicMock) -> None: mock_read_pref.return_value = SeerProjectPreference( organization_id=self.organization.id, project_id=self.group.project.id, @@ -187,7 +197,9 @@ def test_create_autofix_cache_payload(self, mock_read_pref) -> None: assert cache_payload["handoff_target"] is None @patch("sentry.seer.autofix.utils.read_preference_from_sentry_db") - def test_create_autofix_cache_payload_captures_handoff_target(self, mock_read_pref) -> None: + def test_create_autofix_cache_payload_captures_handoff_target( + self, mock_read_pref: MagicMock + ) -> None: mock_read_pref.return_value = SeerProjectPreference( organization_id=self.organization.id, project_id=self.group.project.id, @@ -203,7 +215,7 @@ def test_create_autofix_cache_payload_captures_handoff_target(self, mock_read_pr assert cache_payload["handoff_target"] == CodingAgentProviderType.CURSOR_BACKGROUND_AGENT @patch("sentry.seer.entrypoints.slack.entrypoint.schedule_all_thread_updates") - def test_on_autofix_update(self, mock_schedule_all_thread_updates): + def test_on_autofix_update(self, mock_schedule_all_thread_updates: MagicMock) -> None: ep = self._get_entrypoint() for event_type, event_payload in MOCK_SEER_WEBHOOKS.items(): cache_payload = ep.create_autofix_cache_payload() @@ -223,8 +235,8 @@ def test_on_autofix_update(self, mock_schedule_all_thread_updates): return_value={"ok": True, "ts": "1234567890.000100"}, ) def test_pr_created_and_ready_for_review_post_one_slack_message( - self, mock_send_threaded_message, _mock_has_access - ): + self, mock_send_threaded_message: MagicMock, _mock_has_access: MagicMock + ) -> None: SeerOperatorAutofixCache.populate_post_autofix_cache( entrypoint_key=SeerEntrypointKey.SLACK, run_id=MOCK_RUN_ID, @@ -257,10 +269,10 @@ def test_pr_created_and_ready_for_review_post_one_slack_message( return_value={"ok": True, "ts": "1234567890.000100"}, ) def test_send_thread_update( - self, mock_send_threaded_message, mock_send_threaded_ephemeral_message - ): + self, mock_send_threaded_message: MagicMock, mock_send_threaded_ephemeral_message: MagicMock + ) -> None: data = self._create_update(AutofixStoppingPoint.SOLUTION) - install = self.integration.get_installation(organization_id=self.organization.id) + install = self._get_installation() assert send_thread_update(install=install, thread=self.thread, data=data) == ( "1234567890.000100" @@ -291,8 +303,8 @@ def test_send_thread_update( "sentry.integrations.slack.integration.SlackIntegration.send_threaded_ephemeral_message", side_effect=TimeoutError, ) - def test_send_thread_update_normalizes_timeout(self, _mock_send_ephemeral): - install = self.integration.get_installation(organization_id=self.organization.id) + def test_send_thread_update_normalizes_timeout(self, _mock_send_ephemeral: MagicMock) -> None: + install = self._get_installation() with pytest.raises(IntegrationError): send_thread_update( @@ -303,7 +315,7 @@ def test_send_thread_update_normalizes_timeout(self, _mock_send_ephemeral): ) @patch("sentry.integrations.slack.integration.SlackIntegration.update_message") - def test_update_existing_message(self, mock_update_message): + def test_update_existing_message(self, mock_update_message: MagicMock) -> None: self.slack_request.data = { "message": { "ts": self.thread_ts, @@ -331,7 +343,7 @@ def test_update_existing_message(self, mock_update_message): } data = self._create_update(AutofixStoppingPoint.ROOT_CAUSE) - install = self.integration.get_installation(organization_id=self.organization.id) + install = self._get_installation() update_existing_message( request=self.slack_request, install=install, @@ -355,7 +367,9 @@ def test_update_existing_message(self, mock_update_message): assert actions_block.elements[0].action_id == "other_action" @patch("sentry.integrations.slack.integration.SlackIntegration.update_message") - def test_on_trigger_autofix_success_updates_message(self, mock_update_message): + def test_on_trigger_autofix_success_updates_message( + self, mock_update_message: MagicMock + ) -> None: self.slack_request.data = { "message": { "ts": self.thread_ts, @@ -392,7 +406,7 @@ def test_get_autofix_lock_key(self) -> None: assert AutofixStoppingPoint.ROOT_CAUSE.value in lock_key @patch("sentry.seer.entrypoints.slack.messaging.process_thread_update") - def test_schedule_all_thread_updates(self, mock_process_thread_update): + def test_schedule_all_thread_updates(self, mock_process_thread_update: MagicMock) -> None: threads = [ SlackThreadDetails(thread_ts="1234567890.123456", channel_id="C1234567890"), SlackThreadDetails(thread_ts="1234567890.654321", channel_id="C0987654321"), @@ -412,7 +426,7 @@ def test_schedule_all_thread_updates(self, mock_process_thread_update): "sentry.integrations.slack.integration.SlackIntegration.send_threaded_message", return_value={"ok": True, "ts": "1234567890.000100"}, ) - def test_process_thread_update(self, mock_send_threaded_message): + def test_process_thread_update(self, mock_send_threaded_message: MagicMock) -> None: data = self._create_update(AutofixStoppingPoint.SOLUTION) serialized_data = serialize_notification_data(data) @@ -435,8 +449,8 @@ def test_process_thread_update(self, mock_send_threaded_message): ) @patch("sentry.seer.entrypoints.slack.entrypoint.SeerOperatorAutofixCache.get") def test_prepare_slack_thread_for_autofix_updates_empty_cache( - self, mock_cache_get, mock_populate_cache - ): + self, mock_cache_get: MagicMock, mock_populate_cache: MagicMock + ) -> None: mock_cache_get.return_value = None mock_populate_cache.return_value = {"key": "test_key", "source": "group_id"} @@ -461,8 +475,8 @@ def test_prepare_slack_thread_for_autofix_updates_empty_cache( ) @patch("sentry.seer.entrypoints.slack.entrypoint.SeerOperatorAutofixCache.get") def test_prepare_slack_thread_for_autofix_updates_merges_threads( - self, mock_cache_get, mock_populate_cache - ): + self, mock_cache_get: MagicMock, mock_populate_cache: MagicMock + ) -> None: existing_thread = SlackThreadDetails( thread_ts="9876543210.123456", channel_id="CEXISTING00" ) @@ -495,8 +509,8 @@ def test_prepare_slack_thread_for_autofix_updates_merges_threads( ) @patch("sentry.seer.entrypoints.slack.entrypoint.SeerOperatorAutofixCache.get") def test_prepare_slack_thread_for_autofix_updates_no_duplicate_threads( - self, mock_cache_get, mock_populate_cache - ): + self, mock_cache_get: MagicMock, mock_populate_cache: MagicMock + ) -> None: existing_thread = SlackThreadDetails(thread_ts=self.thread_ts, channel_id=self.channel_id) mock_cache_get.return_value = { "payload": {"threads": [existing_thread]}, @@ -519,8 +533,8 @@ def test_prepare_slack_thread_for_autofix_updates_no_duplicate_threads( @patch("sentry.integrations.slack.integration.SlackIntegration.update_message") def test_update_existing_message_removes_all_buttons_for_non_root_cause( - self, mock_update_message - ): + self, mock_update_message: MagicMock + ) -> None: self.slack_request.data = { "message": { "ts": self.thread_ts, @@ -548,7 +562,7 @@ def test_update_existing_message_removes_all_buttons_for_non_root_cause( } data = self._create_update(AutofixStoppingPoint.SOLUTION) - install = self.integration.get_installation(organization_id=self.organization.id) + install = self._get_installation() update_existing_message( request=self.slack_request, install=install, @@ -565,13 +579,15 @@ def test_update_existing_message_removes_all_buttons_for_non_root_cause( assert all(block.type != "actions" for block in renderable["blocks"]) @patch("sentry.integrations.slack.integration.SlackIntegration.update_message") - def test_update_existing_message_without_slack_user_id(self, mock_update_message): + def test_update_existing_message_without_slack_user_id( + self, mock_update_message: MagicMock + ) -> None: self.slack_request.data = { "message": {"ts": self.thread_ts, "text": "Issue notification", "blocks": []} } data = self._create_update(AutofixStoppingPoint.ROOT_CAUSE) - install = self.integration.get_installation(organization_id=self.organization.id) + install = self._get_installation() update_existing_message( request=self.slack_request, install=install, @@ -587,13 +603,15 @@ def test_update_existing_message_without_slack_user_id(self, mock_update_message @override_options({"notifications.tracking.sources": [NotificationSource.SEER_AUTOFIX_UPDATE]}) @patch("sentry.seer.entrypoints.slack.messaging.record_sent") @patch("sentry.integrations.slack.integration.SlackIntegration.update_message") - def test_update_existing_message_records_sent(self, mock_update_message, mock_record_sent): + def test_update_existing_message_records_sent( + self, mock_update_message: MagicMock, mock_record_sent: MagicMock + ) -> None: self.slack_request.data = { "message": {"ts": self.thread_ts, "text": "Issue notification", "blocks": []} } data = self._create_update(AutofixStoppingPoint.SOLUTION) - install = self.integration.get_installation(organization_id=self.organization.id) + install = self._get_installation() update_existing_message( request=self.slack_request, install=install, @@ -626,13 +644,13 @@ def test_update_existing_message_records_sent(self, mock_update_message, mock_re side_effect=IntegrationError("update failed"), ) def test_update_existing_message_failure_does_not_record_sent( - self, mock_update_message, mock_record_sent - ): + self, mock_update_message: MagicMock, mock_record_sent: MagicMock + ) -> None: self.slack_request.data = { "message": {"ts": self.thread_ts, "text": "Issue notification", "blocks": []} } - install = self.integration.get_installation(organization_id=self.organization.id) + install = self._get_installation() update_existing_message( request=self.slack_request, install=install, @@ -646,7 +664,9 @@ def test_update_existing_message_failure_does_not_record_sent( mock_update_message.assert_called_once() mock_record_sent.assert_not_called() - def _assert_stopping_point_from_action(self, value, expected): + def _assert_stopping_point_from_action( + self, value: str | None, expected: AutofixStoppingPoint + ) -> None: action = BlockKitMessageAction( name=SlackAction.SEER_AUTOFIX_START.value, label="Fix with Seer", @@ -673,11 +693,13 @@ def test_get_group_link_includes_seer_drawer(self) -> None: assert "seerDrawer=true" in link @patch("sentry.integrations.slack.integration.SlackIntegration.update_message") - def test_update_existing_message_handles_invalid_payload(self, mock_update_message): + def test_update_existing_message_handles_invalid_payload( + self, mock_update_message: MagicMock + ) -> None: self.slack_request.data = {"message": None} data = self._create_update(AutofixStoppingPoint.ROOT_CAUSE) - install = self.integration.get_installation(organization_id=self.organization.id) + install = self._get_installation() update_existing_message( request=self.slack_request, install=install, @@ -761,7 +783,7 @@ def test_init_raises_if_integration_not_found(self) -> None: "sentry.integrations.services.integration.service.integration_service.get_organization_integrations", return_value=[], ) - def test_init_raises_if_no_org_integration(self, mock_get_ois): + def test_init_raises_if_no_org_integration(self, mock_get_ois: MagicMock) -> None: with pytest.raises(EntrypointSetupError): SlackAgentEntrypoint( integration_id=self.integration.id, @@ -775,7 +797,7 @@ def test_has_access(self) -> None: assert SlackAgentEntrypoint.has_access(self.organization) @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_ephemeral_message") - def test_on_trigger_agent_error(self, mock_send_ephemeral): + def test_on_trigger_agent_error(self, mock_send_ephemeral: MagicMock) -> None: ep = self._get_entrypoint() ep.on_trigger_agent_error(error="Test error") @@ -804,7 +826,9 @@ def test_create_agent_cache_payload(self) -> None: "sentry.integrations.slack.integration.SlackIntegration.send_threaded_ephemeral_message", return_value={"ok": True, "message_ts": "1234567890.000100"}, ) - def test_send_ephemeral_write_approval_caches_input_id(self, mock_send_ephemeral): + def test_send_ephemeral_write_approval_caches_input_id( + self, mock_send_ephemeral: MagicMock + ) -> None: ep = self._get_entrypoint() data = SeerAgentWriteApproval( run_id=12345, @@ -841,7 +865,7 @@ def test_send_ephemeral_write_approval_caches_input_id(self, mock_send_ephemeral "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", return_value=True, ) - def test_on_agent_update(self, mock_has_history_scope, mock_send): + def test_on_agent_update(self, mock_has_history_scope: MagicMock, mock_send: MagicMock) -> None: ep = self._get_entrypoint() cache_payload = ep.create_agent_cache_payload() run_id = 12345 @@ -863,7 +887,7 @@ def test_on_agent_update(self, mock_has_history_scope, mock_send): assert call_data.summary == "Test summary" @patch("sentry.seer.entrypoints.slack.entrypoint.send_thread_update", return_value="1.1") - def test_on_agent_update_with_no_summary(self, mock_send): + def test_on_agent_update_with_no_summary(self, mock_send: MagicMock) -> None: ep = self._get_entrypoint() cache_payload = ep.create_agent_cache_payload() @@ -879,7 +903,9 @@ def test_on_agent_update_with_no_summary(self, mock_send): assert call_data.error_message == "Seer was unable to generate a response." @patch("sentry.seer.entrypoints.slack.entrypoint.send_thread_update", return_value="1.1") - def test_on_agent_update_does_not_reuse_the_previous_turns_answer(self, mock_send): + def test_on_agent_update_does_not_reuse_the_previous_turns_answer( + self, mock_send: MagicMock + ) -> None: cache_payload = self._get_entrypoint().create_agent_cache_payload() state = _agent_state("First answer") SlackAgentEntrypoint.on_agent_update(cache_payload=cache_payload, run_id=12345, state=state) @@ -900,7 +926,9 @@ def test_on_agent_update_does_not_reuse_the_previous_turns_answer(self, mock_sen @patch( "sentry.seer.entrypoints.slack.entrypoint._send_agent_write_approval", return_value="9.9" ) - def test_on_agent_update_with_write_approval(self, mock_send_write_approval, mock_send): + def test_on_agent_update_with_write_approval( + self, mock_send_write_approval: MagicMock, mock_send: MagicMock + ) -> None: ep = self._get_entrypoint() cache_payload = ep.create_agent_cache_payload() @@ -933,8 +961,8 @@ def test_on_agent_update_with_write_approval(self, mock_send_write_approval, moc @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_ephemeral_message") @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_message") def test_on_agent_update_supports_cached_payload_without_slack_user( - self, mock_send_message, mock_send_ephemeral - ): + self, mock_send_message: MagicMock, mock_send_ephemeral: MagicMock + ) -> None: ep = self._get_entrypoint() cache_payload = ep.create_agent_cache_payload() cache_payload.pop("slack_user_id") @@ -956,7 +984,7 @@ def test_on_agent_update_supports_cached_payload_without_slack_user( mock_send_ephemeral.assert_not_called() @patch("sentry.seer.entrypoints.slack.entrypoint.send_thread_update", return_value="1.1") - def test_on_agent_update_rejects_invalid_write_approval(self, mock_send): + def test_on_agent_update_rejects_invalid_write_approval(self, mock_send: MagicMock) -> None: ep = self._get_entrypoint() cache_payload = ep.create_agent_cache_payload() @@ -1000,7 +1028,9 @@ def test_on_agent_update_skips_when_integration_not_found(self) -> None: "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", return_value=False, ) - def test_on_agent_update_sets_missing_scope_url(self, mock_has_history_scope, mock_send): + def test_on_agent_update_sets_missing_scope_url( + self, mock_has_history_scope: MagicMock, mock_send: MagicMock + ) -> None: """When history scope is missing, on_agent_update sets missing_scope_settings_url.""" ep = self._get_entrypoint() cache_payload = ep.create_agent_cache_payload() @@ -1022,8 +1052,8 @@ def test_on_agent_update_sets_missing_scope_url(self, mock_has_history_scope, mo return_value=True, ) def test_on_agent_update_no_missing_scope_url_when_has_scope( - self, mock_has_history_scope, mock_send - ): + self, mock_has_history_scope: MagicMock, mock_send: MagicMock + ) -> None: """When history scope is present, missing_scope_settings_url should be None.""" ep = self._get_entrypoint() cache_payload = ep.create_agent_cache_payload() @@ -1044,8 +1074,8 @@ def test_on_agent_update_no_missing_scope_url_when_has_scope( return_value=False, ) def test_on_agent_update_missing_scope_url_cached_per_thread( - self, mock_has_history_scope, mock_send - ): + self, mock_has_history_scope: MagicMock, mock_send: MagicMock + ) -> None: """Second call for the same thread should not set the URL (cached).""" ep = self._get_entrypoint() cache_payload = ep.create_agent_cache_payload() @@ -1069,7 +1099,7 @@ def test_on_agent_update_missing_scope_url_cached_per_thread( assert second_data.missing_scope_settings_url is None @patch("sentry.seer.entrypoints.slack.entrypoint.send_thread_update", return_value="1.1") - def test_on_agent_update_does_not_duplicate_answers(self, mock_send): + def test_on_agent_update_does_not_duplicate_answers(self, mock_send: MagicMock) -> None: cache_payload = self._get_entrypoint().create_agent_cache_payload() state = _agent_state("Test summary") @@ -1085,8 +1115,8 @@ def test_on_agent_update_does_not_duplicate_answers(self, mock_send): "sentry.seer.entrypoints.slack.entrypoint._send_agent_write_approval", return_value="9.9" ) def test_on_agent_update_does_not_duplicate_approval_dm_or_notice( - self, mock_send_write_approval, mock_send - ): + self, mock_send_write_approval: MagicMock, mock_send: MagicMock + ) -> None: cache_payload = self._get_entrypoint().create_agent_cache_payload() state = _agent_state( None, @@ -1361,7 +1391,9 @@ def _update( ) def _updated_blocks(self) -> list[Any]: - return self.mock_update.call_args.kwargs["blocks"] + blocks = self.mock_update.call_args.kwargs["blocks"] + assert isinstance(blocks, list) + return blocks def _quotes(self, blocks: list[Any]) -> list[list[dict[str, Any]]]: return [ @@ -1397,7 +1429,9 @@ def _conclusion_text(self) -> str: assert call.kwargs["thread_ts"] == self.message_ts section, _divider, actions = call.kwargs["blocks"] assert actions.elements[0].url.startswith(self.investigation.get_absolute_url()) - return section.text.text + text = section.text.text + assert isinstance(text, str) + return text def test_trigger_success_leaves_status_message_to_updates(self) -> None: entrypoint = self._get_entrypoint() @@ -1585,7 +1619,7 @@ def test_integration_error_on_update_is_swallowed( self, mock_record: Mock, mock_record_sent: Mock ) -> None: cache_payload = self._set_cache() - self.mock_update.side_effect = SlackApiError( + self.mock_update.side_effect = TypedSlackApiError( "error", _slack_response({"ok": False, "error": "message_not_found"}) ) @@ -1707,7 +1741,7 @@ def test_failed_conclusion_post_leaves_final_sent_unset(self, mock_record: Mock) for error, halt_count in (("ratelimited", 2), ("invalid_blocks", 1)): self._set_cache() mock_record.reset_mock() - self.mock_post_message.side_effect = SlackApiError( + self.mock_post_message.side_effect = TypedSlackApiError( "error", _slack_response({"ok": False, "error": error}) ) diff --git a/tests/sentry/seer/entrypoints/slack/test_tasks.py b/tests/sentry/seer/entrypoints/slack/test_tasks.py index fbe44e81e576..2891851e0f72 100644 --- a/tests/sentry/seer/entrypoints/slack/test_tasks.py +++ b/tests/sentry/seer/entrypoints/slack/test_tasks.py @@ -1,3 +1,8 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any +from unittest import mock from unittest.mock import ANY, MagicMock, patch from django.test import override_settings @@ -28,7 +33,7 @@ assert_not_analytics_event, ) -TASK_KWARGS = { +TASK_KWARGS: dict[str, Any] = { "integration_id": 123, "channel_id": "C1234567890", "ts": "1234567890.654321", @@ -46,8 +51,8 @@ class ProcessMentionForSlackTest(TestCase): - def _run_task(self, **overrides): - kwargs = { + def _run_task(self, **overrides: object) -> None: + kwargs: dict[str, Any] = { **TASK_KWARGS, "organization_id": self.organization.id, **overrides, @@ -61,12 +66,12 @@ def _run_task(self, **overrides): @patch("sentry.seer.entrypoints.slack.tasks._resolve_user") def test_happy_path( self, - mock_resolve_user, - mock_agent_cls, - mock_operator_cls, - mock_count_linked, - mock_record, - ): + mock_resolve_user: MagicMock, + mock_agent_cls: MagicMock, + mock_operator_cls: MagicMock, + mock_count_linked: MagicMock, + mock_record: MagicMock, + ) -> None: mock_user = MagicMock(id=self.user.id, username="alice") mock_resolve_user.return_value = mock_user @@ -115,7 +120,9 @@ def test_happy_path( @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.SeerAgentOperator") @patch("sentry.seer.entrypoints.slack.tasks.SlackAgentEntrypoint") - def test_org_not_found(self, mock_agent_cls, mock_operator_cls, mock_record): + def test_org_not_found( + self, mock_agent_cls: MagicMock, mock_operator_cls: MagicMock, mock_record: MagicMock + ) -> None: self._run_task(organization_id=999999999) mock_operator_cls.has_access.assert_not_called() @@ -126,7 +133,9 @@ def test_org_not_found(self, mock_agent_cls, mock_operator_cls, mock_record): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.SeerAgentOperator") @patch("sentry.seer.entrypoints.slack.tasks.SlackAgentEntrypoint") - def test_no_agent_access(self, mock_agent_cls, mock_operator_cls, mock_record): + def test_no_agent_access( + self, mock_agent_cls: MagicMock, mock_operator_cls: MagicMock, mock_record: MagicMock + ) -> None: mock_operator_cls.has_access.return_value = False self._run_task() @@ -138,7 +147,9 @@ def test_no_agent_access(self, mock_agent_cls, mock_operator_cls, mock_record): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.SeerAgentOperator") @patch("sentry.seer.entrypoints.slack.tasks.SlackAgentEntrypoint") - def test_integration_not_found(self, mock_agent_cls, mock_operator_cls, mock_record): + def test_integration_not_found( + self, mock_agent_cls: MagicMock, mock_operator_cls: MagicMock, mock_record: MagicMock + ) -> None: mock_operator_cls.has_access.return_value = True mock_agent_cls.side_effect = EntrypointSetupError("not found") @@ -154,12 +165,12 @@ def test_integration_not_found(self, mock_agent_cls, mock_operator_cls, mock_rec @patch("sentry.seer.entrypoints.slack.tasks._resolve_user") def test_identity_not_linked( self, - mock_resolve_user, - mock_agent_cls, - mock_operator_cls, - mock_send_halt, - mock_record, - ): + mock_resolve_user: MagicMock, + mock_agent_cls: MagicMock, + mock_operator_cls: MagicMock, + mock_send_halt: MagicMock, + mock_record: MagicMock, + ) -> None: mock_resolve_user.return_value = None mock_operator_cls.has_access.return_value = True @@ -186,12 +197,12 @@ def test_identity_not_linked( @patch("sentry.seer.entrypoints.slack.tasks._resolve_user") def test_user_not_org_member( self, - mock_resolve_user, - mock_agent_cls, - mock_operator_cls, - mock_send_not_member, - mock_record, - ): + mock_resolve_user: MagicMock, + mock_agent_cls: MagicMock, + mock_operator_cls: MagicMock, + mock_send_not_member: MagicMock, + mock_record: MagicMock, + ) -> None: other_org = self.create_organization(name="Other Org") mock_user = MagicMock(id=self.create_user().id) mock_resolve_user.return_value = mock_user @@ -224,12 +235,12 @@ def test_user_not_org_member( @patch("sentry.seer.entrypoints.slack.tasks._resolve_user") def test_with_thread_context( self, - mock_resolve_user, - mock_agent_cls, - mock_operator_cls, - mock_count_linked, - mock_record, - ): + mock_resolve_user: MagicMock, + mock_agent_cls: MagicMock, + mock_operator_cls: MagicMock, + mock_count_linked: MagicMock, + mock_record: MagicMock, + ) -> None: mock_resolve_user.return_value = MagicMock(id=self.user.id, username="alice") mock_operator_cls.has_access.return_value = True @@ -293,7 +304,9 @@ def test_with_thread_context( @patch("sentry.seer.entrypoints.slack.tasks.SeerAgentOperator") @patch("sentry.seer.entrypoints.slack.tasks.SlackAgentEntrypoint") @patch("sentry.seer.entrypoints.slack.tasks._resolve_user") - def test_without_thread_context(self, mock_resolve_user, mock_agent_cls, mock_operator_cls): + def test_without_thread_context( + self, mock_resolve_user: MagicMock, mock_agent_cls: MagicMock, mock_operator_cls: MagicMock + ) -> None: mock_resolve_user.return_value = MagicMock(id=self.user.id) mock_operator_cls.has_access.return_value = True @@ -318,12 +331,12 @@ def test_without_thread_context(self, mock_resolve_user, mock_agent_cls, mock_op @patch("sentry.seer.entrypoints.slack.tasks._resolve_user") def test_top_level_mention_analytics( self, - mock_resolve_user, - mock_agent_cls, - mock_operator_cls, - mock_count_linked, - mock_record, - ): + mock_resolve_user: MagicMock, + mock_agent_cls: MagicMock, + mock_operator_cls: MagicMock, + mock_count_linked: MagicMock, + mock_record: MagicMock, + ) -> None: mock_resolve_user.return_value = MagicMock(id=self.user.id, username="alice") mock_operator_cls.has_access.return_value = True @@ -366,8 +379,12 @@ def test_top_level_mention_analytics( @patch("sentry.seer.entrypoints.slack.tasks.SlackAgentEntrypoint") @patch("sentry.seer.entrypoints.slack.tasks._resolve_user") def test_skips_analytics_when_trigger_agent_fails( - self, mock_resolve_user, mock_agent_cls, mock_operator_cls, mock_record - ): + self, + mock_resolve_user: MagicMock, + mock_agent_cls: MagicMock, + mock_operator_cls: MagicMock, + mock_record: MagicMock, + ) -> None: mock_resolve_user.return_value = MagicMock(id=self.user.id) mock_operator_cls.has_access.return_value = True @@ -389,7 +406,7 @@ class LinkedMessagesContextTest(TestCase): LINKED_PERMALINK = "https://acme.slack.com/archives/C9999LINKED/p1700000000222222" - def setUp(self): + def setUp(self) -> None: super().setUp() self.mock_resolve_user = self._start_patch( patch("sentry.seer.entrypoints.slack.tasks._resolve_user") @@ -407,7 +424,7 @@ def setUp(self): self._build_mocks() - def _start_patch(self, p): + def _start_patch(self, p: mock._patch[MagicMock]) -> MagicMock: mock = p.start() self.addCleanup(p.stop) return mock @@ -415,9 +432,9 @@ def _start_patch(self, p): def _build_mocks( self, *, - thread_history_side_effect=None, - conversations_info_side_effect=None, - ): + thread_history_side_effect: Callable[..., object] | None = None, + conversations_info_side_effect: Callable[..., object] | None = None, + ) -> None: self.mock_user = MagicMock(id=self.user.id, username="alice") self.mock_entrypoint = MagicMock() self.mock_entrypoint.thread_ts = "1234567890.123456" @@ -446,8 +463,8 @@ def _build_mocks( self.mock_agent_cls.return_value = self.mock_entrypoint self.mock_operator_cls.return_value = self.mock_operator - def _run(self, *, text, **overrides): - kwargs = { + def _run(self, *, text: str, **overrides: object) -> None: + kwargs: dict[str, Any] = { **TASK_KWARGS, "organization_id": self.organization.id, "text": text, @@ -455,8 +472,8 @@ def _run(self, *, text, **overrides): } process_mention_for_slack(**kwargs) - def test_top_level_permalink_fetches_single_message(self): - def history(*, channel_id, thread_ts, **kwargs): + def test_top_level_permalink_fetches_single_message(self) -> None: + def history(*, channel_id: str, thread_ts: str, **kwargs: object) -> list[dict[str, str]]: if channel_id == "C9999LINKED": return [{"user": "ULINK", "text": "linked body", "ts": "1700000000.222222"}] return [] @@ -480,7 +497,7 @@ def history(*, channel_id, thread_ts, **kwargs): assert "User linked a Slack message in <#C9999LINKED>:" in on_page_context assert "<@ULINK>: linked body" in on_page_context - def test_threaded_permalink_fetches_only_the_linked_reply(self): + def test_threaded_permalink_fetches_only_the_linked_reply(self) -> None: """A permalink into a thread reply must NOT pull the whole thread — we only ship the single targeted reply to Seer. @@ -488,7 +505,7 @@ def test_threaded_permalink_fetches_only_the_linked_reply(self): so we filter client-side. """ - def history(*, channel_id, thread_ts, **kwargs): + def history(*, channel_id: str, thread_ts: str, **kwargs: object) -> list[dict[str, str]]: if channel_id == "C9999LINKED" and thread_ts == "1700000000.111111": # Slack returned the parent + the requested reply — we should # filter to the reply and drop the parent. @@ -520,7 +537,7 @@ def history(*, channel_id, thread_ts, **kwargs): # The thread parent must be filtered out client-side. assert "UPARENT" not in on_page_context - def test_unfurl_attachment_short_circuits_api_call(self): + def test_unfurl_attachment_short_circuits_api_call(self) -> None: """If the inbound app_mention event already includes the unfurled message as an attachment, we use it directly — no Slack API calls.""" attachments = [ @@ -549,7 +566,7 @@ def test_unfurl_attachment_short_circuits_api_call(self): assert "User linked a Slack message in <#C9999LINKED>:" in on_page_context assert "<@ULINK>: linked body" in on_page_context - def test_unfurl_attachment_with_blocks_preserves_rich_text(self): + def test_unfurl_attachment_with_blocks_preserves_rich_text(self) -> None: """Rich-text blocks on the unfurl attachment are preferred over flat text.""" attachments = [ { @@ -591,11 +608,11 @@ def test_unfurl_attachment_with_blocks_preserves_rich_text(self): assert "<@ULINK>: rich body" in on_page_context assert "fallback flat text" not in on_page_context - def test_attachment_for_other_link_falls_through_to_api(self): + def test_attachment_for_other_link_falls_through_to_api(self) -> None: """When the attachment's (channel_id, ts) doesn't match any link in the message, we fall through to the API path.""" - def history(*, channel_id, thread_ts, **kwargs): + def history(*, channel_id: str, thread_ts: str, **kwargs: object) -> list[dict[str, str]]: if channel_id == "C9999LINKED": return [{"user": "UFETCH", "text": "fetched body", "ts": "1700000000.222222"}] return [] @@ -630,7 +647,7 @@ def history(*, channel_id, thread_ts, **kwargs): assert "<@UFETCH>: fetched body" in on_page_context assert "irrelevant" not in on_page_context - def test_private_channel_skipped_and_user_notified(self): + def test_private_channel_skipped_and_user_notified(self) -> None: self._build_mocks( conversations_info_side_effect=lambda **_: {"channel": {"is_private": True}} ) @@ -650,7 +667,7 @@ def test_private_channel_skipped_and_user_notified(self): assert "private channels" in renderable_text assert "<#C9999LINKED>" in renderable_text - def test_unresolved_link_yields_no_block_and_sends_ephemeral(self): + def test_unresolved_link_yields_no_block_and_sends_ephemeral(self) -> None: # get_thread_history returns [] → simulates missing scope or deleted message. self._run( text=f"<@U0BOT> what about <{self.LINKED_PERMALINK}>", @@ -675,8 +692,8 @@ def test_unresolved_link_yields_no_block_and_sends_ephemeral(self): assert "<#C9999LINKED>" in renderable_text assert "need to be invited" in renderable_text - def test_partial_resolution_still_sends_ephemeral_for_unresolved(self): - def history(*, channel_id, thread_ts, **kwargs): + def test_partial_resolution_still_sends_ephemeral_for_unresolved(self) -> None: + def history(*, channel_id: str, thread_ts: str, **kwargs: object) -> list[dict[str, str]]: if channel_id == "C0OK": return [{"user": "UOK", "text": "ok", "ts": "1700000000.111111"}] return [] @@ -700,7 +717,7 @@ def history(*, channel_id, thread_ts, **kwargs): assert "C0BAD" in renderable_text assert "C0OK" not in renderable_text - def test_multiple_unresolved_channels_listed_in_ephemeral(self): + def test_multiple_unresolved_channels_listed_in_ephemeral(self) -> None: text = ( "<@U0BOT> " " and " @@ -714,8 +731,8 @@ def test_multiple_unresolved_channels_listed_in_ephemeral(self): assert "C0AAA" in renderable_text assert "C0BBB" in renderable_text - def test_linked_block_precedes_in_thread_context(self): - def history(*, channel_id, thread_ts, **kwargs): + def test_linked_block_precedes_in_thread_context(self) -> None: + def history(*, channel_id: str, thread_ts: str, **kwargs: object) -> list[dict[str, str]]: # Permalink fetch (narrowed by latest/oldest) if channel_id == "C9999LINKED": return [{"user": "ULINK", "text": "linked body", "ts": "1700000000.222222"}] @@ -740,7 +757,7 @@ def history(*, channel_id, thread_ts, **kwargs): assert "<@ULINK>: linked body" in on_page_context assert "<@UTHREAD>: earlier in thread" in on_page_context - def test_other_workspace_link_is_skipped(self): + def test_other_workspace_link_is_skipped(self) -> None: # domain_name is "acme.slack.com"; this link targets "other.slack.com". url = "https://other.slack.com/archives/C9999OTHER/p1700000000222222" self._run(text=f"<@U0BOT> ignore <{url}>", thread_ts=None) @@ -796,7 +813,7 @@ def test_mixed_unresolved_and_private_renders_both_sections(self) -> None: assert "<#C0PRIV>" in renderable["text"] -REACTION_TASK_KWARGS = { +REACTION_TASK_KWARGS: dict[str, Any] = { "channel_id": "C1234567890", "message_ts": "1234567890.654321", "reaction": "+1", @@ -806,7 +823,7 @@ def test_mixed_unresolved_and_private_renders_both_sections(self) -> None: @override_settings(SENTRY_SELF_HOSTED=False) class ProcessReactionForSlackTest(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.integration = self.create_integration( @@ -821,7 +838,7 @@ def setUp(self): identity_provider=self.idp, external_id=REACTION_TASK_KWARGS["reactor_slack_user_id"], ) - self.defaults = { + self.defaults: dict[str, Any] = { **REACTION_TASK_KWARGS, "integration_id": self.integration.id, "organization_id": self.organization.id, @@ -835,10 +852,10 @@ def setUp(self): ) def test_happy_path( self, - mock_cache_get, - mock_record, - mock_chat_ephemeral, - ): + mock_cache_get: MagicMock, + mock_record: MagicMock, + mock_chat_ephemeral: MagicMock, + ) -> None: with self.feature(_SEER_SLACK_FEATURES): process_reaction_for_slack(**self.defaults) @@ -873,10 +890,10 @@ def test_happy_path( ) def test_thumbs_down_records_negative( self, - mock_cache_get, - mock_record, - mock_chat_ephemeral, - ): + mock_cache_get: MagicMock, + mock_record: MagicMock, + mock_chat_ephemeral: MagicMock, + ) -> None: with self.feature(_SEER_SLACK_FEATURES): process_reaction_for_slack(**{**self.defaults, "reaction": "-1::skin-tone-5"}) @@ -899,10 +916,10 @@ def test_thumbs_down_records_negative( @patch("sentry.analytics.record") def test_thread_not_found_records_halt( self, - mock_record, - mock_lifecycle_record, - mock_cache_get, - ): + mock_record: MagicMock, + mock_lifecycle_record: MagicMock, + mock_cache_get: MagicMock, + ) -> None: with self.feature(_SEER_SLACK_FEATURES): process_reaction_for_slack(**self.defaults) @@ -917,10 +934,10 @@ def test_thread_not_found_records_halt( @patch("sentry.analytics.record") def test_unlinked_identity_records_halt( self, - mock_record, - mock_lifecycle_record, - mock_cache_get, - ): + mock_record: MagicMock, + mock_lifecycle_record: MagicMock, + mock_cache_get: MagicMock, + ) -> None: with self.feature(_SEER_SLACK_FEATURES): process_reaction_for_slack(**{**self.defaults, "reactor_slack_user_id": "UXXXXXXX2"}) @@ -933,9 +950,9 @@ def test_unlinked_identity_records_halt( @override_settings(SENTRY_SELF_HOSTED=True) def test_no_agent_access_records_halt( self, - mock_record, - mock_lifecycle_record, - ): + mock_record: MagicMock, + mock_lifecycle_record: MagicMock, + ) -> None: process_reaction_for_slack(**self.defaults) assert_not_analytics_event(mock_record, SlackSeerAgentFeedback) @@ -945,9 +962,9 @@ def test_no_agent_access_records_halt( @patch("sentry.analytics.record") def test_unsupported_reaction_records_halt( self, - mock_record, - mock_lifecycle_record, - ): + mock_record: MagicMock, + mock_lifecycle_record: MagicMock, + ) -> None: process_reaction_for_slack(**{**self.defaults, "reaction": "heart"}) assert_not_analytics_event(mock_record, SlackSeerAgentFeedback) @@ -957,9 +974,9 @@ def test_unsupported_reaction_records_halt( @patch("sentry.analytics.record") def test_org_not_found_records_failure( self, - mock_record, - mock_lifecycle_record, - ): + mock_record: MagicMock, + mock_lifecycle_record: MagicMock, + ) -> None: process_reaction_for_slack(**{**self.defaults, "organization_id": -1}) assert_not_analytics_event(mock_record, SlackSeerAgentFeedback) diff --git a/tests/sentry/seer/entrypoints/test_cache.py b/tests/sentry/seer/entrypoints/test_cache.py index 28a41ba23b16..5205f958a5ce 100644 --- a/tests/sentry/seer/entrypoints/test_cache.py +++ b/tests/sentry/seer/entrypoints/test_cache.py @@ -1,5 +1,5 @@ from typing import TypedDict -from unittest.mock import patch +from unittest.mock import MagicMock, patch from fixtures.seer.webhooks import MOCK_GROUP_ID, MOCK_RUN_ID from sentry.seer.entrypoints.cache import ( @@ -38,7 +38,7 @@ def test_get_post_autofix_cache_key(self) -> None: assert self.post_cache_key == f"seer:post_autofix:{self.entrypoint_key}:{MOCK_RUN_ID}" @patch("sentry.seer.entrypoints.cache.cache.set") - def test_populate_pre_autofix_cache(self, mock_cache_set): + def test_populate_pre_autofix_cache(self, mock_cache_set: MagicMock) -> None: pre_cache_payload = MockCachePayload(thread_id="pre_cache_payload") result = SeerOperatorAutofixCache.populate_pre_autofix_cache( entrypoint_key=self.entrypoint_key, @@ -55,7 +55,7 @@ def test_populate_pre_autofix_cache(self, mock_cache_set): assert result["payload"] == pre_cache_payload @patch("sentry.seer.entrypoints.cache.cache.set") - def test_populate_post_autofix_cache(self, mock_cache_set): + def test_populate_post_autofix_cache(self, mock_cache_set: MagicMock) -> None: post_cache_payload = MockCachePayload(thread_id="post_cache_payload") result = SeerOperatorAutofixCache.populate_post_autofix_cache( entrypoint_key=self.entrypoint_key, @@ -72,11 +72,11 @@ def test_populate_post_autofix_cache(self, mock_cache_set): assert result["payload"] == post_cache_payload @patch("sentry.seer.entrypoints.cache.cache.get") - def test_get_pre_autofix_cache(self, mock_cache_get): + def test_get_pre_autofix_cache(self, mock_cache_get: MagicMock) -> None: pre_cache_payload = MockCachePayload(thread_id="pre_cache_payload") mock_cache_get.return_value = pre_cache_payload - result = SeerOperatorAutofixCache._get_pre_autofix_cache( + result = SeerOperatorAutofixCache[MockCachePayload]._get_pre_autofix_cache( entrypoint_key=self.entrypoint_key, group_id=MOCK_GROUP_ID ) @@ -87,21 +87,21 @@ def test_get_pre_autofix_cache(self, mock_cache_get): assert result["payload"] == pre_cache_payload @patch("sentry.seer.entrypoints.cache.cache.get") - def test_get_pre_autofix_cache_miss(self, mock_cache_get): + def test_get_pre_autofix_cache_miss(self, mock_cache_get: MagicMock) -> None: mock_cache_get.return_value = None - result = SeerOperatorAutofixCache._get_pre_autofix_cache( + result = SeerOperatorAutofixCache[MockCachePayload]._get_pre_autofix_cache( entrypoint_key=self.entrypoint_key, group_id=MOCK_GROUP_ID ) assert result is None @patch("sentry.seer.entrypoints.cache.cache.get") - def test_get_post_autofix_cache(self, mock_cache_get): + def test_get_post_autofix_cache(self, mock_cache_get: MagicMock) -> None: post_cache_payload = MockCachePayload(thread_id="post_cache_payload") mock_cache_get.return_value = post_cache_payload - result = SeerOperatorAutofixCache._get_post_autofix_cache( + result = SeerOperatorAutofixCache[MockCachePayload]._get_post_autofix_cache( entrypoint_key=self.entrypoint_key, run_id=MOCK_RUN_ID ) @@ -112,10 +112,10 @@ def test_get_post_autofix_cache(self, mock_cache_get): assert result["payload"] == post_cache_payload @patch("sentry.seer.entrypoints.cache.cache.get") - def test_get_post_autofix_cache_miss(self, mock_cache_get): + def test_get_post_autofix_cache_miss(self, mock_cache_get: MagicMock) -> None: mock_cache_get.return_value = None - result = SeerOperatorAutofixCache._get_post_autofix_cache( + result = SeerOperatorAutofixCache[MockCachePayload]._get_post_autofix_cache( entrypoint_key=self.entrypoint_key, run_id=MOCK_RUN_ID ) @@ -123,14 +123,16 @@ def test_get_post_autofix_cache_miss(self, mock_cache_get): @patch("sentry.seer.entrypoints.cache.cache.delete") @patch("sentry.seer.entrypoints.cache.cache.get") - def test_get_prefers_post_cache_and_deletes_pre_cache(self, mock_cache_get, mock_cache_delete): + def test_get_prefers_post_cache_and_deletes_pre_cache( + self, mock_cache_get: MagicMock, mock_cache_delete: MagicMock + ) -> None: pre_cache_payload = MockCachePayload(thread_id="pre_cache_payload") post_cache_payload = MockCachePayload(thread_id="post_cache_payload") mock_cache_get.side_effect = lambda k: ( post_cache_payload if k == self.post_cache_key else pre_cache_payload ) - result = SeerOperatorAutofixCache.get( + result = SeerOperatorAutofixCache[MockCachePayload].get( entrypoint_key=self.entrypoint_key, group_id=MOCK_GROUP_ID, run_id=MOCK_RUN_ID ) @@ -141,13 +143,15 @@ def test_get_prefers_post_cache_and_deletes_pre_cache(self, mock_cache_get, mock @patch("sentry.seer.entrypoints.cache.cache.delete") @patch("sentry.seer.entrypoints.cache.cache.get") - def test_get_falls_back_to_pre_cache(self, mock_cache_get, mock_cache_delete): + def test_get_falls_back_to_pre_cache( + self, mock_cache_get: MagicMock, mock_cache_delete: MagicMock + ) -> None: pre_cache_payload = MockCachePayload(thread_id="pre_cache_payload") mock_cache_get.side_effect = lambda k: ( pre_cache_payload if k == self.pre_cache_key else None ) - result = SeerOperatorAutofixCache.get( + result = SeerOperatorAutofixCache[MockCachePayload].get( entrypoint_key=self.entrypoint_key, group_id=MOCK_GROUP_ID, run_id=MOCK_RUN_ID ) @@ -157,10 +161,10 @@ def test_get_falls_back_to_pre_cache(self, mock_cache_get, mock_cache_delete): mock_cache_delete.assert_not_called() @patch("sentry.seer.entrypoints.cache.cache.get") - def test_get_all_cache_miss(self, mock_cache_get): + def test_get_all_cache_miss(self, mock_cache_get: MagicMock) -> None: mock_cache_get.return_value = None - result = SeerOperatorAutofixCache.get( + result = SeerOperatorAutofixCache[MockCachePayload].get( entrypoint_key=self.entrypoint_key, group_id=MOCK_GROUP_ID, run_id=MOCK_RUN_ID ) @@ -182,7 +186,7 @@ def setUp(self) -> None: {SeerEntrypointKey.SLACK: None}, ) @patch("sentry.seer.entrypoints.cache.cache") - def test_migrate(self, mock_cache): + def test_migrate(self, mock_cache: MagicMock) -> None: pre_cache_payload = MockCachePayload(thread_id="pre_cache_payload") mock_cache.get.side_effect = lambda k: ( pre_cache_payload if k == self.pre_cache_key else None @@ -200,7 +204,7 @@ def test_migrate(self, mock_cache): {SeerEntrypointKey.SLACK: None}, ) @patch("sentry.seer.entrypoints.cache.cache") - def test_migrate_full_miss(self, mock_cache): + def test_migrate_full_miss(self, mock_cache: MagicMock) -> None: mock_cache.get.side_effect = lambda k: None SeerOperatorAutofixCache.migrate(from_group_id=MOCK_GROUP_ID, to_run_id=MOCK_RUN_ID) mock_cache.set.assert_not_called() @@ -210,7 +214,7 @@ def test_migrate_full_miss(self, mock_cache): {SeerEntrypointKey.SLACK: None}, ) @patch("sentry.seer.entrypoints.cache.cache") - def test_migrate_overwrite(self, mock_cache): + def test_migrate_overwrite(self, mock_cache: MagicMock) -> None: pre_cache_payload = MockCachePayload(thread_id="pre_cache_payload") post_cache_payload = MockCachePayload(thread_id="post_cache_payload") mock_cache.get.side_effect = lambda k: ( @@ -242,7 +246,7 @@ def test_get_cache_key(self) -> None: assert self.cache_key == f"seer:agent:{self.entrypoint_key}:{MOCK_RUN_ID}" @patch("sentry.seer.entrypoints.cache.cache.set") - def test_set(self, mock_cache_set): + def test_set(self, mock_cache_set: MagicMock) -> None: payload = MockCachePayload(thread_id="agent_payload") SeerOperatorAgentCache.set( entrypoint_key=self.entrypoint_key, @@ -256,7 +260,7 @@ def test_set(self, mock_cache_set): ) @patch("sentry.seer.entrypoints.cache.cache.get") - def test_get(self, mock_cache_get): + def test_get(self, mock_cache_get: MagicMock) -> None: payload = MockCachePayload(thread_id="agent_payload") mock_cache_get.side_effect = lambda k: payload if k == self.cache_key else None @@ -266,7 +270,7 @@ def test_get(self, mock_cache_get): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_halt") @patch("sentry.seer.entrypoints.cache.cache.get") - def test_get_cache_miss(self, mock_cache_get, mock_record_halt): + def test_get_cache_miss(self, mock_cache_get: MagicMock, mock_record_halt: MagicMock) -> None: mock_cache_get.return_value = None result = SeerOperatorAgentCache.get(entrypoint_key=self.entrypoint_key, run_id=MOCK_RUN_ID) @@ -294,7 +298,7 @@ def test_get_cache_key(self) -> None: ) @patch("sentry.seer.entrypoints.cache.cache.set") - def test_set(self, mock_cache_set): + def test_set(self, mock_cache_set: MagicMock) -> None: payload = MockCachePayload(thread_id="pending_payload") SeerOperatorPendingMentionCache.set( entrypoint_key=self.entrypoint_key, @@ -310,7 +314,7 @@ def test_set(self, mock_cache_set): @patch("sentry.seer.entrypoints.cache.cache.delete") @patch("sentry.seer.entrypoints.cache.cache.get") - def test_pop_hit(self, mock_cache_get, mock_cache_delete): + def test_pop_hit(self, mock_cache_get: MagicMock, mock_cache_delete: MagicMock) -> None: payload = MockCachePayload(thread_id="pending_payload") mock_cache_get.return_value = payload @@ -327,7 +331,9 @@ def test_pop_hit(self, mock_cache_get, mock_cache_delete): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_halt") @patch("sentry.seer.entrypoints.cache.cache.delete") @patch("sentry.seer.entrypoints.cache.cache.get") - def test_pop_miss(self, mock_cache_get, mock_cache_delete, mock_record_halt): + def test_pop_miss( + self, mock_cache_get: MagicMock, mock_cache_delete: MagicMock, mock_record_halt: MagicMock + ) -> None: mock_cache_get.return_value = None result = SeerOperatorPendingMentionCache[MockCachePayload].pop( @@ -343,7 +349,7 @@ def test_pop_miss(self, mock_cache_get, mock_cache_delete, mock_record_halt): class SeerOperatorInvestigationCacheTest(TestCase): @patch("sentry.seer.entrypoints.cache.cache.set") - def test_set_uses_investigation_key_and_ttl(self, mock_cache_set): + def test_set_uses_investigation_key_and_ttl(self, mock_cache_set: MagicMock) -> None: payload = MockCachePayload(thread_id="thread") SeerOperatorInvestigationCache.set( diff --git a/tests/sentry/seer/entrypoints/test_operator.py b/tests/sentry/seer/entrypoints/test_operator.py index 5a98d199dcbe..92049acbe9d2 100644 --- a/tests/sentry/seer/entrypoints/test_operator.py +++ b/tests/sentry/seer/entrypoints/test_operator.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime from typing import Any, TypedDict, cast -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import pytest from django.test import override_settings @@ -71,11 +71,11 @@ class MockAutofixEntrypoint(SeerAutofixEntrypoint[MockCachePayload]): key = cast(SeerEntrypointKey, "MOCK") - def __init__(self): + def __init__(self) -> None: self.thread_id = str(uuid.uuid4()) - self.autofix_errors = [] - self.autofix_run_ids = [] - self.autofix_update_cache_payloads = [] + self.autofix_errors: list[str] = [] + self.autofix_run_ids: list[int] = [] + self.autofix_update_cache_payloads: list[object] = [] self.autofix_already_exists_states: list[tuple[int, bool]] = [] self.handoff_errors: list[str] = [] self.handoff_successes: list[tuple[int, CodingAgentProviderType]] = [] @@ -131,7 +131,7 @@ def _set_automation_handoff( self.project.update_option("sentry:seer_automation_handoff_integration_id", 789) @patch("sentry.seer.entrypoints.operator.has_seer_access", return_value=True) - def test_has_access_with_seer(self, _mock_has_seer_access): + def test_has_access_with_seer(self, _mock_has_seer_access: MagicMock) -> None: MockNoAccessEntrypoint = Mock(spec=SeerAutofixEntrypoint) MockNoAccessEntrypoint.key = cast(SeerEntrypointKey, "MOCK_NO_ACCESS") MockNoAccessEntrypoint.has_access.return_value = False @@ -156,7 +156,7 @@ def test_has_access_with_seer(self, _mock_has_seer_access): ) @patch("sentry.seer.entrypoints.operator.has_seer_access", return_value=False) - def test_has_access_without_seer(self, _mock_has_seer_access): + def test_has_access_without_seer(self, _mock_has_seer_access: MagicMock) -> None: assert not SeerAutofixOperator.has_access(organization=self.group.project.organization) for entrypoint_key in autofix_entrypoint_registry.registrations.keys(): assert not SeerAutofixOperator.has_access( @@ -167,8 +167,8 @@ def test_has_access_without_seer(self, _mock_has_seer_access): @patch("sentry.seer.autofix.autofix_agent.get_autofix_agent_state", return_value=None) @patch("sentry.seer.autofix.autofix_agent.trigger_autofix_agent") def test_slack_autofix_kickoff_creates_activity( - self, mock_trigger_autofix, _mock_get_autofix_state - ): + self, mock_trigger_autofix: MagicMock, _mock_get_autofix_state: MagicMock + ) -> None: mock_trigger_autofix.return_value = self.create_seer_run( organization=self.organization, seer_run_state_id=MOCK_RUN_ID, @@ -194,7 +194,9 @@ def test_slack_autofix_kickoff_creates_activity( @patch("sentry.seer.autofix.autofix_agent.get_autofix_agent_state", return_value=None) @patch("sentry.seer.autofix.autofix_agent.trigger_push_changes") - def test_slack_open_pr_passes_commit_author(self, mock_push_changes, _mock_get_autofix_state): + def test_slack_open_pr_passes_commit_author( + self, mock_push_changes: MagicMock, _mock_get_autofix_state: MagicMock + ) -> None: def open_pr() -> None: self.operator.trigger_autofix( group=self.group, @@ -225,7 +227,9 @@ def open_pr() -> None: } @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") - def test_trigger_handoff_no_config_is_silent_halt(self, mock_trigger_handoff_helper): + def test_trigger_handoff_no_config_is_silent_halt( + self, mock_trigger_handoff_helper: MagicMock + ) -> None: self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) mock_trigger_handoff_helper.assert_not_called() assert self.entrypoint.handoff_successes == [] @@ -238,8 +242,8 @@ def test_trigger_handoff_no_config_is_silent_halt(self, mock_trigger_handoff_hel side_effect=RuntimeError("boom"), ) def test_trigger_handoff_launch_error_calls_error_hook( - self, mock_trigger_handoff_helper, mock_fetch_status - ): + self, mock_trigger_handoff_helper: MagicMock, mock_fetch_status: MagicMock + ) -> None: self._set_automation_handoff() mock_fetch_status.return_value = self._build_explorer_state_with_agents({}) self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) @@ -261,7 +265,9 @@ def _build_explorer_state_with_agents( @patch("sentry.seer.entrypoints.operator.fetch_run_status") @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") - def test_trigger_handoff_success(self, mock_trigger_handoff_helper, mock_fetch_status): + def test_trigger_handoff_success( + self, mock_trigger_handoff_helper: MagicMock, mock_fetch_status: MagicMock + ) -> None: self._set_automation_handoff() mock_fetch_status.return_value = self._build_explorer_state_with_agents({}) self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) @@ -276,8 +282,8 @@ def test_trigger_handoff_success(self, mock_trigger_handoff_helper, mock_fetch_s @patch("sentry.seer.entrypoints.operator.fetch_run_status") @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") def test_trigger_handoff_already_exists_running( - self, mock_trigger_handoff_helper, mock_fetch_status - ): + self, mock_trigger_handoff_helper: MagicMock, mock_fetch_status: MagicMock + ) -> None: self._set_automation_handoff() mock_fetch_status.return_value = self._build_explorer_state_with_agents( { @@ -299,8 +305,8 @@ def test_trigger_handoff_already_exists_running( @patch("sentry.seer.entrypoints.operator.fetch_run_status") @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") def test_trigger_handoff_already_exists_completed( - self, mock_trigger_handoff_helper, mock_fetch_status - ): + self, mock_trigger_handoff_helper: MagicMock, mock_fetch_status: MagicMock + ) -> None: self._set_automation_handoff() mock_fetch_status.return_value = self._build_explorer_state_with_agents( { @@ -322,8 +328,8 @@ def test_trigger_handoff_already_exists_completed( @patch("sentry.seer.entrypoints.operator.fetch_run_status") @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") def test_trigger_handoff_proceeds_when_all_agents_failed( - self, mock_trigger_handoff_helper, mock_fetch_status - ): + self, mock_trigger_handoff_helper: MagicMock, mock_fetch_status: MagicMock + ) -> None: self._set_automation_handoff() mock_fetch_status.return_value = self._build_explorer_state_with_agents( { @@ -349,8 +355,8 @@ def test_trigger_handoff_proceeds_when_all_agents_failed( ) @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") def test_trigger_handoff_state_fetch_error_calls_error_hook( - self, mock_trigger_handoff_helper, mock_fetch_status - ): + self, mock_trigger_handoff_helper: MagicMock, mock_fetch_status: MagicMock + ) -> None: self._set_automation_handoff() self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) mock_trigger_handoff_helper.assert_not_called() @@ -363,7 +369,7 @@ def test_trigger_handoff_state_fetch_error_calls_error_hook( {MockAutofixEntrypoint.key: MockAutofixEntrypoint}, clear=True, ) - def test_process_autofix_updates_early_exits(self, _mock_has_access): + def test_process_autofix_updates_early_exits(self, _mock_has_access: MagicMock) -> None: with patch.object(MockAutofixEntrypoint, "on_autofix_update") as mock_on_autofix_update: # Missing group_id/run_id process_autofix_updates( @@ -399,7 +405,9 @@ def test_process_autofix_updates_early_exits(self, _mock_has_access): @patch.object(SeerAutofixOperator, "has_access", return_value=True) @patch("sentry.seer.entrypoints.cache.SeerOperatorAutofixCache.get") - def test_process_autofix_updates(self, mock_autofix_cache_get, _mock_has_access): + def test_process_autofix_updates( + self, mock_autofix_cache_get: MagicMock, _mock_has_access: MagicMock + ) -> None: cache_payload = self.entrypoint.create_autofix_cache_payload() mock_autofix_cache_get.side_effect = lambda **kwargs: SeerOperatorCacheResult( payload=cache_payload, source="run_id", key="abc" @@ -429,8 +437,8 @@ def test_process_autofix_updates(self, mock_autofix_cache_get, _mock_has_access) @patch.object(SeerAutofixOperator, "has_access", return_value=True) @patch("sentry.seer.entrypoints.cache.SeerOperatorAutofixCache.get") def test_process_autofix_updates_with_activity_already_recorded( - self, mock_autofix_cache_get, _mock_has_access - ): + self, mock_autofix_cache_get: MagicMock, _mock_has_access: MagicMock + ) -> None: cache_payload = self.entrypoint.create_autofix_cache_payload() mock_autofix_cache_get.return_value = SeerOperatorCacheResult( payload=cache_payload, source="run_id", key="abc" @@ -486,8 +494,8 @@ def test_process_autofix_updates_no_operator_access(self) -> None: @patch.object(SeerAutofixOperator, "has_access", return_value=True) @patch("sentry.seer.entrypoints.cache.SeerOperatorAutofixCache.get") def test_process_autofix_updates_skips_entrypoint_without_access( - self, mock_autofix_cache_get, _mock_has_access - ): + self, mock_autofix_cache_get: MagicMock, _mock_has_access: MagicMock + ) -> None: cache_payload = self.entrypoint.create_autofix_cache_payload() mock_autofix_cache_get.side_effect = lambda **kwargs: SeerOperatorCacheResult( payload=cache_payload, source="run_id", key="abc" @@ -525,22 +533,26 @@ def test_can_trigger_autofix_returns_false_without_seer_access(self) -> None: assert SeerAutofixOperator.can_trigger_autofix(group=self.group) is False @patch("sentry.quotas.backend.check_seer_quota", return_value=True) - def test_can_trigger_autofix_returns_true_when_all_conditions_met(self, mock_quota): + def test_can_trigger_autofix_returns_true_when_all_conditions_met( + self, mock_quota: MagicMock + ) -> None: assert SeerAutofixOperator.can_trigger_autofix(group=self.group) is True @patch("sentry.quotas.backend.check_seer_quota", return_value=True) - def test_can_trigger_autofix_returns_false_for_ineligible_category(self, mock_quota): + def test_can_trigger_autofix_returns_false_for_ineligible_category( + self, mock_quota: MagicMock + ) -> None: from sentry.issues.grouptype import FeedbackGroup feedback_group = self.create_group(project=self.project, type=FeedbackGroup.type_id) assert SeerAutofixOperator.can_trigger_autofix(group=feedback_group) is False @patch("sentry.quotas.backend.check_seer_quota", return_value=False) - def test_can_trigger_autofix_returns_false_without_quota(self, mock_quota): + def test_can_trigger_autofix_returns_false_without_quota(self, mock_quota: MagicMock) -> None: assert SeerAutofixOperator.can_trigger_autofix(group=self.group) is False @patch.object(SeerAutofixOperator, "has_access", return_value=True) - def test_seer_event_creates_activity_rca_completed(self, _mock_has_access): + def test_seer_event_creates_activity_rca_completed(self, _mock_has_access: MagicMock) -> None: event_payload = { "run_id": MOCK_RUN_ID, "group_id": self.group.id, @@ -567,7 +579,9 @@ def test_seer_event_creates_activity_rca_completed(self, _mock_has_access): assert "five_whys" not in activity.data @patch.object(SeerAutofixOperator, "has_access", return_value=True) - def test_seer_event_creates_activity_solution_completed(self, _mock_has_access): + def test_seer_event_creates_activity_solution_completed( + self, _mock_has_access: MagicMock + ) -> None: activity_datetime = datetime.fromisoformat("2024-01-15T10:30:00+00:00") event_payload = { "run_id": MOCK_RUN_ID, @@ -595,7 +609,9 @@ def test_seer_event_creates_activity_solution_completed(self, _mock_has_access): assert activity.datetime > activity_datetime @patch.object(SeerAutofixOperator, "has_access", return_value=True) - def test_seer_event_creates_activity_coding_completed(self, _mock_has_access): + def test_seer_event_creates_activity_coding_completed( + self, _mock_has_access: MagicMock + ) -> None: event_payload = { "run_id": MOCK_RUN_ID, "group_id": self.group.id, @@ -616,7 +632,7 @@ def test_seer_event_creates_activity_coding_completed(self, _mock_has_access): assert "code_changes" not in activity.data @patch.object(SeerAutofixOperator, "has_access", return_value=True) - def test_create_seer_activity_all_mapped_event_types(self, _mock_has_access): + def test_create_seer_activity_all_mapped_event_types(self, _mock_has_access: MagicMock) -> None: with capture_action_log() as action_log: for seer_event, expected_activity_type in SEER_EVENT_TO_ACTIVITY_TYPE.items(): event_payload = {"run_id": MOCK_RUN_ID, "group_id": self.group.id} @@ -677,7 +693,7 @@ def test_create_seer_activity_all_mapped_event_types(self, _mock_has_access): ).exists() @patch.object(SeerAutofixOperator, "has_access", return_value=True) - def test_create_seer_activity_skips_non_seer_events(self, _mock_has_access): + def test_create_seer_activity_skips_non_seer_events(self, _mock_has_access: MagicMock) -> None: event_payload = {"run_id": MOCK_RUN_ID, "group_id": self.group.id} process_autofix_updates( @@ -690,7 +706,7 @@ def test_create_seer_activity_skips_non_seer_events(self, _mock_has_access): assert not Activity.objects.filter(group=self.group, type__in=seer_type_values).exists() @patch.object(SeerAutofixOperator, "has_access", return_value=True) - def test_create_seer_activity_option_disabled(self, _mock_has_access): + def test_create_seer_activity_option_disabled(self, _mock_has_access: MagicMock) -> None: event_payload = {"run_id": MOCK_RUN_ID, "group_id": self.group.id} with override_options({"issues.record-seer-actions-as-activities": False}): @@ -704,7 +720,9 @@ def test_create_seer_activity_option_disabled(self, _mock_has_access): assert not Activity.objects.filter(group=self.group, type__in=seer_type_values).exists() @patch.object(SeerAutofixOperator, "has_access", return_value=True) - def test_create_seer_activity_pr_created_with_pull_requests(self, _mock_has_access): + def test_create_seer_activity_pr_created_with_pull_requests( + self, _mock_has_access: MagicMock + ) -> None: event_payload = { "run_id": MOCK_RUN_ID, "group_id": self.group.id, @@ -739,7 +757,7 @@ def test_create_seer_activity_pr_created_with_pull_requests(self, _mock_has_acce ) @patch.object(SeerAutofixOperator, "has_access", return_value=True) - def test_create_seer_activity_iteration_completed(self, _mock_has_access): + def test_create_seer_activity_iteration_completed(self, _mock_has_access: MagicMock) -> None: event_payload = { "run_id": MOCK_RUN_ID, "group_id": self.group.id, @@ -771,8 +789,8 @@ def test_create_seer_activity_iteration_completed(self, _mock_has_access): @patch("sentry.models.activity.invoke_workflow_activity_handlers") @patch.object(SeerAutofixOperator, "has_access", return_value=True) def test_create_seer_activity_invokes_workflow_activity_handlers( - self, _mock_has_access, mock_invoke - ): + self, _mock_has_access: MagicMock, mock_invoke: MagicMock + ) -> None: event_payload = {"run_id": MOCK_RUN_ID, "group_id": self.group.id} process_autofix_updates( @@ -908,7 +926,7 @@ class MockAgentEntrypoint(SeerAgentEntrypoint[MockCachePayload]): key = cast(SeerEntrypointKey, "MOCK_AGENT") - def __init__(self): + def __init__(self) -> None: self.thread_id = str(uuid.uuid4()) self.agent_errors: list[str] = [] self.agent_run_ids: list[int] = [] @@ -937,7 +955,7 @@ def setUp(self) -> None: self.entrypoint = MockAgentEntrypoint() self.operator = SeerAgentOperator(self.entrypoint) - def test_has_access_with_seer_agent(self): + def test_has_access_with_seer_agent(self) -> None: MockNoAccessEntrypoint = Mock(spec=SeerAgentEntrypoint) MockNoAccessEntrypoint.key = cast(SeerEntrypointKey, "MOCK_NO_ACCESS") MockNoAccessEntrypoint.has_access.return_value = False @@ -967,13 +985,13 @@ def test_has_access_with_seer_agent(self): ) @override_settings(SENTRY_SELF_HOSTED=True) - def test_has_access_without_seer_agent(self): + def test_has_access_without_seer_agent(self) -> None: assert not SeerAgentOperator.has_access(organization=self.organization) class TestSeerOperatorCompletionHook(TestCase): @patch("sentry.seer.entrypoints.operator.refresh_agent_run.apply_async") - def test_execute_only_queues_a_refresh(self, mock_apply_async): + def test_execute_only_queues_a_refresh(self, mock_apply_async: MagicMock) -> None: SeerOperatorCompletionHook.execute(self.organization, MOCK_RUN_ID) mock_apply_async.assert_called_once_with( @@ -1006,8 +1024,8 @@ def _execute_with_mock_entrypoint( mock_fetch: Mock, state: SeerRunState, *, - cache_return_value: dict | None | object = _SENTINEL, - registrations: dict | None = None, + cache_return_value: object = _SENTINEL, + registrations: dict[str, Mock] | None = None, ) -> Mock: """Execute the hook with a standard mock entrypoint setup. @@ -1043,7 +1061,7 @@ def _execute_with_mock_entrypoint( return mock_entrypoint_cls @patch("sentry.seer.entrypoints.operator.fetch_run_status") - def test_execute_passes_the_run_state(self, mock_fetch): + def test_execute_passes_the_run_state(self, mock_fetch: MagicMock) -> None: state = self._make_state( blocks=[ MemoryBlock( @@ -1063,7 +1081,9 @@ def test_execute_passes_the_run_state(self, mock_fetch): @patch("sentry.seer.entrypoints.operator.SeerAgentOperator.has_access", return_value=True) @patch("sentry.seer.entrypoints.operator.fetch_run_status") - def test_execute_returns_early_on_fetch_error(self, mock_fetch, _mock_access): + def test_execute_returns_early_on_fetch_error( + self, mock_fetch: MagicMock, _mock_access: MagicMock + ) -> None: mock_fetch.side_effect = Exception("Seer is down") mock_entrypoint_cls = Mock(spec=SeerAgentEntrypoint) @@ -1078,7 +1098,9 @@ def test_execute_returns_early_on_fetch_error(self, mock_fetch, _mock_access): @patch("sentry.seer.entrypoints.operator.SeerAgentOperator.has_access", return_value=True) @patch("sentry.seer.entrypoints.operator.fetch_run_status") - def test_execute_skips_entrypoint_without_access(self, mock_fetch, _mock_access): + def test_execute_skips_entrypoint_without_access( + self, mock_fetch: MagicMock, _mock_access: MagicMock + ) -> None: state = self._make_state( blocks=[ MemoryBlock( @@ -1117,7 +1139,7 @@ def test_execute_skips_entrypoint_without_access(self, mock_fetch, _mock_access) ) @patch("sentry.seer.entrypoints.operator.fetch_run_status") - def test_execute_skips_entrypoint_without_cache(self, mock_fetch): + def test_execute_skips_entrypoint_without_cache(self, mock_fetch: MagicMock) -> None: state = self._make_state( blocks=[ MemoryBlock( @@ -1135,7 +1157,9 @@ def test_execute_skips_entrypoint_without_cache(self, mock_fetch): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.operator.fetch_run_status") - def test_execute_records_failure_on_org_mismatch(self, mock_fetch, mock_record): + def test_execute_records_failure_on_org_mismatch( + self, mock_fetch: MagicMock, mock_record: MagicMock + ) -> None: state = self._make_state( blocks=[ MemoryBlock( @@ -1157,7 +1181,9 @@ def test_execute_records_failure_on_org_mismatch(self, mock_fetch, mock_record): @patch("sentry.seer.entrypoints.operator.SeerAgentOperator.has_access", return_value=True) @patch("sentry.seer.entrypoints.operator.fetch_run_status") - def test_execute_propagates_retryable_entrypoint_failure(self, mock_fetch, _mock_access): + def test_execute_propagates_retryable_entrypoint_failure( + self, mock_fetch: MagicMock, _mock_access: MagicMock + ) -> None: mock_fetch.return_value = self._make_state(blocks=[]) mock_entrypoint_cls = Mock(spec=SeerAgentEntrypoint) mock_entrypoint_cls.has_access.return_value = True @@ -1178,7 +1204,7 @@ def test_execute_propagates_retryable_entrypoint_failure(self, mock_fetch, _mock self._refresh() @patch("sentry.seer.entrypoints.operator.fetch_run_status") - def test_raises_when_another_refresh_holds_the_lock(self, mock_fetch): + def test_raises_when_another_refresh_holds_the_lock(self, mock_fetch: MagicMock) -> None: lock = locks.get( f"seer:operator:agent-refresh:{MOCK_RUN_ID}", duration=10, @@ -1197,7 +1223,7 @@ def setUp(self) -> None: self.operator = SeerAgentOperator(self.entrypoint) @patch("sentry.seer.entrypoints.operator.SeerAgentClient") - def test_slack_code_mode_enabled(self, mock_client_cls): + def test_slack_code_mode_enabled(self, mock_client_cls: MagicMock) -> None: mock_client = Mock() mock_client.start_run.return_value = Mock(seer_run_state_id=1) mock_client.latest_run.return_value = None @@ -1216,7 +1242,7 @@ def test_slack_code_mode_enabled(self, mock_client_cls): assert mock_client_cls.call_args.kwargs["enable_code_mode_tools"] == "only" @patch("sentry.seer.entrypoints.operator.SeerAgentClient") - def test_slack_code_mode_disabled(self, mock_client_cls): + def test_slack_code_mode_disabled(self, mock_client_cls: MagicMock) -> None: mock_client = Mock() mock_client.start_run.return_value = Mock(seer_run_state_id=1) mock_client.latest_run.return_value = None @@ -1235,7 +1261,7 @@ def test_slack_code_mode_disabled(self, mock_client_cls): mock_client.latest_run.assert_called_once_with(only_current_user=False) @patch("sentry.seer.entrypoints.operator.SeerAgentClient") - def test_non_slack_category_ignores_flag(self, mock_client_cls): + def test_non_slack_category_ignores_flag(self, mock_client_cls: MagicMock) -> None: mock_client = Mock() mock_client.start_run.return_value = Mock(seer_run_state_id=1) mock_client.latest_run.return_value = None @@ -1267,7 +1293,7 @@ def _trigger_slack(self, mock_client_cls: Mock) -> None: ) @patch("sentry.seer.entrypoints.operator.SeerAgentClient") - def test_slack_create_pr_turns_on_pull_requests(self, mock_client_cls): + def test_slack_create_pr_turns_on_pull_requests(self, mock_client_cls: MagicMock) -> None: self.organization.update_option("sentry:enable_seer_coding", True) with self.feature( @@ -1281,7 +1307,9 @@ def test_slack_create_pr_turns_on_pull_requests(self, mock_client_cls): assert kwargs["enable_write_pull_request"] is True @patch("sentry.seer.entrypoints.operator.SeerAgentClient") - def test_slack_create_pr_defaults_coding_on_when_option_unset(self, mock_client_cls): + def test_slack_create_pr_defaults_coding_on_when_option_unset( + self, mock_client_cls: MagicMock + ) -> None: with self.feature( ["organizations:seer-slack-code-mode", "organizations:ask-seer-create-pr"] ): @@ -1292,7 +1320,7 @@ def test_slack_create_pr_defaults_coding_on_when_option_unset(self, mock_client_ assert kwargs["enable_write_pull_request"] is True @patch("sentry.seer.entrypoints.operator.SeerAgentClient") - def test_slack_create_pr_needs_the_coding_option(self, mock_client_cls): + def test_slack_create_pr_needs_the_coding_option(self, mock_client_cls: MagicMock) -> None: self.organization.update_option("sentry:enable_seer_coding", False) with self.feature( @@ -1305,7 +1333,7 @@ def test_slack_create_pr_needs_the_coding_option(self, mock_client_cls): assert kwargs["enable_write_pull_request"] is False @patch("sentry.seer.entrypoints.operator.SeerAgentClient") - def test_slack_create_pr_needs_code_mode(self, mock_client_cls): + def test_slack_create_pr_needs_code_mode(self, mock_client_cls: MagicMock) -> None: self.organization.update_option("sentry:enable_seer_coding", True) with self.feature("organizations:ask-seer-create-pr"): diff --git a/tests/sentry/seer/fetch_issues/test_by_error_type.py b/tests/sentry/seer/fetch_issues/test_by_error_type.py index 91f24e2b7eb4..08e76a80edb2 100644 --- a/tests/sentry/seer/fetch_issues/test_by_error_type.py +++ b/tests/sentry/seer/fetch_issues/test_by_error_type.py @@ -33,9 +33,9 @@ def test_simple(self) -> None: project_id=self.project.id, ) group = event.group + assert group is not None group.message = "Message to the error" - assert group is not None group.save() # Assert only 1 Group object in the database @@ -108,6 +108,7 @@ def test_multiple_projects(self) -> None: project_id=self.project.id, ) group_1 = event.group + assert group_1 is not None group_1.save() # Part of the queried results @@ -125,6 +126,7 @@ def test_multiple_projects(self) -> None: project_id=project_2.id, ) group_2 = event.group + assert group_2 is not None group_2.save() # NOT part of the queried results @@ -154,6 +156,7 @@ def test_multiple_projects(self) -> None: project_id=project_3.id, ) group_3 = event.group + assert group_3 is not None group_3.save() # Assert there's 3 Group objects in the database diff --git a/tests/sentry/seer/fetch_issues/test_by_function_name.py b/tests/sentry/seer/fetch_issues/test_by_function_name.py index 5acfecbaca1e..c4de002b94ff 100644 --- a/tests/sentry/seer/fetch_issues/test_by_function_name.py +++ b/tests/sentry/seer/fetch_issues/test_by_function_name.py @@ -1,5 +1,5 @@ from datetime import UTC, datetime, timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch from sentry.integrations.github.integration import GitHubIntegrationProvider from sentry.models.group import Group @@ -100,7 +100,7 @@ def test_empty_projects_list(self) -> None: assert result == [] @patch("sentry.seer.fetch_issues.by_function_name.raw_snql_query") - def test_snuba_query_exception(self, mock_query): + def test_snuba_query_exception(self, mock_query: MagicMock) -> None: mock_query.side_effect = Exception("Snuba error") result = _get_issues_for_file( @@ -114,11 +114,13 @@ def test_snuba_query_exception(self, mock_query): def test_basic_matching(self) -> None: # Create events that should match our search criteria - group = self._create_event( + event = self._create_event( function_names=["target_func", "other_func"], filenames=["test.py", "other.py"], user_id="1", - ).group + ) + assert event.group is not None + group = event.group result = _get_issues_for_file( projects=[self.project], @@ -135,11 +137,13 @@ def test_basic_matching(self) -> None: def test_filename_mismatch(self) -> None: # Create event with different filename - group = self._create_event( + event = self._create_event( function_names=["target_func"], filenames=["other.py"], user_id="1", - ).group + ) + assert event.group is not None + group = event.group result = _get_issues_for_file( projects=[self.project], @@ -155,11 +159,13 @@ def test_filename_mismatch(self) -> None: def test_function_name_mismatch(self) -> None: # Create event with different function name - group = self._create_event( + event = self._create_event( function_names=["other_func"], filenames=["test.py"], user_id="1", - ).group + ) + assert event.group is not None + group = event.group result = _get_issues_for_file( projects=[self.project], @@ -175,12 +181,14 @@ def test_function_name_mismatch(self) -> None: def test_event_too_old(self) -> None: # Create old event - use a smaller offset to avoid timestamp validation errors - group = self._create_event( + event = self._create_event( function_names=["target_func"], filenames=["test.py"], timestamp=before_now(days=NUM_DAYS_AGO + 1).isoformat(), user_id="1", - ).group + ) + assert event.group is not None + group = event.group result = _get_issues_for_file( projects=[self.project], @@ -196,11 +204,13 @@ def test_event_too_old(self) -> None: def test_javascript_simple(self) -> None: # Test with JavaScript files to ensure language-agnostic functionality - group = self._create_event( + event = self._create_event( function_names=["component.blue", "world"], filenames=["foo.js", "baz.js"], user_id="1", - ).group + ) + assert event.group is not None + group = event.group result = _get_issues_for_file( projects=[self.project], @@ -222,11 +232,13 @@ def test_stackframe_limit_edge_case(self) -> None: function_names = ["other_func" for _ in range(STACKFRAME_COUNT - 1)] + ["world"] filenames = ["other.py" for _ in range(STACKFRAME_COUNT - 1)] + ["test.py"] - group = self._create_event( + event = self._create_event( function_names=function_names, filenames=filenames, user_id="1", - ).group + ) + assert event.group is not None + group = event.group result = _get_issues_for_file( projects=[self.project], @@ -242,13 +254,17 @@ def test_stackframe_limit_edge_case(self) -> None: def test_multiple_matching_issues(self) -> None: # Create multiple events that should match - group1 = self._create_event( + event = self._create_event( function_names=["target_func"], filenames=["test.py"], user_id="1", culprit="issue1" - ).group + ) + assert event.group is not None + group1 = event.group - group2 = self._create_event( + event = self._create_event( function_names=["target_func"], filenames=["test.py"], user_id="2", culprit="issue2" - ).group + ) + assert event.group is not None + group2 = event.group result = _get_issues_for_file( projects=[self.project], @@ -284,11 +300,13 @@ def setUp(self) -> None: def test_successful_fetch(self) -> None: # Create some test events - group = self._create_event( + event = self._create_event( filenames=["test.py", "other.py"], function_names=["target_function", "other_func"], user_id="1", - ).group + ) + assert event.group is not None + group = event.group with patch( "sentry.seer.fetch_issues.by_function_name._fetch_issues_from_repo_projects" @@ -320,10 +338,12 @@ def test_successful_fetch(self) -> None: mock_serialize.assert_called_once_with([group]) def test_custom_max_num_issues(self) -> None: - group = self._create_event( + event = self._create_event( filenames=["test.py"], function_names=["target_function"], - ).group + ) + assert event.group is not None + group = event.group with patch( "sentry.seer.fetch_issues.by_function_name._fetch_issues_from_repo_projects" @@ -354,7 +374,9 @@ def test_custom_max_num_issues(self) -> None: assert call_args[1]["max_num_issues_per_file"] == 10 def test_with_run_id(self) -> None: - group = self._create_event().group + event = self._create_event() + assert event.group is not None + group = event.group with patch( "sentry.seer.fetch_issues.by_function_name._fetch_issues_from_repo_projects" @@ -450,7 +472,9 @@ def setUp(self) -> None: @patch("sentry.seer.fetch_issues.by_function_name._get_projects_and_filenames_from_source_file") @patch("sentry.seer.fetch_issues.by_function_name._get_issues_for_file") - def test_no_projects_found_fallback(self, mock_get_issues, mock_get_projects): + def test_no_projects_found_fallback( + self, mock_get_issues: MagicMock, mock_get_projects: MagicMock + ) -> None: # Mock no projects found initially mock_get_projects.return_value = (set(), {"test.py"}) mock_get_issues.return_value = [] @@ -478,7 +502,9 @@ def test_no_projects_found_fallback(self, mock_get_issues, mock_get_projects): @patch("sentry.seer.fetch_issues.by_function_name._get_projects_and_filenames_from_source_file") @patch("sentry.seer.fetch_issues.by_function_name._get_issues_for_file") - def test_projects_found_no_fallback(self, mock_get_issues, mock_get_projects): + def test_projects_found_no_fallback( + self, mock_get_issues: MagicMock, mock_get_projects: MagicMock + ) -> None: # Mock projects found mock_get_projects.return_value = ({self.project}, {"test.py"}) mock_get_issues.return_value = [] @@ -507,8 +533,8 @@ def test_projects_found_no_fallback(self, mock_get_issues, mock_get_projects): @patch("sentry.seer.fetch_issues.by_function_name._get_projects_and_filenames_from_source_file") @patch("sentry.seer.fetch_issues.by_function_name._get_issues_for_file") def test_drops_projects_outside_the_authorized_repo_projects( - self, mock_get_issues, mock_get_projects - ): + self, mock_get_issues: MagicMock, mock_get_projects: MagicMock + ) -> None: other_project = self.create_project(organization=self.organization) mock_get_projects.return_value = ({self.project, other_project}, {"test.py"}) mock_get_issues.return_value = [] @@ -540,6 +566,7 @@ def test_fetch_issues_from_repo_projects_returns_groups(self) -> None: function_names=["target_function", "other_func"], user_id="1", ) + assert event.group is not None expected_group = event.group # Get repo projects diff --git a/tests/sentry/seer/fetch_issues/test_by_text_query.py b/tests/sentry/seer/fetch_issues/test_by_text_query.py index 8f4e52451403..0155069d8c1d 100644 --- a/tests/sentry/seer/fetch_issues/test_by_text_query.py +++ b/tests/sentry/seer/fetch_issues/test_by_text_query.py @@ -4,6 +4,7 @@ from sentry.seer.fetch_issues import utils from sentry.seer.fetch_issues.by_text_query import _fetch_issues_from_repo_projects, fetch_issues from sentry.seer.fetch_issues.utils import get_repo_and_projects +from sentry.services.eventstore.models import Event from sentry.testutils.cases import IntegrationTestCase, TestCase from sentry.testutils.helpers.datetime import before_now @@ -11,14 +12,14 @@ class CreateEventTestCase(TestCase): def _create_event( self, - culprit=None, - timestamp=None, - filenames=None, - function_names=None, - project_id=None, - user_id=None, - handled=False, - ): + culprit: str | None = None, + timestamp: str | None = None, + filenames: list[str] | None = None, + function_names: list[str] | None = None, + project_id: int | None = None, + user_id: str | None = None, + handled: bool = False, + ) -> Event: if culprit is None: culprit = "issue0" if timestamp is None: @@ -36,7 +37,7 @@ def _create_event( for i, filename in enumerate(filenames): frames.append({"filename": filename, "function": function_names[i]}) - return self.store_event( + event: Event = self.store_event( data={ "message": "hello!", "culprit": culprit, @@ -57,6 +58,7 @@ def _create_event( }, project_id=project_id, ) + return event class TestFetchIssuesByTextQuery(IntegrationTestCase, CreateEventTestCase): @@ -79,12 +81,14 @@ def setUp(self) -> None: def test_fetch_issues_message_substring_search(self) -> None: """Test that text queries do case-insensitive substring search in issue messages.""" - group = self._create_event( + event = self._create_event( filenames=["auth.py", "utils.py"], function_names=["authenticate", "validate"], culprit="Authentication failed", user_id="1", - ).group + ) + assert event.group is not None + group = event.group # Test queries that should match content in the message # Message will be something like: "hello! Error auth.py authenticate Authentication failed" @@ -159,12 +163,14 @@ def test_fetch_issues_no_match(self) -> None: def test_fetch_issues_culprit_search(self) -> None: """Test that queries match content in the culprit field.""" - group = self._create_event( + event = self._create_event( filenames=["test.py"], function_names=["test_func"], culprit="Database connection timeout", user_id="1", - ).group + ) + assert event.group is not None + group = event.group # Query for a keyword from the culprit assert self.gh_repo.external_id is not None @@ -216,6 +222,7 @@ def test_fetch_issues_from_repo_projects_returns_groups(self) -> None: culprit="Authentication failed", user_id="1", ) + assert event.group is not None expected_group = event.group # Get repo projects diff --git a/tests/sentry/seer/fetch_issues/test_utils.py b/tests/sentry/seer/fetch_issues/test_utils.py index 1a2ee362009c..85ebe831a96d 100644 --- a/tests/sentry/seer/fetch_issues/test_utils.py +++ b/tests/sentry/seer/fetch_issues/test_utils.py @@ -262,6 +262,7 @@ def test_get_latest_issue_event_with_short_id(self) -> None: group = event.group assert group is not None + assert group.qualified_short_id is not None result = get_latest_issue_event(group.qualified_short_id, self.organization.id) assert isinstance(result, IssueDetails) diff --git a/tests/sentry/seer/similarity/test_grouping_records.py b/tests/sentry/seer/similarity/test_grouping_records.py index ba70905f047a..da70b4267682 100644 --- a/tests/sentry/seer/similarity/test_grouping_records.py +++ b/tests/sentry/seer/similarity/test_grouping_records.py @@ -21,7 +21,7 @@ @mock.patch("sentry.seer.similarity.grouping_records.make_signed_seer_api_request") def test_delete_grouping_records_by_hash_success( mock_seer_request: MagicMock, mock_logger: MagicMock -): +) -> None: mock_seer_request.return_value = HTTPResponse( json.dumps({"success": True}).encode("utf-8"), status=200 ) @@ -43,7 +43,7 @@ def test_delete_grouping_records_by_hash_success( @mock.patch("sentry.seer.similarity.grouping_records.make_signed_seer_api_request") def test_delete_grouping_records_by_hash_timeout( mock_seer_request: MagicMock, mock_logger: MagicMock -): +) -> None: mock_seer_request.side_effect = ReadTimeoutError( DUMMY_POOL, SEER_HASH_GROUPING_RECORDS_DELETE_URL, "read timed out" ) @@ -66,7 +66,7 @@ def test_delete_grouping_records_by_hash_timeout( @mock.patch("sentry.seer.similarity.grouping_records.make_signed_seer_api_request") def test_delete_grouping_records_by_hash_failure( mock_seer_request: MagicMock, mock_logger: MagicMock -): +) -> None: mock_seer_request.return_value = HTTPResponse( b"\n\n500 Internal Server Error\n

Internal Server Error

\n

The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.

\n", reason="INTERNAL SERVER ERROR", diff --git a/tests/sentry/seer/similarity/test_similar_issues.py b/tests/sentry/seer/similarity/test_similar_issues.py index 31b25734d5ff..2bbdaf23570f 100644 --- a/tests/sentry/seer/similarity/test_similar_issues.py +++ b/tests/sentry/seer/similarity/test_similar_issues.py @@ -49,7 +49,7 @@ def setUp(self) -> None: "exception_type": "FailedToFetchError", } - def _make_response(self, data: dict[str, Any], status: int = 200): + def _make_response(self, data: dict[str, Any], status: int = 200) -> HTTPResponse: return HTTPResponse(json.dumps(data).encode("utf-8"), status=status) @mock.patch("sentry.seer.similarity.similar_issues.metrics.incr") @@ -121,9 +121,9 @@ def test_bad_response_data( mock_seer_request: MagicMock, mock_metrics_incr: MagicMock, mock_record_circuit_breaker_error: MagicMock, - ): + ) -> None: existing_grouphash = GroupHash.objects.create(hash="dogs_are_great", project=self.project) - assert existing_grouphash.group_id is None + assert existing_grouphash.group is None cases: list[tuple[Any, str]] = [ (None, "AttributeError"), @@ -192,7 +192,7 @@ def test_redirect( mock_logger: MagicMock, mock_metrics_incr: MagicMock, mock_record_circuit_breaker_error: MagicMock, - ): + ) -> None: mock_seer_request.return_value = HTTPResponse( status=308, headers={"location": "/new/and/improved/endpoint/"} ) @@ -218,7 +218,7 @@ def test_request_error( mock_logger: MagicMock, mock_metrics_incr: MagicMock, mock_record_circuit_breaker_error: MagicMock, - ): + ) -> None: for request_error, expected_error_tag in [ (TimeoutError, "TimeoutError"), ( @@ -258,7 +258,7 @@ def test_error_status( mock_logger: MagicMock, mock_metrics_incr: MagicMock, mock_record_circuit_breaker_error: MagicMock, - ): + ) -> None: for response, status, counts_for_circuit_breaker in [ ("No soup for you", 403, False), ("No soup, period", 500, True), @@ -326,7 +326,7 @@ def test_calls_seer_deletion_task_if_parent_hash_not_found( self, mock_seer_similarity_request: MagicMock, mock_seer_deletion_request: MagicMock, - ): + ) -> None: mock_seer_similarity_request.return_value = self._make_response( { "responses": [ @@ -349,9 +349,9 @@ def test_conditionally_calls_seer_deletion_task_if_parent_hash_missing_group_id( self, mock_seer_similarity_request: MagicMock, mock_seer_deletion_request: MagicMock, - ): + ) -> None: existing_grouphash = GroupHash.objects.create(hash="dogs_are_great", project=self.project) - assert existing_grouphash.group_id is None + assert existing_grouphash.group is None # Set the grouphash creation date to yesterday GroupHashMetadata.objects.get_or_create( @@ -380,7 +380,7 @@ def test_conditionally_calls_seer_deletion_task_if_parent_hash_missing_group_id( newly_created_grouphash = GroupHash.objects.create( hash="adopt_dont_shop", project=self.project ) - assert newly_created_grouphash.group_id is None + assert newly_created_grouphash.group is None # Set the grouphash creation date to today GroupHashMetadata.objects.get_or_create( @@ -409,7 +409,7 @@ def test_conditionally_calls_seer_deletion_task_if_parent_hash_missing_group_id( # grouphash metadata was a thing very_old_grouphash = GroupHash.objects.create(hash="maisey", project=self.project) - assert very_old_grouphash.group_id is None + assert very_old_grouphash.group is None assert very_old_grouphash.metadata is None mock_seer_similarity_request.return_value = self._make_response( @@ -437,12 +437,12 @@ def test_retry_if_parent_hash_not_found_retry_success( mock_seer_similarity_request: MagicMock, mock_logger: MagicMock, mock_grouphash_objects_filter: MagicMock, - ): + ) -> None: existing_grouphash_no_group = GroupHash.objects.create( hash="dogs_are_great", project=self.project ) GroupHashMetadata.objects.create(grouphash=existing_grouphash_no_group) - assert existing_grouphash_no_group.group_id is None + assert existing_grouphash_no_group.group is None existing_grouphash_with_group = GroupHash.objects.create( hash="adopt_dont_shop", project=self.project, group=self.group ) @@ -496,10 +496,10 @@ def test_retry_if_parent_hash_not_found_retry_failure( self, mock_seer_similarity_request: MagicMock, mock_logger: MagicMock, - ): + ) -> None: existing_grouphash = GroupHash.objects.create(hash="dogs_are_great", project=self.project) GroupHashMetadata.objects.create(grouphash=existing_grouphash) - assert existing_grouphash.group_id is None + assert existing_grouphash.group is None mock_seer_similarity_request.return_value = self._make_response( { diff --git a/tests/sentry/seer/similarity/test_types.py b/tests/sentry/seer/similarity/test_types.py index 05139e67174f..2eeaa957fbe7 100644 --- a/tests/sentry/seer/similarity/test_types.py +++ b/tests/sentry/seer/similarity/test_types.py @@ -96,7 +96,7 @@ def test_from_raw_nonexistent_grouphash(self) -> None: def test_from_raw_grouphash_with_no_group(self) -> None: existing_grouphash = GroupHash.objects.create(hash="dogs_are_great", project=self.project) - assert existing_grouphash.group_id is None + assert existing_grouphash.group is None with pytest.raises(SimilarHashMissingGroupError): raw_similar_issue_data = { diff --git a/tests/sentry/seer/similarity/test_utils.py b/tests/sentry/seer/similarity/test_utils.py index d9a024d63ba6..a921d454eea0 100644 --- a/tests/sentry/seer/similarity/test_utils.py +++ b/tests/sentry/seer/similarity/test_utils.py @@ -440,7 +440,9 @@ def create_frames( minified_frames: Literal["all", "some", "none"] = "none", ) -> list[dict[str, Any]]: if minified_frames == "all": - make_context_line = lambda i: "{snip}" + context_line_factory(i) + "{snip}" + make_context_line: Callable[[int], str] = ( + lambda i: "{snip}" + context_line_factory(i) + "{snip}" + ) elif minified_frames == "some": make_context_line = lambda i: ( "{snip}" + context_line_factory(i) + "{snip}" diff --git a/tests/sentry/seer/smart_assignment/test_completion.py b/tests/sentry/seer/smart_assignment/test_completion.py index 9a08fbf22eae..d20f0730703b 100644 --- a/tests/sentry/seer/smart_assignment/test_completion.py +++ b/tests/sentry/seer/smart_assignment/test_completion.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, cast from django.utils import timezone @@ -49,7 +49,8 @@ def _activity(self, predicted_assignee_user_ids: list[int | None]) -> Activity: def _extras(self) -> dict[str, Any]: self.mirror.refresh_from_db() - return self.mirror.extras + # justification: the mirror extras column stores a JSON object. + return cast("dict[str, Any]", self.mirror.extras) def _member(self, username: str = "alice") -> User: user = self.create_user(username=username) diff --git a/tests/sentry/seer/smart_assignment/test_delivery.py b/tests/sentry/seer/smart_assignment/test_delivery.py index 8c2b42028b1e..e2bde0598145 100644 --- a/tests/sentry/seer/smart_assignment/test_delivery.py +++ b/tests/sentry/seer/smart_assignment/test_delivery.py @@ -1,5 +1,5 @@ from datetime import timedelta -from typing import Any +from typing import Any, cast from unittest.mock import ANY, MagicMock, patch from uuid import UUID @@ -35,7 +35,8 @@ def setUp(self) -> None: def _extras(self) -> dict[str, Any]: self.mirror.refresh_from_db() - return self.mirror.extras + # justification: the mirror extras column stores a JSON object. + return cast("dict[str, Any]", self.mirror.extras) def _deliver( self, diff --git a/tests/sentry/seer/supergroups/endpoints/test_organization_supergroup_details.py b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroup_details.py index f4efb5b98f4b..744c31eac39b 100644 --- a/tests/sentry/seer/supergroups/endpoints/test_organization_supergroup_details.py +++ b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroup_details.py @@ -18,14 +18,14 @@ def mock_seer_response(data: dict[str, Any]) -> MagicMock: class OrganizationSupergroupDetailsEndpointTest(APITestCase): endpoint = "sentry-api-0-organization-supergroup-details" - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(self.user) @patch( "sentry.seer.supergroups.endpoints.organization_supergroup_details.make_supergroups_get_request" ) - def test_get_supergroup_details(self, mock_seer): + def test_get_supergroup_details(self, mock_seer: MagicMock) -> None: mock_seer.return_value = mock_seer_response( {"id": 1, "title": "NullPointerException in auth", "group_ids": [10, 20]} ) diff --git a/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py index 342e79bc1206..b8911aa43c39 100644 --- a/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py +++ b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py @@ -23,7 +23,7 @@ def mock_seer_response(data: dict[str, Any]) -> MagicMock: class OrganizationSupergroupsByGroupEndpointTest(APITestCase): endpoint = "sentry-api-0-organization-supergroups-by-group" - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(self.user) self.unresolved_group = self.create_group( @@ -32,7 +32,7 @@ def setUp(self): self.resolved_group = self.create_group(project=self.project, status=GroupStatus.RESOLVED) @patch("sentry.seer.supergroups.by_group.make_supergroups_get_by_group_ids_request") - def test_status_filter_strips_resolved_from_response(self, mock_seer): + def test_status_filter_strips_resolved_from_response(self, mock_seer: MagicMock) -> None: extra_unresolved = self.create_group(project=self.project, status=GroupStatus.UNRESOLVED) mock_seer.return_value = mock_seer_response( { @@ -68,7 +68,7 @@ def test_status_filter_strips_resolved_from_response(self, mock_seer): extra_unresolved.id, ] - def test_status_filter_invalid(self): + def test_status_filter_invalid(self) -> None: with self.feature("organizations:top-issues-ui"): self.get_error_response( self.organization.slug, @@ -78,7 +78,7 @@ def test_status_filter_invalid(self): ) @patch("sentry.seer.supergroups.by_group.make_supergroups_get_by_group_ids_request") - def test_assignee_summary(self, mock_seer): + def test_assignee_summary(self, mock_seer: MagicMock) -> None: user_a = self.create_user(email="a@example.com") user_b = self.create_user(email="b@example.com") team = self.create_team(organization=self.organization, slug="backend") @@ -119,7 +119,7 @@ def test_assignee_summary(self, mock_seer): } @patch("sentry.seer.supergroups.by_group.make_supergroups_get_by_group_ids_request") - def test_assignee_summary_empty(self, mock_seer): + def test_assignee_summary_empty(self, mock_seer: MagicMock) -> None: unassigned = self.create_group(project=self.project) mock_seer.return_value = mock_seer_response( {"data": [{"id": 1, "group_ids": [unassigned.id], "title": "sg"}]} @@ -135,7 +135,7 @@ def test_assignee_summary_empty(self, mock_seer): assert sg["assignees"] == [] @patch("sentry.seer.supergroups.by_group.make_supergroups_get_by_group_ids_request") - def test_assignee_summary_ignores_cross_org_groups(self, mock_seer): + def test_assignee_summary_ignores_cross_org_groups(self, mock_seer: MagicMock) -> None: other_org = self.create_organization() other_project = self.create_project(organization=other_org) other_user = self.create_user(email="other@example.com") @@ -165,7 +165,7 @@ def test_assignee_summary_ignores_cross_org_groups(self, mock_seer): @patch.object(organization_supergroups_by_group, "_MAX_GROUPS_FOR_FETCH", 1) @patch("sentry.seer.supergroups.by_group.make_supergroups_get_by_group_ids_request") - def test_skips_fanout_over_threshold(self, mock_seer): + def test_skips_fanout_over_threshold(self, mock_seer: MagicMock) -> None: mock_seer.return_value = mock_seer_response( { "data": [ @@ -190,7 +190,7 @@ def test_skips_fanout_over_threshold(self, mock_seer): assert "assignees" not in sg assert self.resolved_group.id in sg["group_ids"] - def test_returns_404_when_all_groups_are_in_inaccessible_projects(self): + def test_returns_404_when_all_groups_are_in_inaccessible_projects(self) -> None: self.organization.flags.allow_joinleave = False self.organization.save() @@ -210,7 +210,7 @@ def test_returns_404_when_all_groups_are_in_inaccessible_projects(self): ) @patch("sentry.seer.supergroups.by_group.make_supergroups_get_by_group_ids_request") - def test_filters_out_groups_from_inaccessible_projects(self, mock_seer): + def test_filters_out_groups_from_inaccessible_projects(self, mock_seer: MagicMock) -> None: self.organization.flags.allow_joinleave = False self.organization.save() @@ -248,7 +248,7 @@ def test_filters_out_groups_from_inaccessible_projects(self, mock_seer): assert len(response.data["data"]) == 1 @patch("sentry.seer.supergroups.by_group.make_supergroups_get_by_group_ids_request") - def test_assignee_summary_tolerates_missing_actor(self, mock_seer): + def test_assignee_summary_tolerates_missing_actor(self, mock_seer: MagicMock) -> None: # GroupAssignee row references a user_id that `user_service.get_many_by_id` no longer returns assigned = self.create_group(project=self.project) GroupAssignee.objects.create(group=assigned, project=self.project, user_id=999_999) @@ -266,7 +266,7 @@ def test_assignee_summary_tolerates_missing_actor(self, mock_seer): sg = response.data["data"][0] assert sg["assignees"] == [] - def _login_as_restricted_member(self): + def _login_as_restricted_member(self) -> None: self.organization.flags.allow_joinleave = False self.organization.save() member_user = self.create_user() @@ -281,7 +281,7 @@ def _login_as_restricted_member(self): self.login_as(member_user) @patch("sentry.seer.supergroups.by_group.make_supergroups_get_by_group_ids_request") - def test_strips_inaccessible_groups_from_seer_response(self, mock_seer): + def test_strips_inaccessible_groups_from_seer_response(self, mock_seer: MagicMock) -> None: other_project = self.create_project(organization=self.organization) inaccessible_group = self.create_group(project=other_project) inaccessible_only_group = self.create_group(project=other_project) @@ -323,7 +323,7 @@ def test_strips_inaccessible_groups_from_seer_response(self, mock_seer): @patch.object(organization_supergroups_by_group, "_MAX_GROUPS_FOR_FETCH", 1) @patch("sentry.seer.supergroups.by_group.make_supergroups_get_by_group_ids_request") - def test_strips_inaccessible_groups_over_threshold(self, mock_seer): + def test_strips_inaccessible_groups_over_threshold(self, mock_seer: MagicMock) -> None: other_project = self.create_project(organization=self.organization) inaccessible_group = self.create_group(project=other_project) diff --git a/tests/sentry/seer/supergroups/test_lightweight_rca_cluster.py b/tests/sentry/seer/supergroups/test_lightweight_rca_cluster.py index 993ac447c3d3..3240790a2397 100644 --- a/tests/sentry/seer/supergroups/test_lightweight_rca_cluster.py +++ b/tests/sentry/seer/supergroups/test_lightweight_rca_cluster.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -30,16 +30,18 @@ class TriggerLightweightRCAClusterTest(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.event = self.store_event( data=EVENT_DATA_WITH_STACKTRACE, project_id=self.project.id, ) - self.group = self.event.group + group = self.event.group + assert group is not None + self.group = group @patch("sentry.seer.supergroups.lightweight_rca_cluster.make_lightweight_rca_cluster_request") - def test_calls_seer_with_correct_payload(self, mock_request): + def test_calls_seer_with_correct_payload(self, mock_request: MagicMock) -> None: mock_request.return_value.status = 200 trigger_lightweight_rca_cluster(self.group) @@ -56,14 +58,14 @@ def test_calls_seer_with_correct_payload(self, mock_request): assert len(body["issue"]["events"]) == 1 @patch("sentry.seer.supergroups.lightweight_rca_cluster.make_lightweight_rca_cluster_request") - def test_raises_on_seer_error(self, mock_request): + def test_raises_on_seer_error(self, mock_request: MagicMock) -> None: mock_request.return_value.status = 500 with pytest.raises(Exception): trigger_lightweight_rca_cluster(self.group) @patch("sentry.seer.supergroups.lightweight_rca_cluster.make_lightweight_rca_cluster_request") - def test_skips_event_without_stacktrace(self, mock_request): + def test_skips_event_without_stacktrace(self, mock_request: MagicMock) -> None: event = self.store_event( data={"message": "no stacktrace here", "level": "error", "fingerprint": ["no-stack"]}, project_id=self.project.id, @@ -75,7 +77,7 @@ def test_skips_event_without_stacktrace(self, mock_request): mock_request.assert_not_called() @patch("sentry.seer.supergroups.lightweight_rca_cluster.make_lightweight_rca_cluster_request") - def test_skips_event_with_unsupported_platform(self, mock_request): + def test_skips_event_with_unsupported_platform(self, mock_request: MagicMock) -> None: event = self.store_event( data={**EVENT_DATA_WITH_STACKTRACE, "platform": "other", "fingerprint": ["other-plat"]}, project_id=self.project.id, diff --git a/tests/sentry/seer/test_agent_token.py b/tests/sentry/seer/test_agent_token.py index aa651b2e06e3..ca9d626bbeaa 100644 --- a/tests/sentry/seer/test_agent_token.py +++ b/tests/sentry/seer/test_agent_token.py @@ -3,7 +3,7 @@ import contextlib from collections.abc import Iterable from datetime import datetime, timedelta -from typing import Any +from typing import Any, cast import pytest from django.contrib.auth.models import AnonymousUser @@ -420,7 +420,8 @@ def test_orm_request_access_factory_dispatches_by_agent_credential(self) -> None def _access_for(self, request: Request) -> Access: OrganizationPermission().has_object_permission(request, APIView(), self.org) - return request.access + # justification: Django authentication attaches the resolved Access to the request. + return cast("Access", request.access) def test_agent_access_mirrors_member_projects(self) -> None: # Access derives from the delegating member: a plain (non-global) member sees only diff --git a/tests/sentry/seer/test_breakpoints.py b/tests/sentry/seer/test_breakpoints.py index 37e94fd8fee7..00854970a453 100644 --- a/tests/sentry/seer/test_breakpoints.py +++ b/tests/sentry/seer/test_breakpoints.py @@ -1,5 +1,6 @@ from collections.abc import Generator from unittest import mock +from unittest.mock import MagicMock import pytest from django.test import override_settings @@ -56,7 +57,9 @@ def test_detect_breakpoints(mock_urlopen: mock.MagicMock) -> None: ) @mock.patch("sentry_sdk.capture_exception") @mock.patch("sentry.seer.breakpoints.seer_breakpoint_connection_pool.urlopen") -def test_detect_breakpoints_errors(mock_urlopen, mock_capture_exception, body, status) -> None: +def test_detect_breakpoints_errors( + mock_urlopen: MagicMock, mock_capture_exception: MagicMock, body: bytes, status: int +) -> None: mock_urlopen.return_value = HTTPResponse(body, status=status) assert detect_breakpoints({"data": {}}) == {"data": []} diff --git a/tests/sentry/seer/test_milestones.py b/tests/sentry/seer/test_milestones.py index cbbfdfcf677e..53dedd00308e 100644 --- a/tests/sentry/seer/test_milestones.py +++ b/tests/sentry/seer/test_milestones.py @@ -1,4 +1,5 @@ from datetime import UTC, datetime +from typing import cast from unittest import mock from sentry.models.pullrequest import PullRequestLifecycleState @@ -329,7 +330,11 @@ def _root_cause_state(self, description: str) -> SeerRunState: return _state(blocks=[block]) def _extras(self, milestone: str) -> dict[str, object]: - return SeerRunMilestone.objects.get(seer_run=self.seer_run, milestone=milestone).extras + # justification: the milestone extras column stores a JSON object. + return cast( + "dict[str, object]", + SeerRunMilestone.objects.get(seer_run=self.seer_run, milestone=milestone).extras, + ) def test_reconcile_writes_then_refreshes_extras_on_rerun(self) -> None: reconcile_milestones(self.seer_run, self._root_cause_state("first")) diff --git a/tests/sentry/seer/test_pull_requests.py b/tests/sentry/seer/test_pull_requests.py index f7f4d4895f45..30799a0dbe43 100644 --- a/tests/sentry/seer/test_pull_requests.py +++ b/tests/sentry/seer/test_pull_requests.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, cast from unittest.mock import Mock, patch from sentry.models.pullrequest import ( @@ -36,7 +36,8 @@ def _warning_extra(mock_logger: Mock, event: str) -> dict[str, Any]: call.kwargs["extra"] for call in mock_logger.warning.call_args_list if call.args[0] == event ] assert len(extras) == 1, f"expected exactly one {event} warning, got {len(extras)}" - return extras[0] + # justification: the logger is called with a JSON object of extras. + return cast("dict[str, Any]", extras[0]) class LinkSeerRunPullRequestsTest(TestCase): diff --git a/tests/sentry/seer/test_signed_seer_api.py b/tests/sentry/seer/test_signed_seer_api.py index 61a1cfd8e22f..efd2bec7039b 100644 --- a/tests/sentry/seer/test_signed_seer_api.py +++ b/tests/sentry/seer/test_signed_seer_api.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest @@ -28,8 +29,8 @@ def run_test_case( path: str = PATH, shared_secret: str = "secret-one", - **kwargs, -): + **kwargs: Any, +) -> Any: """ Make a mock connection pool, call `make_signed_seer_api_request` on it, and return the pool's `urlopen` method, so we can make assertions on how `make_signed_seer_api_request` diff --git a/tests/sentry/seer/workflows/test_breached_metrics.py b/tests/sentry/seer/workflows/test_breached_metrics.py index a883445888c4..d89bc6a4b190 100644 --- a/tests/sentry/seer/workflows/test_breached_metrics.py +++ b/tests/sentry/seer/workflows/test_breached_metrics.py @@ -115,7 +115,7 @@ def test_creates_config_without_projects_preserving_environment_filter(self) -> assert config.strategy == SeerWorkflowStrategy.BREACHED_METRIC_INVESTIGATION assert config.enabled is False assert config.filters == {"all_projects": False, "environments": ["production"]} - assert config.backing_workflow_id is None + assert config.backing_workflow is None assert workflow.get_project_ids() == [] def test_project_selection_is_managed_by_connected_monitors(self) -> None: @@ -713,7 +713,7 @@ def test_deleted_backing_workflow_empties_scope_and_is_replaced(self) -> None: Workflow.objects_for_deletion.filter(id=replaced.backing_workflow_id).delete() self.config.refresh_from_db() - assert self.config.backing_workflow_id is None + assert self.config.backing_workflow is None assert self.project_ids() == set() def test_rejects_ineligible_monitors_without_writing_graph(self) -> None: diff --git a/tests/sentry/sentry_apps/api/bases/test_sentryapps.py b/tests/sentry/sentry_apps/api/bases/test_sentryapps.py index a2eb06324096..1d79ffee14bd 100644 --- a/tests/sentry/sentry_apps/api/bases/test_sentryapps.py +++ b/tests/sentry/sentry_apps/api/bases/test_sentryapps.py @@ -286,6 +286,7 @@ def setUp(self) -> None: def test_handle_sentry_app_error(self) -> None: error = SentryAppError(message="cool", status_code=400) response = self.endpoint._handle_sentry_app_exception(error) + assert response is not None assert response.status_code == 400 assert response.exception is True @@ -300,6 +301,7 @@ def test_handle_sentry_app_integrator_error(self) -> None: status_code=400, ) response = self.endpoint._handle_sentry_app_exception(error) + assert response is not None assert response.status_code == 400 assert response.exception is True @@ -311,6 +313,7 @@ def test_handle_sentry_app_sentry_error(self) -> None: message="bruh", webhook_context={"bruh": "bruhhh"}, public_context=public_context ) response = self.endpoint._handle_sentry_app_exception(error) + assert response is not None assert response.status_code == 500 assert response.data == { diff --git a/tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_app_installation_details.py b/tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_app_installation_details.py index c686b677fe0e..da23c60369cd 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_app_installation_details.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_app_installation_details.py @@ -55,6 +55,25 @@ def setUp(self) -> None: prevent_token_exchange=True, ) + published_application = self.published_app.application + unpublished_application = self.unpublished_app.application + published_proxy_user = self.published_app.proxy_user + unpublished_proxy_user = self.unpublished_app.proxy_user + installation_grant = self.installation.api_grant + installation2_grant = self.installation2.api_grant + assert published_application is not None + assert unpublished_application is not None + assert published_proxy_user is not None + assert unpublished_proxy_user is not None + assert installation_grant is not None + assert installation2_grant is not None + self.published_client_id = published_application.client_id + self.unpublished_client_id = unpublished_application.client_id + self.published_proxy_user = published_proxy_user + self.unpublished_proxy_user = unpublished_proxy_user + self.installation_grant = installation_grant + self.installation2_grant = installation2_grant + self.url = reverse( "sentry-api-0-sentry-app-installation-details", args=[self.installation2.uuid] ) @@ -97,7 +116,7 @@ def test_access_within_installs_organization_by_manager(self) -> None: }, "organization": {"slug": self.org.slug, "id": self.org.id}, "uuid": self.installation2.uuid, - "code": self.installation2.api_grant.code, + "code": self.installation2_grant.code, "status": "pending", } @@ -177,9 +196,9 @@ def setUp(self) -> None: super().setUp() self.token = GrantExchanger( install=self.installation, - code=self.installation.api_grant.code, - client_id=self.published_app.application.client_id, - user=self.published_app.proxy_user, + code=self.installation_grant.code, + client_id=self.published_client_id, + user=self.published_proxy_user, ).run() @patch("sentry.analytics.record") @@ -226,9 +245,9 @@ def test_sentry_app_installation_mark_pending_status(self) -> None: def test_sentry_app_installation_mark_installed_wrong_app(self) -> None: self.token = GrantExchanger( install=self.installation2, - code=self.installation2.api_grant.code, - client_id=self.unpublished_app.application.client_id, - user=self.unpublished_app.proxy_user, + code=self.installation2_grant.code, + client_id=self.unpublished_client_id, + user=self.unpublished_proxy_user, ).run() self.url = reverse( "sentry-api-0-sentry-app-installation-details", args=[self.installation.uuid] diff --git a/tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_app_installations.py b/tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_app_installations.py index e6348a12b354..413c5c74c489 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_app_installations.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_app_installations.py @@ -41,6 +41,12 @@ def setUp(self) -> None: user=self.user, prevent_token_exchange=True, ) + installation_grant = self.installation.api_grant + installation2_grant = self.installation2.api_grant + assert installation_grant is not None + assert installation2_grant is not None + self.installation_grant = installation_grant + self.installation2_grant = installation2_grant @control_silo_test @@ -60,7 +66,7 @@ def test_superuser_sees_all_installs(self) -> None: }, "organization": {"slug": self.org.slug, "id": self.org.id}, "uuid": self.installation2.uuid, - "code": self.installation2.api_grant.code, + "code": self.installation2_grant.code, "status": "installed", } ] @@ -76,7 +82,7 @@ def test_superuser_sees_all_installs(self) -> None: }, "organization": {"slug": self.super_org.slug, "id": self.super_org.id}, "uuid": self.installation.uuid, - "code": self.installation.api_grant.code, + "code": self.installation_grant.code, "status": "installed", } ] @@ -112,7 +118,7 @@ def test_users_only_sees_installs_on_their_org(self) -> None: }, "organization": {"slug": self.org.slug, "id": self.org.id}, "uuid": self.installation2.uuid, - "code": self.installation2.api_grant.code, + "code": self.installation2_grant.code, "status": "installed", } ] diff --git a/tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_apps.py b/tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_apps.py index 3657dfe93290..a38918d09303 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_apps.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_apps.py @@ -27,6 +27,11 @@ def setUp(self) -> None: ) self.unpublished_app = self.create_sentry_app(name="Testin", organization=self.org) self.url = reverse("sentry-api-0-organization-sentry-apps", args=[self.org.slug]) + application = self.unpublished_app.application + assert application is not None + assert application.client_secret is not None + self.unpublished_application = application + self.unpublished_client_secret = application.client_secret @control_silo_test @@ -54,8 +59,8 @@ def test_gets_all_apps_in_own_org(self) -> None: "isAlertable": self.unpublished_app.is_alertable, "isDisabled": self.unpublished_app.is_disabled, "verifyInstall": self.unpublished_app.verify_install, - "clientId": self.unpublished_app.application.client_id, - "clientSecret": self.unpublished_app.application.client_secret, + "clientId": self.unpublished_application.client_id, + "clientSecret": self.unpublished_client_secret, "overview": self.unpublished_app.overview, "allowedOrigins": [], "webhookHeaders": [], diff --git a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_authorizations.py b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_authorizations.py index 946d23084121..726bfa61a10e 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_authorizations.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_authorizations.py @@ -44,15 +44,24 @@ def setUp(self) -> None: prevent_token_exchange=True, ) + application = self.sentry_app.application + assert application is not None + assert application.client_secret is not None + grant = self.install.api_grant + assert grant is not None + self.application = application + self.client_secret = application.client_secret + self.api_grant = grant + def get_response(self, *args: int | str, **params: int | str) -> Response: """Overriding `get_response` with some default data.""" return super().get_response( self.install.uuid, **{ - "client_id": self.sentry_app.application.client_id, - "client_secret": self.sentry_app.application.client_secret, + "client_id": self.application.client_id, + "client_secret": self.client_secret, "grant_type": GrantTypes.AUTHORIZATION, - "code": self.install.api_grant.code, + "code": self.api_grant.code, **params, }, ) @@ -106,7 +115,7 @@ def test_invalid_grant(self) -> None: self.get_error_response(code="123", status_code=401) def test_expired_grant(self) -> None: - self.install.api_grant.update(expires_at=timezone.now() - timedelta(minutes=2)) + self.api_grant.update(expires_at=timezone.now() - timedelta(minutes=2)) response = self.get_error_response(status_code=401) assert response.data["detail"] == "Grant has already expired" @@ -189,9 +198,7 @@ def test_client_secret_jwt_exchange_success(self) -> None: self.get_success_response() # Now use client_secret_jwt to refresh - jwt_token = self._create_jwt( - self.sentry_app.application.client_id, self.sentry_app.application.client_secret - ) + jwt_token = self._create_jwt(self.application.client_id, self.client_secret) response = self.get_success_response( self.install.uuid, @@ -211,9 +218,7 @@ def test_client_secret_jwt_deletes_old_token(self) -> None: old_token_id = initial_response.data["id"] # Now use client_secret_jwt to refresh - jwt_token = self._create_jwt( - self.sentry_app.application.client_id, self.sentry_app.application.client_secret - ) + jwt_token = self._create_jwt(self.application.client_id, self.client_secret) response = self.get_success_response( self.install.uuid, @@ -246,8 +251,8 @@ def test_client_secret_jwt_expired_token(self) -> None: # Create an expired JWT expired_time = datetime.now(UTC) - timedelta(hours=1) jwt_token = self._create_jwt( - self.sentry_app.application.client_id, - self.sentry_app.application.client_secret, + self.application.client_id, + self.client_secret, exp=expired_time, ) @@ -265,7 +270,7 @@ def test_client_secret_jwt_invalid_signature(self) -> None: self.get_success_response() # Create a JWT with wrong secret - jwt_token = self._create_jwt(self.sentry_app.application.client_id, "wrong-secret") + jwt_token = self._create_jwt(self.application.client_id, "wrong-secret") response = self.get_error_response( self.install.uuid, @@ -280,7 +285,7 @@ def test_client_secret_jwt_wrong_client_id(self) -> None: # First exchange the grant for a token self.get_success_response() - jwt_token = self._create_jwt("wrong-client-id", self.sentry_app.application.client_secret) + jwt_token = self._create_jwt("wrong-client-id", self.client_secret) response = self.get_error_response( self.install.uuid, @@ -294,9 +299,7 @@ def test_client_secret_jwt_wrong_client_id(self) -> None: def test_client_secret_jwt_requires_existing_token(self) -> None: # CLIENT_SECRET_JWT should only be used to refresh existing tokens # Attempting to use it without first exchanging the grant should fail - jwt_token = self._create_jwt( - self.sentry_app.application.client_id, self.sentry_app.application.client_secret - ) + jwt_token = self._create_jwt(self.application.client_id, self.client_secret) response = self.get_error_response( self.install.uuid, @@ -313,9 +316,7 @@ def test_client_secret_jwt_request_with_new_token(self) -> None: self.get_success_response() # Use client_secret_jwt to get a new token - jwt_token = self._create_jwt( - self.sentry_app.application.client_id, self.sentry_app.application.client_secret - ) + jwt_token = self._create_jwt(self.application.client_id, self.client_secret) response = self.get_success_response( self.install.uuid, diff --git a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_avatar.py b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_avatar.py index b61d59251428..bfaa1b805aaf 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_avatar.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_avatar.py @@ -1,4 +1,5 @@ from base64 import b64encode +from typing import cast from django.conf import settings from django.test import override_settings @@ -27,7 +28,8 @@ def get_avatar( avatars = resp.data["avatars"] for avatar in avatars: if avatar.get("color") == is_color: - return avatar + # justification: the avatar serializer emits one payload per avatar. + return cast("SentryAppAvatarSerializerResponse", avatar) raise AssertionError("Invariant violation: expect avatar to be returned") def create_avatar(self, is_color: bool) -> Response: diff --git a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_components.py b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_components.py index c4380d03ce4d..cb93cef4063c 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_components.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_components.py @@ -33,7 +33,9 @@ def setUp(self) -> None: schema={"elements": [self.create_issue_link_schema()]}, ) - self.component = self.sentry_app.components.first() + component = self.sentry_app.components.first() + assert component is not None + self.component = component self.login_as(user=self.user) @@ -89,9 +91,15 @@ def setUp(self) -> None: status=SentryAppInstallationStatus.PENDING, ) - self.component1 = self.sentry_app1.components.order_by("pk").first() - self.component2 = self.sentry_app2.components.order_by("pk").first() - self.component3 = self.sentry_app3.components.order_by("pk").first() + component1 = self.sentry_app1.components.order_by("pk").first() + component2 = self.sentry_app2.components.order_by("pk").first() + component3 = self.sentry_app3.components.order_by("pk").first() + assert component1 is not None + assert component2 is not None + assert component3 is not None + self.component1 = component1 + self.component2 = component2 + self.component3 = component3 self.login_as(user=self.user) @@ -139,6 +147,7 @@ def test_filter_by_type(self, run: MagicMock) -> None: install = self.create_sentry_app_installation(slug=sentry_app.slug, organization=self.org) component = sentry_app.components.first() + assert component is not None response = self.get_success_response( self.org.slug, qs_params={"projectId": self.project.id, "filter": "alert-rule"} diff --git a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_details.py b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_details.py index 2970a64f5e45..821b44552c28 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_details.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_details.py @@ -57,6 +57,11 @@ def setUp(self) -> None: popularity=self.popularity, ) self.internal_integration = self.create_internal_integration(organization=self.organization) + application = self.published_app.application + assert application is not None + assert application.client_secret is not None + self.published_application = application + self.published_client_secret = application.client_secret @control_silo_test @@ -143,8 +148,8 @@ def _validate_updated_published_app(self, response: Response) -> None: "isAlertable": True, "isDisabled": False, "verifyInstall": self.published_app.verify_install, - "clientId": self.published_app.application.client_id, - "clientSecret": self.published_app.application.client_secret, + "clientId": self.published_application.client_id, + "clientSecret": self.published_client_secret, "overview": self.published_app.overview, "allowedOrigins": [], "webhookHeaders": [], @@ -755,7 +760,10 @@ def test_set_allowed_origins(self) -> None: allowedOrigins=["google.com", "sentry.io"], status_code=200, ) - assert self.published_app.application.get_allowed_origins() == ["google.com", "sentry.io"] + published_app = SentryApp.objects.get(id=self.published_app.id) + application = published_app.application + assert application is not None + assert application.get_allowed_origins() == ["google.com", "sentry.io"] @override_options({"staff.ga-rollout": True}) def test_allowed_origins_with_star(self) -> None: diff --git a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_installation_external_requests.py b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_installation_external_requests.py index 3408e86a6658..9c9eee8440ec 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_installation_external_requests.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_installation_external_requests.py @@ -71,7 +71,7 @@ def test_makes_external_request_with_dependent_data(self) -> None: ) qs = urlencode( { - "projectId": self.project.id, + "projectId": str(self.project.id), "uri": "/get-projects", "query": "proj", "dependentData": orjson.dumps({"org_id": "A"}).decode(), diff --git a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_webhook_requests.py b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_webhook_requests.py index 3636d2cdce26..e9b590530793 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_webhook_requests.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_webhook_requests.py @@ -4,6 +4,7 @@ from django.urls import reverse from requests.models import Response +from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.datetime import before_now, freeze_time from sentry.testutils.silo import control_silo_test, create_test_cells @@ -13,6 +14,11 @@ pytestmark = [requires_snuba] +def webhook_url(sentry_app: SentryApp) -> str: + assert sentry_app.webhook_url is not None + return sentry_app.webhook_url + + @control_silo_test(cells=create_test_cells("us")) class SentryAppWebhookRequestsGetTest(APITestCase): def setUp(self) -> None: @@ -60,13 +66,13 @@ def test_superuser_sees_unowned_published_requests(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.unowned_published_app.webhook_url, + url=webhook_url(self.unowned_published_app), ) buffer.add_request( response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.unowned_published_app.webhook_url, + url=webhook_url(self.unowned_published_app), ) url = reverse( @@ -87,7 +93,7 @@ def test_superuser_sees_unpublished_stats(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.unowned_unpublished_app.webhook_url, + url=webhook_url(self.unowned_unpublished_app), ) url = reverse( @@ -106,7 +112,7 @@ def test_user_sees_owned_published_requests(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) @@ -127,7 +133,7 @@ def test_member_does_not_see_owned_published_requests(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) @@ -157,7 +163,7 @@ def test_manager_sees_owned_published_requests(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) @@ -177,7 +183,7 @@ def test_user_does_not_see_unowned_published_requests(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.unowned_published_app.webhook_url, + url=webhook_url(self.unowned_published_app), ) url = reverse( @@ -195,7 +201,7 @@ def test_user_sees_owned_unpublished_requests(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.unpublished_app.webhook_url, + url=webhook_url(self.unpublished_app), ) url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.unpublished_app.slug]) @@ -210,7 +216,7 @@ def test_internal_app_requests_does_not_have_organization_field(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.internal_app.webhook_url, + url=webhook_url(self.internal_app), ) url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.internal_app.slug]) @@ -231,7 +237,7 @@ def test_manager_sees_owned_internal_request_details(self) -> None: response_code=500, org_id=self.org.id, event="issue.assigned", - url=self.internal_app.webhook_url, + url=webhook_url(self.internal_app), response=self.mock_response, headers={ "Content-Type": "application/json", @@ -263,7 +269,7 @@ def test_member_token_does_not_see_owned_internal_requests(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.internal_app.webhook_url, + url=webhook_url(self.internal_app), ) url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.internal_app.slug]) @@ -366,13 +372,13 @@ def test_event_type_filter(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) buffer.add_request( response_code=400, org_id=self.org.id, event="installation.created", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) @@ -408,14 +414,14 @@ def test_errors_only_filter(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) with freeze_time(now): buffer.add_request( response_code=500, org_id=self.org.id, event="issue.assigned", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), error_id="abc123", project_id=1, response=self.mock_response, @@ -459,7 +465,7 @@ def test_request_id_subject_and_duration_on_success_and_error_rows(self) -> None response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), request_id="req-success", subject_id="123", subject_type="group", @@ -470,7 +476,7 @@ def test_request_id_subject_and_duration_on_success_and_error_rows(self) -> None response_code=500, org_id=self.org.id, event="issue.assigned", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), request_id="req-error", subject_id="456", subject_type="group", @@ -508,7 +514,7 @@ def test_linked_error_not_returned_if_project_does_not_exist(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.unpublished_app.webhook_url, + url=webhook_url(self.unpublished_app), error_id=self.event_id, project_id=1000, ) @@ -529,13 +535,13 @@ def test_org_slug_filter(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) buffer.add_request( response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) @@ -560,21 +566,21 @@ def test_date_filter(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) with freeze_time(now + timedelta(seconds=1)): buffer.add_request( response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) with freeze_time(now + timedelta(seconds=2)): buffer.add_request( response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) @@ -624,28 +630,28 @@ def test_get_includes_installation_requests(self) -> None: response_code=200, org_id=self.org.id, event="issue.created", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) with freeze_time(now + timedelta(seconds=1)): buffer.add_request( response_code=200, org_id=self.org.id, event="installation.created", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) with freeze_time(now + timedelta(seconds=2)): buffer.add_request( response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) with freeze_time(now + timedelta(seconds=3)): buffer.add_request( response_code=200, org_id=self.org.id, event="installation.deleted", - url=self.published_app.webhook_url, + url=webhook_url(self.published_app), ) url = reverse("sentry-api-0-sentry-app-webhook-requests", args=[self.published_app.slug]) diff --git a/tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py b/tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py index 794ffb779cb1..5c5703b05d3b 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py @@ -187,13 +187,14 @@ def make_token_request( method: str = "get", data: Mapping[str, Any] | None = None, ) -> Response: - return getattr(self.client, method)( + response: Response[Any] = getattr(self.client, method)( reverse(endpoint), format="json", **(data or {}), HTTP_AUTHORIZATION=f"Bearer {token.token}", headers={"Content-Type": "application/json"}, ) + return response @control_silo_test @@ -856,6 +857,7 @@ def test_create_integration_strips_webhook_header_whitespace(self) -> None: sentry_app = SentryApp.objects.get(slug=response.data["slug"]) assert sentry_app.webhook_headers == ["X-Trailing: value", "Authorization: Bearer token"] for header in sentry_app.webhook_headers: + assert header is not None assert "\n" not in header assert "\r" not in header diff --git a/tests/sentry/sentry_apps/api/endpoints/test_sentry_internal_app_token_details.py b/tests/sentry/sentry_apps/api/endpoints/test_sentry_internal_app_token_details.py index 4d99451ced62..2cdea9db72f8 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_sentry_internal_app_token_details.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_sentry_internal_app_token_details.py @@ -77,9 +77,11 @@ def test_non_internal_app(self) -> None: self.login_as(user=self.user) + api_token = install.api_token + assert api_token is not None response = self.get_error_response( install.sentry_app.slug, - install.api_token.id, + api_token.id, status_code=status.HTTP_403_FORBIDDEN, ) assert response.data == "This route is limited to internal integrations only" diff --git a/tests/sentry/sentry_apps/api/serializers/test_sentry_app.py b/tests/sentry/sentry_apps/api/serializers/test_sentry_app.py index 99ea6e2322d2..6202ee808e33 100644 --- a/tests/sentry/sentry_apps/api/serializers/test_sentry_app.py +++ b/tests/sentry/sentry_apps/api/serializers/test_sentry_app.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +from typing import cast from sentry.api.serializers import serialize from sentry.auth import access @@ -24,7 +25,8 @@ def test_published_app(self) -> None: result = serialize(sentry_app, None, SentryAppSerializer(), access=None) assert result["name"] == "Tesla App" - assert result["featureData"] == [ + # justification: the serializer emits serialized feature mappings for `featureData`. + assert cast("list[dict[str, object]]", result["featureData"]) == [ { "featureId": 0, "description": "Tesla App can **utilize the Sentry API** to pull data or update resources in Sentry (with permissions granted, of course).", @@ -90,7 +92,6 @@ def test_without_optional_fields(self) -> None: sentry_app.redirect_url = None sentry_app.webhook_url = None sentry_app.date_published = None - sentry_app.owner = None sentry_app.save() result = serialize(sentry_app, None, SentryAppSerializer(), access=None) diff --git a/tests/sentry/sentry_apps/services/test_app_request.py b/tests/sentry/sentry_apps/services/test_app_request.py index b4d9bf7432f8..d39a6a53c564 100644 --- a/tests/sentry/sentry_apps/services/test_app_request.py +++ b/tests/sentry/sentry_apps/services/test_app_request.py @@ -34,6 +34,8 @@ def setUp(self) -> None: ] }, ) + assert self.app.webhook_url is not None + self.webhook_url = self.app.webhook_url def test_get_buffer_requests_for_cell(self) -> None: buffer = SentryAppWebhookRequestsBuffer(self.app) @@ -41,7 +43,7 @@ def test_get_buffer_requests_for_cell(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.app.webhook_url, + url=self.webhook_url, ) requests = app_request_service.get_buffer_requests_for_cell( sentry_app_id=self.app.id, cell_name="us" @@ -88,13 +90,13 @@ def test_get_filtered_buffer_requests_for_cell(self) -> None: response_code=200, org_id=self.org.id, event="issue.assigned", - url=self.app.webhook_url, + url=self.webhook_url, ) buffer.add_request( response_code=200, org_id=self.org.id, event="issue.resolved", - url=self.app.webhook_url, + url=self.webhook_url, ) filter: SentryAppRequestFilterArgs = {"event": "issue.assigned"} requests = app_request_service.get_buffer_requests_for_cell( diff --git a/tests/sentry/sentry_apps/services/test_hook_service.py b/tests/sentry/sentry_apps/services/test_hook_service.py index 1668afa55551..b0ef9a42b4f0 100644 --- a/tests/sentry/sentry_apps/services/test_hook_service.py +++ b/tests/sentry/sentry_apps/services/test_hook_service.py @@ -17,18 +17,24 @@ def setUp(self) -> None: self.sentry_app = self.create_sentry_app( organization_id=self.org.id, events=["issue.created"] ) + assert self.sentry_app.application is not None + assert self.sentry_app.proxy_user is not None + assert self.sentry_app.webhook_url is not None + self.application = self.sentry_app.application + self.proxy_user = self.sentry_app.proxy_user + self.webhook_url = self.sentry_app.webhook_url def call_create_hook( self, project_ids: list[int] | None = None, events: list[str] | None = None ) -> RpcServiceHook: events = events or ["event.created"] return hook_service.create_service_hook( - application_id=self.sentry_app.application.id, - actor_id=self.sentry_app.proxy_user.id, + application_id=self.application.id, + actor_id=self.proxy_user.id, organization_id=self.org.id, project_ids=project_ids, events=events, - url=self.sentry_app.webhook_url, + url=self.webhook_url, ) def test_creates_service_hook(self) -> None: @@ -37,8 +43,8 @@ def test_creates_service_hook(self) -> None: with assume_test_silo_mode(SiloMode.CELL): service_hook = ServiceHook.objects.get( application_id=self.sentry_app.application_id, - actor_id=self.sentry_app.proxy_user.id, - url=self.sentry_app.webhook_url, + actor_id=self.proxy_user.id, + url=self.webhook_url, ) assert service_hook @@ -77,20 +83,20 @@ def test_update_webhook_and_events_with_webhook_url(self) -> None: ) with assume_test_silo_mode(SiloMode.CELL): hook1 = ServiceHook.objects.get( - installation_id=installation1.id, application_id=self.sentry_app.application.id + installation_id=installation1.id, application_id=self.application.id ) hook2 = ServiceHook.objects.get( - installation_id=installation2.id, application_id=self.sentry_app.application.id + installation_id=installation2.id, application_id=self.application.id ) - ServiceHook.objects.filter(application_id=self.sentry_app.application.id).update( + ServiceHook.objects.filter(application_id=self.application.id).update( events=["comment.created"] ) # Call the update method result = hook_service.update_webhook_and_events( organization_id=self.org.id, - application_id=self.sentry_app.application.id, - webhook_url=self.sentry_app.webhook_url, + application_id=self.application.id, + webhook_url=self.webhook_url, events=self.sentry_app.events, ) @@ -120,10 +126,10 @@ def test_update_webhook_and_events_with_many_installations(self) -> None: # but it's enough to test the performance of the update hooks_to_create.append( ServiceHook( - application_id=self.sentry_app.application.id, + application_id=self.application.id, actor_id=self.user.id, installation_id=self.user.id, - url=self.sentry_app.webhook_url, + url=self.webhook_url, events=["comment.created", "error.created"], ) ) @@ -131,7 +137,7 @@ def test_update_webhook_and_events_with_many_installations(self) -> None: assert ( ServiceHook.objects.filter( - application_id=self.sentry_app.application.id, + application_id=self.application.id, events=["comment.created", "error.created"], ).count() == 10000 @@ -139,8 +145,8 @@ def test_update_webhook_and_events_with_many_installations(self) -> None: result = hook_service.update_webhook_and_events( organization_id=self.org.id, - application_id=self.sentry_app.application.id, - webhook_url=self.sentry_app.webhook_url, + application_id=self.application.id, + webhook_url=self.webhook_url, events=self.sentry_app.events, ) @@ -148,14 +154,14 @@ def test_update_webhook_and_events_with_many_installations(self) -> None: assert len(result) == 10000 assert ( ServiceHook.objects.filter( - application_id=self.sentry_app.application.id, + application_id=self.application.id, events=self.sentry_app.events, ).count() == 10000 ) assert ( ServiceHook.objects.filter( - application_id=self.sentry_app.application.id, + application_id=self.application.id, events=["comment.created", "error.created"], ).count() == 0 @@ -164,13 +170,13 @@ def test_update_webhook_and_events_with_many_installations(self) -> None: def test_update_webhook_and_events_without_webhook_url(self) -> None: # Create service hooks hook1 = self.create_service_hook( - application=self.sentry_app.application, + application=self.application, organization=self.org, url="https://example.com", events=["issue.created"], ) hook2 = self.create_service_hook( - application=self.sentry_app.application, + application=self.application, organization=self.org, url="https://example2.com", events=["comment.created"], @@ -179,7 +185,7 @@ def test_update_webhook_and_events_without_webhook_url(self) -> None: # Call update with webhook_url=None (should delete hooks) result = hook_service.update_webhook_and_events( organization_id=self.org.id, - application_id=self.sentry_app.application.id, + application_id=self.application.id, webhook_url=None, events=["issue"], ) @@ -205,7 +211,7 @@ def test_update_webhook_and_events_no_matching_hooks(self) -> None: # Try to update hooks for our app (should find no hooks) result = hook_service.update_webhook_and_events( organization_id=self.org.id, - application_id=self.sentry_app.application.id, + application_id=self.application.id, webhook_url="https://new-url.com", events=["issue"], ) @@ -221,7 +227,7 @@ def test_create_or_update_webhook_and_events_for_installation_create(self) -> No with assume_test_silo_mode(SiloMode.CELL): hook = ServiceHook.objects.get( - installation_id=installation.id, application_id=self.sentry_app.application.id + installation_id=installation.id, application_id=self.application.id ) hook.delete() @@ -229,9 +235,9 @@ def test_create_or_update_webhook_and_events_for_installation_create(self) -> No result = hook_service.create_or_update_webhook_and_events_for_installation( installation_id=installation.id, organization_id=self.org.id, - webhook_url=self.sentry_app.webhook_url, + webhook_url=self.webhook_url, events=self.sentry_app.events, - application_id=self.sentry_app.application.id, + application_id=self.application.id, ) # Should return one hook @@ -240,7 +246,7 @@ def test_create_or_update_webhook_and_events_for_installation_create(self) -> No # Verify hook was created in database with assume_test_silo_mode(SiloMode.CELL): hook = ServiceHook.objects.get( - installation_id=installation.id, application_id=self.sentry_app.application.id + installation_id=installation.id, application_id=self.application.id ) assert hook.url == self.sentry_app.webhook_url assert hook.events == self.sentry_app.events @@ -252,7 +258,7 @@ def test_create_or_update_webhook_and_events_for_installation_update(self) -> No ) with assume_test_silo_mode(SiloMode.CELL): hook = ServiceHook.objects.get( - installation_id=installation.id, application_id=self.sentry_app.application.id + installation_id=installation.id, application_id=self.application.id ) hook.events = ["error.created"] hook.save() @@ -261,9 +267,9 @@ def test_create_or_update_webhook_and_events_for_installation_update(self) -> No result = hook_service.create_or_update_webhook_and_events_for_installation( installation_id=installation.id, organization_id=self.org.id, - webhook_url=self.sentry_app.webhook_url, + webhook_url=self.webhook_url, events=self.sentry_app.events, - application_id=self.sentry_app.application.id, + application_id=self.application.id, ) # Should return one hook @@ -274,7 +280,7 @@ def test_create_or_update_webhook_and_events_for_installation_update(self) -> No with assume_test_silo_mode(SiloMode.CELL): hooks = ServiceHook.objects.filter( installation_id=installation.id, - application_id=self.sentry_app.application.id, + application_id=self.application.id, ) assert hooks.count() == 1 updated_hook = hooks.first() @@ -288,7 +294,7 @@ def test_create_or_update_webhook_and_events_for_installation_delete(self) -> No ) with assume_test_silo_mode(SiloMode.CELL): existing_hook = ServiceHook.objects.get( - installation_id=installation.id, application_id=self.sentry_app.application.id + installation_id=installation.id, application_id=self.application.id ) # Call create_or_update with webhook_url=None (should delete) @@ -297,7 +303,7 @@ def test_create_or_update_webhook_and_events_for_installation_delete(self) -> No organization_id=self.org.id, webhook_url=None, events=["issue"], - application_id=self.sentry_app.application.id, + application_id=self.application.id, ) # Should return empty list @@ -314,7 +320,7 @@ def test_create_or_update_webhook_and_events_for_installation_delete_nonexistent ) with assume_test_silo_mode(SiloMode.CELL): existing_hook = ServiceHook.objects.get( - installation_id=installation.id, application_id=self.sentry_app.application.id + installation_id=installation.id, application_id=self.application.id ) existing_hook.delete() @@ -324,7 +330,7 @@ def test_create_or_update_webhook_and_events_for_installation_delete_nonexistent organization_id=self.org.id, webhook_url=None, events=["issue"], - application_id=self.sentry_app.application.id, + application_id=self.application.id, ) # Should return empty list and not raise exception @@ -344,12 +350,12 @@ def test_create_or_update_webhook_and_events_for_installation_with_duplicate_hoo assert ( ServiceHook.objects.filter( installation_id=installation.id, - application_id=self.sentry_app.application.id, + application_id=self.application.id, ).count() == 1 ) ServiceHook.objects.create( - application_id=self.sentry_app.application.id, + application_id=self.application.id, actor_id=installation.id, installation_id=installation.id, organization_id=self.org.id, @@ -359,7 +365,7 @@ def test_create_or_update_webhook_and_events_for_installation_with_duplicate_hoo assert ( ServiceHook.objects.filter( installation_id=installation.id, - application_id=self.sentry_app.application.id, + application_id=self.application.id, ).count() == 2 ) @@ -368,9 +374,9 @@ def test_create_or_update_webhook_and_events_for_installation_with_duplicate_hoo result = hook_service.create_or_update_webhook_and_events_for_installation( installation_id=installation.id, organization_id=self.org.id, - webhook_url=self.sentry_app.webhook_url, + webhook_url=self.webhook_url, events=self.sentry_app.events, - application_id=self.sentry_app.application.id, + application_id=self.application.id, ) assert len(result) == 1 @@ -379,7 +385,7 @@ def test_create_or_update_webhook_and_events_for_installation_with_duplicate_hoo with assume_test_silo_mode(SiloMode.CELL): hooks = ServiceHook.objects.filter( installation_id=installation.id, - application_id=self.sentry_app.application.id, + application_id=self.application.id, ) assert hooks.count() == 1 hook = hooks.first() @@ -399,7 +405,7 @@ def test_create_or_update_webhook_and_events_for_installation_delete_duplicates( # Manually create a duplicate hook to simulate the concurrency bug with assume_test_silo_mode(SiloMode.CELL): ServiceHook.objects.create( - application_id=self.sentry_app.application.id, + application_id=self.application.id, actor_id=installation.id, installation_id=installation.id, organization_id=self.org.id, @@ -409,7 +415,7 @@ def test_create_or_update_webhook_and_events_for_installation_delete_duplicates( assert ( ServiceHook.objects.filter( installation_id=installation.id, - application_id=self.sentry_app.application.id, + application_id=self.application.id, ).count() == 2 ) @@ -420,7 +426,7 @@ def test_create_or_update_webhook_and_events_for_installation_delete_duplicates( organization_id=self.org.id, webhook_url=None, events=["issue"], - application_id=self.sentry_app.application.id, + application_id=self.application.id, ) assert result == [] @@ -429,7 +435,7 @@ def test_create_or_update_webhook_and_events_for_installation_delete_duplicates( with assume_test_silo_mode(SiloMode.CELL): assert not ServiceHook.objects.filter( installation_id=installation.id, - application_id=self.sentry_app.application.id, + application_id=self.application.id, ).exists() @@ -442,6 +448,12 @@ def setUp(self) -> None: self.sentry_app = self.create_sentry_app( organization_id=self.org.id, events=["issue.created"] ) + assert self.sentry_app.application is not None + assert self.sentry_app.proxy_user is not None + assert self.sentry_app.webhook_url is not None + self.application = self.sentry_app.application + self.proxy_user = self.sentry_app.proxy_user + self.webhook_url = self.sentry_app.webhook_url def test_bulk_create_service_hooks_for_app_success(self) -> None: # Create some installations and organizations @@ -455,7 +467,7 @@ def test_bulk_create_service_hooks_for_app_success(self) -> None: # Delete existing hooks to test bulk creation with assume_test_silo_mode(SiloMode.CELL): - ServiceHook.objects.filter(application_id=self.sentry_app.application.id).delete() + ServiceHook.objects.filter(application_id=self.application.id).delete() # Prepare installation-organization pairs installation_org_pairs = [ @@ -469,7 +481,7 @@ def test_bulk_create_service_hooks_for_app_success(self) -> None: result = hook_service.bulk_create_service_hooks_for_app( cell_name="us", - application_id=self.sentry_app.application.id, + application_id=self.application.id, events=["issue.created", "error.created"], installation_organization_ids=installation_org_pairs, url="https://example.com/webhook", @@ -477,9 +489,7 @@ def test_bulk_create_service_hooks_for_app_success(self) -> None: # Verify hooks were created in database with assume_test_silo_mode(SiloMode.CELL): - hooks = ServiceHook.objects.filter( - application_id=self.sentry_app.application.id - ).order_by("id") + hooks = ServiceHook.objects.filter(application_id=self.application.id).order_by("id") assert hooks.count() == 2 assert len(result) == 2 @@ -503,12 +513,12 @@ def test_bulk_create_service_hooks_for_app_with_event_expansion(self) -> None: # Delete existing hook with assume_test_silo_mode(SiloMode.CELL): - ServiceHook.objects.filter(application_id=self.sentry_app.application.id).delete() + ServiceHook.objects.filter(application_id=self.application.id).delete() # Call bulk create with expandable events result = hook_service.bulk_create_service_hooks_for_app( cell_name="us", - application_id=self.sentry_app.application.id, + application_id=self.application.id, events=["issue", "comment"], # These should expand installation_organization_ids=[ RpcInstallationOrganizationPair( @@ -530,7 +540,7 @@ def test_bulk_create_service_hooks_for_app_empty_list(self) -> None: # Call with empty installation list result = hook_service.bulk_create_service_hooks_for_app( cell_name="us", - application_id=self.sentry_app.application.id, + application_id=self.application.id, events=["issue.created"], installation_organization_ids=[], url="https://example.com/webhook", @@ -541,9 +551,7 @@ def test_bulk_create_service_hooks_for_app_empty_list(self) -> None: # Verify no hooks were created with assume_test_silo_mode(SiloMode.CELL): - hooks_count = ServiceHook.objects.filter( - application_id=self.sentry_app.application.id - ).count() + hooks_count = ServiceHook.objects.filter(application_id=self.application.id).count() # Should still have the hooks from setUp (if any) assert hooks_count >= 0 @@ -555,16 +563,14 @@ def test_bulk_create_service_hooks_for_app_ignore_conflicts(self) -> None: # Verify hook already exists from installation creation with assume_test_silo_mode(SiloMode.CELL): existing_hook = ServiceHook.objects.get( - installation_id=installation.id, application_id=self.sentry_app.application.id + installation_id=installation.id, application_id=self.application.id ) - initial_count = ServiceHook.objects.filter( - application_id=self.sentry_app.application.id - ).count() + initial_count = ServiceHook.objects.filter(application_id=self.application.id).count() # Try to bulk create hook for same installation should not create duplicate result = hook_service.bulk_create_service_hooks_for_app( cell_name="us", - application_id=self.sentry_app.application.id, + application_id=self.application.id, events=["error.created"], installation_organization_ids=[ RpcInstallationOrganizationPair( @@ -578,9 +584,7 @@ def test_bulk_create_service_hooks_for_app_ignore_conflicts(self) -> None: # Verify no duplicate hooks were created with assume_test_silo_mode(SiloMode.CELL): - final_count = ServiceHook.objects.filter( - application_id=self.sentry_app.application.id - ).count() + final_count = ServiceHook.objects.filter(application_id=self.application.id).count() assert final_count == initial_count # Original hook should be unchanged @@ -614,7 +618,7 @@ def test_bulk_create_service_hooks_for_app_large_batch(self) -> None: # Call bulk create result = hook_service.bulk_create_service_hooks_for_app( cell_name="us", - application_id=self.sentry_app.application.id, + application_id=self.application.id, events=["issue.created"], installation_organization_ids=installation_org_pairs, url="https://bulk-test.com/webhook", @@ -629,7 +633,7 @@ def test_bulk_create_service_hooks_for_app_large_batch(self) -> None: installation_id = pair.installation_id org_id = pair.organization_id hook = ServiceHook.objects.get( - installation_id=installation_id, application_id=self.sentry_app.application.id + installation_id=installation_id, application_id=self.application.id ) assert hook.organization_id == org_id assert hook.url == "https://bulk-test.com/webhook" diff --git a/tests/sentry/sentry_apps/tasks/test_sentry_apps.py b/tests/sentry/sentry_apps/tasks/test_sentry_apps.py index 6f60df00353f..32aec8c7c801 100644 --- a/tests/sentry/sentry_apps/tasks/test_sentry_apps.py +++ b/tests/sentry/sentry_apps/tasks/test_sentry_apps.py @@ -26,6 +26,7 @@ from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation from sentry.sentry_apps.models.servicehook import ServiceHook, ServiceHookProject +from sentry.sentry_apps.services.app.serial import serialize_sentry_app_installation from sentry.sentry_apps.tasks.sentry_apps import ( build_comment_webhook, installation_webhook, @@ -79,16 +80,16 @@ def raiseStatusTrue() -> bool: return True -def raiseException(): +def raiseException() -> None: raise Exception -def raiseHTTPError(): +def raiseHTTPError() -> None: raise HTTPError() class RequestMock: - def __init__(self): + def __init__(self) -> None: self.body = "blah blah" @@ -360,8 +361,8 @@ def test_send_alert_event_with_additional_payload( @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def test_send_alert_event_with_additional_payload_legacy_rule_id( - self, mock_record, safe_urlopen - ): + self, mock_record: MagicMock, safe_urlopen: MagicMock + ) -> None: rule = self.create_project_rule( action_data=[ { @@ -813,8 +814,8 @@ def test_ignores_http_error( @patch("sentry_sdk.capture_exception") def test_silently_handles_chunked_encoding_error_unpublished( - self, capture_exception, safe_urlopen - ): + self, capture_exception: MagicMock, safe_urlopen: MagicMock + ) -> None: """ ChunkedEncodingError is swallowed by @ignore_unpublished_app_errors for unpublished apps. In production, it's also in silenced_exceptions on the task. @@ -850,7 +851,9 @@ def test_silently_handles_chunked_encoding_error_unpublished( assert safe_urlopen.call_count == 1 @patch("sentry_sdk.capture_exception") - def test_chunked_encoding_error_propagates_for_published(self, capture_exception, safe_urlopen): + def test_chunked_encoding_error_propagates_for_published( + self, capture_exception: MagicMock, safe_urlopen: MagicMock + ) -> None: """ For published apps, ChunkedEncodingError is NOT swallowed by @ignore_unpublished_app_errors — it propagates up to the @@ -918,7 +921,7 @@ def test_does_not_process_disallowed_event( # ValueError is in the task's ignore list, this won't be # raised in production. with pytest.raises(ValueError): - process_resource_change_bound("delete", "Group", self.create_group().id) + process_resource_change_bound("delete", "Group", str(self.create_group().id)) assert len(safe_urlopen.mock_calls) == 0 # We got an invalid event prior to lifecycle starting so we would exit early @@ -937,7 +940,7 @@ def test_does_not_process_sentry_apps_without_issue_webhooks( # DOES NOT subscribe to Issue events self.create_sentry_app_installation(organization=self.organization) - process_resource_change_bound("created", "Group", self.create_group().id) + process_resource_change_bound("created", "Group", str(self.create_group().id)) assert len(safe_urlopen.mock_calls) == 0 assert_success_metric(mock_record) @@ -1864,7 +1867,10 @@ def test_saves_error_event_id_if_in_header(self, safe_urlopen: MagicMock) -> Non data = {"issue": serialize(self.issue)} with pytest.raises(ClientError): send_webhooks( - installation=self.install, event="issue.assigned", data=data, actor=self.user + installation=serialize_sentry_app_installation(self.install), + event="issue.assigned", + data=data, + actor=self.user, ) requests = self.buffer.get_requests() @@ -1900,7 +1906,7 @@ def test_uses_installation_organization_specific_timeout_override( } ): send_webhooks( - installation=installation, + installation=serialize_sentry_app_installation(installation), event="issue.assigned", data={"issue": serialize(self.issue)}, actor=self.user, @@ -1929,7 +1935,7 @@ def test_uses_defaults_when_webhook_timeout_override_exceeds_hard_timeout( } ): send_webhooks( - installation=self.install, + installation=serialize_sentry_app_installation(self.install), event="issue.assigned", data={"issue": serialize(self.issue)}, actor=self.user, diff --git a/tests/sentry/sentry_apps/test_sentry_app_component_preparer.py b/tests/sentry/sentry_apps/test_sentry_app_component_preparer.py index 1def6c5b3c7a..31bfe7d997ed 100644 --- a/tests/sentry/sentry_apps/test_sentry_app_component_preparer.py +++ b/tests/sentry/sentry_apps/test_sentry_app_component_preparer.py @@ -20,7 +20,9 @@ def setUp(self) -> None: self.install = self.create_sentry_app_installation(slug=self.sentry_app.slug) - self.component = self.sentry_app.components.first() + component = self.sentry_app.components.first() + assert component is not None + self.component = component with assume_test_silo_mode(SiloMode.CELL): self.project = Organization.objects.get( id=self.install.organization_id @@ -100,7 +102,9 @@ def setUp(self) -> None: self.install = self.create_sentry_app_installation(slug=self.sentry_app.slug) - self.component = self.sentry_app.components.first() + component = self.sentry_app.components.first() + assert component is not None + self.component = component with assume_test_silo_mode(SiloMode.CELL): self.project = Organization.objects.get( id=self.install.organization_id @@ -171,11 +175,13 @@ def setUp(self) -> None: slug="pied-piper", organization=self.project.organization ) - self.component = self.sentry_app.components.first() + component = self.sentry_app.components.first() + assert component is not None + self.component = component with assume_test_silo_mode(SiloMode.CELL): - self.project = Organization.objects.get( - id=self.install.organization_id - ).project_set.first() + project = Organization.objects.get(id=self.install.organization_id).project_set.first() + assert project is not None + self.project = project @responses.activate def test_prepares_components_requiring_requests(self) -> None: diff --git a/tests/sentry/sentry_apps/test_sentry_app_creator.py b/tests/sentry/sentry_apps/test_sentry_app_creator.py index 91ec0b0ec0bf..ba57612b88fb 100644 --- a/tests/sentry/sentry_apps/test_sentry_app_creator.py +++ b/tests/sentry/sentry_apps/test_sentry_app_creator.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, patch from django.db import IntegrityError @@ -185,7 +186,9 @@ def setUp(self) -> None: self.org = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.org) - def run_creator(self, **kwargs): + def run_creator( + self, **kwargs: Any + ) -> SentryApp: # justification: forwards arbitrary creator overrides. return SentryAppCreator( is_internal=True, verify_install=False, diff --git a/tests/sentry/sentry_apps/test_sentry_app_installation_creator.py b/tests/sentry/sentry_apps/test_sentry_app_installation_creator.py index b44ace74133f..0fdc34c6688b 100644 --- a/tests/sentry/sentry_apps/test_sentry_app_installation_creator.py +++ b/tests/sentry/sentry_apps/test_sentry_app_installation_creator.py @@ -1,3 +1,4 @@ +from typing import Any, cast from unittest.mock import MagicMock, patch import responses @@ -9,6 +10,7 @@ from sentry.models.apigrant import ApiGrant from sentry.models.auditlogentry import AuditLogEntry from sentry.sentry_apps.installations import SentryAppInstallationCreator +from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation from sentry.sentry_apps.models.servicehook import ServiceHook, ServiceHookProject from sentry.silo.base import SiloMode from sentry.testutils.asserts import assert_count_of_metric, assert_success_metric @@ -35,8 +37,11 @@ def setUp(self) -> None: scopes=("project:read",), events=("issue.created",), ) + application = self.sentry_app.application + assert application is not None + self.application = application - def run_creator(self, **kwargs): + def run_creator(self, **kwargs: Any) -> SentryAppInstallation: return SentryAppInstallationCreator( organization_id=self.org.id, slug="nulldb", **kwargs ).run(user=self.user, request=kwargs.pop("request", None)) @@ -82,7 +87,7 @@ def test_creates_service_hooks(self) -> None: with assume_test_silo_mode(SiloMode.CELL): hook = ServiceHook.objects.get(organization_id=self.org.id) - assert hook.application_id == self.sentry_app.application.id + assert hook.application_id == self.application.id assert hook.actor_id == install.id assert hook.organization_id == self.org.id assert hook.events == self.sentry_app.events @@ -163,5 +168,5 @@ def test_records_analytics(self, record: MagicMock) -> None: def test_placeholder_email(self) -> None: responses.add(responses.POST, "https://example.com/webhook") install = self.run_creator() - proxy_user = User.objects.get(id=install.sentry_app.proxy_user.id) + proxy_user = User.objects.get(id=cast(User, install.sentry_app.proxy_user).id) assert proxy_user.email == f"{proxy_user.username}@proxy-user.sentry.io" diff --git a/tests/sentry/sentry_apps/test_sentry_app_installation_notifier.py b/tests/sentry/sentry_apps/test_sentry_app_installation_notifier.py index 4dcf5484e8e1..9f800ba51ee1 100644 --- a/tests/sentry/sentry_apps/test_sentry_app_installation_notifier.py +++ b/tests/sentry/sentry_apps/test_sentry_app_installation_notifier.py @@ -41,6 +41,9 @@ def setUp(self) -> None: prevent_token_exchange=True, ) self.rpc_user = user_service.get_user(user_id=self.user.id) + grant = self.install.api_grant + assert grant is not None + self.api_grant = grant @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance) def test_task_enqueued(self, safe_urlopen: MagicMock) -> None: @@ -63,7 +66,7 @@ def test_task_enqueued(self, safe_urlopen: MagicMock) -> None: }, "organization": {"slug": self.organization.slug, "id": self.organization.id}, "uuid": self.install.uuid, - "code": self.install.api_grant.code, + "code": self.api_grant.code, "status": "installed", } }, @@ -100,7 +103,7 @@ def test_uninstallation_enqueued(self, safe_urlopen: MagicMock) -> None: }, "organization": {"slug": self.organization.slug, "id": self.organization.id}, "uuid": self.install.uuid, - "code": self.install.api_grant.code, + "code": self.api_grant.code, "status": "installed", } }, diff --git a/tests/sentry/sentry_apps/test_sentry_app_updater.py b/tests/sentry/sentry_apps/test_sentry_app_updater.py index 5c65a3530680..2b183223eeda 100644 --- a/tests/sentry/sentry_apps/test_sentry_app_updater.py +++ b/tests/sentry/sentry_apps/test_sentry_app_updater.py @@ -35,6 +35,9 @@ def setUp(self) -> None: scopes=("project:read", "event:read"), schema={"elements": [self.create_issue_link_schema()]}, ) + proxy_user = self.sentry_app.proxy_user + assert proxy_user is not None + self.proxy_user = proxy_user self.updater = SentryAppUpdater(sentry_app=self.sentry_app) def test_updates_name(self) -> None: @@ -78,13 +81,13 @@ def test_updates_unpublished_app_scopes(self) -> None: # create both expired token and not expired tokens ApiToken.objects.create( application=self.sentry_app.application, - user=self.sentry_app.proxy_user, + user=self.proxy_user, scope_list=self.sentry_app.scope_list, expires_at=(timezone.now() + timedelta(hours=1)), ) ApiToken.objects.create( application=self.sentry_app.application, - user=self.sentry_app.proxy_user, + user=self.proxy_user, scope_list=self.sentry_app.scope_list, expires_at=(timezone.now() - timedelta(hours=1)), ) diff --git a/tests/sentry/sentry_apps/test_webhooks.py b/tests/sentry/sentry_apps/test_webhooks.py index 218f217fde7f..bb79652a488b 100644 --- a/tests/sentry/sentry_apps/test_webhooks.py +++ b/tests/sentry/sentry_apps/test_webhooks.py @@ -1,5 +1,5 @@ from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -65,8 +65,8 @@ def setUp(self) -> None: @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") def test_broadcast_valid_event_to_relevant_installations( - self, mock_installations, mock_send_webhook - ): + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test that webhooks are sent to relevant installations for valid events.""" mock_installations.return_value = [ self.installation_1 @@ -92,8 +92,8 @@ def test_broadcast_valid_event_to_relevant_installations( @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") def test_broadcast_sends_to_multiple_relevant_installations( - self, mock_installations, mock_send_webhook - ): + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test that webhooks are sent to all relevant installations.""" # Both apps subscribe to issue events sentry_app_4 = self.create_sentry_app( @@ -123,8 +123,8 @@ def test_broadcast_sends_to_multiple_relevant_installations( @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") @patch("sentry.sentry_apps.tasks.sentry_apps.logger") def test_broadcast_no_relevant_installations( - self, mock_logger, mock_installations, mock_send_webhook - ): + self, mock_logger: MagicMock, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test that no webhooks are sent when no installations subscribe to the event.""" mock_installations.return_value = [ self.installation_3 @@ -143,7 +143,7 @@ def test_broadcast_no_relevant_installations( mock_send_webhook.delay.assert_not_called() @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_invalid_event_type_raises_error(self, mock_installations): + def test_broadcast_invalid_event_type_raises_error(self, mock_installations: MagicMock) -> None: """Test that invalid event types raise SentryAppSentryError.""" from sentry.sentry_apps.utils.errors import SentryAppSentryError @@ -164,7 +164,9 @@ def test_broadcast_invalid_event_type_raises_error(self, mock_installations): @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_filters_by_subscription(self, mock_installations, mock_send_webhook): + def test_broadcast_filters_by_subscription( + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Only installations subscribed to the event are notified.""" # Create an app that doesn't subscribe to the event resource sentry_app_5 = self.create_sentry_app( @@ -194,7 +196,9 @@ def test_broadcast_filters_by_subscription(self, mock_installations, mock_send_w @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_matches_exact_event(self, mock_installations, mock_send_webhook): + def test_broadcast_matches_exact_event( + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Only the installation subscribed to the exact event is notified.""" # installation_1 subscribes to issue.created; installation_2 only to # issue.assigned, a sibling event that must not match. @@ -238,7 +242,9 @@ def test_valid_event_types_accepted(self) -> None: @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_different_event_types(self, mock_installations, mock_send_webhook): + def test_broadcast_different_event_types( + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test broadcasting different valid event types.""" mock_installations.return_value = [self.installation_2] @@ -256,7 +262,9 @@ def test_broadcast_different_event_types(self, mock_installations, mock_send_web @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_empty_installations_list(self, mock_installations, mock_send_webhook): + def test_broadcast_empty_installations_list( + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test broadcasting when no installations are returned.""" mock_installations.return_value = [] @@ -274,7 +282,9 @@ def test_broadcast_empty_installations_list(self, mock_installations, mock_send_ @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_payload_passed_correctly(self, mock_installations, mock_send_webhook): + def test_broadcast_payload_passed_correctly( + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test that payload data is passed correctly to send_webhooks.""" mock_installations.return_value = [self.installation_1] @@ -304,7 +314,9 @@ def test_broadcast_payload_passed_correctly(self, mock_installations, mock_send_ @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_different_organization_ids(self, mock_installations, mock_send_webhook): + def test_broadcast_different_organization_ids( + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test that the correct organization_id is used to fetch installations.""" different_org = self.create_organization() mock_installations.return_value = [] @@ -338,7 +350,9 @@ def test_event_type_construction(self) -> None: @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_handles_send_webhook_exception(self, mock_installations, mock_send_webhook): + def test_broadcast_handles_send_webhook_exception( + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test that exceptions from send_resource_change_webhook are properly handled.""" mock_installations.return_value = [self.installation_1] mock_send_webhook.delay.side_effect = Exception("Webhook task failed") @@ -357,8 +371,8 @@ def test_broadcast_handles_send_webhook_exception(self, mock_installations, mock @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") def test_broadcast_with_special_characters_in_payload( - self, mock_installations, mock_send_webhook - ): + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test broadcasting with special characters and unicode in payload.""" mock_installations.return_value = [self.installation_1] @@ -382,7 +396,9 @@ def test_broadcast_with_special_characters_in_payload( @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_with_large_payload(self, mock_installations, mock_send_webhook): + def test_broadcast_with_large_payload( + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test broadcasting with a large payload.""" mock_installations.return_value = [self.installation_1] @@ -407,7 +423,9 @@ def test_broadcast_with_large_payload(self, mock_installations, mock_send_webhoo @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_case_sensitive_event_validation(self, mock_installations, mock_send_webhook): + def test_broadcast_case_sensitive_event_validation( + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test that event type validation is case sensitive.""" from sentry.sentry_apps.utils.errors import SentryAppSentryError @@ -426,7 +444,9 @@ def test_broadcast_case_sensitive_event_validation(self, mock_installations, moc @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_with_empty_payload(self, mock_installations, mock_send_webhook): + def test_broadcast_with_empty_payload( + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test broadcasting with an empty payload.""" mock_installations.return_value = [self.installation_1] @@ -445,7 +465,9 @@ def test_broadcast_with_empty_payload(self, mock_installations, mock_send_webhoo @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_none_values_in_payload(self, mock_installations, mock_send_webhook): + def test_broadcast_none_values_in_payload( + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test broadcasting with None values in payload.""" mock_installations.return_value = [self.installation_1] @@ -467,7 +489,7 @@ def test_broadcast_none_values_in_payload(self, mock_installations, mock_send_we ) @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_app_service_exception(self, mock_installations): + def test_broadcast_app_service_exception(self, mock_installations: MagicMock) -> None: """Test handling of exceptions from app_service.installations_for_organization.""" mock_installations.side_effect = Exception("App service unavailable") @@ -483,7 +505,9 @@ def test_broadcast_app_service_exception(self, mock_installations): @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_with_negative_organization_id(self, mock_installations, mock_send_webhook): + def test_broadcast_with_negative_organization_id( + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test broadcasting with negative organization ID.""" mock_installations.return_value = [] @@ -497,7 +521,9 @@ def test_broadcast_with_negative_organization_id(self, mock_installations, mock_ @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_with_zero_organization_id(self, mock_installations, mock_send_webhook): + def test_broadcast_with_zero_organization_id( + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test broadcasting with zero organization ID.""" mock_installations.return_value = [] @@ -511,7 +537,9 @@ def test_broadcast_with_zero_organization_id(self, mock_installations, mock_send @patch("sentry.sentry_apps.tasks.sentry_apps.send_resource_change_webhook") @patch("sentry.sentry_apps.tasks.sentry_apps.app_service.installations_for_organization") - def test_broadcast_queues_tasks_asynchronously(self, mock_installations, mock_send_webhook): + def test_broadcast_queues_tasks_asynchronously( + self, mock_installations: MagicMock, mock_send_webhook: MagicMock + ) -> None: """Test that webhook sending is queued as tasks, not executed synchronously.""" sentry_app = self.create_sentry_app( name="App6", organization=self.organization, events=["issue.created"] diff --git a/tests/sentry/sentry_apps/token_exchange/test_grant_exchanger.py b/tests/sentry/sentry_apps/token_exchange/test_grant_exchanger.py index da4c03cf4dc4..5ff4c8997df3 100644 --- a/tests/sentry/sentry_apps/token_exchange/test_grant_exchanger.py +++ b/tests/sentry/sentry_apps/token_exchange/test_grant_exchanger.py @@ -31,10 +31,17 @@ class TestGrantExchanger(TestCase): def setUp(self) -> None: self.install = self.create_sentry_app_installation(prevent_token_exchange=True) - self.code = self.install.api_grant.code - assert self.install.sentry_app.application is not None - self.client_id = self.install.sentry_app.application.client_id - self.user = self.install.sentry_app.proxy_user + grant = self.install.api_grant + assert grant is not None + application = self.install.sentry_app.application + assert application is not None + user = self.install.sentry_app.proxy_user + assert user is not None + self.grant = grant + self.code = grant.code + self.application = application + self.client_id = application.client_id + self.user = user self.grant_exchanger = GrantExchanger( install=self.install, client_id=self.client_id, code=self.code, user=self.user @@ -74,7 +81,9 @@ def test_adds_token_to_installation(self, mock_record: MagicMock) -> None: @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def test_grant_must_belong_to_installations(self, mock_record: MagicMock) -> None: other_install = self.create_sentry_app_installation(prevent_token_exchange=True) - self.grant_exchanger.code = other_install.api_grant.code + other_grant = other_install.api_grant + assert other_grant is not None + self.grant_exchanger.code = other_grant.code with pytest.raises(SentryAppIntegratorError) as e: self.grant_exchanger.run() @@ -123,7 +132,7 @@ def test_request_user_owns_api_grant(self, mock_record: MagicMock) -> None: @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def test_grant_must_be_active(self, mock_record: MagicMock) -> None: - self.install.api_grant.update(expires_at=(datetime.now(UTC) - timedelta(hours=1))) + self.grant.update(expires_at=(datetime.now(UTC) - timedelta(hours=1))) with pytest.raises(SentryAppIntegratorError) as e: self.grant_exchanger.run() @@ -188,7 +197,7 @@ def test_application_must_exist( assert e.value.message == "Could not find application from grant" assert e.value.webhook_context == { "code": self.code[:4], - "grant_id": self.install.api_grant.id, + "grant_id": self.grant.id, } assert e.value.public_context == {} @@ -321,6 +330,7 @@ def test_exchange_fails_when_other_exchange_commits_after_lock_expires(self) -> install = self.create_sentry_app_installation(prevent_token_exchange=True) assert install.api_grant is not None assert install.sentry_app.application is not None + assert install.sentry_app.proxy_user is not None grant_id = install.api_grant.id exchanger = GrantExchanger( install=install, diff --git a/tests/sentry/sentry_apps/token_exchange/test_manual_refresher.py b/tests/sentry/sentry_apps/token_exchange/test_manual_refresher.py index d49945560bcb..c9e7854f1550 100644 --- a/tests/sentry/sentry_apps/token_exchange/test_manual_refresher.py +++ b/tests/sentry/sentry_apps/token_exchange/test_manual_refresher.py @@ -27,10 +27,16 @@ class TestManualRefresher(TestCase): def setUp(self) -> None: self.install = self.create_sentry_app_installation() - self.client_id = self.install.sentry_app.application.client_id - self.user = self.install.sentry_app.proxy_user - - self.token = self.install.api_token + application = self.install.sentry_app.application + assert application is not None + user = self.install.sentry_app.proxy_user + assert user is not None + token = self.install.api_token + assert token is not None + self.application = application + self.client_id = application.client_id + self.user = user + self.token = token self.refresher = ManualTokenRefresher( install=self.install, @@ -91,8 +97,11 @@ def test_deletes_old_token(self, mock_record: MagicMock) -> None: def test_installation_must_exist(self, mock_record: MagicMock) -> None: # Create a new installation and then delete it deleted_install = self.create_sentry_app_installation() - deleted_client_id = deleted_install.sentry_app.application.client_id + deleted_application = deleted_install.sentry_app.application + assert deleted_application is not None + deleted_client_id = deleted_application.client_id deleted_user = deleted_install.sentry_app.proxy_user + assert deleted_user is not None deleted_install.delete() refresher = ManualTokenRefresher( @@ -129,12 +138,16 @@ def test_installation_must_exist(self, mock_record: MagicMock) -> None: def test_validates_installation_belongs_to_app(self, mock_record: MagicMock) -> None: # Create a different app and installation other_app = self.create_sentry_app(name="other-app", organization=self.organization) + other_application = other_app.application + assert other_application is not None + other_user = other_app.proxy_user + assert other_user is not None # Try to use this installation's client_id with the original install refresher = ManualTokenRefresher( install=self.install, - client_id=other_app.application.client_id, - user=other_app.proxy_user, + client_id=other_application.client_id, + user=other_user, ) with pytest.raises(SentryAppIntegratorError) as e: @@ -242,7 +255,7 @@ def test_sentry_app_must_exist( assert e.value.message == "Sentry App does not exist on attached Application" assert e.value.webhook_context == { - "application_id": self.install.sentry_app.application.id, + "application_id": self.application.id, "installation_uuid": self.install.uuid, "client_id": self.client_id[:SENSITIVE_CHARACTER_LIMIT], } @@ -352,6 +365,7 @@ def test_handles_installation_with_no_existing_token(self, mock_record: MagicMoc @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def test_new_token_has_correct_properties(self, mock_record: MagicMock) -> None: old_token = self.install.api_token + assert old_token is not None new_token = self.refresher.run() # Verify new token has different ID and token value diff --git a/tests/sentry/sentry_apps/token_exchange/test_refresher.py b/tests/sentry/sentry_apps/token_exchange/test_refresher.py index 66df7a83c3c5..a090c3012245 100644 --- a/tests/sentry/sentry_apps/token_exchange/test_refresher.py +++ b/tests/sentry/sentry_apps/token_exchange/test_refresher.py @@ -26,15 +26,23 @@ class TestRefresher(TestCase): def setUp(self) -> None: self.install = self.create_sentry_app_installation() - self.client_id = self.install.sentry_app.application.client_id - self.user = self.install.sentry_app.proxy_user - - self.token = self.install.api_token + application = self.install.sentry_app.application + assert application is not None + user = self.install.sentry_app.proxy_user + assert user is not None + token = self.install.api_token + assert token is not None + assert token.refresh_token is not None + self.application = application + self.client_id = application.client_id + self.user = user + self.token = token + self.refresh_token = token.refresh_token self.refresher = Refresher( install=self.install, client_id=self.client_id, - refresh_token=self.token.refresh_token, + refresh_token=self.refresh_token, user=self.user, ) @@ -52,7 +60,9 @@ def test_deletes_refreshed_token(self) -> None: @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") def test_validates_token_belongs_to_sentry_app(self, mock_record: MagicMock) -> None: new_install = self.create_sentry_app_installation() - refresh_token = new_install.api_token.refresh_token + new_token = new_install.api_token + assert new_token is not None + refresh_token = new_token.refresh_token assert refresh_token is not None self.refresher.refresh_token = refresh_token @@ -189,7 +199,7 @@ def test_sentry_app_must_exist( assert e.value.message == "Sentry App does not exist on attached Application" assert e.value.webhook_context == { - "application_id": self.install.sentry_app.application.id, + "application_id": self.application.id, "installation_uuid": self.install.uuid, "client_id": self.client_id[:SENSITIVE_CHARACTER_LIMIT], } @@ -216,7 +226,7 @@ def test_records_analytics(self, record: MagicMock) -> None: Refresher( install=self.install, client_id=self.client_id, - refresh_token=self.token.refresh_token, + refresh_token=self.refresh_token, user=self.user, ).run() diff --git a/tests/sentry/sentry_apps/token_exchange/test_validator.py b/tests/sentry/sentry_apps/token_exchange/test_validator.py index 1123b89bc811..ed8585395cf5 100644 --- a/tests/sentry/sentry_apps/token_exchange/test_validator.py +++ b/tests/sentry/sentry_apps/token_exchange/test_validator.py @@ -13,8 +13,13 @@ class TestValidator(TestCase): def setUp(self) -> None: self.install = self.create_sentry_app_installation() - self.client_id = self.install.sentry_app.application.client_id - self.user = self.install.sentry_app.proxy_user + application = self.install.sentry_app.application + assert application is not None + user = self.install.sentry_app.proxy_user + assert user is not None + self.application = application + self.client_id = application.client_id + self.user = user self.validator = Validator( install=self.install, client_id=self.client_id, @@ -68,7 +73,7 @@ def test_raises_when_sentry_app_cannot_be_found(self, sentry_app: MagicMock) -> with pytest.raises(SentryAppSentryError) as e: self.validator.run() assert e.value.message == "Integration does not exist" - assert e.value.webhook_context == {"application_id": self.install.sentry_app.application.id} + assert e.value.webhook_context == {"application_id": self.application.id} assert e.value.public_context == {} def test_raises_with_invalid_client_id(self) -> None: @@ -83,15 +88,15 @@ def test_raises_with_invalid_client_id(self) -> None: def test_raises_when_application_is_inactive(self) -> None: from sentry.models.apiapplication import ApiApplicationStatus - self.install.sentry_app.application.status = ApiApplicationStatus.inactive - self.install.sentry_app.application.save() + self.application.status = ApiApplicationStatus.inactive + self.application.save() with pytest.raises(SentryAppIntegratorError) as e: self.validator.run() assert e.value.message == "Application is not active" assert e.value.status_code == 401 assert e.value.webhook_context == { - "application_id": self.install.sentry_app.application.id, + "application_id": self.application.id, "client_id": self.client_id[:4], } assert e.value.public_context == {} diff --git a/tests/sentry/sentry_metrics/consumers/test_schema_validator.py b/tests/sentry/sentry_metrics/consumers/test_schema_validator.py index d9792908e049..65ac429f6297 100644 --- a/tests/sentry/sentry_metrics/consumers/test_schema_validator.py +++ b/tests/sentry/sentry_metrics/consumers/test_schema_validator.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from typing import Any +from typing import Any, cast import pytest from sentry_kafka_schemas.codecs import Codec, ValidationError @@ -194,7 +194,7 @@ def test_metrics_schema_validator( """ Test the behavior of the MetricsSchemaValidator class with different parameters. """ - with override_options({option_name: option_value}): + with override_options({cast(str, option_name): option_value}): validator = MetricsSchemaValidator(codec, option_name if option_name else None) if is_valid: validator.validate(use_case_id, message) diff --git a/tests/sentry/sentry_metrics/querying/data/test_api.py b/tests/sentry/sentry_metrics/querying/data/test_api.py index e819b976a62f..ae553b906fbf 100644 --- a/tests/sentry/sentry_metrics/querying/data/test_api.py +++ b/tests/sentry/sentry_metrics/querying/data/test_api.py @@ -86,7 +86,7 @@ def setUp(self) -> None: self.query_transformer = MetricsAPIQueryResultsTransformer() - def now(self): + def now(self) -> datetime: return MOCK_DATETIME def ts(self, dt: datetime) -> int: @@ -1485,7 +1485,7 @@ def test_query_with_basic_formula_and_coefficient_operators(self) -> None: assert meta[0][1]["unit"] == expected_unit assert meta[0][1]["scaling_factor"] is None - def setup_project_with_metrics(self, name: str): + def setup_project_with_metrics(self, name: str) -> Project: new_project = self.create_project(name=name) for value, transaction, platform, env, time in ( (1, "/hello", "android", "prod", self.now()), diff --git a/tests/sentry/sentry_metrics/test_all_indexers.py b/tests/sentry/sentry_metrics/test_all_indexers.py index 6a63ca5b1c48..d26f2dd48d53 100644 --- a/tests/sentry/sentry_metrics/test_all_indexers.py +++ b/tests/sentry/sentry_metrics/test_all_indexers.py @@ -6,11 +6,12 @@ the mock indexer actually behaves the same as the postgres indexer. """ -from collections.abc import Mapping +from collections.abc import Generator, Mapping +from typing import cast import pytest -from sentry.sentry_metrics.indexer.base import FetchType, FetchTypeExt, Metadata +from sentry.sentry_metrics.indexer.base import FetchType, FetchTypeExt, Metadata, StringIndexer from sentry.sentry_metrics.indexer.cache import ( BULK_RECORD_CACHE_NAMESPACE, CachingIndexer, @@ -31,17 +32,18 @@ @pytest.fixture(params=BACKENDS) -def indexer_cls(request): - return request.param +def indexer_cls(request: pytest.FixtureRequest) -> type[StringIndexer]: + # justification: the fixture parameters are declared as indexer classes above. + return cast("type[StringIndexer]", request.param) @pytest.fixture -def indexer(indexer_cls): +def indexer(indexer_cls: type[StringIndexer]) -> StringIndexer: return indexer_cls() @pytest.fixture -def indexer_cache(): +def indexer_cache() -> Generator[StringIndexerCache]: indexer_cache = StringIndexerCache( cache_name="default", partition_key="test", @@ -53,22 +55,25 @@ def indexer_cache(): @pytest.fixture(params=USE_CASE_IDS) -def use_case_id(request): - return request.param +def use_case_id(request: pytest.FixtureRequest) -> UseCaseID: + # justification: the fixture parameters are declared as UseCaseID members above. + return cast("UseCaseID", request.param) @pytest.fixture -def writes_limiter_option_name(use_case_id): +def writes_limiter_option_name(use_case_id: UseCaseID) -> str: return "sentry-metrics.writes-limiter.limits.releasehealth" def assert_fetch_type_for_tag_string_set( meta: Mapping[str, Metadata], fetch_type: FetchType, str_set: set[str] -): +) -> None: assert all([meta[string].fetch_type == fetch_type for string in str_set]) -def test_static_and_non_static_strings_release_health(indexer, use_case_id) -> None: +def test_static_and_non_static_strings_release_health( + indexer: StringIndexer, use_case_id: UseCaseID +) -> None: static_indexer = StaticStringIndexer(indexer) strings = { use_case_id: { @@ -98,7 +103,9 @@ def test_static_and_non_static_strings_release_health(indexer, use_case_id) -> N assert_fetch_type_for_tag_string_set(meta[use_case_id][3], FetchType.FIRST_SEEN, {"2.0.0"}) -def test_indexer(indexer, indexer_cache, use_case_id) -> None: +def test_indexer( + indexer: StringIndexer, indexer_cache: StringIndexerCache, use_case_id: UseCaseID +) -> None: with override_options( { "sentry-metrics.indexer.read-new-cache-namespace": False, @@ -155,7 +162,9 @@ def test_indexer(indexer, indexer_cache, use_case_id) -> None: assert not results[use_case_id].results.get(999) -def test_resolve_and_reverse_resolve(indexer, indexer_cache, use_case_id) -> None: +def test_resolve_and_reverse_resolve( + indexer: StringIndexer, indexer_cache: StringIndexerCache, use_case_id: UseCaseID +) -> None: """ Test `resolve` and `reverse_resolve` methods """ @@ -174,20 +183,22 @@ def test_resolve_and_reverse_resolve(indexer, indexer_cache, use_case_id) -> Non indexer.bulk_record({use_case_id: org_strings}) # test resolve and reverse_resolve - id = indexer.resolve(use_case_id=use_case_id, org_id=org1_id, string="hello") + id = indexer.resolve(use_case_id, org1_id, "hello") assert id is not None - assert indexer.reverse_resolve(use_case_id=use_case_id, org_id=org1_id, id=id) == "hello" + assert indexer.reverse_resolve(use_case_id, org1_id, id) == "hello" # test record on a string that already exists - indexer.record(use_case_id=use_case_id, org_id=org1_id, string="hello") - assert indexer.resolve(use_case_id=use_case_id, org_id=org1_id, string="hello") == id + indexer.record(use_case_id, org1_id, "hello") + assert indexer.resolve(use_case_id, org1_id, "hello") == id # test invalid values - assert indexer.resolve(use_case_id=use_case_id, org_id=org1_id, string="beep") is None - assert indexer.reverse_resolve(use_case_id=use_case_id, org_id=org1_id, id=1234) is None + assert indexer.resolve(use_case_id, org1_id, "beep") is None + assert indexer.reverse_resolve(use_case_id, org1_id, 1234) is None -def test_already_created_plus_written_results(indexer, indexer_cache, use_case_id) -> None: +def test_already_created_plus_written_results( + indexer: StringIndexer, indexer_cache: StringIndexerCache, use_case_id: UseCaseID +) -> None: """ Test that we correctly combine db read results with db write results for the same organization. @@ -239,7 +250,9 @@ def test_already_created_plus_written_results(indexer, indexer_cache, use_case_i ) -def test_invalid_timestamp_in_indexer_cache(indexer, indexer_cache) -> None: +def test_invalid_timestamp_in_indexer_cache( + indexer: StringIndexer, indexer_cache: StringIndexerCache +) -> None: """ Test that the caching indexer will incur a miss if the data is staying in the cache for too long """ @@ -257,6 +270,7 @@ def test_invalid_timestamp_in_indexer_cache(indexer, indexer_cache) -> None: res = indexer.bulk_record({use_case_id: {org_id: {s}}}) id = res.get_mapped_results()[use_case_id][org_id][s] + assert id is not None assert res.get_fetch_metadata()[use_case_id][org_id][s].fetch_type == FetchType.FIRST_SEEN indexer.cache.cache.set( @@ -291,7 +305,9 @@ def test_invalid_timestamp_in_indexer_cache(indexer, indexer_cache) -> None: ) -def test_already_cached_plus_read_results(indexer, indexer_cache, use_case_id) -> None: +def test_already_cached_plus_read_results( + indexer: StringIndexer, indexer_cache: StringIndexerCache, use_case_id: UseCaseID +) -> None: """ Test that we correctly combine cached results with read results for the same organization. @@ -339,7 +355,7 @@ def test_already_cached_plus_read_results(indexer, indexer_cache, use_case_id) - ) -def test_read_when_bulk_record(indexer, use_case_id) -> None: +def test_read_when_bulk_record(indexer: StringIndexer, use_case_id: UseCaseID) -> None: with override_options( { "sentry-metrics.indexer.read-new-cache-namespace": False, @@ -365,7 +381,12 @@ def test_read_when_bulk_record(indexer, use_case_id) -> None: ) -def test_rate_limited(indexer, indexer_cache, use_case_id, writes_limiter_option_name) -> None: +def test_rate_limited( + indexer: StringIndexer, + indexer_cache: StringIndexerCache, + use_case_id: UseCaseID, + writes_limiter_option_name: str, +) -> None: """ Assert that rate limits per-org and globally are applied at all. @@ -430,7 +451,7 @@ def test_rate_limited(indexer, indexer_cache, use_case_id, writes_limiter_option fetch_type_ext=FetchTypeExt(is_global=False), ) - org_strings2 = {1: rate_limited_strings} + org_strings2 = {1: cast(set[str], rate_limited_strings)} # assert that if we reconfigure limits, the quota resets with override_options( @@ -452,7 +473,7 @@ def test_rate_limited(indexer, indexer_cache, use_case_id, writes_limiter_option assert len(rate_limited_strings - rate_limited_strings2) == 2 -def test_bulk_reverse_resolve(indexer) -> None: +def test_bulk_reverse_resolve(indexer: StringIndexer) -> None: """ Tests reverse resolve properly returns the corresponding strings in the proper order when given a combination of shared and non-shared ids. @@ -476,6 +497,9 @@ def test_bulk_reverse_resolve(indexer) -> None: unknown1 = 6666 unknown2 = 6667 + assert a is not None + assert b is not None + assert c is not None indexes = [a, production, b, unknown1, release, environment, c, unknown2] # we expect the indexer to resolve the indexes to the original strings and return None for unknown indexes expected_result = { diff --git a/tests/sentry/sentry_metrics/test_strings.py b/tests/sentry/sentry_metrics/test_strings.py index e5b9cb12f840..6e9e5028fc91 100644 --- a/tests/sentry/sentry_metrics/test_strings.py +++ b/tests/sentry/sentry_metrics/test_strings.py @@ -104,7 +104,7 @@ def test_reverse_resolve_reindexed() -> None: if parse_mri(string) is not None ], ) -def test_shared_mri_string_range(mri, id) -> None: +def test_shared_mri_string_range(mri: str, id: int) -> None: parsed_mri = parse_mri(mri) assert parsed_mri is not None, mri start, end = { diff --git a/tests/sentry/service_accounts/endpoints/test_organization_service_account_viewer_context.py b/tests/sentry/service_accounts/endpoints/test_organization_service_account_viewer_context.py index e88ef3a9d6eb..3dec918b78c9 100644 --- a/tests/sentry/service_accounts/endpoints/test_organization_service_account_viewer_context.py +++ b/tests/sentry/service_accounts/endpoints/test_organization_service_account_viewer_context.py @@ -56,7 +56,10 @@ def _post( def _issue(self) -> str: response = self._post() assert response.status_code == 200 - return response.data["viewerContext"] + viewer_context = response.data["viewerContext"] + if not isinstance(viewer_context, str): + raise TypeError("Service account endpoint returned no viewer context") + return viewer_context def _get_project(self, viewer_context: str, project: Project) -> Any: # Environments needs only project access; details also serializes per-person fields. diff --git a/tests/sentry/services/eventstore/snuba/test_backend.py b/tests/sentry/services/eventstore/snuba/test_backend.py index 80e50984a62c..48a207998a34 100644 --- a/tests/sentry/services/eventstore/snuba/test_backend.py +++ b/tests/sentry/services/eventstore/snuba/test_backend.py @@ -295,7 +295,7 @@ def test_get_events_snql_with_inner_limit(self) -> None: assert [e.event_id for e in with_inner] == [event_middle.event_id, event_newest.event_id] def test_get_event_beyond_retention(self) -> None: - event = self.store_event( + self.store_event( data={ "event_id": "d" * 32, "type": "default", @@ -308,8 +308,8 @@ def test_get_event_beyond_retention(self) -> None: ) with mock.patch("sentry.quotas.backend.get_event_retention", return_value=7): - event = self.eventstore.get_event_by_id(self.project2.id, "d" * 32) - assert event is None + retained_event = self.eventstore.get_event_by_id(self.project2.id, "d" * 32) + assert retained_event is None def test_get_adjacent_event_ids(self) -> None: event = self.eventstore.get_event_by_id(self.project2.id, "b" * 32) @@ -359,6 +359,7 @@ def test_get_adjacent_event_ids(self) -> None: def test_get_adjacent_event_ids_with_excluded_group(self) -> None: event = self.event2 + assert event.group_id is not None event_filter = Filter( project_ids=[event.project_id], group_ids=[event.group_id], @@ -376,6 +377,7 @@ def test_get_adjacent_event_ids_with_excluded_group(self) -> None: def test_get_adjacent_event_ids_propagates_query_construction_error(self) -> None: event = self.event2 + assert event.group_id is not None event_filter = Filter(project_ids=[event.project_id], group_ids=[event.group_id]) with mock.patch( @@ -387,6 +389,7 @@ def test_get_adjacent_event_ids_propagates_query_construction_error(self) -> Non def test_get_adjacent_event_ids_propagates_query_execution_error(self) -> None: event = self.event2 + assert event.group_id is not None event_filter = Filter(project_ids=[event.project_id], group_ids=[event.group_id]) with mock.patch( @@ -424,6 +427,7 @@ def test_adjacent_event_ids_same_timestamp(self) -> None: assert event1.group_id == event2.group_id # the 2 events should have the same timestamp assert event1.datetime == event2.datetime + assert event1.group_id is not None _filter = Filter( project_ids=[project.id], @@ -1081,6 +1085,7 @@ def test_get_events_double_read_end_to_end(self) -> None: project_id=self.project.id, ) + assert event.group_id is not None trace_item = self.create_eap_occurrence( group_id=event.group_id, event_id=event.event_id, diff --git a/tests/sentry/services/eventstore/test_models.py b/tests/sentry/services/eventstore/test_models.py index 4762bbfec076..c86184726db6 100644 --- a/tests/sentry/services/eventstore/test_models.py +++ b/tests/sentry/services/eventstore/test_models.py @@ -1,9 +1,12 @@ import os import pickle import time -from contextlib import contextmanager +from collections.abc import Callable, Iterator, Sequence +from contextlib import AbstractContextManager, contextmanager +from typing import Any, cast from unittest import mock +import orjson import pytest from sentry import nodestore @@ -16,10 +19,12 @@ from sentry.interfaces.user import User from sentry.issues.issue_occurrence import IssueOccurrence from sentry.models.environment import Environment +from sentry.models.project import Project from sentry.services import eventstore from sentry.services.eventstore.models import Event, GroupEvent from sentry.snuba.dataset import Dataset from sentry.testutils.cases import PerformanceIssueTestCase, TestCase +from sentry.testutils.factories import Factories from sentry.testutils.helpers.datetime import before_now, freeze_time from sentry.testutils.pytest.fixtures import django_db_all from sentry.testutils.skips import requires_snuba @@ -30,7 +35,7 @@ @contextmanager -def timezone_context(tz: str): +def timezone_context(tz: str) -> Iterator[None]: old_tz = os.environ.get("TZ") try: os.environ["TZ"] = tz @@ -263,7 +268,11 @@ def test_snuba_data(self) -> None: "username", "timestamp_ms", ], - filter_keys={"project_id": [self.project.id], "event_id": ["a" * 32]}, + # justification: Snuba accepts string event ids in filter_keys despite the int annotation. + filter_keys=cast( + "dict[str, Sequence[int]]", + {"project_id": [self.project.id], "event_id": ["a" * 32]}, + ), tenant_ids={"referrer": "r", "organization_id": 1234}, )["data"][0], ) @@ -280,7 +289,7 @@ def test_snuba_data(self) -> None: assert event_from_nodestore.culprit == event_from_snuba.culprit user_from_nodestore = event_from_nodestore.get_minimal_user() - user_from_nodestore = User.to_python( + user_from_nodestore_comparison = User.to_python( { "id": user_from_nodestore._data.get("id"), "email": user_from_nodestore._data.get("email"), @@ -288,7 +297,8 @@ def test_snuba_data(self) -> None: "ip_address": user_from_nodestore._data.get("ip_address"), } ) - assert user_from_nodestore == event_from_snuba.get_minimal_user() + assert user_from_nodestore_comparison is not None + assert user_from_nodestore_comparison == event_from_snuba.get_minimal_user() assert event_from_nodestore.ip_address == event_from_snuba.ip_address assert event_from_nodestore.tags == event_from_snuba.tags @@ -335,7 +345,11 @@ def test_snuba_data_transaction(self) -> None: "tags.key", "tags.value", ], - filter_keys={"project_id": [self.project.id], "event_id": ["a" * 32]}, + # justification: Snuba accepts string event ids in filter_keys despite the int annotation. + filter_keys=cast( + "dict[str, Sequence[int]]", + {"project_id": [self.project.id], "event_id": ["a" * 32]}, + ), tenant_ids={"referrer": "r", "organization_id": 1234}, )["data"][0], ) @@ -649,15 +663,24 @@ def test_trace_id_falls_back_to_body() -> None: @django_db_all -def test_renormalization(factories, task_runner, default_project) -> None: +def test_renormalization( + factories: Factories, + task_runner: Callable[[], AbstractContextManager[None]], + default_project: Project, +) -> None: from sentry_relay.processing import StoreNormalizer old_normalize = StoreNormalizer.normalize_event normalize_mock_calls = [] - def normalize(*args, **kwargs): + def normalize( + normalizer: StoreNormalizer, + event: Any = None, + raw_event: Any = None, + json_loads: Callable[[str | bytes], Any] = orjson.loads, + ) -> dict[str, Any]: normalize_mock_calls.append(1) - return old_normalize(*args, **kwargs) + return old_normalize(normalizer, event, raw_event, json_loads) with ( mock.patch("sentry_relay.processing.StoreNormalizer.normalize_event", normalize), @@ -761,7 +784,11 @@ def test_datetime_uses_timestamp_ms_from_snuba(self) -> None: "timestamp", "timestamp_ms", ], - filter_keys={"project_id": [self.project.id], "event_id": ["a" * 32]}, + # justification: Snuba accepts string event ids in filter_keys despite the int annotation. + filter_keys=cast( + "dict[str, Sequence[int]]", + {"project_id": [self.project.id], "event_id": ["a" * 32]}, + ), tenant_ids={"referrer": "r", "organization_id": 1234}, )["data"][0], ) @@ -796,7 +823,11 @@ def test_datetime_uses_timestamp_from_snuba_without_ms(self) -> None: selected_columns=[ "timestamp", ], - filter_keys={"project_id": [self.project.id], "event_id": ["b" * 32]}, + # justification: Snuba accepts string event ids in filter_keys despite the int annotation. + filter_keys=cast( + "dict[str, Sequence[int]]", + {"project_id": [self.project.id], "event_id": ["b" * 32]}, + ), tenant_ids={"referrer": "r", "organization_id": 1234}, )["data"][0], ) @@ -833,7 +864,11 @@ def test_datetime_falls_back_to_nodestore(self) -> None: event_id="c" * 32, snuba_data=snuba.raw_query( selected_columns=["event_id"], - filter_keys={"project_id": [self.project.id], "event_id": ["c" * 32]}, + # justification: Snuba accepts string event ids in filter_keys despite the int annotation. + filter_keys=cast( + "dict[str, Sequence[int]]", + {"project_id": [self.project.id], "event_id": ["c" * 32]}, + ), tenant_ids={"referrer": "r", "organization_id": 1234}, )["data"][0], ) diff --git a/tests/sentry/services/filestore/test_gcs.py b/tests/sentry/services/filestore/test_gcs.py index ddf52f2e0b8f..2b08d01d60e9 100644 --- a/tests/sentry/services/filestore/test_gcs.py +++ b/tests/sentry/services/filestore/test_gcs.py @@ -1,8 +1,15 @@ +from collections.abc import Callable +from typing import cast from unittest import mock import pytest from google.api_core.exceptions import BadGateway, InternalServerError, ServiceUnavailable +# justification: google.api_core does not annotate its exception constructors. +_ServiceUnavailable = cast("Callable[[str], ServiceUnavailable]", ServiceUnavailable) +_InternalServerError = cast("Callable[[str], InternalServerError]", InternalServerError) +_BadGateway = cast("Callable[[str], BadGateway]", BadGateway) + from sentry.services.filestore.gcs import ( GCS_RETRIES, GCS_RETRY_MAX_DELAY, @@ -22,7 +29,7 @@ def test_try_repeated_succeeds_without_sleeping() -> None: def test_try_repeated_sleeps_between_retries_then_succeeds() -> None: - func = mock.Mock(side_effect=[ServiceUnavailable("503"), "ok"]) + func = mock.Mock(side_effect=[_ServiceUnavailable("503"), "ok"]) with mock.patch("sentry.services.filestore.gcs.time.sleep") as sleep: assert try_repeated(func) == "ok" @@ -34,7 +41,7 @@ def test_try_repeated_sleeps_between_retries_then_succeeds() -> None: def test_try_repeated_backs_off_on_every_retry_before_raising() -> None: - func = mock.Mock(side_effect=ServiceUnavailable("503")) + func = mock.Mock(side_effect=_ServiceUnavailable("503")) with mock.patch("sentry.services.filestore.gcs.time.sleep") as sleep: with pytest.raises(ServiceUnavailable): @@ -49,7 +56,7 @@ def test_try_repeated_backs_off_on_every_retry_before_raising() -> None: def test_try_repeated_does_not_retry_bad_gateway() -> None: - func = mock.Mock(side_effect=BadGateway("502")) + func = mock.Mock(side_effect=_BadGateway("502")) with mock.patch("sentry.services.filestore.gcs.time.sleep") as sleep: with pytest.raises(BadGateway): @@ -59,7 +66,7 @@ def test_try_repeated_does_not_retry_bad_gateway() -> None: assert sleep.call_count == 0 -@pytest.mark.parametrize("error", [InternalServerError("500"), BadGateway("502")]) +@pytest.mark.parametrize("error", [_InternalServerError("500"), _BadGateway("502")]) def test_replay_upload_policy_retries_transient_server_errors(error: Exception) -> None: # The first upload attempt fails with a 500/502 and the second succeeds. func = mock.Mock(side_effect=[error, None]) diff --git a/tests/sentry/services/nodestore/bigtable/test_backend.py b/tests/sentry/services/nodestore/bigtable/test_backend.py index 5f1404c9753d..8354ab9cc18b 100644 --- a/tests/sentry/services/nodestore/bigtable/test_backend.py +++ b/tests/sentry/services/nodestore/bigtable/test_backend.py @@ -1,13 +1,15 @@ from __future__ import annotations import os -from collections.abc import Generator +from collections.abc import Generator, Iterable from contextlib import contextmanager -from typing import Any +from typing import Any, cast from unittest import mock import pytest from google.cloud.bigtable import table +from google.cloud.bigtable.row import PartialRowData +from google.cloud.bigtable.row import Row as BigtableRow from google.cloud.bigtable.row_data import DEFAULT_RETRY_READ_ROWS from google.rpc.status_pb2 import Status @@ -47,13 +49,15 @@ class Table(table.Table): def __init__(self) -> None: self._rows: dict[bytes, dict[str, list[MockedBigtableKVStorage.Cell]]] = {} - def direct_row(self, key: str) -> MockedBigtableKVStorage.Row: - return MockedBigtableKVStorage.Row(self, key) + def direct_row(self, key: str) -> BigtableRow: + # justification: the mocked row implements the Bigtable Row interface. + return cast("BigtableRow", MockedBigtableKVStorage.Row(self, key)) def read_row( self, row_key: str, filter_: Any = None, retry: Any = DEFAULT_RETRY_READ_ROWS - ) -> MockedBigtableKVStorage.Row: - return MockedBigtableKVStorage.Row(self, row_key) + ) -> PartialRowData | None: + # justification: the mocked row carries the PartialRowData fields. + return cast("PartialRowData", MockedBigtableKVStorage.Row(self, row_key)) def read_rows( self, @@ -64,12 +68,12 @@ def read_rows( end_inclusive: bool = False, row_set: Any = None, retry: Any = None, - ) -> list[MockedBigtableKVStorage.Row]: + ) -> Iterable[PartialRowData]: assert not row_set.row_ranges, "unsupported" - return [self.read_row(key) for key in row_set.row_keys] + return [cast("PartialRowData", self.read_row(key)) for key in row_set.row_keys] def mutate_rows( - self, rows: list[Any], retry: Any = None, timeout: float | None = None + self, rows: Iterable[Any], retry: Any = None, timeout: float | None = None ) -> list[Status]: # commits not implemented, changes are applied immediately return [Status(code=0) for row in rows] @@ -78,8 +82,9 @@ def _get_table(self, admin: bool = False) -> MockedBigtableKVStorage.Table: try: table = self.__table except AttributeError: - table = self.__table = MockedBigtableKVStorage.Table() - + table = MockedBigtableKVStorage.Table() + self.__table = table + assert isinstance(table, MockedBigtableKVStorage.Table) return table def bootstrap(self, automatic_expiry: bool = True) -> None: diff --git a/tests/sentry/shared_integrations/client/test_base.py b/tests/sentry/shared_integrations/client/test_base.py index 33a633d92665..e15ba59ffc58 100644 --- a/tests/sentry/shared_integrations/client/test_base.py +++ b/tests/sentry/shared_integrations/client/test_base.py @@ -27,13 +27,13 @@ class Client(BaseApiClient): @responses.activate @patch.object(BaseApiClient, "finalize_request", side_effect=lambda req: req) - def test_finalize_request(self, mock_finalize_request) -> None: + def test_finalize_request(self, mock_finalize_request: MagicMock) -> None: # Ensure finalize_request is called before all requests get_response = responses.add(responses.GET, "https://example.com/get", json={}) assert not mock_finalize_request.called assert get_response.call_count == 0 self.api_client.get("https://example.com/get") - assert mock_finalize_request.called + assert bool(mock_finalize_request.called) assert get_response.call_count == 1 # Ensure finalize_request can modify the request prior to sending @@ -67,14 +67,16 @@ def test__request_prepared_request(self) -> None: @patch.object(BaseApiClient, "finalize_request", side_effect=lambda req: req) @patch.object(Session, "send", side_effect=RestrictedIPAddress()) @override_blocklist("172.16.0.0/12") - def test_restricted_ip_address(self, mock_finalize_request, mock_session_send) -> None: + def test_restricted_ip_address( + self, mock_finalize_request: MagicMock, mock_session_send: MagicMock + ) -> None: assert not mock_finalize_request.called with raises(ApiHostError): self.api_client.get("https://172.31.255.255") assert mock_finalize_request.called @patch.object(Session, "send") - def test_default_timeout(self, mock_session_send) -> None: + def test_default_timeout(self, mock_session_send: MagicMock) -> None: response = MagicMock() response.status_code = 204 mock_session_send.return_value = response @@ -86,7 +88,7 @@ def test_default_timeout(self, mock_session_send) -> None: @patch.object(BaseApiClient, "track_response_data") @patch.object(Session, "send") def test_track_response_data_includes_integration_id( - self, mock_session_send, mock_track_response_data + self, mock_session_send: MagicMock, mock_track_response_data: MagicMock ) -> None: response = MagicMock() response.status_code = 204 @@ -105,7 +107,7 @@ class Client(BaseApiClient): @patch.object(BaseApiClient, "track_response_data") @patch.object(Session, "send") def test_track_response_data_includes_organization_id_from_logging_context( - self, mock_session_send, mock_track_response_data + self, mock_session_send: MagicMock, mock_track_response_data: MagicMock ) -> None: response = MagicMock() response.status_code = 204 @@ -124,7 +126,7 @@ class Client(BaseApiClient): @patch.object(BaseApiClient, "track_response_data") @patch.object(Session, "send") def test_track_response_data_uses_org_id_fallback_for_organization_id( - self, mock_session_send, mock_track_response_data + self, mock_session_send: MagicMock, mock_track_response_data: MagicMock ) -> None: response = MagicMock() response.status_code = 204 @@ -143,7 +145,7 @@ class Client(BaseApiClient): @patch("sentry.shared_integrations.client.base.metrics.incr") @patch.object(Session, "send") def test_request_and_response_metrics_include_api_request_type( - self, mock_session_send, mock_metrics_incr + self, mock_session_send: MagicMock, mock_metrics_incr: MagicMock ) -> None: response = MagicMock() response.status_code = 204 @@ -163,7 +165,7 @@ def test_request_and_response_metrics_include_api_request_type( ) @patch("sentry.shared_integrations.client.base.metrics.incr") - def test_get_cached_emits_hit_metric(self, mock_metrics_incr) -> None: + def test_get_cached_emits_hit_metric(self, mock_metrics_incr: MagicMock) -> None: with patch.object(self.api_client, "check_cache", return_value={"cached": True}): self.api_client.get_cached("https://example.com/repos/example/repo/commits") @@ -174,7 +176,7 @@ def test_get_cached_emits_hit_metric(self, mock_metrics_incr) -> None: ) @patch("sentry.shared_integrations.client.base.metrics.incr") - def test_get_cached_emits_miss_metric(self, mock_metrics_incr) -> None: + def test_get_cached_emits_miss_metric(self, mock_metrics_incr: MagicMock) -> None: with ( patch.object(self.api_client, "check_cache", return_value=None), patch.object(self.api_client, "request", return_value={"fresh": True}), @@ -189,7 +191,9 @@ def test_get_cached_emits_miss_metric(self, mock_metrics_incr) -> None: ) @patch("sentry.shared_integrations.client.base.metrics.incr") - def test_get_cached_emits_api_request_type_metric_tag(self, mock_metrics_incr) -> None: + def test_get_cached_emits_api_request_type_metric_tag( + self, mock_metrics_incr: MagicMock + ) -> None: with patch.object(self.api_client, "check_cache", return_value={"cached": True}): self.api_client.get_cached( "https://example.com/repos/example/repo/commits", diff --git a/tests/sentry/shared_integrations/client/test_proxy.py b/tests/sentry/shared_integrations/client/test_proxy.py index 9e6935bb5882..f9f853d00919 100644 --- a/tests/sentry/shared_integrations/client/test_proxy.py +++ b/tests/sentry/shared_integrations/client/test_proxy.py @@ -4,7 +4,7 @@ import responses from django.test import override_settings from pytest import raises -from requests import Request +from requests import PreparedRequest, Request from sentry.constants import ObjectStatus from sentry.net.http import Session @@ -77,7 +77,7 @@ def test_authorize_request_noop(self) -> None: def test_authorize_request_basic(self) -> None: prepared_request = Request(method="POST", url=self.test_url).prepare() - def authorize_request(prepared_request): + def authorize_request(prepared_request: PreparedRequest) -> PreparedRequest: prepared_request.headers["Authorization"] = "Bearer tkn" return prepared_request @@ -156,8 +156,8 @@ def test_set_proxy_request_options_noop_without_proxy(self) -> None: @patch("sentry.shared_integrations.client.proxy.get_control_silo_ip_address") @patch("socket.getaddrinfo") def test_invalid_control_silo_ip_address( - self, mock_getaddrinfo, mock_get_control_silo_ip_address - ): + self, mock_getaddrinfo: MagicMock, mock_get_control_silo_ip_address: MagicMock + ) -> None: with patch("sentry_sdk.capture_exception") as mock_capture_exception, raises(ApiHostError): mock_get_control_silo_ip_address.return_value = ipaddress.ip_address("127.0.0.1") mock_getaddrinfo.return_value = [(2, 1, 6, "", ("172.31.255.255", 0))] @@ -173,8 +173,11 @@ def test_invalid_control_silo_ip_address( @patch("sentry.shared_integrations.client.proxy.get_control_silo_ip_address") @patch("socket.getaddrinfo") def test_valid_control_silo_ip_address( - self, mock_getaddrinfo, mock_get_control_silo_ip_address, mock_is_control_silo_ip_address - ): + self, + mock_getaddrinfo: MagicMock, + mock_get_control_silo_ip_address: MagicMock, + mock_is_control_silo_ip_address: MagicMock, + ) -> None: mock_get_control_silo_ip_address.return_value = ipaddress.ip_address("172.31.255.255") mock_getaddrinfo.return_value = [(2, 1, 6, "", ("172.31.255.255", 0))] client = self.client_cls(org_integration_id=self.oi_id) @@ -182,7 +185,7 @@ def test_valid_control_silo_ip_address( class BailOut(Exception): pass - def test_is_control_silo_ip_address(ip) -> None: + def test_is_control_silo_ip_address(ip: object) -> None: assert ip == "172.31.255.255" # We can't use responses library for this unit test as it hooks Session.send. So we assert that the # is_control_silo_ip_address function is properly called. @@ -201,11 +204,11 @@ def test_is_control_silo_ip_address(ip) -> None: @patch("socket.getaddrinfo") def test_does_not_validate_control_silo_ip_address_in_control( self, - mock_getaddrinfo, - mock_get_control_silo_ip_address, - mock_is_control_silo_ip_address, - mock_is_ipaddress_allowed, - ): + mock_getaddrinfo: MagicMock, + mock_get_control_silo_ip_address: MagicMock, + mock_is_control_silo_ip_address: MagicMock, + mock_is_ipaddress_allowed: MagicMock, + ) -> None: mock_get_control_silo_ip_address.return_value = ipaddress.ip_address("172.31.255.255") mock_getaddrinfo.return_value = [(2, 1, 6, "", ("172.31.255.255", 0))] mock_is_ipaddress_allowed.return_value = True @@ -214,7 +217,7 @@ def test_does_not_validate_control_silo_ip_address_in_control( class BailOut(Exception): pass - def test_socket_connection(*args, **kwargs) -> None: + def test_socket_connection(*args: object, **kwargs: object) -> None: # We can't use responses library for this unit test as it hooks Session.send. So we assert that the # socket connection is being opened. raise BailOut() @@ -248,7 +251,7 @@ def test_retries_proxy_requests_by_default(self) -> None: client = self.client_cls(org_integration_id=self.oi_id) with self.options({"hybridcloud.integrationproxy.retries": 1}): - assert client.post("/some/endpoint") == {"ok": True} + assert client.post("/some/endpoint").json == {"ok": True} assert len(responses.calls) == 2 diff --git a/tests/sentry/silo/test_client.py b/tests/sentry/silo/test_client.py index b350bade6b70..e2cb926dc4e5 100644 --- a/tests/sentry/silo/test_client.py +++ b/tests/sentry/silo/test_client.py @@ -402,7 +402,7 @@ def test_client_restricted_ip_address(self) -> None: class BailOut(Exception): pass - def test_validate_cell_ip_address(ip) -> None: + def test_validate_cell_ip_address(ip: str) -> None: assert ip == "172.31.255.255" # We can't use responses library for this unit test as it hooks Session.send. So we assert that the # validate_cell_ip_address function is properly called for the proxy request code path. @@ -484,7 +484,7 @@ def test_get_cell_ip_addresses_when_single_host_invalid(mock_incr: MagicMock) -> cell_config = (us1_cell, us2_cell, dead_cell) - def mock_gethostbyname_for_cells(hostname): + def mock_gethostbyname_for_cells(hostname: str) -> str: if hostname == us1_cell_address.split("//")[1].split(":")[0]: return "172.31.10.1" elif hostname == us2_cell_address.split("//")[1].split(":")[0]: diff --git a/tests/sentry/similarity/backends/test_redis.py b/tests/sentry/similarity/backends/test_redis.py index e6ba5db6c631..59b613f3b030 100644 --- a/tests/sentry/similarity/backends/test_redis.py +++ b/tests/sentry/similarity/backends/test_redis.py @@ -11,6 +11,10 @@ signature_builder = MinHashSignatureBuilder(32, 0xFFFF) +def character_features(value: str) -> list[bytes]: + return [character.encode() for character in value] + + class RedisScriptMinHashIndexBackendTestCase(TestCase): @cached_property def index(self) -> RedisScriptMinHashIndexBackend: @@ -25,32 +29,43 @@ def index(self) -> RedisScriptMinHashIndexBackend: ) def test_export_import(self) -> None: - self.index.record("example", "1", [("index", "hello world")]) + self.index.record("example", "1", [("index", character_features("hello world"))]) timestamp = int(time.time()) - result = self.index.export("example", [("index", 1)], timestamp=timestamp) + result = self.index.export("example", [("index", "1")], timestamp=timestamp) assert len(result) == 1 # Copy the data from key 1 to key 2. - self.index.import_("example", [("index", 2, result[0])], timestamp=timestamp) - - r1 = msgpack.unpackb(self.index.export("example", [("index", 1)], timestamp=timestamp)[0]) - r2 = msgpack.unpackb(self.index.export("example", [("index", 2)], timestamp=timestamp)[0]) + self.index.import_("example", [("index", "2", result[0])], timestamp=timestamp) + + exported_1 = self.index.export("example", [("index", "1")], timestamp=timestamp)[0] + exported_2 = self.index.export("example", [("index", "2")], timestamp=timestamp)[0] + assert isinstance(exported_1, bytes) + assert isinstance(exported_2, bytes) + r1 = msgpack.unpackb(exported_1) + r2 = msgpack.unpackb(exported_2) assert r1[0] == r2[0] self.assertAlmostEqual(r1[1], r2[1], delta=10) # cannot ensure exact TTL match # Copy the data again to key 2 (duplicating all of the data.) - self.index.import_("example", [("index", 2, result[0])], timestamp=timestamp) + self.index.import_("example", [("index", "2", result[0])], timestamp=timestamp) - result = self.index.export("example", [("index", 2)], timestamp=timestamp) + result = self.index.export("example", [("index", "2")], timestamp=timestamp) assert len(result) == 1 def test_basic(self) -> None: - self.index.record("example", "1", [("index", "hello world")]) - self.index.record("example", "2", [("index", "hello world")]) - self.index.record("example", "3", [("index", "jello world")]) - self.index.record("example", "4", [("index", "yellow world"), ("index", "mellow world")]) - self.index.record("example", "5", [("index", "pizza world")]) + self.index.record("example", "1", [("index", character_features("hello world"))]) + self.index.record("example", "2", [("index", character_features("hello world"))]) + self.index.record("example", "3", [("index", character_features("jello world"))]) + self.index.record( + "example", + "4", + [ + ("index", character_features("yellow world")), + ("index", character_features("mellow world")), + ], + ) + self.index.record("example", "5", [("index", character_features("pizza world"))]) # comparison, without thresholding results = self.index.compare("example", "1", [("index", 0)]) @@ -80,14 +95,14 @@ def test_basic(self) -> None: assert results[0] == ("1", [1.0]) # classification, without thresholding - results = self.index.classify("example", [("index", 0, "hello world")]) + results = self.index.classify("example", [("index", 0, character_features("hello world"))]) assert results[0:2] == [("1", [1.0]), ("2", [1.0])] assert results[2][0] in ("3", "4") # equidistant pairs, order doesn't really matter assert results[3][0] in ("3", "4") assert results[4][0] == "5" # classification, low threshold - results = self.index.classify("example", [("index", 6, "hello world")]) + results = self.index.classify("example", [("index", 6, character_features("hello world"))]) assert len(results) == 4 assert results[0] == ("1", [1.0]) assert results[1] == ("2", [1.0]) # identical contents @@ -95,13 +110,17 @@ def test_basic(self) -> None: assert results[3][0] in ("3", "4") # classification, high threshold (exact match) - results = self.index.classify("example", [("index", self.index.bands, "hello world")]) + results = self.index.classify( + "example", [("index", self.index.bands, character_features("hello world"))] + ) assert len(results) == 2 assert results[0] == ("1", [1.0]) assert results[1] == ("2", [1.0]) # identical contents # classification, candidate limit (with lexicographical collision sort) - results = self.index.classify("example", [("index", 0, "hello world")], limit=1) + results = self.index.classify( + "example", [("index", 0, character_features("hello world"))], limit=1 + ) assert len(results) == 1 assert results[0] == ("1", [1.0]) @@ -114,11 +133,32 @@ def test_basic(self) -> None: ] def test_multiple_index(self) -> None: - self.index.record("example", "1", [("index:a", "hello world"), ("index:b", "hello world")]) - self.index.record("example", "2", [("index:a", "hello world"), ("index:b", "hello world")]) - self.index.record("example", "3", [("index:a", "hello world"), ("index:b", "pizza world")]) - self.index.record("example", "4", [("index:a", "hello world")]) - self.index.record("example", "5", [("index:b", "hello world")]) + self.index.record( + "example", + "1", + [ + ("index:a", character_features("hello world")), + ("index:b", character_features("hello world")), + ], + ) + self.index.record( + "example", + "2", + [ + ("index:a", character_features("hello world")), + ("index:b", character_features("hello world")), + ], + ) + self.index.record( + "example", + "3", + [ + ("index:a", character_features("hello world")), + ("index:b", character_features("pizza world")), + ], + ) + self.index.record("example", "4", [("index:a", character_features("hello world"))]) + self.index.record("example", "5", [("index:b", character_features("hello world"))]) # comparison, without thresholding results = self.index.compare("example", "1", [("index:a", 0), ("index:b", 0)]) @@ -139,7 +179,11 @@ def test_multiple_index(self) -> None: # classification, without thresholding results = self.index.classify( - "example", [("index:a", 0, "hello world"), ("index:b", 0, "hello world")] + "example", + [ + ("index:a", 0, character_features("hello world")), + ("index:b", 0, character_features("hello world")), + ], ) assert len(results) == 5 assert results[:2] == [("1", [1.0, 1.0]), ("2", [1.0, 1.0])] @@ -152,14 +196,15 @@ def test_multiple_index(self) -> None: results = self.index.classify( "example", [ - ("index:a", self.index.bands, "pizza world"), # no direct hits - ("index:b", 8, "pizza world"), # one direct hit + ("index:a", self.index.bands, character_features("pizza world")), # no direct hits + ("index:b", 8, character_features("pizza world")), # one direct hit ], ) assert len(results) == 1 assert results[0][0] == "3" # this should have a value since it's similar even thought it was not # considered as a candidate for this index + assert results[0][1][0] is not None assert results[0][1][0] > 0 assert results[0][1][1] == 1.0 @@ -167,22 +212,29 @@ def test_multiple_index(self) -> None: results = self.index.classify( "example", [ - ("index:a", self.index.bands, "pizza world"), # no direct hits - ("index:b", self.index.bands, "hello world"), # 3 direct hits + ("index:a", self.index.bands, character_features("pizza world")), # no direct hits + ("index:b", self.index.bands, character_features("hello world")), # 3 direct hits ], ) assert len(results) == 3 assert results[0][0] == "1" # tie btw first 2 items is broken by lex sort + assert results[0][1][0] is not None assert results[0][1][0] > 0 assert results[0][1][1] == 1.0 assert results[1][0] == "2" + assert results[1][1][0] is not None assert results[1][1][0] > 0 assert results[1][1][1] == 1.0 assert results[2] == ("5", [0.0, 1.0]) # classification, candidate limit (with lexicographical collision sort) results = self.index.classify( - "example", [("index:a", 0, "hello world"), ("index:b", 0, "hello world")], limit=4 + "example", + [ + ("index:a", 0, character_features("hello world")), + ("index:b", 0, character_features("hello world")), + ], + limit=4, ) assert len(results) == 4 assert results[:2] == [("1", [1.0, 1.0]), ("2", [1.0, 1.0])] @@ -192,33 +244,39 @@ def test_multiple_index(self) -> None: # empty query assert ( - self.index.classify("example", [("index:a", 0, "hello world"), ("index:b", 0, "")]) + self.index.classify( + "example", + [ + ("index:a", 0, character_features("hello world")), + ("index:b", 0, character_features("")), + ], + ) == self.index.compare("example", "4", [("index:a", 0), ("index:b", 0)]) == [("4", [1.0, None]), ("1", [1.0, 0.0]), ("2", [1.0, 0.0]), ("3", [1.0, 0.0])] ) def test_merge(self) -> None: - self.index.record("example", "1", [("index", ["foo", "bar"])]) - self.index.record("example", "2", [("index", ["baz"])]) - assert self.index.classify("example", [("index", 0, ["foo", "bar"])]) == [("1", [1.0])] + self.index.record("example", "1", [("index", [b"foo", b"bar"])]) + self.index.record("example", "2", [("index", [b"baz"])]) + assert self.index.classify("example", [("index", 0, [b"foo", b"bar"])]) == [("1", [1.0])] self.index.merge("example", "1", [("index", "2")]) - assert self.index.classify("example", [("index", 0, ["foo", "bar"])]) == [("1", [0.5])] + assert self.index.classify("example", [("index", 0, [b"foo", b"bar"])]) == [("1", [0.5])] # merge into an empty key should act as a move self.index.merge("example", "2", [("index", "1")]) - assert self.index.classify("example", [("index", 0, ["foo", "bar"])]) == [("2", [0.5])] + assert self.index.classify("example", [("index", 0, [b"foo", b"bar"])]) == [("2", [0.5])] def test_flush_scoped(self) -> None: - self.index.record("example", "1", [("index", ["foo", "bar"])]) - assert self.index.classify("example", [("index", 0, ["foo", "bar"])]) == [("1", [1.0])] + self.index.record("example", "1", [("index", [b"foo", b"bar"])]) + assert self.index.classify("example", [("index", 0, [b"foo", b"bar"])]) == [("1", [1.0])] self.index.flush("example", ["index"]) - assert self.index.classify("example", [("index", 0, ["foo", "bar"])]) == [] + assert self.index.classify("example", [("index", 0, [b"foo", b"bar"])]) == [] def test_flush_unscoped(self) -> None: - self.index.record("example", "1", [("index", ["foo", "bar"])]) - assert self.index.classify("example", [("index", 0, ["foo", "bar"])]) == [("1", [1.0])] + self.index.record("example", "1", [("index", [b"foo", b"bar"])]) + assert self.index.classify("example", [("index", 0, [b"foo", b"bar"])]) == [("1", [1.0])] self.index.flush("*", ["index"]) - assert self.index.classify("example", [("index", 0, ["foo", "bar"])]) == [] + assert self.index.classify("example", [("index", 0, [b"foo", b"bar"])]) == [] diff --git a/tests/sentry/similarity/test_encoder.py b/tests/sentry/similarity/test_encoder.py index ac074614f04d..647c106ba208 100644 --- a/tests/sentry/similarity/test_encoder.py +++ b/tests/sentry/similarity/test_encoder.py @@ -28,7 +28,7 @@ def test_builtin_types() -> None: def test_custom_types() -> None: class Widget: - def __init__(self, color): + def __init__(self, color: object) -> None: self.color = color encoder = Encoder({Widget: lambda i: {"color": i.color}}) diff --git a/tests/sentry/snuba/metrics/fields/test_base.py b/tests/sentry/snuba/metrics/fields/test_base.py index a14d08e40ab2..860eb1186761 100644 --- a/tests/sentry/snuba/metrics/fields/test_base.py +++ b/tests/sentry/snuba/metrics/fields/test_base.py @@ -1,4 +1,5 @@ import copy +from collections.abc import Callable, Sequence from functools import partial from unittest import mock from unittest.mock import patch @@ -60,7 +61,7 @@ def indexer_record(use_case_id: UseCaseID, org_id: int, string: str) -> int: rh_indexer_record = partial(indexer_record, UseCaseID.SESSIONS) -def get_entity_of_metric_mocked(_, metric_mri, use_case_id): +def get_entity_of_metric_mocked(_: object, metric_mri: str, use_case_id: UseCaseID) -> str: return { SessionMRI.RAW_SESSION.value: EntityKey.MetricsCounters, SessionMRI.RAW_USER.value: EntityKey.MetricsSets, @@ -97,7 +98,9 @@ def get_entity_of_metric_mocked(_, metric_mri, use_case_id): ) -def mocked_mri_resolver(metric_mris, mri_func): +def mocked_mri_resolver( + metric_mris: Sequence[str], mri_func: Callable[[str], str] +) -> Callable[[str], str]: return lambda x: x if x in metric_mris else mri_func(x) diff --git a/tests/sentry/snuba/metrics/fields/test_histogram.py b/tests/sentry/snuba/metrics/fields/test_histogram.py index cf6acd7cbee3..f1f0c4987e2f 100644 --- a/tests/sentry/snuba/metrics/fields/test_histogram.py +++ b/tests/sentry/snuba/metrics/fields/test_histogram.py @@ -1,11 +1,27 @@ +from typing import TypedDict + import pytest +from _pytest.mark.structures import ParameterSet + +from sentry.snuba.metrics.fields.histogram import ClickhouseHistogram, rebucket_histogram + -from sentry.snuba.metrics.fields.histogram import rebucket_histogram +class _CallKwargs(TypedDict): + data: ClickhouseHistogram + histogram_from: float | None + histogram_to: float | None + histogram_buckets: int -def _call(data, histogram_from, histogram_to, histogram_buckets, output): +def _call( + data: ClickhouseHistogram, + histogram_from: float | None, + histogram_to: float | None, + histogram_buckets: int, + output: ClickhouseHistogram, +) -> ParameterSet: return pytest.param( - dict( + _CallKwargs( data=data, histogram_from=histogram_from, histogram_to=histogram_to, @@ -28,5 +44,5 @@ def _call(data, histogram_from, histogram_to, histogram_buckets, output): _call([(1, 2, 3)], 0, 2, 1, output=[(0.0, 2.0, 3)]), ], ) -def test_basic(kwargs, output) -> None: +def test_basic(kwargs: _CallKwargs, output: ClickhouseHistogram) -> None: assert rebucket_histogram(**kwargs) == output diff --git a/tests/sentry/snuba/metrics/test_extraction.py b/tests/sentry/snuba/metrics/test_extraction.py index f71a840895e4..83d75a60f580 100644 --- a/tests/sentry/snuba/metrics/test_extraction.py +++ b/tests/sentry/snuba/metrics/test_extraction.py @@ -1,8 +1,11 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from sentry.api.event_search import ParenExpression, parse_search_query +from sentry.models.project import Project +from sentry.relay.types import RuleCondition +from sentry.snuba.dataset import Dataset from sentry.snuba.metrics.extraction import ( OnDemandMetricSpec, SearchQueryConverter, @@ -19,7 +22,7 @@ @django_db_all -def test_equality_of_specs(default_project) -> None: +def test_equality_of_specs(default_project: Project) -> None: spec_1 = OnDemandMetricSpec("count()", "issue:FOO", groupbys=["release", "country_code"]) spec_2 = OnDemandMetricSpec("count()", "issue:FOO", groupbys=["country_code", "release"]) assert are_specs_equal( @@ -27,7 +30,9 @@ def test_equality_of_specs(default_project) -> None: ) -def create_spec_if_needed(dataset, agg, query) -> OnDemandMetricSpec | None: +def create_spec_if_needed( + dataset: str | Dataset | None, agg: str, query: str +) -> OnDemandMetricSpec | None: return ( OnDemandMetricSpec(agg, query) if should_use_on_demand_metrics(dataset, agg, query) @@ -39,7 +44,7 @@ def create_spec_if_needed(dataset, agg, query) -> OnDemandMetricSpec | None: "percentile", [0.5, 0.75, 0.9, 0.95, 0.99], ) -def test_spec_equivalence_with_percentiles(percentile) -> None: +def test_spec_equivalence_with_percentiles(percentile: float) -> None: fixed_percentile = f"p{int(percentile * 100)}" spec_1 = OnDemandMetricSpec(f"{fixed_percentile}(measurements.fp)", "transaction.duration:>1s") @@ -308,7 +313,7 @@ def test_spec_wildcard() -> None: ("title:*?dispatch*", "backend ?dispatch", r"*\?dispatch*"), ], ) -def test_spec_wildcard_escaping(query, title, expected_pattern) -> None: +def test_spec_wildcard_escaping(query: str, title: str, expected_pattern: str) -> None: spec = OnDemandMetricSpec("count()", query) assert spec._metric_type == "c" @@ -465,7 +470,7 @@ def test_spec_ignore_timestamp() -> None: @django_db_all -def test_spec_failure_count(default_project) -> None: +def test_spec_failure_count(default_project: Project) -> None: spec = OnDemandMetricSpec("failure_count()", "transaction.duration:>1s") assert spec._metric_type == "c" @@ -476,7 +481,7 @@ def test_spec_failure_count(default_project) -> None: @django_db_all -def test_spec_failure_rate(default_project) -> None: +def test_spec_failure_rate(default_project: Project) -> None: spec = OnDemandMetricSpec("failure_rate()", "transaction.duration:>1s") assert spec._metric_type == "c" @@ -488,7 +493,7 @@ def test_spec_failure_rate(default_project) -> None: @django_db_all @patch("sentry.snuba.metrics.extraction._get_satisfactory_metric") -def test_spec_apdex(_get_satisfactory_metric, default_project) -> None: +def test_spec_apdex(_get_satisfactory_metric: MagicMock, default_project: Project) -> None: _get_satisfactory_metric.return_value = "transaction.duration" spec = OnDemandMetricSpec("apdex(10)", "release:a") @@ -502,7 +507,7 @@ def test_spec_apdex(_get_satisfactory_metric, default_project) -> None: @django_db_all @patch("sentry.snuba.metrics.extraction._get_satisfactory_metric") -def test_spec_apdex_decimal(_get_satisfactory_metric, default_project) -> None: +def test_spec_apdex_decimal(_get_satisfactory_metric: MagicMock, default_project: Project) -> None: _get_satisfactory_metric.return_value = "transaction.duration" spec = OnDemandMetricSpec("apdex(0.8)", "release:a") @@ -515,7 +520,7 @@ def test_spec_apdex_decimal(_get_satisfactory_metric, default_project) -> None: @django_db_all -def test_spec_epm(default_project) -> None: +def test_spec_epm(default_project: Project) -> None: spec = OnDemandMetricSpec("epm()", "transaction.duration:>1s") assert spec._metric_type == "c" @@ -526,7 +531,7 @@ def test_spec_epm(default_project) -> None: @django_db_all -def test_spec_eps(default_project) -> None: +def test_spec_eps(default_project: Project) -> None: spec = OnDemandMetricSpec("eps()", "transaction.duration:>1s") assert spec._metric_type == "c" @@ -564,7 +569,7 @@ def test_cleanup_equivalent_specs() -> None: "(isNightlyBuild:true OR environment:production)", ], ) -def test_cleanup_with_environment_injection(query) -> None: +def test_cleanup_with_environment_injection(query: str) -> None: # We are simulating the transformation that the frontend performs in the query, since they add the # AND (`event.type:transaction`) at the end. field = "count()" @@ -587,7 +592,9 @@ def test_cleanup_with_environment_injection(query) -> None: @django_db_all @patch("sentry.snuba.metrics.extraction._get_satisfactory_metric") -def test_spec_apdex_without_condition(_get_satisfactory_metric, default_project) -> None: +def test_spec_apdex_without_condition( + _get_satisfactory_metric: MagicMock, default_project: Project +) -> None: _get_satisfactory_metric.return_value = "transaction.duration" spec = OnDemandMetricSpec("apdex(10)", "") @@ -613,7 +620,7 @@ def test_spec_custom_tag() -> None: "(release:a OR (transaction.op:b and browser.version:1)) transaction.duration:>1s", ], ) -def test_query_tokens_to_string(query) -> None: +def test_query_tokens_to_string(query: str) -> None: tokens = parse_search_query(query) new_query = query_tokens_to_string(tokens) new_tokens = parse_search_query(new_query) @@ -634,7 +641,7 @@ def test_query_tokens_to_string(query) -> None: ), ], ) -def test_to_standard_metrics_query(dirty, clean) -> None: +def test_to_standard_metrics_query(dirty: str, clean: str) -> None: cleaned_up_query = to_standard_metrics_query(dirty) cleaned_up_tokens = parse_search_query(cleaned_up_query) clean_tokens = parse_search_query(clean) @@ -658,7 +665,7 @@ def test_to_standard_metrics_query(dirty, clean) -> None: ), ], ) -def test_search_query_converter(query, expected) -> None: +def test_search_query_converter(query: str, expected: RuleCondition) -> None: tokens = parse_search_query(query) converter = SearchQueryConverter(tokens) condition = converter.convert() @@ -683,7 +690,7 @@ def test_search_query_converter(query, expected) -> None: (" (AND) And (And) Or release:initial or (and) or", "release:initial"), ], ) -def test_cleanup_query(dirty, clean) -> None: +def test_cleanup_query(dirty: str, clean: str) -> None: dirty_tokens = parse_search_query(dirty) clean_tokens = parse_search_query(clean) actual_clean = cleanup_search_query(dirty_tokens) diff --git a/tests/sentry/snuba/metrics/test_naming_layer.py b/tests/sentry/snuba/metrics/test_naming_layer.py index bc18b5eed230..7f8c4787b829 100644 --- a/tests/sentry/snuba/metrics/test_naming_layer.py +++ b/tests/sentry/snuba/metrics/test_naming_layer.py @@ -18,7 +18,7 @@ "foo_bar.bar", ], ) -def test_valid_public_name_regex(name) -> None: +def test_valid_public_name_regex(name: str) -> None: matches = re.compile(rf"^{PUBLIC_NAME_REGEX}$").match(name) assert matches assert matches[0] == name @@ -38,7 +38,7 @@ def test_valid_public_name_regex(name) -> None: "session.09_crashed", ], ) -def test_invalid_public_name_regex(name) -> None: +def test_invalid_public_name_regex(name: str) -> None: assert re.compile(rf"^{PUBLIC_NAME_REGEX}$").match(name) is None @@ -71,7 +71,7 @@ def test_invalid_public_name_regex(name) -> None: ), ], ) -def test_parse_mri_with_valid_mri(name, expected) -> None: +def test_parse_mri_with_valid_mri(name: str, expected: ParsedMRI) -> None: parsed_mri = parse_mri(name) assert parsed_mri is not None assert parsed_mri == expected @@ -90,7 +90,7 @@ def test_parse_mri_with_valid_mri(name, expected) -> None: ":/@", ], ) -def test_parse_mri_with_invalid_mri(name) -> None: +def test_parse_mri_with_invalid_mri(name: str) -> None: parsed_mri = parse_mri(name) assert parsed_mri is None @@ -116,5 +116,5 @@ def test_parse_mri_with_invalid_mri(name) -> None: ), ], ) -def test_is_custom_measurement(parsed_mri, expected) -> None: +def test_is_custom_measurement(parsed_mri: ParsedMRI, expected: bool) -> None: assert is_custom_measurement(parsed_mri) == expected diff --git a/tests/sentry/snuba/metrics/test_query.py b/tests/sentry/snuba/metrics/test_query.py index e6c3ee3e7e71..521e8a69baf4 100644 --- a/tests/sentry/snuba/metrics/test_query.py +++ b/tests/sentry/snuba/metrics/test_query.py @@ -3,6 +3,7 @@ import re from collections.abc import Sequence from datetime import datetime, timedelta +from typing import Any import pytest from snuba_sdk import Direction, Granularity, Limit, Offset @@ -29,7 +30,7 @@ class MetricsQueryBuilder: AVG_DURATION_METRIC = MetricField(op="avg", metric_mri=SessionMRI.DURATION.value) - def __init__(self): + def __init__(self) -> None: now = datetime.now() self.org_id: int = 1 self.project_ids: Sequence[int] = [1, 2] @@ -96,7 +97,7 @@ def with_interval(self, interval: int) -> MetricsQueryBuilder: self.interval = interval return self - def to_metrics_query_dict(self): + def to_metrics_query_dict(self) -> dict[str, Any]: return { "org_id": self.org_id, "project_ids": self.project_ids, @@ -556,7 +557,7 @@ def test_series_and_totals_validation() -> None: ), ], ) -def test_granularity_validation(stats_period, interval, error_message) -> None: +def test_granularity_validation(stats_period: str, interval: str, error_message: str) -> None: period_t = parse_stats_period(stats_period) assert period_t is not None interval_t = parse_stats_period(interval) @@ -605,7 +606,7 @@ def test_validate_metric_field_mri() -> None: ), ], ) -def test_validate_interval(select, interval, series) -> None: +def test_validate_interval(select: list[MetricField] | None, interval: int, series: bool) -> None: metrics_query = MetricsQueryBuilder().with_include_series(series) if select: metrics_query = metrics_query.with_select(select) @@ -671,8 +672,11 @@ def test_ensure_interval_set_to_granularity_in_performance_queries() -> None: ], ) def test_start_end_interval_greater_than_interval_is_successful( - start_time_delta, granularity, interval, expected_granularity -): + start_time_delta: timedelta, + granularity: int, + interval: int, + expected_granularity: int, +) -> None: metrics_query = ( MetricsQueryBuilder() .with_select([MetricField(op="p95", metric_mri="d:transactions/duration@millisecond")]) @@ -683,6 +687,7 @@ def test_start_end_interval_greater_than_interval_is_successful( # We set the start time with a different value from default in order to have the start and end interval greater # than the specified interval (e.g., if you have an interval of 2 hours and the difference between start and end is # less than 2 hours, it doesn't make sense to query the data). + assert metrics_query.end is not None metrics_query.start = metrics_query.end - start_time_delta metrics_query_dict = metrics_query.to_metrics_query_dict() diff --git a/tests/sentry/snuba/metrics/test_query_builder.py b/tests/sentry/snuba/metrics/test_query_builder.py index d91954df572f..ccdb852ee606 100644 --- a/tests/sentry/snuba/metrics/test_query_builder.py +++ b/tests/sentry/snuba/metrics/test_query_builder.py @@ -2,9 +2,10 @@ import re import types +from collections.abc import Callable from dataclasses import replace from datetime import datetime, timedelta, timezone -from typing import Any +from typing import Any, cast from unittest import mock import pytest @@ -94,7 +95,7 @@ def PseudoProject(organization_id: int, id: int) -> Any: # TODO: use real proje USE_CASE_ID = UseCaseID.SESSIONS -def get_entity_of_metric_mocked(_, metric_name, use_case_id): +def get_entity_of_metric_mocked(_: object, metric_name: str, use_case_id: UseCaseID) -> str: return { "sentry.sessions.session": EntityKey.MetricsCounters, SessionMRI.RAW_SESSION.value: EntityKey.MetricsCounters, @@ -248,7 +249,7 @@ def get_entity_of_metric_mocked(_, metric_name, use_case_id): ), ], ) -def test_parse_conditions(query_string, expected) -> None: +def test_parse_conditions(query_string: str, expected: Callable[[], object]) -> None: org_id = ORG_ID use_case_id = UseCaseID.SESSIONS for s in ("myapp@2.0.0", "/bar/:orgId/"): @@ -297,14 +298,19 @@ def test_round_range() -> None: ("2022-10-01 09:20:00", "2h", {"timeframe": "91d"}, ("08:00:00 07-02", "10:00:00 10-01")), ], ) -def test_get_date_range(now, interval, parameters, expected) -> None: - def _to_datetimestring(d): +def test_get_date_range( + now: str, + interval: str | None, + parameters: dict[str, str], + expected: tuple[str, str], +) -> None: + def _to_datetimestring(d: datetime) -> str: return d.strftime("%H:%M:%S %m-%d") if interval is not None: parameters["interval"] = interval with freeze_time(now): - start, end, interval = get_date_range(parameters) + start, end, _rollup = get_date_range(parameters) start_actual = _to_datetimestring(start) end_actual = _to_datetimestring(end) @@ -375,11 +381,16 @@ def test_build_snuba_query(mock_now: mock.MagicMock, mock_now2: mock.MagicMock) org_id = 1 use_case_id = UseCaseID.SESSIONS - def expected_query(match, select, extra_groupby, metric_name): + def expected_query( + match: str, + select: tuple[str, str, str], + extra_groupby: list[Column], + metric_name: str, + ) -> Query: function, column, alias = select select_function = Function( - OP_TO_SNUBA_FUNCTION[match][alias], + cast("dict[str, dict[str, str]]", OP_TO_SNUBA_FUNCTION)[match][alias], [ Column("value"), Function( @@ -483,7 +494,7 @@ def expected_query(match, select, extra_groupby, metric_name): @mock.patch( "sentry.snuba.metrics.fields.base._get_entity_of_metric_mri", get_entity_of_metric_mocked ) -def test_build_snuba_query_mri(mock_now, mock_now2) -> None: +def test_build_snuba_query_mri(mock_now: mock.MagicMock, mock_now2: mock.MagicMock) -> None: org_id = 1 use_case_id = UseCaseID.SESSIONS # Your typical release health query querying everything @@ -566,7 +577,9 @@ def test_build_snuba_query_mri(mock_now, mock_now2) -> None: @mock.patch( "sentry.snuba.metrics.fields.base._get_entity_of_metric_mri", get_entity_of_metric_mocked ) -def test_build_snuba_query_derived_metrics(mock_now, mock_now2) -> None: +def test_build_snuba_query_derived_metrics( + mock_now: mock.MagicMock, mock_now2: mock.MagicMock +) -> None: org_id = 1 use_case_id = UseCaseID.SESSIONS # Your typical release health query querying everything @@ -970,7 +983,7 @@ def test_translate_results_derived_metrics(_1: mock.MagicMock, _2: mock.MagicMoc } ) query_definition = QueryDefinition([PseudoProject(1, 1)], query_params) - fields_in_entities: dict[MetricEntity, list[tuple[str | None, str, str]]] = { + fields_in_entities: dict[MetricEntity, list[tuple[MetricOperationType | None, str, str]]] = { "metrics_counters": [ ( None, @@ -997,7 +1010,7 @@ def test_translate_results_derived_metrics(_1: mock.MagicMock, _2: mock.MagicMoc intervals = list( get_intervals(query_definition.start, query_definition.end, query_definition.rollup) ) - results = { + results: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = { "metrics_counters": { "totals": { "data": [ @@ -1091,13 +1104,13 @@ def test_translate_results_missing_slots(_1: mock.MagicMock, _2: mock.MagicMock) } ) query_definition = QueryDefinition([PseudoProject(1, 1)], query_params) - fields_in_entities: dict[MetricEntity, list[tuple[str | None, str, str]]] = { + fields_in_entities: dict[MetricEntity, list[tuple[MetricOperationType | None, str, str]]] = { "metrics_counters": [ ("sum", SessionMRI.RAW_SESSION.value, "sum(sentry.sessions.session)"), ], } - results = { + results: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = { "metrics_counters": { "totals": { "data": [ @@ -1388,8 +1401,11 @@ def test_translate_meta_result_type_composite_entity_derived_metric(_: mock.Magi ], ) def test_only_can_groupby_operations_can_be_added_to_groupby( - select, groupby, usecase, error_string -): + select: list[MetricField], + groupby: list[MetricGroupByField] | None, + usecase: UseCaseID, + error_string: str, +) -> None: query_definition = DeprecatingMetricsQuery( org_id=1, project_ids=[1], @@ -1498,7 +1514,10 @@ def test_only_can_groupby_operations_can_be_added_to_groupby( ], ) def test_only_can_filter_operations_can_be_added_to_where( - select, where, usecase, error_string + select: list[MetricField], + where: list[MetricConditionField], + usecase: UseCaseID, + error_string: str, ) -> None: query_definition = DeprecatingMetricsQuery( org_id=1, @@ -2033,7 +2052,7 @@ def test_timestamp_operators(op: MetricOperationType, clickhouse_op: str) -> Non [False, True], ], ) -def test_having_clause(include_totals, include_series) -> None: +def test_having_clause(include_totals: bool, include_series: bool) -> None: """ Tests that the having clause ends up in the snql queries in the expected form """ diff --git a/tests/sentry/snuba/metrics/test_utils.py b/tests/sentry/snuba/metrics/test_utils.py index f573b672e832..3cb41c54eb4e 100644 --- a/tests/sentry/snuba/metrics/test_utils.py +++ b/tests/sentry/snuba/metrics/test_utils.py @@ -161,7 +161,9 @@ test_get_num_intervals_cases, ids=[x[5] for x in test_get_num_intervals_cases], ) -def test_get_num_intervals(start, end, granularity, interval, expected, test_message) -> None: +def test_get_num_intervals( + start: str, end: str, granularity: int, interval: int, expected: int, test_message: str +) -> None: start_date = datetime.fromisoformat(start) end_date = datetime.fromisoformat(end) @@ -272,7 +274,9 @@ def test_get_num_intervals(start, end, granularity, interval, expected, test_mes test_get_intervals_cases, ids=[x[4] for x in test_get_intervals_cases], ) -def test_get_intervals(start, end, granularity, interval, test_message, expected) -> None: +def test_get_intervals( + start: str, end: str, granularity: int, interval: int, test_message: str, expected: list[str] +) -> None: start_date = datetime.fromisoformat(start) end_date = datetime.fromisoformat(end) @@ -284,7 +288,7 @@ def test_get_intervals(start, end, granularity, interval, test_message, expected assert actual == expected, test_message -def _to_timestring(d): +def _to_timestring(d: datetime) -> str: return d.strftime("%H:%M:%S %Z") @@ -352,8 +356,14 @@ def _to_timestring(d): ids=[x[6] for x in test_to_intervals_cases], ) def test_to_intervals( - start, end, interval, expected_start, expected_end, expected_num_intervals, test_message -): + start: str, + end: str, + interval: int, + expected_start: str, + expected_end: str, + expected_num_intervals: int, + test_message: str, +) -> None: start_date = datetime.fromisoformat(start) end_date = datetime.fromisoformat(end) diff --git a/tests/sentry/snuba/test_discover_query.py b/tests/sentry/snuba/test_discover_query.py index c5491b5b6ca3..f9be6c5f3c38 100644 --- a/tests/sentry/snuba/test_discover_query.py +++ b/tests/sentry/snuba/test_discover_query.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable from datetime import timedelta import pytest @@ -8,6 +9,7 @@ from sentry.discover.arithmetic import ArithmeticValidationError from sentry.discover.models import TeamKeyTransaction from sentry.exceptions import InvalidSearchQuery +from sentry.models.project import Project from sentry.models.projectteam import ProjectTeam from sentry.models.releaseprojectenvironment import ReleaseStages from sentry.models.transaction_threshold import ( @@ -181,10 +183,12 @@ def test_missing_project(self) -> None: assert [item["project"] for item in data] == ["a" * 32, "z" * 32] def test_issue_short_id_mapping(self) -> None: + group = self.event.group + assert group is not None tests = [ - ("issue", f"issue:{self.event.group.qualified_short_id}"), + ("issue", f"issue:{group.qualified_short_id}"), ("issue", f"issue.id:{self.event.group_id}"), - ("issue.id", f"issue:{self.event.group.qualified_short_id}"), + ("issue.id", f"issue:{group.qualified_short_id}"), ("issue.id", f"issue.id:{self.event.group_id}"), ] @@ -202,10 +206,12 @@ def test_issue_short_id_mapping(self) -> None: assert [item["issue.id"] for item in data] == [self.event.group_id] def test_issue_filters(self) -> None: + group = self.event.group + assert group is not None tests = [ "has:issue", "has:issue.id", - f"issue:[{self.event.group.qualified_short_id}]", + f"issue:[{group.qualified_short_id}]", f"issue.id:[{self.event.group_id}]", ] @@ -581,20 +587,20 @@ def test_team_key_transactions(self) -> None: ] for transaction in transactions: - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=(5)), ) - data["transaction"] = transaction - self.store_event(data, project_id=self.project.id) + event_data["transaction"] = transaction + self.store_event(event_data, project_id=self.project.id) for team, transaction in key_transactions: - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=(5)), ) - data["transaction"] = transaction - self.store_event(data, project_id=self.project.id) + event_data["transaction"] = transaction + self.store_event(event_data, project_id=self.project.id) TeamKeyTransaction.objects.create( organization=self.organization, transaction=transaction, @@ -654,14 +660,14 @@ def test_snql_wip_project_threshold_config(self) -> None: ("d" * 10, 3000), ] for idx, event in enumerate(events): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=(3 + idx)), start_timestamp=before_now(minutes=(3 + idx), milliseconds=event[1]), ) - data["event_id"] = f"{idx}" * 32 - data["transaction"] = event[0] - self.store_event(data, project_id=self.project.id) + event_data["event_id"] = f"{idx}" * 32 + event_data["transaction"] = event[0] + self.store_event(event_data, project_id=self.project.id) if idx % 2: ProjectTransactionThresholdOverride.objects.create( @@ -672,11 +678,11 @@ def test_snql_wip_project_threshold_config(self) -> None: metric=TransactionMetric.DURATION.value, ) - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=3), start_timestamp=before_now(minutes=4) ) - data["transaction"] = "e" * 10 - self.store_event(data, project_id=project2.id) + event_data["transaction"] = "e" * 10 + self.store_event(event_data, project_id=project2.id) expected_transaction = ["a" * 10, "b" * 10, "c" * 10, "d" * 10, "e" * 10] expected_project_threshold_config = [ @@ -745,20 +751,20 @@ def test_to_other_function(self) -> None: project = self.create_project() for i in range(3): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = f"/to_other/{i}" - data["release"] = "aaaa" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = f"/to_other/{i}" + event_data["release"] = "aaaa" + self.store_event(event_data, project_id=project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/to_other/y" - data["release"] = "yyyy" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/to_other/y" + event_data["release"] = "yyyy" + self.store_event(event_data, project_id=project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/to_other/z" - data["release"] = "zzzz" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/to_other/z" + event_data["release"] = "zzzz" + self.store_event(event_data, project_id=project.id) columns1 = ["transaction", 'to_other(release,"aaaa")'] columns2 = ["transaction", 'to_other(release,"aaaa",old,new)'] @@ -787,17 +793,17 @@ def test_to_other_function(self) -> None: def test_count_if_function(self) -> None: for i in range(3): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["release"] = "aaaa" - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["release"] = "aaaa" + self.store_event(event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["release"] = "bbbb" - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["release"] = "bbbb" + self.store_event(event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["release"] = "cccc" - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["release"] = "cccc" + self.store_event(event_data, project_id=self.project.id) columns1 = ["count()", "count_if(release,equals,aaaa)", "count_if(release,notEquals,aaaa)"] columns2 = ["count()", "count_if(release,less,bbbb)", "count_if(release,lessOrEquals,bbbb)"] @@ -843,13 +849,13 @@ def test_count_if_function_with_unicode(self) -> None: unicode_phrase1 = "\u716e\u6211\u66f4\u591a\u7684\u98df\u7269\uff0c\u6211\u9913\u4e86" unicode_phrase2 = "\u53cd\u6b63\u611b\u60c5\u4e0d\u5c31\u90a3\u6837" for i in range(3): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["release"] = unicode_phrase1 - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["release"] = unicode_phrase1 + self.store_event(event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["release"] = unicode_phrase2 - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["release"] = unicode_phrase2 + self.store_event(event_data, project_id=self.project.id) columns1 = [ "count()", @@ -888,25 +894,25 @@ def test_count_if_function_with_unicode(self) -> None: def test_failure_count_function(self) -> None: project = self.create_project() - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_count/success" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_count/success" + self.store_event(event_data, project_id=project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_count/unknown" - data["contexts"]["trace"]["status"] = "unknown_error" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_count/unknown" + event_data["contexts"]["trace"]["status"] = "unknown_error" + self.store_event(event_data, project_id=project.id) for i in range(6): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = f"/failure_count/{i}" - data["contexts"]["trace"]["status"] = "unauthenticated" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = f"/failure_count/{i}" + event_data["contexts"]["trace"]["status"] = "unauthenticated" + self.store_event(event_data, project_id=project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_count/0" - data["contexts"]["trace"]["status"] = "unauthenticated" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_count/0" + event_data["contexts"]["trace"]["status"] = "unauthenticated" + self.store_event(event_data, project_id=project.id) queries = [ ("", 8, True), @@ -966,20 +972,22 @@ def test_apdex_function(self) -> None: ("zorp", 3000), ] for idx, event in enumerate(events): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=(5 + idx)), start_timestamp=before_now(minutes=(5 + idx), milliseconds=event[1]), ) - data["measurements"]["lcp"]["value"] = 3000 - data["event_id"] = f"{idx}" * 32 - data["transaction"] = f"/apdex/{event[0]}" - data["user"] = {"email": f"{idx}@example.com"} + event_data["measurements"]["lcp"]["value"] = 3000 + event_data["event_id"] = f"{idx}" * 32 + event_data["transaction"] = f"/apdex/{event[0]}" + event_data["user"] = {"email": f"{idx}@example.com"} if event[0] == "zorp": - self.store_event(data, project_id=project2.id) # No custom thresholds for project2 + self.store_event( + event_data, project_id=project2.id + ) # No custom thresholds for project2 else: - self.store_event(data, project_id=project.id) + self.store_event(event_data, project_id=project.id) queries = [ ("", [0.5, 0.5, 0.25, 0.0, 0.25], ["apdex(100)"], "apdex_100"), @@ -1041,20 +1049,22 @@ def test_count_miserable_function(self) -> None: ("zorp", 3000), ] for idx, event in enumerate(events): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=(5 + idx)), start_timestamp=before_now(minutes=(5 + idx), milliseconds=event[1]), ) - data["measurements"]["lcp"]["value"] = 3000 - data["event_id"] = f"{idx}" * 32 - data["transaction"] = f"/count_miserable/{event[0]}" - data["user"] = {"email": f"{idx}@example.com"} + event_data["measurements"]["lcp"]["value"] = 3000 + event_data["event_id"] = f"{idx}" * 32 + event_data["transaction"] = f"/count_miserable/{event[0]}" + event_data["user"] = {"email": f"{idx}@example.com"} if event[0] == "zorp": - self.store_event(data, project_id=project2.id) # No custom thresholds for project2 + self.store_event( + event_data, project_id=project2.id + ) # No custom thresholds for project2 else: - self.store_event(data, project_id=project.id) + self.store_event(event_data, project_id=project.id) queries = [ ( @@ -1132,20 +1142,22 @@ def test_user_misery_function(self) -> None: ("zorp", 3000), ] for idx, event in enumerate(events): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=(5 + idx)), start_timestamp=before_now(minutes=(5 + idx), milliseconds=event[1]), ) - data["measurements"]["lcp"]["value"] = 3000 - data["event_id"] = f"{idx}" * 32 - data["transaction"] = f"/user_misery/{event[0]}" - data["user"] = {"email": f"{idx}@example.com"} + event_data["measurements"]["lcp"]["value"] = 3000 + event_data["event_id"] = f"{idx}" * 32 + event_data["transaction"] = f"/user_misery/{event[0]}" + event_data["user"] = {"email": f"{idx}@example.com"} if event[0] == "zorp": - self.store_event(data, project_id=project2.id) # No custom thresholds for project2 + self.store_event( + event_data, project_id=project2.id + ) # No custom thresholds for project2 else: - self.store_event(data, project_id=project.id) + self.store_event(event_data, project_id=project.id) queries = [ ( @@ -1169,7 +1181,7 @@ def test_user_misery_function(self) -> None: ), ] - similar = lambda a, b: abs(a - b) < 0.001 + similar: Callable[[float, float], bool] = lambda a, b: abs(a - b) < 0.001 for query, expected_user_misery, col, alias in queries: result = discover.query( @@ -1195,13 +1207,13 @@ def test_count(self) -> None: project = self.create_project() for i in range(6): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/count/6" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/count/6" + self.store_event(event_data, project_id=project.id) for i in range(8): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/count/8" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/count/8" + self.store_event(event_data, project_id=project.id) queries = [ ("", 2, (6, 8), True), @@ -1232,13 +1244,13 @@ def test_compare_numeric_aggregate_function(self) -> None: project = self.create_project() for i in range(6): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=3), start_timestamp=before_now(minutes=4 + i), ) - data["transaction"] = "/percentile" - self.store_event(data, project_id=project.id) + event_data["transaction"] = "/percentile" + self.store_event(event_data, project_id=project.id) fields = [ ( @@ -1289,14 +1301,14 @@ def test_last_seen(self) -> None: expected_timestamp = before_now(minutes=3) string_condition_timestamp = before_now(minutes=4).strftime("%Y-%m-%dT%H:%M:%S+00:00") - data = load_data("transaction", timestamp=expected_timestamp) - data["transaction"] = "/last_seen" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=expected_timestamp) + event_data["transaction"] = "/last_seen" + self.store_event(event_data, project_id=project.id) for i in range(6): - data = load_data("transaction", timestamp=before_now(minutes=i + 4)) - data["transaction"] = "/last_seen" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=i + 4)) + event_data["transaction"] = "/last_seen" + self.store_event(event_data, project_id=project.id) queries = [ ("", 1, True), @@ -1326,14 +1338,14 @@ def test_latest_event(self) -> None: project = self.create_project() expected_timestamp = before_now(minutes=3) - data = load_data("transaction", timestamp=expected_timestamp) - data["transaction"] = "/latest_event" - stored_event = self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=expected_timestamp) + event_data["transaction"] = "/latest_event" + stored_event = self.store_event(event_data, project_id=project.id) for i in range(6): - data = load_data("transaction", timestamp=before_now(minutes=i + 4)) - data["transaction"] = "/latest_event" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=i + 4)) + event_data["transaction"] = "/latest_event" + self.store_event(event_data, project_id=project.id) result = discover.query( selected_columns=["transaction", "latest_event()"], @@ -1356,23 +1368,23 @@ def test_failure_rate(self) -> None: project = self.create_project() for i in range(6): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_rate/over" - data["contexts"]["trace"]["status"] = "unauthenticated" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_rate/over" + event_data["contexts"]["trace"]["status"] = "unauthenticated" + self.store_event(event_data, project_id=project.id) for i in range(4): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_rate/over" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_rate/over" + self.store_event(event_data, project_id=project.id) for i in range(7): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_rate/under" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_rate/under" + self.store_event(event_data, project_id=project.id) for i in range(3): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_rate/under" - data["contexts"]["trace"]["status"] = "unauthenticated" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_rate/under" + event_data["contexts"]["trace"]["status"] = "unauthenticated" + self.store_event(event_data, project_id=project.id) queries = [ ("", 2, True), @@ -1400,17 +1412,17 @@ def test_failure_rate(self) -> None: if expected_length > 1: assert data[1]["failure_rate"] == 0.3 - def _create_percentile_events(self, project): + def _create_percentile_events(self, project: Project) -> None: for i in range(6): start = before_now(minutes=3) end = start - timedelta(minutes=1 + i) - data = load_data( + event_data = load_data( "transaction", timestamp=start, start_timestamp=end, ) - data["transaction"] = "/p50" - self.store_event(data, project_id=project.id) + event_data["transaction"] = "/p50" + self.store_event(event_data, project_id=project.id) def test_percentile(self) -> None: project = self.create_project() @@ -1622,15 +1634,15 @@ def test_p100_with_measurement(self) -> None: project = self.create_project() for i in range(6): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=3), start_timestamp=before_now(minutes=4 + i), ) - data["transaction"] = "/p100" - data["measurements"]["frames_total"] = {"value": 100 * i} - data["measurements"]["frames_slow"] = {"value": 50 * i} - self.store_event(data, project_id=project.id) + event_data["transaction"] = "/p100" + event_data["measurements"]["frames_total"] = {"value": 100 * i} + event_data["measurements"]["frames_slow"] = {"value": 50 * i} + self.store_event(event_data, project_id=project.id) queries = [ ("", 1, True), @@ -1663,13 +1675,13 @@ def test_p100_with_measurement(self) -> None: def test_count_unique(self) -> None: for idx in range(3): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=3), ) - data["user"] = {"email": f"{idx}@example.com"} - data["tags"] = {"foo": "bar" if idx < 1 else "baz"} - self.store_event(data, project_id=self.project.id) + event_data["user"] = {"email": f"{idx}@example.com"} + event_data["tags"] = {"foo": "bar" if idx < 1 else "baz"} + self.store_event(event_data, project_id=self.project.id) result = discover.query( selected_columns=["count_unique(user.display)", "count_unique(foo)"], @@ -1693,12 +1705,12 @@ def test_min_max(self) -> None: for idx in range(3): start = before_now(minutes=3) end = start - timedelta(minutes=1 + idx) - data = load_data( + event_data = load_data( "transaction", timestamp=start, start_timestamp=end, ) - self.store_event(data, project_id=self.project.id) + self.store_event(event_data, project_id=self.project.id) result = discover.query( selected_columns=[ @@ -1724,12 +1736,12 @@ def test_stats_functions(self) -> None: for idx in range(3): start = before_now(minutes=3) end = start - timedelta(minutes=1 + idx) - data = load_data( + event_data = load_data( "transaction", timestamp=start, start_timestamp=end, ) - self.store_event(data, project_id=self.project.id) + self.store_event(event_data, project_id=self.project.id) queries = [ ("var(transaction.duration)", "var_transaction_duration", 3600000000), @@ -1769,12 +1781,12 @@ def test_count_at_least(self) -> None: start_one_minute = end - timedelta(minutes=1) start_two_minute = end - timedelta(minutes=2) for idx in range(3): - data = load_data( + event_data = load_data( "transaction", timestamp=end, start_timestamp=start_one_minute if idx < 1 else start_two_minute, ) - self.store_event(data, project_id=self.project.id) + self.store_event(event_data, project_id=self.project.id) result = discover.query( selected_columns=[ @@ -1800,12 +1812,12 @@ def test_eps(self) -> None: project = self.create_project() for _ in range(6): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=3), ) - data["transaction"] = "/eps" - self.store_event(data, project_id=project.id) + event_data["transaction"] = "/eps" + self.store_event(event_data, project_id=project.id) queries = [ ("", 1, True), @@ -1853,12 +1865,12 @@ def test_epm(self) -> None: project = self.create_project() for _ in range(6): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=3), ) - data["transaction"] = "/epm" - self.store_event(data, project_id=project.id) + event_data["transaction"] = "/epm" + self.store_event(event_data, project_id=project.id) queries = [ ("", 1, True), @@ -1903,20 +1915,20 @@ def test_epm(self) -> None: assert data[0]["tpm_60"] == 6 def test_transaction_status(self) -> None: - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["transaction"] = "/test_transaction/success" - data["contexts"]["trace"]["status"] = "ok" - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["transaction"] = "/test_transaction/success" + event_data["contexts"]["trace"]["status"] = "ok" + self.store_event(event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["transaction"] = "/test_transaction/aborted" - data["contexts"]["trace"]["status"] = "aborted" - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["transaction"] = "/test_transaction/aborted" + event_data["contexts"]["trace"]["status"] = "aborted" + self.store_event(event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["transaction"] = "/test_transaction/already_exists" - data["contexts"]["trace"]["status"] = "already_exists" - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["transaction"] = "/test_transaction/already_exists" + event_data["contexts"]["trace"]["status"] = "already_exists" + self.store_event(event_data, project_id=self.project.id) result = discover.query( selected_columns=["transaction.status"], @@ -1933,18 +1945,18 @@ def test_transaction_status(self) -> None: } == {0, 10, 6} def test_transaction_status_filter(self) -> None: - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["transaction"] = "/test_transaction/success" - data["contexts"]["trace"]["status"] = "ok" - self.store_event(data, project_id=self.project.id) - self.store_event(data, project_id=self.project.id) - - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["transaction"] = "/test_transaction/already_exists" - data["contexts"]["trace"]["status"] = "already_exists" - self.store_event(data, project_id=self.project.id) - - def run_query(query, expected_statuses, message): + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["transaction"] = "/test_transaction/success" + event_data["contexts"]["trace"]["status"] = "ok" + self.store_event(event_data, project_id=self.project.id) + self.store_event(event_data, project_id=self.project.id) + + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["transaction"] = "/test_transaction/already_exists" + event_data["contexts"]["trace"]["status"] = "already_exists" + self.store_event(event_data, project_id=self.project.id) + + def run_query(query: str, expected_statuses: list[int], message: str) -> None: result = discover.query( selected_columns=["transaction.status"], query=query, @@ -1977,18 +1989,18 @@ def run_query(query, expected_statuses, message): run_query("!has:transaction.status", [], "status nonexistant") def test_error_handled_alias(self) -> None: - data = load_data("android-ndk", timestamp=before_now(minutes=10)) + event_data = load_data("android-ndk", timestamp=before_now(minutes=10)) events = ( ("a" * 32, "not handled", False), ("b" * 32, "is handled", True), ("c" * 32, "undefined", None), ) for event in events: - data["event_id"] = event[0] - data["logentry"] = {"formatted": event[1]} - data["exception"]["values"][0]["value"] = event[1] - data["exception"]["values"][0]["mechanism"]["handled"] = event[2] - self.store_event(data=data, project_id=self.project.id) + event_data["event_id"] = event[0] + event_data["logentry"] = {"formatted": event[1]} + event_data["exception"]["values"][0]["value"] = event[1] + event_data["exception"]["values"][0]["mechanism"]["handled"] = event[2] + self.store_event(data=event_data, project_id=self.project.id) queries: list[tuple[str, list[int]]] = [ ("", [0, 1, 1]), @@ -2020,18 +2032,18 @@ def test_error_handled_alias(self) -> None: assert [item["error.handled"] for item in data] == expected_data def test_error_unhandled_alias(self) -> None: - data = load_data("android-ndk", timestamp=before_now(minutes=10)) + event_data = load_data("android-ndk", timestamp=before_now(minutes=10)) events = ( ("a" * 32, "not handled", False), ("b" * 32, "is handled", True), ("c" * 32, "undefined", None), ) for event in events: - data["event_id"] = event[0] - data["logentry"] = {"formatted": event[1]} - data["exception"]["values"][0]["value"] = event[1] - data["exception"]["values"][0]["mechanism"]["handled"] = event[2] - self.store_event(data=data, project_id=self.project.id) + event_data["event_id"] = event[0] + event_data["logentry"] = {"formatted": event[1]} + event_data["exception"]["values"][0]["value"] = event[1] + event_data["exception"]["values"][0]["mechanism"]["handled"] = event[2] + self.store_event(data=event_data, project_id=self.project.id) queries: list[tuple[str, list[str], list[int]]] = [ ("error.unhandled:true", ["a" * 32], [1]), @@ -2061,9 +2073,9 @@ def test_error_unhandled_alias(self) -> None: assert [item["error.unhandled"] for item in data] == error_handled def test_array_fields(self) -> None: - data = load_data("javascript") - data["timestamp"] = before_now(minutes=10).isoformat() - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("javascript") + event_data["timestamp"] = before_now(minutes=10).isoformat() + self.store_event(data=event_data, project_id=self.project.id) expected_filenames = [ "../../sentry/scripts/views.js", @@ -2129,10 +2141,10 @@ def test_spans_op_array_field(self) -> None: "hash": "a" * 16, "exclusive_time": 1.2345, } - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=10), trace_context=trace_context, spans=[] ) - self.store_event(data=data, project_id=self.project.id) + self.store_event(data=event_data, project_id=self.project.id) queries = [ ("has:spans_op", 1), @@ -2155,19 +2167,19 @@ def test_spans_op_array_field(self) -> None: assert len(data) == expected_len def test_orderby_field_alias(self) -> None: - data = load_data("android-ndk", timestamp=before_now(minutes=10)) + event_data = load_data("android-ndk", timestamp=before_now(minutes=10)) events = ( ("a" * 32, "not handled", False), ("b" * 32, "is handled", True), ("c" * 32, "undefined", None), ) for event in events: - data["event_id"] = event[0] - data["transaction"] = event[0] - data["logentry"] = {"formatted": event[1]} - data["exception"]["values"][0]["value"] = event[1] - data["exception"]["values"][0]["mechanism"]["handled"] = event[2] - self.store_event(data=data, project_id=self.project.id) + event_data["event_id"] = event[0] + event_data["transaction"] = event[0] + event_data["logentry"] = {"formatted": event[1]} + event_data["exception"]["values"][0]["value"] = event[1] + event_data["exception"]["values"][0]["mechanism"]["handled"] = event[2] + self.store_event(data=event_data, project_id=self.project.id) queries = [ (["error.unhandled"], [0, 0, 1]), @@ -2196,25 +2208,25 @@ def test_orderby_field_alias(self) -> None: def test_orderby_aggregate_function(self) -> None: project = self.create_project() - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_count/success" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_count/success" + self.store_event(event_data, project_id=project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_count/unknown" - data["contexts"]["trace"]["status"] = "unknown_error" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_count/unknown" + event_data["contexts"]["trace"]["status"] = "unknown_error" + self.store_event(event_data, project_id=project.id) for i in range(6): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = f"/failure_count/{i}" - data["contexts"]["trace"]["status"] = "unauthenticated" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = f"/failure_count/{i}" + event_data["contexts"]["trace"]["status"] = "unauthenticated" + self.store_event(event_data, project_id=project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_count/0" - data["contexts"]["trace"]["status"] = "unauthenticated" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_count/0" + event_data["contexts"]["trace"]["status"] = "unauthenticated" + self.store_event(event_data, project_id=project.id) orderbys = [ (["failure_count()"], [0, 1, 1, 1, 1, 1, 1, 2]), @@ -2713,12 +2725,12 @@ def test_nested_conditional_filter(self) -> None: def test_conditions_with_special_columns(self) -> None: for val in ["a", "b", "c"]: - data = load_data("transaction") - data["timestamp"] = self.one_min_ago.isoformat() - data["transaction"] = val * 32 - data["logentry"] = {"formatted": val * 32} - data["tags"] = {"sub_customer.is-Enterprise-42": val * 32} - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction") + event_data["timestamp"] = self.one_min_ago.isoformat() + event_data["transaction"] = val * 32 + event_data["logentry"] = {"formatted": val * 32} + event_data["tags"] = {"sub_customer.is-Enterprise-42": val * 32} + self.store_event(data=event_data, project_id=self.project.id) result = discover.query( selected_columns=["title", "message"], @@ -2761,12 +2773,12 @@ def test_conditions_with_aggregates(self) -> None: for ev in events: val = ev[0] * 32 for i in range(ev[1]): - data = load_data("transaction") - data["timestamp"] = self.one_min_ago.isoformat() - data["transaction"] = f"{val}-{i}" - data["logentry"] = {"formatted": val} - data["tags"] = {"trek": val} - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction") + event_data["timestamp"] = self.one_min_ago.isoformat() + event_data["transaction"] = f"{val}-{i}" + event_data["logentry"] = {"formatted": val} + event_data["tags"] = {"trek": val} + self.store_event(data=event_data, project_id=self.project.id) result = discover.query( selected_columns=["trek", "count()"], @@ -2793,12 +2805,12 @@ def test_conditions_with_nested_aggregates(self) -> None: for ev in events: val = ev[0] * 32 for i in range(ev[1]): - data = load_data("transaction") - data["timestamp"] = self.one_min_ago.isoformat() - data["transaction"] = f"{val}-{i}" - data["logentry"] = {"formatted": val} - data["tags"] = {"trek": val} - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction") + event_data["timestamp"] = self.one_min_ago.isoformat() + event_data["transaction"] = f"{val}-{i}" + event_data["logentry"] = {"formatted": val} + event_data["tags"] = {"trek": val} + self.store_event(data=event_data, project_id=self.project.id) result = discover.query( selected_columns=["trek", "count()"], @@ -2842,9 +2854,11 @@ def test_conditions_with_timestamps(self) -> None: for t, ev in enumerate(events): val = ev[0] * 32 for i in range(ev[1]): - data = load_data("transaction", timestamp=self.now - timedelta(seconds=3 * t + 1)) - data["transaction"] = f"{val}" - self.store_event(data=data, project_id=self.project.id) + event_data = load_data( + "transaction", timestamp=self.now - timedelta(seconds=3 * t + 1) + ) + event_data["transaction"] = f"{val}" + self.store_event(data=event_data, project_id=self.project.id) results = discover.query( selected_columns=["transaction", "count()"], @@ -2888,9 +2902,9 @@ def test_timestamp_rollup_filter(self) -> None: } def test_count_with_or(self) -> None: - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["transaction"] = "a" * 32 - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["transaction"] = "a" * 32 + self.store_event(data=event_data, project_id=self.project.id) results = discover.query( selected_columns=["transaction", "count()"], @@ -2907,13 +2921,13 @@ def test_count_with_or(self) -> None: assert data[0]["count"] == 1 def test_array_join(self) -> None: - data = load_data("transaction", timestamp=before_now(seconds=90)) - data["measurements"] = { + event_data = load_data("transaction", timestamp=before_now(seconds=90)) + event_data["measurements"] = { "fp": {"value": 1000}, "fcp": {"value": 1000}, "lcp": {"value": 1000}, } - self.store_event(data=data, project_id=self.project.id) + self.store_event(data=event_data, project_id=self.project.id) results = discover.query( selected_columns=["array_join(measurements_key)"], @@ -2973,13 +2987,13 @@ def test_access_to_private_functions(self) -> None: ) def test_sum_array_combinator(self) -> None: - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["measurements"] = { + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["measurements"] = { "fp": {"value": 1000}, "fcp": {"value": 1000}, "lcp": {"value": 1000}, } - self.store_event(data=data, project_id=self.project.id) + self.store_event(data=event_data, project_id=self.project.id) results = discover.query( selected_columns=["sumArray(measurements_value)"], @@ -2993,9 +3007,9 @@ def test_sum_array_combinator(self) -> None: assert results["data"][0]["sumArray_measurements_value"] == 3000.0 def test_any_function(self) -> None: - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["transaction"] = "a" * 32 - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["transaction"] = "a" * 32 + self.store_event(data=event_data, project_id=self.project.id) results = discover.query( selected_columns=["count()", "any(transaction)", "any(user.id)"], diff --git a/tests/sentry/snuba/test_errors.py b/tests/sentry/snuba/test_errors.py index 15b8dfed371d..3550a1190541 100644 --- a/tests/sentry/snuba/test_errors.py +++ b/tests/sentry/snuba/test_errors.py @@ -47,13 +47,13 @@ def setUp(self) -> None: ) # transaction event - data = load_data("transaction", timestamp=self.event_time) - data["transaction"] = "a" * 32 - data["user"] = {"id": "99", "email": "bruce@example.com", "username": "brucew"} - data["release"] = "first-release" - data["environment"] = self.environment.name - data["tags"] = [["key1", "value1"]] - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=self.event_time) + event_data["transaction"] = "a" * 32 + event_data["user"] = {"id": "99", "email": "bruce@example.com", "username": "brucew"} + event_data["release"] = "first-release" + event_data["environment"] = self.environment.name + event_data["tags"] = [["key1", "value1"]] + self.store_event(data=event_data, project_id=self.project.id) self.snuba_params = SnubaParams( organization=self.organization, @@ -94,10 +94,12 @@ def test_project_mapping(self) -> None: assert data[0]["project"] == other_project.slug def test_issue_short_id_mapping(self) -> None: + group = self.event.group + assert group is not None tests = [ - ("issue", f"issue:{self.event.group.qualified_short_id}"), + ("issue", f"issue:{group.qualified_short_id}"), ("issue", f"issue.id:{self.event.group_id}"), - ("issue.id", f"issue:{self.event.group.qualified_short_id}"), + ("issue.id", f"issue:{group.qualified_short_id}"), ("issue.id", f"issue.id:{self.event.group_id}"), ] @@ -115,10 +117,12 @@ def test_issue_short_id_mapping(self) -> None: assert [item["issue.id"] for item in data] == [self.event.group_id] def test_issue_filters(self) -> None: + group = self.event.group + assert group is not None tests = [ "has:issue", "has:issue.id", - f"issue:[{self.event.group.qualified_short_id}]", + f"issue:[{group.qualified_short_id}]", f"issue.id:[{self.event.group_id}]", ] @@ -483,20 +487,20 @@ def test_to_other_function(self) -> None: project = self.create_project() for i in range(3): - data = load_data("javascript", timestamp=before_now(minutes=5)) - data["transaction"] = f"/to_other/{i}" - data["release"] = "aaaa" - self.store_event(data, project_id=project.id) + event_data = load_data("javascript", timestamp=before_now(minutes=5)) + event_data["transaction"] = f"/to_other/{i}" + event_data["release"] = "aaaa" + self.store_event(event_data, project_id=project.id) - data = load_data("javascript", timestamp=before_now(minutes=5)) - data["transaction"] = "/to_other/y" - data["release"] = "yyyy" - self.store_event(data, project_id=project.id) + event_data = load_data("javascript", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/to_other/y" + event_data["release"] = "yyyy" + self.store_event(event_data, project_id=project.id) - data = load_data("javascript", timestamp=before_now(minutes=5)) - data["transaction"] = "/to_other/z" - data["release"] = "zzzz" - self.store_event(data, project_id=project.id) + event_data = load_data("javascript", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/to_other/z" + event_data["release"] = "zzzz" + self.store_event(event_data, project_id=project.id) columns1 = ["transaction", 'to_other(release,"aaaa")'] columns2 = ["transaction", 'to_other(release,"aaaa",old,new)'] @@ -525,17 +529,17 @@ def test_to_other_function(self) -> None: def test_count_if_function(self) -> None: for i in range(3): - data = load_data("javascript", timestamp=before_now(minutes=5)) - data["release"] = "aaaa" - self.store_event(data, project_id=self.project.id) + event_data = load_data("javascript", timestamp=before_now(minutes=5)) + event_data["release"] = "aaaa" + self.store_event(event_data, project_id=self.project.id) - data = load_data("javascript", timestamp=before_now(minutes=5)) - data["release"] = "bbbb" - self.store_event(data, project_id=self.project.id) + event_data = load_data("javascript", timestamp=before_now(minutes=5)) + event_data["release"] = "bbbb" + self.store_event(event_data, project_id=self.project.id) - data = load_data("javascript", timestamp=before_now(minutes=5)) - data["release"] = "cccc" - self.store_event(data, project_id=self.project.id) + event_data = load_data("javascript", timestamp=before_now(minutes=5)) + event_data["release"] = "cccc" + self.store_event(event_data, project_id=self.project.id) columns1 = ["count()", "count_if(release,equals,aaaa)", "count_if(release,notEquals,aaaa)"] columns2 = ["count()", "count_if(release,less,bbbb)", "count_if(release,lessOrEquals,bbbb)"] @@ -581,13 +585,13 @@ def test_count_if_function_with_unicode(self) -> None: unicode_phrase1 = "\u716e\u6211\u66f4\u591a\u7684\u98df\u7269\uff0c\u6211\u9913\u4e86" unicode_phrase2 = "\u53cd\u6b63\u611b\u60c5\u4e0d\u5c31\u90a3\u6837" for i in range(3): - data = load_data("javascript", timestamp=before_now(minutes=5)) - data["release"] = unicode_phrase1 - self.store_event(data, project_id=self.project.id) + event_data = load_data("javascript", timestamp=before_now(minutes=5)) + event_data["release"] = unicode_phrase1 + self.store_event(event_data, project_id=self.project.id) - data = load_data("javascript", timestamp=before_now(minutes=5)) - data["release"] = unicode_phrase2 - self.store_event(data, project_id=self.project.id) + event_data = load_data("javascript", timestamp=before_now(minutes=5)) + event_data["release"] = unicode_phrase2 + self.store_event(event_data, project_id=self.project.id) columns1 = [ "count()", @@ -629,14 +633,14 @@ def test_last_seen(self) -> None: expected_timestamp = before_now(minutes=3) string_condition_timestamp = before_now(minutes=4).strftime("%Y-%m-%dT%H:%M:%S+00:00") - data = load_data("javascript", timestamp=expected_timestamp) - data["transaction"] = "/last_seen" - self.store_event(data, project_id=project.id) + event_data = load_data("javascript", timestamp=expected_timestamp) + event_data["transaction"] = "/last_seen" + self.store_event(event_data, project_id=project.id) for i in range(6): - data = load_data("javascript", timestamp=before_now(minutes=i + 4)) - data["transaction"] = "/last_seen" - self.store_event(data, project_id=project.id) + event_data = load_data("javascript", timestamp=before_now(minutes=i + 4)) + event_data["transaction"] = "/last_seen" + self.store_event(event_data, project_id=project.id) queries = [ ("", 1, True), @@ -666,14 +670,14 @@ def test_latest_event(self) -> None: project = self.create_project() expected_timestamp = before_now(minutes=3) - data = load_data("javascript", timestamp=expected_timestamp) - data["transaction"] = "/latest_event" - stored_event = self.store_event(data, project_id=project.id) + event_data = load_data("javascript", timestamp=expected_timestamp) + event_data["transaction"] = "/latest_event" + stored_event = self.store_event(event_data, project_id=project.id) for i in range(6): - data = load_data("javascript", timestamp=before_now(minutes=i + 4)) - data["transaction"] = "/latest_event" - self.store_event(data, project_id=project.id) + event_data = load_data("javascript", timestamp=before_now(minutes=i + 4)) + event_data["transaction"] = "/latest_event" + self.store_event(event_data, project_id=project.id) result = errors.query( selected_columns=["transaction", "latest_event()"], @@ -696,12 +700,12 @@ def test_eps(self) -> None: project = self.create_project() for _ in range(6): - data = load_data( + event_data = load_data( "javascript", timestamp=before_now(minutes=3), ) - data["transaction"] = "/eps" - self.store_event(data, project_id=project.id) + event_data["transaction"] = "/eps" + self.store_event(event_data, project_id=project.id) queries = [ ("", 1, True), @@ -749,12 +753,12 @@ def test_epm(self) -> None: project = self.create_project() for _ in range(6): - data = load_data( + event_data = load_data( "javascript", timestamp=before_now(minutes=3), ) - data["transaction"] = "/epm" - self.store_event(data, project_id=project.id) + event_data["transaction"] = "/epm" + self.store_event(event_data, project_id=project.id) queries = [ ("", 1, True), @@ -799,18 +803,18 @@ def test_epm(self) -> None: assert data[0]["tpm_60"] == 6 def test_error_handled_alias(self) -> None: - data = load_data("android-ndk", timestamp=before_now(minutes=10)) + event_data = load_data("android-ndk", timestamp=before_now(minutes=10)) events = ( ("a" * 32, "not handled", False), ("b" * 32, "is handled", True), ("c" * 32, "undefined", None), ) for event in events: - data["event_id"] = event[0] - data["logentry"] = {"formatted": event[1]} - data["exception"]["values"][0]["value"] = event[1] - data["exception"]["values"][0]["mechanism"]["handled"] = event[2] - self.store_event(data=data, project_id=self.project.id) + event_data["event_id"] = event[0] + event_data["logentry"] = {"formatted": event[1]} + event_data["exception"]["values"][0]["value"] = event[1] + event_data["exception"]["values"][0]["mechanism"]["handled"] = event[2] + self.store_event(data=event_data, project_id=self.project.id) queries: list[tuple[str, list[int]]] = [ ("", [0, 1, 1]), @@ -842,18 +846,18 @@ def test_error_handled_alias(self) -> None: assert [item["error.handled"] for item in data] == expected_data def test_error_unhandled_alias(self) -> None: - data = load_data("android-ndk", timestamp=before_now(minutes=10)) + event_data = load_data("android-ndk", timestamp=before_now(minutes=10)) events = ( ("a" * 32, "not handled", False), ("b" * 32, "is handled", True), ("c" * 32, "undefined", None), ) for event in events: - data["event_id"] = event[0] - data["logentry"] = {"formatted": event[1]} - data["exception"]["values"][0]["value"] = event[1] - data["exception"]["values"][0]["mechanism"]["handled"] = event[2] - self.store_event(data=data, project_id=self.project.id) + event_data["event_id"] = event[0] + event_data["logentry"] = {"formatted": event[1]} + event_data["exception"]["values"][0]["value"] = event[1] + event_data["exception"]["values"][0]["mechanism"]["handled"] = event[2] + self.store_event(data=event_data, project_id=self.project.id) queries: list[tuple[str, list[str], list[int]]] = [ ("error.unhandled:true", ["a" * 32], [1]), @@ -883,9 +887,9 @@ def test_error_unhandled_alias(self) -> None: assert [item["error.unhandled"] for item in data] == error_handled def test_array_fields(self) -> None: - data = load_data("javascript") - data["timestamp"] = before_now(minutes=10).isoformat() - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("javascript") + event_data["timestamp"] = before_now(minutes=10).isoformat() + self.store_event(data=event_data, project_id=self.project.id) expected_filenames = [ "../../sentry/scripts/views.js", @@ -940,19 +944,19 @@ def test_array_fields(self) -> None: assert sorted(data[0]["stack.filename"]) == expected_filenames def test_orderby_field_alias(self) -> None: - data = load_data("android-ndk", timestamp=before_now(minutes=10)) + event_data = load_data("android-ndk", timestamp=before_now(minutes=10)) events = ( ("a" * 32, "not handled", False), ("b" * 32, "is handled", True), ("c" * 32, "undefined", None), ) for event in events: - data["event_id"] = event[0] - data["transaction"] = event[0] - data["logentry"] = {"formatted": event[1]} - data["exception"]["values"][0]["value"] = event[1] - data["exception"]["values"][0]["mechanism"]["handled"] = event[2] - self.store_event(data=data, project_id=self.project.id) + event_data["event_id"] = event[0] + event_data["transaction"] = event[0] + event_data["logentry"] = {"formatted": event[1]} + event_data["exception"]["values"][0]["value"] = event[1] + event_data["exception"]["values"][0]["mechanism"]["handled"] = event[2] + self.store_event(data=event_data, project_id=self.project.id) queries = [ (["error.unhandled"], [0, 0, 1]), @@ -981,22 +985,22 @@ def test_orderby_field_alias(self) -> None: def test_orderby_aggregate_function(self) -> None: project = self.create_project() - data = load_data("javascript", timestamp=before_now(minutes=5)) - data["transaction"] = "/count/1" - self.store_event(data, project_id=project.id) + event_data = load_data("javascript", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/count/1" + self.store_event(event_data, project_id=project.id) - data = load_data("javascript", timestamp=before_now(minutes=5)) - data["transaction"] = "/count/2" - self.store_event(data, project_id=project.id) + event_data = load_data("javascript", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/count/2" + self.store_event(event_data, project_id=project.id) for i in range(6): - data = load_data("javascript", timestamp=before_now(minutes=5)) - data["transaction"] = f"/count/{i}" - self.store_event(data, project_id=project.id) + event_data = load_data("javascript", timestamp=before_now(minutes=5)) + event_data["transaction"] = f"/count/{i}" + self.store_event(event_data, project_id=project.id) - data = load_data("javascript", timestamp=before_now(minutes=5)) - data["transaction"] = "/count/1" - self.store_event(data, project_id=project.id) + event_data = load_data("javascript", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/count/1" + self.store_event(event_data, project_id=project.id) orderbys = [ (["count"], [1, 1, 1, 1, 2, 3]), @@ -1492,12 +1496,12 @@ def test_nested_conditional_filter(self) -> None: def test_conditions_with_special_columns(self) -> None: for val in ["a", "b", "c"]: - data = load_data("javascript") - data["timestamp"] = self.one_min_ago.isoformat() - data["transaction"] = val * 32 - data["logentry"] = {"formatted": val * 32} - data["tags"] = {"sub_customer.is-Enterprise-42": val * 32} - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("javascript") + event_data["timestamp"] = self.one_min_ago.isoformat() + event_data["transaction"] = val * 32 + event_data["logentry"] = {"formatted": val * 32} + event_data["tags"] = {"sub_customer.is-Enterprise-42": val * 32} + self.store_event(data=event_data, project_id=self.project.id) result = errors.query( selected_columns=["transaction", "message"], @@ -1540,12 +1544,12 @@ def test_conditions_with_nested_aggregates(self) -> None: for ev in events: val = ev[0] * 32 for i in range(ev[1]): - data = load_data("javascript") - data["timestamp"] = self.one_min_ago.isoformat() - data["transaction"] = f"{val}-{i}" - data["logentry"] = {"formatted": val} - data["tags"] = {"trek": val} - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("javascript") + event_data["timestamp"] = self.one_min_ago.isoformat() + event_data["transaction"] = f"{val}-{i}" + event_data["logentry"] = {"formatted": val} + event_data["tags"] = {"trek": val} + self.store_event(data=event_data, project_id=self.project.id) result = errors.query( selected_columns=["trek", "count()"], @@ -1589,9 +1593,11 @@ def test_conditions_with_timestamps(self) -> None: for t, ev in enumerate(events): val = ev[0] * 32 for i in range(ev[1]): - data = load_data("javascript", timestamp=self.now - timedelta(seconds=3 * t + 1)) - data["transaction"] = f"{val}" - self.store_event(data=data, project_id=self.project.id) + event_data = load_data( + "javascript", timestamp=self.now - timedelta(seconds=3 * t + 1) + ) + event_data["transaction"] = f"{val}" + self.store_event(data=event_data, project_id=self.project.id) results = errors.query( selected_columns=["transaction", "count()"], @@ -1635,9 +1641,9 @@ def test_timestamp_rollup_filter(self) -> None: } def test_count_with_or(self) -> None: - data = load_data("javascript", timestamp=before_now(seconds=3)) - data["transaction"] = "a" * 32 - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("javascript", timestamp=before_now(seconds=3)) + event_data["transaction"] = "a" * 32 + self.store_event(data=event_data, project_id=self.project.id) results = errors.query( selected_columns=["transaction", "count()"], @@ -1700,9 +1706,9 @@ def test_access_to_private_functions(self) -> None: ) def test_any_function(self) -> None: - data = load_data("javascript", timestamp=before_now(seconds=3)) - data["transaction"] = "a" * 32 - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("javascript", timestamp=before_now(seconds=3)) + event_data["transaction"] = "a" * 32 + self.store_event(data=event_data, project_id=self.project.id) results = errors.query( selected_columns=["count()", "any(transaction)", "any(user.id)"], diff --git a/tests/sentry/snuba/test_outcomes.py b/tests/sentry/snuba/test_outcomes.py index b5d3d6e1fc92..1f682f5fa613 100644 --- a/tests/sentry/snuba/test_outcomes.py +++ b/tests/sentry/snuba/test_outcomes.py @@ -1,4 +1,5 @@ import urllib.parse +from collections.abc import Mapping from datetime import timedelta import pytest @@ -16,7 +17,11 @@ from sentry.utils.outcomes import Outcome -def _make_query(qs, params=None, allow_minute_resolution=True): +def _make_query( + qs: str, + params: Mapping[str, object] | None = None, + allow_minute_resolution: bool = True, +) -> QueryDefinition: if params is None: params = {} return QueryDefinition.from_query_dict(QueryDict(qs), params, allow_minute_resolution) diff --git a/tests/sentry/snuba/test_profiles.py b/tests/sentry/snuba/test_profiles.py index ee58bd0a9c44..a0d562071d2a 100644 --- a/tests/sentry/snuba/test_profiles.py +++ b/tests/sentry/snuba/test_profiles.py @@ -16,6 +16,7 @@ ) from sentry.search.events.datasets.profiles import COLUMNS as PROFILE_COLUMNS from sentry.search.events.datasets.profiles import ProfilesDatasetConfig +from sentry.search.events.types import ParamsType from sentry.snuba.dataset import Dataset from sentry.testutils.factories import Factories from sentry.testutils.pytest.fixtures import django_db_all @@ -26,7 +27,7 @@ @pytest.fixture -def params(): +def params() -> ParamsType: organization = Factories.create_organization() team = Factories.create_team(organization=organization) project1 = Factories.create_project(organization=organization, teams=[team]) @@ -41,7 +42,6 @@ def params(): "project_id": [project1.id, project2.id], "project_objects": [project1, project2], "organization_id": organization.id, - "user_id": user.id, "team_id": [team.id], } @@ -157,7 +157,7 @@ def params(): # The cases below only build queries, so they share one `params` setup through subtests. @django_db_all -def test_field_resolution(params, subtests: pytest.Subtests) -> None: +def test_field_resolution(params: ParamsType, subtests: pytest.Subtests) -> None: for builder_name, query_builder_fn in QUERY_BUILDER_FNS: for field, resolved in FIELD_RESOLUTION_CASES: with subtests.test(msg=field, query_builder=builder_name): @@ -173,7 +173,7 @@ def test_field_resolution(params, subtests: pytest.Subtests) -> None: @django_db_all -def test_aggregate_resolution(params, subtests: pytest.Subtests) -> None: +def test_aggregate_resolution(params: ParamsType, subtests: pytest.Subtests) -> None: for builder_name, query_builder_fn in QUERY_BUILDER_FNS: for field, resolved in AGGREGATE_RESOLUTION_CASES: with subtests.test(msg=field, query_builder=builder_name): @@ -186,7 +186,7 @@ def test_aggregate_resolution(params, subtests: pytest.Subtests) -> None: @django_db_all -def test_invalid_field_resolution(params, subtests: pytest.Subtests) -> None: +def test_invalid_field_resolution(params: ParamsType, subtests: pytest.Subtests) -> None: for builder_name, query_builder_fn in QUERY_BUILDER_FNS: for field, message in INVALID_FIELD_RESOLUTION_CASES: with subtests.test(msg=field, query_builder=builder_name): @@ -307,7 +307,7 @@ def is_null(column: str) -> Function: @django_db_all -def test_where_resolution(params, subtests: pytest.Subtests) -> None: +def test_where_resolution(params: ParamsType, subtests: pytest.Subtests) -> None: for query, conditions in WHERE_RESOLUTION_CASES: with subtests.test(msg=query): builder = ProfilesQueryBuilder( @@ -323,7 +323,7 @@ def test_where_resolution(params, subtests: pytest.Subtests) -> None: @pytest.mark.xfail(reason="date filters cannot negated") @django_db_all -def test_where_resolution_negated_timestamp(params) -> None: +def test_where_resolution_negated_timestamp(params: ParamsType) -> None: ProfilesQueryBuilder( dataset=Dataset.Profiles, params=params, @@ -334,7 +334,7 @@ def test_where_resolution_negated_timestamp(params) -> None: @pytest.mark.parametrize("field", [pytest.param("project"), pytest.param("project.name")]) @django_db_all -def test_where_resolution_project_slug(params, field) -> None: +def test_where_resolution_project_slug(params: ParamsType, field: str) -> None: project = params["project_objects"][0] builder = ProfilesQueryBuilder( @@ -357,7 +357,7 @@ def test_where_resolution_project_slug(params, field) -> None: @pytest.mark.parametrize("field", [pytest.param("project"), pytest.param("project.name")]) @pytest.mark.parametrize("direction", [pytest.param("", id="asc"), pytest.param("-", id="desc")]) @django_db_all -def test_order_by_resolution_project_slug(params, field, direction) -> None: +def test_order_by_resolution_project_slug(params: ParamsType, field: str, direction: str) -> None: builder = ProfilesQueryBuilder( dataset=Dataset.Profiles, params=params, @@ -395,7 +395,7 @@ def test_order_by_resolution_project_slug(params, field, direction) -> None: ], ) @django_db_all -def test_has_resolution(params, field, column) -> None: +def test_has_resolution(params: ParamsType, field: str, column: str) -> None: builder = ProfilesQueryBuilder( dataset=Dataset.Profiles, params=params, @@ -421,7 +421,7 @@ def test_has_resolution(params, field, column) -> None: ], ) @django_db_all -def test_not_has_resolution(params, field, column) -> None: +def test_not_has_resolution(params: ParamsType, field: str, column: str) -> None: builder = ProfilesQueryBuilder( dataset=Dataset.Profiles, params=params, diff --git a/tests/sentry/snuba/test_rpc_dataset_common.py b/tests/sentry/snuba/test_rpc_dataset_common.py index c05f78c5a4ed..a06eaa5ee2ef 100644 --- a/tests/sentry/snuba/test_rpc_dataset_common.py +++ b/tests/sentry/snuba/test_rpc_dataset_common.py @@ -23,7 +23,7 @@ from sentry.exceptions import InvalidSearchQuery from sentry.search.eap.columns import ResolvedAttribute from sentry.search.eap.types import SearchResolverConfig, SupportedTraceItemType -from sentry.search.events.types import SnubaParams +from sentry.search.events.types import SAMPLING_MODES, SnubaParams from sentry.snuba.occurrences_rpc import Occurrences from sentry.snuba.ourlogs import OurLogs from sentry.snuba.rpc_dataset_common import LimitBy, RPCBase, TableQuery, TableRequest @@ -119,7 +119,7 @@ def _identity_column() -> MagicMock: class TestProcessColumnValuesTruncation(TestCase): def test_truncates_long_strings(self) -> None: long_str = "x" * 100 - final_data: list[dict] = [{}] + final_data: list[dict[str, object]] = [{}] RPCBase.process_column_values( _make_column_value([long_str]), final_data, @@ -131,7 +131,7 @@ def test_truncates_long_strings(self) -> None: def test_no_truncation_without_param(self) -> None: long_str = "x" * 100 - final_data: list[dict] = [{}] + final_data: list[dict[str, object]] = [{}] RPCBase.process_column_values( _make_column_value([long_str]), final_data, @@ -145,7 +145,7 @@ def test_does_not_truncate_non_string_values(self) -> None: av.val_int = 42 column_value = MagicMock() column_value.results = [av] - final_data: list[dict] = [{}] + final_data: list[dict[str, object]] = [{}] RPCBase.process_column_values( column_value, final_data, @@ -160,7 +160,7 @@ def test_null_values_are_unchanged(self) -> None: av.is_null = True column_value = MagicMock() column_value.results = [av] - final_data: list[dict] = [{}] + final_data: list[dict[str, object]] = [{}] RPCBase.process_column_values( column_value, final_data, @@ -296,7 +296,7 @@ def test_duplicate_name(self) -> None: ) @pytest.mark.parametrize(trace_id_test_cases[0], trace_id_test_cases[1]) @django_db_all -def test_force_sampling_mode_in_table(dataset, query, mode): +def test_force_sampling_mode_in_table(dataset: type[RPCBase], query: str, mode: int) -> None: owner = Factories.create_user() organization = Factories.create_organization(owner=owner) @@ -493,7 +493,9 @@ def test_table_limit_by_builds_rpc_request() -> None: ], ) @django_db_all -def test_table_limit_by_rejects_invalid_input(limit_by, sampling_mode, match) -> None: +def test_table_limit_by_rejects_invalid_input( + limit_by: LimitBy, sampling_mode: SAMPLING_MODES | None, match: str +) -> None: owner = Factories.create_user() organization = Factories.create_organization(owner=owner) project = Factories.create_project(organization=organization) @@ -707,7 +709,7 @@ def can_expose(attribute: str, *_args: Any, **_kwargs: Any) -> bool: ) @pytest.mark.parametrize(trace_id_test_cases[0], trace_id_test_cases[1]) @django_db_all -def test_force_sampling_mode_in_timeseries(dataset, query, mode): +def test_force_sampling_mode_in_timeseries(dataset: type[RPCBase], query: str, mode: int) -> None: owner = Factories.create_user() organization = Factories.create_organization(owner=owner) diff --git a/tests/sentry/snuba/test_transactions.py b/tests/sentry/snuba/test_transactions.py index 1a1195d658f2..ff11c270c0c5 100644 --- a/tests/sentry/snuba/test_transactions.py +++ b/tests/sentry/snuba/test_transactions.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable from datetime import timedelta import pytest @@ -8,6 +9,7 @@ from sentry.discover.arithmetic import ArithmeticValidationError from sentry.discover.models import TeamKeyTransaction from sentry.exceptions import InvalidSearchQuery +from sentry.models.project import Project from sentry.models.projectteam import ProjectTeam from sentry.models.releaseprojectenvironment import ReleaseStages from sentry.models.transaction_threshold import ( @@ -41,18 +43,18 @@ def setUp(self) -> None: self.event_time = self.one_min_ago # error event - data = load_data("javascript") - data["timestamp"] = before_now(minutes=10).isoformat() - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("javascript") + event_data["timestamp"] = before_now(minutes=10).isoformat() + self.store_event(data=event_data, project_id=self.project.id) # transaction event - data = load_data("transaction", timestamp=self.event_time) - data["transaction"] = "a" * 32 - data["user"] = {"id": "99", "email": "bruce@example.com", "username": "brucew"} - data["release"] = "first-release" - data["environment"] = self.environment.name - data["tags"] = [["key1", "value1"]] - self.event = self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=self.event_time) + event_data["transaction"] = "a" * 32 + event_data["user"] = {"id": "99", "email": "bruce@example.com", "username": "brucew"} + event_data["release"] = "first-release" + event_data["environment"] = self.environment.name + event_data["tags"] = [["key1", "value1"]] + self.event = self.store_event(data=event_data, project_id=self.project.id) self.snuba_params = SnubaParams( organization=self.organization, @@ -146,13 +148,13 @@ def test_conditional_filter(self) -> None: project2 = self.create_project(organization=self.organization) project3 = self.create_project(organization=self.organization) - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["transaction"] = "b" * 32 - self.event = self.store_event(data=data, project_id=project2.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["transaction"] = "b" * 32 + self.event = self.store_event(data=event_data, project_id=project2.id) - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["transaction"] = "c" * 32 - self.event = self.store_event(data=data, project_id=project3.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["transaction"] = "c" * 32 + self.event = self.store_event(data=event_data, project_id=project3.id) result = transactions.query( selected_columns=["project", "transaction"], @@ -174,21 +176,21 @@ def test_conditional_filter(self) -> None: def test_nested_conditional_filter(self) -> None: project2 = self.create_project(organization=self.organization) - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["release"] = "a" * 32 - self.event = self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["release"] = "a" * 32 + self.event = self.store_event(data=event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["release"] = "b" * 32 - self.event = self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["release"] = "b" * 32 + self.event = self.store_event(data=event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["release"] = "c" * 32 - self.event = self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["release"] = "c" * 32 + self.event = self.store_event(data=event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["release"] = "a" * 32 - self.event = self.store_event(data=data, project_id=project2.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["release"] = "a" * 32 + self.event = self.store_event(data=event_data, project_id=project2.id) result = transactions.query( selected_columns=["release"], @@ -313,13 +315,13 @@ def test_latest_release_condition(self) -> None: assert "event_id" not in data[0] def test_message_filter(self) -> None: - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["transaction"] = "oh yeah" - self.event = self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["transaction"] = "oh yeah" + self.event = self.store_event(event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["transaction"] = "oh no" - self.event = self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["transaction"] = "oh no" + self.event = self.store_event(event_data, project_id=self.project.id) tests: list[tuple[str, list[str]]] = [ ('message:"oh no"', ["oh no"]), @@ -373,29 +375,29 @@ def test_semver_condition(self) -> None: release_2 = self.create_release(version="test@1.2.4") release_3 = self.create_release(version="test@1.2.5") - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = release_1.version - release_1_e_1 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = release_1.version + release_1_e_1 = self.store_event(event_data, project_id=self.project.id).event_id - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = release_1.version - release_1_e_2 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = release_1.version + release_1_e_2 = self.store_event(event_data, project_id=self.project.id).event_id - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = release_2.version - release_2_e_1 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = release_2.version + release_2_e_1 = self.store_event(event_data, project_id=self.project.id).event_id - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = release_2.version - release_2_e_2 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = release_2.version + release_2_e_2 = self.store_event(event_data, project_id=self.project.id).event_id - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = release_3.version - release_3_e_1 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = release_3.version + release_3_e_1 = self.store_event(event_data, project_id=self.project.id).event_id - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = release_3.version - release_3_e_2 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = release_3.version + release_3_e_2 = self.store_event(event_data, project_id=self.project.id).event_id result = transactions.query( selected_columns=["id"], @@ -458,25 +460,25 @@ def test_release_stage_condition(self) -> None: ) self.create_release(version="not_adopted_release", environments=[self.environment]) - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = adopted_release.version - data["environment"] = self.environment.name - adopted_release_e_1 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = adopted_release.version + event_data["environment"] = self.environment.name + adopted_release_e_1 = self.store_event(event_data, project_id=self.project.id).event_id - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = adopted_release.version - data["environment"] = self.environment.name - adopted_release_e_2 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = adopted_release.version + event_data["environment"] = self.environment.name + adopted_release_e_2 = self.store_event(event_data, project_id=self.project.id).event_id - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = replaced_release.version - data["environment"] = self.environment.name - replaced_release_e_1 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = replaced_release.version + event_data["environment"] = self.environment.name + replaced_release_e_1 = self.store_event(event_data, project_id=self.project.id).event_id - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = replaced_release.version - data["environment"] = self.environment.name - replaced_release_e_2 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = replaced_release.version + event_data["environment"] = self.environment.name + replaced_release_e_2 = self.store_event(event_data, project_id=self.project.id).event_id self.snuba_params.environments = [self.environment] @@ -520,20 +522,20 @@ def test_semver_package_condition(self) -> None: release_1 = self.create_release(version="test@1.2.3") release_2 = self.create_release(version="test2@1.2.4") - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = release_1.version - data["environment"] = self.environment.name - release_1_e_1 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = release_1.version + event_data["environment"] = self.environment.name + release_1_e_1 = self.store_event(event_data, project_id=self.project.id).event_id - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = release_1.version - data["environment"] = self.environment.name - release_1_e_2 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = release_1.version + event_data["environment"] = self.environment.name + release_1_e_2 = self.store_event(event_data, project_id=self.project.id).event_id - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = release_2.version - data["environment"] = self.environment.name - release_2_e_1 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = release_2.version + event_data["environment"] = self.environment.name + release_2_e_1 = self.store_event(event_data, project_id=self.project.id).event_id result = transactions.query( selected_columns=["id"], @@ -559,20 +561,20 @@ def test_semver_build_condition(self) -> None: release_1 = self.create_release(version="test@1.2.3+123") release_2 = self.create_release(version="test2@1.2.4+124") - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = release_1.version - data["environment"] = self.environment.name - release_1_e_1 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = release_1.version + event_data["environment"] = self.environment.name + release_1_e_1 = self.store_event(event_data, project_id=self.project.id).event_id - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = release_1.version - data["environment"] = self.environment.name - release_1_e_2 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = release_1.version + event_data["environment"] = self.environment.name + release_1_e_2 = self.store_event(event_data, project_id=self.project.id).event_id - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["release"] = release_2.version - data["environment"] = self.environment.name - release_2_e_1 = self.store_event(data, project_id=self.project.id).event_id + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["release"] = release_2.version + event_data["environment"] = self.environment.name + release_2_e_1 = self.store_event(event_data, project_id=self.project.id).event_id result = transactions.query( selected_columns=["id"], @@ -602,13 +604,13 @@ def test_semver_build_condition(self) -> None: assert {r["id"] for r in result["data"]} == {release_1_e_1, release_1_e_2, release_2_e_1} def test_message_orderby(self) -> None: - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["transaction"] = "oh yeah" - self.event = self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["transaction"] = "oh yeah" + self.event = self.store_event(event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["transaction"] = "oh no" - self.event = self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["transaction"] = "oh no" + self.event = self.store_event(event_data, project_id=self.project.id) tests = [ ("message", ["a" * 32, "oh no", "oh yeah"]), @@ -641,9 +643,9 @@ def test_missing_project(self) -> None: for project_name in ["a" * 32, "z" * 32, "m" * 32]: other_project = self.create_project(organization=self.organization, slug=project_name) projects.append(other_project) - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["transaction"] = "ohh no" - self.event = self.store_event(data, project_id=other_project.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["transaction"] = "ohh no" + self.event = self.store_event(event_data, project_id=other_project.id) self.snuba_params.projects = projects @@ -663,13 +665,13 @@ def test_missing_project(self) -> None: assert [item["project"] for item in data] == ["a" * 32, "z" * 32] def test_offsets(self) -> None: - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["transaction"] = "hello1" - self.event = self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["transaction"] = "hello1" + self.event = self.store_event(event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["transaction"] = "hello2" - self.event = self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["transaction"] = "hello2" + self.event = self.store_event(event_data, project_id=self.project.id) result = transactions.query( selected_columns=["message"], @@ -694,11 +696,11 @@ def test_orderby_field_alias(self) -> None: ("c" * 32, "aborted", None), ) for event in events: - data = load_data("transaction", timestamp=before_now(minutes=10)) - data["event_id"] = event[0] - data["transaction"] = event[0] - data["contexts"]["trace"]["status"] = event[1] - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=10)) + event_data["event_id"] = event[0] + event_data["transaction"] = event[0] + event_data["contexts"]["trace"]["status"] = event[1] + self.store_event(data=event_data, project_id=self.project.id) queries = [ ("transaction.status", [0, 6, 10]), @@ -725,20 +727,20 @@ def test_orderby_field_alias(self) -> None: assert [x["transaction.status"] for x in data] == expected def test_transaction_status(self) -> None: - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["transaction"] = "/test_transaction/success" - data["contexts"]["trace"]["status"] = "ok" - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["transaction"] = "/test_transaction/success" + event_data["contexts"]["trace"]["status"] = "ok" + self.store_event(event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["transaction"] = "/test_transaction/aborted" - data["contexts"]["trace"]["status"] = "aborted" - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["transaction"] = "/test_transaction/aborted" + event_data["contexts"]["trace"]["status"] = "aborted" + self.store_event(event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["transaction"] = "/test_transaction/already_exists" - data["contexts"]["trace"]["status"] = "already_exists" - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["transaction"] = "/test_transaction/already_exists" + event_data["contexts"]["trace"]["status"] = "already_exists" + self.store_event(event_data, project_id=self.project.id) result = transactions.query( selected_columns=["transaction.status"], @@ -781,9 +783,9 @@ def test_project_mapping(self) -> None: other_project = self.create_project(organization=self.organization) self.snuba_params.projects = [other_project] - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["transaction"] = "hello" - self.store_event(data, project_id=other_project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["transaction"] = "hello" + self.store_event(event_data, project_id=other_project.id) result = transactions.query( selected_columns=["project", "message"], @@ -802,8 +804,8 @@ def test_sorting_and_reverse_sorting_project_name(self) -> None: for project_name in ["a" * 32, "z" * 32, "m" * 32]: other_project = self.create_project(organization=self.organization, slug=project_name) projects.append(other_project) - data = load_data("transaction", timestamp=before_now(minutes=1)) - self.store_event(data, project_id=other_project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + self.store_event(event_data, project_id=other_project.id) self.snuba_params.projects = projects @@ -830,9 +832,9 @@ def test_sorting_and_reverse_sorting_project_name(self) -> None: assert [item["project"] for item in data] == ["a" * 32, "m" * 32, "z" * 32] def test_tags_colliding_with_fields(self) -> None: - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["tags"] = [["id", "new"]] - event = self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["tags"] = [["id", "new"]] + event = self.store_event(event_data, project_id=self.project.id) tests = [ ("id", "", sorted([self.event.event_id, event.event_id])), @@ -854,9 +856,9 @@ def test_tags_colliding_with_fields(self) -> None: assert [item[column] for item in data] == expected def test_tags_orderby(self) -> None: - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["tags"] = [["key1", "value2"]] - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["tags"] = [["key1", "value2"]] + self.store_event(event_data, project_id=self.project.id) tests = [ ("key1", "key1", ["value1", "value2"]), @@ -878,9 +880,9 @@ def test_tags_orderby(self) -> None: assert [item[column] for item in data] == expected def test_tags_filter(self) -> None: - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["tags"] = [["key1", "value2"]] - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["tags"] = [["key1", "value2"]] + self.store_event(event_data, project_id=self.project.id) tests: list[tuple[str, str, list[str]]] = [ ("key1", "", ["value1", "value2"]), @@ -930,20 +932,20 @@ def test_team_key_transactions(self) -> None: ] for transaction in txns: - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=(5)), ) - data["transaction"] = transaction - self.store_event(data, project_id=self.project.id) + event_data["transaction"] = transaction + self.store_event(event_data, project_id=self.project.id) for team, transaction in key_txns: - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=(5)), ) - data["transaction"] = transaction - self.store_event(data, project_id=self.project.id) + event_data["transaction"] = transaction + self.store_event(event_data, project_id=self.project.id) TeamKeyTransaction.objects.create( organization=self.organization, transaction=transaction, @@ -1000,8 +1002,8 @@ def test_timestamp_rounding_filters(self) -> None: three_day_ago = before_now(days=3) self.snuba_params.start = three_day_ago - data = load_data("transaction", timestamp=two_day_ago) - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=two_day_ago) + self.store_event(event_data, project_id=self.project.id) result = transactions.query( selected_columns=["timestamp.to_hour", "timestamp.to_day"], @@ -1019,22 +1021,22 @@ def test_timestamp_rounding_filters(self) -> None: def test_user_display(self) -> None: # `user.display` should give `username` - data = load_data("transaction", timestamp=self.event_time) - data["transaction"] = "a" * 32 - data["user"] = {"username": "brucew", "id": "1234", "ip": "127.0.0.1"} - self.event = self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=self.event_time) + event_data["transaction"] = "a" * 32 + event_data["user"] = {"username": "brucew", "id": "1234", "ip": "127.0.0.1"} + self.event = self.store_event(data=event_data, project_id=self.project.id) # `user.display` should give `id` - data = load_data("transaction", timestamp=self.event_time) - data["transaction"] = "a" * 32 - data["user"] = {"id": "1234", "ip": "127.0.0.1"} - self.event = self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=self.event_time) + event_data["transaction"] = "a" * 32 + event_data["user"] = {"id": "1234", "ip": "127.0.0.1"} + self.event = self.store_event(data=event_data, project_id=self.project.id) # `user.display` should give `ip` - data = load_data("transaction", timestamp=self.event_time) - data["transaction"] = "a" * 32 - data["user"] = {"ip_address": "127.0.0.1"} - self.event = self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=self.event_time) + event_data["transaction"] = "a" * 32 + event_data["user"] = {"ip_address": "127.0.0.1"} + self.event = self.store_event(data=event_data, project_id=self.project.id) result = transactions.query( selected_columns=["user.display"], @@ -1053,10 +1055,10 @@ def test_user_display(self) -> None: def test_user_display_filter(self) -> None: # `user.display` should give `username` - data = load_data("transaction", timestamp=self.event_time) - data["transaction"] = "a" * 32 - data["user"] = {"username": "brucew", "ip": "127.0.0.1"} - self.event = self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=self.event_time) + event_data["transaction"] = "a" * 32 + event_data["user"] = {"username": "brucew", "ip": "127.0.0.1"} + self.event = self.store_event(data=event_data, project_id=self.project.id) result = transactions.query( selected_columns=["user.display"], @@ -1073,8 +1075,8 @@ def test_using_project_and_project_name(self) -> None: for project_name in ["a" * 32, "z" * 32, "m" * 32]: other_project = self.create_project(organization=self.organization, slug=project_name) projects.append(other_project) - data = load_data("transaction", timestamp=self.event_time) - self.store_event(data=data, project_id=other_project.id) + event_data = load_data("transaction", timestamp=self.event_time) + self.store_event(data=event_data, project_id=other_project.id) self.snuba_params.projects = projects @@ -1117,14 +1119,14 @@ def test_snql_wip_project_threshold_config(self) -> None: ("d" * 10, 3000), ] for idx, event in enumerate(events): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=(3 + idx)), start_timestamp=before_now(minutes=(3 + idx), milliseconds=event[1]), ) - data["event_id"] = f"{idx}" * 32 - data["transaction"] = event[0] - self.store_event(data, project_id=self.project.id) + event_data["event_id"] = f"{idx}" * 32 + event_data["transaction"] = event[0] + self.store_event(event_data, project_id=self.project.id) if idx % 2: ProjectTransactionThresholdOverride.objects.create( @@ -1135,11 +1137,11 @@ def test_snql_wip_project_threshold_config(self) -> None: metric=TransactionMetric.DURATION.value, ) - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=3), start_timestamp=before_now(minutes=4) ) - data["transaction"] = "e" * 10 - self.store_event(data, project_id=project2.id) + event_data["transaction"] = "e" * 10 + self.store_event(event_data, project_id=project2.id) expected_transaction = ["a" * 10, "b" * 10, "c" * 10, "d" * 10, "e" * 10] expected_project_threshold_config = [ @@ -1208,20 +1210,20 @@ def test_to_other_function(self) -> None: project = self.create_project() for i in range(3): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = f"/to_other/{i}" - data["release"] = "aaaa" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = f"/to_other/{i}" + event_data["release"] = "aaaa" + self.store_event(event_data, project_id=project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/to_other/y" - data["release"] = "yyyy" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/to_other/y" + event_data["release"] = "yyyy" + self.store_event(event_data, project_id=project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/to_other/z" - data["release"] = "zzzz" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/to_other/z" + event_data["release"] = "zzzz" + self.store_event(event_data, project_id=project.id) columns1 = ["transaction", 'to_other(release,"aaaa")'] columns2 = ["transaction", 'to_other(release,"aaaa",old,new)'] @@ -1250,17 +1252,17 @@ def test_to_other_function(self) -> None: def test_count_if_function(self) -> None: for i in range(3): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["release"] = "aaaa" - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["release"] = "aaaa" + self.store_event(event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["release"] = "bbbb" - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["release"] = "bbbb" + self.store_event(event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["release"] = "cccc" - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["release"] = "cccc" + self.store_event(event_data, project_id=self.project.id) columns1 = ["count()", "count_if(release,equals,aaaa)", "count_if(release,notEquals,aaaa)"] columns2 = ["count()", "count_if(release,less,bbbb)", "count_if(release,lessOrEquals,bbbb)"] @@ -1306,13 +1308,13 @@ def test_count_if_function_with_unicode(self) -> None: unicode_phrase1 = "\u716e\u6211\u66f4\u591a\u7684\u98df\u7269\uff0c\u6211\u9913\u4e86" unicode_phrase2 = "\u53cd\u6b63\u611b\u60c5\u4e0d\u5c31\u90a3\u6837" for i in range(3): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["release"] = unicode_phrase1 - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["release"] = unicode_phrase1 + self.store_event(event_data, project_id=self.project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["release"] = unicode_phrase2 - self.store_event(data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["release"] = unicode_phrase2 + self.store_event(event_data, project_id=self.project.id) columns1 = [ "count()", @@ -1351,25 +1353,25 @@ def test_count_if_function_with_unicode(self) -> None: def test_failure_count_function(self) -> None: project = self.create_project() - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_count/success" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_count/success" + self.store_event(event_data, project_id=project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_count/unknown" - data["contexts"]["trace"]["status"] = "unknown_error" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_count/unknown" + event_data["contexts"]["trace"]["status"] = "unknown_error" + self.store_event(event_data, project_id=project.id) for i in range(6): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = f"/failure_count/{i}" - data["contexts"]["trace"]["status"] = "unauthenticated" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = f"/failure_count/{i}" + event_data["contexts"]["trace"]["status"] = "unauthenticated" + self.store_event(event_data, project_id=project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_count/0" - data["contexts"]["trace"]["status"] = "unauthenticated" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_count/0" + event_data["contexts"]["trace"]["status"] = "unauthenticated" + self.store_event(event_data, project_id=project.id) queries = [ ("", 8, True), @@ -1429,20 +1431,22 @@ def test_apdex_function(self) -> None: ("zorp", 3000), ] for idx, event in enumerate(events): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=(5 + idx)), start_timestamp=before_now(minutes=(5 + idx), milliseconds=event[1]), ) - data["measurements"]["lcp"]["value"] = 3000 - data["event_id"] = f"{idx}" * 32 - data["transaction"] = f"/apdex/{event[0]}" - data["user"] = {"email": f"{idx}@example.com"} + event_data["measurements"]["lcp"]["value"] = 3000 + event_data["event_id"] = f"{idx}" * 32 + event_data["transaction"] = f"/apdex/{event[0]}" + event_data["user"] = {"email": f"{idx}@example.com"} if event[0] == "zorp": - self.store_event(data, project_id=project2.id) # No custom thresholds for project2 + self.store_event( + event_data, project_id=project2.id + ) # No custom thresholds for project2 else: - self.store_event(data, project_id=project.id) + self.store_event(event_data, project_id=project.id) queries = [ ("", [0.5, 0.5, 0.25, 0.0, 0.25], ["apdex(100)"], "apdex_100"), @@ -1504,20 +1508,22 @@ def test_count_miserable_function(self) -> None: ("zorp", 3000), ] for idx, event in enumerate(events): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=(5 + idx)), start_timestamp=before_now(minutes=(5 + idx), milliseconds=event[1]), ) - data["measurements"]["lcp"]["value"] = 3000 - data["event_id"] = f"{idx}" * 32 - data["transaction"] = f"/count_miserable/{event[0]}" - data["user"] = {"email": f"{idx}@example.com"} + event_data["measurements"]["lcp"]["value"] = 3000 + event_data["event_id"] = f"{idx}" * 32 + event_data["transaction"] = f"/count_miserable/{event[0]}" + event_data["user"] = {"email": f"{idx}@example.com"} if event[0] == "zorp": - self.store_event(data, project_id=project2.id) # No custom thresholds for project2 + self.store_event( + event_data, project_id=project2.id + ) # No custom thresholds for project2 else: - self.store_event(data, project_id=project.id) + self.store_event(event_data, project_id=project.id) queries = [ ( @@ -1595,20 +1601,22 @@ def test_user_misery_function(self) -> None: ("zorp", 3000), ] for idx, event in enumerate(events): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=(5 + idx)), start_timestamp=before_now(minutes=(5 + idx), milliseconds=event[1]), ) - data["measurements"]["lcp"]["value"] = 3000 - data["event_id"] = f"{idx}" * 32 - data["transaction"] = f"/user_misery/{event[0]}" - data["user"] = {"email": f"{idx}@example.com"} + event_data["measurements"]["lcp"]["value"] = 3000 + event_data["event_id"] = f"{idx}" * 32 + event_data["transaction"] = f"/user_misery/{event[0]}" + event_data["user"] = {"email": f"{idx}@example.com"} if event[0] == "zorp": - self.store_event(data, project_id=project2.id) # No custom thresholds for project2 + self.store_event( + event_data, project_id=project2.id + ) # No custom thresholds for project2 else: - self.store_event(data, project_id=project.id) + self.store_event(event_data, project_id=project.id) queries = [ ( @@ -1632,7 +1640,7 @@ def test_user_misery_function(self) -> None: ), ] - similar = lambda a, b: abs(a - b) < 0.001 + similar: Callable[[float, float], bool] = lambda a, b: abs(a - b) < 0.001 for query, expected_user_misery, col, alias in queries: result = transactions.query( @@ -1658,13 +1666,13 @@ def test_count(self) -> None: project = self.create_project() for i in range(6): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/count/6" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/count/6" + self.store_event(event_data, project_id=project.id) for i in range(8): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/count/8" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/count/8" + self.store_event(event_data, project_id=project.id) queries = [ ("", 2, (6, 8), True), @@ -1695,13 +1703,13 @@ def test_compare_numeric_aggregate_function(self) -> None: project = self.create_project() for i in range(6): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=3), start_timestamp=before_now(minutes=4 + i), ) - data["transaction"] = "/percentile" - self.store_event(data, project_id=project.id) + event_data["transaction"] = "/percentile" + self.store_event(event_data, project_id=project.id) fields = [ ( @@ -1752,14 +1760,14 @@ def test_last_seen(self) -> None: expected_timestamp = before_now(minutes=3) string_condition_timestamp = before_now(minutes=4).strftime("%Y-%m-%dT%H:%M:%S+00:00") - data = load_data("transaction", timestamp=expected_timestamp) - data["transaction"] = "/last_seen" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=expected_timestamp) + event_data["transaction"] = "/last_seen" + self.store_event(event_data, project_id=project.id) for i in range(6): - data = load_data("transaction", timestamp=before_now(minutes=i + 4)) - data["transaction"] = "/last_seen" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=i + 4)) + event_data["transaction"] = "/last_seen" + self.store_event(event_data, project_id=project.id) queries = [ ("", 1, True), @@ -1789,14 +1797,14 @@ def test_latest_event(self) -> None: project = self.create_project() expected_timestamp = before_now(minutes=3) - data = load_data("transaction", timestamp=expected_timestamp) - data["transaction"] = "/latest_event" - stored_event = self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=expected_timestamp) + event_data["transaction"] = "/latest_event" + stored_event = self.store_event(event_data, project_id=project.id) for i in range(6): - data = load_data("transaction", timestamp=before_now(minutes=i + 4)) - data["transaction"] = "/latest_event" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=i + 4)) + event_data["transaction"] = "/latest_event" + self.store_event(event_data, project_id=project.id) result = transactions.query( selected_columns=["transaction", "latest_event()"], @@ -1819,23 +1827,23 @@ def test_failure_rate(self) -> None: project = self.create_project() for i in range(6): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_rate/over" - data["contexts"]["trace"]["status"] = "unauthenticated" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_rate/over" + event_data["contexts"]["trace"]["status"] = "unauthenticated" + self.store_event(event_data, project_id=project.id) for i in range(4): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_rate/over" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_rate/over" + self.store_event(event_data, project_id=project.id) for i in range(7): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_rate/under" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_rate/under" + self.store_event(event_data, project_id=project.id) for i in range(3): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_rate/under" - data["contexts"]["trace"]["status"] = "unauthenticated" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_rate/under" + event_data["contexts"]["trace"]["status"] = "unauthenticated" + self.store_event(event_data, project_id=project.id) queries = [ ("", 2, True), @@ -1863,17 +1871,17 @@ def test_failure_rate(self) -> None: if expected_length > 1: assert data[1]["failure_rate"] == 0.3 - def _create_percentile_events(self, project): + def _create_percentile_events(self, project: Project) -> None: for i in range(6): start = before_now(minutes=3) end = start - timedelta(minutes=1 + i) - data = load_data( + event_data = load_data( "transaction", timestamp=start, start_timestamp=end, ) - data["transaction"] = "/p50" - self.store_event(data, project_id=project.id) + event_data["transaction"] = "/p50" + self.store_event(event_data, project_id=project.id) def test_percentile(self) -> None: project = self.create_project() @@ -2085,15 +2093,15 @@ def test_p100_with_measurement(self) -> None: project = self.create_project() for i in range(6): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=3), start_timestamp=before_now(minutes=4 + i), ) - data["transaction"] = "/p100" - data["measurements"]["frames_total"] = {"value": 100 * i} - data["measurements"]["frames_slow"] = {"value": 50 * i} - self.store_event(data, project_id=project.id) + event_data["transaction"] = "/p100" + event_data["measurements"]["frames_total"] = {"value": 100 * i} + event_data["measurements"]["frames_slow"] = {"value": 50 * i} + self.store_event(event_data, project_id=project.id) queries = [ ("", 1, True), @@ -2126,13 +2134,13 @@ def test_p100_with_measurement(self) -> None: def test_count_unique(self) -> None: for idx in range(3): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=3), ) - data["user"] = {"email": f"{idx}@example.com"} - data["tags"] = {"foo": "bar" if idx < 1 else "baz"} - self.store_event(data, project_id=self.project.id) + event_data["user"] = {"email": f"{idx}@example.com"} + event_data["tags"] = {"foo": "bar" if idx < 1 else "baz"} + self.store_event(event_data, project_id=self.project.id) result = transactions.query( selected_columns=["count_unique(user.display)", "count_unique(foo)"], @@ -2156,12 +2164,12 @@ def test_min_max(self) -> None: for idx in range(3): start = before_now(minutes=3) end = start - timedelta(minutes=1 + idx) - data = load_data( + event_data = load_data( "transaction", timestamp=start, start_timestamp=end, ) - self.store_event(data, project_id=self.project.id) + self.store_event(event_data, project_id=self.project.id) result = transactions.query( selected_columns=[ @@ -2187,12 +2195,12 @@ def test_stats_functions(self) -> None: for idx in range(3): start = before_now(minutes=3) end = start - timedelta(minutes=1 + idx) - data = load_data( + event_data = load_data( "transaction", timestamp=start, start_timestamp=end, ) - self.store_event(data, project_id=self.project.id) + self.store_event(event_data, project_id=self.project.id) queries = [ ("var(transaction.duration)", "var_transaction_duration", 3600000000), @@ -2232,12 +2240,12 @@ def test_count_at_least(self) -> None: start_one_minute = end - timedelta(minutes=1) start_two_minute = end - timedelta(minutes=2) for idx in range(3): - data = load_data( + event_data = load_data( "transaction", timestamp=end, start_timestamp=start_one_minute if idx < 1 else start_two_minute, ) - self.store_event(data, project_id=self.project.id) + self.store_event(event_data, project_id=self.project.id) result = transactions.query( selected_columns=[ @@ -2263,12 +2271,12 @@ def test_eps(self) -> None: project = self.create_project() for _ in range(6): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=3), ) - data["transaction"] = "/eps" - self.store_event(data, project_id=project.id) + event_data["transaction"] = "/eps" + self.store_event(event_data, project_id=project.id) queries = [ ("", 1, True), @@ -2316,12 +2324,12 @@ def test_epm(self) -> None: project = self.create_project() for _ in range(6): - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=3), ) - data["transaction"] = "/epm" - self.store_event(data, project_id=project.id) + event_data["transaction"] = "/epm" + self.store_event(event_data, project_id=project.id) queries = [ ("", 1, True), @@ -2366,18 +2374,18 @@ def test_epm(self) -> None: assert data[0]["tpm_60"] == 6 def test_transaction_status_filter(self) -> None: - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["transaction"] = "/test_transaction/success" - data["contexts"]["trace"]["status"] = "ok" - self.store_event(data, project_id=self.project.id) - self.store_event(data, project_id=self.project.id) - - data = load_data("transaction", timestamp=before_now(minutes=1)) - data["transaction"] = "/test_transaction/already_exists" - data["contexts"]["trace"]["status"] = "already_exists" - self.store_event(data, project_id=self.project.id) - - def run_query(query, expected_statuses, message): + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["transaction"] = "/test_transaction/success" + event_data["contexts"]["trace"]["status"] = "ok" + self.store_event(event_data, project_id=self.project.id) + self.store_event(event_data, project_id=self.project.id) + + event_data = load_data("transaction", timestamp=before_now(minutes=1)) + event_data["transaction"] = "/test_transaction/already_exists" + event_data["contexts"]["trace"]["status"] = "already_exists" + self.store_event(event_data, project_id=self.project.id) + + def run_query(query: str, expected_statuses: list[int], message: str) -> None: result = transactions.query( selected_columns=["transaction.status"], query=query, @@ -2412,25 +2420,25 @@ def run_query(query, expected_statuses, message): def test_orderby_aggregate_function(self) -> None: project = self.create_project() - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_count/success" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_count/success" + self.store_event(event_data, project_id=project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_count/unknown" - data["contexts"]["trace"]["status"] = "unknown_error" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_count/unknown" + event_data["contexts"]["trace"]["status"] = "unknown_error" + self.store_event(event_data, project_id=project.id) for i in range(6): - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = f"/failure_count/{i}" - data["contexts"]["trace"]["status"] = "unauthenticated" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = f"/failure_count/{i}" + event_data["contexts"]["trace"]["status"] = "unauthenticated" + self.store_event(event_data, project_id=project.id) - data = load_data("transaction", timestamp=before_now(minutes=5)) - data["transaction"] = "/failure_count/0" - data["contexts"]["trace"]["status"] = "unauthenticated" - self.store_event(data, project_id=project.id) + event_data = load_data("transaction", timestamp=before_now(minutes=5)) + event_data["transaction"] = "/failure_count/0" + event_data["contexts"]["trace"]["status"] = "unauthenticated" + self.store_event(event_data, project_id=project.id) orderbys = [ ("failure_count()", [0, 1, 1, 1, 1, 1, 1, 2]), @@ -2469,10 +2477,10 @@ def test_spans_op_array_field(self) -> None: "hash": "a" * 16, "exclusive_time": 1.2345, } - data = load_data( + event_data = load_data( "transaction", timestamp=before_now(minutes=10), trace_context=trace_context, spans=[] ) - self.store_event(data=data, project_id=self.project.id) + self.store_event(data=event_data, project_id=self.project.id) queries = [ ("has:spans_op", 1), @@ -2542,12 +2550,12 @@ def test_measurements(self) -> None: def test_conditions_with_special_columns(self) -> None: for val in ["b", "c", "d"]: - data = load_data("transaction") - data["timestamp"] = self.one_min_ago.isoformat() - data["transaction"] = val * 32 - data["logentry"] = {"formatted": val * 32} - data["tags"] = {"sub_customer.is-Enterprise-42": val * 32} - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction") + event_data["timestamp"] = self.one_min_ago.isoformat() + event_data["transaction"] = val * 32 + event_data["logentry"] = {"formatted": val * 32} + event_data["tags"] = {"sub_customer.is-Enterprise-42": val * 32} + self.store_event(data=event_data, project_id=self.project.id) result = transactions.query( selected_columns=["title", "message"], @@ -2590,12 +2598,12 @@ def test_conditions_with_aggregates(self) -> None: for ev in events: val = ev[0] * 32 for i in range(ev[1]): - data = load_data("transaction") - data["timestamp"] = self.one_min_ago.isoformat() - data["transaction"] = f"{val}-{i}" - data["logentry"] = {"formatted": val} - data["tags"] = {"trek": val} - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction") + event_data["timestamp"] = self.one_min_ago.isoformat() + event_data["transaction"] = f"{val}-{i}" + event_data["logentry"] = {"formatted": val} + event_data["tags"] = {"trek": val} + self.store_event(data=event_data, project_id=self.project.id) result = transactions.query( selected_columns=["trek", "count()"], @@ -2622,12 +2630,12 @@ def test_conditions_with_nested_aggregates(self) -> None: for ev in events: val = ev[0] * 32 for i in range(ev[1]): - data = load_data("transaction") - data["timestamp"] = self.one_min_ago.isoformat() - data["transaction"] = f"{val}-{i}" - data["logentry"] = {"formatted": val} - data["tags"] = {"trek": val} - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction") + event_data["timestamp"] = self.one_min_ago.isoformat() + event_data["transaction"] = f"{val}-{i}" + event_data["logentry"] = {"formatted": val} + event_data["tags"] = {"trek": val} + self.store_event(data=event_data, project_id=self.project.id) result = transactions.query( selected_columns=["trek", "count()"], @@ -2671,9 +2679,11 @@ def test_conditions_with_timestamps(self) -> None: for t, ev in enumerate(events): val = ev[0] * 32 for i in range(ev[1]): - data = load_data("transaction", timestamp=self.now - timedelta(seconds=3 * t + 1)) - data["transaction"] = f"{val}" - self.store_event(data=data, project_id=self.project.id) + event_data = load_data( + "transaction", timestamp=self.now - timedelta(seconds=3 * t + 1) + ) + event_data["transaction"] = f"{val}" + self.store_event(data=event_data, project_id=self.project.id) results = transactions.query( selected_columns=["transaction", "count()"], @@ -2719,9 +2729,9 @@ def test_timestamp_rollup_filter(self) -> None: } def test_count_with_or(self) -> None: - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["transaction"] = "a" * 32 - self.store_event(data=data, project_id=self.project.id) + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["transaction"] = "a" * 32 + self.store_event(data=event_data, project_id=self.project.id) results = transactions.query( selected_columns=["transaction", "count()"], @@ -2738,14 +2748,14 @@ def test_count_with_or(self) -> None: assert data[0]["count"] == 2 def test_array_join(self) -> None: - data = load_data("transaction", timestamp=before_now(seconds=90)) - data["transaction"] = "foo" - data["measurements"] = { + event_data = load_data("transaction", timestamp=before_now(seconds=90)) + event_data["transaction"] = "foo" + event_data["measurements"] = { "fp": {"value": 1000}, "fcp": {"value": 1000}, "lcp": {"value": 1000}, } - self.store_event(data=data, project_id=self.project.id) + self.store_event(data=event_data, project_id=self.project.id) results = transactions.query( selected_columns=["array_join(measurements_key)"], @@ -2805,13 +2815,13 @@ def test_access_to_private_functions(self) -> None: ) def test_sum_array_combinator(self) -> None: - data = load_data("transaction", timestamp=before_now(seconds=3)) - data["measurements"] = { + event_data = load_data("transaction", timestamp=before_now(seconds=3)) + event_data["measurements"] = { "fp": {"value": 1000}, "fcp": {"value": 1000}, "lcp": {"value": 1000}, } - self.store_event(data=data, project_id=self.project.id) + self.store_event(data=event_data, project_id=self.project.id) results = transactions.query( selected_columns=["sumArray(measurements_value)"], diff --git a/tests/sentry/spans/consumers/process/__init__.py b/tests/sentry/spans/consumers/process/__init__.py index 4b78019068b8..93c564189eb0 100644 --- a/tests/sentry/spans/consumers/process/__init__.py +++ b/tests/sentry/spans/consumers/process/__init__.py @@ -1,24 +1,36 @@ +from collections.abc import Mapping +from typing import Any, cast + from sentry_kafka_schemas.schema_types.ingest_spans_v1 import SpanEvent from sentry.spans.consumers.process_segments.types import Attributes def build_mock_span( - project_id, *, span_op=None, is_segment=False, attributes=None, description=None, **kwargs -): - span_attributes: Attributes = { - "sentry.duration": {"value": 0.107, "type": "double"}, - "sentry.environment": {"value": "development", "type": "string"}, - "sentry.release": { - "value": "backend@24.2.0.dev0+699ce0cd1281cc3c7275d0a474a595375c769ae8", - "type": "string", + project_id: int, + *, + span_op: str | None = None, + is_segment: bool = False, + attributes: Mapping[str, object] | None = None, + description: str | None = None, + **kwargs: Any, +) -> SpanEvent: + span_attributes: Attributes = cast( + Attributes, + { + "sentry.duration": {"value": 0.107, "type": "double"}, + "sentry.environment": {"value": "development", "type": "string"}, + "sentry.release": { + "value": "backend@24.2.0.dev0+699ce0cd1281cc3c7275d0a474a595375c769ae8", + "type": "string", + }, + "sentry.platform": {"value": "python", "type": "string"}, + "sentry.op": {"value": span_op or "base.dispatch.sleep", "type": "string"}, + "sentry.segment.id": {"value": "a49b42af9fb69da0", "type": "string"}, + "sentry.profile_id": {"type": "string", "value": "dbae2b82559649a1a34a2878134a007b"}, + **dict(attributes or {}), }, - "sentry.platform": {"value": "python", "type": "string"}, - "sentry.op": {"value": span_op or "base.dispatch.sleep", "type": "string"}, - "sentry.segment.id": {"value": "a49b42af9fb69da0", "type": "string"}, - "sentry.profile_id": {"type": "string", "value": "dbae2b82559649a1a34a2878134a007b"}, - **(attributes or {}), - } + ) if description is not None: span_attributes["sentry.description"] = {"type": "string", "value": description} diff --git a/tests/sentry/spans/consumers/process/test_consumer.py b/tests/sentry/spans/consumers/process/test_consumer.py index 949b1d4ed43e..0bef84a7ca49 100644 --- a/tests/sentry/spans/consumers/process/test_consumer.py +++ b/tests/sentry/spans/consumers/process/test_consumer.py @@ -37,7 +37,7 @@ def test_basic(kafka_slice_id: int | None) -> None: commits = [] - def add_commit(offsets, force=False): + def add_commit(offsets: object, force: bool = False) -> None: commits.append(offsets) step = fac.create_with_partitions(add_commit, {Partition(topic, 0): 0}) @@ -142,7 +142,7 @@ def test_schema_validator_rejects_none_fields(field_to_set_none: str) -> None: commits = [] - def add_commit(offsets, force=False): + def add_commit(offsets: object, force: bool = False) -> None: commits.append(offsets) step = fac.create_with_partitions(add_commit, {Partition(topic, 0): 0}) @@ -222,7 +222,7 @@ def test_flusher_processes_limit() -> None: commits = [] - def add_commit(offsets, force=False): + def add_commit(offsets: object, force: bool = False) -> None: commits.append(offsets) # Create with 4 partitions/shards to test process sharing @@ -276,7 +276,7 @@ def produce_to_pipe(project_id: int, payload: KafkaPayload, dropped: int) -> Non commits = [] - def add_commit(offsets, force=False): + def add_commit(offsets: object, force: bool = False) -> None: commits.append(offsets) step = fac.create_with_partitions(add_commit, {Partition(topic, 0): 0}) diff --git a/tests/sentry/spans/consumers/process/test_flusher.py b/tests/sentry/spans/consumers/process/test_flusher.py index 040a5f7d17bd..e97a5bd3adf5 100644 --- a/tests/sentry/spans/consumers/process/test_flusher.py +++ b/tests/sentry/spans/consumers/process/test_flusher.py @@ -1,7 +1,11 @@ +from __future__ import annotations + import time +from multiprocessing.process import BaseProcess +from multiprocessing.sharedctypes import Synchronized from time import sleep from types import SimpleNamespace -from typing import Any +from typing import Any, cast from unittest import mock import orjson @@ -10,7 +14,7 @@ from sentry.spans.buffer import SpansBuffer from sentry.spans.buffer_types import Span -from sentry.spans.consumers.process.flusher import SpanFlusher +from sentry.spans.consumers.process.flusher import ProduceToPipe, SpanFlusher from sentry.testutils.helpers.options import override_options from tests.sentry.spans.test_buffer import DEFAULT_OPTIONS @@ -46,8 +50,14 @@ def _buffer_with_segment( def _blocking_main_for_join_test( - buffer, shards, stopped, current_drift, backpressure_since, healthy_since, produce_to_pipe -): + buffer: SpansBuffer, + shards: list[int], + stopped: Synchronized[int], + current_drift: Synchronized[int], + backpressure_since: Synchronized[int], + healthy_since: Synchronized[int], + produce_to_pipe: ProduceToPipe | None, +) -> None: """Module-level function for multiprocessing (must be picklable).""" healthy_since.value = int(time.time()) # Block ignoring stopped to simulate stuck I/O (e.g., blocked on Kafka produce) @@ -66,10 +76,10 @@ def test_flusher_logs_flushed_segments() -> None: buffer = _buffer_with_segment( project_id=project_id, trace_id=trace_id, span_id=span_id, slice_id=slice_id ) - stopped = SimpleNamespace(value=0) - current_drift = SimpleNamespace(value=0) - backpressure_since = SimpleNamespace(value=0) - healthy_since = SimpleNamespace(value=0) + stopped = cast("Synchronized[int]", SimpleNamespace(value=0)) + current_drift = cast("Synchronized[int]", SimpleNamespace(value=0)) + backpressure_since = cast("Synchronized[int]", SimpleNamespace(value=0)) + healthy_since = cast("Synchronized[int]", SimpleNamespace(value=0)) produced: list[tuple[int, Any, int]] = [] def produce_to_pipe(project_id: int, payload: Any, dropped: int) -> None: @@ -115,10 +125,10 @@ def test_flusher_produces_flushed_segments_to_process_segment_task() -> None: buffer = _buffer_with_segment( project_id=project_id, trace_id=trace_id, span_id=span_id, slice_id=slice_id ) - stopped = SimpleNamespace(value=0) - current_drift = SimpleNamespace(value=0) - backpressure_since = SimpleNamespace(value=0) - healthy_since = SimpleNamespace(value=0) + stopped = cast("Synchronized[int]", SimpleNamespace(value=0)) + current_drift = cast("Synchronized[int]", SimpleNamespace(value=0)) + backpressure_since = cast("Synchronized[int]", SimpleNamespace(value=0)) + healthy_since = cast("Synchronized[int]", SimpleNamespace(value=0)) task_future = mock.Mock() def wait_for_task_delivery() -> None: @@ -160,7 +170,7 @@ def test_backpressure() -> None: with mock.patch("time.sleep"): messages = [] - def append(msg): + def append(msg: object) -> None: messages.append(msg) sleep(1.0) @@ -237,8 +247,14 @@ def test_flusher_waits_for_exited_processes_during_startup() -> None: # exit without setting healthy_since, simulating a process that fails early def never_healthy_main( - buffer, shards, stopped, current_drift, backpressure_since, healthy_since, produce_to_pipe - ): + buffer: SpansBuffer, + shards: list[int], + stopped: Synchronized[int], + current_drift: Synchronized[int], + backpressure_since: Synchronized[int], + healthy_since: Synchronized[int], + produce_to_pipe: ProduceToPipe | None, + ) -> None: return with ( @@ -264,8 +280,14 @@ def test_flusher_timeout_waiting_for_processes_startup() -> None: # block without setting healthy_since, simulating a process that hangs during startup def hang_main( - buffer, shards, stopped, current_drift, backpressure_since, healthy_since, produce_to_pipe - ): + buffer: SpansBuffer, + shards: list[int], + stopped: Synchronized[int], + current_drift: Synchronized[int], + backpressure_since: Synchronized[int], + healthy_since: Synchronized[int], + produce_to_pipe: ProduceToPipe | None, + ) -> None: while not stopped.value: sleep(0.05) @@ -304,7 +326,7 @@ def test_flusher_join_timeout_kills_all_processes() -> None: # Patch BaseProcess.kill to track calls (SpawnProcess inherits from this) original_kill = multiprocessing.process.BaseProcess.kill - def patched_kill(self): + def patched_kill(self: BaseProcess) -> None: # Find which process_index this is if flusher is not None: for idx, proc in flusher.processes.items(): diff --git a/tests/sentry/spans/consumers/process_segments/test_convert.py b/tests/sentry/spans/consumers/process_segments/test_convert.py index f61aa58843f2..a1efff7a2382 100644 --- a/tests/sentry/spans/consumers/process_segments/test_convert.py +++ b/tests/sentry/spans/consumers/process_segments/test_convert.py @@ -406,9 +406,9 @@ def test_convert_renamed_attribute_meta() -> None: [(123, 123), ("123", 123), (None, 0)], ids=["int_key_id", "str_key_id", "none_key_id"], ) -def test_convert_outcomes(key_id, expected_key_id) -> None: +def test_convert_outcomes(key_id: int | str | None, expected_key_id: int) -> None: message: SpanEvent = copy.deepcopy(SPAN_KAFKA_MESSAGE) - message["key_id"] = key_id + message["key_id"] = cast(int, key_id) item = convert_span_to_item(cast(CompatibleSpan, message)) diff --git a/tests/sentry/spans/consumers/process_segments/test_enrichment.py b/tests/sentry/spans/consumers/process_segments/test_enrichment.py index 11f2db590cc6..8f6423713373 100644 --- a/tests/sentry/spans/consumers/process_segments/test_enrichment.py +++ b/tests/sentry/spans/consumers/process_segments/test_enrichment.py @@ -1,4 +1,5 @@ -from typing import cast +from collections.abc import Mapping +from typing import Any, cast import pytest from sentry_conventions.attributes import ATTRIBUTE_METADATA, ATTRIBUTE_NAMES @@ -490,7 +491,9 @@ def test_write_tags_for_performance_issue_detection() -> None: } -def _mock_performance_issue_span(is_segment, attributes, **fields) -> SpanEvent: +def _mock_performance_issue_span( + is_segment: bool, attributes: Mapping[str, object], **fields: object +) -> SpanEvent: return cast( SpanEvent, { @@ -621,7 +624,7 @@ def test_shared_attributes_have_unique_deprecation_chains() -> None: ], ) def test_shared_attributes_preserve_segment_keys(attribute_names: tuple[str, ...]) -> None: - segment_attributes = { + segment_attributes: dict[str, Any] = { name: {"type": "string", "value": f"segment-value-{i}"} for i, name in enumerate(attribute_names) } diff --git a/tests/sentry/spans/consumers/process_segments/test_message.py b/tests/sentry/spans/consumers/process_segments/test_message.py index 17156bc16b99..1d110588c396 100644 --- a/tests/sentry/spans/consumers/process_segments/test_message.py +++ b/tests/sentry/spans/consumers/process_segments/test_message.py @@ -1,11 +1,12 @@ import uuid from hashlib import md5 -from typing import Any +from typing import Any, cast from unittest import mock -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from django.utils import timezone +from sentry_kafka_schemas.schema_types.ingest_spans_v1 import SpanEvent from sentry import nodestore from sentry.constants import MAX_TAG_VALUE_LENGTH @@ -49,7 +50,7 @@ process_segment, ) from sentry.spans.consumers.process_segments.shim import build_shim_event_data -from sentry.spans.consumers.process_segments.types import attribute_value +from sentry.spans.consumers.process_segments.types import CompatibleSpan, attribute_value from sentry.testutils.cases import TestCase from sentry.testutils.helpers.features import with_feature from sentry.testutils.helpers.options import override_options @@ -69,7 +70,7 @@ def generate_n_plus_one_spans( *, has_performance_issues_spans_relay_flag: bool = False, event_id: str | None = None, -): +) -> list[SpanEvent]: """ Build a set of spans containing an N+1 problem. @@ -107,7 +108,7 @@ def generate_n_plus_one_spans( ) repeating_span_description = 'SELECT "sentry_organization"."id", "sentry_organization"."name", "sentry_organization"."slug", "sentry_organization"."status", "sentry_organization"."date_added", "sentry_organization"."default_role", "sentry_organization"."is_test", "sentry_organization"."flags" FROM "sentry_organization" WHERE "sentry_organization"."id" = %s LIMIT 21' - def repeating_span(): + def repeating_span() -> SpanEvent: return build_mock_span( project_id=project.id, span_op="db", @@ -133,7 +134,7 @@ def setUp(self) -> None: def tearDown(self) -> None: message_module.cache = None - def generate_basic_spans(self): + def generate_basic_spans(self) -> list[SpanEvent]: segment_span = build_mock_span( project_id=self.project.id, is_segment=True, @@ -186,7 +187,9 @@ def test_enrich_spans_no_segment(self) -> None: assert span["hash"] def test_build_shim_event_data_supports_legacy_profile_id(self) -> None: - segment_span = build_mock_span(project_id=self.project.id, is_segment=True) + segment_span = cast( + CompatibleSpan, build_mock_span(project_id=self.project.id, is_segment=True) + ) segment_span["hash"] = "hashed-segment" event = build_shim_event_data(segment_span, [segment_span]) @@ -213,7 +216,11 @@ def test_create_models(self) -> None: def test_create_models_trim_environment_name(self) -> None: spans = self.generate_basic_spans() - spans[1]["attributes"]["sentry.environment"]["value"] = "a" * 100 + attributes = spans[1]["attributes"] + assert attributes is not None + environment_attribute = attributes["sentry.environment"] + assert environment_attribute is not None + environment_attribute["value"] = "a" * 100 assert process_segment(spans) # Environment is trimmed @@ -323,7 +330,7 @@ def test_n_plus_one_issue_detection_without_segment_span( ) repeating_span_description = 'SELECT "sentry_organization"."id", "sentry_organization"."name", "sentry_organization"."slug", "sentry_organization"."status", "sentry_organization"."date_added", "sentry_organization"."default_role", "sentry_organization"."is_test", "sentry_organization"."flags" FROM "sentry_organization" WHERE "sentry_organization"."id" = %s LIMIT 21' - def repeating_span(): + def repeating_span() -> SpanEvent: return build_mock_span( project_id=self.project.id, span_op="db", @@ -465,7 +472,7 @@ def test_invalid_detector_types_ignored_in_enablement_option(self) -> None: ) @mock.patch("sentry.spans.consumers.process_segments.message.set_project_flag_and_signal") - def test_record_signals(self, mock_track): + def test_record_signals(self, mock_track: MagicMock) -> None: span = build_mock_span( project_id=self.project.id, is_segment=True, @@ -483,7 +490,7 @@ def test_record_signals(self, mock_track): assert "has_insights_agent_monitoring" not in signals @mock.patch("sentry.spans.consumers.process_segments.message.set_project_flag_and_signal") - def test_record_signals_vitals_from_top_level_is_segment(self, mock_track): + def test_record_signals_vitals_from_top_level_is_segment(self, mock_track: MagicMock) -> None: span = build_mock_span( project_id=self.project.id, is_segment=True, @@ -502,7 +509,9 @@ def test_record_signals_vitals_from_top_level_is_segment(self, mock_track): assert "has_insights_vitals" in signals @mock.patch("sentry.spans.consumers.process_segments.message.set_project_flag_and_signal") - def test_record_signals_does_not_set_screen_load_from_navigation_segment(self, mock_track): + def test_record_signals_does_not_set_screen_load_from_navigation_segment( + self, mock_track: MagicMock + ) -> None: span = build_mock_span( project_id=self.project.id, is_segment=True, @@ -519,7 +528,7 @@ def test_record_signals_does_not_set_screen_load_from_navigation_segment(self, m assert "has_insights_screen_load" not in signals @mock.patch("sentry.spans.consumers.process_segments.message.set_project_flag_and_signal") - def test_record_signals_agents_via_gen_ai_op_name(self, mock_track): + def test_record_signals_agents_via_gen_ai_op_name(self, mock_track: MagicMock) -> None: """Test that spans with gen_ai.operation.name attribute trigger agents insight.""" span = build_mock_span( project_id=self.project.id, @@ -559,8 +568,11 @@ def test_segment_name_propagation(self) -> None: def test_segment_name_propagation_when_name_missing(self) -> None: child_span, segment_span = self.generate_basic_spans() - del segment_span["name"] - del segment_span["attributes"]["sentry.segment.name"] + segment_span_dict = cast(dict[str, Any], segment_span) + del segment_span_dict["name"] + segment_attributes = segment_span["attributes"] + assert segment_attributes is not None + del segment_attributes["sentry.segment.name"] processed_spans = process_segment([child_span, segment_span]) @@ -1110,17 +1122,19 @@ def test_drop_segment_with_skip_enrichment(self) -> None: @exclude_experimental_detectors class TestProcessSegmentCaching(TestCase): - def setUp(self): + def setUp(self) -> None: self.project = self.create_project() self.base_ts = 1707953018.972 # Default `end_timestamp` generated by `build_mock_span` message_module.cache = None - def tearDown(self): + def tearDown(self) -> None: message_module.cache = None @mock.patch("sentry.spans.consumers.process_segments.message._bump_release_last_seen") @mock.patch("sentry.spans.consumers.process_segments.message._create_models") - def test_first_segment_calls_create_models(self, mock_create, mock_bump): + def test_first_segment_calls_create_models( + self, mock_create: MagicMock, mock_bump: MagicMock + ) -> None: segment = build_mock_span(project_id=self.project.id, is_segment=True) process_segment([segment]) @@ -1129,7 +1143,7 @@ def test_first_segment_calls_create_models(self, mock_create, mock_bump): @mock.patch("sentry.spans.consumers.process_segments.message._bump_release_last_seen") @mock.patch("sentry.spans.consumers.process_segments.message._create_models") - def test_duplicate_segment_is_noop(self, mock_create, mock_bump): + def test_duplicate_segment_is_noop(self, mock_create: MagicMock, mock_bump: MagicMock) -> None: segment = build_mock_span(project_id=self.project.id, is_segment=True) process_segment([segment]) mock_create.reset_mock() @@ -1141,7 +1155,9 @@ def test_duplicate_segment_is_noop(self, mock_create, mock_bump): @mock.patch("sentry.spans.consumers.process_segments.message._bump_release_last_seen") @mock.patch("sentry.spans.consumers.process_segments.message._create_models") - def test_segment_within_interval_is_noop(self, mock_create, mock_bump): + def test_segment_within_interval_is_noop( + self, mock_create: MagicMock, mock_bump: MagicMock + ) -> None: segment = build_mock_span(project_id=self.project.id, is_segment=True) process_segment([segment]) mock_create.reset_mock() @@ -1158,7 +1174,9 @@ def test_segment_within_interval_is_noop(self, mock_create, mock_bump): @mock.patch("sentry.spans.consumers.process_segments.message._bump_release_last_seen") @mock.patch("sentry.spans.consumers.process_segments.message._create_models") - def test_segment_exceeding_interval_calls_bump(self, mock_create, mock_bump): + def test_segment_exceeding_interval_calls_bump( + self, mock_create: MagicMock, mock_bump: MagicMock + ) -> None: segment = build_mock_span(project_id=self.project.id, is_segment=True) process_segment([segment]) mock_create.reset_mock() @@ -1175,7 +1193,9 @@ def test_segment_exceeding_interval_calls_bump(self, mock_create, mock_bump): @mock.patch("sentry.spans.consumers.process_segments.message._bump_release_last_seen") @mock.patch("sentry.spans.consumers.process_segments.message._create_models") - def test_bump_advances_cached_timestamp(self, mock_create, mock_bump): + def test_bump_advances_cached_timestamp( + self, mock_create: MagicMock, mock_bump: MagicMock + ) -> None: segment = build_mock_span(project_id=self.project.id, is_segment=True) process_segment([segment]) mock_create.reset_mock() @@ -1210,7 +1230,9 @@ def test_bump_advances_cached_timestamp(self, mock_create, mock_bump): @mock.patch("sentry.spans.consumers.process_segments.message._bump_release_last_seen") @mock.patch("sentry.spans.consumers.process_segments.message._create_models") - def test_different_release_triggers_create_models(self, mock_create, mock_bump): + def test_different_release_triggers_create_models( + self, mock_create: MagicMock, mock_bump: MagicMock + ) -> None: segment = build_mock_span(project_id=self.project.id, is_segment=True) process_segment([segment]) mock_create.reset_mock() @@ -1227,7 +1249,9 @@ def test_different_release_triggers_create_models(self, mock_create, mock_bump): @mock.patch("sentry.spans.consumers.process_segments.message._bump_release_last_seen") @mock.patch("sentry.spans.consumers.process_segments.message._create_models") - def test_different_environment_triggers_create_models(self, mock_create, mock_bump): + def test_different_environment_triggers_create_models( + self, mock_create: MagicMock, mock_bump: MagicMock + ) -> None: segment = build_mock_span(project_id=self.project.id, is_segment=True) process_segment([segment]) mock_create.reset_mock() @@ -1244,7 +1268,9 @@ def test_different_environment_triggers_create_models(self, mock_create, mock_bu @mock.patch("sentry.spans.consumers.process_segments.message._bump_release_last_seen") @mock.patch("sentry.spans.consumers.process_segments.message._create_models") - def test_out_of_order_old_event_is_noop(self, mock_create, mock_bump): + def test_out_of_order_old_event_is_noop( + self, mock_create: MagicMock, mock_bump: MagicMock + ) -> None: later = build_mock_span( project_id=self.project.id, is_segment=True, diff --git a/tests/sentry/spans/consumers/process_segments/test_shim.py b/tests/sentry/spans/consumers/process_segments/test_shim.py index 717bbd89ce69..11ba0ebe4aaa 100644 --- a/tests/sentry/spans/consumers/process_segments/test_shim.py +++ b/tests/sentry/spans/consumers/process_segments/test_shim.py @@ -19,7 +19,7 @@ from tests.sentry.spans.consumers.process_segments.test_convert import SPAN_KAFKA_MESSAGE -def build_segment_span(**kwargs) -> CompatibleSpan: +def build_segment_span(**kwargs: Any) -> CompatibleSpan: segment_span = build_mock_span(project_id=415, is_segment=True, hash="dogs_are_great", **kwargs) return make_compatible(segment_span) diff --git a/tests/sentry/spans/consumers/process_segments/test_tasks.py b/tests/sentry/spans/consumers/process_segments/test_tasks.py index f7264a01f8b3..80ff1667edc8 100644 --- a/tests/sentry/spans/consumers/process_segments/test_tasks.py +++ b/tests/sentry/spans/consumers/process_segments/test_tasks.py @@ -1,3 +1,4 @@ +from typing import cast from unittest import mock from arroyo.types import Topic as ArroyoTopic @@ -8,6 +9,7 @@ _check_span_duplicates, process_segment_task, ) +from sentry.spans.consumers.process_segments.types import CompatibleSpan from sentry.testutils.helpers.options import override_options from sentry.utils import json from tests.sentry.spans.consumers.process import build_mock_span @@ -19,7 +21,7 @@ side_effect=lambda x, **kwargs: x, ) def test_process_segment_task_produces_trace_item(mock_process_segment: mock.MagicMock) -> None: - span_data = build_mock_span(project_id=1, is_segment=True) + span_data = cast(CompatibleSpan, build_mock_span(project_id=1, is_segment=True)) segment_bytes = json.dumps({"spans": [span_data]}).encode("utf-8") with mock.patch( @@ -54,8 +56,8 @@ def test_process_segment_task_filters_duplicate_spans( mock_process_segment: mock.MagicMock, ) -> None: """The task must run spans through dedupe before producing them.""" - span1 = build_mock_span(project_id=1, is_segment=True, span_id="a" * 16) - span2 = build_mock_span(project_id=1, is_segment=False, span_id="b" * 16) + span1 = cast(CompatibleSpan, build_mock_span(project_id=1, is_segment=True, span_id="a" * 16)) + span2 = cast(CompatibleSpan, build_mock_span(project_id=1, is_segment=False, span_id="b" * 16)) segment_bytes = json.dumps({"spans": [span1, span2]}).encode("utf-8") with ( @@ -85,7 +87,7 @@ def test_process_segment_task_filters_duplicate_spans( side_effect=lambda x, **kwargs: x, ) def test_process_segment_task_honors_skip_enrichment(mock_process_segment: mock.MagicMock) -> None: - span_data = build_mock_span(project_id=1, is_segment=True) + span_data = cast(CompatibleSpan, build_mock_span(project_id=1, is_segment=True)) segment_bytes = json.dumps({"spans": [span_data], "skip_enrichment": True}).encode("utf-8") with mock.patch("sentry.spans.consumers.process_segments.tasks._snuba_items_producer"): @@ -97,7 +99,7 @@ def test_process_segment_task_honors_skip_enrichment(mock_process_segment: mock. class TestCheckSpanDuplicates: @override_options({"spans.process-segments.dedupe-ttl": 0}) def test_disabled_when_ttl_is_zero(self) -> None: - spans = [build_mock_span(project_id=1, is_segment=True)] + spans = [cast(CompatibleSpan, build_mock_span(project_id=1, is_segment=True))] with mock.patch("sentry.spans.consumers.process_segments.tasks.redis") as mock_redis: result = _check_span_duplicates(spans) assert result == spans @@ -111,8 +113,8 @@ def test_disabled_when_ttl_is_zero(self) -> None: ) def test_emits_metric_on_duplicate(self) -> None: spans = [ - build_mock_span(project_id=1, is_segment=True, span_id="span1"), - build_mock_span(project_id=1, is_segment=False, span_id="span2"), + cast(CompatibleSpan, build_mock_span(project_id=1, is_segment=True, span_id="span1")), + cast(CompatibleSpan, build_mock_span(project_id=1, is_segment=False, span_id="span2")), ] with ( mock.patch("sentry.spans.consumers.process_segments.tasks.redis") as mock_redis, @@ -141,8 +143,8 @@ def test_emits_metric_on_duplicate(self) -> None: ) def test_filters_duplicates_when_enabled(self) -> None: spans = [ - build_mock_span(project_id=1, is_segment=True, span_id="span1"), - build_mock_span(project_id=1, is_segment=False, span_id="span2"), + cast(CompatibleSpan, build_mock_span(project_id=1, is_segment=True, span_id="span1")), + cast(CompatibleSpan, build_mock_span(project_id=1, is_segment=False, span_id="span2")), ] with ( mock.patch("sentry.spans.consumers.process_segments.tasks.redis") as mock_redis, @@ -171,7 +173,9 @@ def test_filters_duplicates_when_enabled(self) -> None: } ) def test_returns_all_spans_on_redis_error(self) -> None: - spans = [build_mock_span(project_id=1, is_segment=True, span_id="span1")] + spans = [ + cast(CompatibleSpan, build_mock_span(project_id=1, is_segment=True, span_id="span1")) + ] with mock.patch("sentry.spans.consumers.process_segments.tasks.redis") as mock_redis: mock_redis.redis_clusters.get_binary.side_effect = Exception("redis is down") diff --git a/tests/sentry/spans/log_analyzer/test_analyzer.py b/tests/sentry/spans/log_analyzer/test_analyzer.py index 53c39f128677..8edab5b0b87e 100644 --- a/tests/sentry/spans/log_analyzer/test_analyzer.py +++ b/tests/sentry/spans/log_analyzer/test_analyzer.py @@ -9,7 +9,7 @@ @pytest.fixture -def sample_log_entries(): +def sample_log_entries() -> list[LogEntry]: """Create sample log entries for testing.""" return [ LogEntry( @@ -31,7 +31,7 @@ def sample_log_entries(): ] -def test_aggregate_traces(sample_log_entries): +def test_aggregate_traces(sample_log_entries: list[LogEntry]) -> None: """Test aggregating traces across log entries.""" analyzer = LogAnalyzer(sample_log_entries) traces = analyzer.aggregate_traces() @@ -46,7 +46,7 @@ def test_aggregate_traces(sample_log_entries): assert trace_a.log_entries == 2 -def test_filter_by_project(sample_log_entries): +def test_filter_by_project(sample_log_entries: list[LogEntry]) -> None: """Test filtering by project ID.""" analyzer = LogAnalyzer(sample_log_entries) filtered = analyzer.filter_by_project("4505130643619840") @@ -57,7 +57,7 @@ def test_filter_by_project(sample_log_entries): ) -def test_filter_by_trace(sample_log_entries): +def test_filter_by_trace(sample_log_entries: list[LogEntry]) -> None: """Test filtering by trace ID.""" analyzer = LogAnalyzer(sample_log_entries) filtered = analyzer.filter_by_trace("trace-a") @@ -66,7 +66,7 @@ def test_filter_by_trace(sample_log_entries): assert all(op.trace_id == "trace-a" for entry in filtered.log_entries for op in entry.traces) -def test_get_top_traces(sample_log_entries): +def test_get_top_traces(sample_log_entries: list[LogEntry]) -> None: """Test getting top traces by latency.""" analyzer = LogAnalyzer(sample_log_entries) top_traces = analyzer.get_top_traces(limit=2) @@ -76,7 +76,7 @@ def test_get_top_traces(sample_log_entries): assert top_traces[0].total_latency_ms == 1000 -def test_get_summary_stats(sample_log_entries): +def test_get_summary_stats(sample_log_entries: list[LogEntry]) -> None: """Test getting summary statistics.""" analyzer = LogAnalyzer(sample_log_entries) stats = analyzer.get_summary_stats() diff --git a/tests/sentry/spans/log_analyzer/test_fetchers.py b/tests/sentry/spans/log_analyzer/test_fetchers.py index 84321c47f235..a1773d9956b8 100644 --- a/tests/sentry/spans/log_analyzer/test_fetchers.py +++ b/tests/sentry/spans/log_analyzer/test_fetchers.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import datetime, timezone +from pathlib import Path +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -71,7 +73,9 @@ def test_parse_top_traces_empty_list() -> None: ], ids=["basic", "time_range", "consumer", "custom_project"], ) -def test_build_gcp_filter(kwargs, expected_substrings): +def test_build_gcp_filter( + kwargs: dict[str, Any], expected_substrings: list[str] +) -> None: # justification: parametrized filter kwargs are heterogeneous """Test build_gcp_filter with various arguments.""" result = build_gcp_filter(**kwargs) for sub in expected_substrings: @@ -79,7 +83,7 @@ def test_build_gcp_filter(kwargs, expected_substrings): @pytest.fixture -def sample_log_data(tmp_path): +def sample_log_data(tmp_path: Path) -> str: """Create a sample log JSON file.""" data = [ { @@ -112,7 +116,7 @@ def sample_log_data(tmp_path): return str(file_path) -def test_fetch_logs_from_file_all(sample_log_data): +def test_fetch_logs_from_file_all(sample_log_data: str) -> None: """Test fetching all logs without filters.""" logs = fetch_logs_from_file(sample_log_data) @@ -123,7 +127,7 @@ def test_fetch_logs_from_file_all(sample_log_data): assert len(logs[1].traces) == 1 -def test_fetch_logs_from_file_filter_by_consumer(sample_log_data): +def test_fetch_logs_from_file_filter_by_consumer(sample_log_data: str) -> None: """Test filtering by consumer.""" logs = fetch_logs_from_file(sample_log_data, consumer="process-spans-6") diff --git a/tests/sentry/spans/test_add_buffer_lua.py b/tests/sentry/spans/test_add_buffer_lua.py index 03f1426579c4..df97950e30f9 100644 --- a/tests/sentry/spans/test_add_buffer_lua.py +++ b/tests/sentry/spans/test_add_buffer_lua.py @@ -57,7 +57,7 @@ def eval_add_buffer_script( metrics_sample_rate: int = 1, ) -> list[Any]: sha = client.script_load(add_buffer_script.script) - return client.execute_command( + response: list[Any] = client.execute_command( "EVALSHA", sha, 1, @@ -73,6 +73,7 @@ def eval_add_buffer_script( metrics_sample_rate, *span_ids, ) + return response def _metrics_table(result: list[Any]) -> dict[bytes, int]: diff --git a/tests/sentry/spans/test_buffer.py b/tests/sentry/spans/test_buffer.py index 21df2832d886..eb83751a16c5 100644 --- a/tests/sentry/spans/test_buffer.py +++ b/tests/sentry/spans/test_buffer.py @@ -1,7 +1,7 @@ from __future__ import annotations import itertools -from collections.abc import Sequence +from collections.abc import Iterator, Sequence from typing import cast from unittest import mock @@ -110,7 +110,7 @@ def _span( ) -def _normalize_output(output: dict[SegmentKey, FlushedSegment]): +def _normalize_output(output: dict[SegmentKey, FlushedSegment]) -> None: for segment in output.values(): segment.spans.sort(key=lambda span: span.payload["span_id"]) @@ -130,7 +130,7 @@ def _delete_span_buffer_keys(client: StrictRedis[bytes] | RedisCluster[bytes]) - pytest.param(("single", 1), id="single-chunk1"), ] ) -def buffer(request): +def buffer(request: pytest.FixtureRequest) -> Iterator[SpansBuffer]: redis_type, max_spans_per_evalsha = request.param test_options = { **DEFAULT_OPTIONS, @@ -163,7 +163,7 @@ def buffer(request): yield buf -def assert_ttls(client: StrictRedis[bytes] | RedisCluster[bytes]): +def assert_ttls(client: StrictRedis[bytes] | RedisCluster[bytes]) -> None: """ Check that all keys have a TTL, because if the consumer dies before flushing, we should not leak memory. @@ -173,7 +173,7 @@ def assert_ttls(client: StrictRedis[bytes] | RedisCluster[bytes]): assert client.ttl(k) > -1, k -def assert_clean(client: StrictRedis[bytes] | RedisCluster[bytes]): +def assert_clean(client: StrictRedis[bytes] | RedisCluster[bytes]) -> None: """ Check that there's no leakage. @@ -187,7 +187,7 @@ class _SplitBatch: pass -def process_spans(spans: Sequence[Span | _SplitBatch], buffer: SpansBuffer, now): +def process_spans(spans: Sequence[Span | _SplitBatch], buffer: SpansBuffer, now: int) -> None: """ Call buffer.process_spans on the list of spans. @@ -999,7 +999,9 @@ def test_parent_in_other_project(buffer: SpansBuffer, subtests: pytest.Subtests) ] ), ) -def test_parent_in_other_project_and_nested_is_segment_span(buffer: SpansBuffer, spans) -> None: +def test_parent_in_other_project_and_nested_is_segment_span( + buffer: SpansBuffer, spans: list[Span] +) -> None: process_spans(spans, buffer, now=0) assert_ttls(buffer.client) @@ -1085,12 +1087,12 @@ def test_flush_rebalance(buffer: SpansBuffer) -> None: @pytest.mark.parametrize("compression_level", [-1, 0]) -def test_compression_functionality(compression_level) -> None: +def test_compression_functionality(compression_level: int) -> None: """Test that compression is working correctly at various compression levels.""" with override_options({**DEFAULT_OPTIONS, "spans.buffer.compression.level": compression_level}): buffer = SpansBuffer(assigned_shards=list(range(32))) - def make_payload(span_id: str): + def make_payload(span_id: str) -> bytes: return orjson.dumps( { "span_id": span_id, @@ -1157,7 +1159,9 @@ def make_payload(span_id: str): @mock.patch("sentry.spans.buffer.Project") -def test_max_segment_bytes_detaches_over_limit(mock_project_model, buffer: SpansBuffer) -> None: +def test_max_segment_bytes_detaches_over_limit( + mock_project_model: mock.MagicMock, buffer: SpansBuffer +) -> None: """When a segment's cumulative ingested bytes exceed max-segment-bytes, subsequent subsegments are written to a detached (salted) key so that overflow spans are not lost.""" mock_project = mock.Mock() @@ -1223,7 +1227,7 @@ def test_max_segment_bytes_detaches_over_limit(mock_project_model, buffer: Spans @mock.patch("sentry.spans.buffer.Project") def test_max_segment_bytes_under_limit_merges_normally( - mock_project_model, buffer: SpansBuffer + mock_project_model: mock.MagicMock, buffer: SpansBuffer ) -> None: """When a segment is within max-segment-bytes, subsegments merge normally.""" mock_project = mock.Mock() @@ -1271,7 +1275,7 @@ def test_max_segment_bytes_under_limit_merges_normally( @mock.patch("sentry.spans.buffer.Project") -def test_flush_oversized_segments(mock_project_model, buffer: SpansBuffer) -> None: +def test_flush_oversized_segments(mock_project_model: mock.MagicMock, buffer: SpansBuffer) -> None: """When cumulative bytes exceed max-segment-bytes, spans split across segments but none are dropped.""" mock_project = mock.Mock() @@ -1882,7 +1886,9 @@ def test_flush_lock_mixed_contention( @mock.patch("sentry.spans.buffer.Project") -def test_flush_lock_detaches_subsegment(mock_project_model, buffer: SpansBuffer) -> None: +def test_flush_lock_detaches_subsegment( + mock_project_model: mock.MagicMock, buffer: SpansBuffer +) -> None: """ Tests that a span that arrives while a segment is being flushed detaches to a new segment rather than merge into the locked segment. @@ -1934,7 +1940,7 @@ def test_flush_lock_detaches_subsegment(mock_project_model, buffer: SpansBuffer) @mock.patch("sentry.spans.buffer.Project") def test_no_duplicate_flush_after_lock_expiry_and_new_spans( - mock_project_model, buffer: SpansBuffer + mock_project_model: mock.MagicMock, buffer: SpansBuffer ) -> None: """ Regression test for the double-flush bug caused by interaction between @@ -2182,7 +2188,7 @@ def _dspan( pytest.param("single", id="single"), ] ) -def distributed_buffer(request): +def distributed_buffer(request: pytest.FixtureRequest) -> Iterator[SpansBuffer]: redis_type = request.param with override_options(DEFAULT_OPTIONS): if redis_type == "cluster": @@ -2287,7 +2293,7 @@ def test_distributed_payload_keys_populated(distributed_buffer: SpansBuffer) -> assert_clean(buf.client) -def _get_schema_examples(): +def _get_schema_examples() -> Sequence[object]: """Load all ingest-spans schema examples for parametrization.""" examples = [] for ex in sentry_kafka_schemas.iter_examples("ingest-spans"): @@ -2296,7 +2302,7 @@ def _get_schema_examples(): @pytest.mark.parametrize("example", _get_schema_examples()) -def test_schema_examples(buffer: SpansBuffer, example: dict) -> None: +def test_schema_examples(buffer: SpansBuffer, example: dict[str, object]) -> None: """ Feed official ingest-spans schema examples through the buffer pipeline to verify they are handled without errors. diff --git a/tests/sentry/spans/test_buffer_logger.py b/tests/sentry/spans/test_buffer_logger.py index 113c28750188..c3d8c93f199d 100644 --- a/tests/sentry/spans/test_buffer_logger.py +++ b/tests/sentry/spans/test_buffer_logger.py @@ -1,7 +1,8 @@ from __future__ import annotations +from typing import TypedDict, cast from unittest import mock -from unittest.mock import call +from unittest.mock import MagicMock, call from sentry.spans.buffer_logger import ( BufferLogger, @@ -15,6 +16,7 @@ emit_observability_metrics, ) from sentry.spans.buffer_types import ( + EvalshaData, EvalshaResult, FlushCandidate, InsertedSubsegment, @@ -30,6 +32,12 @@ def _segment_id(project_id: int, trace_id: str, span_id: str) -> SegmentKey: return f"span-buf:s:{{{project_id}:{trace_id}}}:{span_id}".encode("ascii") +class MetricsData(TypedDict): + latency_metrics: list[EvalshaData] + gauge_metrics: list[EvalshaData] + longest_evalsha_data: tuple[float, EvalshaData, EvalshaData] + + def test_subsegment_debug_log_emits_debug_log() -> None: span = Span( trace_id="a" * 32, @@ -227,7 +235,7 @@ def test_flush_segment_log_emits_debug_log() -> None: @mock.patch("sentry.spans.buffer_logger.time") -def test_accumulates_batches_and_tracks_cumulative_latency(mock_time): +def test_accumulates_batches_and_tracks_cumulative_latency(mock_time: MagicMock) -> None: """ Test that batches are accumulated with cumulative latency tracking, and that trimming occurs when exceeding MAX_ENTRIES (1000). @@ -286,7 +294,9 @@ def test_accumulates_batches_and_tracks_cumulative_latency(mock_time): @mock.patch("sentry.spans.buffer_logger.logger") @mock.patch("sentry.spans.buffer_logger.time") -def test_logs_only_top_50_when_more_than_1000_traces(mock_time, mock_logger): +def test_logs_only_top_50_when_more_than_1000_traces( + mock_time: MagicMock, mock_logger: MagicMock +) -> None: """ Test periodic logging (every 1 minute) of top 50 entries by cumulative latency, and verify dictionary is cleared after logging. @@ -328,7 +338,7 @@ def test_logs_only_top_50_when_more_than_1000_traces(mock_time, mock_logger): @mock.patch("sentry.spans.buffer_logger.logger") -def test_no_logging_when_no_data(mock_logger): +def test_no_logging_when_no_data(mock_logger: MagicMock) -> None: """Test that nothing is logged when tracked metrics are empty.""" buffer_logger = BufferLogger() @@ -343,7 +353,7 @@ def test_no_logging_when_no_data(mock_logger): @mock.patch("sentry.spans.buffer_logger.time") -def test_flusher_logger_accumulates_segments_and_spans(mock_time): +def test_flusher_logger_accumulates_segments_and_spans(mock_time: MagicMock) -> None: """ Test that FlusherLogger accumulates segment count, span count, and bytes across multiple calls for the same trace, and tracks cumulative flush latency. @@ -393,7 +403,7 @@ def test_flusher_logger_accumulates_segments_and_spans(mock_time): @mock.patch("sentry.spans.buffer_logger.time") -def test_flusher_logger_records_loaded_segments(mock_time): +def test_flusher_logger_records_loaded_segments(mock_time: MagicMock) -> None: with override_options({"spans.buffer.flusher-cumulative-logger-enabled": True}): mock_time.time.return_value = 1000.0 flusher_logger = FlusherLogger() @@ -435,7 +445,7 @@ def test_flusher_logger_records_loaded_segments(mock_time): @mock.patch("sentry.spans.buffer_logger.time") -def test_flusher_logger_prunes_to_top_50_by_bytes(mock_time): +def test_flusher_logger_prunes_to_top_50_by_bytes(mock_time: MagicMock) -> None: """ Test that FlusherLogger prunes to top 50 entries by cumulative bytes when exceeding MAX_ENTRIES. @@ -459,7 +469,9 @@ def test_flusher_logger_prunes_to_top_50_by_bytes(mock_time): @mock.patch("sentry.spans.buffer_logger.logger") @mock.patch("sentry.spans.buffer_logger.time") -def test_flusher_logger_logs_and_resets_after_interval(mock_time, mock_logger): +def test_flusher_logger_logs_and_resets_after_interval( + mock_time: MagicMock, mock_logger: MagicMock +) -> None: """ Test that FlusherLogger logs entries after the 60s interval and resets state, including cumulative flush latency as a top-level field. @@ -513,66 +525,71 @@ def test_flusher_logger_logs_and_resets_after_interval(mock_time, mock_logger): class TestEmitObservabilityMetrics: - def data(self): - return { - "latency_metrics": [ - [ - [b"redirect_step_latency_ms", 1], - [b"sunionstore_args_step_latency_ms", 1], - [b"sunionstore_step_latency_ms", 2], - [b"arg_cleanup_step_latency_ms", 3], - [b"spop_step_latency_ms", 5], - [b"ingested_count_step_latency_ms", 8], - [b"total_step_latency_ms", 13], - ], - [ - [b"redirect_step_latency_ms", 1], - [b"sunionstore_args_step_latency_ms", 1], - [b"ingested_count_step_latency_ms", 2], - [b"total_step_latency_ms", 3], + def data(self) -> MetricsData: + return cast( + MetricsData, + { + "latency_metrics": [ + [ + [b"redirect_step_latency_ms", 1], + [b"sunionstore_args_step_latency_ms", 1], + [b"sunionstore_step_latency_ms", 2], + [b"arg_cleanup_step_latency_ms", 3], + [b"spop_step_latency_ms", 5], + [b"ingested_count_step_latency_ms", 8], + [b"total_step_latency_ms", 13], + ], + [ + [b"redirect_step_latency_ms", 1], + [b"sunionstore_args_step_latency_ms", 1], + [b"ingested_count_step_latency_ms", 2], + [b"total_step_latency_ms", 3], + ], ], - ], - "gauge_metrics": [ - [ - [b"redirect_table_size", 1123], - [b"redirect_depth", 5], - [b"parent_span_set_before_size", 813], - [b"parent_span_set_after_size", 2134], - [b"spopcalls", 55], - ], - [ - [b"redirect_table_size", 1123], - [b"redirect_depth", 5], - [b"parent_span_set_before_size", 813], - [b"parent_span_set_after_size", 2134], - [b"spopcalls", 55], + "gauge_metrics": [ + [ + [b"redirect_table_size", 1123], + [b"redirect_depth", 5], + [b"parent_span_set_before_size", 813], + [b"parent_span_set_after_size", 2134], + [b"spopcalls", 55], + ], + [ + [b"redirect_table_size", 1123], + [b"redirect_depth", 5], + [b"parent_span_set_before_size", 813], + [b"parent_span_set_after_size", 2134], + [b"spopcalls", 55], + ], ], - ], - "longest_evalsha_data": ( - 13, - [ - [b"redirect_step_latency_ms", 1], - [b"sunionstore_args_step_latency_ms", 1], - [b"sunionstore_step_latency_ms", 2], - [b"arg_cleanup_step_latency_ms", 3], - [b"spop_step_latency_ms", 5], - [b"ingested_count_step_latency_ms", 8], - [b"total_step_latency_ms", 13], - ], - [ - [b"redirect_table_size", 1123], - [b"redirect_depth", 5], - [b"parent_span_set_before_size", 813], - [b"parent_span_set_after_size", 2134], - [b"spopcalls", 55], - ], - ), - } + "longest_evalsha_data": ( + 13, + [ + [b"redirect_step_latency_ms", 1], + [b"sunionstore_args_step_latency_ms", 1], + [b"sunionstore_step_latency_ms", 2], + [b"arg_cleanup_step_latency_ms", 3], + [b"spop_step_latency_ms", 5], + [b"ingested_count_step_latency_ms", 8], + [b"total_step_latency_ms", 13], + ], + [ + [b"redirect_table_size", 1123], + [b"redirect_depth", 5], + [b"parent_span_set_before_size", 813], + [b"parent_span_set_after_size", 2134], + [b"spopcalls", 55], + ], + ), + }, + ) @mock.patch("sentry.spans.buffer_logger.metrics.timing") @mock.patch("sentry.spans.buffer_logger.metrics.gauge") @mock.patch("sentry.spans.buffer_logger.metrics.incr") - def test_emit_observability_metrics(self, mock_incr, mock_gauge, mock_timing): + def test_emit_observability_metrics( + self, mock_incr: MagicMock, mock_gauge: MagicMock, mock_timing: MagicMock + ) -> None: emit_observability_metrics( latency_metrics=self.data()["latency_metrics"], gauge_metrics=self.data()["gauge_metrics"], @@ -581,7 +598,7 @@ def test_emit_observability_metrics(self, mock_incr, mock_gauge, mock_timing): LE = "spans.buffer.process_spans.longest_evalsha.step_latency_ms" - def t(stage): + def t(stage: str) -> dict[str, str]: return {"stage": stage} mock_timing.assert_has_calls( @@ -742,7 +759,7 @@ def t(stage): ) @mock.patch("sentry.spans.buffer_logger.metrics.incr") - def test_emit_observability_metrics_oversized_count_metric(self, mock_incr): + def test_emit_observability_metrics_oversized_count_metric(self, mock_incr: MagicMock) -> None: emit_observability_metrics( latency_metrics=[[]], gauge_metrics=[ diff --git a/tests/sentry/spans/test_debug_trace_logger.py b/tests/sentry/spans/test_debug_trace_logger.py index f9bd811db69a..dac18b33245c 100644 --- a/tests/sentry/spans/test_debug_trace_logger.py +++ b/tests/sentry/spans/test_debug_trace_logger.py @@ -1,6 +1,7 @@ from __future__ import annotations from unittest import mock +from unittest.mock import MagicMock from sentry.spans.buffer_types import Span from sentry.spans.debug_trace_logger import DebugTraceLogger @@ -24,7 +25,7 @@ def _make_span( class TestDebugTraceLogger: @mock.patch("sentry.spans.debug_trace_logger.logger") - def test_logs_subsegment_info_with_no_span_keys(self, mock_logger): + def test_logs_subsegment_info_with_no_span_keys(self, mock_logger: MagicMock) -> None: """Test logging when parent_span_id matches the only span's span_id.""" mock_client = mock.MagicMock() @@ -57,7 +58,7 @@ def test_logs_subsegment_info_with_no_span_keys(self, mock_logger): mock_client.pipeline.assert_not_called() @mock.patch("sentry.spans.debug_trace_logger.logger") - def test_logs_subsegment_info_with_span_keys(self, mock_logger): + def test_logs_subsegment_info_with_span_keys(self, mock_logger: MagicMock) -> None: """Test logging with multiple spans where some have different span_ids.""" mock_client = mock.MagicMock() mock_pipeline = mock.MagicMock() @@ -93,7 +94,7 @@ def test_logs_subsegment_info_with_span_keys(self, mock_logger): assert len(extra["subsegment_spans"]) == 3 @mock.patch("sentry.spans.debug_trace_logger.logger") - def test_span_key_format(self, mock_logger): + def test_span_key_format(self, mock_logger: MagicMock) -> None: """Test that span keys are generated in the correct format.""" mock_client = mock.MagicMock() mock_pipeline = mock.MagicMock() @@ -122,7 +123,9 @@ def test_span_key_format(self, mock_logger): assert extra["set_sizes"]["span-buf:s:{111:trace222}:B"] == 3 @mock.patch("sentry.spans.debug_trace_logger.logger") - def test_subsegment_info_skips_when_trace_not_in_debug_traces(self, mock_logger): + def test_subsegment_info_skips_when_trace_not_in_debug_traces( + self, mock_logger: MagicMock + ) -> None: """Test that log_subsegment_info returns early when trace is not in debug_traces.""" mock_client = mock.MagicMock() @@ -140,7 +143,7 @@ def test_subsegment_info_skips_when_trace_not_in_debug_traces(self, mock_logger) mock_client.pipeline.assert_not_called() @mock.patch("sentry.spans.debug_trace_logger.logger") - def test_flush_info_with_root_span_flag(self, mock_logger): + def test_flush_info_with_root_span_flag(self, mock_logger: MagicMock) -> None: """Test log_flush_info when HRS flag is set.""" mock_client = mock.MagicMock() mock_client.exists.return_value = 1 @@ -181,7 +184,7 @@ def test_flush_info_with_root_span_flag(self, mock_logger): mock_client.zscore.assert_called_once_with(queue_key, segment_key) @mock.patch("sentry.spans.debug_trace_logger.logger") - def test_flush_info_without_root_span_flag(self, mock_logger): + def test_flush_info_without_root_span_flag(self, mock_logger: MagicMock) -> None: """Test log_flush_info when HRS flag is not set.""" mock_client = mock.MagicMock() mock_client.exists.return_value = 0 @@ -214,7 +217,7 @@ def test_flush_info_without_root_span_flag(self, mock_logger): assert extra["ttl_remaining_seconds"] == 60 @mock.patch("sentry.spans.debug_trace_logger.logger") - def test_flush_info_skips_when_trace_not_in_debug_traces(self, mock_logger): + def test_flush_info_skips_when_trace_not_in_debug_traces(self, mock_logger: MagicMock) -> None: """Test that log_flush_info returns early when trace is not in debug_traces.""" mock_client = mock.MagicMock() @@ -239,7 +242,7 @@ def test_flush_info_skips_when_trace_not_in_debug_traces(self, mock_logger): mock_client.zscore.assert_not_called() @mock.patch("sentry.spans.debug_trace_logger.logger") - def test_should_log_trace(self, mock_logger): + def test_should_log_trace(self, mock_logger: MagicMock) -> None: """Test _should_log_trace helper method.""" mock_client = mock.MagicMock() diff --git a/tests/sentry/stacktraces/test_functions.py b/tests/sentry/stacktraces/test_functions.py index 38b01a21cc0b..88b015403af7 100644 --- a/tests/sentry/stacktraces/test_functions.py +++ b/tests/sentry/stacktraces/test_functions.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + import pytest from sentry.interfaces.stacktrace import Frame @@ -221,7 +223,7 @@ ], ], ) -def test_trim_native_function_name(input, output) -> None: +def test_trim_native_function_name(input: str, output: str) -> None: assert trim_function_name(input, "native") == output @@ -235,7 +237,7 @@ def test_trim_native_function_name(input, output) -> None: ], ], ) -def test_trim_csharp_function_name(input, output) -> None: +def test_trim_csharp_function_name(input: str, output: str) -> None: assert trim_function_name(input, "csharp") == output @@ -268,11 +270,11 @@ def test_trim_csharp_function_name(input, output) -> None: ], ], ) -def test_trim_cocoa_function_name(input, output) -> None: +def test_trim_cocoa_function_name(input: str, output: str) -> None: assert trim_function_name(input, "cocoa") == output -def replace_group(value, start): +def replace_group(value: str, start: int) -> str: if start == 0: assert value == "anonymous namespace" return "(new value)" @@ -293,7 +295,9 @@ def replace_group(value, start): ], ], ) -def test_enclosed_string_simple(input, start, end, replacement, output) -> None: +def test_enclosed_string_simple( + input: str, start: str, end: str, replacement: str | Callable[[str, int], str], output: str +) -> None: assert replace_enclosed_string(input, start, end, replacement) == output @@ -308,7 +312,7 @@ def test_enclosed_string_simple(input, start, end, replacement, output) -> None: ["foo bar [baz (blah)]", ["foo", "bar", "[baz (blah)]"]], ], ) -def test_split_func_tokens(input, output) -> None: +def test_split_func_tokens(input: str, output: list[str]) -> None: assert split_func_tokens(input) == output @@ -354,5 +358,7 @@ def test_trim_function_name_cocoa() -> None: [{"source_link": "woejfdsiwjidsoi", "lineNo": "7"}, None], ], ) -def test_get_source_link_for_frame(input, output) -> None: - assert get_source_link_for_frame(Frame.to_python(input)) == output +def test_get_source_link_for_frame(input: dict[str, str], output: str | None) -> None: + frame = Frame.to_python(input) + assert frame is not None + assert get_source_link_for_frame(frame) == output diff --git a/tests/sentry/stacktraces/test_in_app_normalization.py b/tests/sentry/stacktraces/test_in_app_normalization.py index 27e9a2bef8e6..4fbc93cd1f16 100644 --- a/tests/sentry/stacktraces/test_in_app_normalization.py +++ b/tests/sentry/stacktraces/test_in_app_normalization.py @@ -7,7 +7,10 @@ from sentry.testutils.cases import TestCase -def make_stacktrace(frame_0_in_app="not set", frame_1_in_app="not set") -> dict[str, Any]: +def make_stacktrace( + frame_0_in_app: bool | str | None = "not set", + frame_1_in_app: bool | str | None = "not set", +) -> dict[str, Any]: frames = [ { "abs_path": "http://example.com/foo.js", @@ -196,7 +199,7 @@ def test_macos_package_in_app_detection(self) -> None: class iOSInAppDetectionTest(TestCase): - def assert_correct_in_app_value(self, function, is_in_app: bool): + def assert_correct_in_app_value(self, function: str, is_in_app: bool) -> None: data: dict[str, Any] = { "platform": "cocoa", "debug_meta": {"images": []}, # omitted diff --git a/tests/sentry/statistical_detectors/test_algorithm.py b/tests/sentry/statistical_detectors/test_algorithm.py index a1ac484fbd2a..38953533af9b 100644 --- a/tests/sentry/statistical_detectors/test_algorithm.py +++ b/tests/sentry/statistical_detectors/test_algorithm.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from datetime import datetime, timedelta, timezone import pytest @@ -49,7 +49,10 @@ ), ], ) -def test_moving_average_detector_state_to_redis_dict(state, expected) -> None: +def test_moving_average_detector_state_to_redis_dict( + state: MovingAverageDetectorState, + expected: Mapping[str | bytes, bytes | float | int | str], +) -> None: assert state.to_redis_dict() == expected @@ -103,7 +106,10 @@ def test_moving_average_detector_state_to_redis_dict(state, expected) -> None: ), ], ) -def test_moving_average_detector_state_from_redis_dict(data, expected) -> None: +def test_moving_average_detector_state_from_redis_dict( + data: Mapping[str | bytes, bytes | float | int | str], + expected: MovingAverageDetectorState, +) -> None: assert MovingAverageDetectorState.from_redis_dict(data) == expected @@ -183,7 +189,9 @@ def test_moving_average_detector_state_from_redis_dict(data, expected) -> None: ), ], ) -def test_moving_average_detector_state_from_redis_dict_error(data, error) -> None: +def test_moving_average_detector_state_from_redis_dict_error( + data: Mapping[str | bytes, bytes | float | int | str], error: type[Exception] +) -> None: with pytest.raises(error): MovingAverageDetectorState.from_redis_dict(data) @@ -199,8 +207,8 @@ def test_moving_average_detector_state_from_redis_dict_error(data, error) -> Non ], ) def test_moving_average_detector_state_should_auto_resolve( - baseline, avg, rel_threshold, auto_resolve -): + baseline: float, avg: float, rel_threshold: float, auto_resolve: bool +) -> None: state = MovingAverageDetectorState( timestamp=datetime(2023, 8, 31, 11, 28, 52), count=10, @@ -224,8 +232,13 @@ def test_moving_average_detector_state_should_auto_resolve( ], ) def test_moving_average_detector_state_should_escalate( - baseline, regressed, avg, min_change, rel_threshold, escalate -): + baseline: float, + regressed: float, + avg: float, + min_change: float, + rel_threshold: float, + escalate: bool, +) -> None: state = MovingAverageDetectorState( timestamp=datetime(2023, 8, 31, 11, 28, 52), count=10, @@ -276,14 +289,14 @@ def test_moving_average_detector_state_should_escalate( ], ) def test_moving_average_relative_change_detector( - min_data_points, - moving_avg_long_factory, - moving_avg_short_factory, - threshold, - values, - regressed_indices, - improved_indices, -): + min_data_points: int, + moving_avg_long_factory: Callable[[], ExponentialMovingAverage], + moving_avg_short_factory: Callable[[], ExponentialMovingAverage], + threshold: float, + values: list[float], + regressed_indices: list[int], + improved_indices: list[int], +) -> None: all_regressed = [] all_improved = [] diff --git a/tests/sentry/statistical_detectors/test_issue_platform_adapter.py b/tests/sentry/statistical_detectors/test_issue_platform_adapter.py index 367023e3561c..5c72a556779c 100644 --- a/tests/sentry/statistical_detectors/test_issue_platform_adapter.py +++ b/tests/sentry/statistical_detectors/test_issue_platform_adapter.py @@ -1,4 +1,5 @@ from unittest import mock +from unittest.mock import MagicMock from sentry.issues.grouptype import PerformanceP95EndpointRegressionGroupType from sentry.seer.breakpoints import BreakpointData @@ -7,8 +8,8 @@ @mock.patch("sentry.statistical_detectors.issue_platform_adapter.produce_occurrence_to_kafka") def test_send_regressions_to_plaform( - mock_produce_occurrence_to_kafka, -): + mock_produce_occurrence_to_kafka: MagicMock, +) -> None: project_id = "123" mock_regression: BreakpointData = { diff --git a/tests/sentry/sudo/test_middleware.py b/tests/sentry/sudo/test_middleware.py index 2c7e7e84c145..6c122e3c012c 100644 --- a/tests/sentry/sudo/test_middleware.py +++ b/tests/sentry/sudo/test_middleware.py @@ -1,17 +1,24 @@ +from typing import cast + import pytest -from django.http import HttpResponse +from django.http import HttpRequest, HttpResponse from fixtures.sudo_testutils import BaseTestCase from sentry.middleware.placeholder import placeholder_get_response from sudo.middleware import SudoMiddleware from sudo.settings import COOKIE_NAME -from sudo.utils import grant_sudo_privileges, revoke_sudo_privileges +from sudo.utils import _SudoRequest, grant_sudo_privileges, revoke_sudo_privileges + + +def _sudo_request(request: HttpRequest) -> _SudoRequest: + # justification: sudo stashes these attributes on the request object. + return cast("_SudoRequest", request) class SudoMiddlewareTestCase(BaseTestCase): middleware = SudoMiddleware(placeholder_get_response) - def assertSignedCookieEqual(self, v1, v2, reason=None): + def assertSignedCookieEqual(self, v1: str, v2: str, reason: str | None = None) -> None: value, _, _ = v1.split(":") return self.assertEqual(value, v2, reason) @@ -38,8 +45,8 @@ def test_process_response_with_sudo_sets_cookie(self) -> None: self.assertEqual(morsels[0][0], COOKIE_NAME) _, sudo = morsels[0] self.assertEqual(sudo.key, COOKIE_NAME) - self.assertSignedCookieEqual(sudo.value, self.request._sudo_token) - self.assertEqual(sudo["max-age"], self.request._sudo_max_age) + self.assertSignedCookieEqual(sudo.value, _sudo_request(self.request)._sudo_token) + self.assertEqual(sudo["max-age"], _sudo_request(self.request)._sudo_max_age) self.assertTrue(sudo["httponly"]) # Asserting that these are insecure together explicitly @@ -49,7 +56,7 @@ def test_process_response_with_sudo_sets_cookie(self) -> None: def test_process_response_sets_secure_cookie(self) -> None: self.login() - self.request.is_secure = lambda: True + setattr(self.request, "is_secure", lambda: True) self.middleware.process_request(self.request) grant_sudo_privileges(self.request) @@ -70,7 +77,7 @@ def test_process_response_sudo_revoked_removes_cookie(self) -> None: self.login() self.middleware.process_request(self.request) grant_sudo_privileges(self.request) - self.request.COOKIES[COOKIE_NAME] = self.request._sudo_token + self.request.COOKIES[COOKIE_NAME] = _sudo_request(self.request)._sudo_token revoke_sudo_privileges(self.request) response = self.middleware.process_response(self.request, HttpResponse()) morsels = list(response.cookies.items()) diff --git a/tests/sentry/sudo/test_utils.py b/tests/sentry/sudo/test_utils.py index 15593c1d2a62..9d9e6e857697 100644 --- a/tests/sentry/sudo/test_utils.py +++ b/tests/sentry/sudo/test_utils.py @@ -1,19 +1,29 @@ +from typing import cast + import pytest from django.core.signing import BadSignature +from django.http import HttpRequest from fixtures.sudo_testutils import BaseTestCase from sudo.settings import COOKIE_AGE, COOKIE_NAME -from sudo.utils import grant_sudo_privileges, has_sudo_privileges, revoke_sudo_privileges +from sudo.utils import ( + _SudoRequest, + grant_sudo_privileges, + has_sudo_privileges, + revoke_sudo_privileges, +) class GrantSudoPrivilegesTestCase(BaseTestCase): - def assertRequestHasToken(self, request, max_age): + def assertRequestHasToken(self, request: HttpRequest, max_age: int) -> None: token = request.session[COOKIE_NAME] + # justification: sudo stashes these attributes on the request object. + sudo_request = cast("_SudoRequest", request) self.assertRegex(token, r"^\w{12}$") - self.assertTrue(request._sudo) - self.assertEqual(request._sudo_token, token) - self.assertEqual(request._sudo_max_age, max_age) + self.assertTrue(sudo_request._sudo) + self.assertEqual(sudo_request._sudo_token, token) + self.assertEqual(sudo_request._sudo_max_age, max_age) def test_grant_token_not_logged_in(self) -> None: with pytest.raises(ValueError): @@ -38,8 +48,10 @@ def test_without_user(self) -> None: class RevokeSudoPrivilegesTestCase(BaseTestCase): - def assertRequestNotSudo(self, request): - self.assertFalse(self.request._sudo) + def assertRequestNotSudo(self, request: HttpRequest) -> None: + # justification: sudo stashes these attributes on the request object. + sudo_request = cast("_SudoRequest", request) + self.assertFalse(sudo_request._sudo) self.assertNotIn(COOKIE_NAME, self.request.session) def test_revoke_sudo_privileges_noop(self) -> None: @@ -71,31 +83,31 @@ def test_revoked(self) -> None: def test_cookie_and_token_match(self) -> None: self.login() - def get_signed_cookie(key, salt="", max_age=None) -> str: + def get_signed_cookie(key: str, salt: str = "", max_age: int | None = None) -> str: return "abc123" self.request.session[COOKIE_NAME] = "abc123" - self.request.get_signed_cookie = get_signed_cookie + setattr(self.request, "get_signed_cookie", get_signed_cookie) self.assertTrue(has_sudo_privileges(self.request)) def test_cookie_and_token_mismatch(self) -> None: self.login() - def get_signed_cookie(key, salt="", max_age=None) -> str: + def get_signed_cookie(key: str, salt: str = "", max_age: int | None = None) -> str: return "nope" self.request.session[COOKIE_NAME] = "abc123" - self.request.get_signed_cookie = get_signed_cookie + setattr(self.request, "get_signed_cookie", get_signed_cookie) self.assertFalse(has_sudo_privileges(self.request)) def test_cookie_bad_signature(self) -> None: self.login() - def get_signed_cookie(key, salt="", max_age=None): + def get_signed_cookie(key: str, salt: str = "", max_age: int | None = None) -> str: raise BadSignature self.request.session[COOKIE_NAME] = "abc123" - self.request.get_signed_cookie = get_signed_cookie + setattr(self.request, "get_signed_cookie", get_signed_cookie) self.assertFalse(has_sudo_privileges(self.request)) def test_missing_keys(self) -> None: diff --git a/tests/sentry/tagstore/test_types.py b/tests/sentry/tagstore/test_types.py index bdd1f6edd0ef..329db9e13511 100644 --- a/tests/sentry/tagstore/test_types.py +++ b/tests/sentry/tagstore/test_types.py @@ -1,6 +1,7 @@ from __future__ import annotations import pickle +from typing import Any import pytest @@ -10,13 +11,13 @@ @classes -def test_pickle(cls) -> None: +def test_pickle(cls: type[Any]) -> None: value = cls(**{name: 1 for name in cls.__slots__}) assert pickle.loads(pickle.dumps(value)) == value @classes -def test_sorting(cls) -> None: +def test_sorting(cls: type[Any]) -> None: value1 = cls(**{name: 1 for name in cls.__slots__}) value2 = cls(**{name: 2 for name in cls.__slots__}) diff --git a/tests/sentry/tasks/seer/agentic_triage/test_simple_triage.py b/tests/sentry/tasks/seer/agentic_triage/test_simple_triage.py index 26a729b5bcbc..685a4a24f6f3 100644 --- a/tests/sentry/tasks/seer/agentic_triage/test_simple_triage.py +++ b/tests/sentry/tasks/seer/agentic_triage/test_simple_triage.py @@ -86,7 +86,7 @@ def test_excludes_prior_seer_pull_requests_after_cooldown( class TestAgenticTriageScore(TestCase): """Tests for _agentic_triage_score — min-max normalization and weighted sum.""" - def test_varying_values(self): + def test_varying_values(self) -> None: """Candidates with different factor values get different scores.""" factors = { 1: {"max_timestamp": 100.0, "max_level": 3, "unique_users": 10, "event_count": 500}, @@ -104,7 +104,7 @@ def test_varying_values(self): # No single group dominates all factors, so scores should differ. assert len(set(scores.values())) == 3 - def test_all_identical(self): + def test_all_identical(self) -> None: """When all candidates have the same values, all scores are 0.""" factors = { 1: {"max_timestamp": 100.0, "max_level": 3, "unique_users": 10, "event_count": 50}, @@ -114,7 +114,7 @@ def test_all_identical(self): assert scores[1] == 0.0 assert scores[2] == 0.0 - def test_single_candidate(self): + def test_single_candidate(self) -> None: """A single candidate gets score 0 (min == max for all factors).""" factors = { 1: {"max_timestamp": 100.0, "max_level": 3, "unique_users": 10, "event_count": 50}, @@ -122,7 +122,7 @@ def test_single_candidate(self): scores = _agentic_triage_score([1], factors) assert scores[1] == 0.0 - def test_missing_group_in_snuba(self): + def test_missing_group_in_snuba(self) -> None: """A group not returned by Snuba gets 0 for all factors.""" factors = { 1: {"max_timestamp": 200.0, "max_level": 4, "unique_users": 100, "event_count": 500}, @@ -132,7 +132,7 @@ def test_missing_group_in_snuba(self): assert scores[1] > scores[2] assert scores[2] == 0.0 - def test_zero_weight_factor_skipped(self): + def test_zero_weight_factor_skipped(self) -> None: """Factors with weight=0 are excluded from the score.""" factors = { 1: {"max_timestamp": 100.0, "max_level": 3, "unique_users": 999, "event_count": 50}, @@ -146,7 +146,7 @@ def test_zero_weight_factor_skipped(self): # Group 2 has higher recency so it should score higher. assert scores[2] > scores[1] - def test_empty_inputs(self): + def test_empty_inputs(self) -> None: """Empty group_ids or factors returns all zeros.""" assert _agentic_triage_score([], {}) == {} assert _agentic_triage_score([1, 2], {}) == {1: 0.0, 2: 0.0} diff --git a/tests/sentry/tasks/seer/test_agentic_triage.py b/tests/sentry/tasks/seer/test_agentic_triage.py index a6177e97b4d7..934062f8a8a0 100644 --- a/tests/sentry/tasks/seer/test_agentic_triage.py +++ b/tests/sentry/tasks/seer/test_agentic_triage.py @@ -1,6 +1,8 @@ import time +from collections.abc import Iterator from datetime import UTC, datetime from inspect import unwrap +from typing import Any from unittest.mock import Mock, patch import pytest @@ -8,11 +10,12 @@ from django.test import override_settings from taskbroker_client.scheduler.config import crontab from taskbroker_client.scheduler.runner import ScheduleEntry +from taskbroker_client.task import Task from sentry.hybridcloud.models.outbox import CellOutbox from sentry.hybridcloud.outbox.category import OutboxCategory from sentry.models.group import Group -from sentry.models.organization import OrganizationStatus +from sentry.models.organization import Organization, OrganizationStatus from sentry.models.project import Project from sentry.processing_errors.grouptype import LowValueSpanConfigurationType from sentry.seer.agent.client import SeerAgentClient @@ -65,7 +68,9 @@ from sentry.utils.redis import redis_clusters -def _dispatched_feature_body(organization): +def _dispatched_feature_body( + organization: Organization, +) -> tuple[SeerRun, Any]: # justification: decoded outbox JSON payload seer_run = SeerRun.objects.get(organization=organization, type=SeerRunType.FEATURE_RUN) outbox = CellOutbox.objects.get( category=OutboxCategory.SEER_RUN_CREATE, @@ -77,7 +82,7 @@ def _dispatched_feature_body(organization): @override_settings(SENTRY_SELF_HOSTED=False) @django_db_all -def test_dispatch_leaves_tool_mode_to_seer(default_organization): +def test_dispatch_leaves_tool_mode_to_seer(default_organization: Organization) -> None: run = Factories.create_seer_workflow_run(organization=default_organization) plan = AgenticTriageShardPlan(payload={"candidates": []}, title="Triage") shards = [ @@ -100,7 +105,7 @@ def test_dispatch_leaves_tool_mode_to_seer(default_organization): @override_settings(SENTRY_SELF_HOSTED=False) @django_db_all -def test_redispatch_preserves_original_request(default_organization): +def test_redispatch_preserves_original_request(default_organization: Organization) -> None: run = Factories.create_seer_workflow_run(organization=default_organization) plan = AgenticTriageShardPlan(payload={"candidates": []}, title="Triage") shard = Factories.create_seer_workflow_run_execution(run=run, extras=plan.to_extras()) @@ -127,7 +132,7 @@ class AgenticTriageFixtures(Fixtures): project-eligibility and event-seeding logic lives in one place.""" @pytest.fixture(autouse=True) - def enable_agentic_triage(self): + def enable_agentic_triage(self) -> Iterator[None]: with ( override_options({"seer.night_shift.enable": True}), with_feature("organizations:seer-night-shift"), @@ -135,8 +140,12 @@ def enable_agentic_triage(self): yield def _make_eligible( - self, project, *, stopping_point=AutofixStoppingPoint.OPEN_PR.value, **tweak_overrides - ): + self, + project: Project, + *, + stopping_point: str = AutofixStoppingPoint.OPEN_PR.value, + **tweak_overrides: object, + ) -> Project: """Configure a project to pass every eligibility gate: automation on, a connected repo, a PR-producing stopping point, and tweaks enabled. Override stopping_point (or pass enabled=False) to exercise one gate.""" @@ -149,7 +158,14 @@ def _make_eligible( project.update_option("sentry:seer_nightshift_tweaks", {"enabled": True, **tweak_overrides}) return project - def _store_event_and_update_group(self, project, fingerprint, *, timestamp=None, **group_attrs): + def _store_event_and_update_group( + self, + project: Project, + fingerprint: object, + *, + timestamp: datetime | None = None, + **group_attrs: object, + ) -> Group: event = self.store_event( data={ "fingerprint": [fingerprint], @@ -231,7 +247,7 @@ def test_manual_overrides_win(self) -> None: (run_agentic_triage_execution, "run_agentic_triage_execution"), ], ) -def test_dispatch_keeps_names_known_to_old_workers(task, suffix: str) -> None: +def test_dispatch_keeps_names_known_to_old_workers(task: Task[..., Any], suffix: str) -> None: legacy_suffix = suffix.replace("agentic_triage", "night_shift") assert task.fullname == f"seer:sentry.tasks.seer.night_shift.{legacy_suffix}" assert seer_tasks.get(task.name) is task @@ -276,7 +292,7 @@ def test_uses_configured_agentic_triage_schedule(self) -> None: @django_db_all @override_settings(SENTRY_SELF_HOSTED=False) class TestScheduleAgenticTriage(TestCase): - def create_org_with_seer(self): + def create_org_with_seer(self) -> Organization: """Create an org with a SeerProjectRepository so it survives the pre-filter.""" org = self.create_organization() project = self.create_project(organization=org) @@ -1027,7 +1043,7 @@ class TestRunAgenticTriageFeatureDelivery(AgenticTriageFixtures, TestCase, Snuba reset_snuba_data = False - def _shard_group_ids(self, shard): + def _shard_group_ids(self, shard: SeerWorkflowRunExecution) -> list[int]: outbox = CellOutbox.objects.get( category=OutboxCategory.SEER_RUN_CREATE, object_identifier=shard.seer_run_id ) @@ -1279,7 +1295,9 @@ def test_partial_shard_failure_resumes_without_duplicate_runs(self) -> None: real_start_feature_run = SeerAgentClient.start_feature_run calls = 0 - def fail_second_dispatch(client, *args, **kwargs): + def fail_second_dispatch( + client: SeerAgentClient, *args: Any, **kwargs: Any + ) -> SeerRun: # justification: forwarded verbatim to start_feature_run nonlocal calls calls += 1 if calls == 2: diff --git a/tests/sentry/tasks/seer/test_autofix.py b/tests/sentry/tasks/seer/test_autofix.py index d16a1ad04779..b8ecb7c30636 100644 --- a/tests/sentry/tasks/seer/test_autofix.py +++ b/tests/sentry/tasks/seer/test_autofix.py @@ -1,4 +1,5 @@ from datetime import timedelta +from typing import Any from unittest.mock import ANY, MagicMock, Mock, call, patch import orjson @@ -7,6 +8,7 @@ from django.utils import timezone from urllib3.response import HTTPResponse +from sentry.models.organization import Organization from sentry.seer.autofix.constants import SeerAutomationSource from sentry.seer.autofix.exceptions import IssueSummaryUnavailable from sentry.seer.autofix.utils import get_seer_seat_based_tier_cache_key @@ -257,7 +259,12 @@ def test_summary_unavailable(self, mock_summary: MagicMock) -> None: class TestAutofixIssueDataJudge(SentryTestCase): - def _create_agentic_triage_run(self, organization, **kwargs): + def _create_agentic_triage_run( + # justification: factory kwargs vary per test case. + self, + organization: Organization, + **kwargs: Any, + ) -> SeerWorkflowRun: config = SeerWorkflowConfig.get_or_create_for_strategy( organization.id, SeerWorkflowStrategy.AGENTIC_TRIAGE ) diff --git a/tests/sentry/tasks/seer/test_backfill_supergroups_lightweight.py b/tests/sentry/tasks/seer/test_backfill_supergroups_lightweight.py index 310e71b8a55f..28167a0d3ba5 100644 --- a/tests/sentry/tasks/seer/test_backfill_supergroups_lightweight.py +++ b/tests/sentry/tasks/seer/test_backfill_supergroups_lightweight.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from datetime import UTC, datetime, timedelta from typing import Any, cast from unittest.mock import MagicMock, patch @@ -8,6 +9,7 @@ from sentry.eventstore import backend as eventstore from sentry.models.group import DEFAULT_TYPE_ID +from sentry.services.eventstore.models import Event from sentry.tasks.seer.backfill_supergroups_lightweight import ( backfill_supergroups_lightweight_for_org, ) @@ -28,8 +30,10 @@ TEST_SEER_SECRET = "viewer-context-test-secret-at-least-32-bytes" -def _make_event_data(message="test error", fingerprint=None): - data = { +def _make_event_data( + message: str = "test error", fingerprint: list[str] | None = None +) -> dict[str, object]: + data: dict[str, object] = { "message": message, "level": "error", "platform": "python", @@ -57,12 +61,13 @@ def _make_event_data(message="test error", fingerprint=None): class BackfillSupergroupsLightweightForOrgTest(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self.event = self.store_event( data=_make_event_data(), project_id=self.project.id, ) + assert self.event.group is not None self.group = self.event.group self.group.substatus = GroupSubStatus.NEW self.group.save(update_fields=["substatus"]) @@ -71,7 +76,7 @@ def setUp(self): @patch( "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) - def test_processes_groups_and_sends_to_seer(self, mock_request): + def test_processes_groups_and_sends_to_seer(self, mock_request: MagicMock) -> None: mock_request.return_value = MagicMock(status=200) backfill_supergroups_lightweight_for_org(self.organization.id) @@ -114,7 +119,7 @@ def test_task_activation_propagates_viewer_context_to_seer(self, mock_urlopen: A @patch( "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) - def test_moves_to_next_project_after_current_exhausted(self, mock_request): + def test_moves_to_next_project_after_current_exhausted(self, mock_request: MagicMock) -> None: """After finishing one project's groups, chains to the next project.""" mock_request.return_value = MagicMock(status=200) @@ -148,7 +153,7 @@ def test_moves_to_next_project_after_current_exhausted(self, mock_request): @patch( "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) - def test_self_chains_when_more_groups_exist(self, mock_request): + def test_self_chains_when_more_groups_exist(self, mock_request: MagicMock) -> None: mock_request.return_value = MagicMock(status=200) # Create enough groups to fill a batch @@ -179,7 +184,7 @@ def test_self_chains_when_more_groups_exist(self, mock_request): @patch( "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) - def test_completes_when_no_more_projects(self, mock_request): + def test_completes_when_no_more_projects(self, mock_request: MagicMock) -> None: """When all projects are exhausted, the task completes without chaining.""" mock_request.return_value = MagicMock(status=200) @@ -198,7 +203,7 @@ def test_completes_when_no_more_projects(self, mock_request): @patch( "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) - def test_respects_killswitch(self, mock_request): + def test_respects_killswitch(self, mock_request: MagicMock) -> None: with self.options({"seer.supergroups_backfill_lightweight.killswitch": True}): backfill_supergroups_lightweight_for_org(self.organization.id) @@ -207,7 +212,7 @@ def test_respects_killswitch(self, mock_request): @patch( "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) - def test_skips_without_feature_flag(self, mock_request): + def test_skips_without_feature_flag(self, mock_request: MagicMock) -> None: backfill_supergroups_lightweight_for_org(self.organization.id) mock_request.assert_not_called() @@ -216,7 +221,7 @@ def test_skips_without_feature_flag(self, mock_request): @patch( "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) - def test_continues_on_individual_group_failure(self, mock_request): + def test_continues_on_individual_group_failure(self, mock_request: MagicMock) -> None: event2 = self.store_event( data=_make_event_data("second error", fingerprint=["group2"]), project_id=self.project.id, @@ -239,7 +244,9 @@ def test_continues_on_individual_group_failure(self, mock_request): "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) @patch("sentry.tasks.seer.backfill_supergroups_lightweight.bulk_snuba_queries") - def test_skips_old_groups_with_no_events(self, mock_snuba, mock_request): + def test_skips_old_groups_with_no_events( + self, mock_snuba: MagicMock, mock_request: MagicMock + ) -> None: """Groups inside the age window but with no Snuba events are skipped.""" # Inside the 90-day DB cutoff so the group reaches Snuba; this exercises # the secondary skip path (e.g. events aged out of Snuba retention). @@ -257,7 +264,9 @@ def test_skips_old_groups_with_no_events(self, mock_snuba, mock_request): "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) @patch("sentry.tasks.seer.backfill_supergroups_lightweight.bulk_snuba_queries") - def test_skips_groups_older_than_max_age(self, mock_snuba, mock_request): + def test_skips_groups_older_than_max_age( + self, mock_snuba: MagicMock, mock_request: MagicMock + ) -> None: self.group.last_seen = datetime.now(UTC) - timedelta(days=91) self.group.save(update_fields=["last_seen"]) @@ -271,7 +280,7 @@ def test_skips_groups_older_than_max_age(self, mock_snuba, mock_request): @patch( "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) - def test_processes_groups_within_max_age(self, mock_request): + def test_processes_groups_within_max_age(self, mock_request: MagicMock) -> None: mock_request.return_value = MagicMock(status=200) self.group.last_seen = datetime.now(UTC) - timedelta(days=89) self.group.save(update_fields=["last_seen"]) @@ -284,7 +293,7 @@ def test_processes_groups_within_max_age(self, mock_request): @patch( "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) - def test_skips_security_report_event_types(self, mock_request): + def test_skips_security_report_event_types(self, mock_request: MagicMock) -> None: mock_request.return_value = MagicMock(status=200) # Bind real node data, then stamp the event as a security report so the @@ -292,7 +301,7 @@ def test_skips_security_report_event_types(self, mock_request): # normalization to mint a real CSP fixture in the test. original_bind = eventstore.bind_nodes - def bind_and_mark_csp(events): + def bind_and_mark_csp(events: Sequence[Event]) -> None: original_bind(events) for event in events: if event.data: @@ -312,7 +321,9 @@ def bind_and_mark_csp(events): "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) @patch("sentry.tasks.seer.backfill_supergroups_lightweight.bulk_snuba_queries") - def test_retries_snuba_query_on_failure(self, mock_snuba, mock_request, mock_sleep): + def test_retries_snuba_query_on_failure( + self, mock_snuba: MagicMock, mock_request: MagicMock, mock_sleep: MagicMock + ) -> None: mock_request.return_value = MagicMock(status=200) mock_snuba.side_effect = [ SnubaError("transient"), @@ -330,7 +341,9 @@ def test_retries_snuba_query_on_failure(self, mock_snuba, mock_request, mock_sle "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) @patch("sentry.tasks.seer.backfill_supergroups_lightweight.bulk_snuba_queries") - def test_raises_after_snuba_retries_exhausted(self, mock_snuba, mock_request, mock_sleep): + def test_raises_after_snuba_retries_exhausted( + self, mock_snuba: MagicMock, mock_request: MagicMock, mock_sleep: MagicMock + ) -> None: mock_snuba.side_effect = SnubaError("persistent") with pytest.raises(SnubaError): @@ -343,7 +356,7 @@ def test_raises_after_snuba_retries_exhausted(self, mock_snuba, mock_request, mo @patch( "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) - def test_skips_non_error_groups(self, mock_request): + def test_skips_non_error_groups(self, mock_request: MagicMock) -> None: self.group.type = DEFAULT_TYPE_ID + 1 self.group.save(update_fields=["type"]) @@ -355,7 +368,7 @@ def test_skips_non_error_groups(self, mock_request): @patch( "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) - def test_resumes_from_cursor(self, mock_request): + def test_resumes_from_cursor(self, mock_request: MagicMock) -> None: mock_request.return_value = MagicMock(status=200) event2 = self.store_event( @@ -380,7 +393,7 @@ def test_resumes_from_cursor(self, mock_request): @patch( "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) - def test_chains_then_completes_on_exact_batch_boundary(self, mock_request): + def test_chains_then_completes_on_exact_batch_boundary(self, mock_request: MagicMock) -> None: mock_request.return_value = MagicMock(status=200) # Create exactly TEST_BATCH_SIZE groups total (setUp already created 1) @@ -433,11 +446,11 @@ def test_chains_then_completes_on_exact_batch_boundary(self, mock_request): @patch( "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) - def test_skips_events_without_stacktrace(self, mock_request): + def test_skips_events_without_stacktrace(self, mock_request: MagicMock) -> None: """Events without stacktraces are filtered out before sending to Seer.""" original_bind = eventstore.bind_nodes - def bind_and_strip_stacktrace(events): + def bind_and_strip_stacktrace(events: Sequence[Event]) -> None: original_bind(events) for event in events: if event.data: @@ -457,11 +470,11 @@ def bind_and_strip_stacktrace(events): @patch( "sentry.tasks.seer.backfill_supergroups_lightweight.make_lightweight_rca_cluster_request" ) - def test_skips_events_with_unsupported_platform(self, mock_request): + def test_skips_events_with_unsupported_platform(self, mock_request: MagicMock) -> None: """Events with platform 'other' are filtered out before sending to Seer.""" original_bind = eventstore.bind_nodes - def bind_and_set_platform_other(events): + def bind_and_set_platform_other(events: Sequence[Event]) -> None: original_bind(events) for event in events: if event.data: diff --git a/tests/sentry/tasks/seer/test_context_engine_index.py b/tests/sentry/tasks/seer/test_context_engine_index.py index e06456505cc6..131d3c459854 100644 --- a/tests/sentry/tasks/seer/test_context_engine_index.py +++ b/tests/sentry/tasks/seer/test_context_engine_index.py @@ -1,10 +1,12 @@ from unittest import mock +from unittest.mock import MagicMock import orjson import pytest from django.test import override_settings from urllib3.response import HTTPResponse +from sentry.models.organization import Organization from sentry.seer.agent.context_engine_utils import ProjectEventCounts from sentry.tasks.seer.context_engine_index import ( get_allowed_org_ids_context_engine_indexing, @@ -60,7 +62,7 @@ def test_returns_early_when_no_high_volume_projects(self) -> None: @override_settings(SEER_API_SHARED_SECRET="viewer-context-test-secret") @mock.patch("sentry.seer.signed_seer_api.seer_autofix_default_connection_pool.urlopen") - def test_calls_seer_endpoint_with_correct_payload(self, mock_urlopen): + def test_calls_seer_endpoint_with_correct_payload(self, mock_urlopen: MagicMock) -> None: mock_urlopen.return_value = HTTPResponse(b"", status=200) event_counts = { @@ -112,7 +114,7 @@ def test_calls_seer_endpoint_with_correct_payload(self, mock_urlopen): assert get_viewer_context() is None @mock.patch("sentry.tasks.seer.context_engine_index.make_org_project_knowledge_index_request") - def test_raises_on_seer_error(self, mock_request): + def test_raises_on_seer_error(self, mock_request: MagicMock) -> None: mock_request.return_value.status = 500 with override_options({"explorer.context_engine_indexing.enable": True}): @@ -140,7 +142,7 @@ def test_raises_on_seer_error(self, mock_request): @django_db_all class TestGetAllowedOrgIdsContextEngineIndexing(TestCase): - def _create_org_with_github(self): + def _create_org_with_github(self) -> Organization: org = self.create_organization() self.create_integration( organization=org, @@ -159,7 +161,9 @@ def test_returns_only_orgs_assigned_to_current_slot(self) -> None: target_slot = int(md5_text(str(org_ids[0])).hexdigest(), 16) % TOTAL_SLOTS frozen_time = f"2024-01-14 {target_slot:02d}:00:00" - def feature_enabled_for_test_orgs(_flag_name: str, org, *args, **kwargs) -> bool: + def feature_enabled_for_test_orgs( + _flag_name: str, org: Organization, *args: object, **kwargs: object + ) -> bool: return org.id in org_ids with freeze_time(frozen_time): @@ -272,7 +276,9 @@ def test_excludes_orgs_without_github_integration(self) -> None: target_slot = int(md5_text(str(org_with_github.id)).hexdigest(), 16) % TOTAL_SLOTS frozen_time = f"2024-01-14 {target_slot:02d}:00:00" - def feature_enabled_for_all(_flag_name: str, org, *args, **kwargs) -> bool: + def feature_enabled_for_all( + _flag_name: str, org: Organization, *args: object, **kwargs: object + ) -> bool: return True with freeze_time(frozen_time): @@ -332,7 +338,7 @@ def setUp(self) -> None: @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") def test_returns_early_when_option_disabled( - self, mock_make_org_repo_knowledge_index_request + self, mock_make_org_repo_knowledge_index_request: MagicMock ) -> None: with override_options({"explorer.context_engine_indexing.enable": False}): index_repos(self.org.id) @@ -340,7 +346,7 @@ def test_returns_early_when_option_disabled( @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") def test_returns_early_when_feature_flag_disabled( - self, mock_make_org_repo_knowledge_index_request + self, mock_make_org_repo_knowledge_index_request: MagicMock ) -> None: with override_options({"explorer.context_engine_indexing.enable": True}): index_repos(self.org.id) @@ -348,7 +354,7 @@ def test_returns_early_when_feature_flag_disabled( @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") def test_returns_early_when_no_projects( - self, mock_make_org_repo_knowledge_index_request + self, mock_make_org_repo_knowledge_index_request: MagicMock ) -> None: org_without_projects = self.create_organization() with override_options({"explorer.context_engine_indexing.enable": True}): @@ -358,7 +364,7 @@ def test_returns_early_when_no_projects( @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") def test_calls_seer_with_correct_org_and_repos( - self, mock_make_org_repo_knowledge_index_request + self, mock_make_org_repo_knowledge_index_request: MagicMock ) -> None: self.create_seer_project_repository(project=self.project1, repository=self.repo1) self.create_seer_project_repository(project=self.project2, repository=self.repo2) @@ -402,7 +408,7 @@ def make_request(*args: object, **kwargs: object) -> mock.Mock: @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") def test_deduplicates_repos_across_projects( - self, mock_make_org_repo_knowledge_index_request + self, mock_make_org_repo_knowledge_index_request: MagicMock ) -> None: self.create_seer_project_repository(project=self.project1, repository=self.repo1) self.create_seer_project_repository(project=self.project2, repository=self.repo1) @@ -430,7 +436,7 @@ def test_deduplicates_repos_across_projects( @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") def test_uses_seer_project_preferences_if_available( - self, mock_make_org_repo_knowledge_index_request + self, mock_make_org_repo_knowledge_index_request: MagicMock ) -> None: mock_make_org_repo_knowledge_index_request.return_value.status = 200 # Map project2 to the same repo as project1 @@ -466,7 +472,7 @@ def test_uses_seer_project_preferences_if_available( @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") def test_skips_projects_without_seer_repositories( - self, mock_make_org_repo_knowledge_index_request + self, mock_make_org_repo_knowledge_index_request: MagicMock ) -> None: mock_make_org_repo_knowledge_index_request.return_value.status = 200 @@ -489,7 +495,9 @@ def test_skips_projects_without_seer_repositories( class TestScheduleContextEngineIndexingTasks(TestCase): @override_settings(SEER_API_SHARED_SECRET="viewer-context-test-secret") @mock.patch("sentry.seer.signed_seer_api.seer_autofix_default_connection_pool.urlopen") - def test_global_knowledge_index_sends_system_viewer_context(self, mock_urlopen) -> None: + def test_global_knowledge_index_sends_system_viewer_context( + self, mock_urlopen: MagicMock + ) -> None: mock_urlopen.return_value = HTTPResponse(b"", status=200) index_sentry_knowledge() @@ -508,8 +516,12 @@ def test_global_knowledge_index_sends_system_viewer_context(self, mock_urlopen) "sentry.tasks.seer.context_engine_index.get_allowed_org_ids_context_engine_indexing" ) def test_dispatches_for_allowed_orgs( - self, mock_get_orgs, mock_index, mock_build, mock_index_repos - ): + self, + mock_get_orgs: MagicMock, + mock_index: MagicMock, + mock_build: MagicMock, + mock_index_repos: MagicMock, + ) -> None: org1 = self.create_organization() org2 = self.create_organization() mock_get_orgs.return_value = [org1.id, org2.id] @@ -536,8 +548,12 @@ def test_dispatches_for_allowed_orgs( "sentry.tasks.seer.context_engine_index.get_allowed_org_ids_context_engine_indexing" ) def test_dispatches_index_repos_on_sunday( - self, mock_get_orgs, mock_index, mock_build, mock_index_repos - ): + self, + mock_get_orgs: MagicMock, + mock_index: MagicMock, + mock_build: MagicMock, + mock_index_repos: MagicMock, + ) -> None: org1 = self.create_organization() mock_get_orgs.return_value = [org1.id] @@ -557,7 +573,9 @@ def test_dispatches_index_repos_on_sunday( @mock.patch("sentry.tasks.seer.context_engine_index.index_repos.apply_async") @mock.patch("sentry.tasks.seer.context_engine_index.build_service_map.apply_async") @mock.patch("sentry.tasks.seer.context_engine_index.index_org_project_knowledge.apply_async") - def test_noop_when_no_allowed_orgs(self, mock_index, mock_build, mock_index_repos): + def test_noop_when_no_allowed_orgs( + self, mock_index: MagicMock, mock_build: MagicMock, mock_index_repos: MagicMock + ) -> None: with override_options({"explorer.context_engine_indexing.enable": True}): schedule_context_engine_indexing_tasks() diff --git a/tests/sentry/tasks/seer/test_explorer_index.py b/tests/sentry/tasks/seer/test_explorer_index.py index e1389357744a..a95994fa28a5 100644 --- a/tests/sentry/tasks/seer/test_explorer_index.py +++ b/tests/sentry/tasks/seer/test_explorer_index.py @@ -1,6 +1,6 @@ from datetime import UTC, datetime from unittest import mock -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from django.test import override_settings @@ -361,7 +361,7 @@ def test_batches_projects_with_delays(self) -> None: @django_db_all class TestRunExplorerIndexForProjects(TestCase): @patch("sentry.tasks.seer.explorer_index.make_agent_index_request") - def test_calls_seer_endpoint_successfully(self, mock_request): + def test_calls_seer_endpoint_successfully(self, mock_request: MagicMock) -> None: mock_request.return_value.status = 200 mock_request.return_value.json.return_value = {"scheduled_count": 3, "projects": []} @@ -379,7 +379,7 @@ def test_calls_seer_endpoint_successfully(self, mock_request): ] @patch("sentry.tasks.seer.explorer_index.make_agent_index_request") - def test_handles_request_error(self, mock_request): + def test_handles_request_error(self, mock_request: MagicMock) -> None: mock_request.return_value.status = 500 projects = [(1, 100)] @@ -398,7 +398,7 @@ def test_skips_when_killswitch_enabled(self) -> None: @override_settings(SEER_API_SHARED_SECRET="viewer-context-test-secret") @patch("sentry.seer.signed_seer_api.seer_autofix_default_connection_pool.urlopen") - def test_sets_viewer_context_for_single_org_batch(self, mock_urlopen): + def test_sets_viewer_context_for_single_org_batch(self, mock_urlopen: MagicMock) -> None: mock_urlopen.return_value = HTTPResponse(b'{"scheduled_count":2,"projects":[]}', status=200) run_explorer_index_for_projects([(1, 100), (2, 100)], "2024-01-15T12:00:00+00:00") @@ -414,7 +414,9 @@ def test_sets_viewer_context_for_single_org_batch(self, mock_urlopen): @override_settings(SEER_API_SHARED_SECRET="viewer-context-test-secret") @patch("sentry.seer.signed_seer_api.seer_autofix_default_connection_pool.urlopen") - def test_sets_system_viewer_context_without_org_for_multi_org_batch(self, mock_urlopen): + def test_sets_system_viewer_context_without_org_for_multi_org_batch( + self, mock_urlopen: MagicMock + ) -> None: mock_urlopen.return_value = HTTPResponse(b'{"scheduled_count":2,"projects":[]}', status=200) run_explorer_index_for_projects([(1, 100), (2, 200)], "2024-01-15T12:00:00+00:00") diff --git a/tests/sentry/tasks/seer/test_pr_iteration.py b/tests/sentry/tasks/seer/test_pr_iteration.py index 270f318b41df..d949dec3c989 100644 --- a/tests/sentry/tasks/seer/test_pr_iteration.py +++ b/tests/sentry/tasks/seer/test_pr_iteration.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from contextlib import AbstractContextManager, nullcontext from datetime import timedelta from typing import Any, Literal @@ -1480,7 +1481,7 @@ def test_collapses_duplicate_review_body_ids_in_batch( mock_trigger: MagicMock, ) -> None: mock_fetch.return_value = self._state() - body = lambda: Feedback( + body: Callable[[], Feedback] = lambda: Feedback( source=GithubPrReviewBodyFeedbackSource(review_id=500, body="summary") ) mock_pop.return_value = [self._queued(body()), self._queued(body())] diff --git a/tests/sentry/tasks/test_ai_agent_monitoring.py b/tests/sentry/tasks/test_ai_agent_monitoring.py index 545c1700d018..05c451dd406b 100644 --- a/tests/sentry/tasks/test_ai_agent_monitoring.py +++ b/tests/sentry/tasks/test_ai_agent_monitoring.py @@ -1,3 +1,5 @@ +from typing import Any, cast + import pytest import responses @@ -15,7 +17,8 @@ def _get_metadata_from_cache() -> AIModelMetadataConfig | None: """ Utility function to retrieve LLM model metadata from cache. """ - return cache.get(AI_MODEL_METADATA_CACHE_KEY) + # justification: this cache key only ever stores AIModelMetadataConfig payloads. + return cast("AIModelMetadataConfig | None", cache.get(AI_MODEL_METADATA_CACHE_KEY)) MOCK_OPENROUTER_API_RESPONSE = { @@ -179,7 +182,7 @@ def setUp(self) -> None: # Clear cache before each test cache.delete(AI_MODEL_METADATA_CACHE_KEY) - def _mock_openrouter_api_response(self, mock_response: dict): + def _mock_openrouter_api_response(self, mock_response: dict[str, Any]) -> None: responses.add( responses.GET, OPENROUTER_MODELS_API_URL, @@ -187,7 +190,7 @@ def _mock_openrouter_api_response(self, mock_response: dict): status=200, ) - def _mock_models_dev_api_response(self, mock_response: dict): + def _mock_models_dev_api_response(self, mock_response: dict[str, Any]) -> None: responses.add( responses.GET, MODELS_DEV_API_URL, diff --git a/tests/sentry/tasks/test_assemble.py b/tests/sentry/tasks/test_assemble.py index aa8b47c46149..3c9521773907 100644 --- a/tests/sentry/tasks/test_assemble.py +++ b/tests/sentry/tasks/test_assemble.py @@ -4,7 +4,7 @@ import zipfile from datetime import UTC, datetime, timedelta from hashlib import sha1 -from typing import Any +from typing import Any, cast from unittest import mock from unittest.mock import MagicMock, patch @@ -49,6 +49,9 @@ from sentry.testutils.helpers.redis import use_redis_cluster from sentry.testutils.objectstore import debug_files_test_both_backends +# justification: assemble_artifacts treats version=None as "no release" but is typed for str +_NO_RELEASE = cast(str, None) + class BaseAssembleTest(TestCase): def setUp(self) -> None: @@ -123,8 +126,8 @@ def test_exclusive_objectstore_dif_avoids_creating_file(self) -> None: ) dif = ProjectDebugFile.objects.get(project_id=self.project.id, checksum=checksum) - assert dif.file_id is None - assert dif.storage_path is not None + assert cast("object", dif.file_id) is None + assert dif.storage_path assert not File.objects.filter(type="project.dif", checksum=checksum).exists() assert not FileBlobIndex.objects.exists() @@ -144,8 +147,8 @@ def test_assemble_from_files(self) -> None: for reference, checksum in files: file_blob = FileBlob.objects.get(checksum=checksum) ref_bytes = reference.getvalue() - with file_blob.getfile() as f: - assert f.read(len(ref_bytes)) == ref_bytes + with file_blob.getfile() as assembled_file: + assert assembled_file.read(len(ref_bytes)) == ref_bytes FileBlobOwner.objects.filter(blob=file_blob, organization_id=self.organization.id).get() rv = assemble_file( @@ -164,8 +167,8 @@ def test_assemble_from_files(self) -> None: assert f.type == "dummy.type" # upload all blobs a second time - for f, _ in files: - f.seek(0) + for uploaded_file, _ in files: + uploaded_file.seek(0) FileBlob.from_files(files, organization=self.organization) # assemble a second time @@ -198,8 +201,8 @@ def test_assemble_duplicate_blobs(self) -> None: for reference, checksum in files: file_blob = FileBlob.objects.get(checksum=checksum) ref_bytes = reference.getvalue() - with file_blob.getfile() as f: - assert f.read(len(ref_bytes)) == ref_bytes + with file_blob.getfile() as assembled_file: + assert assembled_file.read(len(ref_bytes)) == ref_bytes FileBlobOwner.objects.filter(blob=file_blob, organization_id=self.organization.id).get() rv = assemble_file( @@ -254,9 +257,9 @@ def test_artifacts_with_debug_ids(self) -> None: expected_debug_ids = ["eb6e60f1-65ff-4f6f-adff-f1bbeded627b"] for version, dist, count in [ - (None, None, 0), + (_NO_RELEASE, None, 0), ("1.0", None, 1), - (None, "android", 0), + (_NO_RELEASE, "android", 0), ("1.0", "android", 1), ]: assemble_artifacts( @@ -385,6 +388,7 @@ def test_assembled_bundle_is_rejected_if_a_file_is_too_large(self) -> None: AssembleTask.ARTIFACT_BUNDLE, self.organization.id, total_checksum ) assert status == ChunkFileState.ERROR + assert details is not None assert "exceeds the maximum uncompressed file size" in details files = File.objects.filter() @@ -699,7 +703,7 @@ def test_upload_multiple_artifacts_with_same_bundle_id_and_no_release_dist_pair( assemble_artifacts( org_id=self.organization.id, project_ids=[self.project.id], - version=None, + version=_NO_RELEASE, checksum=total_checksum, chunks=[blob1.checksum], ) @@ -761,7 +765,7 @@ def test_upload_multiple_artifacts_with_same_bundle_id_and_different_release_dis def test_upload_multiple_artifacts_with_first_release_and_second_no_release_and_same_bundle_id( self, - ): + ) -> None: bundle_file = self.create_artifact_bundle_zip( fixture_path="artifact_bundle_debug_ids", project=self.project.id ) @@ -770,7 +774,7 @@ def test_upload_multiple_artifacts_with_first_release_and_second_no_release_and_ bundle_id = "67429b2f-1d9e-43bb-a626-771a1e37555c" debug_id = "eb6e60f1-65ff-4f6f-adff-f1bbeded627b" - for version in ("1.0", None): + for version in ("1.0", _NO_RELEASE): assemble_artifacts( org_id=self.organization.id, project_ids=[self.project.id], @@ -797,7 +801,7 @@ def test_upload_multiple_artifacts_with_first_release_and_second_no_release_and_ def test_upload_multiple_artifacts_with_first_no_release_and_second_release_and_same_bundle_id( self, - ): + ) -> None: bundle_file = self.create_artifact_bundle_zip( fixture_path="artifact_bundle_debug_ids", project=self.project.id ) @@ -806,7 +810,7 @@ def test_upload_multiple_artifacts_with_first_no_release_and_second_release_and_ bundle_id = "67429b2f-1d9e-43bb-a626-771a1e37555c" debug_id = "eb6e60f1-65ff-4f6f-adff-f1bbeded627b" - for version in (None, "1.0"): + for version in (_NO_RELEASE, "1.0"): assemble_artifacts( org_id=self.organization.id, project_ids=[self.project.id], @@ -833,7 +837,7 @@ def test_upload_multiple_artifacts_with_first_no_release_and_second_release_and_ def test_upload_multiple_artifacts_with_existing_bundle_id_duplicate( self, - ): + ) -> None: bundle_file = self.create_artifact_bundle_zip( fixture_path="artifact_bundle_debug_ids", project=self.project.id ) @@ -860,7 +864,7 @@ def test_upload_multiple_artifacts_with_existing_bundle_id_duplicate( assemble_artifacts( org_id=self.organization.id, project_ids=[self.project.id], - version=None, + version=_NO_RELEASE, checksum=total_checksum, chunks=[blob1.checksum], ) @@ -1095,7 +1099,7 @@ def test_get_url_extension(url: str, extension: str) -> None: class AssemblePlaceholderReleaseTest(BaseAssembleTest): debug_id = "eb6e60f1-65ff-4f6f-adff-f1bbeded627b" - def assemble(self, bundle_file: bytes, version: str | None, dist: str | None = None) -> None: + def assemble(self, bundle_file: bytes, version: str, dist: str | None = None) -> None: blob = FileBlob.from_file_with_organization(ContentFile(bundle_file), self.organization) checksum = sha1(bundle_file).hexdigest() assemble_artifacts( @@ -1154,7 +1158,7 @@ def test_placeholder_release_ignored(self) -> None: def test_placeholder_release_from_manifest_ignored(self) -> None: self.assemble( make_artifact_bundle(make_debug_id_files(self.debug_id), release="null"), - version=None, + version=_NO_RELEASE, ) assert self.release_names() == [] @@ -1264,7 +1268,14 @@ def test_real_release_kept(self) -> None: @freeze_time("2023-05-31T10:00:00") class ArtifactBundleIndexingTest(TestCase): - def _create_bundle_and_bind_to_release(self, release, dist, bundle_id, indexing_state, date): + def _create_bundle_and_bind_to_release( + self, + release: str, + dist: str, + bundle_id: str, + indexing_state: int, + date: datetime, + ) -> ArtifactBundle: artifact_bundle = ArtifactBundle.objects.create( organization_id=self.organization.id, bundle_id=bundle_id, @@ -1318,7 +1329,8 @@ def test_index_if_needed_with_no_bundles( project_ids=[], ) as post_assembler: post_assembler._index_bundle_if_needed( - artifact_bundle=None, + # justification: the bundle is not read below the indexing threshold + artifact_bundle=cast(ArtifactBundle, None), release=release, dist=dist, ) @@ -1327,8 +1339,8 @@ def test_index_if_needed_with_no_bundles( @patch("sentry.tasks.assemble.index_artifact_bundles_for_release") def test_index_if_needed_with_lower_bundles_than_threshold( - self, index_artifact_bundles_for_release - ): + self, index_artifact_bundles_for_release: MagicMock + ) -> None: release = "1.0" dist = "android" @@ -1348,7 +1360,8 @@ def test_index_if_needed_with_lower_bundles_than_threshold( project_ids=[], ) as post_assembler: post_assembler._index_bundle_if_needed( - artifact_bundle=None, + # justification: the bundle is not read below the indexing threshold + artifact_bundle=cast(ArtifactBundle, None), release=release, dist=dist, ) @@ -1357,8 +1370,8 @@ def test_index_if_needed_with_lower_bundles_than_threshold( @patch("sentry.tasks.assemble.index_artifact_bundles_for_release") def test_index_if_needed_with_higher_bundles_than_threshold( - self, index_artifact_bundles_for_release - ): + self, index_artifact_bundles_for_release: MagicMock + ) -> None: release = "1.0" dist = "android" @@ -1434,14 +1447,19 @@ def test_index_if_needed_with_bundles_already_indexed( dist=dist, project_ids=[], ) as post_assembler: - post_assembler._index_bundle_if_needed(artifact_bundle=None, release=release, dist=dist) + post_assembler._index_bundle_if_needed( + # justification: the bundle is not read below the indexing threshold + artifact_bundle=cast(ArtifactBundle, None), + release=release, + dist=dist, + ) index_artifact_bundles_for_release.assert_not_called() @patch("sentry.tasks.assemble.index_artifact_bundles_for_release") def test_index_if_needed_with_newer_bundle_already_stored( - self, index_artifact_bundles_for_release - ): + self, index_artifact_bundles_for_release: MagicMock + ) -> None: release = "1.0" dist = "android" @@ -1493,7 +1511,7 @@ def test_index_if_needed_with_newer_bundle_already_stored( @use_redis_cluster() def test_redis_assemble_status() -> None: task = AssembleTask.DIF - project_id = uuid.uuid4().hex + project_id = uuid.uuid4().int checksum = uuid.uuid4().hex # If it doesn't exist, it should return correct values. diff --git a/tests/sentry/tasks/test_auto_ongoing_issues.py b/tests/sentry/tasks/test_auto_ongoing_issues.py index f0c2fb279824..049a8d65893b 100644 --- a/tests/sentry/tasks/test_auto_ongoing_issues.py +++ b/tests/sentry/tasks/test_auto_ongoing_issues.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta, timezone from unittest import mock +from unittest.mock import MagicMock from sentry.issues.ongoing import TRANSITION_AFTER_DAYS from sentry.models.activity import Activity @@ -159,7 +160,7 @@ def test_multiple_old_new(self) -> None: @mock.patch("sentry.utils.metrics.incr") @mock.patch("sentry.tasks.auto_ongoing_issues.ITERATOR_CHUNK", new=2) @mock.patch("sentry.tasks.auto_ongoing_issues.CHILD_TASK_COUNT", new=50) - def test_not_all_groups_get_updated(self, mock_metrics_incr) -> None: + def test_not_all_groups_get_updated(self, mock_metrics_incr: MagicMock) -> None: now = datetime.now(tz=timezone.utc) project = self.create_project() groups_count = 110 @@ -314,7 +315,7 @@ def test_simple(self) -> None: @mock.patch("sentry.utils.metrics.incr") @mock.patch("sentry.tasks.auto_ongoing_issues.ITERATOR_CHUNK", new=2) @mock.patch("sentry.tasks.auto_ongoing_issues.CHILD_TASK_COUNT", new=50) - def test_not_all_groups_get_updated(self, mock_metrics_incr) -> None: + def test_not_all_groups_get_updated(self, mock_metrics_incr: MagicMock) -> None: now = datetime.now(tz=timezone.utc) project = self.create_project() groups_count = 110 @@ -478,7 +479,7 @@ def test_simple(self) -> None: @mock.patch("sentry.utils.metrics.incr") @mock.patch("sentry.tasks.auto_ongoing_issues.ITERATOR_CHUNK", new=2) @mock.patch("sentry.tasks.auto_ongoing_issues.CHILD_TASK_COUNT", new=50) - def test_not_all_groups_get_updated(self, mock_metrics_incr) -> None: + def test_not_all_groups_get_updated(self, mock_metrics_incr: MagicMock) -> None: now = datetime.now(tz=timezone.utc) project = self.create_project() groups_count = 110 diff --git a/tests/sentry/tasks/test_backfill_group_action_log.py b/tests/sentry/tasks/test_backfill_group_action_log.py index 22b2864ac4cb..12d739121aa9 100644 --- a/tests/sentry/tasks/test_backfill_group_action_log.py +++ b/tests/sentry/tasks/test_backfill_group_action_log.py @@ -219,6 +219,7 @@ def setUp(self) -> None: data={"message": "test error", "level": "error"}, project_id=self.project.id, ) + assert self.event.group is not None self.group = self.event.group def _options( diff --git a/tests/sentry/tasks/test_base.py b/tests/sentry/tasks/test_base.py index 30441fbab3e9..ee404f25991c 100644 --- a/tests/sentry/tasks/test_base.py +++ b/tests/sentry/tasks/test_base.py @@ -15,7 +15,7 @@ namespace=test_tasks, silo_mode=SiloMode.CELL, ) -def region_task(param) -> str: +def region_task(param: str) -> str: return f"Region task {param}" @@ -24,7 +24,7 @@ def region_task(param) -> str: namespace=test_tasks, silo_mode=SiloMode.CONTROL, ) -def control_task(param) -> str: +def control_task(param: str) -> str: return f"Control task {param}" @@ -33,7 +33,7 @@ def control_task(param) -> str: namespace=test_tasks, alias="tests.tasks.test_base.alias_task", ) -def task_with_alias(param) -> str: +def task_with_alias(param: str) -> str: return f"Task with alias {param}" @@ -44,7 +44,7 @@ def task_with_alias(param) -> str: retry=Retry(times=3, on=(Exception,)), silo_mode=SiloMode.CELL, ) -def region_task_with_alias(param) -> str: +def region_task_with_alias(param: str) -> str: return f"Region task with alias {param}" @@ -54,7 +54,7 @@ def region_task_with_alias(param) -> str: alias="tests.tasks.test_base.control_alias_task", silo_mode=SiloMode.CONTROL, ) -def control_task_with_alias(param) -> str: +def control_task_with_alias(param: str) -> str: return f"Control task with alias {param}" @@ -64,7 +64,7 @@ def control_task_with_alias(param) -> str: alias="tests.tasks.test_base.alias_task_alias_namespace", alias_namespace=exampletasks, ) -def task_with_alias_and_alias_namespace(param) -> str: +def task_with_alias_and_alias_namespace(param: str) -> str: return f"Task with alias and alias namespace {param}" @@ -118,7 +118,7 @@ def test_instrumented_task_parameters() -> None: processing_deadline_duration=60, compression_type=CompressionType.ZSTD, ) - def hello_task(): + def hello_task() -> None: pass decorated = namespace.get("hello_task") diff --git a/tests/sentry/tasks/test_beacon.py b/tests/sentry/tasks/test_beacon.py index a79d5693183a..a00b33e2a82a 100644 --- a/tests/sentry/tasks/test_beacon.py +++ b/tests/sentry/tasks/test_beacon.py @@ -2,7 +2,7 @@ from datetime import timedelta from importlib.metadata import PackageNotFoundError from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch from uuid import uuid4 import responses @@ -119,13 +119,13 @@ def setUp(self) -> None: @responses.activate def test_simple( self, - safe_urlread, - safe_urlopen, - mock_get_all_package_versions, - mock_cpu_count, - mock_cpu_percent, - mock_virtual_memory, - ): + safe_urlread: MagicMock, + safe_urlopen: MagicMock, + mock_get_all_package_versions: MagicMock, + mock_cpu_count: MagicMock, + mock_cpu_percent: MagicMock, + mock_virtual_memory: MagicMock, + ) -> None: self.organization self.project self.team @@ -179,13 +179,13 @@ def test_simple( @responses.activate def test_no_cpu_ram_usage( self, - safe_urlread, - safe_urlopen, - mock_get_all_package_versions, - mock_cpu_count, - mock_cpu_percent, - mock_virtual_memory, - ): + safe_urlread: MagicMock, + safe_urlopen: MagicMock, + mock_get_all_package_versions: MagicMock, + mock_cpu_count: MagicMock, + mock_cpu_percent: MagicMock, + mock_virtual_memory: MagicMock, + ) -> None: self.organization self.project self.team @@ -239,13 +239,13 @@ def test_no_cpu_ram_usage( @responses.activate def test_anonymous( self, - safe_urlread, - safe_urlopen, - mock_get_all_package_versions, - mock_cpu_count, - mock_cpu_percent, - mock_virtual_memory, - ): + safe_urlread: MagicMock, + safe_urlopen: MagicMock, + mock_get_all_package_versions: MagicMock, + mock_cpu_count: MagicMock, + mock_cpu_percent: MagicMock, + mock_virtual_memory: MagicMock, + ) -> None: self.organization self.project self.team @@ -298,13 +298,13 @@ def test_anonymous( @responses.activate def test_with_broadcasts( self, - safe_urlread, - safe_urlopen, - mock_get_all_package_versions, - mock_cpu_count, - mock_cpu_percent, - mock_virtual_memory, - ): + safe_urlread: MagicMock, + safe_urlopen: MagicMock, + mock_get_all_package_versions: MagicMock, + mock_cpu_count: MagicMock, + mock_cpu_percent: MagicMock, + mock_virtual_memory: MagicMock, + ) -> None: broadcast_id = uuid4().hex mock_get_all_package_versions.return_value = {} safe_urlread.return_value = json.dumps( @@ -362,13 +362,13 @@ def test_with_broadcasts( @responses.activate def test_disabled( self, - safe_urlread, - safe_urlopen, - mock_get_all_package_versions, - mock_cpu_count, - mock_cpu_percent, - mock_virtual_memory, - ): + safe_urlread: MagicMock, + safe_urlopen: MagicMock, + mock_get_all_package_versions: MagicMock, + mock_cpu_count: MagicMock, + mock_cpu_percent: MagicMock, + mock_virtual_memory: MagicMock, + ) -> None: mock_get_all_package_versions.return_value = {"foo": "1.0"} with self.settings(SENTRY_BEACON=False): @@ -382,13 +382,13 @@ def test_disabled( @responses.activate def test_debug( self, - safe_urlread, - safe_urlopen, - mock_get_all_package_versions, - mock_cpu_count, - mock_cpu_percent, - mock_virtual_memory, - ): + safe_urlread: MagicMock, + safe_urlopen: MagicMock, + mock_get_all_package_versions: MagicMock, + mock_cpu_count: MagicMock, + mock_cpu_percent: MagicMock, + mock_virtual_memory: MagicMock, + ) -> None: mock_get_all_package_versions.return_value = {"foo": "1.0"} with self.settings(DEBUG=True): @@ -400,11 +400,11 @@ def test_debug( @responses.activate def test_metrics( self, - safe_urlopen, - mock_cpu_count, - mock_cpu_percent, - mock_virtual_memory, - ): + safe_urlopen: MagicMock, + mock_cpu_count: MagicMock, + mock_cpu_percent: MagicMock, + mock_virtual_memory: MagicMock, + ) -> None: metrics = [ { "description": "SentryApp", diff --git a/tests/sentry/tasks/test_commit_context.py b/tests/sentry/tasks/test_commit_context.py index 2e9a5dd09098..30c2b17ecbb2 100644 --- a/tests/sentry/tasks/test_commit_context.py +++ b/tests/sentry/tasks/test_commit_context.py @@ -1,6 +1,8 @@ import logging +from collections.abc import Callable from datetime import datetime, timedelta from datetime import timezone as datetime_timezone +from typing import Any from unittest.mock import MagicMock, patch import responses @@ -24,6 +26,7 @@ from sentry.integrations.types import EventLifecycleOutcome from sentry.models.commit import Commit from sentry.models.commitauthor import CommitAuthor +from sentry.models.group import Group from sentry.models.groupowner import GroupOwner, GroupOwnerType, SuspectCommitStrategy from sentry.models.pullrequest import ( CommentType, @@ -104,6 +107,18 @@ def setUp(self) -> None: project_id=self.project.id, ) + @property + def event_group(self) -> Group: + group = self.event.group + assert group is not None + return group + + @property + def event_platform(self) -> str: + platform = self.event.platform + assert platform is not None + return platform + class TestCommitContextAllFrames(TestCommitContextIntegration): def setUp(self) -> None: @@ -188,9 +203,9 @@ def test_inactive_integration(self, mock_get_commit_context: MagicMock) -> None: event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) @@ -222,9 +237,9 @@ def test_success_existing_commit( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) @@ -253,7 +268,7 @@ def test_success_existing_commit( IntegrationsSuccessfullyFetchedCommitContextAllFrames( organization_id=self.organization.id, project_id=self.project.id, - group_id=self.event.group_id, + group_id=self.event_group.id, event_id=self.event.event_id, num_frames=1, num_unique_commits=1, @@ -269,7 +284,9 @@ def test_success_existing_commit( @patch( "sentry.integrations.github.integration.GitHubIntegration.get_commit_context_all_frames", ) - def test_success_updating_group_owner(self, mock_get_commit_context, mock_record): + def test_success_updating_group_owner( + self, mock_get_commit_context: MagicMock, mock_record: MagicMock + ) -> None: """ Runs through process_commit_context twice to make sure we aren't creating duplicate GroupOwners for the same suggestion. @@ -288,9 +305,9 @@ def test_success_updating_group_owner(self, mock_get_commit_context, mock_record event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) @@ -319,9 +336,9 @@ def test_success_updating_group_owner(self, mock_get_commit_context, mock_record event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) @@ -363,9 +380,9 @@ def test_success_create_commit( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) @@ -402,7 +419,9 @@ def test_success_create_commit( @patch( "sentry.integrations.github.integration.GitHubIntegration.get_commit_context_all_frames", ) - def test_success_external_author_no_user(self, mock_get_commit_context: MagicMock, _) -> None: + def test_success_external_author_no_user( + self, mock_get_commit_context: MagicMock, _: Any + ) -> None: """ Test that process_commit_context creates GroupOwner with user_id=None when commit author has no Sentry user mapping. @@ -431,9 +450,9 @@ def test_success_external_author_no_user(self, mock_get_commit_context: MagicMoc event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) @@ -478,9 +497,9 @@ def test_success_multiple_blames( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) @@ -540,9 +559,9 @@ def test_maps_correct_files( assert not GroupOwner.objects.filter(group=self.event.group).exists() process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) @@ -565,7 +584,7 @@ def test_maps_correct_files( ], extra={ "event": self.event.event_id, - "group": self.event.group_id, + "group": self.event_group.id, "organization": self.event.project.organization_id, }, ) @@ -576,8 +595,11 @@ def test_maps_correct_files( "sentry.integrations.github.integration.GitHubIntegration.get_commit_context_all_frames", ) def test_failure_no_inapp_frames( - self, mock_get_commit_context, mock_record, mock_process_suspect_commits - ): + self, + mock_get_commit_context: MagicMock, + mock_record: MagicMock, + mock_process_suspect_commits: MagicMock, + ) -> None: """ A simple failure case where the event has no in app frames, so we bail out. """ @@ -617,9 +639,9 @@ def test_failure_no_inapp_frames( event_frames = get_frame_paths(self.event_with_no_inapp_frames) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, sdk_name="sentry.python", ) @@ -633,7 +655,7 @@ def test_failure_no_inapp_frames( IntegrationsFailedToFetchCommitContextAllFrames( organization_id=self.organization.id, project_id=self.project.id, - group_id=self.event.group_id, + group_id=self.event_group.id, event_id=self.event.event_id, num_frames=0, num_successfully_mapped_frames=0, @@ -648,8 +670,12 @@ def test_failure_no_inapp_frames( "sentry.integrations.github.integration.GitHubIntegration.get_commit_context_all_frames", ) def test_failure_no_blames( - self, mock_get_commit_context, mock_record, mock_process_suspect_commits, mock_logger_info - ): + self, + mock_get_commit_context: MagicMock, + mock_record: MagicMock, + mock_process_suspect_commits: MagicMock, + mock_logger_info: MagicMock, + ) -> None: """ A simple failure case where no blames are returned. We bail out. """ @@ -659,9 +685,9 @@ def test_failure_no_blames( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, sdk_name="sentry.python", ) @@ -674,7 +700,7 @@ def test_failure_no_blames( IntegrationsFailedToFetchCommitContextAllFrames( organization_id=self.organization.id, project_id=self.project.id, - group_id=self.event.group_id, + group_id=self.event_group.id, event_id=self.event.event_id, num_frames=1, num_successfully_mapped_frames=1, @@ -686,7 +712,7 @@ def test_failure_no_blames( "process_commit_context_all_frames.find_commit_context_failed", extra={ "organization": self.organization.id, - "group": self.event.group_id, + "group": self.event_group.id, "event": self.event.event_id, "project_id": self.project.id, "reason": "no_commits_found", @@ -700,8 +726,11 @@ def test_failure_no_blames( "sentry.integrations.github.integration.GitHubIntegration.get_commit_context_all_frames", ) def test_time_threshold_filtering( - self, mock_get_commit_context, mock_record, mock_process_suspect_commits - ): + self, + mock_get_commit_context: MagicMock, + mock_record: MagicMock, + mock_process_suspect_commits: MagicMock, + ) -> None: """ A hacky way to run a parameterized test in TestCase. Tests the logic that filters commits by age relative to issue first_seen. @@ -769,8 +798,8 @@ def test_time_threshold_filtering( group_first_seen = datetime.now(tz=datetime_timezone.utc) - timedelta( days=group_first_seen_days_ago ) - self.event.group.first_seen = group_first_seen - self.event.group.save() + self.event_group.first_seen = group_first_seen + self.event_group.save() # Build the mock return value mock_blames = [blame_mapping[blame_name] for blame_name in blames_setup] @@ -783,9 +812,9 @@ def test_time_threshold_filtering( with self.options({"issues.suspect-commit-strategy": True}): process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, sdk_name="sentry.python", ) @@ -814,8 +843,11 @@ def test_time_threshold_filtering( "sentry.integrations.github.integration.GitHubIntegration.get_commit_context_all_frames", ) def test_release_date_used_as_cutoff( - self, mock_get_commit_context, mock_record, mock_process_suspect_commits - ): + self, + mock_get_commit_context: MagicMock, + mock_record: MagicMock, + mock_process_suspect_commits: MagicMock, + ) -> None: """ When a group has a first_release, the release date should be used as the upper bound for suspect commit filtering instead of group.first_seen. @@ -864,17 +896,17 @@ def test_release_date_used_as_cutoff( mock_get_commit_context.return_value = [blame_after_release, blame_before_release] - self.event.group.first_release = release - self.event.group.first_seen = now - self.event.group.save() + self.event_group.first_release = release + self.event_group.first_seen = now + self.event_group.save() with self.tasks(): event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, sdk_name="sentry.python", ) @@ -891,8 +923,11 @@ def test_release_date_used_as_cutoff( "sentry.integrations.github.integration.GitHubIntegration.get_commit_context_all_frames", ) def test_no_release_falls_back_to_first_seen( - self, mock_get_commit_context, mock_record, mock_process_suspect_commits - ): + self, + mock_get_commit_context: MagicMock, + mock_record: MagicMock, + mock_process_suspect_commits: MagicMock, + ) -> None: """ When the group has no first_release, the behavior should fall back to using group.first_seen as the cutoff. @@ -901,17 +936,17 @@ def test_no_release_falls_back_to_first_seen( mock_get_commit_context.return_value = [self.blame_recent] - self.event.group.first_release = None - self.event.group.first_seen = now - self.event.group.save() + self.event_group.first_release = None + self.event_group.first_seen = now + self.event_group.save() with self.tasks(): event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, sdk_name="sentry.python", ) @@ -925,8 +960,11 @@ def test_no_release_falls_back_to_first_seen( "sentry.integrations.github.integration.GitHubIntegration.get_commit_context_all_frames", ) def test_release_date_released_preferred_over_date_added( - self, mock_get_commit_context, mock_record, mock_process_suspect_commits - ): + self, + mock_get_commit_context: MagicMock, + mock_record: MagicMock, + mock_process_suspect_commits: MagicMock, + ) -> None: """ When a release has date_released set, it should be preferred over date_added. """ @@ -958,17 +996,17 @@ def test_release_date_released_preferred_over_date_added( mock_get_commit_context.return_value = [blame_between_dates] - self.event.group.first_release = release - self.event.group.first_seen = now - self.event.group.save() + self.event_group.first_release = release + self.event_group.first_seen = now + self.event_group.save() with self.tasks(): event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, sdk_name="sentry.python", ) @@ -982,8 +1020,8 @@ def test_release_date_released_preferred_over_date_added( side_effect=ApiError("File not found", code=404), ) def test_no_retry_on_non_retryable_api_error( - self, mock_get_commit_context, mock_process_suspect_commits - ): + self, mock_get_commit_context: MagicMock, mock_process_suspect_commits: MagicMock + ) -> None: """ A failure case where the integration hits a 404 error. This type of failure should immediately bail with no retries. @@ -993,9 +1031,9 @@ def test_no_retry_on_non_retryable_api_error( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, sdk_name="sentry.python", ) @@ -1011,10 +1049,10 @@ def test_no_retry_on_non_retryable_api_error( ) def test_failure_unknown( self, - mock_get_commit_context, - mock_process_suspect_commits, - mock_logger_warning, - ): + mock_get_commit_context: MagicMock, + mock_process_suspect_commits: MagicMock, + mock_logger_warning: MagicMock, + ) -> None: """ A failure case where the integration returned an API error. The error should be recorded. @@ -1024,9 +1062,9 @@ def test_failure_unknown( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, sdk_name="sentry.python", ) @@ -1038,7 +1076,7 @@ def test_failure_unknown( "process_commit_context_all_frames.get_commit_context_all_frames.unknown_error", extra={ "organization": self.organization.id, - "group": self.event.group_id, + "group": self.event_group.id, "event": self.event.event_id, "project_id": self.project.id, "integration_id": self.integration.id, @@ -1105,9 +1143,9 @@ def test_filters_invalid_and_dedupes_frames( assert not GroupOwner.objects.filter(group=self.event.group).exists() process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=frames_with_dups, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, sdk_name="sentry.python", ) @@ -1124,7 +1162,7 @@ def test_filters_invalid_and_dedupes_frames( ], extra={ "event": self.event.event_id, - "group": self.event.group_id, + "group": self.event_group.id, "organization": self.organization.id, }, ) @@ -1133,7 +1171,7 @@ def test_filters_invalid_and_dedupes_frames( IntegrationsSuccessfullyFetchedCommitContextAllFrames( organization_id=self.organization.id, project_id=self.project.id, - group_id=self.event.group_id, + group_id=self.event_group.id, event_id=self.event.event_id, # 1 was a duplicate, 2 filtered out because of missing properties num_frames=2, @@ -1200,7 +1238,7 @@ def setUp(self) -> None: oi.config = {"pr_comments": True} oi.save(update_fields=["config"]) - def add_responses(self): + def add_responses(self) -> None: responses.add( responses.GET, self.base_url + f"/repos/example/commits/{self.commit.key}/pulls", @@ -1219,9 +1257,9 @@ def test_gh_comment_not_github( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) assert not mock_comment_workflow.called @@ -1241,9 +1279,9 @@ def test_gh_comment_oi_config_disabled_no_comment( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) assert not mock_comment_workflow.called @@ -1251,8 +1289,11 @@ def test_gh_comment_oi_config_disabled_no_comment( @patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_gh_comment_no_pr_from_api( - self, get_jwt, mock_comment_workflow, mock_get_commit_context - ): + self, + get_jwt: Callable[..., str], + mock_comment_workflow: MagicMock, + mock_get_commit_context: MagicMock, + ) -> None: """No comments on suspect commit with no pr returned from API response""" mock_get_commit_context.return_value = [self.blame] self.pull_request.delete() @@ -1268,9 +1309,9 @@ def test_gh_comment_no_pr_from_api( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) assert not mock_comment_workflow.called @@ -1279,8 +1320,12 @@ def test_gh_comment_no_pr_from_api( @patch("sentry_sdk.capture_exception") @responses.activate def test_gh_comment_api_error( - self, mock_capture_exception, get_jwt, mock_comment_workflow, mock_get_commit_context - ): + self, + mock_capture_exception: MagicMock, + get_jwt: Callable[..., str], + mock_comment_workflow: MagicMock, + mock_get_commit_context: MagicMock, + ) -> None: """Captures exception if Github API call errors""" mock_get_commit_context.return_value = [self.blame] responses.add( @@ -1294,9 +1339,9 @@ def test_gh_comment_api_error( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) assert mock_capture_exception.called @@ -1305,8 +1350,11 @@ def test_gh_comment_api_error( @patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_gh_comment_commit_not_in_default_branch( - self, get_jwt, mock_comment_workflow, mock_get_commit_context - ): + self, + get_jwt: Callable[..., str], + mock_comment_workflow: MagicMock, + mock_get_commit_context: MagicMock, + ) -> None: """No comments on commit not in default branch""" mock_get_commit_context.return_value = [self.blame] responses.add( @@ -1320,9 +1368,9 @@ def test_gh_comment_commit_not_in_default_branch( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) assert not mock_comment_workflow.called @@ -1330,8 +1378,11 @@ def test_gh_comment_commit_not_in_default_branch( @patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_gh_comment_no_pr_from_query( - self, get_jwt, mock_comment_workflow, mock_get_commit_context - ): + self, + get_jwt: Callable[..., str], + mock_comment_workflow: MagicMock, + mock_get_commit_context: MagicMock, + ) -> None: """No comments on suspect commit with no pr row in table""" mock_get_commit_context.return_value = [self.blame] self.pull_request.delete() @@ -1342,9 +1393,9 @@ def test_gh_comment_no_pr_from_query( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) assert not mock_comment_workflow.called @@ -1368,9 +1419,9 @@ def test_gh_comment_pr_too_old( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) assert not mock_comment_workflow.called @@ -1379,23 +1430,26 @@ def test_gh_comment_pr_too_old( @patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_gh_comment_pr_info_level_issue( - self, get_jwt, mock_comment_workflow, mock_get_commit_context - ): + self, + get_jwt: Callable[..., str], + mock_comment_workflow: MagicMock, + mock_get_commit_context: MagicMock, + ) -> None: """No comment on pr that's has info level issue""" mock_get_commit_context.return_value = [self.blame] self.pull_request.date_added = before_now(days=1) self.pull_request.save() self.add_responses() - self.event.group.update(level=logging.INFO) + self.event_group.update(level=logging.INFO) with self.tasks(): event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) assert not mock_comment_workflow.called @@ -1411,7 +1465,7 @@ def test_gh_comment_repeat_issue( ) -> None: """No comment on a pr that has a comment with the issue in the same pr list""" mock_get_commit_context.return_value = [self.blame] - self.pull_request_comment.group_ids.append(self.event.group_id) + self.pull_request_comment.group_ids.append(self.event_group.id) self.pull_request_comment.save() self.add_responses() @@ -1420,9 +1474,9 @@ def test_gh_comment_repeat_issue( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) assert not mock_comment_workflow.called @@ -1431,8 +1485,11 @@ def test_gh_comment_repeat_issue( @patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_gh_comment_create_queued( - self, get_jwt, mock_comment_workflow, mock_get_commit_context - ): + self, + get_jwt: Callable[..., str], + mock_comment_workflow: MagicMock, + mock_get_commit_context: MagicMock, + ) -> None: """Task queued if no prior comment exists""" mock_get_commit_context.return_value = [self.blame] self.pull_request_comment.delete() @@ -1443,9 +1500,9 @@ def test_gh_comment_create_queued( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) assert mock_comment_workflow.called @@ -1457,8 +1514,11 @@ def test_gh_comment_create_queued( @patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_gh_comment_create_queued_existing_pr_commit( - self, get_jwt, mock_comment_workflow, mock_get_commit_context - ): + self, + get_jwt: Callable[..., str], + mock_comment_workflow: MagicMock, + mock_get_commit_context: MagicMock, + ) -> None: """Task queued if no prior comment exists""" mock_get_commit_context.return_value = [self.blame] pr_commit = PullRequestCommit.objects.create( @@ -1472,9 +1532,9 @@ def test_gh_comment_create_queued_existing_pr_commit( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) assert mock_comment_workflow.called @@ -1500,9 +1560,9 @@ def test_gh_comment_update_queue( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) assert mock_comment_workflow.called @@ -1521,9 +1581,9 @@ def test_gh_comment_no_repo( event_frames = get_frame_paths(self.event) process_commit_context( event_id=self.event.event_id, - event_platform=self.event.platform, + event_platform=self.event_platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group.id, project_id=self.event.project_id, ) assert not mock_comment_workflow.called @@ -1533,14 +1593,18 @@ def test_gh_comment_no_repo( @patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_gh_comment_debounces( - self, get_jwt, mock_record, mock_comment_workflow, mock_get_commit_context - ): + self, + get_jwt: Callable[..., str], + mock_record: MagicMock, + mock_comment_workflow: MagicMock, + mock_get_commit_context: MagicMock, + ) -> None: mock_get_commit_context.return_value = [self.blame] self.add_responses() assert not GroupOwner.objects.filter(group=self.event.group).exists() groupowner = GroupOwner.objects.create( - group_id=self.event.group_id, + group_id=self.event_group.id, type=GroupOwnerType.SUSPECT_COMMIT.value, user_id=1, project_id=self.event.project_id, @@ -1565,13 +1629,13 @@ def test_gh_comment_debounces( project=self.project, commit=self.commit, group_owner=groupowner, - group_id=self.event.group_id, + group_id=self.event_group.id, ) install.queue_pr_comment_task_if_needed( project=self.project, commit=self.commit, group_owner=groupowner, - group_id=self.event.group_id, + group_id=self.event_group.id, ) assert mock_comment_workflow.call_count == 1 @@ -1586,14 +1650,18 @@ def test_gh_comment_debounces( @patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_gh_comment_multiple_comments( - self, get_jwt, mock_record, mock_comment_workflow, mock_get_commit_context - ): + self, + get_jwt: Callable[..., str], + mock_record: MagicMock, + mock_comment_workflow: MagicMock, + mock_get_commit_context: MagicMock, + ) -> None: self.add_responses() assert not GroupOwner.objects.filter(group=self.event.group).exists() groupowner = GroupOwner.objects.create( - group_id=self.event.group_id, + group_id=self.event_group.id, type=GroupOwnerType.SUSPECT_COMMIT.value, user_id=1, project_id=self.event.project_id, @@ -1628,13 +1696,13 @@ def test_gh_comment_multiple_comments( project=self.project, commit=self.commit, group_owner=groupowner, - group_id=self.event.group_id, + group_id=self.event_group.id, ) install.queue_pr_comment_task_if_needed( project=self.project, commit=self.commit, group_owner=groupowner, - group_id=self.event.group_id, + group_id=self.event_group.id, ) assert mock_comment_workflow.call_count == 1 diff --git a/tests/sentry/tasks/test_commits.py b/tests/sentry/tasks/test_commits.py index 718c004dac0e..06ebe06e67c4 100644 --- a/tests/sentry/tasks/test_commits.py +++ b/tests/sentry/tasks/test_commits.py @@ -10,6 +10,7 @@ from sentry.models.commit import Commit from sentry.models.deploy import Deploy from sentry.models.latestreporeleaseenvironment import LatestRepoReleaseEnvironment +from sentry.models.organization import Organization from sentry.models.release import Release from sentry.models.releaseheadcommit import ReleaseHeadCommit from sentry.models.repository import Repository @@ -24,6 +25,7 @@ from sentry.testutils.asserts import assert_slo_metric from sentry.testutils.cases import TestCase from sentry.testutils.silo import assume_test_silo_mode, control_silo_test +from sentry.users.models.user import User from sentry.utils.cache import cache from social_auth.models import UserSocialAuth @@ -37,7 +39,9 @@ def _github_compare_commits_result(self, repo_name: str, end_sha: str) -> list[d {"id": end_sha, "repository": repo_name}, ] - def _setup_github_compare_commits_cache_context(self): + def _setup_github_compare_commits_cache_context( + self, + ) -> tuple[Organization, Repository, Release, Release, Release, list[dict[str, str]]]: org = self.create_organization(owner=self.user, name="baz") repo = Repository.objects.create( name="example", @@ -68,7 +72,7 @@ def test_github_compare_commits_cache_key_avoids_ambiguous_id_collisions( assert key_one != key_two - def _create_example_repo(self, org): + def _create_example_repo(self, org: Organization) -> Repository: integration = self.create_integration( organization=org, provider="example", external_id="example:1" ) @@ -79,7 +83,7 @@ def _create_example_repo(self, org): integration_id=integration.id, ) - def _test_simple_action(self, user, org): + def _test_simple_action(self, user: User, org: Organization) -> None: repo = self._create_example_repo(org) release = Release.objects.create(organization_id=org.id, version="abcabcabc") @@ -310,8 +314,11 @@ def test_release_locked(self, mock_record_event: MagicMock) -> None: @patch("sentry.tasks.commits.handle_invalid_identity") @patch.object(ExampleRepositoryProvider, "compare_commits") def test_fetch_error_invalid_identity( - self, mock_compare_commits, mock_handle_invalid_identity, mock_record - ): + self, + mock_compare_commits: MagicMock, + mock_handle_invalid_identity: MagicMock, + mock_record: MagicMock, + ) -> None: self.login_as(user=self.user) org = self.create_organization(owner=self.user, name="baz") diff --git a/tests/sentry/tasks/test_digests.py b/tests/sentry/tasks/test_digests.py index 9aa4a5dc06c9..c9e3cb2a68ac 100644 --- a/tests/sentry/tasks/test_digests.py +++ b/tests/sentry/tasks/test_digests.py @@ -1,4 +1,5 @@ import uuid +from collections.abc import Iterator from contextlib import contextmanager from unittest import mock @@ -94,7 +95,7 @@ def test_no_records(self) -> None: def test_unable_to_acquire_lock_propagates(self) -> None: @contextmanager - def raise_lock_error(*args, **kwargs): + def raise_lock_error(*args: object, **kwargs: object) -> Iterator[None]: raise UnableToAcquireLock("test lock contention") yield # type: ignore[unreachable] diff --git a/tests/sentry/tasks/test_groupowner.py b/tests/sentry/tasks/test_groupowner.py index c681381c5b10..ef3f311e6561 100644 --- a/tests/sentry/tasks/test_groupowner.py +++ b/tests/sentry/tasks/test_groupowner.py @@ -75,7 +75,13 @@ def setUp(self) -> None: group_id=self.event.group.id, project_id=self.project.id, release_id=self.release.id ) - def set_release_commits(self, author_email): + @property + def event_group_id(self) -> int: + group_id = self.event.group_id + assert group_id is not None + return group_id + + def set_release_commits(self, author_email: str) -> None: self.commitSha = "a" * 40 self.release.set_commits( [ @@ -98,7 +104,7 @@ def test_simple(self) -> None: event_id=self.event.event_id, event_platform=self.event.platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group_id, project_id=self.event.project_id, ) assert GroupOwner.objects.get( @@ -145,21 +151,21 @@ def test_delete_old_entries(self) -> None: event_id=self.event.event_id, event_platform=self.event.platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group_id, project_id=self.event.project_id, ) process_suspect_commits( event_id=self.event.event_id, event_platform=self.event.platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group_id, project_id=self.event.project_id, ) process_suspect_commits( event_id=self.event.event_id, event_platform=self.event.platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group_id, project_id=self.event.project_id, ) @@ -232,11 +238,13 @@ def test_delete_old_entries(self) -> None: self.set_release_commits(self.user_2.email) event_2_frames = get_frame_paths(event_2) + event_2_group_id = event_2.group_id + assert event_2_group_id is not None process_suspect_commits( event_id=event_2.event_id, event_platform=event_2.platform, event_frames=event_2_frames, - group_id=event_2.group_id, + group_id=event_2_group_id, project_id=event_2.project_id, ) assert GroupOwner.objects.filter(group=self.event.group).count() == 2 @@ -245,11 +253,13 @@ def test_delete_old_entries(self) -> None: self.set_release_commits(self.user_3.email) event_3_frames = get_frame_paths(event_3) + event_3_group_id = event_3.group_id + assert event_3_group_id is not None process_suspect_commits( event_id=event_3.event_id, event_platform=event_3.platform, event_frames=event_3_frames, - group_id=event_3.group_id, + group_id=event_3_group_id, project_id=event_3.project_id, ) assert GroupOwner.objects.filter(group=self.event.group).count() == 2 @@ -266,7 +276,7 @@ def test_delete_old_entries(self) -> None: event_id=event_3.event_id, event_platform=event_3.platform, event_frames=event_3_frames, - group_id=event_3.group_id, + group_id=event_3_group_id, project_id=event_3.project_id, ) # Won't be processed because the cache is present and this group has owners @@ -284,7 +294,7 @@ def test_update_existing_entries(self) -> None: event_id=self.event.event_id, event_platform=self.event.platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group_id, project_id=self.event.project_id, ) go = GroupOwner.objects.get( @@ -303,7 +313,7 @@ def test_update_existing_entries(self) -> None: event_id=self.event.event_id, event_platform=self.event.platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group_id, project_id=self.event.project_id, ) go.refresh_from_db() @@ -341,11 +351,13 @@ def test_no_release_or_commit(self) -> None: }, project_id=self.project.id, ) + event_group_id = event_with_no_release.group_id + assert event_group_id is not None process_suspect_commits( event_with_no_release.event_id, event_with_no_release.platform, get_frame_paths(event_with_no_release), - event_with_no_release.group_id, + event_group_id, event_with_no_release.project_id, ) assert GroupOwner.objects.filter(group=event_with_no_release.group).count() == 0 @@ -438,7 +450,7 @@ def test_keep_highest_score(self, patched_committers: MagicMock) -> None: event_id=self.event.event_id, event_platform=self.event.platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group_id, project_id=self.event.project_id, ) # Doesn't use self.user2 due to low score. @@ -465,7 +477,7 @@ def test_select_preferred_owners_by_score(self, patched_committers: MagicMock) - event_id=self.event.event_id, event_platform=self.event.platform, event_frames=get_frame_paths(self.event), - group_id=self.event.group_id, + group_id=self.event_group_id, project_id=self.event.project_id, ) @@ -482,12 +494,12 @@ def test_no_matching_commits_debounces_for_one_day(self) -> None: event_id=self.event.event_id, event_platform=self.event.platform, event_frames=[{"filename": "unrelated.py", "in_app": True}], - group_id=self.event.group_id, + group_id=self.event_group_id, project_id=self.event.project_id, ) assert not GroupOwner.objects.filter(group=self.event.group).exists() - cache_key = DEBOUNCE_CACHE_KEY(self.event.group_id) + cache_key = DEBOUNCE_CACHE_KEY(self.event_group_id) assert cache.get(cache_key) is True frozen_time.shift(timedelta(hours=23)) @@ -514,12 +526,12 @@ def test_low_suspect_committer_score(self, patched_committers: MagicMock) -> Non event_id=self.event.event_id, event_platform=self.event.platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group_id, project_id=self.event.project_id, ) assert not GroupOwner.objects.filter(user_id=self.user.id).exists() - assert cache.get(DEBOUNCE_CACHE_KEY(self.event.group_id)) is True + assert cache.get(DEBOUNCE_CACHE_KEY(self.event_group_id)) is True def test_owners_count(self) -> None: self.set_release_commits(self.user.email) @@ -530,12 +542,12 @@ def test_owners_count(self) -> None: event_id=self.event.event_id, event_platform=self.event.platform, event_frames=event_frames, - group_id=self.event.group_id, + group_id=self.event_group_id, project_id=self.event.project_id, ) owners = GroupOwner.objects.filter( - group_id=self.event.group_id, + group_id=self.event_group_id, project=self.event.project, organization_id=self.event.project.organization_id, type=GroupOwnerType.SUSPECT_COMMIT.value, diff --git a/tests/sentry/tasks/test_llm_issue_detection.py b/tests/sentry/tasks/test_llm_issue_detection.py index 3514e0bb6301..045eb88fc149 100644 --- a/tests/sentry/tasks/test_llm_issue_detection.py +++ b/tests/sentry/tasks/test_llm_issue_detection.py @@ -1,6 +1,6 @@ import uuid from datetime import timedelta -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import pytest from django.db.models import F @@ -30,7 +30,7 @@ class LLMIssueDetectionTest(TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() patcher = patch("sentry.tasks.llm_issue_detection.detection.Project.objects.filter") self.mock_project_filter = patcher.start() @@ -88,8 +88,11 @@ def test_seer_requests_use_system_viewer_context( "sentry.tasks.llm_issue_detection.trace_data.get_project_top_transaction_traces_for_llm_detection" ) def test_detect_llm_issues_no_transactions( - self, mock_get_transactions, mock_seer_request, mock_budget_request - ): + self, + mock_get_transactions: MagicMock, + mock_seer_request: MagicMock, + mock_budget_request: MagicMock, + ) -> None: mock_budget_request.return_value = self._budget_ok_response() mock_get_transactions.return_value = [] @@ -109,8 +112,11 @@ def test_detect_llm_issues_no_transactions( @patch("sentry.tasks.llm_issue_detection.trace_data.Spans.run_table_query") @patch("sentry.tasks.llm_issue_detection.detection.make_issue_detection_request") def test_detect_llm_issues_no_traces( - self, mock_seer_request, mock_spans_query, mock_budget_request - ): + self, + mock_seer_request: MagicMock, + mock_spans_query: MagicMock, + mock_budget_request: MagicMock, + ) -> None: mock_budget_request.return_value = self._budget_ok_response() mock_spans_query.side_effect = [ @@ -128,7 +134,9 @@ def test_detect_llm_issues_no_traces( mock_seer_request.assert_not_called() @patch("sentry.tasks.llm_issue_detection.detection.produce_occurrence_to_kafka") - def test_create_issue_occurrence_from_detection(self, mock_produce_occurrence): + def test_create_issue_occurrence_from_detection( + self, mock_produce_occurrence: MagicMock + ) -> None: detected_issue = DetectedIssue( title="Inefficient Database Queries", explanation="Multiple sequential queries could be batched", @@ -189,8 +197,8 @@ def test_create_issue_occurrence_from_detection(self, mock_produce_occurrence): @patch("sentry.tasks.llm_issue_detection.detection.produce_occurrence_to_kafka") def test_create_issue_occurrence_fingerprint_uses_transaction_name( - self, mock_produce_occurrence - ): + self, mock_produce_occurrence: MagicMock + ) -> None: detected_issue = DetectedIssue( title="Inefficient Database Queries", explanation="Multiple queries in loop", @@ -211,7 +219,9 @@ def test_create_issue_occurrence_fingerprint_uses_transaction_name( assert occurrence.type == AIDetectedDBGroupType @patch("sentry.tasks.llm_issue_detection.detection.produce_occurrence_to_kafka") - def test_general_type_skips_occurrence_creation(self, mock_produce_occurrence): + def test_general_type_skips_occurrence_creation( + self, mock_produce_occurrence: MagicMock + ) -> None: detected_issue = DetectedIssue( title="Other", explanation="Something unusual happening here", @@ -235,10 +245,10 @@ def test_general_type_skips_occurrence_creation(self, mock_produce_occurrence): @patch("sentry.tasks.llm_issue_detection.trace_data.Spans.run_table_query") def test_detect_llm_issues_full_flow( self, - mock_spans_query, - mock_seer_request, - mock_budget_request, - ): + mock_spans_query: MagicMock, + mock_seer_request: MagicMock, + mock_budget_request: MagicMock, + ) -> None: mock_budget_request.return_value = self._budget_ok_response() mock_spans_query.side_effect = [ @@ -283,11 +293,11 @@ def test_detect_llm_issues_full_flow( @patch("sentry.tasks.llm_issue_detection.detection.logger.error") def test_detect_llm_issues_seer_error_logged( self, - mock_logger_error, - mock_spans_query, - mock_seer_request, - mock_budget_request, - ): + mock_logger_error: MagicMock, + mock_spans_query: MagicMock, + mock_seer_request: MagicMock, + mock_budget_request: MagicMock, + ) -> None: mock_budget_request.return_value = self._budget_ok_response() mock_spans_query.side_effect = [ @@ -317,7 +327,9 @@ def test_detect_llm_issues_seer_error_logged( "sentry.tasks.llm_issue_detection.trace_data.get_project_top_transaction_traces_for_llm_detection" ) @patch("sentry.tasks.llm_issue_detection.detection.make_signed_seer_api_request") - def test_check_budget_fail_open(self, mock_budget_request, mock_get_transactions, _): + def test_check_budget_fail_open( + self, mock_budget_request: MagicMock, mock_get_transactions: MagicMock, _: object + ) -> None: mock_budget_response = Mock() mock_budget_response.status = 500 mock_budget_response.data = b"Internal Server Error" @@ -336,8 +348,11 @@ def test_check_budget_fail_open(self, mock_budget_request, mock_get_transactions ) @patch("sentry.tasks.llm_issue_detection.detection.make_signed_seer_api_request") def test_check_budget_over_budget( - self, mock_budget_request, mock_get_transactions, mock_seer_request - ): + self, + mock_budget_request: MagicMock, + mock_get_transactions: MagicMock, + mock_seer_request: MagicMock, + ) -> None: mock_budget_response = Mock() mock_budget_response.status = 200 mock_budget_response.data = b'{"has_budget": false}' @@ -355,8 +370,11 @@ def test_check_budget_over_budget( ) @patch("sentry.tasks.llm_issue_detection.detection.make_signed_seer_api_request") def test_plan_tier_forwarded_to_seer( - self, mock_budget_request, mock_get_transactions, mock_seer_request - ): + self, + mock_budget_request: MagicMock, + mock_get_transactions: MagicMock, + mock_seer_request: MagicMock, + ) -> None: mock_budget_request.return_value = self._budget_ok_response() mock_get_transactions.return_value = [] @@ -376,8 +394,11 @@ def test_plan_tier_forwarded_to_seer( ) @patch("sentry.tasks.llm_issue_detection.detection.make_signed_seer_api_request") def test_plan_tier_defaults_to_business( - self, mock_budget_request, mock_get_transactions, mock_seer_request - ): + self, + mock_budget_request: MagicMock, + mock_get_transactions: MagicMock, + mock_seer_request: MagicMock, + ) -> None: mock_budget_request.return_value = self._budget_ok_response() mock_get_transactions.return_value = [] @@ -393,8 +414,11 @@ def test_plan_tier_defaults_to_business( "sentry.tasks.llm_issue_detection.trace_data.get_project_top_transaction_traces_for_llm_detection" ) def test_traces_sent_per_plan_tier( - self, mock_get_transactions, mock_seer_request, mock_budget_request - ): + self, + mock_get_transactions: MagicMock, + mock_seer_request: MagicMock, + mock_budget_request: MagicMock, + ) -> None: mock_budget_request.return_value = self._budget_ok_response() mock_get_transactions.return_value = [ TraceMetadataWithSpanCount(trace_id=f"t{i}", span_count=50) for i in range(4) @@ -414,8 +438,11 @@ def test_traces_sent_per_plan_tier( "sentry.tasks.llm_issue_detection.trace_data.get_project_top_transaction_traces_for_llm_detection" ) def test_traces_per_invocation_option_override( - self, mock_get_transactions, mock_seer_request, mock_budget_request - ): + self, + mock_get_transactions: MagicMock, + mock_seer_request: MagicMock, + mock_budget_request: MagicMock, + ) -> None: mock_budget_request.return_value = self._budget_ok_response() mock_get_transactions.return_value = [ TraceMetadataWithSpanCount(trace_id=f"t{i}", span_count=50) for i in range(4) @@ -437,7 +464,9 @@ class LLMIssueDetectionProjectFilterTest(TestCase): @patch( "sentry.tasks.llm_issue_detection.trace_data.get_project_top_transaction_traces_for_llm_detection" ) - def test_skips_projects_without_transactions(self, mock_get_transactions, mock_budget_request): + def test_skips_projects_without_transactions( + self, mock_get_transactions: MagicMock, mock_budget_request: MagicMock + ) -> None: project_with_txns = self.create_project(organization=self.organization) project_with_txns.update(flags=F("flags").bitor(Project.flags.has_transactions)) @@ -482,7 +511,7 @@ class TestGetValidTraceIdsBySpanCount: ) @patch("sentry.tasks.llm_issue_detection.trace_data.Spans.run_table_query") def test_filters_by_span_count( - self, mock_spans_query: Mock, query_result: dict, expected: dict[str, int] + self, mock_spans_query: Mock, query_result: dict[str, object], expected: dict[str, int] ) -> None: mock_spans_query.return_value = query_result mock_snuba_params = Mock() @@ -503,7 +532,7 @@ def setUp(self) -> None: self.ten_mins_ago = before_now(minutes=10) @patch("sentry.tasks.llm_issue_detection.trace_data.get_valid_trace_ids_by_span_count") - def test_returns_sampled_traces(self, mock_span_count) -> None: + def test_returns_sampled_traces(self, mock_span_count: MagicMock) -> None: mock_span_count.side_effect = lambda trace_ids, *args: {tid: 50 for tid in trace_ids} trace_id_1 = uuid.uuid4().hex @@ -543,7 +572,7 @@ def test_returns_sampled_traces(self, mock_span_count) -> None: assert result_trace_ids == {trace_id_1, trace_id_2} @patch("sentry.tasks.llm_issue_detection.trace_data.get_valid_trace_ids_by_span_count") - def test_excludes_test_environment_traces(self, mock_span_count) -> None: + def test_excludes_test_environment_traces(self, mock_span_count: MagicMock) -> None: mock_span_count.side_effect = lambda trace_ids, *args: {tid: 50 for tid in trace_ids} prod_trace_id = uuid.uuid4().hex diff --git a/tests/sentry/tasks/test_merge.py b/tests/sentry/tasks/test_merge.py index f7b6eb5e9015..3af7997bcaa8 100644 --- a/tests/sentry/tasks/test_merge.py +++ b/tests/sentry/tasks/test_merge.py @@ -1,6 +1,6 @@ from types import SimpleNamespace from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch from sentry import buffer, eventstream from sentry.issues.models.groupderiveddata import GroupDerivedData @@ -26,7 +26,7 @@ @patch.object(features, "index", new=index) class MergeGroupTest(TestCase, SnubaTestCase): @patch("sentry.eventstream.backend") - def test_merge_calls_eventstream(self, mock_eventstream) -> None: + def test_merge_calls_eventstream(self, mock_eventstream: MagicMock) -> None: group1 = self.create_group(self.project) group2 = self.create_group(self.project) @@ -38,7 +38,7 @@ def test_merge_calls_eventstream(self, mock_eventstream) -> None: mock_eventstream.end_merge.assert_called_once_with(eventstream_state) @patch("sentry.tasks.merge.current_task") - def test_selfchain_skips_when_already_spawned(self, mock_current_task) -> None: + def test_selfchain_skips_when_already_spawned(self, mock_current_task: MagicMock) -> None: # A prior delivery of this activation already spawned its continuation. mock_current_task.return_value = SimpleNamespace(id="merge-act-skip") mark_spawned("merge_groups", "merge-act-skip") @@ -51,7 +51,9 @@ def test_selfchain_skips_when_already_spawned(self, mock_current_task) -> None: assert mock_delay.call_count == 0 @patch("sentry.tasks.merge.current_task") - def test_selfchain_marks_and_dedupes_across_deliveries(self, mock_current_task) -> None: + def test_selfchain_marks_and_dedupes_across_deliveries( + self, mock_current_task: MagicMock + ) -> None: group1 = self.create_group(self.project) group2 = self.create_group(self.project) target = self.create_group(self.project) @@ -69,7 +71,9 @@ def test_selfchain_marks_and_dedupes_across_deliveries(self, mock_current_task) @patch("sentry.tasks.merge.mark_spawned") @patch("sentry.tasks.merge.current_task", return_value=None) - def test_selfchain_noop_without_activation(self, mock_current_task, mock_mark) -> None: + def test_selfchain_noop_without_activation( + self, mock_current_task: MagicMock, mock_mark: MagicMock + ) -> None: # Synchronous/eager path (e.g. the initial direct call) has no current activation, so the # guard is inert and existing behavior is preserved. group1 = self.create_group(self.project) @@ -114,6 +118,7 @@ def test_merge_with_event_integrity(self) -> None: project_id=project.id, ) group1 = event1.group + assert group1 is not None event2 = self.store_event( data={ "event_id": "b" * 32, @@ -124,6 +129,7 @@ def test_merge_with_event_integrity(self) -> None: project_id=project.id, ) group2 = event2.group + assert group2 is not None with self.tasks(): eventstream_state = eventstream.backend.start_merge(project.id, [group1.id], group2.id) @@ -132,15 +138,15 @@ def test_merge_with_event_integrity(self) -> None: assert not Group.objects.filter(id=group1.id).exists() - event1 = eventstore.backend.get_event_by_id(project.id, event1.event_id) - assert event1 is not None - assert event1.group_id == group2.id - assert event1.data["extra"]["foo"] == "bar" + fetched_event1 = eventstore.backend.get_event_by_id(project.id, event1.event_id) + assert fetched_event1 is not None + assert fetched_event1.group_id == group2.id + assert fetched_event1.data["extra"]["foo"] == "bar" - event2 = eventstore.backend.get_event_by_id(project.id, event2.event_id) - assert event2 is not None - assert event2.group_id == group2.id - assert event2.data["extra"]["foo"] == "baz" + fetched_event2 = eventstore.backend.get_event_by_id(project.id, event2.event_id) + assert fetched_event2 is not None + assert fetched_event2.group_id == group2.id + assert fetched_event2.data["extra"]["foo"] == "baz" def test_merge_creates_redirect(self) -> None: groups = [self.create_group() for _ in range(0, 3)] @@ -185,7 +191,9 @@ def test_merge_updates_tag_values_seen(self) -> None: project_id=project.id, ) target = event1.group + assert target is not None other = event2.group + assert other is not None with self.tasks(): merge_groups([other.id], target.id) @@ -196,16 +204,18 @@ def test_merge_with_group_meta(self) -> None: project1 = self.create_project() event1 = self.store_event(data={}, project_id=project1.id) group1 = event1.group + assert group1 is not None project2 = self.create_project() event2 = self.store_event(data={}, project_id=project2.id) group2 = event2.group + assert group2 is not None - GroupMeta.objects.create(group=event1.group, key="github:tid", value="134") + GroupMeta.objects.create(group=group1, key="github:tid", value="134") - GroupMeta.objects.create(group=event1.group, key="other:tid", value="567") + GroupMeta.objects.create(group=group1, key="other:tid", value="567") - GroupMeta.objects.create(group=event2.group, key="other:tid", value="abc") + GroupMeta.objects.create(group=group2, key="other:tid", value="abc") GroupMeta.objects.populate_cache([group1, group2]) @@ -231,6 +241,7 @@ def test_user_report_merge(self) -> None: project1 = self.create_project() event1 = self.store_event(data={}, project_id=project1.id) group1 = event1.group + assert group1 is not None project2 = self.create_project() group2 = self.create_group(project2) diff --git a/tests/sentry/tasks/test_on_demand_metrics.py b/tests/sentry/tasks/test_on_demand_metrics.py index eec03b848353..284b517c79fe 100644 --- a/tests/sentry/tasks/test_on_demand_metrics.py +++ b/tests/sentry/tasks/test_on_demand_metrics.py @@ -33,7 +33,7 @@ def owner() -> User: @pytest.fixture -def organization(owner: User) -> None: +def organization(owner: User) -> Organization: return Factories.create_organization(owner=owner) diff --git a/tests/sentry/tasks/test_organization_contributors.py b/tests/sentry/tasks/test_organization_contributors.py index 04642b0b5a9b..15b82fb86013 100644 --- a/tests/sentry/tasks/test_organization_contributors.py +++ b/tests/sentry/tasks/test_organization_contributors.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import MagicMock, patch from sentry.silo.base import SiloMode from sentry.tasks.organization_contributors import ( @@ -24,7 +24,7 @@ def setUp(self) -> None: ) @patch("sentry.tasks.organization_contributors.logger") - def test_resets_num_actions_for_all_contributors(self, mock_logger): + def test_resets_num_actions_for_all_contributors(self, mock_logger: MagicMock) -> None: contributor1 = self.create_organization_contributor( organization=self.organization, integration=self.integration, @@ -132,7 +132,9 @@ def setUp(self) -> None: @patch("sentry.tasks.organization_contributors.logger") @patch("sentry.tasks.organization_contributors.quotas.backend.assign_seat") - def test_seat_assigned_successfully(self, mock_assign_seat, mock_logger): + def test_seat_assigned_successfully( + self, mock_assign_seat: MagicMock, mock_logger: MagicMock + ) -> None: contributor = self.create_organization_contributor( organization=self.organization, integration=self.integration, @@ -150,7 +152,9 @@ def test_seat_assigned_successfully(self, mock_assign_seat, mock_logger): @patch("sentry.tasks.organization_contributors.logger") @patch("sentry.tasks.organization_contributors.quotas.backend.assign_seat") - def test_seat_assignment_fails_logs_warning(self, mock_assign_seat, mock_logger): + def test_seat_assignment_fails_logs_warning( + self, mock_assign_seat: MagicMock, mock_logger: MagicMock + ) -> None: contributor = self.create_organization_contributor( organization=self.organization, integration=self.integration, @@ -178,7 +182,9 @@ def test_seat_assignment_fails_logs_warning(self, mock_assign_seat, mock_logger) @patch("sentry.tasks.organization_contributors.logger") @patch("sentry.tasks.organization_contributors.quotas.backend.assign_seat") - def test_contributor_not_found_logs_warning(self, mock_assign_seat, mock_logger): + def test_contributor_not_found_logs_warning( + self, mock_assign_seat: MagicMock, mock_logger: MagicMock + ) -> None: assign_seat_to_organization_contributor(999999) mock_assign_seat.assert_not_called() diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index 1c6707ae8da1..729638f987ad 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -5,7 +5,7 @@ import uuid from datetime import datetime, timedelta from hashlib import md5 -from typing import Any +from typing import Any, cast from unittest import mock from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch @@ -20,6 +20,8 @@ from sentry.constants import ObjectStatus from sentry.eventstream.types import EventStreamEventType from sentry.feedback.lib.utils import FeedbackCreationSource +from sentry.integrations.models.data_forwarder import DataForwarder +from sentry.integrations.models.data_forwarder_project import DataForwarderProject from sentry.integrations.models.integration import Integration from sentry.integrations.source_code_management.commit_context import CommitInfo, FileBlameInfo from sentry.integrations.types import DataForwarderProviderSlug @@ -31,6 +33,7 @@ PerformanceP95EndpointRegressionGroupType, ) from sentry.issues.ingest import save_issue_occurrence +from sentry.issues.issue_occurrence import IssueOccurrence from sentry.issues.occurrence_consumer import EventLookupError from sentry.issues.ownership.grammar import Matcher, Owner, Rule, dump_schema from sentry.models.activity import Activity, ActivityIntegration @@ -53,7 +56,7 @@ from sentry.models.projectteam import ProjectTeam from sentry.models.userreport import UserReport from sentry.services import eventstore -from sentry.services.eventstore.models import Event +from sentry.services.eventstore.models import Event, GroupEvent from sentry.services.eventstore.processing import event_processing_store from sentry.silo.base import SiloMode from sentry.silo.safety import unguarded_write @@ -64,6 +67,7 @@ GROUP_CATEGORY_POST_PROCESS_PIPELINE, HIGHER_ISSUE_OWNERS_PER_PROJECT_PER_MIN_RATELIMIT, ISSUE_OWNERS_PER_PROJECT_PER_MIN_RATELIMIT, + PostProcessJob, feedback_filter_decorator, locks, post_process_group, @@ -92,6 +96,17 @@ pytestmark = [requires_snuba] +def require_group(event: Event | GroupEvent) -> Group: + assert event.group is not None + return event.group + + +def require_occurrence(event: Event) -> IssueOccurrence: + group_event = cast(GroupEvent, event) + assert group_event.occurrence is not None + return group_event.occurrence + + class ProcessWorkflowsKwargsMatcher: """ Matcher for process_workflows_event.apply_async kwargs. @@ -105,13 +120,13 @@ class ProcessWorkflowsKwargsMatcher: def __init__( self, - event=None, - group=None, - is_new=None, - is_regression=None, - is_new_group_environment=None, - has_reappeared=None, - has_escalated=None, + event: Event | None = None, + group: Group | None = None, + is_new: bool | None = None, + is_regression: bool | None = None, + is_new_group_environment: bool | None = None, + has_reappeared: bool | None = None, + has_escalated: bool | None = None, ): self.event = event self.group = group @@ -121,7 +136,7 @@ def __init__( self.has_reappeared = has_reappeared self.has_escalated = has_escalated - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, dict): return False @@ -198,11 +213,15 @@ def test_step_names_are_usable_as_killswitch_keys(self) -> None: class BasePostProcessGroupMixin(BaseTestCase, metaclass=abc.ABCMeta): @abc.abstractmethod - def create_event(self, data, project_id, assert_no_errors=True): + def create_event( + self, data: dict[str, object], project_id: int, assert_no_errors: bool = True + ) -> Event: pass @abc.abstractmethod - def call_post_process_group(self, is_new, is_regression, is_new_group_environment, event): + def call_post_process_group( + self, is_new: bool, is_regression: bool, is_new_group_environment: bool, event: Event + ) -> None: pass @@ -213,11 +232,11 @@ class CorePostProcessGroupTestMixin(BasePostProcessGroupMixin): @patch("sentry.signals.event_processed.send_robust") def test_issueless( self, - mock_signal, - mock_process_resource_change_bound, - mock_process_service_hook, - mock_process_workflows_event, - ): + mock_signal: MagicMock, + mock_process_resource_change_bound: MagicMock, + mock_process_service_hook: MagicMock, + mock_process_workflows_event: MagicMock, + ) -> None: min_ago = before_now(minutes=1).isoformat() event = self.store_event( data={ @@ -392,7 +411,7 @@ def test_workflow_engine(self, mock_process_workflows_event: MagicMock) -> None: def test_group_refresh(self, mock_process_workflows_event: MagicMock) -> None: event = self.create_event(data={"message": "testing"}, project_id=self.project.id) - group1 = event.group + group1 = require_group(event) group2 = self.create_group(project=self.project) assert event.group_id == group1.id @@ -426,7 +445,7 @@ class ResourceChangeBoundsTestMixin(BasePostProcessGroupMixin): @patch("sentry.sentry_apps.tasks.sentry_apps.process_resource_change_bound.delay") def test_processes_resource_change_task_on_new_group(self, delay: MagicMock) -> None: event = self.create_event(data={}, project_id=self.project.id) - group = event.group + group = require_group(event) self.call_post_process_group( is_new=True, is_regression=False, @@ -576,8 +595,8 @@ def hook(job: Any) -> bool: set_malicious_issue_detection_hook(_noop_malicious_issue_detection_hook) assert calls == [(event.event_id, False, False)] - event.group.refresh_from_db() - assert event.group.status == GroupStatus.UNRESOLVED + require_group(event).refresh_from_db() + assert require_group(event).status == GroupStatus.UNRESOLVED def test_malicious_issue_detection_halts_when_processor_returns_true(self) -> None: from sentry.tasks.post_process import ( @@ -618,7 +637,7 @@ class InboxTestMixin(BasePostProcessGroupMixin): def test_group_inbox_regression(self, mock_process_workflows_event: MagicMock) -> None: new_event = self.create_event(data={"message": "testing"}, project_id=self.project.id) - group = new_event.group + group = require_group(new_event) assert group.status == GroupStatus.UNRESOLVED assert group.substatus == GroupSubStatus.NEW @@ -652,15 +671,16 @@ def test_group_inbox_regression(self, mock_process_workflows_event: MagicMock) - # resolve the new issue so regression actually happens group.status = GroupStatus.RESOLVED group.substatus = None + assert group.active_at is not None group.active_at = group.active_at - timedelta(minutes=1) group.save(update_fields=["status", "substatus", "active_at"]) # trigger a transition from resolved to regressed by firing an event that groups to that issue regressed_event = self.create_event(data={"message": "testing"}, project_id=self.project.id) - assert regressed_event.group == new_event.group + assert regressed_event.group == require_group(new_event) - group = regressed_event.group + group = require_group(regressed_event) group.refresh_from_db() assert group.status == GroupStatus.UNRESOLVED assert group.substatus == GroupSubStatus.REGRESSED @@ -693,7 +713,7 @@ def test_group_inbox_regression(self, mock_process_workflows_event: MagicMock) - class AssignmentTestMixin(BasePostProcessGroupMixin): - def make_ownership(self, extra_rules=None): + def make_ownership(self, extra_rules: list[Rule] | None = None) -> None: self.user_2 = self.create_user() self.create_team_membership(team=self.team, user=self.user_2) rules = [ @@ -728,16 +748,17 @@ def test_owner_assignment_order_precedence(self) -> None: is_new_group_environment=False, event=event, ) - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() + assert assignee is not None assert assignee.user_id == self.user.id assert assignee.team is None - owners = list(GroupOwner.objects.filter(group=event.group)) + owners = list(GroupOwner.objects.filter(group=require_group(event))) assert len(owners) == 2 assert {(self.user.id, None), (None, self.team.id)} == { (o.user_id, o.team_id) for o in owners } - activity = Activity.objects.get(group=event.group) + activity = Activity.objects.get(group=require_group(event)) assert activity.data == { "assignee": str(self.user.id), "assigneeEmail": self.user.email, @@ -767,14 +788,16 @@ def test_owner_assignment_extra_groups(self) -> None: is_new_group_environment=False, event=event, ) - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() + assert assignee is not None assert assignee.user_id == extra_user.id assert assignee.team is None - owners = list(GroupOwner.objects.filter(group=event.group)) + owners = list(GroupOwner.objects.filter(group=require_group(event))) assert len(owners) == 2 + # justification: FlexibleForeignKey(null=True) types team_id as int assert {(extra_user.id, None), (self.user.id, None)} == { - (o.user_id, o.team_id) for o in owners + (o.user_id, cast(int | None, o.team_id)) for o in owners } def test_owner_assignment_existing_owners(self) -> None: @@ -805,11 +828,12 @@ def test_owner_assignment_existing_owners(self) -> None: is_new_group_environment=False, event=event, ) - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() + assert assignee is not None assert assignee.user_id is None assert assignee.team == extra_team - owners = list(GroupOwner.objects.filter(group=event.group)) + owners = list(GroupOwner.objects.filter(group=require_group(event))) assert {(None, extra_team.id), (self.user.id, None)} == { (o.user_id, o.team_id) for o in owners } @@ -843,7 +867,7 @@ def test_owner_assignment_existing_assignee_preserved(self) -> None: ) # No assignee should exist prior to post processing - assert not event.group.assignee_set.exists() + assert not require_group(event).assignee_set.exists() # First post-processing - should assign to other_team (last matching rule) self.call_post_process_group( @@ -853,7 +877,8 @@ def test_owner_assignment_existing_assignee_preserved(self) -> None: event=event, ) - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() + assert assignee is not None assert assignee.team == other_team new_rules = [ @@ -871,7 +896,8 @@ def test_owner_assignment_existing_assignee_preserved(self) -> None: event=event, ) - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() + assert assignee is not None assert assignee.team == other_team # If we had a completely new group, it would get assigned to self.team (new last matching rule) @@ -891,7 +917,8 @@ def test_owner_assignment_existing_assignee_preserved(self) -> None: event=fresh_event, ) - fresh_assignee = fresh_event.group.assignee_set.first() + fresh_assignee = require_group(fresh_event).assignee_set.first() + assert fresh_assignee is not None assert fresh_assignee.team == self.team def test_owner_assignment_assign_user(self) -> None: @@ -910,7 +937,8 @@ def test_owner_assignment_assign_user(self) -> None: is_new_group_environment=False, event=event, ) - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() + assert assignee is not None assert assignee.user_id == self.user.id assert assignee.team is None @@ -929,7 +957,7 @@ def test_owner_assignment_ownership_no_matching_owners(self) -> None: is_new_group_environment=False, event=event, ) - assert not event.group.assignee_set.exists() + assert not require_group(event).assignee_set.exists() def test_owner_assignment_existing_assignment(self) -> None: self.make_ownership() @@ -941,14 +969,15 @@ def test_owner_assignment_existing_assignment(self) -> None: }, project_id=self.project.id, ) - event.group.assignee_set.create(team=self.team, project=self.project) + require_group(event).assignee_set.create(team=self.team, project=self.project) self.call_post_process_group( is_new=False, is_regression=False, is_new_group_environment=False, event=event, ) - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() + assert assignee is not None assert assignee.user_id is None assert assignee.team == self.team @@ -969,7 +998,8 @@ def test_only_first_assignment_works(self) -> None: is_new_group_environment=False, event=event, ) - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() + assert assignee is not None assert assignee.user_id == self.user.id assert assignee.team is None @@ -988,7 +1018,8 @@ def test_only_first_assignment_works(self) -> None: is_new_group_environment=False, event=event, ) - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() + assert assignee is not None # Assignment shouldn't change. assert assignee.user_id == self.user.id assert assignee.team is None @@ -1012,7 +1043,7 @@ def test_owner_assignment_owner_is_gone(self) -> None: is_new_group_environment=False, event=event, ) - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() assert assignee is None def test_suspect_committer_affect_cache_debouncing_issue_owners_calculations(self) -> None: @@ -1027,7 +1058,7 @@ def test_suspect_committer_affect_cache_debouncing_issue_owners_calculations(sel ) committer = GroupOwner( - group=event.group, + group=require_group(event), project=event.project, organization=event.project.organization, type=GroupOwnerType.SUSPECT_COMMIT.value, @@ -1041,14 +1072,15 @@ def test_suspect_committer_affect_cache_debouncing_issue_owners_calculations(sel }, project_id=self.project.id, ) - event.group.assignee_set.create(team=self.team, project=self.project) + require_group(event).assignee_set.create(team=self.team, project=self.project) self.call_post_process_group( is_new=False, is_regression=False, is_new_group_environment=False, event=event, ) - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() + assert assignee is not None assert assignee.user_id is None assert assignee.team == self.team @@ -1087,15 +1119,16 @@ def test_owner_assignment_when_owners_have_been_unassigned(self) -> None: is_new_group_environment=False, event=event_2, ) - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() + assert assignee is not None assert assignee.user_id == self.user.id user_3 = self.create_user() self.create_team_membership(self.team, user=user_3) # De-assign group assignees - GroupAssignee.objects.deassign(event.group, self.user) - assert event.group.assignee_set.first() is None + GroupAssignee.objects.deassign(require_group(event), self.user) + assert require_group(event).assignee_set.first() is None # Change ProjectOwnership rules rules = [ @@ -1134,12 +1167,16 @@ def test_owner_assignment_when_owners_have_been_unassigned(self) -> None: ) # Group should be re-assigned to the new group owner - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() + assert assignee is not None assert assignee.user_id == user_3.id + assert assignee.user_id is not None # De-assign group assignees - GroupAssignee.objects.deassign(event.group, user_service.get_user(user_id=assignee.user_id)) - assert event.group.assignee_set.first() is None + GroupAssignee.objects.deassign( + require_group(event), user_service.get_user(user_id=assignee.user_id) + ) + assert require_group(event).assignee_set.first() is None user_4 = self.create_user() self.create_team_membership(self.team, user=user_4) @@ -1188,7 +1225,8 @@ def test_owner_assignment_when_owners_have_been_unassigned(self) -> None: ) # Group should be re-assigned to the new group owner - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() + assert assignee is not None assert assignee.user_id == user_4.id def test_auto_assignment_when_owners_have_been_unassigned(self) -> None: @@ -1226,13 +1264,13 @@ def test_auto_assignment_when_owners_have_been_unassigned(self) -> None: # Set assignee_exists cache (should be invalidated when ownership changes) cache.set( - ASSIGNEE_EXISTS_KEY(event.group_id), + ASSIGNEE_EXISTS_KEY(require_group(event).id), (True, timezone.now().timestamp()), ASSIGNEE_EXISTS_DURATION, ) # De-assign group assignees - GroupAssignee.objects.deassign(event.group, self.user) - assert event.group.assignee_set.first() is None + GroupAssignee.objects.deassign(require_group(event), self.user) + assert require_group(event).assignee_set.first() is None # Change ProjectOwnership rules rules = [ @@ -1277,8 +1315,8 @@ def test_ensure_when_assignees_and_owners_are_cached_does_not_cause_unbound_erro project_id=self.project.id, ) - cache.set(ASSIGNEE_EXISTS_KEY(event.group.id), (True, timezone.now().timestamp())) - cache.set(ISSUE_OWNERS_DEBOUNCE_KEY(event.group.id), timezone.now().timestamp()) + cache.set(ASSIGNEE_EXISTS_KEY(require_group(event).id), (True, timezone.now().timestamp())) + cache.set(ISSUE_OWNERS_DEBOUNCE_KEY(require_group(event).id), timezone.now().timestamp()) self.call_post_process_group( is_new=False, @@ -1302,7 +1340,7 @@ def test_auto_assignment_when_owners_are_invalid(self) -> None: ) # Hard code an invalid group owner invalid_codeowner = GroupOwner( - group=event.group, + group=require_group(event), project=event.project, organization=event.project.organization, type=GroupOwnerType.CODEOWNERS.value, @@ -1318,9 +1356,9 @@ def test_auto_assignment_when_owners_are_invalid(self) -> None: event=event, ) - assignee = event.group.assignee_set.first() + assignee = require_group(event).assignee_set.first() assert assignee is None - assert len(GroupOwner.objects.filter(group_id=event.group)) == 0 + assert len(GroupOwner.objects.filter(group=require_group(event))) == 0 @patch("sentry.utils.metrics.incr") def test_debounces_handle_owner_assignments(self, mock_incr: MagicMock) -> None: @@ -1333,10 +1371,10 @@ def test_debounces_handle_owner_assignments(self, mock_incr: MagicMock) -> None: }, project_id=self.project.id, ) - debounce_time = cache.get(ISSUE_OWNERS_DEBOUNCE_KEY(event.group_id)) + debounce_time = cache.get(ISSUE_OWNERS_DEBOUNCE_KEY(require_group(event).id)) assert debounce_time is None - cache.set(ISSUE_OWNERS_DEBOUNCE_KEY(event.group_id), timezone.now().timestamp()) + cache.set(ISSUE_OWNERS_DEBOUNCE_KEY(require_group(event).id), timezone.now().timestamp()) # Should debounce because no ownership change occurred and the debounce cache is set self.call_post_process_group( @@ -1358,10 +1396,10 @@ def test_no_issue_owners_debounce_when_ownership_changes(self, mock_incr: MagicM }, project_id=self.project.id, ) - debounce_time = cache.get(ISSUE_OWNERS_DEBOUNCE_KEY(event.group_id)) + debounce_time = cache.get(ISSUE_OWNERS_DEBOUNCE_KEY(require_group(event).id)) assert debounce_time is None - cache.set(ISSUE_OWNERS_DEBOUNCE_KEY(event.group_id), timezone.now().timestamp()) + cache.set(ISSUE_OWNERS_DEBOUNCE_KEY(require_group(event).id), timezone.now().timestamp()) # Simulate ownership rules changing GroupOwner.set_project_ownership_version(self.project.id) @@ -1388,9 +1426,9 @@ def test_debounces_assignee_existence_check(self, mock_incr: MagicMock) -> None: }, project_id=self.project.id, ) - GroupAssignee.objects.assign(event.group, self.user) + GroupAssignee.objects.assign(require_group(event), self.user) - assignee_cache_value = cache.get(ASSIGNEE_EXISTS_KEY(event.group_id)) + assignee_cache_value = cache.get(ASSIGNEE_EXISTS_KEY(require_group(event).id)) assert assignee_cache_value is None # First event: should check DB, find assignee exists, cache result with timestamp @@ -1400,7 +1438,7 @@ def test_debounces_assignee_existence_check(self, mock_incr: MagicMock) -> None: is_new_group_environment=False, event=event, ) - assignee_cache_value = cache.get(ASSIGNEE_EXISTS_KEY(event.group_id)) + assignee_cache_value = cache.get(ASSIGNEE_EXISTS_KEY(require_group(event).id)) assert assignee_cache_value is not None assert isinstance(assignee_cache_value, tuple) cached_assignee_exists, cached_timestamp = assignee_cache_value @@ -1422,11 +1460,11 @@ def test_no_assignee_exists_debounce_when_ownership_changes(self, mock_incr: Mag }, project_id=self.project.id, ) - GroupAssignee.objects.assign(event.group, self.user) + GroupAssignee.objects.assign(require_group(event), self.user) old_timestamp = timezone.now().timestamp() cache.set( - ASSIGNEE_EXISTS_KEY(event.group_id), + ASSIGNEE_EXISTS_KEY(require_group(event).id), (True, old_timestamp), ASSIGNEE_EXISTS_DURATION, ) @@ -1444,7 +1482,7 @@ def test_no_assignee_exists_debounce_when_ownership_changes(self, mock_incr: Mag is_new_group_environment=False, event=event, ) - new_cache_value = cache.get(ASSIGNEE_EXISTS_KEY(event.group_id)) + new_cache_value = cache.get(ASSIGNEE_EXISTS_KEY(require_group(event).id)) assert new_cache_value is not None new_assignee_exists, new_timestamp = new_cache_value assert new_assignee_exists is True @@ -1462,13 +1500,13 @@ def test_debounces_assignee_existence_check_when_ownership_older( }, project_id=self.project.id, ) - GroupAssignee.objects.assign(event.group, self.user) + GroupAssignee.objects.assign(require_group(event), self.user) # Ownership changed in the past (before cache is set) GroupOwner.set_project_ownership_version(self.project.id) cache.set( - ASSIGNEE_EXISTS_KEY(event.group_id), + ASSIGNEE_EXISTS_KEY(require_group(event).id), (True, timezone.now().timestamp()), ASSIGNEE_EXISTS_DURATION, ) @@ -1498,7 +1536,7 @@ def test_caches_assignee_does_not_exist(self, mock_incr: MagicMock) -> None: ) # No assignee for this group - assignee_cache_value = cache.get(ASSIGNEE_EXISTS_KEY(event.group_id)) + assignee_cache_value = cache.get(ASSIGNEE_EXISTS_KEY(require_group(event).id)) assert assignee_cache_value is None self.call_post_process_group( @@ -1509,7 +1547,7 @@ def test_caches_assignee_does_not_exist(self, mock_incr: MagicMock) -> None: ) # Cache should store (False, timestamp) - assignee_cache_value = cache.get(ASSIGNEE_EXISTS_KEY(event.group_id)) + assignee_cache_value = cache.get(ASSIGNEE_EXISTS_KEY(require_group(event).id)) assert assignee_cache_value is not None assert isinstance(assignee_cache_value, tuple) cached_assignee_exists, cached_timestamp = assignee_cache_value @@ -1733,6 +1771,7 @@ def test_release_fallback_when_code_mapping_integration_is_inactive( mock_process_suspect_commits: MagicMock, ) -> None: with assume_test_silo_mode(SiloMode.CONTROL): + assert self.organization_integration is not None self.organization_integration.update(status=ObjectStatus.DISABLED) with self.tasks(): @@ -1841,7 +1880,7 @@ def test_invalidates_snooze_issue_platform( self, mock_process_workflows_event: MagicMock, mock_send_unignored_robust: MagicMock ) -> None: event = self.create_event(data={"message": "testing"}, project_id=self.project.id) - group = event.group + group = require_group(event) should_detect_escalation = group.issue_type.should_detect_escalation() # Check for has_reappeared=False if is_new=True @@ -1880,7 +1919,7 @@ def test_invalidates_snooze_issue_platform( ) mock_process_workflows_event.apply_async.assert_called_with( kwargs=ProcessWorkflowsKwargsMatcher( - event=event, group=event.group, has_reappeared=True, has_escalated=False + event=event, group=require_group(event), has_reappeared=True, has_escalated=False ), headers=ANY, ) @@ -1921,7 +1960,7 @@ def test_invalidates_snooze( ) -> None: event = self.create_event(data={"message": "testing"}, project_id=self.project.id) - group = event.group + group = require_group(event) # Check for has_reappeared=False if is_new=True self.call_post_process_group( @@ -2003,7 +2042,7 @@ def test_invalidates_snooze_with_buffers( event_2 = self.create_event( data={"message": "testing", "fingerprint": ["group-1"]}, project_id=self.project.id ) - group = event.group + group = require_group(event) group.times_seen = 50 group.status = GroupStatus.IGNORED group.substatus = GroupSubStatus.UNTIL_CONDITION_MET @@ -2018,7 +2057,7 @@ def test_invalidates_snooze_with_buffers( ) assert GroupSnooze.objects.filter(id=snooze.id).exists() - buffer.backend.incr(Group, {"times_seen": 60}, filters={"id": event.group.id}) + buffer.backend.incr(Group, {"times_seen": 60}, filters={"id": require_group(event).id}) self.call_post_process_group( is_new=False, is_regression=False, @@ -2047,7 +2086,7 @@ def test_invalidates_snooze_with_buffers( @patch("sentry.workflow_engine.tasks.workflows.process_workflows_event") def test_maintains_valid_snooze(self, mock_process_workflows_event: MagicMock) -> None: event = self.create_event(data={}, project_id=self.project.id) - group = event.group + group = require_group(event) assert group.status == GroupStatus.UNRESOLVED assert group.substatus == GroupSubStatus.NEW snooze = GroupSnooze.objects.create(group=group, until=timezone.now() + timedelta(hours=1)) @@ -2084,7 +2123,7 @@ def test_forecast_in_activity(self, mock_is_escalating: MagicMock) -> None: previously ignored until_escalating. """ event = self.create_event(data={"message": "testing"}, project_id=self.project.id) - group = event.group + group = require_group(event) group.update( first_seen=timezone.now() - timedelta(days=1), status=GroupStatus.IGNORED, @@ -2111,7 +2150,7 @@ def test_skip_escalation_logic_for_new_groups(self, mock_is_escalating: MagicMoc a day old. """ event = self.create_event(data={"message": "testing"}, project_id=self.project.id) - group = event.group + group = require_group(event) group.status = GroupStatus.IGNORED group.substatus = GroupSubStatus.UNTIL_ESCALATING group.update(first_seen=timezone.now() - timedelta(hours=1)) @@ -2194,8 +2233,8 @@ def test_sdk_crash_monitoring_not_called_without_sample_rate( mock_sdk_crash_detection.detect_sdk_crash.assert_not_called() def test_sdk_crash_monitoring_is_not_called_with_disabled_feature( - self, mock_sdk_crash_detection - ): + self, mock_sdk_crash_detection: MagicMock + ) -> None: event = self.create_event( data={"message": "testing"}, project_id=self.project.id, @@ -2695,7 +2734,7 @@ class DetectNewEscalationTestMixin(BasePostProcessGroupMixin): @patch("sentry.tasks.post_process.run_post_process_job", side_effect=run_post_process_job) def test_has_escalated(self, mock_run_post_process_job: MagicMock) -> None: event = self.create_event(data={}, project_id=self.project.id) - group = event.group + group = require_group(event) group.update( first_seen=timezone.now() - timedelta(hours=1), times_seen=10, @@ -2722,7 +2761,7 @@ def test_has_escalated_old( self, mock_run_post_process_job: MagicMock, mock_threshold: MagicMock ) -> None: event = self.create_event(data={}, project_id=self.project.id) - group = event.group + group = require_group(event) group.update(first_seen=timezone.now() - timedelta(days=2), times_seen=10000) self.call_post_process_group( @@ -2744,7 +2783,7 @@ def test_has_not_escalated( self, mock_run_post_process_job: MagicMock, mock_threshold: MagicMock ) -> None: event = self.create_event(data={}, project_id=self.project.id) - group = event.group + group = require_group(event) group.update( first_seen=timezone.now() - timedelta(hours=1), times_seen=10, @@ -2770,7 +2809,7 @@ def test_has_escalated_locked( self, mock_run_post_process_job: MagicMock, mock_threshold: MagicMock ) -> None: event = self.create_event(data={}, project_id=self.project.id) - group = event.group + group = require_group(event) group.update(first_seen=timezone.now() - timedelta(hours=1), times_seen=10000) lock = locks.get(f"detect_escalation:{group.id}", duration=10, name="detect_escalation") with lock.acquire(): @@ -2792,7 +2831,7 @@ def test_has_escalated_already_escalated( self, mock_run_post_process_job: MagicMock, mock_threshold: MagicMock ) -> None: event = self.create_event(data={}, project_id=self.project.id) - group = event.group + group = require_group(event) self.call_post_process_group( is_new=True, is_regression=False, @@ -2827,7 +2866,7 @@ def test_does_not_escalate_non_new_substatus( if substatus == GroupSubStatus.NEW: continue event = self.create_event(data={}, project_id=self.project.id) - group = event.group + group = require_group(event) group.update( first_seen=timezone.now() - timedelta(hours=1), times_seen=10000, @@ -2854,7 +2893,7 @@ def test_no_escalation_less_than_floor( self, mock_run_post_process_job: MagicMock, mock_threshold: MagicMock ) -> None: event = self.create_event(data={}, project_id=self.project.id) - group = event.group + group = require_group(event) group.update(first_seen=timezone.now() - timedelta(hours=1), times_seen=9) event.group = Group.objects.get(id=group.id) @@ -2876,7 +2915,7 @@ def test_has_not_escalated_less_than_an_hour( self, mock_run_post_process_job: MagicMock, mock_threshold: MagicMock ) -> None: event = self.create_event(data={}, project_id=self.project.id) - group = event.group + group = require_group(event) # the group is less than an hour old, but we use 1 hr for the hourly event rate anyway group.update(first_seen=timezone.now() - timedelta(minutes=1), times_seen=10) event.group = Group.objects.get(id=group.id) @@ -2898,7 +2937,7 @@ def test_zero_escalation_rate( self, mock_run_post_process_job: MagicMock, mock_threshold: MagicMock ) -> None: event = self.create_event(data={}, project_id=self.project.id) - group = event.group + group = require_group(event) group.update(first_seen=timezone.now() - timedelta(hours=1), times_seen=10000) self.call_post_process_group( is_new=True, @@ -2929,7 +2968,7 @@ def test_process_similarity(self, mock_safe_execute: MagicMock) -> None: mock_safe_execute.assert_any_call(similarity.record, mock.ANY, mock.ANY) - def assert_not_called_with(self, mock_function: Mock): + def assert_not_called_with(self, mock_function: Mock) -> None: """ Helper function to check that safe_execute isn't called with similarity.record It can/will be called with other parameters @@ -3055,7 +3094,9 @@ def test_step_is_skipped_by_fully_specified_condition( class KickOffSeerAutomationTestMixin(BasePostProcessGroupMixin): @patch("sentry.tasks.seer.autofix.generate_summary_and_run_automation.delay") @override_settings(SENTRY_SELF_HOSTED=False) - def test_kick_off_seer_automation_with_features(self, mock_generate_summary_and_run_automation): + def test_kick_off_seer_automation_with_features( + self, mock_generate_summary_and_run_automation: MagicMock + ) -> None: self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( data={"message": "testing"}, @@ -3070,14 +3111,14 @@ def test_kick_off_seer_automation_with_features(self, mock_generate_summary_and_ ) mock_generate_summary_and_run_automation.assert_called_once_with( - event.group.id, trigger_path="old_seer_automation" + require_group(event).id, trigger_path="old_seer_automation" ) @override_settings(SENTRY_SELF_HOSTED=True) @patch("sentry.tasks.seer.autofix.generate_summary_and_run_automation.delay") def test_kick_off_seer_automation_when_self_hosted( - self, mock_generate_summary_and_run_automation - ): + self, mock_generate_summary_and_run_automation: MagicMock + ) -> None: self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( data={"message": "testing"}, @@ -3095,8 +3136,8 @@ def test_kick_off_seer_automation_when_self_hosted( @patch("sentry.tasks.seer.autofix.generate_summary_and_run_automation.delay") @override_settings(SENTRY_SELF_HOSTED=False) def test_kick_off_seer_automation_without_scanner_on( - self, mock_generate_summary_and_run_automation - ): + self, mock_generate_summary_and_run_automation: MagicMock + ) -> None: self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( data={"message": "testing"}, @@ -3116,8 +3157,8 @@ def test_kick_off_seer_automation_without_scanner_on( @patch("sentry.tasks.seer.autofix.generate_summary_and_run_automation.delay") @override_settings(SENTRY_SELF_HOSTED=False) def test_kick_off_seer_automation_skips_existing_fixability_score( - self, mock_generate_summary_and_run_automation - ): + self, mock_generate_summary_and_run_automation: MagicMock + ) -> None: self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( data={"message": "testing"}, @@ -3125,7 +3166,7 @@ def test_kick_off_seer_automation_skips_existing_fixability_score( ) # Set seer_fixability_score on the group - group = event.group + group = require_group(event) group.seer_fixability_score = 0.75 group.save() @@ -3141,8 +3182,8 @@ def test_kick_off_seer_automation_skips_existing_fixability_score( @patch("sentry.tasks.seer.autofix.generate_summary_and_run_automation.delay") @override_settings(SENTRY_SELF_HOSTED=False) def test_kick_off_seer_automation_skips_existing_issue( - self, mock_generate_summary_and_run_automation - ): + self, mock_generate_summary_and_run_automation: MagicMock + ) -> None: self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( data={"message": "testing"}, @@ -3150,7 +3191,7 @@ def test_kick_off_seer_automation_skips_existing_issue( ) # Group has no seer_fixability_score (None by default) - group = event.group + group = require_group(event) assert group.seer_fixability_score is None self.call_post_process_group( @@ -3165,8 +3206,8 @@ def test_kick_off_seer_automation_skips_existing_issue( @patch("sentry.tasks.seer.autofix.generate_summary_and_run_automation.delay") @override_settings(SENTRY_SELF_HOSTED=False) def test_kick_off_seer_automation_skips_with_existing_fixability_score( - self, mock_generate_summary_and_run_automation - ): + self, mock_generate_summary_and_run_automation: MagicMock + ) -> None: from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key self.project.update_option("sentry:seer_scanner_automation", True) @@ -3176,7 +3217,7 @@ def test_kick_off_seer_automation_skips_with_existing_fixability_score( ) # Set seer_fixability_score on the group - group = event.group + group = require_group(event) group.seer_fixability_score = 0.75 group.save() @@ -3199,10 +3240,10 @@ def test_kick_off_seer_automation_skips_with_existing_fixability_score( @override_settings(SENTRY_SELF_HOSTED=False) def test_rate_limit_only_checked_after_all_other_checks_pass( self, - mock_generate_summary_and_run_automation, - mock_has_budget, - mock_is_rate_limited, - ): + mock_generate_summary_and_run_automation: MagicMock, + mock_has_budget: MagicMock, + mock_is_rate_limited: MagicMock, + ) -> None: """Test that rate limit check only happens after all other checks pass""" mock_has_budget.return_value = True mock_is_rate_limited.return_value = False @@ -3220,9 +3261,11 @@ def test_rate_limit_only_checked_after_all_other_checks_pass( is_new_group_environment=True, event=event, ) - mock_is_rate_limited.assert_called_once_with(event.project, event.group.organization) + mock_is_rate_limited.assert_called_once_with( + event.project, require_group(event).organization + ) mock_generate_summary_and_run_automation.assert_called_once_with( - event.group.id, trigger_path="old_seer_automation" + require_group(event).id, trigger_path="old_seer_automation" ) mock_is_rate_limited.reset_mock() @@ -3269,8 +3312,8 @@ def test_rate_limit_only_checked_after_all_other_checks_pass( @patch("sentry.tasks.seer.autofix.generate_summary_and_run_automation.delay") @override_settings(SENTRY_SELF_HOSTED=False) def test_kick_off_seer_automation_skips_when_lock_held( - self, mock_generate_summary_and_run_automation - ): + self, mock_generate_summary_and_run_automation: MagicMock + ) -> None: """Test that seer automation is skipped when another task is already processing the same issue""" from sentry.seer.autofix.issue_summary import get_issue_summary_lock_key from sentry.tasks.post_process import locks @@ -3282,7 +3325,7 @@ def test_kick_off_seer_automation_skips_when_lock_held( ) # Acquire the lock manually to simulate another task in progress - lock_key, lock_name = get_issue_summary_lock_key(event.group.id) + lock_key, lock_name = get_issue_summary_lock_key(require_group(event).id) lock = locks.get(lock_key, duration=10, name=lock_name) with lock.acquire(): @@ -3312,14 +3355,14 @@ def test_kick_off_seer_automation_skips_when_lock_held( # Now it should be called since no lock is held mock_generate_summary_and_run_automation.assert_called_once_with( - event2.group.id, trigger_path="old_seer_automation" + require_group(event2).id, trigger_path="old_seer_automation" ) @patch("sentry.tasks.seer.autofix.generate_summary_and_run_automation.delay") @override_settings(SENTRY_SELF_HOSTED=False) def test_kick_off_seer_automation_with_hide_ai_features_enabled( - self, mock_generate_summary_and_run_automation - ): + self, mock_generate_summary_and_run_automation: MagicMock + ) -> None: """Test that seer automation is not started when organization has hideAiFeatures set to True""" self.project.update_option("sentry:seer_scanner_automation", True) self.organization.update_option("sentry:hide_ai_features", True) @@ -3341,7 +3384,7 @@ def test_kick_off_seer_automation_with_hide_ai_features_enabled( class KickOffLightweightRCAClusterTestMixin(BasePostProcessGroupMixin): @patch("sentry.tasks.seer.lightweight_rca_cluster.trigger_lightweight_rca_cluster_task.delay") - def test_kick_off_lightweight_rca_cluster_when_enabled(self, mock_task): + def test_kick_off_lightweight_rca_cluster_when_enabled(self, mock_task: MagicMock) -> None: event = self.create_event( data={"message": "testing"}, project_id=self.project.id, @@ -3355,10 +3398,12 @@ def test_kick_off_lightweight_rca_cluster_when_enabled(self, mock_task): event=event, ) - mock_task.assert_called_once_with(event.group.id, event_id=event.event_id) + mock_task.assert_called_once_with(require_group(event).id, event_id=event.event_id) @patch("sentry.tasks.seer.lightweight_rca_cluster.trigger_lightweight_rca_cluster_task.delay") - def test_kick_off_lightweight_rca_cluster_skips_when_not_enabled(self, mock_task): + def test_kick_off_lightweight_rca_cluster_skips_when_not_enabled( + self, mock_task: MagicMock + ) -> None: event = self.create_event( data={"message": "testing"}, project_id=self.project.id, @@ -3374,7 +3419,9 @@ def test_kick_off_lightweight_rca_cluster_skips_when_not_enabled(self, mock_task mock_task.assert_not_called() @patch("sentry.tasks.seer.lightweight_rca_cluster.trigger_lightweight_rca_cluster_task.delay") - def test_kick_off_lightweight_rca_cluster_skips_when_not_new(self, mock_task): + def test_kick_off_lightweight_rca_cluster_skips_when_not_new( + self, mock_task: MagicMock + ) -> None: event = self.create_event( data={"message": "testing"}, project_id=self.project.id, @@ -3393,11 +3440,11 @@ def test_kick_off_lightweight_rca_cluster_skips_when_not_new(self, mock_task): @patch("sentry.seer.autofix.utils.is_seer_seat_based_tier_enabled", return_value=True) class SeatBasedSeerAutomationTestMixin(BasePostProcessGroupMixin): - def _seat_based_post_process(self, **group_overrides): + def _seat_based_post_process(self, **group_overrides: object) -> Event: """Helper: create event, optionally update group fields, run post-process.""" event = self.create_event(data={"message": "testing"}, project_id=self.project.id) if group_overrides: - event.group.update(**group_overrides) + require_group(event).update(None, **group_overrides) self.call_post_process_group( is_new=True, is_regression=False, is_new_group_environment=True, event=event ) @@ -3406,16 +3453,16 @@ def _seat_based_post_process(self, **group_overrides): @patch("sentry.tasks.seer.autofix.generate_issue_summary_only.delay") @override_settings(SENTRY_SELF_HOSTED=False) def test_seat_based_org_skips_old_issues( - self, mock_generate_summary_only, mock_seat_based_tier - ): + self, mock_generate_summary_only: MagicMock, mock_seat_based_tier: MagicMock + ) -> None: self._seat_based_post_process(first_seen=timezone.now() - timedelta(minutes=10)) mock_generate_summary_only.assert_not_called() @patch("sentry.tasks.seer.autofix.generate_issue_summary_only.delay") @override_settings(SENTRY_SELF_HOSTED=False) def test_seat_based_org_skips_when_fixability_exists( - self, mock_generate_summary_only, mock_seat_based_tier - ): + self, mock_generate_summary_only: MagicMock, mock_seat_based_tier: MagicMock + ) -> None: self._seat_based_post_process(seer_fixability_score=0.5) mock_generate_summary_only.assert_not_called() @@ -3426,7 +3473,9 @@ class SeerAutomationHelperFunctionsTestMixin(BasePostProcessGroupMixin): @override_settings(SENTRY_SELF_HOSTED=False) @patch("sentry.quotas.backend.check_seer_quota", return_value=True) @patch("sentry.features.has", return_value=True) - def test_is_issue_eligible_for_seer_automation(self, mock_features_has, mock_has_budget): + def test_is_issue_eligible_for_seer_automation( + self, mock_features_has: MagicMock, mock_has_budget: MagicMock + ) -> None: """Test permission check with various failure conditions.""" from sentry.constants import DataCategory from sentry.issues.grouptype import GroupCategory @@ -3434,7 +3483,7 @@ def test_is_issue_eligible_for_seer_automation(self, mock_features_has, mock_has self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event(data={"message": "testing"}, project_id=self.project.id) - group = event.group + group = require_group(event) # All conditions pass assert is_issue_eligible_for_seer_automation(group) is True @@ -3739,21 +3788,25 @@ def test_unavailable_nodestore_fails_after_retries( @patch("sentry.tasks.seer.autofix.generate_issue_summary_only.delay") @override_settings(SENTRY_SELF_HOSTED=False) def test_seat_based_org_generates_summary_for_new_issues( - self, mock_generate_summary_only, mock_seat_based_tier - ): + self, mock_generate_summary_only: MagicMock, mock_seat_based_tier: MagicMock + ) -> None: event = self.create_event(data={"message": "testing"}, project_id=self.project.id) self.call_post_process_group( is_new=True, is_regression=False, is_new_group_environment=True, event=event ) - mock_generate_summary_only.assert_called_once_with(event.group.id) + mock_generate_summary_only.assert_called_once_with(require_group(event).id) def setUp(self) -> None: super().setUp() - def create_event(self, data, project_id, assert_no_errors=True): + def create_event( + self, data: dict[str, object], project_id: int, assert_no_errors: bool = True + ) -> Event: return self.store_event(data=data, project_id=project_id, assert_no_errors=assert_no_errors) - def call_post_process_group(self, is_new, is_regression, is_new_group_environment, event): + def call_post_process_group( + self, is_new: bool, is_regression: bool, is_new_group_environment: bool, event: Event + ) -> None: event.data.save() post_process_group( is_new=is_new, @@ -3778,12 +3831,19 @@ class PostProcessGroupPerformanceTest( KickOffSeerAutomationTestMixin, SeatBasedSeerAutomationTestMixin, ): - def create_event(self, data, project_id, assert_no_errors=True): - fingerprint = data["fingerprint"][0] if data.get("fingerprint") else "some_group" + def create_event( + self, data: dict[str, object], project_id: int, assert_no_errors: bool = True + ) -> Event: + fingerprint_values = data.get("fingerprint") + assert fingerprint_values is None or isinstance(fingerprint_values, list) + fingerprint = fingerprint_values[0] if fingerprint_values else "some_group" fingerprint = f"{PerformanceNPlusOneGroupType.type_id}-{fingerprint}" - return self.create_performance_issue(fingerprint=fingerprint) + # justification: this harness stores an Event, with the occurrence carried alongside it. + return cast(Event, self.create_performance_issue(fingerprint=fingerprint)) - def call_post_process_group(self, is_new, is_regression, is_new_group_environment, event): + def call_post_process_group( + self, is_new: bool, is_regression: bool, is_new_group_environment: bool, event: Event + ) -> None: event.data.save() with self.feature(PerformanceNPlusOneGroupType.build_post_process_group_feature_name()): post_process_group( @@ -3805,16 +3865,17 @@ def call_post_process_group(self, is_new, is_regression, is_new_group_environmen @patch("sentry.signals.event_processed.send_robust") def test_full_pipeline_with_group_states( self, - event_processed_signal_mock, - transaction_processed_signal_mock, - mock_process_workflows_event, - run_post_process_job_mock, - mock_process_workflow_engine, - mock_handle_auto_assignment, - mock_handle_owner_assignment, - ): - event = self.create_performance_issue() - assert event.group + event_processed_signal_mock: MagicMock, + transaction_processed_signal_mock: MagicMock, + mock_process_workflows_event: MagicMock, + run_post_process_job_mock: MagicMock, + mock_process_workflow_engine: MagicMock, + mock_handle_auto_assignment: MagicMock, + mock_handle_owner_assignment: MagicMock, + ) -> None: + # justification: create_performance_issue returns a GroupEvent when an occurrence is emitted + event = cast(GroupEvent, self.create_performance_issue()) + assert require_group(event) # TODO(jangjodi): Fix this ordering test; side_effects should be a function (lambda), # but because post-processing is async, this causes the assert to fail because it doesn't @@ -3856,18 +3917,21 @@ class PostProcessGroupAggregateEventTest( SnoozeTestSkipSnoozeMixin, PerformanceIssueTestCase, ): - def create_event(self, data, project_id, assert_no_errors=True): + def create_event( + self, data: dict[str, object], project_id: int, assert_no_errors: bool = True + ) -> Event: group = self.create_group( type=PerformanceP95EndpointRegressionGroupType.type_id, ) event = self.store_event(data=data, project_id=project_id) event.group = group - event = event.for_group(group) - - return event + # justification: GroupEvent is not an Event subclass but callers use the Event API + return cast(Event, event.for_group(group)) - def call_post_process_group(self, is_new, is_regression, is_new_group_environment, event): + def call_post_process_group( + self, is_new: bool, is_regression: bool, is_new_group_environment: bool, event: Event + ) -> None: event.data.save() with self.feature( PerformanceP95EndpointRegressionGroupType.build_post_process_group_feature_name() @@ -3894,7 +3958,9 @@ class PostProcessGroupGenericTest( KickOffSeerAutomationTestMixin, SeatBasedSeerAutomationTestMixin, ): - def create_event(self, data, project_id, assert_no_errors=True): + def create_event( + self, data: dict[str, object], project_id: int, assert_no_errors: bool = True + ) -> Event: data["type"] = "generic" event = self.store_event( data=data, project_id=project_id, assert_no_errors=assert_no_errors @@ -3906,16 +3972,19 @@ def create_event(self, data, project_id, assert_no_errors=True): group_event = event.for_group(group_info.group) group_event.occurrence = occurrence - return group_event + # justification: GroupEvent is not an Event subclass but callers use the Event API + return cast(Event, group_event) - def call_post_process_group(self, is_new, is_regression, is_new_group_environment, event): + def call_post_process_group( + self, is_new: bool, is_regression: bool, is_new_group_environment: bool, event: Event + ) -> None: post_process_group( is_new=is_new, is_regression=is_regression, is_new_group_environment=is_new_group_environment, group_id=event.group_id, - occurrence_id=event.occurrence.id, - project_id=event.group.project_id, + occurrence_id=require_occurrence(event).id, + project_id=require_group(event).project_id, eventstream_type=EventStreamEventType.Generic.value, ) @@ -3969,7 +4038,10 @@ def test_occurrences_for_same_event_have_independent_locks( ) assert mock_run_job.call_count == 2 - assert mock_run_job.call_args_list[0].args[0]["event"].occurrence.id == event.occurrence.id + assert ( + mock_run_job.call_args_list[0].args[0]["event"].occurrence.id + == require_occurrence(event).id + ) assert mock_run_job.call_args_list[1].args[0]["event"].occurrence.id == second_occurrence.id @patch("sentry.tasks.post_process.run_post_process_job") @@ -3992,10 +4064,10 @@ def test_occurrence_fetch_retries_missing_event( event.event_id, group_id=event.group_id, skip_transaction_groupevent=True, - occurrence_id=event.occurrence.id, + occurrence_id=require_occurrence(event).id, ) mock_sleep.assert_called_once_with(1.0) - assert mock_run_job.call_args.args[0]["event"].occurrence.id == event.occurrence.id + assert mock_run_job.call_args.args[0]["event"].occurrence.id == require_occurrence(event).id @patch("sentry.tasks.post_process.handle_owner_assignment") @patch("sentry.tasks.post_process.handle_auto_assignment") @@ -4006,14 +4078,14 @@ def test_occurrence_fetch_retries_missing_event( @patch("sentry.utils.snuba.raw_query") def test_full_pipeline_with_group_states( self, - snuba_raw_query_mock, - event_processed_signal_mock, - mock_process_workflows_event, - run_post_process_job_mock, - mock_process_workflow_engine, - mock_handle_auto_assignment, - mock_handle_owner_assignment, - ): + snuba_raw_query_mock: MagicMock, + event_processed_signal_mock: MagicMock, + mock_process_workflows_event: MagicMock, + run_post_process_job_mock: MagicMock, + mock_process_workflow_engine: MagicMock, + mock_handle_auto_assignment: MagicMock, + mock_handle_owner_assignment: MagicMock, + ) -> None: event = self.create_event(data={"message": "testing"}, project_id=self.project.id) call_order = [ mock_handle_owner_assignment, @@ -4095,12 +4167,12 @@ def assert_promotes_pending_attachments( def create_event( self, - data, - project_id, - assert_no_errors=True, - feedback_type=FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE, - is_spam=False, - ): + data: dict[str, object], + project_id: int, + assert_no_errors: bool = True, + feedback_type: FeedbackCreationSource | None = FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE, + is_spam: bool = False, + ) -> Event: data["type"] = "generic" event = self.store_event( @@ -4140,26 +4212,31 @@ def create_event( group_event = event.for_group(group_info.group) group_event.occurrence = occurrence - return group_event + # justification: GroupEvent is not an Event subclass but callers use the Event API + return cast(Event, group_event) - def call_post_process_group(self, is_new, is_regression, is_new_group_environment, event): + def call_post_process_group( + self, is_new: bool, is_regression: bool, is_new_group_environment: bool, event: Event + ) -> None: with self.feature(FeedbackGroup.build_post_process_group_feature_name()): post_process_group( is_new=is_new, is_regression=is_regression, is_new_group_environment=is_new_group_environment, group_id=event.group_id, - occurrence_id=event.occurrence.id, - project_id=event.group.project_id, + occurrence_id=require_occurrence(event).id, + project_id=require_group(event).project_id, eventstream_type=EventStreamEventType.Error.value, ) - def run_decorated_step(self, killswitch_conditions): + def run_decorated_step( + self, killswitch_conditions: list[dict[str, str]] + ) -> list[PostProcessJob]: # The step is wrapped by feedback_filter_decorator, so this only works if the # decorator preserves the wrapped function's name. calls = [] - def process_snoozes(job): + def process_snoozes(job: PostProcessJob) -> None: calls.append(job) event = self.create_event(data={}, project_id=self.project.id) @@ -4402,7 +4479,7 @@ def test_feedback_sends_webhook(self, mock_delay: MagicMock) -> None: ) mock_delay.assert_called_once_with( - action="created", sender="Group", instance_id=str(event.group.id) + action="created", sender="Group", instance_id=str(require_group(event).id) ) @@ -4424,10 +4501,14 @@ class ProcessDataForwardingTest(BasePostProcessGroupMixin, SnubaTestCase): }, } - def create_event(self, data, project_id, assert_no_errors=True): + def create_event( + self, data: dict[str, object], project_id: int, assert_no_errors: bool = True + ) -> Event: return self.store_event(data=data, project_id=project_id, assert_no_errors=assert_no_errors) - def call_post_process_group(self, is_new, is_regression, is_new_group_environment, event): + def call_post_process_group( + self, is_new: bool, is_regression: bool, is_new_group_environment: bool, event: Event + ) -> None: event.data.save() post_process_group( is_new=is_new, @@ -4438,7 +4519,12 @@ def call_post_process_group(self, is_new, is_regression, is_new_group_environmen project_id=event.project_id, ) - def setup_forwarder(self, provider, is_enabled=True, **config_overrides): + def setup_forwarder( + self, + provider: DataForwarderProviderSlug, + is_enabled: bool = True, + **config_overrides: str, + ) -> tuple[DataForwarder, DataForwarderProject]: config = self.DEFAULT_FORWARDER_CONFIGS[provider].copy() config.update(config_overrides) @@ -4470,7 +4556,7 @@ def test_process_data_forwarding_no_forwarders(self) -> None: ) @patch("data_forwarding.amazon_sqs.forwarder.AmazonSQSForwarder.forward_event") - def test_process_data_forwarding_sqs_enabled(self, mock_forward): + def test_process_data_forwarding_sqs_enabled(self, mock_forward: MagicMock) -> None: mock_forward.return_value = True _, data_forwarder_project = self.setup_forwarder(DataForwarderProviderSlug.SQS) event = self.create_event( @@ -4489,7 +4575,7 @@ def test_process_data_forwarding_sqs_enabled(self, mock_forward): assert call_args[0][1] == data_forwarder_project @patch("data_forwarding.amazon_sqs.forwarder.AmazonSQSForwarder.forward_event") - def test_process_data_forwarding_sqs_with_s3_bucket(self, mock_forward): + def test_process_data_forwarding_sqs_with_s3_bucket(self, mock_forward: MagicMock) -> None: """Test SQS forwarder with S3 bucket configured for large payloads.""" mock_forward.return_value = True @@ -4516,7 +4602,7 @@ def test_process_data_forwarding_sqs_with_s3_bucket(self, mock_forward): assert call_args[0][1].get_config()["s3_bucket"] == "my-sentry-events-bucket" @patch("data_forwarding.splunk.forwarder.SplunkForwarder.forward_event") - def test_process_data_forwarding_splunk_enabled(self, mock_forward): + def test_process_data_forwarding_splunk_enabled(self, mock_forward: MagicMock) -> None: mock_forward.return_value = True self.setup_forwarder(DataForwarderProviderSlug.SPLUNK) event = self.create_event( @@ -4533,7 +4619,7 @@ def test_process_data_forwarding_splunk_enabled(self, mock_forward): assert mock_forward.call_count == 1 @patch("data_forwarding.segment.forwarder.SegmentForwarder.forward_event") - def test_process_data_forwarding_segment_enabled(self, mock_forward): + def test_process_data_forwarding_segment_enabled(self, mock_forward: MagicMock) -> None: mock_forward.return_value = True self.setup_forwarder(DataForwarderProviderSlug.SEGMENT) event = self.create_event( @@ -4550,7 +4636,7 @@ def test_process_data_forwarding_segment_enabled(self, mock_forward): assert mock_forward.call_count == 1 @patch("data_forwarding.amazon_sqs.forwarder.AmazonSQSForwarder.forward_event") - def test_process_data_forwarding_disabled_forwarder(self, mock_forward): + def test_process_data_forwarding_disabled_forwarder(self, mock_forward: MagicMock) -> None: self.setup_forwarder(DataForwarderProviderSlug.SQS, is_enabled=False) event = self.create_event( data={"message": "test message", "level": "error"}, @@ -4568,8 +4654,8 @@ def test_process_data_forwarding_disabled_forwarder(self, mock_forward): @patch("data_forwarding.amazon_sqs.forwarder.AmazonSQSForwarder.forward_event") @patch("data_forwarding.splunk.forwarder.SplunkForwarder.forward_event") def test_process_data_forwarding_multiple_forwarders( - self, mock_splunk_forward, mock_sqs_forward - ): + self, mock_splunk_forward: MagicMock, mock_sqs_forward: MagicMock + ) -> None: mock_sqs_forward.return_value = True mock_splunk_forward.return_value = True @@ -4592,8 +4678,8 @@ def test_process_data_forwarding_multiple_forwarders( @patch("data_forwarding.amazon_sqs.forwarder.AmazonSQSForwarder.forward_event") @patch("data_forwarding.splunk.forwarder.SplunkForwarder.forward_event") def test_process_data_forwarding_one_forwarder_fails( - self, mock_splunk_forward, mock_sqs_forward - ): + self, mock_splunk_forward: MagicMock, mock_sqs_forward: MagicMock + ) -> None: """Test that when one forwarder fails, other forwarders still execute.""" mock_sqs_forward.side_effect = Exception("SQS connection failed") mock_splunk_forward.return_value = True diff --git a/tests/sentry/tasks/test_process_buffer.py b/tests/sentry/tasks/test_process_buffer.py index 13e51ae8090c..308d52ea79ef 100644 --- a/tests/sentry/tasks/test_process_buffer.py +++ b/tests/sentry/tasks/test_process_buffer.py @@ -16,7 +16,7 @@ def test_constraints_model_name(self) -> None: @mock.patch("sentry.buffer.backend.process") def test_calls_process_with_model_name(self, process: mock.MagicMock) -> None: columns = {"times_seen": 1} - filters = {"pk": 1} + filters: dict[str, object] = {"pk": 1} process_incr(model_name="sentry.Group", columns=columns, filters=filters) process.assert_called_once_with( model=Group, columns=columns, filters=filters, extra=None, signal_only=None diff --git a/tests/sentry/tasks/test_relay.py b/tests/sentry/tasks/test_relay.py index 0e054eadf0a9..44f3b60814c9 100644 --- a/tests/sentry/tasks/test_relay.py +++ b/tests/sentry/tasks/test_relay.py @@ -1,4 +1,7 @@ import contextlib +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager +from typing import Any, cast from unittest import mock import pytest @@ -7,6 +10,8 @@ from sentry.db.postgres.transactions import in_test_hide_transaction_boundary from sentry.models.options.organization_option import OrganizationOption from sentry.models.options.project_option import ProjectOption +from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.models.projectkey import ProjectKey, ProjectKeyStatus from sentry.relay.projectconfig_cache.redis import RedisProjectConfigCache from sentry.relay.projectconfig_debounce_cache.redis import RedisProjectConfigDebounceCache @@ -18,33 +23,37 @@ schedule_build_project_config, schedule_invalidate_project_config, ) +from sentry.testutils.factories import Factories from sentry.testutils.helpers.task_runner import BurstTaskRunner from sentry.testutils.hybrid_cloud import simulated_transaction_watermarks from sentry.testutils.pytest.fixtures import django_db_all from sentry.testutils.thread_leaks.pytest import thread_leak_allowlist -def _cache_keys_for_project(project): +def _cache_keys_for_project(project: Project) -> Iterator[str]: for key in ProjectKey.objects.filter(project_id=project.id): + assert key.public_key is not None yield key.public_key @pytest.fixture(autouse=True) -def disable_auto_on_commit(): +def disable_auto_on_commit() -> Iterator[None]: simulated_transaction_watermarks.state["default"] = -1 with in_test_hide_transaction_boundary(): yield @pytest.fixture -def emulate_transactions(django_capture_on_commit_callbacks): +def emulate_transactions( + django_capture_on_commit_callbacks: Callable[..., AbstractContextManager[list[object]]], +) -> Callable[..., AbstractContextManager[None]]: # This contraption helps in testing the usage of `transaction.on_commit` in # schedule_build_project_config. Normally tests involving transactions would # require us to use the transactional testcase (or # `pytest.mark.django_db(transaction=True)`), but that incurs a 2x slowdown # in test speed and we're trying to keep our testcases fast. @contextlib.contextmanager - def inner(assert_num_callbacks=1): + def inner(assert_num_callbacks: int = 1) -> Iterator[None]: with BurstTaskRunner() as burst: with django_capture_on_commit_callbacks(execute=True) as callbacks: yield @@ -76,7 +85,7 @@ def inner(assert_num_callbacks=1): @pytest.fixture -def redis_cache(): +def redis_cache() -> Iterator[RedisProjectConfigCache]: cache = RedisProjectConfigCache() with ( mock.patch( @@ -90,7 +99,7 @@ def redis_cache(): @pytest.fixture -def debounce_cache(): +def debounce_cache() -> Iterator[RedisProjectConfigDebounceCache]: cache = RedisProjectConfigDebounceCache() with ( mock.patch( @@ -109,7 +118,7 @@ def debounce_cache(): @pytest.fixture -def invalidation_debounce_cache(): +def invalidation_debounce_cache() -> Iterator[RedisProjectConfigDebounceCache]: debounce_cache = RedisProjectConfigDebounceCache() with ( mock.patch( @@ -130,45 +139,45 @@ def invalidation_debounce_cache(): @django_db_all def test_debounce( - default_projectkey, - default_organization, - debounce_cache, - django_cache, -): - tasks = [] - - def signal_send(self, task, args, kwargs): + default_projectkey: ProjectKey, + default_organization: Organization, + debounce_cache: RedisProjectConfigDebounceCache, + django_cache: object, +) -> None: + tasks: list[dict[str, object]] = [] + + def signal_send(self: object, task: object, args: object, kwargs: dict[str, object]) -> None: assert not args tasks.append(kwargs) with mock.patch("taskbroker_client.task.Task._signal_send", signal_send): - schedule_build_project_config(public_key=default_projectkey.public_key) - schedule_build_project_config(public_key=default_projectkey.public_key) + schedule_build_project_config(public_key=cast(str, default_projectkey.public_key)) + schedule_build_project_config(public_key=cast(str, default_projectkey.public_key)) assert len(tasks) == 1 - assert tasks[0]["public_key"] == default_projectkey.public_key + assert tasks[0]["public_key"] == cast(str, default_projectkey.public_key) @django_db_all def test_generate( - default_project, - default_organization, - default_projectkey, - redis_cache, - django_cache, -): - assert not redis_cache.get(default_projectkey.public_key) + default_project: Project, + default_organization: Organization, + default_projectkey: ProjectKey, + redis_cache: RedisProjectConfigCache, + django_cache: object, +) -> None: + assert not redis_cache.get(cast(str, default_projectkey.public_key)) - build_project_config(default_projectkey.public_key) + build_project_config(cast(str, default_projectkey.public_key)) - cfg = redis_cache.get(default_projectkey.public_key) + cfg = redis_cache.get(cast(str, default_projectkey.public_key)) assert cfg["organizationId"] == default_organization.id assert cfg["projectId"] == default_project.id assert cfg["publicKeys"] == [ { "isEnabled": True, - "publicKey": default_projectkey.public_key, + "publicKey": cast(str, default_projectkey.public_key), "numericId": default_projectkey.id, } ] @@ -176,15 +185,15 @@ def test_generate( @django_db_all def test_project_update_option( - default_projectkey, - default_project, - emulate_transactions, - redis_cache, - django_cache, -): + default_projectkey: ProjectKey, + default_project: Project, + emulate_transactions: Callable[..., AbstractContextManager[None]], + redis_cache: RedisProjectConfigCache, + django_cache: object, +) -> None: # Put something in the cache, otherwise triggers/the invalidation task won't compute # anything. - redis_cache.set_many({default_projectkey.public_key: {"dummy": "dummy"}}) + redis_cache.set_many({cast(str, default_projectkey.public_key): {"dummy": "dummy"}}) # XXX: there should only be one hook triggered, regardless of debouncing with emulate_transactions(assert_num_callbacks=2): @@ -192,7 +201,7 @@ def test_project_update_option( "sentry:relay_pii_config", '{"applications": {"$string": ["@creditcard:mask"]}}' ) - assert redis_cache.get(default_projectkey.public_key)["config"]["piiConfig"] == { + assert redis_cache.get(cast(str, default_projectkey.public_key))["config"]["piiConfig"] == { "applications": {"$string": ["@creditcard:mask"]} } @@ -213,29 +222,29 @@ def test_project_update_option( @django_db_all def test_project_delete_option( - default_projectkey, - default_project, - emulate_transactions, - redis_cache, - django_cache, -): + default_projectkey: ProjectKey, + default_project: Project, + emulate_transactions: Callable[..., AbstractContextManager[None]], + redis_cache: RedisProjectConfigCache, + django_cache: object, +) -> None: # Put something in the cache, otherwise triggers/the invalidation task won't compute # anything. - redis_cache.set_many({default_projectkey.public_key: {"dummy": "dummy"}}) + redis_cache.set_many({cast(str, default_projectkey.public_key): {"dummy": "dummy"}}) # XXX: there should only be one hook triggered, regardless of debouncing with emulate_transactions(assert_num_callbacks=1): default_project.delete_option("sentry:relay_pii_config") - assert redis_cache.get(default_projectkey)["config"]["piiConfig"] == {} + assert redis_cache.get(cast(str, default_projectkey.public_key))["config"]["piiConfig"] == {} @django_db_all def test_project_get_option_does_not_reload( - default_project, - emulate_transactions, - django_cache, -): + default_project: Project, + emulate_transactions: Callable[..., AbstractContextManager[None]], + django_cache: object, +) -> None: ProjectOption.objects._option_cache.clear() with ( emulate_transactions(assert_num_callbacks=0), @@ -251,10 +260,10 @@ def test_project_get_option_does_not_reload( @django_db_all def test_compute_projectkey_config_sees_options_saved_by_another_process( - default_project, - default_projectkey, - django_cache, -): + default_project: Project, + default_projectkey: ProjectKey, + django_cache: object, +) -> None: organization = default_project.organization OrganizationOption.objects.set_value(organization, "sentry:sensitive_fields", ["org_old"]) ProjectOption.objects.set_value(default_project, "sentry:sensitive_fields", ["project_old"]) @@ -289,11 +298,11 @@ def test_compute_projectkey_config_sees_options_saved_by_another_process( @django_db_all def test_invalidation_project_deleted( - default_project, - emulate_transactions, - redis_cache, - django_cache, -): + default_project: Project, + emulate_transactions: Callable[..., AbstractContextManager[None]], + redis_cache: RedisProjectConfigCache, + django_cache: object, +) -> None: # Ensure we have a ProjectKey project_key = next(_cache_keys_for_project(default_project)) assert project_key @@ -318,11 +327,11 @@ def test_invalidation_project_deleted( @django_db_all def test_projectkeys( - default_project, - emulate_transactions, - redis_cache, - django_cache, -): + default_project: Project, + emulate_transactions: Callable[..., AbstractContextManager[None]], + redis_cache: RedisProjectConfigCache, + django_cache: object, +) -> None: # When a projectkey is deleted the invalidation task should be triggered and the project # should be cached as disabled. @@ -338,37 +347,37 @@ def test_projectkeys( pk.save() for key in deleted_pks: - assert redis_cache.get(key.public_key)["disabled"] + assert redis_cache.get(cast(str, key.public_key))["disabled"] - (pk_json,) = redis_cache.get(pk.public_key)["publicKeys"] + (pk_json,) = redis_cache.get(cast(str, pk.public_key))["publicKeys"] assert pk_json["publicKey"] == pk.public_key with emulate_transactions(assert_num_callbacks=2): pk.status = ProjectKeyStatus.INACTIVE pk.save() - assert redis_cache.get(pk.public_key)["disabled"] + assert redis_cache.get(cast(str, pk.public_key))["disabled"] with emulate_transactions(): pk.delete() - assert redis_cache.get(pk.public_key)["disabled"] + assert redis_cache.get(cast(str, pk.public_key))["disabled"] for key in ProjectKey.objects.filter(project_id=default_project.id): - assert not redis_cache.get(key.public_key) + assert not redis_cache.get(cast(str, key.public_key)) @django_db_all(transaction=True) def test_db_transaction( - default_project, - default_projectkey, - redis_cache, - task_runner, - django_cache, -): + default_project: Project, + default_projectkey: ProjectKey, + redis_cache: RedisProjectConfigCache, + task_runner: Callable[[], AbstractContextManager[None]], + django_cache: object, +) -> None: # Put something in the cache, otherwise triggers/the invalidation task won't compute # anything. - redis_cache.set_many({default_projectkey.public_key: {"dummy": "dummy"}}) + redis_cache.set_many({cast(str, default_projectkey.public_key): {"dummy": "dummy"}}) with task_runner(), transaction.atomic(router.db_for_write(ProjectOption)): default_project.update_option( @@ -377,9 +386,9 @@ def test_db_transaction( # Assert that cache entry hasn't been created yet, only after the # transaction has committed. - assert redis_cache.get(default_projectkey.public_key) == {"dummy": "dummy"} + assert redis_cache.get(cast(str, default_projectkey.public_key)) == {"dummy": "dummy"} - assert redis_cache.get(default_projectkey.public_key)["config"]["piiConfig"] == { + assert redis_cache.get(cast(str, default_projectkey.public_key))["config"]["piiConfig"] == { "applications": {"$string": ["@creditcard:mask"]} } @@ -395,7 +404,7 @@ def test_db_transaction( pass # Assert that database rollback is honored - assert redis_cache.get(default_projectkey.public_key)["config"]["piiConfig"] == { + assert redis_cache.get(cast(str, default_projectkey.public_key))["config"]["piiConfig"] == { "applications": {"$string": ["@creditcard:mask"]} } @@ -404,14 +413,16 @@ def test_db_transaction( class TestInvalidationTask: def test_debounce( self, - default_project, - default_organization, - invalidation_debounce_cache, - django_cache, - ): + default_project: Project, + default_organization: Organization, + invalidation_debounce_cache: RedisProjectConfigDebounceCache, + django_cache: object, + ) -> None: tasks = [] - def apply_async(args=None, kwargs=None, countdown=None): + def apply_async( + args: object = None, kwargs: object = None, countdown: object = None + ) -> None: assert not args tasks.append(kwargs) @@ -451,16 +462,16 @@ def apply_async(args=None, kwargs=None, countdown=None): def test_invalidate( self, - default_project, - default_organization, - default_projectkey, - task_runner, - redis_cache, - django_cache, - ): + default_project: Project, + default_organization: Organization, + default_projectkey: ProjectKey, + task_runner: Callable[[], AbstractContextManager[None]], + redis_cache: RedisProjectConfigCache, + django_cache: object, + ) -> None: cfg = {"dummy-key": "val"} - redis_cache.set_many({default_projectkey.public_key: cfg}) - assert redis_cache.get(default_projectkey.public_key) == cfg + redis_cache.set_many({cast(str, default_projectkey.public_key): cfg}) + assert redis_cache.get(cast(str, default_projectkey.public_key)) == cfg with task_runner(): schedule_invalidate_project_config(project_id=default_project.id, trigger="test") @@ -473,17 +484,17 @@ def test_invalidate( def test_invalidate_org( self, - default_project, - default_organization, - default_projectkey, - redis_cache, - task_runner, - django_cache, - ): + default_project: Project, + default_organization: Organization, + default_projectkey: ProjectKey, + redis_cache: RedisProjectConfigCache, + task_runner: Callable[[], AbstractContextManager[None]], + django_cache: object, + ) -> None: # Currently for org-wide we delete the config instead of computing it. cfg = {"dummy-key": "val"} - redis_cache.set_many({default_projectkey.public_key: cfg}) - assert redis_cache.get(default_projectkey.public_key) == cfg + redis_cache.set_many({cast(str, default_projectkey.public_key): cfg}) + assert redis_cache.get(cast(str, default_projectkey.public_key)) == cfg with task_runner(): schedule_invalidate_project_config( @@ -497,23 +508,26 @@ def test_invalidate_org( def test_invalidate_org_writes_each_config_as_computed( self, - default_organization, - default_project, - default_projectkey, - factories, - redis_cache, - task_runner, - django_cache, - ): + default_organization: Organization, + default_project: Project, + default_projectkey: ProjectKey, + factories: Factories, + redis_cache: RedisProjectConfigCache, + task_runner: Callable[[], AbstractContextManager[None]], + django_cache: object, + ) -> None: other_project = factories.create_project(organization=default_organization) other_projectkey = ProjectKey.objects.get(project=other_project) seed = {"dummy-key": "val"} - public_keys = [default_projectkey.public_key, other_projectkey.public_key] + public_keys = [ + cast(str, default_projectkey.public_key), + cast(str, other_projectkey.public_key), + ] redis_cache.set_many({public_key: seed for public_key in public_keys}) - fresh_before_each_compute = [] + fresh_before_each_compute: list[int] = [] - def record_then_compute(key): + def record_then_compute(key: ProjectKey) -> dict[str, Any]: fresh_before_each_compute.append( sum(redis_cache.get(public_key) != seed for public_key in public_keys) ) @@ -536,10 +550,10 @@ def record_then_compute(key): @mock.patch("django.db.transaction.on_commit", wraps=transaction.on_commit) def test_project_config_invalidations_after_commit( self, - oncommit, - schedule_inner, - default_project, - ): + oncommit: mock.MagicMock, + schedule_inner: mock.MagicMock, + default_project: Project, + ) -> None: schedule_invalidate_project_config( trigger="test", project_id=default_project.id, countdown=2 ) @@ -558,18 +572,18 @@ def test_project_config_invalidations_after_commit( @mock.patch("sentry.tasks.relay._schedule_invalidate_project_config") def test_project_config_invalidations_delayed( self, - schedule_inner, - default_project, - ): + schedule_inner: mock.MagicMock, + default_project: Project, + ) -> None: with transaction.atomic(router.db_for_write(ProjectOption)): schedule_invalidate_project_config( - trigger="inside-transaction", project_id=default_project, countdown=2 + trigger="inside-transaction", project_id=default_project.id, countdown=2 ) assert schedule_inner.call_count == 0 assert schedule_inner.call_count == 1 schedule_invalidate_project_config( - trigger="outside-transaction", project_id=default_project, countdown=2 + trigger="outside-transaction", project_id=default_project.id, countdown=2 ) assert schedule_inner.call_count == 2 @@ -577,20 +591,21 @@ def test_project_config_invalidations_delayed( @django_db_all(transaction=True) @thread_leak_allowlist(reason="relay integration tests", issue=97040) def test_invalidate_hierarchy( - default_project, - default_projectkey, - redis_cache, - debounce_cache, - invalidation_debounce_cache, - django_cache, -): + default_project: Project, + default_projectkey: ProjectKey, + redis_cache: RedisProjectConfigCache, + debounce_cache: RedisProjectConfigDebounceCache, + invalidation_debounce_cache: RedisProjectConfigDebounceCache, + django_cache: object, +) -> None: # Put something in the cache, otherwise the invalidation task won't compute anything. - redis_cache.set_many({default_projectkey.public_key: {"dummy": "dummy"}}) + redis_cache.set_many({cast(str, default_projectkey.public_key): {"dummy": "dummy"}}) orig_apply_async = invalidate_project_config.apply_async calls = [] - def proxy(*args, **kwargs): + # justification: forwarded verbatim to the task's apply_async. + def proxy(*args: Any, **kwargs: Any) -> None: calls.append((args, kwargs)) orig_apply_async(*args, **kwargs) @@ -605,12 +620,12 @@ def proxy(*args, **kwargs): run(max_jobs=10) assert len(calls) == 1 - cache = redis_cache.get(default_projectkey) + cache = redis_cache.get(cast(str, default_projectkey.public_key)) assert cache["disabled"] is False @django_db_all(transaction=True) -def test_schedule_invalidate_project_config_without_autocommit(default_project): +def test_schedule_invalidate_project_config_without_autocommit(default_project: Project) -> None: """ Regression test: schedule_invalidate_project_config must not raise TransactionManagementError when called without autocommit and outside an diff --git a/tests/sentry/tasks/test_reprocessing2.py b/tests/sentry/tasks/test_reprocessing2.py index da6896b372ba..458808d096c0 100644 --- a/tests/sentry/tasks/test_reprocessing2.py +++ b/tests/sentry/tasks/test_reprocessing2.py @@ -1,9 +1,11 @@ from __future__ import annotations import uuid +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager from time import time from types import SimpleNamespace -from typing import Any +from typing import Any, Literal, cast from unittest import mock from unittest.mock import patch @@ -21,6 +23,7 @@ from sentry.models.group import Group from sentry.models.groupassignee import GroupAssignee from sentry.models.groupredirect import GroupRedirect +from sentry.models.project import Project from sentry.models.userreport import UserReport from sentry.reprocessing2 import is_group_finished, start_group_reprocessing from sentry.services import eventstore @@ -44,11 +47,14 @@ from sentry.testutils.pytest.fixtures import django_db_all from sentry.testutils.skips import requires_snuba from sentry.types.activity import ActivityType +from sentry.users.models.user import User pytestmark = [requires_snuba] +type EventPreprocessor = Callable[[dict[str, Any]], dict[str, Any]] -def _create_user_report(evt): + +def _create_user_report(evt: Event) -> None: UserReport.objects.create( project_id=evt.project_id, event_id=evt.event_id, @@ -57,13 +63,13 @@ def _create_user_report(evt): @pytest.fixture(autouse=True) -def reprocessing_feature(): +def reprocessing_feature() -> Iterator[None]: with override_settings(SENTRY_REPROCESSING_PAGE_SIZE=1): yield @pytest.fixture(params=[False, True], ids=["redis", "inline"]) -def inline_payload_options(request): +def inline_payload_options(request: pytest.FixtureRequest) -> Iterator[None]: with override_options( { "store.enable-inline-payloads": float(request.param), @@ -74,8 +80,10 @@ def inline_payload_options(request): @pytest.fixture -def process_and_save(default_project, task_runner): - def inner(data, seconds_ago=1): +def process_and_save( + default_project: Project, task_runner: Callable[[], AbstractContextManager[None]] +) -> Callable[..., str]: + def inner(data: dict[str, Any], seconds_ago: int = 1) -> str: # Set platform to native so all parts of reprocessing fire, symbolication will # not happen without this set to certain values data.setdefault("platform", "native") @@ -86,8 +94,8 @@ def inner(data, seconds_ago=1): ) mgr = EventManager(data=data, project=default_project) mgr.normalize() - data = mgr.get_data() - event_id = data["event_id"] + data = cast("dict[str, Any]", mgr.get_data()) + event_id: str = data["event_id"] cache_key = event_processing_store.store(dict(data)) with task_runner(): @@ -101,11 +109,11 @@ def inner(data, seconds_ago=1): @pytest.fixture -def register_event_preprocessor(): +def register_event_preprocessor() -> Iterator[Callable[[EventPreprocessor], None]]: with mock.patch("sentry.tasks.store.get_event_preprocessors") as m: m.return_value = [] - def inner(f): + def inner(f: EventPreprocessor) -> None: m.return_value = [f] yield inner @@ -115,21 +123,22 @@ def inner(f): @pytest.mark.snuba @pytest.mark.parametrize("change_groups", (True, False), ids=("new_group", "same_group")) def test_basic( - task_runner, - default_project, - change_groups, - reset_snuba, - process_and_save, - register_event_preprocessor, - django_cache, - inline_payload_options, -): + task_runner: Callable[[], AbstractContextManager[None]], + default_project: Project, + change_groups: bool, + reset_snuba: None, + process_and_save: Callable[..., str], + register_event_preprocessor: Callable[[EventPreprocessor], None], + django_cache: object, + inline_payload_options: None, +) -> None: from sentry import eventstream tombstone_calls = [] old_tombstone_fn = eventstream.backend.tombstone_events_unsafe - def tombstone_called(*args, **kwargs): + # justification: passthrough wrapper around the tombstone backend method. + def tombstone_called(*args: Any, **kwargs: Any) -> None: tombstone_calls.append((args, kwargs)) old_tombstone_fn(*args, **kwargs) @@ -137,7 +146,7 @@ def tombstone_called(*args, **kwargs): abs_count = 0 @register_event_preprocessor - def event_preprocessor(data): + def event_preprocessor(data: dict[str, Any]) -> dict[str, Any]: nonlocal abs_count tags = data.setdefault("tags", []) @@ -226,13 +235,13 @@ def get_event_by_processing_counter(n: str) -> list[Event]: @django_db_all @pytest.mark.snuba def test_concurrent_events_go_into_new_group( - default_project, - reset_snuba, - register_event_preprocessor, - process_and_save, - default_user, - django_cache, -): + default_project: Project, + reset_snuba: None, + register_event_preprocessor: Callable[[EventPreprocessor], None], + process_and_save: Callable[..., str], + default_user: User, + django_cache: object, +) -> None: """ Assert that both unmodified and concurrently inserted events go into "the new group", i.e. the successor of the reprocessed (old) group that @@ -240,7 +249,7 @@ def test_concurrent_events_go_into_new_group( """ @register_event_preprocessor - def event_preprocessor(data): + def event_preprocessor(data: dict[str, Any]) -> dict[str, Any]: extra = data.setdefault("extra", {}) extra.setdefault("processing_counter", 0) extra["processing_counter"] += 1 @@ -301,15 +310,15 @@ def event_preprocessor(data): @pytest.mark.parametrize("remaining_events", ["delete", "keep"]) @pytest.mark.parametrize("max_events", [2, None]) def test_max_events( - default_project, - reset_snuba, - register_event_preprocessor, - process_and_save, - remaining_events, - max_events, -): + default_project: Project, + reset_snuba: None, + register_event_preprocessor: Callable[[EventPreprocessor], None], + process_and_save: Callable[..., str], + remaining_events: Literal["delete", "keep"], + max_events: int | None, +) -> None: @register_event_preprocessor - def event_preprocessor(data): + def event_preprocessor(data: dict[str, Any]) -> dict[str, Any]: extra = data.setdefault("extra", {}) extra.setdefault("processing_counter", 0) extra["processing_counter"] += 1 @@ -326,7 +335,7 @@ def event_preprocessor(data): old_events[event_id] = old_event for evt in old_events.values(): - _create_user_report(evt) + _create_user_report(cast("Event", evt)) (group_id,) = {e.group_id for e in old_events.values()} assert group_id is not None @@ -381,13 +390,13 @@ def event_preprocessor(data): @django_db_all @pytest.mark.snuba def test_attachments_and_userfeedback( - default_project, - reset_snuba, - register_event_preprocessor, - process_and_save, -): + default_project: Project, + reset_snuba: None, + register_event_preprocessor: Callable[[EventPreprocessor], None], + process_and_save: Callable[..., str], +) -> None: @register_event_preprocessor - def event_preprocessor(data): + def event_preprocessor(data: dict[str, Any]) -> dict[str, Any]: extra = data.setdefault("extra", {}) extra.setdefault("processing_counter", 0) extra["processing_counter"] += 1 @@ -427,7 +436,7 @@ def event_preprocessor(data): blob_path=":hello world", ) - _create_user_report(evt) + _create_user_report(cast("Event", evt)) with BurstTaskRunner() as burst: assert event.group_id @@ -461,13 +470,13 @@ def event_preprocessor(data): @pytest.mark.parametrize("remaining_events", ["keep", "delete"]) @mock.patch("sentry.reprocessing2.logger") def test_nodestore_missing( - mock_logger, - default_project, - reset_snuba, - process_and_save, - remaining_events, - django_cache, -): + mock_logger: mock.MagicMock, + default_project: Project, + reset_snuba: None, + process_and_save: Callable[..., str], + remaining_events: Literal["keep", "delete"], + django_cache: object, +) -> None: event_id = process_and_save({"message": "hello world", "platform": "python"}) event = eventstore.backend.get_event_by_id(default_project.id, event_id) assert event is not None @@ -508,18 +517,18 @@ def test_nodestore_missing( @django_db_all @pytest.mark.snuba def test_apply_new_fingerprinting_rules( - default_project, - reset_snuba, - register_event_preprocessor, - process_and_save, -): + default_project: Project, + reset_snuba: None, + register_event_preprocessor: Callable[[EventPreprocessor], None], + process_and_save: Callable[..., str], +) -> None: """ Assert that after changing fingerprinting rules, the new fingerprinting config is respected by reprocessing. """ @register_event_preprocessor - def event_preprocessor(data): + def event_preprocessor(data: dict[str, Any]) -> dict[str, Any]: extra = data.setdefault("extra", {}) extra.setdefault("processing_counter", 0) extra["processing_counter"] += 1 @@ -582,18 +591,18 @@ def event_preprocessor(data): @django_db_all @pytest.mark.snuba def test_apply_new_stack_trace_rules( - default_project, - reset_snuba, - register_event_preprocessor, - process_and_save, -): + default_project: Project, + reset_snuba: None, + register_event_preprocessor: Callable[[EventPreprocessor], None], + process_and_save: Callable[..., str], +) -> None: """ Assert that after changing stacktrace rules, the new grouping config is respected by reprocessing. """ @register_event_preprocessor - def event_preprocessor(data): + def event_preprocessor(data: dict[str, Any]) -> dict[str, Any]: extra = data.setdefault("extra", {}) extra.setdefault("processing_counter", 0) extra["processing_counter"] += 1 @@ -683,7 +692,7 @@ def event_preprocessor(data): @django_db_all -def test_finish_reprocessing(default_project) -> None: +def test_finish_reprocessing(default_project: Project) -> None: # Pretend that the old group has more than one activity still connected: old_group = Group.objects.create(project=default_project) new_group = Group.objects.create(project=default_project) @@ -715,7 +724,7 @@ def test_finish_reprocessing(default_project) -> None: @django_db_all -def test_reprocessing_an_ongoing_reprocessing(default_project) -> None: +def test_reprocessing_an_ongoing_reprocessing(default_project: Project) -> None: # Pretend that the old group has more than one activity still connected: old_group = Group.objects.create(project=default_project, data={}) @@ -732,7 +741,7 @@ def test_reprocessing_an_ongoing_reprocessing(default_project) -> None: @django_db_all -def test_reprocessing_allowed_when_previous_run_is_stale(default_project) -> None: +def test_reprocessing_allowed_when_previous_run_is_stale(default_project: Project) -> None: old_group = Group.objects.create(project=default_project, data={}) new_group_id = start_group_reprocessing(default_project.id, old_group.id, "delete") assert "_reprocessing_old_group_id" in Group.objects.get(id=new_group_id).data @@ -746,7 +755,7 @@ def test_reprocessing_allowed_when_previous_run_is_stale(default_project) -> Non @django_db_all -def test_reprocessing_blocked_when_previous_run_is_recent(default_project) -> None: +def test_reprocessing_blocked_when_previous_run_is_recent(default_project: Project) -> None: old_group = Group.objects.create(project=default_project, data={}) new_group_id = start_group_reprocessing(default_project.id, old_group.id, "delete") assert "_reprocessing_old_group_id" in Group.objects.get(id=new_group_id).data @@ -763,7 +772,9 @@ def test_reprocessing_blocked_when_previous_run_is_recent(default_project) -> No @django_db_all @patch("sentry.tasks.reprocessing2.current_task") -def test_selfchain_skips_when_already_spawned(mock_current_task, default_project) -> None: +def test_selfchain_skips_when_already_spawned( + mock_current_task: mock.MagicMock, default_project: Project +) -> None: # A prior delivery of this activation already spawned its continuation; a broker re-pend # must short-circuit before starting reprocessing or forking the chain. mock_current_task.return_value = SimpleNamespace(id="reprocess-act-skip") @@ -784,7 +795,10 @@ def test_selfchain_skips_when_already_spawned(mock_current_task, default_project @pytest.mark.snuba @patch("sentry.tasks.reprocessing2.current_task") def test_selfchain_marks_and_dedupes_across_deliveries( - mock_current_task, default_project, reset_snuba, process_and_save + mock_current_task: mock.MagicMock, + default_project: Project, + reset_snuba: None, + process_and_save: Callable[..., str], ) -> None: # First delivery processes a page and spawns the continuation once. A re-pend must use the # mid-chain payload (new_group_id/query_state/start_time) — not another start hop — because @@ -817,7 +831,11 @@ def test_selfchain_marks_and_dedupes_across_deliveries( @patch("sentry.tasks.reprocessing2.mark_spawned") @patch("sentry.tasks.reprocessing2.current_task", return_value=None) def test_selfchain_noop_without_activation( - mock_current_task, mock_mark, default_project, reset_snuba, process_and_save + mock_current_task: mock.MagicMock, + mock_mark: mock.MagicMock, + default_project: Project, + reset_snuba: None, + process_and_save: Callable[..., str], ) -> None: # Synchronous/eager path (e.g. direct call outside a worker) has no current activation, so the # guard is inert and existing behavior is preserved. @@ -839,7 +857,10 @@ def test_selfchain_noop_without_activation( @pytest.mark.snuba @patch("sentry.tasks.reprocessing2.current_task") def test_selfchain_does_not_mark_on_terminal_hop( - mock_current_task, default_project, reset_snuba, process_and_save + mock_current_task: mock.MagicMock, + default_project: Project, + reset_snuba: None, + process_and_save: Callable[..., str], ) -> None: # When there are no events left, reprocess_group flushes remaining work and returns without # self-chaining. It must not mark the activation as spawned. diff --git a/tests/sentry/tasks/test_statistical_detectors.py b/tests/sentry/tasks/test_statistical_detectors.py index cac18b73d0d7..6733906973b3 100644 --- a/tests/sentry/tasks/test_statistical_detectors.py +++ b/tests/sentry/tasks/test_statistical_detectors.py @@ -1,7 +1,9 @@ import itertools import uuid +from collections.abc import Generator from copy import deepcopy from datetime import UTC, datetime, timedelta +from typing import Any, cast from unittest import mock import orjson @@ -11,6 +13,7 @@ from arroyo.backends.local.storages.memory import MemoryMessageStorage from arroyo.types import Partition from arroyo.types import Topic as ArroyoTopic +from django.core.cache.backends.base import BaseCache from django.db.models import F from django.test import override_settings from urllib3.response import HTTPResponse @@ -18,6 +21,7 @@ from sentry.conf.types.kafka_definition import Topic from sentry.issues.endpoints.project_performance_issue_settings import InternalProjectOptions from sentry.issues.grouptype import ( + GroupType, ProfileFunctionRegressionType, ) from sentry.issues.issue_occurrence import IssueOccurrence @@ -26,6 +30,7 @@ from sentry.issues.status_change_message import StatusChangeMessage from sentry.models.group import GroupStatus from sentry.models.options.project_option import ProjectOption +from sentry.models.organization import Organization from sentry.models.project import Project from sentry.models.statistical_detectors import ( RegressionGroup, @@ -60,6 +65,7 @@ from sentry.testutils.helpers.datetime import before_now, freeze_time from sentry.testutils.pytest.fixtures import django_db_all from sentry.types.group import GroupSubStatus +from sentry.users.models.user import User from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.kafka_config import get_topic_definition from sentry.viewer_context import ( @@ -72,22 +78,22 @@ @pytest.fixture -def timestamp(): +def timestamp() -> datetime: return datetime(2023, 8, 1, 12, 7, 42, 521000, tzinfo=UTC) @pytest.fixture -def owner(): +def owner() -> User: return Factories.create_user() @pytest.fixture -def organization(owner): +def organization(owner: User) -> Organization: return Factories.create_organization(owner=owner) @pytest.fixture -def project(organization): +def project(organization: Organization) -> Project: return Factories.create_project(organization=organization) @@ -95,10 +101,10 @@ def project(organization): @mock.patch("sentry.tasks.statistical_detectors.PROJECTS_PER_BATCH", 5) @django_db_all def test_run_detection_options_multiple_batches( - detect_function_trends, - organization, - timestamp, -): + detect_function_trends: mock.MagicMock, + organization: Organization, + timestamp: datetime, +) -> None: projects = [] flags = Project.flags.has_profiles @@ -140,12 +146,12 @@ def test_run_detection_options_multiple_batches( @mock.patch("sentry.tasks.statistical_detectors.query_functions") @django_db_all def test_detect_function_trends_options( - query_functions, - task_enabled, - option_enabled, - timestamp, - project, -): + query_functions: mock.MagicMock, + task_enabled: bool, + option_enabled: bool, + timestamp: datetime, + project: Project, +) -> None: ProjectOption.objects.set_value( project=project, key="sentry:performance_issue_settings", @@ -164,10 +170,10 @@ def test_detect_function_trends_options( @mock.patch("sentry.tasks.statistical_detectors.query_functions") @django_db_all def test_detect_function_trends_options_with_str( - query_functions, - timestamp, - project, -): + query_functions: mock.MagicMock, + timestamp: datetime, + project: Project, +) -> None: ProjectOption.objects.set_value( project=project, key="sentry:performance_issue_settings", @@ -185,7 +191,9 @@ def test_detect_function_trends_options_with_str( @mock.patch("sentry.snuba.functions.query") @django_db_all -def test_detect_function_trends_query_timerange(functions_query, timestamp, project) -> None: +def test_detect_function_trends_query_timerange( + functions_query: mock.MagicMock, timestamp: datetime, project: Project +) -> None: options = { "statistical_detectors.enable": True, } @@ -215,7 +223,12 @@ def test_detect_function_trends_query_timerange(functions_query, timestamp, proj pytest.param(3, 4, id="three per project"), ], ) -def test_limit_regressions_by_project(detector_cls, ratelimit, timestamp, expected_idx) -> None: +def test_limit_regressions_by_project( + detector_cls: type[FunctionRegressionDetector], + ratelimit: int, + timestamp: datetime, + expected_idx: int, +) -> None: payloads = { (project_id, group): DetectorPayload( project_id=project_id, @@ -229,7 +242,7 @@ def test_limit_regressions_by_project(detector_cls, ratelimit, timestamp, expect for group in range(1, project_id + 1) } - def trends(): + def trends() -> Generator[TrendBundle]: yield TrendBundle(TrendType.Skipped, 0, payloads[(1, 1)]) yield TrendBundle(TrendType.Improved, 0, payloads[(2, 1)]) yield TrendBundle(TrendType.Regressed, 0, payloads[(2, 2)]) @@ -297,12 +310,12 @@ def trends(): ) @django_db_all def test_get_regression_versions( - detector_cls, - regression_groups, - expected_versions, - project, - timestamp, -): + detector_cls: type[FunctionRegressionDetector], + regression_groups: list[tuple[int, bool, str]], + expected_versions: list[int | None], + project: Project, + timestamp: datetime, +) -> None: with freeze_time(timestamp - MISSING_ISSUE_GROUP_GRACE_PERIOD): RegressionGroup.objects.bulk_create( RegressionGroup( @@ -336,7 +349,7 @@ def test_get_regression_versions( } ] - def mock_regressions(): + def mock_regressions() -> Generator[BreakpointData]: yield from breakpoints with freeze_time(timestamp): @@ -382,8 +395,12 @@ def mock_regressions(): ) @django_db_all def test_missing_issue_group_grace_period( - age, expected_versions, expected_groups, project, timestamp -): + age: timedelta, + expected_versions: list[int], + expected_groups: list[tuple[int, bool]], + project: Project, + timestamp: datetime, +) -> None: detector_cls = FunctionRegressionDetector fingerprint = generate_fingerprint(detector_cls.regression_type, "123") previous_breakpoint = timestamp - timedelta(days=2) @@ -410,10 +427,10 @@ def test_missing_issue_group_grace_period( "trend_percentage": 1.23, "absolute_percentage_change": 1.23, "trend_difference": 1.23, - "breakpoint": (timestamp - timedelta(hours=12)).timestamp(), + "breakpoint": cast(int, (timestamp - timedelta(hours=12)).timestamp()), } - def get_regressions(): + def get_regressions() -> Generator[BreakpointData]: yield regression with freeze_time(timestamp): @@ -471,16 +488,16 @@ def get_regressions(): ) @django_db_all def test_get_regression_versions_active( - detector_cls, - object_name, - issue_type, - status, - substatus, - should_emit, - project, - timestamp, - django_cache, # the environment can persist in the cache otherwise -): + detector_cls: type[FunctionRegressionDetector], + object_name: str, + issue_type: type[GroupType], + status: int, + substatus: int | None, + should_emit: bool, + project: Project, + timestamp: datetime, + django_cache: BaseCache, # the environment can persist in the cache otherwise +) -> None: fingerprint = generate_fingerprint(detector_cls.regression_type, object_name) RegressionGroup.objects.create( @@ -545,7 +562,7 @@ def test_get_regression_versions_active( } ] - def mock_regressions(): + def mock_regressions() -> Generator[BreakpointData]: yield from breakpoints regressions = list(detector_cls.get_regression_versions(mock_regressions())) @@ -564,13 +581,13 @@ def mock_regressions(): @mock.patch("sentry.tasks.statistical_detectors.FunctionChangePointQueue") @django_db_all def test_detect_function_trends( - change_point_queue, - query_functions, - timestamp, - project, - count, - should_emit, -): + change_point_queue: mock.MagicMock, + query_functions: mock.MagicMock, + timestamp: datetime, + project: Project, + count: int, + should_emit: bool, +) -> None: n = 50 timestamps = [timestamp - timedelta(hours=n - i) for i in range(n)] @@ -604,12 +621,12 @@ def test_detect_function_trends( @mock.patch("sentry.statistical_detectors.detector.produce_occurrence_to_kafka") @django_db_all def test_detect_function_trends_auto_resolution( - produce_occurrence_to_kafka, - change_point_queue, - functions_query, - timestamp, - project, -): + produce_occurrence_to_kafka: mock.MagicMock, + change_point_queue: mock.MagicMock, + functions_query: mock.MagicMock, + timestamp: datetime, + project: Project, +) -> None: n = 150 timestamps = [timestamp - timedelta(hours=n - i) for i in range(n)] @@ -663,13 +680,13 @@ def test_detect_function_trends_auto_resolution( @mock.patch("sentry.tasks.statistical_detectors.FunctionChangePointQueue") @django_db_all def test_detect_function_trends_ratelimit( - change_point_queue, - query_functions, - ratelimit, - expected_calls, - timestamp, - project, -): + change_point_queue: mock.MagicMock, + query_functions: mock.MagicMock, + ratelimit: int, + expected_calls: int, + timestamp: datetime, + project: Project, +) -> None: n = 50 timestamps = [timestamp - timedelta(hours=n - i) for i in range(n)] @@ -728,7 +745,7 @@ def isolated_function_change_point_queue() -> FunctionChangePointQueue: return queue -def test_function_change_point_queue_claims_and_acknowledges_due_candidates(): +def test_function_change_point_queue_claims_and_acknowledges_due_candidates() -> None: queue = isolated_function_change_point_queue() now = datetime.now(UTC) @@ -758,7 +775,7 @@ def test_function_change_point_queue_claims_and_acknowledges_due_candidates(): queue.client.delete(queue.key_for(day)) -def test_function_change_point_queue_recovers_expired_leases(): +def test_function_change_point_queue_recovers_expired_leases() -> None: queue = isolated_function_change_point_queue() now = datetime.now(UTC) ready_at = now - timedelta(days=1) @@ -775,7 +792,7 @@ def test_function_change_point_queue_recovers_expired_leases(): queue.client.delete(queue.key_for(ready_at.date())) -def test_function_change_point_queue_competing_claimers(): +def test_function_change_point_queue_competing_claimers() -> None: queue = isolated_function_change_point_queue() other_queue = FunctionChangePointQueue(client=queue.client) other_queue.ready_key = queue.ready_key @@ -792,7 +809,7 @@ def test_function_change_point_queue_competing_claimers(): queue.client.delete(queue.key_for(ready_at.date())) -def test_function_change_point_queue_key_has_fixed_expiration(): +def test_function_change_point_queue_key_has_fixed_expiration() -> None: queue = isolated_function_change_point_queue() now = datetime.now(UTC) @@ -811,7 +828,7 @@ def test_function_change_point_queue_key_has_fixed_expiration(): queue.client.delete(ready_key) -def test_function_change_point_queue_claims_bounded_batches(): +def test_function_change_point_queue_claims_bounded_batches() -> None: queue = isolated_function_change_point_queue() now = datetime.now(UTC) @@ -839,7 +856,9 @@ def test_function_change_point_queue_claims_bounded_batches(): @mock.patch("sentry.tasks.statistical_detectors.process_function_change_points") @mock.patch("sentry.tasks.statistical_detectors.FunctionChangePointQueue") -def test_scheduled_function_change_points_pauses_when_disabled(queue_cls, process, timestamp): +def test_scheduled_function_change_points_pauses_when_disabled( + queue_cls: mock.MagicMock, process: mock.MagicMock, timestamp: datetime +) -> None: due = [(1, "123", timestamp)] queue_cls.return_value.claim_due.return_value = due @@ -859,7 +878,9 @@ def test_scheduled_function_change_points_pauses_when_disabled(queue_cls, proces @mock.patch("sentry.tasks.statistical_detectors.process_function_change_points") @mock.patch("sentry.tasks.statistical_detectors.FunctionChangePointQueue") -def test_scheduled_function_change_points_dispatches_bounded_batches(queue_cls, process, timestamp): +def test_scheduled_function_change_points_dispatches_bounded_batches( + queue_cls: mock.MagicMock, process: mock.MagicMock, timestamp: datetime +) -> None: due = [(1, str(function), timestamp) for function in range(25)] queue_cls.return_value.claim_due.return_value = due @@ -891,8 +912,13 @@ def test_scheduled_function_change_points_dispatches_bounded_batches(queue_cls, @mock.patch("sentry.tasks.statistical_detectors.process_function_change_points") @mock.patch("sentry.tasks.statistical_detectors.FunctionChangePointQueue") def test_scheduled_function_change_points_continues_full_batches( - queue_cls, process, continuation, timestamp, candidate_count, expected_continuations -): + queue_cls: mock.MagicMock, + process: mock.MagicMock, + continuation: mock.MagicMock, + timestamp: datetime, + candidate_count: int, + expected_continuations: int, +) -> None: queue_cls.return_value.claim_due.return_value = [ (1, str(function), timestamp) for function in range(candidate_count) ] @@ -914,8 +940,8 @@ def test_scheduled_function_change_points_continues_full_batches( ) @mock.patch("sentry.tasks.statistical_detectors.process_function_change_points") def test_scheduled_function_change_points_recovers_dispatch_failure( - process, dispatch_results, expected_remaining -): + process: mock.MagicMock, dispatch_results: list[Exception | None], expected_remaining: int +) -> None: queue = isolated_function_change_point_queue() now = datetime.now(UTC) ready_at = now - timedelta(hours=1) @@ -942,7 +968,9 @@ def test_scheduled_function_change_points_recovers_dispatch_failure( @mock.patch("sentry.tasks.statistical_detectors.process_function_change_points") -def test_scheduled_function_change_points_recovers_acknowledgement_failure(process): +def test_scheduled_function_change_points_recovers_acknowledgement_failure( + process: mock.MagicMock, +) -> None: queue = isolated_function_change_point_queue() now = datetime.now(UTC) ready_at = now - timedelta(hours=1) @@ -970,12 +998,14 @@ def test_scheduled_function_change_points_recovers_acknowledgement_failure(proce queue.client.delete(queue.key_for(ready_at.date())) -def test_process_function_change_points_waits_for_delivery(): +def test_process_function_change_points_waits_for_delivery() -> None: assert process_function_change_points.wait_for_delivery is True @mock.patch("sentry.tasks.statistical_detectors._detect_function_change_points") -def test_process_function_change_points_groups_by_start(detect, timestamp): +def test_process_function_change_points_groups_by_start( + detect: mock.MagicMock, timestamp: datetime +) -> None: next_start = timestamp + timedelta(hours=1) process_function_change_points( [ @@ -998,13 +1028,13 @@ def test_process_function_change_points_groups_by_start(detect, timestamp): @override_settings(SEER_API_SHARED_SECRET="viewer-context-test-secret") @django_db_all def test_detect_function_change_points( - mock_raw_snql_query, - mock_urlopen, - mock_emit_function_regression_issue, - mock_metrics_incr, - timestamp, - project, -): + mock_raw_snql_query: mock.MagicMock, + mock_urlopen: mock.MagicMock, + mock_emit_function_regression_issue: mock.MagicMock, + mock_metrics_incr: mock.MagicMock, + timestamp: datetime, + project: Project, +) -> None: start_of_hour = timestamp.replace(minute=0, second=0, microsecond=0) fingerprint = 12345 @@ -1151,13 +1181,13 @@ def detect_regressions( @mock.patch("sentry.statistical_detectors.detector.produce_occurrence_to_kafka") @django_db_all def test_new_regression_group( - produce_occurrence_to_kafka, - detector_cls, - object_name, - project, - timestamp, -): - def get_regressions(ts): + produce_occurrence_to_kafka: mock.MagicMock, + detector_cls: type[FunctionRegressionDetector], + object_name: str, + project: Project, + timestamp: datetime, +) -> None: + def get_regressions(ts: datetime) -> Generator[BreakpointData]: yield { "project": str(project.id), "transaction": object_name, @@ -1168,7 +1198,7 @@ def get_regressions(ts): "trend_percentage": 1.23, "absolute_percentage_change": 1.23, "trend_difference": 1.23, - "breakpoint": (ts - timedelta(days=1)).timestamp(), + "breakpoint": cast(int, (ts - timedelta(days=1)).timestamp()), } regressions = get_regressions(timestamp) @@ -1182,7 +1212,7 @@ def get_regressions(ts): ) assert len(regression_groups) == 1 # confirm the regression group was saved - def get_trends(ts): + def get_trends(ts: datetime) -> Generator[TrendBundle]: payload = DetectorPayload( project_id=project.id, group=object_name, @@ -1235,12 +1265,12 @@ def get_trends(ts): @pytest.mark.parametrize("active", [False, True]) @django_db_all def test_save_regressions_with_versions( - detector_cls, - object_name, - project, - timestamp, - active, -): + detector_cls: type[FunctionRegressionDetector], + object_name: str, + project: Project, + timestamp: datetime, + active: bool, +) -> None: fingerprint = generate_fingerprint(detector_cls.regression_type, object_name) original_group = RegressionGroup( type=detector_cls.regression_type.value, @@ -1255,7 +1285,7 @@ def test_save_regressions_with_versions( with freeze_time(timestamp - MISSING_ISSUE_GROUP_GRACE_PERIOD): RegressionGroup.objects.bulk_create([original_group]) - def get_regressions(ts): + def get_regressions(ts: datetime) -> Generator[BreakpointData]: yield { "project": str(project.id), "transaction": object_name, @@ -1266,7 +1296,7 @@ def get_regressions(ts): "trend_percentage": 1.23, "absolute_percentage_change": 1.23, "trend_difference": 1.23, - "breakpoint": ts.timestamp(), + "breakpoint": cast(int, ts.timestamp()), } # there is a buffer on issue creation, so in the first 24 hours @@ -1316,15 +1346,15 @@ def get_regressions(ts): @mock.patch("sentry.statistical_detectors.detector.produce_occurrence_to_kafka") @django_db_all def test_detect_function_trends_missing_issue_group( - produce_occurrence_to_kafka, - change_point_queue, - detect_trends, - age, - expected_active, - trend_type, - project, - timestamp, -): + produce_occurrence_to_kafka: mock.MagicMock, + change_point_queue: mock.MagicMock, + detect_trends: mock.MagicMock, + age: timedelta, + expected_active: bool, + trend_type: TrendType, + project: Project, + timestamp: datetime, +) -> None: detector_cls = FunctionRegressionDetector original_group = RegressionGroup( type=detector_cls.regression_type.value, @@ -1339,7 +1369,7 @@ def test_detect_function_trends_missing_issue_group( with freeze_time(timestamp - age): RegressionGroup.objects.bulk_create([original_group]) - def get_trends(): + def get_trends() -> Generator[TrendBundle]: yield TrendBundle( type=trend_type, score=1, @@ -1376,7 +1406,9 @@ def get_trends(): @pytest.mark.parametrize("batch_size", [1, 2, 100]) @django_db_all -def test_filter_escalating_groups_missing_issues_in_batches(batch_size, project, timestamp): +def test_filter_escalating_groups_missing_issues_in_batches( + batch_size: int, project: Project, timestamp: datetime +) -> None: detector_cls = FunctionRegressionDetector bundles = [] groups = [] @@ -1462,21 +1494,21 @@ def test_filter_escalating_groups_missing_issues_in_batches(batch_size, project, @mock.patch("sentry.statistical_detectors.detector.produce_occurrence_to_kafka") @django_db_all def test_redirect_escalations( - produce_occurrence_to_kafka, - mock_uuid4, - detector_cls, - object_name, - baseline, - regressed, - escalated, - issue_type, - status, - substatus, - should_escalate, - project, - timestamp, - django_cache, # the environment can persist in the cache otherwise -): + produce_occurrence_to_kafka: mock.MagicMock, + mock_uuid4: mock.MagicMock, + detector_cls: type[FunctionRegressionDetector], + object_name: int | str, + baseline: int, + regressed: int, + escalated: int, + issue_type: type[GroupType], + status: int, + substatus: int | None, + should_escalate: bool, + project: Project, + timestamp: datetime, + django_cache: BaseCache, # the environment can persist in the cache otherwise +) -> None: fingerprint = generate_fingerprint(detector_cls.regression_type, object_name) RegressionGroup.objects.create( @@ -1526,7 +1558,7 @@ def test_redirect_escalations( group.substatus = substatus group.save() - def get_trends(ts): + def get_trends(ts: datetime) -> Generator[TrendBundle]: payload = DetectorPayload( project_id=project.id, group=object_name, @@ -1644,7 +1676,7 @@ def setUp(self) -> None: vroom_patch = mock.patch("sentry.tasks.statistical_detectors.get_from_profiling_service") self.vroom = vroom_patch.start() self.addCleanup(vroom_patch.stop) - self.payload = { + self.payload: dict[str, Any] = { "culprit": "work", "detection_time": "2023-08-01T12:00:00.123456789Z", "event": { @@ -1913,8 +1945,8 @@ def test_functions_query(self) -> None: @mock.patch("sentry.tasks.statistical_detectors.get_from_profiling_service") def test_emit_function_regression_issue_transaction_function( - self, mock_get_from_profiling_service - ): + self, mock_get_from_profiling_service: mock.MagicMock + ) -> None: mock_value = mock.MagicMock() mock_value.status = 200 mock_value.data = b'{"occurrence_payloads":[]}' @@ -1942,7 +1974,9 @@ def test_emit_function_regression_issue_transaction_function( {project.id: project for project in self.projects}, regressions, self.now ) - def get_example(stored): + def get_example( + stored: dict[str, Any], + ) -> dict[str, Any]: # justification: untyped test helper payload return { "profile_id": stored["transaction"]["contexts"]["profile"]["profile_id"], } @@ -1977,9 +2011,9 @@ def get_example(stored): @mock.patch("sentry.tasks.statistical_detectors.raw_snql_query") def test_emit_function_regression_issue_continuous_function( self, - mock_raw_snql_query, - mock_get_from_profiling_service, - ): + mock_raw_snql_query: mock.MagicMock, + mock_get_from_profiling_service: mock.MagicMock, + ) -> None: mock_raw_snql_query.return_value = { "data": [ { @@ -2023,7 +2057,9 @@ def test_emit_function_regression_issue_continuous_function( {project.id: project for project in self.projects}, regressions, self.now ) - def get_example(stored): + def get_example( + stored: dict[str, Any], + ) -> dict[str, Any]: # justification: untyped test helper payload return { "profiler_id": stored["profiler_id"], "thread_id": "1", @@ -2089,7 +2125,9 @@ def test_emit_function_regression_issue_mixed( {project.id: project for project in self.projects}, regressions, self.now ) - def get_example(stored): + def get_example( + stored: dict[str, Any], + ) -> dict[str, Any]: # justification: untyped test helper payload # when the 2 modes are mixed, we try to prefer transaction based profiles return { "profile_id": stored["transaction"]["contexts"]["profile"]["profile_id"], diff --git a/tests/sentry/tasks/test_store.py b/tests/sentry/tasks/test_store.py index 1740e5464376..167ffdeb3262 100644 --- a/tests/sentry/tasks/test_store.py +++ b/tests/sentry/tasks/test_store.py @@ -1,5 +1,8 @@ +from collections.abc import Iterator from time import time +from typing import Any from unittest import mock +from unittest.mock import MagicMock import orjson import pytest @@ -10,6 +13,8 @@ from sentry.event_manager import EventManager from sentry.exceptions import HashDiscarded from sentry.ingest.types import ConsumerType +from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.services.eventstore.processing import event_processing_store from sentry.tasks.store import ( is_process_disabled, @@ -36,77 +41,77 @@ EVENT_ID = "cc3e6c2bb6b6498097f336d1e6979f4b" -def _remove_extra(data): +def _remove_extra(data: dict[str, Any]) -> Any: del data["extra"] return data -def _noop(data): +def _noop(data: dict[str, Any]) -> Any: return None -def _put_on_hold(data): +def _put_on_hold(data: dict[str, Any]) -> Any: data["unprocessed"] = True return data @pytest.fixture -def mock_save_event(): +def mock_save_event() -> Iterator[MagicMock]: with mock.patch("sentry.tasks.store.save_event") as m: yield m @pytest.fixture -def mock_save_event_transaction(): +def mock_save_event_transaction() -> Iterator[MagicMock]: with mock.patch("sentry.tasks.store.save_event_transaction") as m: yield m @pytest.fixture -def mock_process_event(): +def mock_process_event() -> Iterator[MagicMock]: with mock.patch("sentry.tasks.store.process_event") as m: yield m @pytest.fixture -def mock_symbolicate_event(): +def mock_symbolicate_event() -> Iterator[MagicMock]: with mock.patch("sentry.tasks.symbolication.symbolicate_event") as m: yield m @pytest.fixture -def mock_event_processing_store(): +def mock_event_processing_store() -> Iterator[MagicMock]: with mock.patch("sentry.services.eventstore.processing.event_processing_store") as m: m.store.return_value = "e:1" yield m @pytest.fixture -def mock_transaction_processing_store(): +def mock_transaction_processing_store() -> Iterator[MagicMock]: with mock.patch("sentry.services.eventstore.processing.transaction_processing_store") as m: yield m @pytest.fixture -def mock_refund(): +def mock_refund() -> Iterator[MagicMock]: with mock.patch.object(quotas, "refund") as m: yield m @pytest.fixture -def mock_get_preprocessors(): +def mock_get_preprocessors() -> Iterator[MagicMock]: with mock.patch("sentry.tasks.store.get_event_preprocessors") as m: yield m @django_db_all def test_move_to_process_event( - default_project, - mock_process_event, - mock_save_event, - mock_symbolicate_event, - mock_get_preprocessors, -): + default_project: Project, + mock_process_event: MagicMock, + mock_save_event: MagicMock, + mock_symbolicate_event: MagicMock, + mock_get_preprocessors: MagicMock, +) -> None: mock_get_preprocessors.return_value = [_remove_extra, _noop] data = { "project": default_project.id, @@ -125,12 +130,12 @@ def test_move_to_process_event( @django_db_all def test_move_to_process_event_inline_save_event_still_submits_process_event( - default_project, - mock_process_event, - mock_save_event, - mock_symbolicate_event, - mock_get_preprocessors, -): + default_project: Project, + mock_process_event: MagicMock, + mock_save_event: MagicMock, + mock_symbolicate_event: MagicMock, + mock_get_preprocessors: MagicMock, +) -> None: mock_get_preprocessors.return_value = [_noop] data = { "project": default_project.id, @@ -159,8 +164,11 @@ def test_move_to_process_event_inline_save_event_still_submits_process_event( @django_db_all def test_move_to_save_event( - default_project, mock_process_event, mock_save_event, mock_symbolicate_event -): + default_project: Project, + mock_process_event: MagicMock, + mock_save_event: MagicMock, + mock_symbolicate_event: MagicMock, +) -> None: data = { "project": default_project.id, "platform": "NOTMATTLANG", @@ -178,8 +186,11 @@ def test_move_to_save_event( @django_db_all def test_move_to_save_event_inline( - default_project, mock_process_event, mock_save_event, mock_symbolicate_event -): + default_project: Project, + mock_process_event: MagicMock, + mock_save_event: MagicMock, + mock_symbolicate_event: MagicMock, +) -> None: data = { "project": default_project.id, "platform": "NOTMATTLANG", @@ -206,13 +217,13 @@ def test_move_to_save_event_inline( @django_db_all @pytest.mark.parametrize("cache_key,expected_writes", [("e:1", 1), (None, 0)]) def test_process_event_mutate_and_save( - default_project, - mock_event_processing_store, - mock_save_event, - mock_get_preprocessors, - cache_key, - expected_writes, -): + default_project: Project, + mock_event_processing_store: MagicMock, + mock_save_event: MagicMock, + mock_get_preprocessors: MagicMock, + cache_key: str, + expected_writes: int, +) -> None: mock_get_preprocessors.return_value = [_remove_extra, _noop] data = { @@ -248,8 +259,11 @@ def test_process_event_mutate_and_save( @django_db_all def test_process_event_no_mutate_and_save( - default_project, mock_event_processing_store, mock_save_event, mock_get_preprocessors -): + default_project: Project, + mock_event_processing_store: MagicMock, + mock_save_event: MagicMock, + mock_get_preprocessors: MagicMock, +) -> None: mock_get_preprocessors.return_value = [_noop] data = { @@ -288,15 +302,15 @@ def test_process_event_no_mutate_and_save( ) @pytest.mark.parametrize("load_shed", (False, True)) def test_process_event_payload_transport( - default_project, - mock_event_processing_store, - mock_save_event, - mock_get_preprocessors, - cache_key, - expected_writes, - send_inline, - load_shed, -): + default_project: Project, + mock_event_processing_store: MagicMock, + mock_save_event: MagicMock, + mock_get_preprocessors: MagicMock, + cache_key: str, + expected_writes: int, + send_inline: bool, + load_shed: bool, +) -> None: data = {"project": default_project.id, "event_id": EVENT_ID, "platform": "python"} mock_get_preprocessors.return_value = [_noop] mock_event_processing_store.store.return_value = cache_key @@ -319,8 +333,11 @@ def test_process_event_payload_transport( @django_db_all def test_process_event_unprocessed( - default_project, mock_event_processing_store, mock_save_event, mock_get_preprocessors -): + default_project: Project, + mock_event_processing_store: MagicMock, + mock_save_event: MagicMock, + mock_get_preprocessors: MagicMock, +) -> None: mock_get_preprocessors.return_value = [_put_on_hold] data = { @@ -350,7 +367,7 @@ def test_process_event_unprocessed( @django_db_all -def test_hash_discarded_raised(default_project, mock_refund) -> None: +def test_hash_discarded_raised(default_project: Project, mock_refund: MagicMock) -> None: data = { "project": default_project.id, "platform": "NOTMATTLANG", @@ -369,7 +386,9 @@ def test_hash_discarded_raised(default_project, mock_refund) -> None: @django_db_all @pytest.mark.parametrize("inline", [False, True]) -def test_save_event_start_is_persisted_before_grouping(default_project, inline) -> None: +def test_save_event_start_is_persisted_before_grouping( + default_project: Project, inline: bool +) -> None: manager = EventManager( { "project": default_project.id, @@ -389,7 +408,7 @@ def test_save_event_start_is_persisted_before_grouping(default_project, inline) with freeze_time() as clock: save_started = time() - def slow_save(manager, **kwargs): + def slow_save(manager: Any, **kwargs: Any) -> Any: assert manager.get_data()["save_event_start"] == save_started clock.shift(5) event = original_save(manager, **kwargs) @@ -411,7 +430,7 @@ def slow_save(manager, **kwargs): @django_db_all -def test_save_event_sets_viewer_context(default_project) -> None: +def test_save_event_sets_viewer_context(default_project: Project) -> None: data = { "project": default_project.id, "platform": "python", @@ -422,7 +441,7 @@ def test_save_event_sets_viewer_context(default_project) -> None: captured_vc = None - def capture_vc(*args, **kwargs): + def capture_vc(*args: Any, **kwargs: Any) -> None: nonlocal captured_vc captured_vc = get_viewer_context() raise HashDiscarded("stop after capturing") @@ -444,7 +463,9 @@ def capture_vc(*args, **kwargs): "sentry.event_manager.severity_connection_pool.urlopen", return_value=HTTPResponse(body=orjson.dumps({"severity": 0.1231}), status=200), ) -def test_save_event_passes_system_viewer_context_to_seer(mock_urlopen, default_project) -> None: +def test_save_event_passes_system_viewer_context_to_seer( + mock_urlopen: MagicMock, default_project: Project +) -> None: data = { "project": default_project.id, "platform": "python", @@ -482,7 +503,7 @@ def test_save_event_passes_system_viewer_context_to_seer(mock_urlopen, default_p @django_db_all def test_save_event_deletes_processing_store_at_end( - default_project, mock_event_processing_store + default_project: Project, mock_event_processing_store: MagicMock ) -> None: data = { "project": default_project.id, @@ -520,14 +541,16 @@ def test_save_event_deletes_processing_store_at_end( @django_db_all -def test_inline_save_persists_then_cleans_up_backup_without_working_key(default_project): +def test_inline_save_persists_then_cleans_up_backup_without_working_key( + default_project: Project, +) -> None: data = {"project": default_project.id, "event_id": EVENT_ID, "platform": "python"} key = event_processing_store.store(dict(data), unprocessed=True) working_key = cache_key_for_event(data) assert key == working_key + ":u" assert event_processing_store.get(working_key) is None - def check_backup(**kwargs): + def check_backup(**kwargs: Any) -> None: assert kwargs["cache_key"] is None backup = event_processing_store.get(working_key, unprocessed=True) assert backup is not None @@ -542,7 +565,7 @@ def check_backup(**kwargs): @django_db_all @pytest.mark.parametrize("cache_key", (None, "e:test")) def test_save_event_deletes_processing_store_on_failure( - default_project, mock_event_processing_store, cache_key + default_project: Project, mock_event_processing_store: MagicMock, cache_key: str ) -> None: data = { "project": default_project.id, @@ -572,7 +595,7 @@ def test_save_event_deletes_processing_store_on_failure( @django_db_all @pytest.mark.parametrize("cache_key", (None, "", "e:working-event")) def test_discard_event_cleans_up_attachments_with_optional_cache_key( - default_project, cache_key + default_project: Project, cache_key: str ) -> None: data = { "project": default_project.id, @@ -606,7 +629,9 @@ def test_discard_event_cleans_up_attachments_with_optional_cache_key( @pytest.fixture(params=["org", "project"]) -def options_model(request, default_organization, default_project): +def options_model( + request: pytest.FixtureRequest, default_organization: Organization, default_project: Project +) -> Any: if request.param == "org": return default_organization elif request.param == "project": @@ -618,15 +643,15 @@ def options_model(request, default_organization, default_project): @django_db_all @pytest.mark.parametrize("setting_method", ["datascrubbers", "piiconfig"]) def test_scrubbing_after_processing( - default_project, - default_organization, - mock_save_event, - mock_get_preprocessors, - mock_event_processing_store, + default_project: Project, + default_organization: Organization, + mock_save_event: MagicMock, + mock_get_preprocessors: MagicMock, + mock_event_processing_store: MagicMock, setting_method: str, - options_model, -): - def more_extra(data): + options_model: Any, +) -> None: + def more_extra(data: dict[str, Any]) -> Any: data["extra"]["ooo2"] = "event preprocessor" return data @@ -681,7 +706,9 @@ def test_killswitch() -> None: @django_db_all -def test_transactions_store(default_project, mock_transaction_processing_store) -> None: +def test_transactions_store( + default_project: Project, mock_transaction_processing_store: MagicMock +) -> None: data = { "project": default_project.id, "platform": "transaction", @@ -725,7 +752,10 @@ def test_transactions_store(default_project, mock_transaction_processing_store) ), ) def test_inline_transaction_tracks_redis_deletion_only_with_cache_key( - default_project, mock_transaction_processing_store, cache_key, expected_stages + default_project: Project, + mock_transaction_processing_store: MagicMock, + cache_key: str, + expected_stages: list[str], ) -> None: data = {"project": default_project.id, "event_id": EVENT_ID, "type": "transaction"} @@ -751,12 +781,12 @@ def test_inline_transaction_tracks_redis_deletion_only_with_cache_key( @django_db_all def test_store_consumer_type( - default_project, - mock_save_event, - mock_save_event_transaction, - mock_event_processing_store, - mock_transaction_processing_store, -): + default_project: Project, + mock_save_event: MagicMock, + mock_save_event_transaction: MagicMock, + mock_event_processing_store: MagicMock, + mock_transaction_processing_store: MagicMock, +) -> None: data = { "project": default_project.id, "platform": "python", @@ -809,7 +839,7 @@ def test_store_consumer_type( @django_db_all -def test_should_process_new_path_js(default_project): +def test_should_process_new_path_js(default_project: Project) -> None: data = { "project": default_project.id, "platform": "javascript", @@ -819,7 +849,7 @@ def test_should_process_new_path_js(default_project): @django_db_all -def test_should_process_new_path_java_proguard(default_project): +def test_should_process_new_path_java_proguard(default_project: Project) -> None: data = { "project": default_project.id, "platform": "java", @@ -830,7 +860,7 @@ def test_should_process_new_path_java_proguard(default_project): @django_db_all -def test_should_process_new_path_no_preprocessor(default_project): +def test_should_process_new_path_no_preprocessor(default_project: Project) -> None: data = { "project": default_project.id, "platform": "python", @@ -840,7 +870,9 @@ def test_should_process_new_path_no_preprocessor(default_project): @django_db_all -def test_process_event_new_path_js(default_project, mock_event_processing_store, mock_save_event): +def test_process_event_new_path_js( + default_project: Project, mock_event_processing_store: MagicMock, mock_save_event: MagicMock +) -> None: data = { "project": default_project.id, "platform": "javascript", @@ -858,8 +890,11 @@ def test_process_event_new_path_js(default_project, mock_event_processing_store, @django_db_all def test_preprocess_routes_to_save_new_path_python( - default_project, mock_process_event, mock_save_event, mock_symbolicate_event -): + default_project: Project, + mock_process_event: MagicMock, + mock_save_event: MagicMock, + mock_symbolicate_event: MagicMock, +) -> None: data = { "project": default_project.id, "platform": "python", diff --git a/tests/sentry/tasks/test_symbolication.py b/tests/sentry/tasks/test_symbolication.py index 840ece545734..234d3983366a 100644 --- a/tests/sentry/tasks/test_symbolication.py +++ b/tests/sentry/tasks/test_symbolication.py @@ -1,10 +1,14 @@ import copy import itertools +from collections.abc import Iterator +from typing import Any from unittest import mock +from unittest.mock import MagicMock import pytest from sentry.lang.native.symbolicator import Symbolicator, SymbolicatorFunction +from sentry.models.project import Project from sentry.tasks.store import preprocess_event from sentry.tasks.symbolication import symbolicate_event from sentry.testutils.helpers.options import override_options @@ -15,32 +19,32 @@ @pytest.fixture -def mock_save_event(): +def mock_save_event() -> Iterator[MagicMock]: with mock.patch("sentry.tasks.store.save_event") as m: yield m @pytest.fixture -def mock_process_event(): +def mock_process_event() -> Iterator[MagicMock]: with mock.patch("sentry.tasks.store.process_event") as m: yield m @pytest.fixture -def mock_symbolicate_event(): +def mock_symbolicate_event() -> Iterator[MagicMock]: with mock.patch("sentry.tasks.symbolication.symbolicate_event") as m: yield m @pytest.fixture -def mock_symbolication_function(): +def mock_symbolication_function() -> Iterator[MagicMock]: """Mocks the symbolication function invoked via `SymbolicatorFunction.__call__`.""" with mock.patch.object(SymbolicatorFunction, "__call__") as m: yield m @pytest.fixture -def mock_event_processing_store(): +def mock_event_processing_store() -> Iterator[MagicMock]: with mock.patch("sentry.services.eventstore.processing.event_processing_store") as m: yield m @@ -51,15 +55,15 @@ def mock_event_processing_store(): [(False, False, 1), (True, False, 1), (True, True, 0)], ) def test_move_to_symbolicate_event( - default_project, - mock_process_event, - mock_save_event, - mock_symbolicate_event, - mock_event_processing_store, - inline, - disable_store, - redis_backups, -): + default_project: Project, + mock_process_event: MagicMock, + mock_save_event: MagicMock, + mock_symbolicate_event: MagicMock, + mock_event_processing_store: MagicMock, + inline: bool, + disable_store: bool, + redis_backups: int, +) -> None: data = {"platform": "native", "project": default_project.id, "event_id": EVENT_ID} cache_key = None if disable_store else "e:1" mock_event_processing_store.store.return_value = "e:1" @@ -88,15 +92,15 @@ def test_move_to_symbolicate_event( @django_db_all @pytest.mark.parametrize("load_shed", (False, True)) def test_symbolication_passes_unprocessed_event_inline( - default_project, - mock_process_event, - mock_symbolication_function, - load_shed, -): + default_project: Project, + mock_process_event: MagicMock, + mock_symbolication_function: MagicMock, + load_shed: bool, +) -> None: data = {"platform": "native", "project": default_project.id, "event_id": EVENT_ID} unprocessed = copy.deepcopy(data) - def _symbolicate_in_place(symbolicator, data): + def _symbolicate_in_place(symbolicator: Any, data: dict[str, Any]) -> Any: data["symbolicated"] = True return data @@ -126,16 +130,16 @@ def _symbolicate_in_place(symbolicator, data): ], ) def test_symbolication_continues_after_error_or_load_shedding( - default_project, - mock_event_processing_store, - mock_process_event, - mock_symbolication_function, - error, - load_shed, - expected_metrics, - symbolicate_functions, - inline, -): + default_project: Project, + mock_event_processing_store: MagicMock, + mock_process_event: MagicMock, + mock_symbolication_function: MagicMock, + error: RuntimeError, + load_shed: bool, + expected_metrics: dict[str, Any], + symbolicate_functions: list[str], + inline: bool, +) -> None: data = {"platform": "native", "project": default_project.id, "event_id": EVENT_ID} cache_key = None if inline else "e:1" mock_event_processing_store.get.return_value = data @@ -169,8 +173,12 @@ def test_symbolication_continues_after_error_or_load_shedding( @django_db_all @pytest.mark.parametrize("inline", (False, True)) def test_symbolicate_minidump_and_native_stacktrace( - default_project, mock_event_processing_store, mock_process_event, mock_save_event, inline -): + default_project: Project, + mock_event_processing_store: MagicMock, + mock_process_event: MagicMock, + mock_save_event: MagicMock, + inline: bool, +) -> None: """ An event containing both a minidump and an additional raw native stacktrace (relay's `MinidumpMultiException` feature inserts the minidump placeholder @@ -220,7 +228,7 @@ def test_symbolicate_minidump_and_native_stacktrace( stored_data = {} counter = itertools.count() - def _store(event_data): + def _store(event_data: dict[str, Any]) -> Any: key = f"e:{next(counter)}" stored_data[key] = event_data return key diff --git a/tests/sentry/tasks/test_web_vitals_issue_detection.py b/tests/sentry/tasks/test_web_vitals_issue_detection.py index 3dc5dabec921..92a4bf59c77e 100644 --- a/tests/sentry/tasks/test_web_vitals_issue_detection.py +++ b/tests/sentry/tasks/test_web_vitals_issue_detection.py @@ -1,5 +1,6 @@ +from collections.abc import Iterator from contextlib import contextmanager -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from django.test import override_settings @@ -20,7 +21,7 @@ def setUp(self) -> None: self.ten_mins_ago = before_now(minutes=10) @contextmanager - def mock_code_mapping(self): + def mock_code_mapping(self) -> Iterator[dict[str, MagicMock]]: with ( patch( "sentry.tasks.web_vitals_issue_detection.get_autofix_repos_from_project_code_mappings" @@ -36,7 +37,7 @@ def mock_code_mapping(self): yield {"mock_repos": mock_repos} @patch("sentry.tasks.web_vitals_issue_detection.detect_web_vitals_issues_for_project.delay") - def test_run_detection_dispatches_sub_tasks_when_enabled(self, mock_delay): + def test_run_detection_dispatches_sub_tasks_when_enabled(self, mock_delay: MagicMock) -> None: project = self.create_project() with ( @@ -54,7 +55,7 @@ def test_run_detection_dispatches_sub_tasks_when_enabled(self, mock_delay): assert mock_delay.call_args[0][0] == project.id @patch("sentry.tasks.web_vitals_issue_detection.detect_web_vitals_issues_for_project.delay") - def test_run_detection_skips_when_no_github_code_mappings(self, mock_delay): + def test_run_detection_skips_when_no_github_code_mappings(self, mock_delay: MagicMock) -> None: project = self.create_project() with ( @@ -70,7 +71,7 @@ def test_run_detection_skips_when_no_github_code_mappings(self, mock_delay): assert not mock_delay.called @patch("sentry.tasks.web_vitals_issue_detection.detect_web_vitals_issues_for_project.delay") - def test_run_detection_skips_when_not_allowlisted(self, mock_delay): + def test_run_detection_skips_when_not_allowlisted(self, mock_delay: MagicMock) -> None: with ( self.mock_code_mapping(), self.options( @@ -86,7 +87,9 @@ def test_run_detection_skips_when_not_allowlisted(self, mock_delay): @pytest.mark.snuba @patch("sentry.web_vitals.issue_platform_adapter.produce_occurrence_to_kafka") - def test_run_detection_produces_occurrences(self, mock_produce_occurrence_to_kafka): + def test_run_detection_produces_occurrences( + self, mock_produce_occurrence_to_kafka: MagicMock + ) -> None: project = self.create_project() spans = [] @@ -218,7 +221,9 @@ def test_run_detection_produces_occurrences(self, mock_produce_occurrence_to_kaf @pytest.mark.snuba @patch("sentry.web_vitals.issue_platform_adapter.produce_occurrence_to_kafka") - def test_run_detection_groups_rendering_vitals(self, mock_produce_occurrence_to_kafka): + def test_run_detection_groups_rendering_vitals( + self, mock_produce_occurrence_to_kafka: MagicMock + ) -> None: project = self.create_project() spans = [] @@ -329,8 +334,8 @@ def test_run_detection_groups_rendering_vitals(self, mock_produce_occurrence_to_ @pytest.mark.snuba @patch("sentry.web_vitals.issue_platform_adapter.produce_occurrence_to_kafka") def test_run_detection_does_not_produce_occurrences_for_existing_issues( - self, mock_produce_occurrence_to_kafka - ): + self, mock_produce_occurrence_to_kafka: MagicMock + ) -> None: project = self.create_project() spans = [ @@ -382,8 +387,8 @@ def test_run_detection_does_not_produce_occurrences_for_existing_issues( @pytest.mark.snuba @patch("sentry.web_vitals.issue_platform_adapter.produce_occurrence_to_kafka") def test_run_detection_does_not_create_issue_on_insufficient_samples( - self, mock_produce_occurrence_to_kafka - ): + self, mock_produce_occurrence_to_kafka: MagicMock + ) -> None: project = self.create_project() spans = [ @@ -424,8 +429,8 @@ def test_run_detection_does_not_create_issue_on_insufficient_samples( @pytest.mark.snuba @patch("sentry.web_vitals.issue_platform_adapter.produce_occurrence_to_kafka") def test_run_detection_selects_trace_closest_to_p75_web_vital_value( - self, mock_produce_occurrence_to_kafka - ): + self, mock_produce_occurrence_to_kafka: MagicMock + ) -> None: project = self.create_project() spans = [ @@ -509,7 +514,9 @@ def test_run_detection_selects_trace_closest_to_p75_web_vital_value( @pytest.mark.snuba @patch("sentry.web_vitals.issue_platform_adapter.produce_occurrence_to_kafka") - def test_run_detection_selects_trace_from_worst_score(self, mock_produce_occurrence_to_kafka): + def test_run_detection_selects_trace_from_worst_score( + self, mock_produce_occurrence_to_kafka: MagicMock + ) -> None: project = self.create_project() spans = [ @@ -620,8 +627,10 @@ def test_run_detection_selects_trace_from_worst_score(self, mock_produce_occurre @patch("sentry.tasks.web_vitals_issue_detection.detect_web_vitals_issues_for_project.delay") @patch("sentry.tasks.web_vitals_issue_detection.get_merged_settings") def test_run_detection_does_not_run_for_project_when_user_has_disabled( - self, mock_get_merged_settings, mock_detect_web_vitals_issues_for_project - ): + self, + mock_get_merged_settings: MagicMock, + mock_detect_web_vitals_issues_for_project: MagicMock, + ) -> None: mock_get_merged_settings.return_value = { "web_vitals_detection_enabled": False, } diff --git a/tests/sentry/tasks/test_weekly_report_utils.py b/tests/sentry/tasks/test_weekly_report_utils.py index 5bc77ddf3973..1ec628a97d27 100644 --- a/tests/sentry/tasks/test_weekly_report_utils.py +++ b/tests/sentry/tasks/test_weekly_report_utils.py @@ -286,6 +286,7 @@ def test_organization_project_issue_substatus_summaries(self) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event1.group is not None event1.group.substatus = GroupSubStatus.ONGOING event1.group.save() @@ -299,6 +300,7 @@ def test_organization_project_issue_substatus_summaries(self) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event2.group is not None event2.group.substatus = GroupSubStatus.NEW event2.group.save() timestamp = self.now.timestamp() @@ -336,6 +338,7 @@ def test_org_key_error_issues_batched(self) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event1.group is not None event2 = self.store_event( data={ "event_id": "b" * 32, @@ -347,6 +350,7 @@ def test_org_key_error_issues_batched(self) -> None: default_event_type=EventType.DEFAULT, ) group2 = event2.group + assert group2 is not None group2.status = GroupStatus.RESOLVED group2.substatus = None group2.resolved_at = self.now - timedelta(minutes=1) @@ -433,6 +437,7 @@ def test_organization_project_issue_summaries_query(self) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event1.group is not None event1.group.substatus = GroupSubStatus.NEW event1.group.save() @@ -446,6 +451,7 @@ def test_organization_project_issue_summaries_query(self) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event2.group is not None event2.group.substatus = GroupSubStatus.ESCALATING event2.group.save() @@ -459,6 +465,7 @@ def test_organization_project_issue_summaries_query(self) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event3.group is not None event3.group.substatus = GroupSubStatus.REGRESSED event3.group.save() @@ -473,6 +480,7 @@ def test_organization_project_issue_summaries_query(self) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event4.group is not None event4.group.status = GroupStatus.RESOLVED event4.group.substatus = None event4.group.save() @@ -505,6 +513,7 @@ def test_past_resolved_issues_basic(self) -> None: default_event_type=EventType.DEFAULT, ) group1 = event1.group + assert group1 is not None group1.status = GroupStatus.RESOLVED group1.substatus = None group1.resolved_at = self.now - timedelta(minutes=1) @@ -595,6 +604,7 @@ def test_past_resolved_issues_excludes_outside_window(self) -> None: default_event_type=EventType.DEFAULT, ) group1 = event1.group + assert group1 is not None group1.status = GroupStatus.RESOLVED group1.substatus = None group1.resolved_at = self.now - timedelta(days=14) diff --git a/tests/sentry/tasks/test_weekly_reports.py b/tests/sentry/tasks/test_weekly_reports.py index 5c41e282dd94..47e9deef7bc6 100644 --- a/tests/sentry/tasks/test_weekly_reports.py +++ b/tests/sentry/tasks/test_weekly_reports.py @@ -1,5 +1,5 @@ import zoneinfo -from datetime import timedelta +from datetime import datetime, timedelta from unittest import mock import pytest @@ -62,6 +62,7 @@ from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import assume_test_silo_mode from sentry.types.group import GroupSubStatus +from sentry.users.models.user import User from sentry.users.models.user_option import UserOption from sentry.users.services.user_option import user_option_service from sentry.utils import redis @@ -85,13 +86,13 @@ def setUp(self) -> None: def store_event_outcomes( self, - organization_id, - project_id, - timestamp, - num_times, - outcome=Outcome.ACCEPTED, - category=DataCategory.ERROR, - ): + organization_id: int, + project_id: int, + timestamp: datetime, + num_times: int, + outcome: Outcome = Outcome.ACCEPTED, + category: DataCategory = DataCategory.ERROR, + ) -> None: self.store_outcomes( { "org_id": organization_id, @@ -181,7 +182,7 @@ def test_message_links_customer_domains(self) -> None: f"http://{self.organization.slug}.testserver/issues/?referrer=weekly_report" in html ) - def _set_option_value(self, value): + def _set_option_value(self, value: str) -> None: with assume_test_silo_mode(SiloMode.CONTROL): NotificationSettingOption.objects.update_or_create( scope_type="organization", @@ -191,15 +192,15 @@ def _set_option_value(self, value): defaults={"value": value}, ) - def _set_timezone(self, user, value): + def _set_timezone(self, user: User, value: str) -> None: with assume_test_silo_mode(SiloMode.CONTROL): UserOption.objects.set_value(user=user, key="timezone", value=value) @mock.patch("sentry.tasks.summaries.weekly_reports.prepare_template_context") @mock.patch("sentry.tasks.summaries.weekly_reports.OrganizationReportBatch.send_email") def test_deliver_reports_respects_settings( - self, mock_send_email, mock_prepare_template_context - ): + self, mock_send_email: mock.MagicMock, mock_prepare_template_context: mock.MagicMock + ) -> None: self.store_event_outcomes( self.organization.id, self.project.id, self.two_days_ago, num_times=2 ) @@ -326,6 +327,7 @@ def test_message_builder_filter_resolved_batched(self) -> None: default_event_type=EventType.DEFAULT, ) group2 = event2.group + assert group2 is not None group2.status = GroupStatus.RESOLVED group2.substatus = None group2.resolved_at = self.now - timedelta(minutes=1) @@ -347,7 +349,9 @@ def test_message_builder_filter_resolved_batched(self) -> None: group_id for group_id, _ in ctx.projects_context_map[self.project.id].key_error_issues_by_id } + assert event1.group is not None assert event1.group.id in key_error_issue_ids + assert event3.group is not None assert event3.group.id in key_error_issue_ids assert len(ctx.projects_context_map[self.project.id].key_error_issues_by_id) == 2 @@ -384,7 +388,9 @@ def test_message_builder_simple( ) group1 = event1.group + assert group1 is not None group2 = event2.group + assert group2 is not None group1.status = GroupStatus.RESOLVED group1.substatus = None @@ -607,7 +613,9 @@ def test_message_builder_multiple_users_prevent_resend( ) group1 = event1.group + assert group1 is not None group2 = event2.group + assert group2 is not None group1.status = GroupStatus.RESOLVED group1.substatus = None @@ -701,6 +709,7 @@ def test_message_builder_substatus_simple(self, message_builder: mock.MagicMock) default_event_type=EventType.DEFAULT, ) group1 = event1.group + assert group1 is not None group1.substatus = GroupSubStatus.NEW group1.save() @@ -715,6 +724,7 @@ def test_message_builder_substatus_simple(self, message_builder: mock.MagicMock) default_event_type=EventType.DEFAULT, ) group2 = event2.group + assert group2 is not None group2.substatus = GroupSubStatus.ONGOING group2.save() @@ -765,6 +775,7 @@ def test_message_builder_advanced(self, message_builder: mock.MagicMock) -> None ) group1 = event1.group + assert group1 is not None group1.status = GroupStatus.RESOLVED group1.substatus = None group1.resolved_at = self.two_days_ago @@ -820,6 +831,7 @@ def test_issue_counts_in_trends(self, message_builder: mock.MagicMock) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event1.group is not None event1.group.substatus = GroupSubStatus.NEW event1.group.save() @@ -833,6 +845,7 @@ def test_issue_counts_in_trends(self, message_builder: mock.MagicMock) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event2.group is not None event2.group.substatus = GroupSubStatus.ESCALATING event2.group.save() @@ -846,6 +859,7 @@ def test_issue_counts_in_trends(self, message_builder: mock.MagicMock) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event3.group is not None event3.group.substatus = GroupSubStatus.REGRESSED event3.group.save() @@ -859,6 +873,7 @@ def test_issue_counts_in_trends(self, message_builder: mock.MagicMock) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event4.group is not None event4.group.substatus = GroupSubStatus.ONGOING event4.group.save() @@ -908,6 +923,7 @@ def test_issue_pct_change_with_previous_week(self, message_builder: mock.MagicMo project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event1.group is not None event1.group.substatus = GroupSubStatus.NEW event1.group.save() @@ -921,6 +937,7 @@ def test_issue_pct_change_with_previous_week(self, message_builder: mock.MagicMo project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event2.group is not None event2.group.substatus = GroupSubStatus.ESCALATING event2.group.save() @@ -967,6 +984,7 @@ def test_pct_change_partial_cache_falls_back_per_key( project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event1.group is not None event1.group.substatus = GroupSubStatus.NEW event1.group.save() @@ -1042,6 +1060,7 @@ def test_issue_counts_multi_project(self, message_builder: mock.MagicMock) -> No project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event1.group is not None event1.group.substatus = GroupSubStatus.NEW event1.group.save() @@ -1055,6 +1074,7 @@ def test_issue_counts_multi_project(self, message_builder: mock.MagicMock) -> No project_id=project2.id, default_event_type=EventType.DEFAULT, ) + assert event2.group is not None event2.group.substatus = GroupSubStatus.ESCALATING event2.group.save() @@ -1068,6 +1088,7 @@ def test_issue_counts_multi_project(self, message_builder: mock.MagicMock) -> No project_id=project2.id, default_event_type=EventType.DEFAULT, ) + assert event3.group is not None event3.group.substatus = GroupSubStatus.REGRESSED event3.group.save() @@ -1113,6 +1134,7 @@ def test_issue_cache_round_trip(self, message_builder: mock.MagicMock) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event1.group is not None event1.group.substatus = GroupSubStatus.NEW event1.group.save() @@ -1126,6 +1148,7 @@ def test_issue_cache_round_trip(self, message_builder: mock.MagicMock) -> None: project_id=self.project.id, default_event_type=EventType.DEFAULT, ) + assert event2.group is not None event2.group.substatus = GroupSubStatus.ONGOING event2.group.save() @@ -1564,8 +1587,8 @@ def test_schedule_organizations_with_redis_tracking( @mock.patch("sentry.tasks.summaries.weekly_reports.prepare_organization_report") def test_schedule_organizations_updates_redis_during_processing( - self, mock_prepare_organization_report - ): + self, mock_prepare_organization_report: mock.MagicMock + ) -> None: """Test that schedule_organizations updates Redis with the current organization ID during processing.""" timestamp = self.timestamp @@ -1640,8 +1663,8 @@ def test_schedule_organizations_leaves_a_ttl_when_the_run_fails( @mock.patch("sentry.tasks.summaries.weekly_reports.prepare_organization_report") def test_schedule_organizations_starts_from_beginning_when_no_redis_key( - self, mock_prepare_organization_report - ): + self, mock_prepare_organization_report: mock.MagicMock + ) -> None: """Test that schedule_organizations starts from the beginning when no Redis key exists.""" timestamp = self.timestamp redis_cluster = redis.clusters.get("default").get_local_client_for_key( @@ -1926,6 +1949,7 @@ def test_past_issues_in_template_context( ) group1 = event1.group + assert group1 is not None group1.status = GroupStatus.RESOLVED group1.substatus = None group1.resolved_at = self.two_days_ago @@ -2069,10 +2093,10 @@ def test_spans_chart_url_none_without_feature_flag(self) -> None: assert template_ctx is not None assert template_ctx["spans_chart_url"] is None - def test_chart_palette_equals_top_spans_limit(self): + def test_chart_palette_equals_top_spans_limit(self) -> None: assert len(CHART_PALETTE) == TOP_SPANS_LIMIT - def test_project_breakdown_equals_covers_project_limit(self): + def test_project_breakdown_equals_covers_project_limit(self) -> None: assert len(project_breakdown_colors) == TOP_SPANS_LIMIT def test_resolution_url_is_rendered_as_label_link(self) -> None: diff --git a/tests/sentry/taskworker/test_config.py b/tests/sentry/taskworker/test_config.py index d786e4bea645..508dcbe28d12 100644 --- a/tests/sentry/taskworker/test_config.py +++ b/tests/sentry/taskworker/test_config.py @@ -41,7 +41,7 @@ def test_taskworker_schedule_unique() -> None: @pytest.mark.parametrize("name,config", list(settings.TASKWORKER_SCHEDULES.items())) -def test_taskworker_schedule_type(name: str, config: dict[str, Any], load_tasks) -> None: +def test_taskworker_schedule_type(name: str, config: dict[str, Any], load_tasks: object) -> None: assert config["task"], f"schedule {name} is missing a task name" (namespace, taskname) = config["task"].split(":") assert app.taskregistry.get_task(namespace, taskname), f"task for {name} is not registered" @@ -53,7 +53,7 @@ def test_taskworker_schedule_type(name: str, config: dict[str, Any], load_tasks) ) -def test_taskworker_schedule_parameters(load_tasks) -> None: +def test_taskworker_schedule_parameters(load_tasks: object) -> None: for config in settings.TASKWORKER_SCHEDULES.values(): (namespace, taskname) = config["task"].split(":") task = app.taskregistry.get_task(namespace, taskname) diff --git a/tests/sentry/templatetags/test_sentry_helpers.py b/tests/sentry/templatetags/test_sentry_helpers.py index 5db57f1f00c7..7ebdc788b918 100644 --- a/tests/sentry/templatetags/test_sentry_helpers.py +++ b/tests/sentry/templatetags/test_sentry_helpers.py @@ -207,5 +207,5 @@ def test_sanitize_periods_breaks_url_scheme() -> None: ("not a number", "0ms"), ), ) -def test_format_duration_ms(value: object, expected: str) -> None: +def test_format_duration_ms(value: int | float | str | None, expected: str) -> None: assert format_duration_ms(value) == expected diff --git a/tests/sentry/test_culprit.py b/tests/sentry/test_culprit.py index e9761e7b6826..d3ae364de8ba 100644 --- a/tests/sentry/test_culprit.py +++ b/tests/sentry/test_culprit.py @@ -2,7 +2,7 @@ from sentry.event_manager import get_culprit as get_culprit_impl -def get_culprit(data): +def get_culprit(data: dict[str, object]) -> str: mgr = EventManager(data) mgr.normalize() return get_culprit_impl(mgr.get_data()) diff --git a/tests/sentry/test_killswitches.py b/tests/sentry/test_killswitches.py index bcc987aefb49..c3cd3e7fdc84 100644 --- a/tests/sentry/test_killswitches.py +++ b/tests/sentry/test_killswitches.py @@ -2,7 +2,7 @@ import pytest -from sentry.killswitches import normalize_value, value_matches +from sentry.killswitches import Context, normalize_value, value_matches def test_normalize_value() -> None: @@ -62,9 +62,9 @@ def test_normalize_value() -> None: ), ), ) -def test_value_matches_positive(cfg, value) -> None: - cfg = normalize_value("store.load-shed-group-creation-projects", cfg) - assert value_matches("store.load-shed-group-creation-projects", cfg, value) +def test_value_matches_positive(cfg: list[dict[str, object]], value: Context) -> None: + normalized = normalize_value("store.load-shed-group-creation-projects", cfg) + assert value_matches("store.load-shed-group-creation-projects", normalized, value) @pytest.mark.parametrize( @@ -96,6 +96,6 @@ def test_value_matches_positive(cfg, value) -> None: ), ), ) -def test_value_matches_negative(cfg, value) -> None: - cfg = normalize_value("store.load-shed-group-creation-projects", cfg) - assert not value_matches("store.load-shed-group-creation-projects", cfg, value) +def test_value_matches_negative(cfg: list[dict[str, object]], value: Context) -> None: + normalized = normalize_value("store.load-shed-group-creation-projects", cfg) + assert not value_matches("store.load-shed-group-creation-projects", normalized, value) diff --git a/tests/sentry/test_sdk_updates.py b/tests/sentry/test_sdk_updates.py index cb6ec32bccc0..b0b43f47ef50 100644 --- a/tests/sentry/test_sdk_updates.py +++ b/tests/sentry/test_sdk_updates.py @@ -1,3 +1,5 @@ +from typing import cast + import pytest from sentry.sdk_updates import SdkIndexState, SdkSetupState, get_suggested_updates @@ -121,11 +123,12 @@ def test_enable_two_integrations() -> None: "sentry.dotnet", ] ) -def some_dotnet_sdk(request): - return request.param +def some_dotnet_sdk(request: pytest.FixtureRequest) -> str: + # justification: the fixture parameters are declared as strings above. + return cast("str", request.param) -def test_add_aspnetcore_sdk(some_dotnet_sdk) -> None: +def test_add_aspnetcore_sdk(some_dotnet_sdk: str) -> None: setup = SdkSetupState( sdk_name=some_dotnet_sdk, sdk_version="1.2.0", @@ -152,7 +155,7 @@ def test_add_aspnetcore_sdk(some_dotnet_sdk) -> None: ] -def test_add_serilog_sdk(some_dotnet_sdk) -> None: +def test_add_serilog_sdk(some_dotnet_sdk: str) -> None: setup = SdkSetupState( sdk_name=some_dotnet_sdk, sdk_version="1.2.0", @@ -174,7 +177,7 @@ def test_add_serilog_sdk(some_dotnet_sdk) -> None: ] -def test_add_no_dotnet_sdk(some_dotnet_sdk) -> None: +def test_add_no_dotnet_sdk(some_dotnet_sdk: str) -> None: setup = SdkSetupState( sdk_name=some_dotnet_sdk, sdk_version="1.2.0", @@ -191,7 +194,7 @@ def test_add_no_dotnet_sdk(some_dotnet_sdk) -> None: assert suggestions == [] -def test_more_specific_dotnet_sdk(some_dotnet_sdk) -> None: +def test_more_specific_dotnet_sdk(some_dotnet_sdk: str) -> None: setup = SdkSetupState( sdk_name=some_dotnet_sdk, sdk_version="1.2.0", diff --git a/tests/sentry/test_stacktraces.py b/tests/sentry/test_stacktraces.py index cbb5e4813741..536c4d1234fa 100644 --- a/tests/sentry/test_stacktraces.py +++ b/tests/sentry/test_stacktraces.py @@ -191,7 +191,7 @@ def test_find_stacktraces_skip_none(self) -> None: assert infos[0].exception_type == "Error" -def _thread(**kwargs): +def _thread(**kwargs: object) -> dict[str, object]: return {"stacktrace": {"frames": [{"in_app": True, "marco": "polo"}]}, **kwargs} @@ -210,8 +210,10 @@ def _thread(**kwargs): {"threads": {"values": [None, _thread()]}}, ], ) -def test_get_crash_frame(event) -> None: - assert get_crash_frame_from_event_data(event)["marco"] == "polo" +def test_get_crash_frame(event: dict[str, object]) -> None: + crash_frame = get_crash_frame_from_event_data(event) + assert crash_frame is not None + assert crash_frame["marco"] == "polo" @pytest.mark.parametrize( @@ -223,5 +225,5 @@ def test_get_crash_frame(event) -> None: {"threads": {"values": [_thread(current=True), _thread(current=True)]}}, ], ) -def test_get_crash_frame_returns_none(event) -> None: +def test_get_crash_frame_returns_none(event: dict[str, object]) -> None: assert get_crash_frame_from_event_data(event) is None diff --git a/tests/sentry/test_viewer_context.py b/tests/sentry/test_viewer_context.py index 879758e749af..4cdca5e2299c 100644 --- a/tests/sentry/test_viewer_context.py +++ b/tests/sentry/test_viewer_context.py @@ -3,6 +3,7 @@ import contextvars import dataclasses import threading +from collections.abc import Iterator from typing import Any from unittest import mock @@ -42,11 +43,11 @@ def test_actor_id_from_references_rejects_invalid_input( class TestActorType: - def test_values_are_strings(self): - assert ActorType.USER == "user" - assert ActorType.SYSTEM == "system" - assert ActorType.INTEGRATION == "integration" - assert ActorType.UNKNOWN == "unknown" + def test_values_are_strings(self) -> None: + assert ActorType.USER.value == "user" + assert ActorType.SYSTEM.value == "system" + assert ActorType.INTEGRATION.value == "integration" + assert ActorType.UNKNOWN.value == "unknown" class TestViewerContext: @@ -64,7 +65,7 @@ class TestViewerContext: ), ], ) - def test_actor_roundtrip(self, actor, wire_actor): + def test_actor_roundtrip(self, actor: Actor, wire_actor: dict[str, object]) -> None: ctx = ViewerContext(actor=actor, organization_id=42) wire = {"version": 2, "actor": wire_actor, "organization_id": 42} assert ctx.serialize() == wire @@ -79,7 +80,7 @@ def test_actor_roundtrip(self, actor, wire_actor): "invalid", ], ) - def test_invalid_superuser_claim(self, superuser): + def test_invalid_superuser_claim(self, superuser: object) -> None: with pytest.raises(ValueError): ViewerContext.deserialize( { @@ -89,7 +90,7 @@ def test_invalid_superuser_claim(self, superuser): } ) - def test_superuser_access_roundtrip_and_org_change(self): + def test_superuser_access_roundtrip_and_org_change(self) -> None: ctx = ViewerContext( actor=Actor( type=ActorType.USER, actor_id=42, superuser=SuperuserAccess(expires_at=1234567890) @@ -106,18 +107,18 @@ def test_superuser_access_roundtrip_and_org_change(self): assert changed is not None assert changed.actor.superuser is None - def test_defaults(self): + def test_defaults(self) -> None: ctx = ViewerContext(actor=Actor(type=ActorType.UNKNOWN)) assert ctx.organization_id is None assert ctx.actor.actor_id is None assert ctx.actor.type is ActorType.UNKNOWN - def test_frozen(self): + def test_frozen(self) -> None: ctx = ViewerContext(actor=Actor(type=ActorType.USER, actor_id=1)) with pytest.raises(dataclasses.FrozenInstanceError): ctx.actor.actor_id = 2 # type: ignore[misc] - def test_construction(self): + def test_construction(self) -> None: ctx = ViewerContext(actor=Actor(type=ActorType.USER, actor_id=42), organization_id=10) assert ctx.organization_id == 10 assert ctx.actor.actor_id == 42 @@ -125,10 +126,10 @@ def test_construction(self): class TestViewerContextScope: - def test_get_returns_none_outside_scope(self): + def test_get_returns_none_outside_scope(self) -> None: assert get_viewer_context() is None - def test_scope_sets_and_resets(self): + def test_scope_sets_and_resets(self) -> None: ctx = ViewerContext(actor=Actor(type=ActorType.USER, actor_id=1)) assert get_viewer_context() is None @@ -137,7 +138,7 @@ def test_scope_sets_and_resets(self): assert get_viewer_context() is None - def test_nested_scopes(self): + def test_nested_scopes(self) -> None: outer = ViewerContext(actor=Actor(type=ActorType.USER, actor_id=1)) inner = ViewerContext(actor=Actor(type=ActorType.USER, actor_id=2)) @@ -151,7 +152,7 @@ def test_nested_scopes(self): assert get_viewer_context() is None - def test_cleanup_on_exception(self): + def test_cleanup_on_exception(self) -> None: ctx = ViewerContext(actor=Actor(type=ActorType.USER, actor_id=1)) with pytest.raises(RuntimeError): @@ -161,14 +162,14 @@ def test_cleanup_on_exception(self): assert get_viewer_context() is None - def test_copy_context_propagates_to_thread(self): + def test_copy_context_propagates_to_thread(self) -> None: ctx = ViewerContext(actor=Actor(type=ActorType.USER, actor_id=1), organization_id=10) result: list[ViewerContext | None] = [None] with viewer_context_scope(ctx): copied = contextvars.copy_context() - def _worker(): + def _worker() -> None: result[0] = copied.run(get_viewer_context) t = threading.Thread(target=_worker) @@ -177,7 +178,7 @@ def _worker(): assert result[0] is ctx - def test_context_propagating_thread_pool_executor(self): + def test_context_propagating_thread_pool_executor(self) -> None: from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor ctx = ViewerContext(actor=Actor(type=ActorType.USER, actor_id=7), organization_id=99) @@ -188,7 +189,7 @@ def test_context_propagating_thread_pool_executor(self): assert result is ctx - def test_context_propagating_executor_does_not_leak_across_submissions(self): + def test_context_propagating_executor_does_not_leak_across_submissions(self) -> None: from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor ctx = ViewerContext(actor=Actor(type=ActorType.USER, actor_id=1)) @@ -204,7 +205,7 @@ def test_context_propagating_executor_does_not_leak_across_submissions(self): assert inside is ctx assert outside is None - def test_set_organization_updates_current_context(self): + def test_set_organization_updates_current_context(self) -> None: ctx = ViewerContext(actor=Actor(type=ActorType.USER, actor_id=1)) with viewer_context_scope(ctx): @@ -218,7 +219,7 @@ def test_set_organization_updates_current_context(self): assert get_viewer_context() is None - def test_set_organization_is_noop_without_context(self): + def test_set_organization_is_noop_without_context(self) -> None: set_viewer_context_organization(42) assert get_viewer_context() is None @@ -226,20 +227,23 @@ def test_set_organization_is_noop_without_context(self): class TestObserve: @pytest.fixture(autouse=True) - def patch_metrics(self): + def patch_metrics(self) -> Iterator[mock.MagicMock]: with ( mock.patch("sentry.viewer_context.random", return_value=0.0), mock.patch("sentry.viewer_context.sentry_sdk.metrics.count") as m, ): yield m - def _tags(self, m): + def _tags(self, m: mock.MagicMock) -> dict[str, object]: assert m.call_count == 1 args, kwargs = m.call_args assert args == ("viewer_context.observation", 1) - return kwargs["attributes"] + attributes = kwargs["attributes"] + if not isinstance(attributes, dict): + raise TypeError("viewer context attributes are not a mapping") + return attributes - def test_no_context_tags_actor_none(self, patch_metrics): + def test_no_context_tags_actor_none(self, patch_metrics: mock.MagicMock) -> None: observe_viewer_context_propagation("seer_rpc_out") tags = self._tags(patch_metrics) assert tags == { @@ -251,7 +255,9 @@ def test_no_context_tags_actor_none(self, patch_metrics): "sentry.client_sample_rate": 0.001, } - def test_with_context_tags_actor_and_presence_flags(self, patch_metrics): + def test_with_context_tags_actor_and_presence_flags( + self, patch_metrics: mock.MagicMock + ) -> None: ctx = ViewerContext(actor=Actor(type=ActorType.USER, actor_id=42), organization_id=7) with viewer_context_scope(ctx): observe_viewer_context_propagation("seer_rpc_out") @@ -260,7 +266,7 @@ def test_with_context_tags_actor_and_presence_flags(self, patch_metrics): assert tags["has_user_id"] == "true" assert tags["has_org_id"] == "true" - def test_partial_context_tags_individual_flags(self, patch_metrics): + def test_partial_context_tags_individual_flags(self, patch_metrics: mock.MagicMock) -> None: # User without org (e.g. cross-org admin lookup before org is resolved) ctx = ViewerContext(actor=Actor(type=ActorType.USER, actor_id=42)) with viewer_context_scope(ctx): @@ -269,7 +275,7 @@ def test_partial_context_tags_individual_flags(self, patch_metrics): assert tags["has_user_id"] == "true" assert tags["has_org_id"] == "false" - def test_explicit_ctx_overrides_contextvar(self, patch_metrics): + def test_explicit_ctx_overrides_contextvar(self, patch_metrics: mock.MagicMock) -> None: # Caller passes a resolved value that's distinct from the contextvar with viewer_context_scope(ViewerContext(actor=Actor(type=ActorType.SYSTEM))): observe_viewer_context_propagation( @@ -278,34 +284,40 @@ def test_explicit_ctx_overrides_contextvar(self, patch_metrics): tags = self._tags(patch_metrics) assert tags["actor_type"] == "integration" - def test_explicit_ctx_none_treated_as_missing(self, patch_metrics): + def test_explicit_ctx_none_treated_as_missing(self, patch_metrics: mock.MagicMock) -> None: # Distinguishes "ctx wasn't passed" (sentinel) from "ctx is explicitly None" with viewer_context_scope(ViewerContext(actor=Actor(type=ActorType.UNKNOWN))): observe_viewer_context_propagation("seer_rpc_out", ctx=None) tags = self._tags(patch_metrics) assert tags["actor_type"] == "none" - def test_expected_false_does_not_log_when_missing(self, patch_metrics, caplog): + def test_expected_false_does_not_log_when_missing( + self, patch_metrics: mock.MagicMock, caplog: pytest.LogCaptureFixture + ) -> None: observe_viewer_context_propagation("optional_point", expected=False) tags = self._tags(patch_metrics) assert tags["expected"] == "false" # No warning log for expected=False misses assert all(r.levelname != "WARNING" for r in caplog.records) - def test_expected_true_logs_when_missing(self, patch_metrics, caplog): + def test_expected_true_logs_when_missing( + self, patch_metrics: mock.MagicMock, caplog: pytest.LogCaptureFixture + ) -> None: import logging with caplog.at_level(logging.WARNING, logger="sentry.viewer_context"): observe_viewer_context_propagation("rpc_inbound") assert any(r.message == "viewer_context.missing" for r in caplog.records) - def test_expected_true_does_not_log_when_present(self, patch_metrics, caplog): + def test_expected_true_does_not_log_when_present( + self, patch_metrics: mock.MagicMock, caplog: pytest.LogCaptureFixture + ) -> None: ctx = ViewerContext(actor=Actor(type=ActorType.USER, actor_id=1)) with viewer_context_scope(ctx): observe_viewer_context_propagation("rpc_inbound") assert all(r.message != "viewer_context.missing" for r in caplog.records) - def test_extra_attributes_merged_into_metric_tags(self, patch_metrics): + def test_extra_attributes_merged_into_metric_tags(self, patch_metrics: mock.MagicMock) -> None: observe_viewer_context_propagation( "seer_rpc_in", extra_attributes={"method": "get_organization_slug"} ) @@ -313,7 +325,9 @@ def test_extra_attributes_merged_into_metric_tags(self, patch_metrics): assert tags["point"] == "seer_rpc_in" assert tags["method"] == "get_organization_slug" - def test_extra_attributes_cannot_override_fixed_tags(self, patch_metrics): + def test_extra_attributes_cannot_override_fixed_tags( + self, patch_metrics: mock.MagicMock + ) -> None: # Fixed tags must take precedence so callers can't accidentally swap # `point` or `actor_type` and corrupt the metric's cardinality story. ctx = ViewerContext(actor=Actor(type=ActorType.USER, actor_id=1)) @@ -326,7 +340,9 @@ def test_extra_attributes_cannot_override_fixed_tags(self, patch_metrics): assert tags["point"] == "seer_rpc_in" assert tags["method"] == "real_method" - def test_extra_attributes_propagate_to_missing_log(self, patch_metrics, caplog): + def test_extra_attributes_propagate_to_missing_log( + self, patch_metrics: mock.MagicMock, caplog: pytest.LogCaptureFixture + ) -> None: import logging with caplog.at_level(logging.WARNING, logger="sentry.viewer_context"): @@ -335,8 +351,8 @@ def test_extra_attributes_propagate_to_missing_log(self, patch_metrics, caplog): ) missing_records = [r for r in caplog.records if r.message == "viewer_context.missing"] assert len(missing_records) == 1 - assert missing_records[0].method == "ghost_method" - assert missing_records[0].point == "seer_rpc_in" + assert getattr(missing_records[0], "method") == "ghost_method" + assert getattr(missing_records[0], "point") == "seer_rpc_in" @pytest.mark.parametrize( @@ -349,12 +365,12 @@ def test_extra_attributes_propagate_to_missing_log(self, patch_metrics, caplog): {"version": 1, "organization_id": 42, "user_id": 7}, ], ) -def test_rejects_legacy_wire_after_cutover(wire): +def test_rejects_legacy_wire_after_cutover(wire: object) -> None: with pytest.raises(ValueError, match="Expected a V2 viewer context"): ViewerContext.deserialize(wire) -def test_actor_id_distinguishes_actor_types_and_delegates(): +def test_actor_id_distinguishes_actor_types_and_delegates() -> None: user = Actor(type=ActorType.USER, actor_id=7) service_account = Actor(type=ActorType.SERVICE_ACCOUNT, actor_id=7) agent = Actor(type=ActorType.AGENT, actor_id=7, delegated_user_id=8) @@ -375,9 +391,9 @@ def test_actor_id_distinguishes_actor_types_and_delegates(): (Actor(type=ActorType.SYSTEM, delegated_user_id=8), None), ], ) -def test_get_acting_user_id(actor, expected): +def test_get_acting_user_id(actor: Actor, expected: int | None) -> None: assert get_acting_user_id(ViewerContext(actor=actor)) == expected -def test_get_acting_user_id_without_context(): +def test_get_acting_user_id_without_context() -> None: assert get_acting_user_id(None) is None diff --git a/tests/sentry/test_viewer_context_jwt.py b/tests/sentry/test_viewer_context_jwt.py index 72604147a568..bb4bbd990dfa 100644 --- a/tests/sentry/test_viewer_context_jwt.py +++ b/tests/sentry/test_viewer_context_jwt.py @@ -2,6 +2,7 @@ import time from datetime import timedelta +from typing import Any import jwt as pyjwt import pytest @@ -26,11 +27,13 @@ class TestEncodeEarlyAdopterClaim(TestCase): - def _claims(self, vc: ViewerContext) -> dict: + def _claims( + self, vc: ViewerContext + ) -> dict[str, Any]: # justification: decoded JWT claims are dynamic JSON. return pyjwt.decode(encode_viewer_context(vc), "test-secret-key", algorithms=["HS256"]) @override_settings(SEER_API_SHARED_SECRET="test-secret-key") - def test_early_adopter_organization_sets_claim(self): + def test_early_adopter_organization_sets_claim(self) -> None: organization = self.create_organization() organization.flags.early_adopter = True organization.save() @@ -42,7 +45,7 @@ def test_early_adopter_organization_sets_claim(self): assert claims["organization_is_early_adopter"] is True @override_settings(SEER_API_SHARED_SECRET="test-secret-key") - def test_early_adopter_claim_in_control_silo(self): + def test_early_adopter_claim_in_control_silo(self) -> None: with outbox_runner(): organization = self.create_organization() organization.flags.early_adopter = True @@ -56,7 +59,7 @@ def test_early_adopter_claim_in_control_silo(self): assert claims["organization_is_early_adopter"] is True @override_settings(SEER_API_SHARED_SECRET="test-secret-key") - def test_regular_organization_omits_claim(self): + def test_regular_organization_omits_claim(self) -> None: organization = self.create_organization() claims = self._claims( @@ -66,7 +69,7 @@ def test_regular_organization_omits_claim(self): assert "organization_is_early_adopter" not in claims @override_settings(SEER_API_SHARED_SECRET="test-secret-key") - def test_missing_organization_omits_claim(self): + def test_missing_organization_omits_claim(self) -> None: claims = self._claims( ViewerContext(actor=Actor(type=ActorType.UNKNOWN), organization_id=987654321) ) @@ -74,7 +77,7 @@ def test_missing_organization_omits_claim(self): assert "organization_is_early_adopter" not in claims @override_settings(SEER_API_SHARED_SECRET="test-secret-key") - def test_no_organization_omits_claim(self): + def test_no_organization_omits_claim(self) -> None: claims = self._claims(ViewerContext(actor=Actor(type=ActorType.SYSTEM))) assert "organization_is_early_adopter" not in claims @@ -82,7 +85,7 @@ def test_no_organization_omits_claim(self): class TestEncodeDecodeRoundtrip(TestCase): @override_settings(SEER_API_SHARED_SECRET="test-secret-key") - def test_roundtrip(self): + def test_roundtrip(self) -> None: vc = ViewerContext( organization_id=42, actor=Actor(type=ActorType.SERVICE_ACCOUNT, actor_id=7) ) @@ -91,7 +94,7 @@ def test_roundtrip(self): assert result == vc @override_settings(SEER_API_SHARED_SECRET="test-secret-key") - def test_roundtrip_minimal(self): + def test_roundtrip_minimal(self) -> None: vc = ViewerContext(actor=Actor(type=ActorType.SYSTEM)) token = encode_viewer_context(vc) result = decode_viewer_context(token) @@ -102,7 +105,7 @@ def test_roundtrip_minimal(self): assert result.actor.type == ActorType.SYSTEM @override_settings(SEER_API_SHARED_SECRET="test-secret-key") - def test_roundtrip_integration(self): + def test_roundtrip_integration(self) -> None: vc = ViewerContext(actor=Actor(type=ActorType.INTEGRATION), organization_id=99) token = encode_viewer_context(vc) result = decode_viewer_context(token) @@ -114,7 +117,7 @@ def test_roundtrip_integration(self): class TestEncodeViewerContext(TestCase): @override_settings(SEER_API_SHARED_SECRET="test-secret-key") - def test_superuser_jwt_lifetime_cannot_exceed_five_minutes(self): + def test_superuser_jwt_lifetime_cannot_exceed_five_minutes(self) -> None: vc = ViewerContext( actor=Actor( type=ActorType.USER, @@ -127,7 +130,7 @@ def test_superuser_jwt_lifetime_cannot_exceed_five_minutes(self): assert claims["exp"] - claims["iat"] == 300 @override_settings(SEER_API_SHARED_SECRET="test-secret-key") - def test_superuser_deadline_is_not_renewed_when_reencoded(self): + def test_superuser_deadline_is_not_renewed_when_reencoded(self) -> None: deadline = int(time.time()) + 300 vc = ViewerContext( actor=Actor( @@ -143,7 +146,7 @@ def test_superuser_deadline_is_not_renewed_when_reencoded(self): assert claims["exp"] == deadline @override_settings(SEER_API_SHARED_SECRET="test-secret-key") - def test_standard_claims_present(self): + def test_standard_claims_present(self) -> None: vc = ViewerContext(actor=Actor(type=ActorType.UNKNOWN), organization_id=1) token = encode_viewer_context(vc) @@ -154,14 +157,14 @@ def test_standard_claims_present(self): assert claims["exp"] - claims["iat"] == 900 # default TTL @override_settings(SEER_API_SHARED_SECRET="test-secret-key") - def test_custom_ttl(self): + def test_custom_ttl(self) -> None: vc = ViewerContext(actor=Actor(type=ActorType.UNKNOWN), organization_id=1) token = encode_viewer_context(vc, ttl=300) claims = pyjwt.decode(token, options={"verify_signature": False}) assert claims["exp"] - claims["iat"] == 300 - def test_custom_key_parameter(self): + def test_custom_key_parameter(self) -> None: vc = ViewerContext(actor=Actor(type=ActorType.UNKNOWN), organization_id=1) token = encode_viewer_context(vc, key="my-custom-key") result = decode_viewer_context(token, key="my-custom-key") @@ -169,13 +172,13 @@ def test_custom_key_parameter(self): assert result.organization_id == 1 @override_settings(SEER_API_SHARED_SECRET="") - def test_no_key_raises_value_error(self): + def test_no_key_raises_value_error(self) -> None: vc = ViewerContext(actor=Actor(type=ActorType.UNKNOWN), organization_id=1) with pytest.raises(ValueError, match="No signing key available"): encode_viewer_context(vc) @override_settings(SEER_API_SHARED_SECRET="test-secret-key") - def test_token_field_not_in_jwt(self): + def test_token_field_not_in_jwt(self) -> None: vc = ViewerContext(actor=Actor(type=ActorType.UNKNOWN), organization_id=1) jwt_token = encode_viewer_context(vc) @@ -188,7 +191,7 @@ def test_token_field_not_in_jwt(self): class TestDecodeViewerContext(TestCase): @override_settings(SEER_API_SHARED_SECRET="test-secret-key") - def test_expired_token_rejected(self): + def test_expired_token_rejected(self) -> None: payload = { "organization_id": 1, "version": 2, @@ -208,14 +211,14 @@ def test_expired_token_rejected(self): with pytest.raises(pyjwt.exceptions.ExpiredSignatureError): decode_viewer_context(expired_token) - def test_wrong_key_rejected(self): + def test_wrong_key_rejected(self) -> None: vc = ViewerContext(actor=Actor(type=ActorType.UNKNOWN), organization_id=1) token = encode_viewer_context(vc, key="correct-key") with pytest.raises(pyjwt.exceptions.InvalidTokenError, match="No verification key"): decode_viewer_context(token, key="wrong-key") - def test_leeway_accepts_nearly_expired(self): + def test_leeway_accepts_nearly_expired(self) -> None: payload = { "organization_id": 1, "version": 2, @@ -233,7 +236,7 @@ def test_leeway_accepts_nearly_expired(self): assert result.organization_id == 1 @override_settings(SEER_API_SHARED_SECRET="") - def test_no_verification_key_raises(self): + def test_no_verification_key_raises(self) -> None: vc = ViewerContext(actor=Actor(type=ActorType.UNKNOWN), organization_id=1) token = encode_viewer_context(vc, key="some-key") @@ -242,11 +245,11 @@ def test_no_verification_key_raises(self): class TestIsJwtViewerContext(TestCase): - def test_jwt_string(self): + def test_jwt_string(self) -> None: assert is_jwt_viewer_context("eyJhbGciOiJIUzI1NiJ9.eyJmb28iOiJiYXIifQ.sig") is True - def test_json_string(self): + def test_json_string(self) -> None: assert is_jwt_viewer_context('{"actor_type": "user", "organization_id": 1}') is False - def test_empty_string(self): + def test_empty_string(self) -> None: assert is_jwt_viewer_context("") is False diff --git a/tests/sentry/testutils/pytest/test_transactional_flush.py b/tests/sentry/testutils/pytest/test_transactional_flush.py index ddc6dab27dca..d05161a8126c 100644 --- a/tests/sentry/testutils/pytest/test_transactional_flush.py +++ b/tests/sentry/testutils/pytest/test_transactional_flush.py @@ -172,7 +172,7 @@ def truncate_waits() -> bool: [cleanup_pid], ) query, wait_event_type = cursor.fetchone() - return query.startswith("TRUNCATE") and wait_event_type == "Lock" + return bool(query.startswith("TRUNCATE") and wait_event_type == "Lock") _wait_for(truncate_waits, "Django's flush to wait for the writer") cursor.execute("COMMIT") diff --git a/tests/sentry/toolbar/utils/test_url.py b/tests/sentry/toolbar/utils/test_url.py index 20d4ebaa4435..99b4bedb3652 100644 --- a/tests/sentry/toolbar/utils/test_url.py +++ b/tests/sentry/toolbar/utils/test_url.py @@ -26,7 +26,7 @@ def test_url_matches_scheme(referrer: str, target: str) -> None: ("https://example.com", "http://example.com"), ], ) -def test_url_matches_rejects_mispatched_scheme(referrer, target) -> None: +def test_url_matches_rejects_mispatched_scheme(referrer: str, target: str) -> None: assert not url_matches(urlparse(referrer), target) diff --git a/tests/sentry/toolbar/views/test_iframe_view.py b/tests/sentry/toolbar/views/test_iframe_view.py index 7a48b52a186f..459d6ea6e7d9 100644 --- a/tests/sentry/toolbar/views/test_iframe_view.py +++ b/tests/sentry/toolbar/views/test_iframe_view.py @@ -1,3 +1,4 @@ +from django.http import HttpResponse from django.test import override_settings from django.urls import reverse from django.utils.html import escapejs @@ -103,13 +104,14 @@ def test_no_referrer(self) -> None: assert f"const state = '{escapejs('invalid-domain')}';" in res.content.decode("utf-8") -def _get_csp_parts(response): +def _get_csp_parts(response: HttpResponse) -> list[str]: # Fallback to `c` for tests in dev. csp = response.headers.get("Content-Security-Policy", response.headers.get("c")) + assert csp is not None return [chunk.strip() for chunk in csp.split(";")] -def _has_nonce(response): +def _has_nonce(response: HttpResponse) -> bool: content = response.content.decode("utf-8") # Middleware automatically injects the `nonce` attribute onto our