diff --git a/sentry_sdk/crons.py b/sentry_sdk/crons.py deleted file mode 100644 index e652460df4..0000000000 --- a/sentry_sdk/crons.py +++ /dev/null @@ -1,123 +0,0 @@ -from functools import wraps -import sys -import uuid - -from sentry_sdk import Hub -from sentry_sdk._compat import reraise -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.utils import nanosecond_time - - -if TYPE_CHECKING: - from typing import Any, Callable, Dict, Optional - - -class MonitorStatus: - IN_PROGRESS = "in_progress" - OK = "ok" - ERROR = "error" - - -def _create_checkin_event( - monitor_slug=None, check_in_id=None, status=None, duration=None -): - # type: (Optional[str], Optional[str], Optional[str], Optional[float]) -> Dict[str, Any] - options = Hub.current.client.options if Hub.current.client else {} - check_in_id = check_in_id or uuid.uuid4().hex # type: str - # convert nanosecond to millisecond - duration = int(duration * 0.000001) if duration is not None else duration - - checkin = { - "type": "check_in", - "monitor_slug": monitor_slug, - # TODO: Add schedule and schedule_type to monitor config - # "monitor_config": { - # "schedule": "*/10 0 0 0 0", - # "schedule_type": "cron", - # }, - "check_in_id": check_in_id, - "status": status, - "duration": duration, - "environment": options["environment"], - "release": options["release"], - } - - return checkin - - -def capture_checkin(monitor_slug=None, check_in_id=None, status=None, duration=None): - # type: (Optional[str], Optional[str], Optional[str], Optional[float]) -> str - hub = Hub.current - - check_in_id = check_in_id or uuid.uuid4().hex - checkin_event = _create_checkin_event( - monitor_slug=monitor_slug, - check_in_id=check_in_id, - status=status, - duration=duration, - ) - hub.capture_event(checkin_event) - - return checkin_event["check_in_id"] - - -def monitor(monitor_slug=None, app=None): - # type: (Optional[str], Any) -> Callable[..., Any] - """ - Decorator to capture checkin events for a monitor. - - Usage: - ``` - import sentry_sdk - - app = Celery() - - @app.task - @sentry_sdk.monitor(monitor_slug='my-fancy-slug') - def test(arg): - print(arg) - ``` - - This does not have to be used with Celery, but if you do use it with celery, - put the `@sentry_sdk.monitor` decorator below Celery's `@app.task` decorator. - """ - - def decorate(func): - # type: (Callable[..., Any]) -> Callable[..., Any] - if not monitor_slug: - return func - - @wraps(func) - def wrapper(*args, **kwargs): - # type: (*Any, **Any) -> Any - start_timestamp = nanosecond_time() - check_in_id = capture_checkin( - monitor_slug=monitor_slug, status=MonitorStatus.IN_PROGRESS - ) - - try: - result = func(*args, **kwargs) - except Exception: - duration = nanosecond_time() - start_timestamp - capture_checkin( - monitor_slug=monitor_slug, - check_in_id=check_in_id, - status=MonitorStatus.ERROR, - duration=duration, - ) - exc_info = sys.exc_info() - reraise(*exc_info) - - duration = nanosecond_time() - start_timestamp - capture_checkin( - monitor_slug=monitor_slug, - check_in_id=check_in_id, - status=MonitorStatus.OK, - duration=duration, - ) - - return result - - return wrapper - - return decorate diff --git a/sentry_sdk/crons/__init__.py b/sentry_sdk/crons/__init__.py new file mode 100644 index 0000000000..5d1fe357d2 --- /dev/null +++ b/sentry_sdk/crons/__init__.py @@ -0,0 +1,3 @@ +from sentry_sdk.crons.api import capture_checkin # noqa +from sentry_sdk.crons.consts import MonitorStatus # noqa +from sentry_sdk.crons.decorator import monitor # noqa diff --git a/sentry_sdk/crons/api.py b/sentry_sdk/crons/api.py new file mode 100644 index 0000000000..aba523ea37 --- /dev/null +++ b/sentry_sdk/crons/api.py @@ -0,0 +1,56 @@ +import uuid + +from sentry_sdk import Hub +from sentry_sdk._types import TYPE_CHECKING + + +if TYPE_CHECKING: + from typing import Any, Dict, Optional + + +def _create_check_in_event( + monitor_slug=None, + check_in_id=None, + status=None, + duration_s=None, + monitor_config=None, +): + # type: (Optional[str], Optional[str], Optional[str], Optional[float], Optional[Dict[str, Any]]) -> Dict[str, Any] + options = Hub.current.client.options if Hub.current.client else {} + check_in_id = check_in_id or uuid.uuid4().hex # type: str + + check_in = { + "type": "check_in", + "monitor_slug": monitor_slug, + "monitor_config": monitor_config or {}, + "check_in_id": check_in_id, + "status": status, + "duration": duration_s, + "environment": options.get("environment", None), + "release": options.get("release", None), + } + + return check_in + + +def capture_checkin( + monitor_slug=None, + check_in_id=None, + status=None, + duration=None, + monitor_config=None, +): + # type: (Optional[str], Optional[str], Optional[str], Optional[float], Optional[Dict[str, Any]]) -> str + hub = Hub.current + + check_in_id = check_in_id or uuid.uuid4().hex + check_in_event = _create_check_in_event( + monitor_slug=monitor_slug, + check_in_id=check_in_id, + status=status, + duration_s=duration, + monitor_config=monitor_config, + ) + hub.capture_event(check_in_event) + + return check_in_event["check_in_id"] diff --git a/sentry_sdk/crons/consts.py b/sentry_sdk/crons/consts.py new file mode 100644 index 0000000000..be686b4539 --- /dev/null +++ b/sentry_sdk/crons/consts.py @@ -0,0 +1,4 @@ +class MonitorStatus: + IN_PROGRESS = "in_progress" + OK = "ok" + ERROR = "error" diff --git a/sentry_sdk/crons/decorator.py b/sentry_sdk/crons/decorator.py new file mode 100644 index 0000000000..41ff6d2b02 --- /dev/null +++ b/sentry_sdk/crons/decorator.py @@ -0,0 +1,74 @@ +from functools import wraps +import sys + +from sentry_sdk._compat import reraise +from sentry_sdk._types import TYPE_CHECKING +from sentry_sdk.crons import capture_checkin +from sentry_sdk.crons.consts import MonitorStatus +from sentry_sdk.utils import now + + +if TYPE_CHECKING: + from typing import Any, Callable, Optional + + +def monitor(monitor_slug=None): + # type: (Optional[str]) -> Callable[..., Any] + """ + Decorator to capture checkin events for a monitor. + + Usage: + ``` + import sentry_sdk + + app = Celery() + + @app.task + @sentry_sdk.monitor(monitor_slug='my-fancy-slug') + def test(arg): + print(arg) + ``` + + This does not have to be used with Celery, but if you do use it with celery, + put the `@sentry_sdk.monitor` decorator below Celery's `@app.task` decorator. + """ + + def decorate(func): + # type: (Callable[..., Any]) -> Callable[..., Any] + if not monitor_slug: + return func + + @wraps(func) + def wrapper(*args, **kwargs): + # type: (*Any, **Any) -> Any + start_timestamp = now() + check_in_id = capture_checkin( + monitor_slug=monitor_slug, status=MonitorStatus.IN_PROGRESS + ) + + try: + result = func(*args, **kwargs) + except Exception: + duration_s = now() - start_timestamp + capture_checkin( + monitor_slug=monitor_slug, + check_in_id=check_in_id, + status=MonitorStatus.ERROR, + duration=duration_s, + ) + exc_info = sys.exc_info() + reraise(*exc_info) + + duration_s = now() - start_timestamp + capture_checkin( + monitor_slug=monitor_slug, + check_in_id=check_in_id, + status=MonitorStatus.OK, + duration=duration_s, + ) + + return result + + return wrapper + + return decorate diff --git a/sentry_sdk/integrations/celery.py b/sentry_sdk/integrations/celery.py index f8541fa0b2..d69dd467bb 100644 --- a/sentry_sdk/integrations/celery.py +++ b/sentry_sdk/integrations/celery.py @@ -1,26 +1,34 @@ from __future__ import absolute_import import sys -from sentry_sdk.consts import OP +import shutil +import functools +from sentry_sdk.consts import OP +from sentry_sdk._compat import reraise +from sentry_sdk._functools import wraps +from sentry_sdk.crons import capture_checkin, MonitorStatus from sentry_sdk.hub import Hub -from sentry_sdk.tracing import TRANSACTION_SOURCE_TASK +from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations.logging import ignore_logger +from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_TASK +from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, + logger, + now, ) -from sentry_sdk.tracing import Transaction -from sentry_sdk._compat import reraise -from sentry_sdk.integrations import Integration, DidNotEnable -from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk._functools import wraps if TYPE_CHECKING: from typing import Any - from typing import TypeVar from typing import Callable + from typing import Dict + from typing import List from typing import Optional + from typing import Tuple + from typing import TypeVar + from typing import Union from sentry_sdk._types import EventProcessor, Event, Hint, ExcInfo @@ -29,13 +37,23 @@ try: from celery import VERSION as CELERY_VERSION + from celery import Task, Celery + from celery.app.trace import task_has_custom + from celery.beat import Service # type: ignore from celery.exceptions import ( # type: ignore - SoftTimeLimitExceeded, - Retry, Ignore, Reject, + Retry, + SoftTimeLimitExceeded, + ) + from celery.schedules import crontab, schedule # type: ignore + from celery.signals import ( # type: ignore + beat_init, + task_prerun, + task_failure, + task_success, + task_retry, ) - from celery.app.trace import task_has_custom except ImportError: raise DidNotEnable("Celery not installed") @@ -46,10 +64,13 @@ class CeleryIntegration(Integration): identifier = "celery" - def __init__(self, propagate_traces=True): - # type: (bool) -> None + def __init__(self, propagate_traces=True, monitor_beat_tasks=False): + # type: (bool, bool) -> None self.propagate_traces = propagate_traces + if monitor_beat_tasks: + _patch_celery_beat_tasks() + @staticmethod def setup_once(): # type: () -> None @@ -294,3 +315,253 @@ def sentry_workloop(*args, **kwargs): hub.flush() Worker.workloop = sentry_workloop + + +def _get_headers(task): + # type: (Task) -> Dict[str, Any] + headers = task.request.get("headers") or {} + return headers + + +def _get_humanized_interval(seconds): + # type: (float) -> Tuple[int, str] + TIME_UNITS = ( # noqa: N806 + ("day", 60 * 60 * 24.0), + ("hour", 60 * 60.0), + ("minute", 60.0), + ) + + seconds = float(seconds) + for unit, divider in TIME_UNITS: + if seconds >= divider: + interval = int(seconds / divider) + return (interval, unit) + + return (1, "minute") + + +def _get_monitor_config(celery_schedule, app): + # type: (Any, Celery) -> Dict[str, Any] + monitor_config = {} # type: Dict[str, Any] + schedule_type = None # type: Optional[str] + schedule_value = None # type: Optional[Union[str, int]] + schedule_unit = None # type: Optional[str] + + if isinstance(celery_schedule, crontab): + schedule_type = "crontab" + schedule_value = ( + "{0._orig_minute} " + "{0._orig_hour} " + "{0._orig_day_of_month} " + "{0._orig_month_of_year} " + "{0._orig_day_of_week}".format(celery_schedule) + ) + elif isinstance(celery_schedule, schedule): + schedule_type = "interval" + (schedule_value, schedule_unit) = _get_humanized_interval( + celery_schedule.seconds + ) + + else: + logger.warning( + "Celery schedule type '%s' not supported by Sentry Crons.", + type(celery_schedule), + ) + return {} + + monitor_config["schedule"] = {} + monitor_config["schedule"]["type"] = schedule_type + monitor_config["schedule"]["value"] = schedule_value + + if schedule_unit is not None: + monitor_config["schedule"]["unit"] = schedule_unit + + monitor_config["timezone"] = app.conf.timezone or "UTC" + + return monitor_config + + +def _reinstall_patched_tasks(app, sender, add_updated_periodic_tasks): + # type: (Celery, Service, List[functools.partial[Any]]) -> None + + # Stop Celery Beat + sender.stop() + + # Update tasks to include Monitor information in headers + for add_updated_periodic_task in add_updated_periodic_tasks: + add_updated_periodic_task() + + # Start Celery Beat (with new (cloned) schedule, because old one is still in use) + new_schedule_filename = sender.schedule_filename + ".new" + shutil.copy2(sender.schedule_filename, new_schedule_filename) + app.Beat(schedule=new_schedule_filename).run() + + +# Nested functions do not work as Celery hook receiver, +# so defining it here explicitly +celery_beat_init = None + + +def _patch_celery_beat_tasks(): + # type: () -> None + + global celery_beat_init + + def celery_beat_init(sender, **kwargs): + # type: (Service, Dict[Any, Any]) -> None + + # Because we restart Celery Beat, + # make sure that this will not be called infinitely + beat_init.disconnect(celery_beat_init) + + app = sender.app + + add_updated_periodic_tasks = [] + + for name in sender.scheduler.schedule.keys(): + # Ignore Celery's internal tasks + if name.startswith("celery."): + continue + + monitor_name = name + + schedule_entry = sender.scheduler.schedule[name] + celery_schedule = schedule_entry.schedule + monitor_config = _get_monitor_config(celery_schedule, app) + + if monitor_config is None: + continue + + headers = schedule_entry.options.pop("headers", {}) + headers.update( + { + "headers": { + "sentry-monitor-slug": monitor_name, + "sentry-monitor-config": monitor_config, + }, + } + ) + + task_signature = app.tasks.get(schedule_entry.task).s() + task_signature.set(headers=headers) + + logger.debug( + "Set up Sentry Celery Beat monitoring for %s (%s)", + task_signature, + monitor_name, + ) + + add_updated_periodic_tasks.append( + functools.partial( + app.add_periodic_task, + celery_schedule, + task_signature, + args=schedule_entry.args, + kwargs=schedule_entry.kwargs, + name=schedule_entry.name, + **(schedule_entry.options or {}) + ) + ) + + _reinstall_patched_tasks(app, sender, add_updated_periodic_tasks) + + beat_init.connect(celery_beat_init) + task_prerun.connect(crons_task_before_run) + task_success.connect(crons_task_success) + task_failure.connect(crons_task_failure) + task_retry.connect(crons_task_retry) + + +def crons_task_before_run(sender, **kwargs): + # type: (Task, Dict[Any, Any]) -> None + logger.debug("celery_task_before_run %s", sender) + headers = _get_headers(sender) + + if "sentry-monitor-slug" not in headers: + return + + monitor_config = ( + headers["sentry-monitor-config"] if "sentry-monitor-config" in headers else {} + ) + + start_timestamp_s = now() + + check_in_id = capture_checkin( + monitor_slug=headers["sentry-monitor-slug"], + monitor_config=monitor_config, + status=MonitorStatus.IN_PROGRESS, + ) + + headers.update({"sentry-monitor-check-in-id": check_in_id}) + headers.update({"sentry-monitor-start-timestamp-s": start_timestamp_s}) + + sender.s().set(headers=headers) + + +def crons_task_success(sender, **kwargs): + # type: (Task, Dict[Any, Any]) -> None + logger.debug("celery_task_success %s", sender) + headers = _get_headers(sender) + + if "sentry-monitor-slug" not in headers: + return + + monitor_config = ( + headers["sentry-monitor-config"] if "sentry-monitor-config" in headers else {} + ) + + start_timestamp_s = headers["sentry-monitor-start-timestamp-s"] + + capture_checkin( + monitor_slug=headers["sentry-monitor-slug"], + monitor_config=monitor_config, + check_in_id=headers["sentry-monitor-check-in-id"], + duration=now() - start_timestamp_s, + status=MonitorStatus.OK, + ) + + +def crons_task_failure(sender, **kwargs): + # type: (Task, Dict[Any, Any]) -> None + logger.debug("celery_task_failure %s", sender) + headers = _get_headers(sender) + + if "sentry-monitor-slug" not in headers: + return + + monitor_config = ( + headers["sentry-monitor-config"] if "sentry-monitor-config" in headers else {} + ) + + start_timestamp_s = headers["sentry-monitor-start-timestamp-s"] + + capture_checkin( + monitor_slug=headers["sentry-monitor-slug"], + monitor_config=monitor_config, + check_in_id=headers["sentry-monitor-check-in-id"], + duration=now() - start_timestamp_s, + status=MonitorStatus.ERROR, + ) + + +def crons_task_retry(sender, **kwargs): + # type: (Task, Dict[Any, Any]) -> None + logger.debug("celery_task_retry %s", sender) + headers = _get_headers(sender) + + if "sentry-monitor-slug" not in headers: + return + + monitor_config = ( + headers["sentry-monitor-config"] if "sentry-monitor-config" in headers else {} + ) + + start_timestamp_s = headers["sentry-monitor-start-timestamp-s"] + + capture_checkin( + monitor_slug=headers["sentry-monitor-slug"], + monitor_config=monitor_config, + check_in_id=headers["sentry-monitor-check-in-id"], + duration=now() - start_timestamp_s, + status=MonitorStatus.ERROR, + ) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 7091513ed9..cc91e37448 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -1311,3 +1311,16 @@ def nanosecond_time(): def nanosecond_time(): # type: () -> int raise AttributeError + + +if PY2: + + def now(): + # type: () -> float + return time.time() + +else: + + def now(): + # type: () -> float + return time.perf_counter() diff --git a/tests/integrations/celery/__init__.py b/tests/integrations/celery/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/celery/test_celery_beat_crons.py b/tests/integrations/celery/test_celery_beat_crons.py new file mode 100644 index 0000000000..8c99faef39 --- /dev/null +++ b/tests/integrations/celery/test_celery_beat_crons.py @@ -0,0 +1,288 @@ +import mock + +import pytest + +pytest.importorskip("celery") + +from sentry_sdk.integrations.celery import ( + _get_headers, + _get_humanized_interval, + _get_monitor_config, + _reinstall_patched_tasks, + crons_task_before_run, + crons_task_success, + crons_task_failure, + crons_task_retry, +) +from sentry_sdk.crons import MonitorStatus +from celery.schedules import crontab, schedule + + +def test_get_headers(): + fake_task = mock.MagicMock() + fake_task.request = { + "bla": "blub", + "foo": "bar", + } + + assert _get_headers(fake_task) == {} + + fake_task.request.update( + { + "headers": { + "bla": "blub", + }, + } + ) + + assert _get_headers(fake_task) == {"bla": "blub"} + + +@pytest.mark.parametrize( + "seconds, expected_tuple", + [ + (0, (1, "minute")), + (0.00001, (1, "minute")), + (1, (1, "minute")), + (100, (1, "minute")), + (1000, (16, "minute")), + (10000, (2, "hour")), + (100000, (1, "day")), + (100000000, (1157, "day")), + ], +) +def test_get_humanized_interval(seconds, expected_tuple): + assert _get_humanized_interval(seconds) == expected_tuple + + +def test_crons_task_before_run(): + fake_task = mock.MagicMock() + fake_task.request = { + "headers": { + "sentry-monitor-slug": "test123", + "sentry-monitor-config": { + "schedule": { + "type": "interval", + "value": 3, + "unit": "day", + }, + "timezone": "Europe/Vienna", + }, + "sentry-monitor-some-future-key": "some-future-value", + }, + } + + with mock.patch( + "sentry_sdk.integrations.celery.capture_checkin" + ) as mock_capture_checkin: + crons_task_before_run(fake_task) + + mock_capture_checkin.assert_called_once_with( + monitor_slug="test123", + monitor_config={ + "schedule": { + "type": "interval", + "value": 3, + "unit": "day", + }, + "timezone": "Europe/Vienna", + }, + status=MonitorStatus.IN_PROGRESS, + ) + + +def test_crons_task_success(): + fake_task = mock.MagicMock() + fake_task.request = { + "headers": { + "sentry-monitor-slug": "test123", + "sentry-monitor-check-in-id": "1234567890", + "sentry-monitor-start-timestamp-s": 200.1, + "sentry-monitor-config": { + "schedule": { + "type": "interval", + "value": 3, + "unit": "day", + }, + "timezone": "Europe/Vienna", + }, + "sentry-monitor-some-future-key": "some-future-value", + }, + } + + with mock.patch( + "sentry_sdk.integrations.celery.capture_checkin" + ) as mock_capture_checkin: + with mock.patch("sentry_sdk.integrations.celery.now", return_value=500.5): + crons_task_success(fake_task) + + mock_capture_checkin.assert_called_once_with( + monitor_slug="test123", + monitor_config={ + "schedule": { + "type": "interval", + "value": 3, + "unit": "day", + }, + "timezone": "Europe/Vienna", + }, + duration=300.4, + check_in_id="1234567890", + status=MonitorStatus.OK, + ) + + +def test_crons_task_failure(): + fake_task = mock.MagicMock() + fake_task.request = { + "headers": { + "sentry-monitor-slug": "test123", + "sentry-monitor-check-in-id": "1234567890", + "sentry-monitor-start-timestamp-s": 200.1, + "sentry-monitor-config": { + "schedule": { + "type": "interval", + "value": 3, + "unit": "day", + }, + "timezone": "Europe/Vienna", + }, + "sentry-monitor-some-future-key": "some-future-value", + }, + } + + with mock.patch( + "sentry_sdk.integrations.celery.capture_checkin" + ) as mock_capture_checkin: + with mock.patch("sentry_sdk.integrations.celery.now", return_value=500.5): + crons_task_failure(fake_task) + + mock_capture_checkin.assert_called_once_with( + monitor_slug="test123", + monitor_config={ + "schedule": { + "type": "interval", + "value": 3, + "unit": "day", + }, + "timezone": "Europe/Vienna", + }, + duration=300.4, + check_in_id="1234567890", + status=MonitorStatus.ERROR, + ) + + +def test_crons_task_retry(): + fake_task = mock.MagicMock() + fake_task.request = { + "headers": { + "sentry-monitor-slug": "test123", + "sentry-monitor-check-in-id": "1234567890", + "sentry-monitor-start-timestamp-s": 200.1, + "sentry-monitor-config": { + "schedule": { + "type": "interval", + "value": 3, + "unit": "day", + }, + "timezone": "Europe/Vienna", + }, + "sentry-monitor-some-future-key": "some-future-value", + }, + } + + with mock.patch( + "sentry_sdk.integrations.celery.capture_checkin" + ) as mock_capture_checkin: + with mock.patch("sentry_sdk.integrations.celery.now", return_value=500.5): + crons_task_retry(fake_task) + + mock_capture_checkin.assert_called_once_with( + monitor_slug="test123", + monitor_config={ + "schedule": { + "type": "interval", + "value": 3, + "unit": "day", + }, + "timezone": "Europe/Vienna", + }, + duration=300.4, + check_in_id="1234567890", + status=MonitorStatus.ERROR, + ) + + +def test_get_monitor_config(): + app = mock.MagicMock() + app.conf = mock.MagicMock() + app.conf.timezone = "Europe/Vienna" + + celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10") + + monitor_config = _get_monitor_config(celery_schedule, app) + assert monitor_config == { + "schedule": { + "type": "crontab", + "value": "*/10 12 3 * *", + }, + "timezone": "Europe/Vienna", + } + assert "unit" not in monitor_config["schedule"] + + celery_schedule = schedule(run_every=3) + + monitor_config = _get_monitor_config(celery_schedule, app) + assert monitor_config == { + "schedule": { + "type": "interval", + "value": 1, + "unit": "minute", + }, + "timezone": "Europe/Vienna", + } + + unknown_celery_schedule = mock.MagicMock() + monitor_config = _get_monitor_config(unknown_celery_schedule, app) + assert monitor_config == {} + + +def test_get_monitor_config_default_timezone(): + app = mock.MagicMock() + app.conf = mock.MagicMock() + app.conf.timezone = None + + celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10") + + monitor_config = _get_monitor_config(celery_schedule, app) + + assert monitor_config["timezone"] == "UTC" + + +def test_reinstall_patched_tasks(): + fake_beat = mock.MagicMock() + fake_beat.run = mock.MagicMock() + + app = mock.MagicMock() + app.Beat = mock.MagicMock(return_value=fake_beat) + + sender = mock.MagicMock() + sender.schedule_filename = "test_schedule_filename" + sender.stop = mock.MagicMock() + + add_updated_periodic_tasks = [mock.MagicMock(), mock.MagicMock(), mock.MagicMock()] + + with mock.patch("sentry_sdk.integrations.celery.shutil.copy2") as mock_copy2: + _reinstall_patched_tasks(app, sender, add_updated_periodic_tasks) + + sender.stop.assert_called_once_with() + + add_updated_periodic_tasks[0].assert_called_once_with() + add_updated_periodic_tasks[1].assert_called_once_with() + add_updated_periodic_tasks[2].assert_called_once_with() + + mock_copy2.assert_called_once_with( + "test_schedule_filename", "test_schedule_filename.new" + ) + fake_beat.run.assert_called_once_with() diff --git a/tests/test_crons.py b/tests/test_crons.py index dd632a315a..d79e79c57d 100644 --- a/tests/test_crons.py +++ b/tests/test_crons.py @@ -20,7 +20,9 @@ def _break_world(name): def test_decorator(sentry_init): sentry_init() - with mock.patch("sentry_sdk.crons.capture_checkin") as fake_capture_checking: + with mock.patch( + "sentry_sdk.crons.decorator.capture_checkin" + ) as fake_capture_checking: result = _hello_world("Grace") assert result == "Hello, Grace" @@ -41,7 +43,9 @@ def test_decorator(sentry_init): def test_decorator_error(sentry_init): sentry_init() - with mock.patch("sentry_sdk.crons.capture_checkin") as fake_capture_checking: + with mock.patch( + "sentry_sdk.crons.decorator.capture_checkin" + ) as fake_capture_checking: with pytest.raises(Exception): result = _break_world("Grace") diff --git a/tox.ini b/tox.ini index 24d1cd3b40..bc522578f0 100644 --- a/tox.ini +++ b/tox.ini @@ -336,6 +336,7 @@ deps = pyramid-v1.10: pyramid>=1.10,<1.11 # Quart + quart: blinker<1.6 quart: quart>=0.16.1 quart: quart-auth quart: pytest-asyncio @@ -380,6 +381,7 @@ deps = sanic-v21: sanic>=21.0,<22.0 sanic-v22: sanic>=22.0,<22.9.0 + sanic: websockets<11.0 sanic: aiohttp sanic-v21: sanic_testing<22 sanic-v22: sanic_testing<22.9.0 @@ -507,8 +509,9 @@ commands = ; Running `py.test` as an executable suffers from an import error ; when loading tests in scenarios. In particular, django fails to ; load the settings from the test module. + {py2.7}: python -m pytest --ignore-glob='*py3.py' --durations=5 -vvv {env:TESTPATH} {posargs} - {py3.5,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}: python -m pytest --durations=5 -vvv {env:TESTPATH} {posargs} + {py3.5,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}: python -m pytest -rsx --durations=5 -vvv {env:TESTPATH} {posargs} [testenv:linters] commands =