Exemplo n.º 1
0
from sentry_sdk import capture_exception

from zerver.lib.logging_util import log_to_file
from zerver.models import (
    Message,
    Realm,
    RealmAuditLog,
    Recipient,
    Subscription,
    UserActivity,
    UserMessage,
    UserProfile,
)

logger = logging.getLogger("zulip.soft_deactivation")
log_to_file(logger, settings.SOFT_DEACTIVATION_LOG_PATH)
BULK_CREATE_BATCH_SIZE = 10000


def filter_by_subscription_history(
    user_profile: UserProfile,
    all_stream_messages: DefaultDict[int, List[Message]],
    all_stream_subscription_logs: DefaultDict[int, List[RealmAuditLog]],
) -> List[UserMessage]:
    user_messages_to_insert: List[UserMessage] = []

    def store_user_message_to_insert(message: Message) -> None:
        message = UserMessage(user_profile=user_profile,
                              message_id=message["id"],
                              flags=0)
        user_messages_to_insert.append(message)
Exemplo n.º 2
0
from typing import Union, Any, Callable, Sequence, Dict, Optional, TypeVar, Tuple, cast
from zerver.lib.logging_util import log_to_file

# This is a hack to ensure that RemoteZulipServer always exists even
# if Zilencer isn't enabled.
if settings.ZILENCER_ENABLED:
    from zilencer.models import get_remote_server_by_uuid, RemoteZulipServer
else:  # nocoverage # Hack here basically to make impossible code paths compile
    from mock import Mock
    get_remote_server_by_uuid = Mock()
    RemoteZulipServer = Mock()  # type: ignore # https://github.com/JukkaL/mypy/issues/1188

ReturnT = TypeVar('ReturnT')

webhook_logger = logging.getLogger("zulip.zerver.webhooks")
log_to_file(webhook_logger, settings.API_KEY_ONLY_WEBHOOK_LOG_PATH)

class _RespondAsynchronously:
    pass

# Return RespondAsynchronously from an @asynchronous view if the
# response will be provided later by calling handler.zulip_finish(),
# or has already been provided this way. We use this for longpolling
# mode.
RespondAsynchronously = _RespondAsynchronously()

AsyncWrapperT = Callable[..., Union[HttpResponse, _RespondAsynchronously]]
def asynchronous(method: Callable[..., Union[HttpResponse, _RespondAsynchronously]]) -> AsyncWrapperT:
    # TODO: this should be the correct annotation when mypy gets fixed: type:
    #   (Callable[[HttpRequest, base.BaseHandler, Sequence[Any], Dict[str, Any]],
    #     Union[HttpResponse, _RespondAsynchronously]]) ->
Exemplo n.º 3
0
from zerver.lib.logging_util import log_to_file

# This is a hack to ensure that RemoteZulipServer always exists even
# if Zilencer isn't enabled.
if settings.ZILENCER_ENABLED:
    from zilencer.models import get_remote_server_by_uuid, RemoteZulipServer
else:
    from mock import Mock
    get_remote_server_by_uuid = Mock()
    RemoteZulipServer = Mock(
    )  # type: ignore # https://github.com/JukkaL/mypy/issues/1188

ReturnT = TypeVar('ReturnT')

webhook_logger = logging.getLogger("zulip.zerver.webhooks")
log_to_file(webhook_logger, settings.API_KEY_ONLY_WEBHOOK_LOG_PATH)


class _RespondAsynchronously:
    pass


# Return RespondAsynchronously from an @asynchronous view if the
# response will be provided later by calling handler.zulip_finish(),
# or has already been provided this way. We use this for longpolling
# mode.
RespondAsynchronously = _RespondAsynchronously()

AsyncWrapperT = Callable[..., Union[HttpResponse, _RespondAsynchronously]]

Exemplo n.º 4
0
# Documented in https://zulip.readthedocs.io/en/latest/subsystems/sending-messages.html#soft-deactivation

from zerver.lib.logging_util import log_to_file
from collections import defaultdict
import logging
from django.db import transaction
from django.db.models import Max
from django.conf import settings
from django.utils.timezone import now as timezone_now
from typing import DefaultDict, List, Union, Any

from zerver.models import UserProfile, UserMessage, RealmAuditLog, \
    Subscription, Message, Recipient, UserActivity

logger = logging.getLogger("zulip.soft_deactivation")
log_to_file(logger, settings.SOFT_DEACTIVATION_LOG_PATH)

def filter_by_subscription_history(user_profile: UserProfile,
                                   all_stream_messages: DefaultDict[int, List[Message]],
                                   all_stream_subscription_logs: DefaultDict[int, List[RealmAuditLog]],
                                   ) -> List[UserMessage]:
    user_messages_to_insert = []  # type: List[UserMessage]

    def store_user_message_to_insert(message: Message) -> None:
        message = UserMessage(user_profile=user_profile,
                              message_id=message['id'], flags=0)
        user_messages_to_insert.append(message)

    for (stream_id, stream_messages) in all_stream_messages.items():
        stream_subscription_logs = all_stream_subscription_logs[stream_id]
Exemplo n.º 5
0
from datetime import timedelta
from typing import Any

from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils.timezone import now as timezone_now

from zerver.actions.message_send import build_message_send_dict, do_send_messages
from zerver.lib.logging_util import log_to_file
from zerver.lib.message import SendMessageRequest
from zerver.models import Message, ScheduledMessage, get_user_by_delivery_email

## Setup ##
logger = logging.getLogger(__name__)
log_to_file(logger, settings.DELIVER_SCHEDULED_MESSAGES_LOG_PATH)


class Command(BaseCommand):
    help = """Deliver scheduled messages from the ScheduledMessage table.
Run this command under supervisor.

This management command is run via supervisor.

Usage: ./manage.py deliver_scheduled_messages
"""

    def construct_message(
            self, scheduled_message: ScheduledMessage) -> SendMessageRequest:
        message = Message()
        original_sender = scheduled_message.sender
Exemplo n.º 6
0
import datetime
from email.utils import parseaddr, formataddr
import logging
import ujson

import os
from typing import Any, Dict, Iterable, List, Mapping, Optional

from zerver.lib.logging_util import log_to_file
from confirmation.models import generate_key

## Logging setup ##

logger = logging.getLogger('zulip.send_email')
log_to_file(logger, settings.EMAIL_LOG_PATH)


class FromAddress:
    SUPPORT = parseaddr(settings.ZULIP_ADMINISTRATOR)[1]
    NOREPLY = parseaddr(settings.NOREPLY_EMAIL_ADDRESS)[1]

    # Generates an unpredictable noreply address.
    @staticmethod
    def tokenized_no_reply_address() -> str:
        if settings.ADD_TOKENS_TO_NOREPLY_ADDRESS:
            return parseaddr(
                settings.TOKENIZED_NOREPLY_EMAIL_ADDRESS)[1].format(
                    token=generate_key())
        return FromAddress.NOREPLY
Exemplo n.º 7
0
from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
from zerver.lib.utils import generate_random_token
from zerver.models import Realm, UserProfile, RealmAuditLog
from corporate.models import Customer, CustomerPlan, LicenseLedger, \
    get_current_plan_by_customer, get_customer_by_realm, \
    get_current_plan_by_realm
from zproject.config import get_secret

STRIPE_PUBLISHABLE_KEY = get_secret('stripe_publishable_key')
stripe.api_key = get_secret('stripe_secret_key')

BILLING_LOG_PATH = os.path.join(
    '/var/log/zulip' if not settings.DEVELOPMENT else
    settings.DEVELOPMENT_LOG_DIRECTORY, 'billing.log')
billing_logger = logging.getLogger('corporate.stripe')
log_to_file(billing_logger, BILLING_LOG_PATH)
log_to_file(logging.getLogger('stripe'), BILLING_LOG_PATH)

CallableT = TypeVar('CallableT', bound=Callable[..., Any])

MIN_INVOICED_LICENSES = 30
MAX_INVOICED_LICENSES = 1000
DEFAULT_INVOICE_DAYS_UNTIL_DUE = 30


def get_latest_seat_count(realm: Realm) -> int:
    non_guests = UserProfile.objects.filter(
        realm=realm, is_active=True,
        is_bot=False).exclude(role=UserProfile.ROLE_GUEST).count()
    guests = UserProfile.objects.filter(realm=realm,
                                        is_active=True,
Exemplo n.º 8
0
import datetime
from email.utils import parseaddr, formataddr
import logging
import ujson

import os
from typing import Any, Dict, Iterable, List, Mapping, Optional

from zerver.lib.logging_util import log_to_file
from confirmation.models import generate_key

## Logging setup ##

logger = logging.getLogger('zulip.send_email')
log_to_file(logger, settings.EMAIL_LOG_PATH)

class FromAddress:
    SUPPORT = parseaddr(settings.ZULIP_ADMINISTRATOR)[1]
    NOREPLY = parseaddr(settings.NOREPLY_EMAIL_ADDRESS)[1]

    # Generates an unpredictable noreply address.
    @staticmethod
    def tokenized_no_reply_address() -> str:
        if settings.ADD_TOKENS_TO_NOREPLY_ADDRESS:
            return parseaddr(settings.TOKENIZED_NOREPLY_EMAIL_ADDRESS)[1].format(token=generate_key())
        return FromAddress.NOREPLY

def build_email(template_prefix: str, to_user_id: Optional[int]=None,
                to_email: Optional[str]=None, from_name: Optional[str]=None,
                from_address: Optional[str]=None, reply_to_email: Optional[str]=None,
Exemplo n.º 9
0
from zerver.lib.logging_util import log_to_file
from zerver.lib.timestamp import ceiling_to_day, ceiling_to_hour, floor_to_hour, verify_UTC
from zerver.models import (
    Message,
    Realm,
    RealmAuditLog,
    Stream,
    UserActivityInterval,
    UserProfile,
    models,
)

## Logging setup ##

logger = logging.getLogger('zulip.management')
log_to_file(logger, settings.ANALYTICS_LOG_PATH)

# You can't subtract timedelta.max from a datetime, so use this instead
TIMEDELTA_MAX = timedelta(days=365 * 1000)

## Class definitions ##


class CountStat:
    HOUR = 'hour'
    DAY = 'day'
    FREQUENCIES = frozenset([HOUR, DAY])

    def __init__(self,
                 property: str,
                 data_collector: 'DataCollector',
Exemplo n.º 10
0
from typing import Any

from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils.timezone import now as timezone_now
from ujson import loads

from zerver.lib.context_managers import lockfile
from zerver.lib.logging_util import log_to_file
from zerver.lib.management import sleep_forever
from zerver.lib.send_email import EmailNotDeliveredException, send_email
from zerver.models import ScheduledEmail

## Setup ##
logger = logging.getLogger(__name__)
log_to_file(logger, settings.EMAIL_DELIVERER_LOG_PATH)

class Command(BaseCommand):
    help = """Deliver emails queued by various parts of Zulip
(either for immediate sending or sending at a specified time).

Run this command under supervisor. This is for SMTP email delivery.

Usage: ./manage.py deliver_email
"""

    def handle(self, *args: Any, **options: Any) -> None:

        if settings.EMAIL_DELIVERER_DISABLED:
            sleep_forever()
Exemplo n.º 11
0
from django.utils.timezone import now as timezone_now
from psycopg2.sql import Composable, Identifier, Literal, SQL

from zerver.lib.logging_util import log_to_file
from zerver.models import (Message, UserMessage, ArchivedUserMessage, Realm,
                           Attachment, ArchivedAttachment, Reaction,
                           ArchivedReaction, SubMessage, ArchivedSubMessage,
                           Recipient, Stream, ArchiveTransaction,
                           get_user_including_cross_realm)

from typing import Any, Dict, List, Optional

import logging

logger = logging.getLogger('zulip.retention')
log_to_file(logger, settings.RETENTION_LOG_PATH)

MESSAGE_BATCH_SIZE = 1000

models_with_message_key: List[Dict[str, Any]] = [
    {
        'class': Reaction,
        'archive_class': ArchivedReaction,
        'table_name': 'zerver_reaction',
        'archive_table_name': 'zerver_archivedreaction'
    },
    {
        'class': SubMessage,
        'archive_class': ArchivedSubMessage,
        'table_name': 'zerver_submessage',
        'archive_table_name': 'zerver_archivedsubmessage'
Exemplo n.º 12
0
import logging

from argparse import ArgumentParser
from typing import Any, List


from django.conf import settings

from zerver.lib.logging_util import log_to_file
from zerver.lib.management import ZulipBaseCommand
from zerver.models import UserProfile
from zproject.backends import ZulipLDAPException, sync_user_from_ldap

## Setup ##
logger = logging.getLogger('zulip.sync_ldap_user_data')
log_to_file(logger, settings.LDAP_SYNC_LOG_PATH)

# Run this on a cronjob to pick up on name changes.
def sync_ldap_user_data(user_profiles: List[UserProfile]) -> None:
    logger.info("Starting update.")
    for u in user_profiles:
        # This will save the user if relevant, and will do nothing if the user
        # does not exist.
        try:
            if sync_user_from_ldap(u):
                logger.info("Updated %s." % (u.email,))
            else:
                logger.warning("Did not find %s in LDAP." % (u.email,))
                if settings.LDAP_DEACTIVATE_NON_MATCHING_USERS:
                    logger.info("Deactivated non-matching user: %s" % (u.email,))
        except ZulipLDAPException as e:
Exemplo n.º 13
0
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils.timezone import now as timezone_now

from zerver.lib.context_managers import lockfile
from zerver.lib.logging_util import log_to_file
from zerver.lib.management import sleep_forever
from zerver.models import ScheduledMessage, Message, get_user
from zerver.lib.actions import do_send_messages
from zerver.lib.addressee import Addressee

## Setup ##
logger = logging.getLogger(__name__)
log_to_file(logger, settings.SCHEDULED_MESSAGE_DELIVERER_LOG_PATH)

class Command(BaseCommand):
    help = """Deliver scheduled messages from the ScheduledMessage table.
Run this command under supervisor.

This management command is run via supervisor.  Do not run on multiple
machines, as you may encounter multiple sends in a specific race
condition.  (Alternatively, you can set `EMAIL_DELIVERER_DISABLED=True`
on all but one machine to make the command have no effect.)

Usage: ./manage.py deliver_scheduled_messages
"""

    def construct_message(self, scheduled_message: ScheduledMessage) -> Dict[str, Any]:
        message = Message()
Exemplo n.º 14
0
from django.db import connection
from django.db.models import F

from analytics.models import BaseCount, \
    FillState, InstallationCount, RealmCount, StreamCount, \
    UserCount, installation_epoch, last_successful_fill
from zerver.lib.logging_util import log_to_file
from zerver.lib.timestamp import ceiling_to_day, \
    ceiling_to_hour, floor_to_hour, verify_UTC
from zerver.models import Message, Realm, \
    Stream, UserActivityInterval, UserProfile, models

## Logging setup ##

logger = logging.getLogger('zulip.management')
log_to_file(logger, settings.ANALYTICS_LOG_PATH)

# You can't subtract timedelta.max from a datetime, so use this instead
TIMEDELTA_MAX = timedelta(days=365*1000)

## Class definitions ##

class CountStat:
    HOUR = 'hour'
    DAY = 'day'
    FREQUENCIES = frozenset([HOUR, DAY])

    def __init__(self, property: str, data_collector: 'DataCollector', frequency: str,
                 interval: Optional[timedelta]=None) -> None:
        self.property = property
        self.data_collector = data_collector
Exemplo n.º 15
0
from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
from zerver.lib.utils import generate_random_token
from zerver.models import Realm, UserProfile, RealmAuditLog
from corporate.models import Customer, CustomerPlan, LicenseLedger, \
    get_active_plan
from zproject.settings import get_secret

STRIPE_PUBLISHABLE_KEY = get_secret('stripe_publishable_key')
stripe.api_key = get_secret('stripe_secret_key')

BILLING_LOG_PATH = os.path.join('/var/log/zulip'
                                if not settings.DEVELOPMENT
                                else settings.DEVELOPMENT_LOG_DIRECTORY,
                                'billing.log')
billing_logger = logging.getLogger('corporate.stripe')
log_to_file(billing_logger, BILLING_LOG_PATH)
log_to_file(logging.getLogger('stripe'), BILLING_LOG_PATH)

CallableT = TypeVar('CallableT', bound=Callable[..., Any])

MIN_INVOICED_LICENSES = 30
DEFAULT_INVOICE_DAYS_UNTIL_DUE = 30

def get_seat_count(realm: Realm) -> int:
    non_guests = UserProfile.objects.filter(
        realm=realm, is_active=True, is_bot=False, is_guest=False).count()
    guests = UserProfile.objects.filter(
        realm=realm, is_active=True, is_bot=False, is_guest=True).count()
    return max(non_guests, math.ceil(guests / 5))

def sign_string(string: str) -> Tuple[str, str]:
Exemplo n.º 16
0
from django.template import loader
from django.conf import settings
from django.utils.timezone import now as timezone_now

from zerver.lib.notifications import build_message_list, hash_util_encode, \
    one_click_unsubscribe_link
from zerver.lib.send_email import send_future_email, FromAddress
from zerver.models import UserProfile, UserMessage, Recipient, Stream, \
    Subscription, UserActivity, get_active_streams, get_user_profile_by_id, \
    Realm
from zerver.context_processors import common_context
from zerver.lib.queue import queue_json_publish
from zerver.lib.logging_util import log_to_file

logger = logging.getLogger(__name__)
log_to_file(logger, settings.DIGEST_LOG_PATH)

VALID_DIGEST_DAY = 1  # Tuesdays
DIGEST_CUTOFF = 5

# Digests accumulate 4 types of interesting traffic for a user:
# 1. Missed PMs
# 2. New streams
# 3. New users
# 4. Interesting stream traffic, as determined by the longest and most
#    diversely comment upon topics.

def inactive_since(user_profile: UserProfile, cutoff: datetime.datetime) -> bool:
    # Hasn't used the app in the last DIGEST_CUTOFF (5) days.
    most_recent_visit = [row.last_visit for row in
                         UserActivity.objects.filter(
Exemplo n.º 17
0
from datetime import timedelta

from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils.timezone import now as timezone_now

from zerver.lib.context_managers import lockfile
from zerver.lib.logging_util import log_to_file
from zerver.models import ScheduledMessage, Message
from zerver.lib.actions import do_send_messages
from zerver.lib.addressee import Addressee

## Setup ##
logger = logging.getLogger(__name__)
log_to_file(logger, settings.SCHEDULED_MESSAGE_DELIVERER_LOG_PATH)


class Command(BaseCommand):
    help = """Deliver scheduled messages from the ScheduledMessage table.
Run this command under supervisor.

Usage: ./manage.py deliver_scheduled_messages
"""

    def construct_message(
            self, scheduled_message: ScheduledMessage) -> Dict[str, Any]:
        message = Message()
        message.sender = scheduled_message.sender
        message.content = scheduled_message.content
        message.recipient = scheduled_message.recipient
Exemplo n.º 18
0
import logging
from argparse import ArgumentParser
from typing import Any, List

from django.conf import settings
from django.db import transaction

from zerver.lib.logging_util import log_to_file
from zerver.lib.management import ZulipBaseCommand
from zerver.models import UserProfile
from zproject.backends import ZulipLDAPException, sync_user_from_ldap

## Setup ##
logger = logging.getLogger('zulip.sync_ldap_user_data')
log_to_file(logger, settings.LDAP_SYNC_LOG_PATH)


# Run this on a cronjob to pick up on name changes.
def sync_ldap_user_data(user_profiles: List[UserProfile],
                        deactivation_protection: bool = True) -> None:
    logger.info("Starting update.")
    with transaction.atomic():
        realms = {u.realm.string_id for u in user_profiles}

        for u in user_profiles:
            # This will save the user if relevant, and will do nothing if the user
            # does not exist.
            try:
                sync_user_from_ldap(u, logger)
            except ZulipLDAPException as e:
                logger.error("Error attempting to update user %s:",
Exemplo n.º 19
0
import logging
import time
from typing import Any

from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils.timezone import now as timezone_now

from zerver.lib.logging_util import log_to_file
from zerver.lib.management import sleep_forever
from zerver.lib.send_email import EmailNotDeliveredException, deliver_scheduled_emails
from zerver.models import ScheduledEmail

## Setup ##
logger = logging.getLogger(__name__)
log_to_file(logger, settings.EMAIL_DELIVERER_LOG_PATH)


class Command(BaseCommand):
    help = """Send emails queued by various parts of Zulip
for later delivery.

Run this command under supervisor.

Usage: ./manage.py deliver_scheduled_emails
"""

    def handle(self, *args: Any, **options: Any) -> None:

        if settings.EMAIL_DELIVERER_DISABLED:
            sleep_forever()
Exemplo n.º 20
0
from zerver.lib.logging_util import log_to_file

# This is a hack to ensure that RemoteZulipServer always exists even
# if Zilencer isn't enabled.
if settings.ZILENCER_ENABLED:
    from zilencer.models import get_remote_server_by_uuid, RemoteZulipServer
else:  # nocoverage # Hack here basically to make impossible code paths compile
    from mock import Mock
    get_remote_server_by_uuid = Mock()
    RemoteZulipServer = Mock(
    )  # type: ignore[misc] # https://github.com/JukkaL/mypy/issues/1188

ReturnT = TypeVar('ReturnT')

webhook_logger = logging.getLogger("zulip.zerver.webhooks")
log_to_file(webhook_logger, settings.API_KEY_ONLY_WEBHOOK_LOG_PATH)

webhook_unexpected_events_logger = logging.getLogger(
    "zulip.zerver.lib.webhooks.common")
log_to_file(webhook_unexpected_events_logger,
            settings.WEBHOOK_UNEXPECTED_EVENTS_LOG_PATH)


def cachify(method: Callable[..., ReturnT]) -> Callable[..., ReturnT]:
    dct: Dict[Tuple[Any, ...], ReturnT] = {}

    def cache_wrapper(*args: Any) -> ReturnT:
        tup = tuple(args)
        if tup in dct:
            return dct[tup]
        result = method(*args)
Exemplo n.º 21
0
from django.conf import settings
from django.utils.timezone import now as timezone_now

from confirmation.models import one_click_unsubscribe_link
from zerver.lib.email_notifications import build_message_list
from zerver.lib.send_email import send_future_email, FromAddress
from zerver.lib.url_encoding import encode_stream
from zerver.models import UserProfile, Recipient, Subscription, UserActivity, \
    get_active_streams, get_user_profile_by_id, Realm, Message, RealmAuditLog
from zerver.context_processors import common_context
from zerver.lib.queue import queue_json_publish
from zerver.lib.logging_util import log_to_file

logger = logging.getLogger(__name__)
log_to_file(logger, settings.DIGEST_LOG_PATH)

DIGEST_CUTOFF = 5

# Digests accumulate 2 types of interesting traffic for a user:
# 1. New streams
# 2. Interesting stream traffic, as determined by the longest and most
#    diversely comment upon topics.


def inactive_since(user_profile: UserProfile,
                   cutoff: datetime.datetime) -> bool:
    # Hasn't used the app in the last DIGEST_CUTOFF (5) days.
    most_recent_visit = [
        row.last_visit
        for row in UserActivity.objects.filter(user_profile=user_profile)
Exemplo n.º 22
0
from zerver.lib.queue import queue_json_publish
from zerver.lib.rate_limiter import RateLimitedUser
from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_error, json_method_not_allowed, json_success, json_unauthorized
from zerver.lib.subdomains import get_subdomain, user_matches_subdomain
from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
from zerver.lib.types import ViewFuncT
from zerver.lib.user_agent import parse_user_agent
from zerver.lib.utils import has_api_key_format, statsd
from zerver.models import Realm, UserProfile, get_client, get_user_profile_by_api_key

if settings.ZILENCER_ENABLED:
    from zilencer.models import RemoteZulipServer, get_remote_server_by_uuid

webhook_logger = logging.getLogger("zulip.zerver.webhooks")
log_to_file(webhook_logger, settings.WEBHOOK_LOG_PATH)

webhook_unsupported_events_logger = logging.getLogger(
    "zulip.zerver.webhooks.unsupported")
log_to_file(webhook_unsupported_events_logger,
            settings.WEBHOOK_UNSUPPORTED_EVENTS_LOG_PATH)

FuncT = TypeVar('FuncT', bound=Callable[..., object])


def cachify(method: FuncT) -> FuncT:
    dct: Dict[Tuple[object, ...], object] = {}

    def cache_wrapper(*args: object) -> object:
        tup = tuple(args)
        if tup in dct: