Skip to content

Allow Unset values to avoid being send during serialization #10

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
merged 1 commit into from
Oct 29, 2020

Conversation

bowenwr
Copy link

@bowenwr bowenwr commented Oct 26, 2020

Fixes openapi-generators#205.

Introduces a new sentinel value, UNSET which becomes the new default for nullable fields which aren't required. If a field is set to this value, it will not be sent as part of the payload during serialization.

Since we still want to treat endpoint arguments and query params as they were originally without Unset, this logic has moved to to_query_string on Property.

Given a str type, a nullable property which isn't required might look something like:

my_property: Union[str, None, Unset] = UNSET

@bowenwr bowenwr force-pushed the allow-unset-values branch from 4bdadd6 to e9a3ee5 Compare October 26, 2020 19:42
@bowenwr bowenwr marked this pull request as ready for review October 26, 2020 19:58
@bowenwr
Copy link
Author

bowenwr commented Oct 26, 2020

FYI @benchling/ce-team and @rswang since Daniel is out

Comment on lines 6 to 7
Unset = NewType("Unset", object)
UNSET: Unset = object()
Copy link

@packyg packyg Oct 27, 2020

Choose a reason for hiding this comment

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

Shouldn't this be

Suggested change
Unset = NewType("Unset", object)
UNSET: Unset = object()
Unset = NewType("Unset", object)
UNSET: Unset = Unset(object())

(not that it matters)

Copy link

@forest-benchling forest-benchling Oct 28, 2020

Choose a reason for hiding this comment

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

I guess another alternative would be to keep it as UNSET = object() and use Literal[UNSET] as the type? (Also doesn't seem like it matters)

Copy link
Author

Choose a reason for hiding this comment

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

When I tried that previously, PyCharm complained:

'Literal' may be parameterized with literal ints, byte and unicode strings, bools, Enum values, None, other literal types, or type aliases to other literal types 

else:
return f"{self.python_name}: {self.get_type_string()}"

def to_query_string(self) -> str:
Copy link

Choose a reason for hiding this comment

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

This threw me for a while - I thought get_query_string was building literal query strings, e,g. ?property=value which this is... not

I think you should rename this to something like

Suggested change
def to_query_string(self) -> str:
def to_query_method_arg(self) -> str:

or

Suggested change
def to_query_string(self) -> str:
def to_query_parameter(self) -> str:

(and get_query_string()/query_string() to match)

Copy link
Author

Choose a reason for hiding this comment

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

Thanks, I renamed it. It's unclear to me whether the "new" query behavior should be the default the this response body/model body should be the new method, but it seems to me like it's used in more places.

I also had already changed it and then had to put back the query parsing....


{% for property in model.required_properties + model.optional_properties %}
{% if not property.required %}
if {{property.python_name}} != UNSET:
Copy link

Choose a reason for hiding this comment

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

You should use instance equality with a sentinel object

Suggested change
if {{property.python_name}} != UNSET:
if {{property.python_name}} is not UNSET:

@bowenwr bowenwr requested a review from packyg October 27, 2020 02:22
Copy link

@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.

LGTM, just the overlooked rename

{% endfor %}
{% for parameter in endpoint.header_parameters %}
{{ parameter.to_string() }},
{{ parameter.to_query_string() }},
Copy link

Choose a reason for hiding this comment

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

I think you forgot to update this one

Suggested change
{{ parameter.to_query_string() }},
{{ parameter.to_query_method_arg() }},

@bowenwr
Copy link
Author

bowenwr commented Oct 27, 2020

FYI @mzhu22

@bowenwr
Copy link
Author

bowenwr commented Oct 28, 2020

One of the major drawbacks to this approach is that it applies to both request and response bodies. When really we only want it to apply to request bodies. I don't think there's a way to really control this via the spec without misusing it.

Do we think it would be better to leave the original typings as is and only change the defaults to Unset and ignore typing violations?

@packyg
Copy link

packyg commented Oct 28, 2020

Do we think it would be better to leave the original typings as is and only change the defaults to Unset and ignore typing violations?

@bowenwr good point. Maybe it would be better to leave Unset off the type. You could then lie to mypy with just cast(<Target Type>, UNSET) and mypy should behave as expected otherwise.

The response objects still have None for those values, overriding the default UNSET val, right?

Copy link

@forest-benchling forest-benchling left a comment

Choose a reason for hiding this comment

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

👏 yay! Are you going to also try to submit this PR upstream? Seems like a useful addition for others as well.

@@ -24,13 +24,23 @@ def test_get_type_string(self):
p = Property(name="test", required=True, default=None, nullable=False)

Choose a reason for hiding this comment

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

Is there any way we can have a more end-to-end test that unset values are not sent over to the API endpoint?

I'm a bit nervous since I'm very unfamiliar with this codebase.

@@ -36,7 +38,7 @@ else:
{% endif %}
{% if inner_property.template %}
{% from "property_templates/" + inner_property.template import transform %}
{{ transform(inner_property, source, destination) | indent(8) }}
{{ transform(inner_property, source, destination) | indent(4) }}

Choose a reason for hiding this comment

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

What caused this indentation level to change?

if {{property.python_name}} is not UNSET:
properties["{{ property.name }}"] = {{ property.python_name }}
{% else %}
properties["{{ property.name }}"] = {{ property.python_name }}

Choose a reason for hiding this comment

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

Is it possible that a property is both required AND unset, and if so is it desired that it would be exposed in the dictionary directly (instead of, say, erroring)?

Copy link
Author

Choose a reason for hiding this comment

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

If a property is required, we shouldn't allow unset - we simply shouldn't allow a default. It could still be nullable and required, in which case we should allow it to be None.

if no_optional or (self.required and not self.nullable):
return self._type_string
return f"Optional[{self._type_string}]"
return type_string(no_optional, self, self._type_string)
Copy link

@forest-benchling forest-benchling Oct 28, 2020

Choose a reason for hiding this comment

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

Could this be a method on Property instead of a function that takes in self? (Same for query_method_arg)

@@ -63,24 +70,42 @@ def get_imports(self, *, prefix: str) -> Set[str]:
prefix: A prefix to put before any relative (local) module names. This should be the number of . to get
back to the root of the generated client.
"""
if self.nullable or not self.required:
if self.required and self.nullable:
return {"from typing import Optional"}

Choose a reason for hiding this comment

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

If self.required is true, why do we need to import Optional?



def query_method_arg(no_optional: bool, prop: Property, inner_type_string: str, is_outer_union: bool = False) -> str:
if no_optional or (prop.required and not prop.nullable):

Choose a reason for hiding this comment

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

Potentially slightly more readable as:

type_string = inner_type_string
if is_outer_union:
	type_string = f"Union[{type_string}]"
if not no_optional and (not prop.required or prop.nullable):
	type_string = f"Optional[{type_string}]"
return type_string

Similarly for type_string above. Don't feel too strongly though, will leave it to you.

if is_outer_union:
return f"Optional[Union[{inner_type_string}]]"
return f"Optional[{inner_type_string}]"
elif not prop.nullable and not prop.required:

Choose a reason for hiding this comment

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

Should this be elif prop.nullable and not prop.required, given that we're unioning it with None in L632?

Copy link
Author

Choose a reason for hiding this comment

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

I dropped this change in the latest commit where I preserve the original typing and just set the default.

@@ -12,6 +12,9 @@ if {{ source }} is not None:
{% if property.required %}
{{ destination }} = {{ source }}
{% else %}
{{ destination }} = {{ source }} if {{ source }} else None
if {{ source }} is UNSET:

Choose a reason for hiding this comment

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

For my context, where is the macro transform actually called?

Copy link
Author

Choose a reason for hiding this comment

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

It's called frequently in the various .pyi templates - you'll find it easily if you search and make sure you aren't filtering to only .py files.

@bowenwr
Copy link
Author

bowenwr commented Oct 29, 2020

Do we think it would be better to leave the original typings as is and only change the defaults to Unset and ignore typing violations?

@bowenwr good point. Maybe it would be better to leave Unset off the type. You could then lie to mypy with just cast(<Target Type>, UNSET) and mypy should behave as expected otherwise.

The response objects still have None for those values, overriding the default UNSET val, right?

I liked this suggestion, I implemented it and tested it out. It seems to pass our integration tests. I still needed to_query_method_arg to preserve the original defaults but otherwise I like this change as a lot less invasive.

@bowenwr
Copy link
Author

bowenwr commented Oct 29, 2020

👏 yay! Are you going to also try to submit this PR upstream? Seems like a useful addition for others as well.

I'd like to upstream this at some point though it would probably need considerably more polish and E2E tests. I'll probably incubate it a bit internally too to make sure I haven't missed any cases as we expand our spec coverage.

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.

Remove optional generated class attributes set to None from serialized payload
3 participants