Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Relax Representation Invariant Checking Logic for Recursive Classes #1162

Merged
merged 11 commits into from
Mar 22, 2025

Conversation

ckatsube
Copy link
Contributor

@ckatsube ckatsube commented Mar 15, 2025

Motivation & Proposal

The Representation Invariant checker timing is currently too strict. In many data structures, it's common for instances to mutate other instances of the same type (i.e. graphs/trees or recursive data structures). The current logic enforces RI checks in two places: 1) when an attribute is set EXCEPT for instance methods 2) after the instance method is called. The problem is that Path 1 is executed inside instance methods that set the attributes of different instances of the same type (motivating example below).

The proposed change is to additionally exempt instances of the same type as self during the Path 1 check and push checking these RIs until after the instance method has returned, alongside the Path 2 check.

More practical motivating example with RecursiveList

Note: this specific example can technically be avoided by having a better constructor for RecursiveList but the bug still stands regardless.

from __future__ import annotations
from typing import Any
from python_ta.contracts import check_contracts


@check_contracts
class RecursiveList:
    """
    Representation Invariants:
    - (self._value is None and self._rest is None) or \
        (self._value is not None and self._rest is not None)
    """
    _value: Any | None
    _rest: RecursiveList | None

    def __init__(self, items: list) -> None:
        if items == []:
            self._value = None
            self._rest = None
        else:
            self._value = items[0]
            self._rest = RecursiveList(items[1:])

    def insert(self, value: Any):
        """Insert value at the current position (push current value to next position)"""
        new_rest = RecursiveList([])
        # new_rest._value and ._rest are originally None by this construction logic.
        new_rest._value = self._value  # RI violation occurs here despite RI being satisfied in the next line.
        new_rest._rest = self._rest
        self._value = value
        self._rest = new_rest


if __name__ == '__main__':
    node_a = RecursiveList([10, 20, 30])
    node_a.insert(5)

Changes

  • Modified the Path 1 check to also exempt RI checking if the type of the parent frame's self matches the type of self

  • Accumulated all of the instances that were exempt in the Path 1 check to later check at Path 2

  • Modified the Path 2 logic to also check RIs of the accumulated instance attributes

  • Added unit tests to test_class_contracts for both the attribute mutation and function arg mutation cases.

Type of Change

(Write an X or a brief description next to the type or types that best describe your changes.)

Type Applies?
🚨 Breaking change (fix or feature that would cause existing functionality to change)
New feature (non-breaking change that adds functionality)
🐛 Bug fix (non-breaking change that fixes an issue) X
♻️ Refactoring (internal change to codebase, without changing functionality)
🚦 Test update (change that only adds or modifies tests)
📚 Documentation update (change that only updates documentation)
📦 Dependency update (change that updates a dependency)
🔧 Internal (change that only affects developers or continuous integration)

Checklist

(Complete each of the following items for your pull request. Indicate that you have completed an item by changing the [ ] into a [x] in the raw text, or by clicking on the checkbox in the rendered description on GitHub.)

Before opening your pull request:

  • I have performed a self-review of my changes.
    • Check that all changed files included in this pull request are intentional changes.
    • Check that all changes are relevant to the purpose of this pull request, as described above.
  • I have added tests for my changes, if applicable.
    • This is required for all bug fixes and new features.
  • I have updated the project documentation, if applicable.
    • This is required for new features.
  • I have updated the project Changelog (this is required for all changes).
  • If this is my first contribution, I have added myself to the list of contributors.

After opening your pull request:

  • I have verified that the pre-commit.ci checks have passed.
  • I have verified that the CI tests have passed.
  • I have reviewed the test coverage changes reported by Coveralls.
  • I have requested a review from a project maintainer.

Questions and Comments

I made a minor logical nuance where subclasses should be able to temporarily violate parent class instances' Representation Invariants in its instance methods (Child.modify(parent)). If you think that Child instances should not be allowed to temporarily violate Parent RIs and this check should be strictly equal class types (only Child==Child and Parent == Parent but no Child modifies Parent), let me know! It would effectively just be changing not isinstance(caller_self, type(self)) to not type(caller_self) == type(self)

@ckatsube ckatsube marked this pull request as draft March 15, 2025 21:36
@ckatsube
Copy link
Contributor Author

Converted to draft because the failing test test_call_super_creates_temp_invalid where the subclass Teacher's RI is throwing an error after calling a subclass method. Need to figure out why subclass RIs are being evaluated after a parent method is ran (when it shouldn't be).

@ckatsube ckatsube marked this pull request as ready for review March 15, 2025 22:25
@coveralls
Copy link
Collaborator

coveralls commented Mar 15, 2025

Pull Request Test Coverage Report for Build 13983965613

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 23 of 23 (100.0%) changed or added relevant lines in 1 file are covered.
  • 27 unchanged lines in 2 files lost coverage.
  • Overall coverage increased (+0.1%) to 93.089%

Files with Coverage Reduction New Missed Lines %
python_ta/init.py 9 94.65%
python_ta/contracts/init.py 18 92.39%
Totals Coverage Status
Change from base Build 13749526863: 0.1%
Covered Lines: 3300
Relevant Lines: 3545

💛 - Coveralls

@ckatsube
Copy link
Contributor Author

Not a current collaborator so can't directly request a reviewer.

@david-yz-liu
I'd like to request a review.

PR size: small

@david-yz-liu david-yz-liu self-requested a review March 16, 2025 14:50
@david-yz-liu
Copy link
Contributor

Thank you @ckatsube! It's great to see you active on here. :) I will review soon.

BTW, if you want to join the new SDS slack, you're welcome to join by following the instructions under "Slack" at https://www.cs.toronto.edu/~david/sds/welcome-to-sds.html.

Copy link
Contributor

@david-yz-liu david-yz-liu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, @ckatsube! I left some comments. Also it would be good to add a test case to illustrate the interaction with inheritance.

@@ -221,6 +225,12 @@ def new_setattr(self: klass, name: str, value: Any) -> None:
else:
super(klass, self).__delattr__(name)
raise AssertionError(str(e)) from None
else:
caller_klass = type(caller_self)
if "__mutated_instances__" in caller_klass.__dict__:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use hasattr and getattr instead of accessing __dict__

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good!

else:
caller_klass = type(caller_self)
if "__mutated_instances__" in caller_klass.__dict__:
mutable_instances = caller_klass.__dict__["__mutated_instances__"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable should probably be called mutated_instances

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the consistency check 👍

@@ -221,6 +225,12 @@ def new_setattr(self: klass, name: str, value: Any) -> None:
else:
super(klass, self).__delattr__(name)
raise AssertionError(str(e)) from None
else:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this else is a bit too broad: if caller_self is self it doesn't need to be added to __mutated_instances__, as the RIs should be checked on self at the end of the calling method.

Also, it's worth adding a comment to explain what's going on (parallel to the comment in the if branch).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good! Thanks for the keen eye

# Only validating if the attribute is not being set in a instance/class method
# AND self is an instance of caller_self's type -- aka caller_self is equal to
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check for isinstance(caller_self, type(self)) is good (it's fine for an instance of a child class to violate an RI on another instance of the a parent class).

In the comment, the wording should be reversed (caller_self is an instance of self's type); the part including and after the -- is not necessary and can be removed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for following up on the question!

except PyTAContractError as e:
raise AssertionError(str(e)) from None
else:
return r
finally:
setattr(instance_klass, "__mutated_instances__", mutated_instances_to_restore)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The restoring is generally good; however, we should have different cases for whether __mutated_instances__ was an attribute or not originally. If it wasn't, we should use delattr to remove the instance attribute altogether, so it remains a temporary attribute.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like a plan--added a branch for deleting the temporary attribute

def test_violate_ri_in_other_instance(person, person_2) -> None:
"""
Call a method that changes age of another instance of the same class to something invalid
Excepts the RI to be violated hence an AssertionError to be thrown.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Excepts" -> "Expects"

"thrown" -> "raised"

Also, please add a period to the end of the sentence on the previous line.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

@ckatsube
Copy link
Contributor Author

Resolved the review comments and added the requested tests.

Additionally modified the logic around invariant checking the mutated instances to use the klass/klass_mod of the mutated instance to prevent child class RIs from being enforced on mutated parent class instances (this was caught by the new tests).

@ckatsube
Copy link
Contributor Author

Since the checks passed, this is ready for re-review!

Copy link
Contributor

@david-yz-liu david-yz-liu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @ckatsube!

@david-yz-liu david-yz-liu merged commit 477f476 into pyta-uoft:master Mar 22, 2025
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants