-
-
Notifications
You must be signed in to change notification settings - Fork 114
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
Forbid extra fields when structuring dictionaries into attrs classes #142
Conversation
Codecov Report
@@ Coverage Diff @@
## master #142 +/- ##
==========================================
+ Coverage 98.21% 98.22% +0.01%
==========================================
Files 6 6
Lines 615 620 +5
==========================================
+ Hits 604 609 +5
Misses 11 11
Continue to review full report at Codecov.
|
src/cattr/gen.py
Outdated
@@ -181,6 +183,17 @@ def make_dict_structure_fn(cl: Type, converter, **kwargs): | |||
f" res['{ian}'] = {struct_handler_name}(o['{kn}'], __c_t_{an})" | |||
) | |||
lines.append(" }") | |||
if forbid_extra_fields: | |||
post_lines.append(" allowed_fields = {") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wasn't sure how much of a (non-)goal it is to have tidy generated code. For example, I could probably have written
post_lines.append(f" allowed_fields = {set(a.name for a in attrs)}")
which is shorter and maybe a little cleaner from the point of view of this function, but would likely lead to a very long line in the generated code in many cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hello,
thanks for your work on this, let's see about tidying this up and getting it into the next release.
I would also possibly add a parameter for the GenConverter
, similar to omit_if_default
, that would be used for generating all classes here. (At this level, it'd be OK for the flag to be called just forbid_extra_keys
.)
src/cattr/gen.py
Outdated
@@ -109,7 +109,9 @@ def generate_mapping(cl: Type, old_mapping): | |||
return cls(**mapping) | |||
|
|||
|
|||
def make_dict_structure_fn(cl: Type, converter, **kwargs): | |||
def make_dict_structure_fn( | |||
cl: Type, converter, forbid_extra_fields: bool = False, **kwargs |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The argument at this layer should be a little obfuscated, since it might shadow an attribute name you're trying to override (in **kwargs). I'd change it to _cattrs_forbid_extra_fields
. Note that we can add this flag on the GenConverter
level with a better name.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I wondered about that. Will do.
src/cattr/gen.py
Outdated
" }", | ||
" unknown_fields = set(o.keys()) - allowed_fields", | ||
" if unknown_fields:", | ||
" raise KeyError(", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wouldn't use a KeyError
here. First of all, KeyError
is raised when a key is missing from a dictionary, not when it's extra :)
Second, KeyError
is a built-in Python exception. User libraries are supposed either throw Exception
or something inheriting from Exception
(see https://docs.python.org/3/library/exceptions.html#Exception). Ideally we would have a StructureError
and you could inherit from that, but we don't currently and that should be a different task, so just throw Exception
for now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, I think KeyError
counts as "something inheriting from Exception
", but agreed that custom exception subclasses would be best, and that a KeyError
is a little weird.
try:
raise KeyError('key error')
except Exception:
print("caught your key error")
# prints caught your key error
I was thinking of it as the key being missing from the class, rather than from the dictionary. But I'm happy to put in Exception
for now if that's your preference!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, let's switch to a bare Exception
for now. KeyError
is a bit of a stretch I think.
While working on this round of updates, I noticed that you'd suggested Started thinking about tests a little tonight, but would probably appreciate some feedback on what sort of tests are desired here:
|
PS. Thanks for the quick turnaround with a review last night! |
Er yeah, keys is better.
If you don't have Hypothesis experience it's ok to skip. Yeah, I'd just add a test or two to
That would be great, yeah. You can add a test to Thanks! |
Added some tests. Ended up feeling lazy tonight about tests for Started on docs, but not done there. |
Cool, no rush. Thanks! |
Okay, I think I'd be ready to call this done. Let me know if there are other changes you'd like to see. |
Also: I'm accustomed to a merge/squash workflow to keep git history tidy, but have been assuming it would be appropriate to keep the history as-is to make reviewing easy. LMK if/when it's appropriate to squash. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great job! Left a comment inline.
Also, to save me from opening up a PR to your PR, can you just apply this change:
if _cattrs_forbid_extra_keys:
allowed_fields = {a.name for a in attrs}
globs["__c_a"] = allowed_fields
post_lines += [
" unknown_fields = set(o.keys()) - __c_a",
" if unknown_fields:",
" raise Exception(",
f" 'Extra fields in constructor for {cl_name}: ' + ', '.join(unknown_fields)"
" )",
]
(i.e. precalculate allowed_fields
and make it available in the globals).
tests/metadata/test_genconverter.py
Outdated
|
||
|
||
@given(simple_typed_attrs(defaults=True), unstructure_strats) | ||
def test_simple_roundtrip_defaults_with_extra_keys_forbidden( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, not sure what this test is supposed to cover that the test before (test_simple_roundtrip_with_extra_keys_forbidden
) doesn't? If nothing important, feel free to remove (Hypothesis tests take some time to run, would like to keep their number down).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think what I had in mind at the time was to make sure that it hadn't somehow made it error in the wrong order (i.e. when there are keys present in the structured object that aren't present in the dict), but given the current implementation I think that's not really a concern, and test_forbid_extra_keys_nested_override
would probably catch that in a simple case anyway, so I've gone ahead and deleted. Definitely nice to avoid having too many tests with hypothesis - can really increase the testing time.
Should be set I think. Pre-calculating the allowed fields is a nice touch - thanks for that pointer. |
I don't really have a policy for squashing, feel free if you feel like it. I will take another look at this in the next day or two and maybe merge it it. Note that GitHub's telling me this branch cannot be rebased to due conflicts for some reason |
Default is to maintain the old behavior. - Add `_cattr_forbid_extra_keys` arg to `make_dict_structure_fn` and implement new behavior - Update `GenConverter` with `forbid_extra_keys` option to enable this for all classes - Add tests and update docs
5982028
to
fea3292
Compare
Squashed. |
Great works, thanks a lot! |
Would resolve #101. I'm assuming this will need some further work (e.g. new tests; fixing other tests this breaks, etc), but wanted to go ahead and get it up for a first review to see if the approach is reasonable.