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

Raise AttributeError on attempts to access unset oneof fields #510

Merged
merged 8 commits into from
Jul 21, 2023

Conversation

a-khabarov
Copy link
Contributor

This commit modifies Message.__getattribute__ to raise AttributeError whenever an attempt is made to access an unset oneof field. This provides several benefits over the current approach:

  • There is no longer any risk of betterproto users accidentally relying on values of unset fields.

  • Pattern matching with match/case on messages containing oneof groups is now supported. The following is now possible:

    @dataclasses.dataclass(eq=False, repr=False)
    class Test(betterproto.Message):
      x: int = betterproto.int32_field(1, group="g")
      y: str = betterproto.string_field(2, group="g")
    
    match Test(y="text"):
      case Test(x=v):
        print("x", v)
      case Test(y=v):
        print("y", v) 

    Before this commit the code above would output x 0 instead of y text, but now the output is y text as expected. The reason this works is because an AttributeError in a case pattern does not propagate and instead simply skips the case.

  • We now have a type-checkable way to deconstruct oneof. When running mypy for the snippet above v has type int in the first case and type str in the second case. For versions of Python that do not support match/case (before 3.10) it is now possbile to use try/except/else blocks to achieve the same result:

    t = Test(y="text")
    try:
      v0: int = t.x
    except AttributeError:
      v1: str = t.y  # `oneof` contains `y`
    else:
      pass  # `oneof` contains `x`

This is a breaking change. The previous behavior is still accessible via Message.__unsafe_get.

@a-khabarov
Copy link
Contributor Author

I think this PR solves all of the problems mentioned in #358

src/betterproto/__init__.py Outdated Show resolved Hide resolved
src/betterproto/__init__.py Outdated Show resolved Hide resolved
@Gobot1234
Copy link
Collaborator

Gobot1234 commented Jul 18, 2023

Thanks for the pr it looks good apart from the changes requiring you to access _Message__unsafe_get which should not be api that anyone should have to deal with. Unless this is just for testing purposes

@a-khabarov
Copy link
Contributor Author

_Message__unsafe_get is just for testing/use within betterproto itself, not for the users of betterproto. I don't see any reason why the users of betterproto would want to call _Message__unsafe_get instead of just using ..

@Gobot1234
Copy link
Collaborator

Gobot1234 commented Jul 18, 2023

If it's just for testing like that can you just remove the method and use object.__getattribute__(message, name) in tests?

@a-khabarov
Copy link
Contributor Author

Okay, I will remove it and update the tests to use object.__getattribute__(message, name).

@a-khabarov
Copy link
Contributor Author

I've removed __unsafe_get and updated the tests to use object.__getattribute__(message, name).

This commit modifies `Message.__getattribute__` to raise
`AttributeError` whenever an attempt is made to access an unset `oneof`
field. This provides several benefits over the current approach:

* There is no longer any risk of `betterproto` users accidentally
  relying on values of unset fields.

* Pattern matching with `match/case` on messages containing `oneof`
  groups is now supported. The following is now possible:
  ```
  @dataclasses.dataclass(eq=False, repr=False)
  class Test(betterproto.Message):
    x: int = betterproto.int32_field(1, group="g")
    y: str = betterproto.string_field(2, group="g")

  match Test(y="text"):
    case Test(x=v):
      print("x", v)
    case Test(y=v):
      print("y", v)
  ```
  Before this commit the code above would output `x 0` instead of
  `y text`, but now the output is `y text` as expected. The reason
  this works is because an `AttributeError` in a `case` pattern does
  not propagate and instead simply skips the `case`.

* We now have a type-checkable way to deconstruct `oneof`. When running
  `mypy` for the snippet above `v` has type `int` in the first `case`
  and type `str` in the second `case`. For versions of Python that do
  not support `match/case` (before 3.10) it is now possbile to use
  `try/except/else` blocks to achieve the same result:
  ```
  t = Test(y="text")
  try:
    v0: int = t.x
  except AttributeError:
    v1: str = t.y  # `oneof` contains `y`
  else:
    pass  # `oneof` contains `x`
  ```

This is a breaking change.
@Gobot1234
Copy link
Collaborator

In future would you mind avoiding force pushing, it makes things harder to review and we squash the commits anyway

@a-khabarov
Copy link
Contributor Author

a-khabarov commented Jul 19, 2023

For {group_current[group]} in attribute errors, do we also need !r? The output at the moment seems to be of the form AttributeError: 'g' is set to a, not 'b' (__repr__ not called for group_current[group]).

@Gobot1234
Copy link
Collaborator

Would you mind also adding a small match test case for the feature you have shown in your OP?

@a-khabarov
Copy link
Contributor Author

Okay, I will add it.

assert betterproto.which_one_of(foo, "group1")[0] == "baz"

foo.sub.val = 1
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this a behaviour change?

Copy link
Contributor Author

@a-khabarov a-khabarov Jul 19, 2023

Choose a reason for hiding this comment

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

This PR makes it impossible to use foo.sub.val = 1 when foo.sub is unset in the group.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think this is a bit of a tradeoff. With this change the users of betterproto can no longer use the foo.sub.val = 1 syntax for fields that are unset in groups, but this also means that there is less risk of them accidentally changing which field is set in a group.

This is needed to allow formatting Python 3.10 pattern matching
syntax.
The pattern matching syntax is only supported in Python 3.10+,
so this commit adds it in a separate file to avoid syntax errors
for older Python versions.
These is no `23.0.0` release.
Pass `--target-version py310` to allow pattern matching syntax.
@a-khabarov
Copy link
Contributor Author

I've added a test for pattern matching. Sorry for all the commits, I should've installed pre-commit locally before pushing.

@a-khabarov
Copy link
Contributor Author

@Gobot1234 Thank you! Would you mind merging this PR? (since I don't have write access)

@Gobot1234 Gobot1234 merged commit 6faac1d into danielgtaylor:master Jul 21, 2023
17 checks passed
@a-khabarov a-khabarov deleted the safer-oneof-access branch July 21, 2023 12:37
@MicaelJarniac
Copy link
Contributor

I think it'd be nice to have this reflected in the docs.

bbonenfant pushed a commit to pachyderm/python-betterproto that referenced this pull request Jan 23, 2024
…nielgtaylor#510)

# Conflicts:
#	poetry.lock
#	src/betterproto/__init__.py
#	src/betterproto/plugin/parser.py
bbonenfant added a commit to pachyderm/python-betterproto that referenced this pull request Jan 23, 2024
…nielgtaylor#510)

# Conflicts:
#	poetry.lock
#	src/betterproto/__init__.py
#	src/betterproto/plugin/parser.py

# Conflicts:
#	poetry.lock
#	pyproject.toml
MicaelJarniac added a commit to MicaelJarniac/python-betterproto that referenced this pull request Feb 22, 2024
Removed the parts of the example that showed accessing an unset value, as it now raises an `AttributeError`, and added an example of the `match` way of accessing the attributes.

Related to danielgtaylor#510 and danielgtaylor#358.
@MicaelJarniac
Copy link
Contributor

I've opened a PR to change the README to reflect this:

Gobot1234 pushed a commit that referenced this pull request Mar 19, 2024
Removed the parts of the example that showed accessing an unset value, as it now raises an `AttributeError`, and added an example of the `match` way of accessing the attributes.

Related to #510 and #358.
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.

None yet

4 participants