icontract Documentation

repository·master·Indexed 19 days ago

https://github.com/parquery/icontract

A design-by-contract library for Python 3 that provides informative violation messages and supports contract inheritance. It allows developers to define pre-conditions using require(), post-conditions using ensure(), and class invariants using invariant(), all of which raise a ViolationError upon failure. The library supports synchronous and asynchronous contracts, state capture via snapshot(), and provides tools for monitoring invariant checks through InvariantCheckEvent.

Tokens
13.1K
Snippets
46
Records
62
Agent score
64%

What's inside icontract

  1. Overview of icontract and its ecosystem

    master

    icontract implements the design-by-contract paradigm for Python 3. It provides informative violation messages (including source code of the condition and variable values) and supports contract inheritance (weakening preconditions, strengthening postconditions/invariants).

    Key ecosystem tools include:

    • Linting: pyicontract-lint
    • Documentation: sphinx-icontract
    • Automated Testing: icontract-hypothesis (infers Hypothesis strategies from contracts) with IDE integrations for Vim, PyCharm, and VSCode.
    • Formal Verification: Integrated with CrossHair for automatic verification, with IDE integrations for PyCharm and VSCode.
    • Web APIs: fastapi-icontract for enforcing contracts in FastAPI and exposing them in OpenAPI/Swagger schemas.
  2. Async invariants and dunder methods

    master
    Invariants used to wrap dunder methods (like __init__) cannot be asynchronous. Most dunder methods must remain synchronous, and wrapping them with async code would violate Python's constraints. However, you can use synchronous invariants on async methods without issue.
  3. How precondition and postcondition decorators are stacked

    master

    When applying multiple @icontract.require (preconditions) or @icontract.postcondition decorators to a single function, icontract does not create multiple wrappers. Instead, it applies a single checker function to the function. The individual contracts are stored in the checker's __preconditions__ and __postconditions__ attributes, respectively. At runtime, the checker iterates through these attributes to verify all contracts.

    Important: Execution Order and Custom Decorators icontract uses the __wrapped__ attribute to find the checker function. This means contracts are verified at the innermost decorator level in the stack. If you use custom decorators, they must call functools.update_wrapper to ensure icontract can correctly traverse the stack.

    If custom decorators are placed between icontract decorators, the contracts will be verified after those custom decorators have been applied, not immediately when the contract was defined. To avoid unexpected behavior, it is recommended to group all icontract preconditions and postconditions together.

    @some_custom_decorator
    @icontract.require(lambda x: x > 0)
    @another_custom_decorator
    @icontract.require(lambda x, y: y < x)
    def some_func(x: int, y: int) -> None:
      # ...
  4. How icontract violation messages work

    master

    When a contract is breached, icontract raises an icontract.errors.ViolationError. These error messages are designed to be highly informative by including:

    1. The source code of the contract condition that failed.
    2. The values of all relevant variables (including nested attributes and global variables) at the time of the breach.
    3. An optional custom description if one was provided in the decorator.

    Note: Pre-conditions are checked before post-conditions. If a pre-condition fails, the post-condition is not evaluated.

  5. How icontract handles recursion in contracts

    master

    To prevent infinite recursion when functions or invariants depend on each other, icontract implements a suspension mechanism:

    1. Function Contracts: If a precondition or postcondition triggers a call that re-enters the same function currently undergoing contract checking, icontract suspends further contract checking for that specific re-entry.
    2. Class Invariants: To prevent infinite loops in invariants (e.g., an invariant that calls an instance method which in turn triggers the invariant), icontract sets a dunder attribute __dbc_invariant_check_is_in_progress__ on the instance. While this attribute is present, the invariant-checking wrappers will simply return the result of the function call without triggering a new invariant check.
    3. Construction: Invariant checks are automatically disabled during the __init__ phase. This prevents errors where an invariant might attempt to check an attribute that has not yet been fully initialized.
    # Example of recursive function contracts
    @icontract.require(lambda: another_func())
    def some_func() -> bool:
        ...
    
    @icontract.require(lambda: some_func())
    def another_func() -> bool:
        ...
    
    some_func()
    
    # Example of invariant depending on instance methods
    @icontract.invariant(lambda self: self.some_func())
    class SomeClass(icontract.DBC):
        def __init__(self) -> None:
            ...
    
        def some_func(self) -> bool:
            ...
  6. Understand how contracts are strengthened or weakened during inheritance

    master

    When a child class introduces new contracts, they interact with parent contracts as follows:

    • Strengthening (Invariants and Postconditions): If a child class adds new invariants or postconditions, the function/class must satisfy both the parent's and the child's requirements. The child's requirements are a subset of the allowed behavior.
    • Weakening (Preconditions): Adding preconditions to a child class method weakens the precondition. A caller only needs to satisfy the requirements of the child's specific implementation (or the parent's, whichever is more permissive in the context of the override).

    Note on __init__: The __init__ method is a special case. Because constructors are exempt from polymorphism, preconditions and postconditions of base classes are not inherited for __init__. Only the contracts specified on the concrete class's __init__ apply.

  7. How invariants are applied to classes

    master

    Invariants are handled via class decorators (e.g., using icontract.DBC). Unlike function decorators, they do not need to be stacked. The first invariant decorator wraps every public method of the class with a checker function. The invariants themselves are stored in the class's __invariants__ attribute. At runtime, the method wrapper iterates through __invariants__ to perform the checks.

    Requirement for Class Decorators: Any custom class decorators used on a class must ensure that the decorators applied to the class's individual functions use functools.update_wrapper so that icontract can still traverse the function decorator stacks.

  8. Use snapshots to verify state transitions

    master

    Standard postconditions cannot verify how an argument's state changed (e.g., if an element was appended to a list) because they only see the final state. The @icontract.snapshot decorator solves this by capturing the state of arguments before the function call and providing them to the postcondition via an OLD object.

    Key behaviors:

    • The capture function can accept zero, one, or multiple arguments.
    • If the capture function has a single argument, the property name in OLD defaults to that argument's name.
    • You can explicitly name the property in OLD using the name argument in @icontract.snapshot.
    • You can combine multiple arguments into a single snapshot by passing them to the capture function.
    import icontract
    from typing import List
    
    # Example 1: Default naming (single argument)
    @icontract.snapshot(lambda lst: lst[:])
    @icontract.ensure(lambda OLD, lst, value: lst == OLD.lst + [value])
    def some_func(lst: List[int], value: int) -> None:
        lst.append(value)
    
    # Example 2: Explicit naming
    @icontract.snapshot(lambda lst: len(lst), name="len_lst")
    @icontract.ensure(lambda OLD, lst, value: len(lst) == OLD.len_lst + 1)
    def some_func_named(lst: List[int], value: int) -> None:
        lst.append(value)
    
    # Example 3: Combining multiple arguments
    @icontract.snapshot(lambda lst_a, lst_b: set(lst_a).union(lst_b), name="union")
    @icontract.ensure(lambda OLD, lst_a, lst_b: set(lst_a).union(lst_b) == OLD.union)
    def some_func_combined(lst_a: List[int], lst_b: List[int]) -> None:
        lst_a.append(1)
  9. Inherit snapshots in child classes

    master

    Snapshots defined in base classes are inherited by child classes for computational efficiency. You can access these snapshots in a child class's postconditions using the OLD object, just as if they were defined in the child class itself.

    import abc
    import icontract
    from typing import List
    
    class A(icontract.DBC):
        @abc.abstractmethod
        @icontract.snapshot(lambda lst: lst[:])
        @icontract.ensure(lambda OLD, lst: len(lst) == len(OLD.lst) + 1)
        def func(self, lst: List[int], value: int) -> None:
            pass
    
    class B(A):
        # Inherits the snapshot 'lst' from class A
        @icontract.ensure(lambda OLD, lst, value: lst == OLD.lst + [value])
        def func(self, lst: List[int], value: int) -> None:
            lst.append(value)
  10. Evaluate icontract import overhead

    master

    Import overhead depends on whether you are using pre/post-conditions or invariants, and whether they are enabled or disabled.

    Pre and Post-conditions

    Overhead for pre/post-conditions is minimal. As you add more conditions to a module, the overhead per condition actually decreases because icontract initializes necessary fields in the function objects during the initial setup.

    Invariants

    Invariants are significantly more costly to import than pre/post-conditions. If your application requires extremely minimal import times, be aware that adding many class invariants will increase the time taken to import the module.

    Disabled Contracts

    When contracts are disabled, the overhead is significantly lower (primarily just the cost of the Python interpreter parsing the code). icontract is designed to return immediately when a condition is disabled, minimizing impact.

  11. Understanding icontract performance and overhead

    master

    When evaluating the performance of icontract, note that benchmarks often use simplified function bodies to isolate the overhead of the contracts themselves. In such cases, the code without contracts runs in nanoseconds, which may make the contract enforcement appear relatively slow.

    In real-world applications, methods typically execute in microseconds or milliseconds. As long as the icontract overhead remains in the microsecond range, it is generally considered practically acceptable for most use cases.

    Key performance considerations:

    • Recursion and Inheritance: Unlike deal or dpcontracts, icontract supports recursion and inheritance of contracts. Other libraries may achieve higher speeds by explicitly not supporting these features.
  12. Understand icontract performance costs

    master

    Using icontract introduces two types of computational overhead:

    1. Import Cost: Extra parsing performed when you import the module, regardless of whether contracts are enabled or disabled. This is caused by the Python interpreter parsing the contract code.
    2. Run-time Cost: The cost of verifying the contract during function execution.

    When choosing between computational efficiency and correctness, consider that while icontract adds overhead, it is typically measured in microseconds. For most practical applications where functions perform more complex work, this overhead is negligible compared to the benefits of formal contracts.