Skip to content

Change optional Open API query parameters to allow None #297

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

Closed

Conversation

forest-benchling
Copy link
Collaborator

@forest-benchling forest-benchling commented Jan 13, 2021

Fixes #285.

Instead of methods like

def sync_detailed(
    *,
    client: Client,
    size: Union[Unset, int] = UNSET,
) -> Response[Thing]:

we will now generate

def sync_detailed(
    *,
    client: Client,
    size: Union[Unset, None, int] = None,
) -> Response[Thing]:

and treat None the same as Unset.

This is a bit different from the original proposal in the linked issue, but if you scroll down you can see that it's what was agreed upon in the end IIUC (though I wasn't sure if we agreed on what the default value should be). In addition, it has the benefit of preserving back-compat.

As part of this issue, I also tried to collapse nested Optional and Union types, so that what was formerly Union[Unset, Optional[int]] or Optional[Union[Unset, int]] is now Union[Unset, None, int].

@codecov
Copy link

codecov bot commented Jan 13, 2021

Codecov Report

Merging #297 (4b8fc70) into main (d76bae8) will not change coverage.
The diff coverage is 100.00%.

Impacted file tree graph

@@            Coverage Diff            @@
##              main      #297   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           47        47           
  Lines         1385      1392    +7     
=========================================
+ Hits          1385      1392    +7     
Impacted Files Coverage Δ
...penapi_python_client/parser/properties/__init__.py 100.00% <100.00%> (ø)
...i_python_client/parser/properties/enum_property.py 100.00% <100.00%> (ø)
..._python_client/parser/properties/model_property.py 100.00% <100.00%> (ø)
...penapi_python_client/parser/properties/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 d76bae8...05d284e. Read the comment docs.

@forest-benchling forest-benchling marked this pull request as ready for review January 13, 2021 14:39
@forest-benchling
Copy link
Collaborator Author

cc @dbanty @emann LMK if you have any feedback! @packyg @bowenwr note that the implementation of get_type_string is a bit more complicated than what I originally showed you, since I tried to get rid of silly types like Union[Unset, None, Optional[int]].

@bowenwr
Copy link
Contributor

bowenwr commented Jan 15, 2021

@dbanty Any thoughts on this?

@dbanty
Copy link
Collaborator

dbanty commented Jan 15, 2021

@dbanty Any thoughts on this?

I am planning on reviewing this weekend (which is starting soon!). So I should have some feedback or an approval for you by Tuesday 😊

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 work! I really love cleaning up all those type string functions to centralize the nullable/required logic more. My comments are mostly split between some if-logic consolidation and the big question of whether we need more logic in the templates to ensure that nullable is always the same as not-required for query params.

Also, you'll need to pull main into your branch because I fixed that py thing as well while I was doing something else 😦. The reason it fails on the local task but not in CI is because it's required only by a dev dependency, and CI intentionally leaves those out since they don't matter to the end product. Probably worth updating the task to do the same at some point 🧐.

Thanks again! Looking forward to your response / follow-up changes!

Comment on lines 179 to 192
if self.required:
if self.nullable:
return f"Union[None, {self._get_inner_prop_string()}]"
else:
return type_string
else:
if self.nullable:
return f"Union[Unset, None, {self._get_inner_prop_string()}]"
else:
if query_parameter:
# For query parameters, None has the same meaning as Unset
return f"Union[Unset, None, {self._get_inner_prop_string()}]"
else:
return f"Union[Unset, {self._get_inner_prop_string()}]"
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this cleans up the logic a little bit (one case per output option) and covers the case of required and nullable and query_parameter correctly. Spacing may be wrong because I'm editing in GitHub >.>.

Suggested change
if self.required:
if self.nullable:
return f"Union[None, {self._get_inner_prop_string()}]"
else:
return type_string
else:
if self.nullable:
return f"Union[Unset, None, {self._get_inner_prop_string()}]"
else:
if query_parameter:
# For query parameters, None has the same meaning as Unset
return f"Union[Unset, None, {self._get_inner_prop_string()}]"
else:
return f"Union[Unset, {self._get_inner_prop_string()}]"
if self.required and not self.nullable:
return type_string
if query_parameter or (self.nullable and not self.required):
# For query parameters, nullable and required are the same
return f"Union[Unset, None, {self._get_inner_prop_string()}]"
if self.nullable:
return f"Union[None, {self._get_inner_prop_string()}]"
return f"Union[Unset, {self._get_inner_prop_string()}]"

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think the case that differs is if query_parameter=True, required=True, nullable=True. In the original code, we get f"Union[None, {self._get_inner_prop_string()}]", whereas in the suggestion, we get f"Union[Unset, None, {self._get_inner_prop_string()}]". It seems like the original code would be correct in that case, unless it's the case that query parameters cannot exist with required=True, nullable=True?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, hmm, it seems like the semantics are weird for a query parameter with required=True, nullable=True...what would it mean for you to pass None?

This also impacts your other comment in endpoint_macros.pyi...if we decide that we aren't gonna allow passing None, then I think we also need {% if property.required and not property.nullable %} in L25.

This is so brain-twisting 🤔

Copy link
Collaborator Author

@forest-benchling forest-benchling Jan 19, 2021

Choose a reason for hiding this comment

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

@dbanty (cc @bowenwr) OK I think I figured out why I'm so confused. Seems like for query parameters, it's redundant to have BOTH required and nullable as properties, because there's no distinction between None and UNSET.

In the current implementation, we have:
image

However, it's confusing because in the last row, we specifically say nullable=False, but still it's allowing None.

Is it possible to ban the nullable attribute on query parameters? That would simplify things a lot. Though it would be a breaking change.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think what we're going for here is to interpret both nullable and required to mean the same thing in the context of query parameters. Basically, there is no standard way to pass null through a query parameter, so rather than setting our own decision we're opting to not have a null value. In which case, null and not required become the same thing semantically when using this generator, and therefore should be the same signature.

Interestingly, in my response to #295 I encountered the "style" attribute which does seem to indicate that an "empty" value should be provided as empty, as the generated clients do today. It's not clear if empty is equivalent to null though...

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I see. In that case @dbanty do you think it would be reasonable to stick to the original implementation, which converts query_parameter=True, required=True, nullable=True into Union[None, {self._get_inner_prop_string()}]?

Comment on lines +49 to +62
if self.required:
if self.nullable:
return f"Optional[{type_string}]"
else:
return type_string
else:
if self.nullable:
return f"Union[Unset, None, {type_string}]"
else:
if query_parameter:
# For query parameters, None has the same meaning as Unset
return f"Union[Unset, None, {type_string}]"
else:
return f"Union[Unset, {type_string}]"
Copy link
Collaborator

Choose a reason for hiding this comment

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

I can't include it in a suggestion because of the way GitHub split the lines, but you could make this first case the same if check as no_optional. This is basically the same change I suggested for UnionProperty.

Suggested change
if self.required:
if self.nullable:
return f"Optional[{type_string}]"
else:
return type_string
else:
if self.nullable:
return f"Union[Unset, None, {type_string}]"
else:
if query_parameter:
# For query parameters, None has the same meaning as Unset
return f"Union[Unset, None, {type_string}]"
else:
return f"Union[Unset, {type_string}]"
if self.required and not self.nullable:
return type_string
if query_parameter or (self.nullable and not self.required):
# For query parameters, nullable has the same meaning as required
return f"Union[Unset, None, {type_string}]"
if self.nullable:
return f"Optional[{type_string}]"
return f"Union[Unset, {type_string}]"

@@ -33,7 +33,7 @@ params: Dict[str, Any] = {
}
{% for property in endpoint.query_parameters %}
{% if not property.required %}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think to truly make not-required the same as nullable for query params we want to add this.

Suggested change
{% if not property.required %}
{% if not property.required or property.nullable %}

@@ -9,12 +9,12 @@ if _{{ property.python_name }} is not None:
{% endif %}
{% endmacro %}

{% macro transform(property, source, destination, declare_type=True) %}
{% macro transform(property, source, destination, declare_type=True, query_parameter=False) %}
{% if property.required %}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we also need this? I could be confusing myself now 🥸

Suggested change
{% if property.required %}
{% if property.required and (not query_parameter or not property.nullable) %}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Wouldn't we be safe in that case by the {% if property.nullable %}if {{ source }} else None {%endif%} on L14?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh I see, I was thinking that in endpoint_macros we were only ignoring params that were UNSET, not also None.

Actually though I think I just spotted another issue but I can't comment on it. Line 25 in endpoint_macros will include all params if they are required, even if they are None. If we truly want to treat nullable and optional as the same thing we'll need to adjust that if and the one on line 35.

@@ -14,7 +14,7 @@ if _{{ property.python_name }} is not None:
{% endif %}
{% endmacro %}

{% macro transform(property, source, destination, declare_type=True) %}
{% macro transform(property, source, destination, declare_type=True, query_parameter=False) %}
{% if property.required %}
{% if property.nullable %}
{{ destination }} = {{ source }}.isoformat() if {{ source }} else None
Copy link
Collaborator

Choose a reason for hiding this comment

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

There's probably something in here too. I guess we need some end to end tests with just nullable or just non-required to make sure we're covering those bases? We don't want to ever pass None to a query param. I'm going to stop writing this same note over and over now 😅

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I added every permutation of nullable, required for the datetime query parameter to the E2E test.

However, I wasn't sure how far to take it--ideally it would cover all types, but that would be an exponential explosion 😬

@@ -31,13 +31,35 @@ def test_get_type_string(self, mocker):
assert p.get_type_string(no_optional=True) == base_type_string

p = Property(name="test", required=False, default=None, nullable=True)
assert p.get_type_string() == f"Union[Unset, Optional[{base_type_string}]]"
assert p.get_type_string() == f"Union[Unset, None, {base_type_string}]"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Honestly I like this style better overall. I think the Optional name gets confusing in some cases (the whole reason we're changing this behavior...).


p = Property(name="test", required=False, default=None, nullable=False)
assert p.get_type_string(query_parameter=True) == f"Union[Unset, None, {base_type_string}]"
assert p.get_type_string(no_optional=True, query_parameter=True) == base_type_string
Copy link
Collaborator

Choose a reason for hiding this comment

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

This feels like maybe a good opportunity to use @pytest.mark.parametrize to make the different test cases and expected outcomes easier to read. Not at all required (clearly lots of other tests could use it but don't) but could be nice.

@dbanty dbanty added this to the 0.8.0 milestone Jan 16, 2021
@forest-benchling
Copy link
Collaborator Author

@dbanty PTAL at the new changeset. While addressing your comments, I realized that the existing code generation wasn't correctly handling the case where a query parameter is nullable but not required.

@forest-benchling
Copy link
Collaborator Author

@dbanty (cc @bowenwr) I made another change in the latest commit to pass a json kwarg to get_type_string, reflecting whether the type is the type after JSON serialization. This change cleans up the logic in the property templates, but along the way it also fixes a couple pre-existing bugs:

  • The types of union properties and model properties were declared incorrectly in to_dict
  • Enums were not correctly being serialized in to_dict

@dbanty
Copy link
Collaborator

dbanty commented Jan 26, 2021

I haven't forgotten about this I promise, just been a bit busy 😅. Thanks so much for putting all this work in, I'll review as soon as I can!

return type_string
if self.required:
if self.nullable:
return f"Optional[{type_string}]"
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we're still not in alignment on design here. nullable should be the same as not required semantically when considering a query parameter. So if required and nullable and query_parameter it should be returning f"Union[Unset, None, {type_string}]". I believe my previous suggestion to this (and the other, similar) code block makes this happen.

It's possible I'm missing or misunderstanding something about this, and if so please let me know. Otherwise I'll hold off additional review until you make that change.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hmm...if required and nullable and query_parameter are all True, wouldn't we want the type to be Optional[{type_string}], not allowing Unset? Otherwise, if you allowed Unset, why would you make it "required"?

Or is it different for query parameters, where "required" would still allow Unset to pass through.

Copy link
Collaborator

Choose a reason for hiding this comment

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

It's different for query parameters. Basically we're not allowing nullable query parameters and instead treating nullable like not required. Additionally, we're allowing None to function just like Unset, but either way we're not passing through the parameter. I think having a different result for nullable and required might infer to the user that we can/will actually pass through a None, which we will not.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@dbanty I started implementing this approach, but it causes a bunch of other errors because in the templates, we are assuming that if something is required and nullable, then we only need to check for None, whereas with this change we now also need to check for Unset.

I think this is getting pretty complicated and hard to reason about, and I worry about future maintainability. Maybe we should just make the type Optional if it's either not required or nullable, and throw away Unset in the case of query parameters? cc @bowenwr

Copy link
Contributor

Choose a reason for hiding this comment

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

In my original implementation of Unset I didn't apply it to query parameters. I'm personally in favor of that approach.

Copy link
Collaborator

Choose a reason for hiding this comment

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

If we have to pick one or the other I'd prefer Unset over None because it better indicates what we're doing with that parameter to the user. It also happens to be the behavior of the released version for non-required query params. What's your opinion @emann?

@dbanty
Copy link
Collaborator

dbanty commented Feb 8, 2021

Closing in favor of #331 which, while not nearly as cool, brings us in line with the spec with a relatively simple change. Thank you so much for all your hard work on this @forest-benchling , sorry we couldn't come up with a better solution that would more cleanly solve Benchling's problem while appeasing my annoying sensibilities 😅. Hopefully #331 is good enough at least for now and we can talk about other options in the future if it's not.

@dbanty dbanty closed this Feb 8, 2021
@eli-bl eli-bl deleted the benchling-issue-285 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.

Use Optional for Query Params Instead of Union[Unset, ...]
3 participants