Skip to content

Commit

Permalink
Merge pull request #107 from life4/permament-disable
Browse files Browse the repository at this point in the history
Allow permamently disabling contracts
  • Loading branch information
orsinium committed Apr 9, 2022
2 parents 81cc0b3 + 87e0530 commit 85d3381
Show file tree
Hide file tree
Showing 7 changed files with 191 additions and 36 deletions.
4 changes: 4 additions & 0 deletions deal/_runtime/_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,17 @@ 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)
return contracts.wrapped

@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
Expand Down
4 changes: 4 additions & 0 deletions deal/_runtime/_inherit.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from types import FunctionType, MethodType
from typing import Callable, Generic, Optional, TypeVar

from .._state import state
from ._contracts import Contracts


Expand All @@ -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)
Expand Down
57 changes: 52 additions & 5 deletions deal/_state.py
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
1 change: 1 addition & 0 deletions deal/linter/_extractors/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 12 additions & 2 deletions docs/basic/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_linter/test_extractors/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import ast
from pathlib import Path
import sys
from pathlib import Path
from textwrap import dedent
from typing import Dict

Expand Down
145 changes: 117 additions & 28 deletions tests/test_state.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

0 comments on commit 85d3381

Please sign in to comment.