diff --git a/deal/_runtime/_contracts.py b/deal/_runtime/_contracts.py index a024bef8..b56ec61f 100644 --- a/deal/_runtime/_contracts.py +++ b/deal/_runtime/_contracts.py @@ -53,6 +53,8 @@ def __init__(self, func: F) -> None: @classmethod def attach(cls, contract_type: str, validator: 'Validator', func: F) -> F: + if state.removed: + return func contracts = cls._ensure_wrapped(func) validator.function = func getattr(contracts, contract_type).append(validator) @@ -60,6 +62,8 @@ def attach(cls, contract_type: str, validator: 'Validator', func: F) -> F: @classmethod def attach_has(cls, patcher: 'HasPatcher', func: F) -> F: + if state.removed: + return func contracts = cls._ensure_wrapped(func) contracts.patcher = patcher return contracts.wrapped diff --git a/deal/_runtime/_inherit.py b/deal/_runtime/_inherit.py index 2770eecc..23b39ab9 100644 --- a/deal/_runtime/_inherit.py +++ b/deal/_runtime/_inherit.py @@ -2,6 +2,7 @@ from types import FunctionType, MethodType from typing import Callable, Generic, Optional, TypeVar +from .._state import state from ._contracts import Contracts @@ -22,6 +23,9 @@ def __init__(self, func: F) -> None: @classmethod def wrap(cls, target): + if state.removed: + return target + # wrap function if not isinstance(target, type): return cls(target) diff --git a/deal/_state.py b/deal/_state.py index a58544b0..5d731a07 100644 --- a/deal/_state.py +++ b/deal/_state.py @@ -1,38 +1,85 @@ import os -from typing import Callable, TypeVar +import warnings +from types import MappingProxyType +from typing import Callable, Mapping, TypeVar T = TypeVar('T', bound=Callable) +PERMAMENT_ERROR = RuntimeError('contracts are permanently disabled') +PROD_ENV = MappingProxyType(dict( + LAMBDA_TASK_ROOT='AWS', + GCLOUD_PROJECT='GCP', +)) +TEST_ENV = MappingProxyType(dict( + PYTEST_CURRENT_TEST='pytest', + CI='CI', +)) class _State: - __slots__ = ('debug', 'color') + __slots__ = ('debug', 'removed', 'color') debug: bool + removed: bool color: bool def __init__(self) -> None: + self.removed = False self.reset() def reset(self) -> None: - """Restore contracts switch to default. + """Restore contracts state to the default. All contracts are disabled on production by default. See [runtime][runtime] documentation. [runtime]: https://deal.readthedocs.io/basic/runtime.html """ + if self.removed: + raise PERMAMENT_ERROR self.debug = __debug__ self.color = 'NO_COLOR' not in os.environ - def enable(self) -> None: + def enable(self, warn: bool = True) -> None: """Enable all contracts. + + By default, deal will do a few sanity checks to make sure you haven't + unintentionally enabled contracts on a production environment. + Pass `warn=False` to disable this behavior. """ + if self.removed: + raise PERMAMENT_ERROR self.debug = True + if warn: + if not __debug__: + msg = 'It is production but deal is enabled. Is it intentional?' + warnings.warn(msg, category=RuntimeWarning) + else: + self._warn_if(PROD_ENV, 'enabled') - def disable(self) -> None: + def disable(self, *, permament: bool = False, warn: bool = True) -> None: """Disable all contracts. + + If `permament=True`, contracts are permanently disabled + for the current interpreter runtime and cannot be turned on again. + + By default, deal will do a few sanity checks to make sure you haven't + unintentionally disabled contracts on a test environment. + Pass `warn=False` to disable this behavior. """ + if self.removed and permament: + raise PERMAMENT_ERROR self.debug = False + self.removed = permament + if warn: + self._warn_if(TEST_ENV, 'disabled') + + def _warn_if(self, env_vars: Mapping[str, str], state: str) -> None: + for var, env in env_vars.items(): + if not os.environ.get(var): + continue + msg = f'It is {env} but deal is {state}. Is it intentional?' + warnings.warn(msg, category=RuntimeWarning) + return state = _State() diff --git a/deal/linter/_extractors/exceptions.py b/deal/linter/_extractors/exceptions.py index c1872717..5f1a6508 100644 --- a/deal/linter/_extractors/exceptions.py +++ b/deal/linter/_extractors/exceptions.py @@ -11,6 +11,7 @@ from .common import TOKENS, Extractor, Token, get_full_name, get_name, get_stub, infer from .contracts import get_contracts + try: import docstring_parser except ImportError: diff --git a/docs/basic/runtime.md b/docs/basic/runtime.md index 390e16d5..5b6305e0 100644 --- a/docs/basic/runtime.md +++ b/docs/basic/runtime.md @@ -6,12 +6,12 @@ Call the functions, do usual tests, just play around with the application, deplo ## Contracts on production -If you run Python with `-O` option, all contracts will be disabled. This is uses Python's `__debug__` variable: +If you run Python with `-O` option, all contracts will be disabled. Under the hood, it's controlled by the `__debug__` variable: > The built-in variable `__debug__` is True under normal circumstances, False when optimization is requested (command line option -O). > Source: [Python documentation](https://docs.python.org/3/reference/simple_stmts.html#assert) -Also, you can explicitly enable or disable contracts: +If needed, you can also explicitly enable or disable contracts in runtime: ```python # disable all contracts @@ -25,6 +25,16 @@ deal.enable() deal.reset() ``` +It's easy to mess up with the contracts' state when you change it manually. To help you a bit, deal will emit a [RuntimeWarning](https://docs.python.org/3/library/warnings.html) if you accidentally enable contracts in production or disable them in tests. If you've got this warning and you know what you're doing, pass `warn=False` to skip this check. + +## Permamently disable contracts + +When contracts are disabled, functions are still get wrapped in case you want to enable contracts again, after all functions already initialized. That means, even if you disable contracts, there is still a small overhead in runtime that might be critical in for some applications. To avoid it and tell deal to disable contracts permanently, call `deal.disable(permament=True)`. There is what you should know: + +1. If you permamently disable the contracts, you cannot enable them back anymore. Trying to do so will raise `RuntimeError`. +1. This flag is checked only when functions are decorated, so you need to call it before importing any decorated functions. +1. Functions that were decorated before you permamently disabled contracts will behave in the same way as if you just called `deal.disable()`, with a quick check of the state in runtime on each call. + ## Colors If no error message or custom exception specified for a contract, deal will show contract source code and passed parameters as the exception message. By default, deal highlights syntax for this source code. If your terminal doesn't support colors (which is possible on CI), you can specify `NO_COLOR` environment variable to disable syntax highlighting: diff --git a/tests/test_linter/test_extractors/test_exceptions.py b/tests/test_linter/test_extractors/test_exceptions.py index f2a9fd21..23574183 100644 --- a/tests/test_linter/test_extractors/test_exceptions.py +++ b/tests/test_linter/test_extractors/test_exceptions.py @@ -1,6 +1,6 @@ import ast -from pathlib import Path import sys +from pathlib import Path from textwrap import dedent from typing import Dict diff --git a/tests/test_state.py b/tests/test_state.py index e5e9404d..7cd959b5 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -1,12 +1,29 @@ +import os + import pytest import deal +import deal.introspection from deal._imports import deactivate +from deal._state import state from .test_runtime.helpers import run_sync -def test_contract_state_switch_custom_param(): +@pytest.fixture +def restore_state(): + state.reset() + yield + state.removed = False + state.reset() + deactivate() + + +def count_contracts(func) -> int: + return len(list(deal.introspection.get_contracts(func))) + + +def test_contract_state_switch_custom_param(restore_state): func = deal.pre(lambda x: x > 0)(lambda x: x * 2) deal.disable() func(-2) @@ -15,7 +32,7 @@ def test_contract_state_switch_custom_param(): func(-2) -def test_contract_state_switch_default_param(): +def test_contract_state_switch_default_param(restore_state): func = deal.pre(lambda x: x > 0)(lambda x: x * 2) deal.disable() assert func(-2) == -4 @@ -24,7 +41,7 @@ def test_contract_state_switch_default_param(): func(-2) -def test_contract_state_switch_default_param_async(): +def test_contract_state_switch_default_param_async(restore_state): @deal.pre(lambda x: x > 0) async def func(x): return x * 2 @@ -36,7 +53,7 @@ async def func(x): run_sync(func(-2)) -def test_contract_state_switch_default_param_generator(): +def test_contract_state_switch_default_param_generator(restore_state): @deal.pre(lambda x: x > 0) def func(x): yield x * 2 @@ -48,37 +65,109 @@ def func(x): list(func(-2)) -def test_state_switch_module_load(): +def test_state_disable_permament(restore_state): + @deal.pre(lambda x: x > 0) + @deal.inherit + @deal.pure + def func1(x): + yield x * 2 + + deal.disable(permament=True) + + @deal.pre(lambda x: x > 0) + @deal.inherit + @deal.pure + def func2(x): + yield x * 2 + + assert count_contracts(func1) == 3 + assert count_contracts(func2) == 0 + + +def test_state_disable_permament__cant_disable_twice(restore_state): + deal.disable(permament=True) + with pytest.raises(RuntimeError): + deal.disable(permament=True) with pytest.raises(RuntimeError): - deal.module_load() - try: - deal.disable() - deal.activate() - deal.module_load() - finally: - deactivate() deal.enable() + with pytest.raises(RuntimeError): + deal.reset() -def test_state_switch_module_load_debug(): +def test_state_switch_module_load(restore_state): with pytest.raises(RuntimeError): deal.module_load() - try: - deal.disable() - deal.activate() - deal.enable() - finally: - deactivate() - deal.reset() + deal.disable() + deal.activate() + deal.module_load() + +def test_state_switch_module_load_debug(restore_state): + with pytest.raises(RuntimeError): + deal.module_load() + deal.disable() + deal.activate() + deal.enable() -def test_state_switch_activate(): - try: - assert deal.activate() - assert deactivate() - deal.disable() - assert not deal.activate() - finally: - deactivate() +def test_state_switch_activate(restore_state): + assert deal.activate() + assert deactivate() + + deal.disable() + assert not deal.activate() + + +@pytest.fixture +def set_env_vars(): + old_vars = os.environ.copy() + yield os.environ.update + os.environ.clear() + os.environ.update(old_vars) + + +@pytest.mark.parametrize('env_vars, expected', [ + (dict(), None), + (dict(CI='true'), None), + (dict(GCLOUD_PROJECT='example'), 'It is GCP but deal is enabled'), + (dict(LAMBDA_TASK_ROOT='/home/'), 'It is AWS but deal is enabled'), +]) +def test_enable__warnings(restore_state, env_vars, set_env_vars, expected): + os.environ.clear() + set_env_vars(env_vars) + ewarn = RuntimeWarning if expected else None + with pytest.warns(ewarn) as warns: deal.enable() + if expected: + assert len(warns) == 1 + assert str(warns[0].message) == f'{expected}. Is it intentional?' + else: + assert len(warns) == 0 + + with pytest.warns(None) as warns: + deal.enable(warn=False) + assert len(warns) == 0 + + +@pytest.mark.parametrize('env_vars, expected', [ + (dict(), None), + (dict(GCLOUD_PROJECT='example'), None), + (dict(LAMBDA_TASK_ROOT='/home/'), None), + (dict(CI='true'), 'It is CI but deal is disabled'), + (dict(PYTEST_CURRENT_TEST='test_example'), 'It is pytest but deal is disabled'), +]) +def test_disable__warnings(restore_state, env_vars, set_env_vars, expected): + os.environ.clear() + set_env_vars(env_vars) + ewarn = RuntimeWarning if expected else None + with pytest.warns(ewarn) as warns: + deal.disable() + if expected: + assert len(warns) == 1 + assert str(warns[0].message) == f'{expected}. Is it intentional?' + else: + assert len(warns) == 0 + + with pytest.warns(None) as warns: + deal.disable(warn=False) + assert len(warns) == 0