Skip to content

Add additional property support by subscripting model #252

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

Merged

Conversation

packyg
Copy link
Contributor

@packyg packyg commented Nov 25, 2020

Implements #218

Add support for additionalProperties by holding extra props in a private dict.
Can be accessed with pass-through subscripting dunder methods, including a @property to get the keys (see the bottom of model.pyi)

  • Updated from_dict to pop all named properties (from a shallow copy), with the rest going in the catch-all
    • Required saving the value to a temp variable for inspection
  • to_dict sets the additional properties first, giving precedent to named props

This would be a big win for my team - we had been relying on the previous behavior of having dicts when type=object to handle cases that we haven't or can't model very well.

@packyg
Copy link
Contributor Author

packyg commented Nov 25, 2020

@dbanty I'm interested to see if you like this approach. If you do, I'll go ahead and update the tests and add new test cases

(cc @bowenwr @dtkav)

@dbanty dbanty linked an issue Nov 28, 2020 that may be closed by this pull request
Copy link
Collaborator

@dbanty dbanty left a comment

Choose a reason for hiding this comment

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

Overall I think it looks good. I'm a little uneasy about putting set/getitem directly on the model but I don't have a good reason for being uneasy so it's probably fine 😅. I pointed out a couple places I think we could clean up some of the dict changes. Other than that, I would like @emannguitar to take a look at this as well to get his opinion.

{% for property in model.required_properties + model.optional_properties %}
{% if property.required %}
"{{ property.name }}": {{ property.python_name }},
{% endif %}
{% endfor %}
}
})
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why not leave the regular properties in the field dict declaration? Would be a bit faster I think rather than having to update after the fact, even without having additional properties.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My thinking here was that if a user for some reason set the same property both ways, the modeled version of the property takes precedence

model.some_json_property = "val1"
model["someJsonProperty"] = "val2"

model.to_dict()
# {"someJsonProperty": "val1"} 

{% for property in model.required_properties + model.optional_properties %}
{{ property.python_name }}={{ property.python_name }},
{% endfor %}
)

{% if model.additional_properties %}
{{model.reference.module_name}}._additional_properties.update(d)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why update vs set directly? I would think assignment would be faster but maybe not.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For some reason I had it in my head that attr wouldn't like this. But these models aren't frozen so I'll make that change.


@property
def additional_properties(self) -> Dict[str, {{ additional_property_type }}]:
return self._additional_properties
Copy link
Collaborator

Choose a reason for hiding this comment

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

What's the benefit of having this internal w/ @property vs just declaring it as additional_properties originally?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There isn't one really. I had thought I might need to add some special handling here but ended up not needing it.

I've gone ahead and just made additional_properties public.

@dbanty dbanty requested a review from emann November 28, 2020 23:47
Copy link
Collaborator

@emann emann left a comment

Choose a reason for hiding this comment

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

LGTM outside of @dbanty's comments

@emann
Copy link
Collaborator

emann commented Nov 30, 2020

Actually two small things:

  1. Per the OpenAPI spec, type: object by itself (i.e. without specifying additionalProperties: true or additionalProperties: {}) is also a free-form object
  2. Given that a free-form object is really just an arbitrary dict at the end of the day, they should just be dicts instead of a model with nothing but _aditional_properties

Copy link
Contributor Author

@packyg packyg left a comment

Choose a reason for hiding this comment

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

Thanks for the feedback @dbanty and @emannguitar.

I realized I also didn't properly handle serialization/deserialization when the schema of the additionalProperties is specified and the dict holds models. I will take a crack at that.

  1. Per the OpenAPI spec, type: object by itself (i.e. without specifying additionalProperties: true or additionalProperties: {}) is also a free-form object

This wasn't immediately clear to me. I know this is the case for JSON schema, wasn't sure it was for OpenAPI, but it appears so: "Consistent with JSON Schema, additionalProperties defaults to true."
I will revisit to account for this.


  1. Given that a free-form object is really just an arbitrary dict at the end of the day, they should just be dicts instead of a model with nothing but _aditional_properties

This is true and was my initial thought for objects with additionalProperties and no named properties, but it gets more complicated when we get to the case I mentioned at the top of this comment and it's a Dict[str, SomeModelClass]

{% for property in model.required_properties + model.optional_properties %}
{% if property.required %}
"{{ property.name }}": {{ property.python_name }},
{% endif %}
{% endfor %}
}
})
Copy link
Contributor Author

Choose a reason for hiding this comment

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

My thinking here was that if a user for some reason set the same property both ways, the modeled version of the property takes precedence

model.some_json_property = "val1"
model["someJsonProperty"] = "val2"

model.to_dict()
# {"someJsonProperty": "val1"} 

{% for property in model.required_properties + model.optional_properties %}
{{ property.python_name }}={{ property.python_name }},
{% endfor %}
)

{% if model.additional_properties %}
{{model.reference.module_name}}._additional_properties.update(d)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

For some reason I had it in my head that attr wouldn't like this. But these models aren't frozen so I'll make that change.


@property
def additional_properties(self) -> Dict[str, {{ additional_property_type }}]:
return self._additional_properties
Copy link
Contributor Author

Choose a reason for hiding this comment

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

There isn't one really. I had thought I might need to add some special handling here but ended up not needing it.

I've gone ahead and just made additional_properties public.

@packyg
Copy link
Contributor Author

packyg commented Dec 3, 2020

OK, I've now updated this to properly interpret additionalProperties per the spec (defaulting to true)

I also added support for additionalProperties where the elements are modeled. This requires calling to_dict/from_dict for serialization/deserialization, so we can't just use a raw dict there.

I'll start working on tests soon, but again wanted to get this out there.

Comment on lines +5 to +11
{% if initial_value != None %}
{{ property.python_name }} = {{ initial_value }}
if {{ source }} is not None:
{{ property.python_name }} = {{ property.reference.class_name }}.from_dict(cast(Dict[str, Any], {{ source }}))
{% elif property.nullable %}
{{ property.python_name }} = None
{% else %}
{{ property.python_name }} = UNSET
{% endif %}
Copy link
Contributor Author

@packyg packyg Dec 3, 2020

Choose a reason for hiding this comment

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

This addresses what I think was a bug before

(Below has to do with the fact we are now poping the dict values)

@@ -16,7 +23,7 @@ if {{ source }} is not None:
{{ destination }} = {{ source }}.to_dict()
{% endif %}
{% else %}
{{ destination }}{% if declare_type %}: {{ property.get_type_string() }}{% endif %} = UNSET
{{ destination }}{% if declare_type %}: Union[{% if property.nullable %}None, {% endif %}Unset, Dict[str, Any]]{% endif %} = UNSET
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same here - the type was wrong here

@packyg packyg requested review from dbanty and emann December 3, 2020 03:11
@codecov
Copy link

codecov bot commented Dec 4, 2020

Codecov Report

Merging #252 (6ccdde4) into main (4556d9a) will not change coverage.
The diff coverage is 100.00%.

Impacted file tree graph

@@            Coverage Diff            @@
##              main      #252   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           46        46           
  Lines         1302      1314   +12     
=========================================
+ Hits          1302      1314   +12     
Impacted Files Coverage Δ
...penapi_python_client/parser/properties/__init__.py 100.00% <100.00%> (ø)
..._python_client/parser/properties/model_property.py 100.00% <100.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 4556d9a...6ccdde4. Read the comment docs.

Copy link
Collaborator

@emann emann left a comment

Choose a reason for hiding this comment

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

LGTM!

@dbanty I generated a client for inventory to test and this seems to fix the issues we were having

Copy link
Collaborator

@dbanty dbanty left a comment

Choose a reason for hiding this comment

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

Awesome, fantastic work! I'll put together a release including this feature shortly.

@dbanty dbanty merged commit 3df7be3 into openapi-generators:main Dec 8, 2020
dbanty added a commit that referenced this pull request Dec 8, 2020
@eli-bl eli-bl deleted the additionalProperties-support branch November 26, 2024 22:44
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.

Support for additionalProperties and "free form" objects
3 participants