From bc06d720efa653539dc62835446c7ceaabc02cd8 Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Thu, 19 Jan 2023 01:06:02 -0700 Subject: [PATCH 01/20] fields & fieldlists --- .../smithy_python/_private/http/__init__.py | 161 ++++++++++++++++-- .../smithy_python/interfaces/http.py | 85 +++++++++ .../tests/unit/test_http_fields.py | 145 ++++++++++++++++ 3 files changed, 377 insertions(+), 14 deletions(-) create mode 100644 python-packages/smithy-python/tests/unit/test_http_fields.py diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index fda29f9bc..1a9029e86 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -14,11 +14,13 @@ # TODO: move all of this out of _private +from collections import OrderedDict from dataclasses import dataclass, field from typing import Any, Protocol from urllib.parse import urlparse, urlunparse -from smithy_python.interfaces import http as http_interface +from ... import interfaces +from ...interfaces.http import FieldPosition as FieldPosition # re-export class URI: @@ -91,16 +93,15 @@ def __eq__(self, other: object) -> bool: class Request: def __init__( self, - url: http_interface.URI, + url: interfaces.http.URI, method: str = "GET", - headers: http_interface.HeadersList | None = None, + headers: interfaces.http.HeadersList | None = None, body: Any = None, ): - self.url: http_interface.URI = url + self.url: interfaces.http.URI = url self.method: str = method self.body: Any = body - - self.headers: http_interface.HeadersList = [] + self.headers: interfaces.http.HeadersList = [] if headers is not None: self.headers = headers @@ -109,18 +110,150 @@ class Response: def __init__( self, status_code: int, - headers: http_interface.HeadersList, + headers: interfaces.http.HeadersList, body: Any, ): self.status_code: int = status_code - self.headers: http_interface.HeadersList = headers + self.headers: interfaces.http.HeadersList = headers self.body: Any = body +class Field(interfaces.http.Field): + """ + A name-value pair representing a single field in an HTTP Request or Response. + + The kind will dictate metadata placement within an HTTP message. + + All field names are case insensitive and case-variance must be treated as + equivalent. Names may be normalized but should be preserved for accuracy during + transmission. + """ + + def __init__( + self, + name: str, + value: list[str] | None = None, + kind: FieldPosition = FieldPosition.HEADER, + ) -> None: + self.name = name + self.value = value + self.kind = kind + + def add(self, value: str) -> None: + """Append a value to a field""" + if self.value is None: + self.value = [value] + else: + self.value.append(value) + + def set(self, value: list[str]) -> None: + """Overwrite existing field values.""" + self.value = value + + def remove(self, value: str) -> None: + """Remove all matching entries from list""" + if self.value is None: + return + try: + while True: + self.value.remove(value) + except ValueError: + return + + def _quote_and_escape_single_value(self, value: str) -> str: + """Escapes and quotes a single value if necessary. + + A value is surrounded by double quotes if it contains comma (,) or whitespace. + Any double quote characters present in the value (before quoting) are escaped + with a backslash. + """ + escaped = value.replace('"', '\\"') + needs_quoting = any(char == "," or char.isspace() for char in escaped) + quoted = f'"{escaped}"' if needs_quoting else escaped + return quoted + + def get_value(self) -> str: + """ + Get comma-delimited string values. + + Values with spaces or commas are double-quoted. + """ + if self.value is None: + return "" + return ",".join(self._quote_and_escape_single_value(val) for val in self.value) + + def get_value_list(self) -> list[str]: + """Get string values as a list""" + if self.value is None: + return [] + else: + return self.value + + def __eq__(self, other: object) -> bool: + """Name, values, and kind must match. Values order must match.""" + if not isinstance(other, Field): + return False + return ( + self.name == other.name + and self.kind == other.kind + and self.value == other.value + ) + + def __repr__(self) -> str: + return f"Field({self.kind.name} {self.name}: {self.get_value()})" + + +class Fields(interfaces.http.Fields): + """Collection of Field entries mapped by name.""" + + def __init__( + self, + initial: list[interfaces.http.Field] | None = None, + *, + encoding: str = "utf-8", + ) -> None: + init_tuples = [] if initial is None else [(fld.name, fld) for fld in initial] + self.entries: OrderedDict[str, interfaces.http.Field] = OrderedDict(init_tuples) + self.encoding: str = encoding + + def set_field(self, field: interfaces.http.Field) -> None: + """Set entry for a Field name.""" + self.entries[field.name] = field + + def get_field(self, name: str) -> interfaces.http.Field: + """Retrieve Field entry""" + return self.entries[name] + + def remove_field(self, name: str) -> None: + """Delete entry from collection""" + del self.entries[name] + + def get_by_type(self, kind: FieldPosition) -> list[interfaces.http.Field]: + """Helper function for retrieving specific types of fields + + Used to grab all headers or all trailers + """ + return [entry for entry in self.entries.values() if entry.kind == kind] + + def __eq__(self, other: object) -> bool: + """Encoding must match. Entries must match in values but not order.""" + if not isinstance(other, Fields): + return False + if self.encoding != other.encoding: + return False + if set(self.entries.keys()) != set(other.entries.keys()): + return False + for field_name, self_field in self.entries.items(): + other_field = other.get_field(field_name) + if self_field != other_field: + return False + return True + + @dataclass -class Endpoint(http_interface.Endpoint): - url: http_interface.URI - headers: http_interface.HeadersList = field(default_factory=list) +class Endpoint(interfaces.http.Endpoint): + url: interfaces.http.URI + headers: interfaces.http.HeadersList = field(default_factory=list) @dataclass @@ -131,10 +264,10 @@ class StaticEndpointParams: :params url: A static URI to route requests to. """ - url: str | http_interface.URI + url: str | interfaces.http.URI -class StaticEndpointResolver(http_interface.EndpointResolver[StaticEndpointParams]): +class StaticEndpointResolver(interfaces.http.EndpointResolver[StaticEndpointParams]): """A basic endpoint resolver that forwards a static url.""" async def resolve_endpoint(self, params: StaticEndpointParams) -> Endpoint: @@ -164,7 +297,7 @@ async def resolve_endpoint(self, params: StaticEndpointParams) -> Endpoint: class _StaticEndpointConfig(Protocol): - endpoint_resolver: http_interface.EndpointResolver[StaticEndpointParams] | None + endpoint_resolver: interfaces.http.EndpointResolver[StaticEndpointParams] | None def set_static_endpoint_resolver(config: _StaticEndpointConfig) -> None: diff --git a/python-packages/smithy-python/smithy_python/interfaces/http.py b/python-packages/smithy-python/smithy_python/interfaces/http.py index 6070f9cfe..ff77ee58a 100644 --- a/python-packages/smithy-python/smithy_python/interfaces/http.py +++ b/python-packages/smithy-python/smithy_python/interfaces/http.py @@ -1,4 +1,18 @@ +# Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +from collections import OrderedDict from dataclasses import dataclass +from enum import Enum from typing import Any, Protocol, TypeVar # Defining headers as a list instead of a mapping to avoid ambiguity and @@ -7,6 +21,77 @@ QueryParamsList = list[tuple[str, str]] +class FieldPosition(Enum): + HEADER = 0 + TRAILER = 1 + + +class Field(Protocol): + """ + A name-value pair representing a single field in an HTTP Request or Response. + + The kind will dictate metadata placement within an HTTP message. + + All field names are case insensitive and case-variance must be treated as + equivalent. Names may be normalized but should be preserved for accuracy during + transmission. + """ + + name: str + value: list[str] | None = None + kind: FieldPosition = FieldPosition.HEADER + + def add(self, value: str) -> None: + """Append a value to a field""" + ... + + def set(self, value: list[str]) -> None: + """Overwrite existing field values""" + ... + + def remove(self, value: str) -> None: + """Remove all matching entries from list""" + ... + + def get_value(self) -> str: + """Get comma-delimited string. + + Values containing commas or quotes are double-quoted. + """ + ... + + def get_value_list(self) -> list[str]: + """Get string values as a list""" + ... + + +class Fields: + """Collection of Field entries mapped by name.""" + + # Entries are keyed off the name of a provided Field + entries: OrderedDict[str, Field] + encoding: str | None = "utf-8" + + def set_field(self, field: Field) -> None: + """Set entry for a Field name.""" + ... + + def get_field(self, name: str) -> Field: + """Retrieve Field entry""" + ... + + def remove_field(self, name: str) -> None: + """Delete entry from collection""" + ... + + def get_by_type(self, kind: FieldPosition) -> list[Field]: + """Helper function for retrieving specific types of fields + + Used to grab all headers or all trailers + """ + ... + + class URI(Protocol): """Universal Resource Identifier, target location for a :py:class:`Request`.""" diff --git a/python-packages/smithy-python/tests/unit/test_http_fields.py b/python-packages/smithy-python/tests/unit/test_http_fields.py new file mode 100644 index 000000000..6a0215b4d --- /dev/null +++ b/python-packages/smithy-python/tests/unit/test_http_fields.py @@ -0,0 +1,145 @@ +# Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. + +# mypy: allow-untyped-defs +# mypy: allow-incomplete-defs + +import pytest + +from smithy_python._private.http import Field, FieldPosition, Fields + + +def test_field_single_valued_basics() -> None: + field = Field("fname", ["fval"], FieldPosition.HEADER) + assert field.name == "fname" + assert field.kind == FieldPosition.HEADER + assert field.value == ["fval"] + assert field.get_value() == "fval" + assert field.get_value_list() == ["fval"] + + +def test_field_multi_valued_basics() -> None: + field = Field("fname", ["fval1", "fval2"], FieldPosition.HEADER) + assert field.name == "fname" + assert field.kind == FieldPosition.HEADER + assert field.value == ["fval1", "fval2"] + assert field.get_value() == "fval1,fval2" + assert field.get_value_list() == ["fval1", "fval2"] + + +@pytest.mark.parametrize( + "values,expected", + [ + (["val1"], "val1"), + (["val1", "val2"], "val1,val2"), + (["©väl", "val2"], "©väl,val2"), + # Values with spaces or commas must be double-quoted. + ([" val1 ", "val2"], '" val1 ",val2'), + (["v,a,l,1", "val2"], '"v,a,l,1",val2'), + # Double quotes are escaped with a single backslash. The second backslash below + # is for escaping the actual backslash in the string for Python. + (['"quotes"', "val2"], '\\"quotes\\",val2'), + ], +) +def test_field_serialization(values, expected): + field = Field(name="_", value=values) + assert field.get_value() == expected + + +@pytest.mark.parametrize( + "f1,f2", + [ + ( + Field("fname", ["fval1", "fval2"], FieldPosition.TRAILER), + Field("fname", ["fval1", "fval2"], FieldPosition.TRAILER), + ), + ( + Field("fname", ["fval1", "fval2"]), + Field("fname", ["fval1", "fval2"]), + ), + ( + Field("fname"), + Field("fname"), + ), + ], +) +def test_field_equality(f1, f2) -> None: + assert f1 == f2 + + +@pytest.mark.parametrize( + "f1,f2", + [ + ( + Field("fname", ["fval1", "fval2"], FieldPosition.HEADER), + Field("fname", ["fval1", "fval2"], FieldPosition.TRAILER), + ), + ( + Field("fname", ["fval1", "fval2"], FieldPosition.HEADER), + Field("fname", ["fval2", "fval1"], FieldPosition.HEADER), + ), + ( + Field("fname", ["fval1", "fval2"], FieldPosition.HEADER), + Field("fname", ["fval1"], FieldPosition.HEADER), + ), + ( + Field("fname1", ["fval1", "fval2"], FieldPosition.HEADER), + Field("fname2", ["fval1", "fval2"], FieldPosition.HEADER), + ), + ], +) +def test_field_inqueality(f1, f2) -> None: + assert f1 != f2 + + +@pytest.mark.parametrize( + "fs1,fs2", + [ + ( + Fields([Field(name="fname", value=["fval1", "fval2"])]), + Fields([Field(name="fname", value=["fval1", "fval2"])]), + ), + # field order does not matter (but value-within-field order does) + ( + Fields([Field(name="f1"), Field(name="f2")]), + Fields([Field(name="f2"), Field(name="f1")]), + ), + ], +) +def test_fields_equality(fs1, fs2) -> None: + assert fs1 == fs2 + + +@pytest.mark.parametrize( + "fs1,fs2", + [ + ( + Fields(), + Fields([Field(name="fname")]), + ), + ( + Fields(encoding="utf-1"), + Fields(encoding="utf-2"), + ), + ( + Fields([Field(name="fname", value=["val1"])]), + Fields([Field(name="fname", value=["val2"])]), + ), + ( + Fields([Field(name="fname", value=["val2", "val1"])]), + Fields([Field(name="fname", value=["val1", "val2"])]), + ), + ], +) +def test_fields_inequality(fs1, fs2) -> None: + assert fs1 != fs2 From 0dd4bcb3112ea103f02fb8c12a739aa5131bfaeb Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Thu, 19 Jan 2023 13:42:12 -0700 Subject: [PATCH 02/20] docstrings --- .../smithy_python/_private/http/__init__.py | 12 ++++++--- .../smithy_python/interfaces/http.py | 25 ++++++++++++++++--- .../tests/unit/test_http_fields.py | 2 +- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index 1a9029e86..a04c56f52 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -204,14 +204,20 @@ def __repr__(self) -> str: class Fields(interfaces.http.Fields): - """Collection of Field entries mapped by name.""" - def __init__( self, initial: list[interfaces.http.Field] | None = None, *, encoding: str = "utf-8", ) -> None: + """ + Collection of header and trailer entries mapped by name. + + :param initial: Initial list of ``Field`` objects. ``Field``s can alse be added + with :func:`set_field` and later removed with :func:`remove_field`. + :param encoding: The string encoding to be used when converting the ``Field`` + name and value from ``str`` to ``bytes`` for transmission. + """ init_tuples = [] if initial is None else [(fld.name, fld) for fld in initial] self.entries: OrderedDict[str, interfaces.http.Field] = OrderedDict(init_tuples) self.encoding: str = encoding @@ -233,7 +239,7 @@ def get_by_type(self, kind: FieldPosition) -> list[interfaces.http.Field]: Used to grab all headers or all trailers """ - return [entry for entry in self.entries.values() if entry.kind == kind] + return [entry for entry in self.entries.values() if entry.kind is kind] def __eq__(self, other: object) -> bool: """Encoding must match. Entries must match in values but not order.""" diff --git a/python-packages/smithy-python/smithy_python/interfaces/http.py b/python-packages/smithy-python/smithy_python/interfaces/http.py index ff77ee58a..0502efce9 100644 --- a/python-packages/smithy-python/smithy_python/interfaces/http.py +++ b/python-packages/smithy-python/smithy_python/interfaces/http.py @@ -1,4 +1,4 @@ -# Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"). You # may not use this file except in compliance with the License. A copy of @@ -22,15 +22,30 @@ class FieldPosition(Enum): + """ + The type of a field. Defines its placement in a request or response. + """ HEADER = 0 + """ + Header field. In HTTP this is a header as defined in RFC 9114 Section 6.3. + Implementations of other protocols may use this FieldPosition for similar types + of metadata. + """ + TRAILER = 1 + """ + Trailer field. In HTTP this is a trailer as defined in RFC 9114 Section 6.5. + Implementations of other protocols may use this FieldPosition for similar types + of metadata. + """ class Field(Protocol): """ - A name-value pair representing a single field in an HTTP Request or Response. + A name-value pair representing a single field in a request or response - The kind will dictate metadata placement within an HTTP message. + The kind will dictate metadata placement within an the message, for example as + header or trailer field in a HTTP request as defined in RFC 9114 Section 4.2. All field names are case insensitive and case-variance must be treated as equivalent. Names may be normalized but should be preserved for accuracy during @@ -66,7 +81,9 @@ def get_value_list(self) -> list[str]: class Fields: - """Collection of Field entries mapped by name.""" + """ + Protocol agnostic mapping of key-value pair request metadata, such as HTTP fields. + """ # Entries are keyed off the name of a provided Field entries: OrderedDict[str, Field] diff --git a/python-packages/smithy-python/tests/unit/test_http_fields.py b/python-packages/smithy-python/tests/unit/test_http_fields.py index 6a0215b4d..7036ce449 100644 --- a/python-packages/smithy-python/tests/unit/test_http_fields.py +++ b/python-packages/smithy-python/tests/unit/test_http_fields.py @@ -1,4 +1,4 @@ -# Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"). You # may not use this file except in compliance with the License. A copy of From c4a9b3fcc3bd8baeaa45750e2a2904ff21062611 Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Thu, 19 Jan 2023 17:08:50 -0700 Subject: [PATCH 03/20] Field.value cannot be None, test escaping of backslashes --- .../smithy_python/_private/http/__init__.py | 22 +++++-------------- .../smithy_python/interfaces/http.py | 1 + .../tests/unit/test_http_fields.py | 4 ++++ 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index a04c56f52..d0f9bed2f 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -136,15 +136,12 @@ def __init__( kind: FieldPosition = FieldPosition.HEADER, ) -> None: self.name = name - self.value = value + self.value: list[str] = value if value is not None else [] self.kind = kind def add(self, value: str) -> None: """Append a value to a field""" - if self.value is None: - self.value = [value] - else: - self.value.append(value) + self.value.append(value) def set(self, value: list[str]) -> None: """Overwrite existing field values.""" @@ -152,8 +149,6 @@ def set(self, value: list[str]) -> None: def remove(self, value: str) -> None: """Remove all matching entries from list""" - if self.value is None: - return try: while True: self.value.remove(value) @@ -174,20 +169,15 @@ def _quote_and_escape_single_value(self, value: str) -> str: def get_value(self) -> str: """ - Get comma-delimited string values. + Get comma-delimited string of values. Values with spaces or commas are double-quoted. """ - if self.value is None: - return "" return ",".join(self._quote_and_escape_single_value(val) for val in self.value) def get_value_list(self) -> list[str]: - """Get string values as a list""" - if self.value is None: - return [] - else: - return self.value + """Get values as a list of strings.""" + return self.value def __eq__(self, other: object) -> bool: """Name, values, and kind must match. Values order must match.""" @@ -199,7 +189,7 @@ def __eq__(self, other: object) -> bool: and self.value == other.value ) - def __repr__(self) -> str: + def __str__(self) -> str: return f"Field({self.kind.name} {self.name}: {self.get_value()})" diff --git a/python-packages/smithy-python/smithy_python/interfaces/http.py b/python-packages/smithy-python/smithy_python/interfaces/http.py index 0502efce9..1342f1d99 100644 --- a/python-packages/smithy-python/smithy_python/interfaces/http.py +++ b/python-packages/smithy-python/smithy_python/interfaces/http.py @@ -25,6 +25,7 @@ class FieldPosition(Enum): """ The type of a field. Defines its placement in a request or response. """ + HEADER = 0 """ Header field. In HTTP this is a header as defined in RFC 9114 Section 6.3. diff --git a/python-packages/smithy-python/tests/unit/test_http_fields.py b/python-packages/smithy-python/tests/unit/test_http_fields.py index 7036ce449..e6dfd00b2 100644 --- a/python-packages/smithy-python/tests/unit/test_http_fields.py +++ b/python-packages/smithy-python/tests/unit/test_http_fields.py @@ -49,6 +49,10 @@ def test_field_multi_valued_basics() -> None: # Double quotes are escaped with a single backslash. The second backslash below # is for escaping the actual backslash in the string for Python. (['"quotes"', "val2"], '\\"quotes\\",val2'), + # Backslashes are also escaped. The following case is a single backslash getting + # serialized into two backslashes. Python escaping accounts for each actual + # backslash being written as two. + (["foo,bar\\", "val2"], '"foo,bar\\\\",val2'), ], ) def test_field_serialization(values, expected): From f635bd4af97c3b394c92cf519d5d8c60243e29ad Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Thu, 19 Jan 2023 18:32:36 -0700 Subject: [PATCH 04/20] missing "Protocol" --- python-packages/smithy-python/smithy_python/interfaces/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python-packages/smithy-python/smithy_python/interfaces/http.py b/python-packages/smithy-python/smithy_python/interfaces/http.py index 1342f1d99..a2cc3f2c6 100644 --- a/python-packages/smithy-python/smithy_python/interfaces/http.py +++ b/python-packages/smithy-python/smithy_python/interfaces/http.py @@ -81,7 +81,7 @@ def get_value_list(self) -> list[str]: ... -class Fields: +class Fields(Protocol): """ Protocol agnostic mapping of key-value pair request metadata, such as HTTP fields. """ From 20fe974ea67607fccd02050d3dea79eded33d427 Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Thu, 19 Jan 2023 20:05:46 -0700 Subject: [PATCH 05/20] dots in docstrings Co-authored-by: Nate Prewitt --- .../smithy_python/_private/http/__init__.py | 12 ++++++------ .../smithy-python/smithy_python/interfaces/http.py | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index d0f9bed2f..ef88222e0 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -140,7 +140,7 @@ def __init__( self.kind = kind def add(self, value: str) -> None: - """Append a value to a field""" + """Append a value to a field.""" self.value.append(value) def set(self, value: list[str]) -> None: @@ -148,7 +148,7 @@ def set(self, value: list[str]) -> None: self.value = value def remove(self, value: str) -> None: - """Remove all matching entries from list""" + """Remove all matching entries from list.""" try: while True: self.value.remove(value) @@ -217,17 +217,17 @@ def set_field(self, field: interfaces.http.Field) -> None: self.entries[field.name] = field def get_field(self, name: str) -> interfaces.http.Field: - """Retrieve Field entry""" + """Retrieve Field entry.""" return self.entries[name] def remove_field(self, name: str) -> None: - """Delete entry from collection""" + """Delete entry from collection.""" del self.entries[name] def get_by_type(self, kind: FieldPosition) -> list[interfaces.http.Field]: - """Helper function for retrieving specific types of fields + """Helper function for retrieving specific types of fields. - Used to grab all headers or all trailers + Used to grab all headers or all trailers. """ return [entry for entry in self.entries.values() if entry.kind is kind] diff --git a/python-packages/smithy-python/smithy_python/interfaces/http.py b/python-packages/smithy-python/smithy_python/interfaces/http.py index a2cc3f2c6..6f38d133a 100644 --- a/python-packages/smithy-python/smithy_python/interfaces/http.py +++ b/python-packages/smithy-python/smithy_python/interfaces/http.py @@ -62,11 +62,11 @@ def add(self, value: str) -> None: ... def set(self, value: list[str]) -> None: - """Overwrite existing field values""" + """Overwrite existing field values.""" ... def remove(self, value: str) -> None: - """Remove all matching entries from list""" + """Remove all matching entries from list.""" ... def get_value(self) -> str: @@ -77,7 +77,7 @@ def get_value(self) -> str: ... def get_value_list(self) -> list[str]: - """Get string values as a list""" + """Get string values as a list.""" ... @@ -95,17 +95,17 @@ def set_field(self, field: Field) -> None: ... def get_field(self, name: str) -> Field: - """Retrieve Field entry""" + """Retrieve Field entry.""" ... def remove_field(self, name: str) -> None: - """Delete entry from collection""" + """Delete entry from collection.""" ... def get_by_type(self, kind: FieldPosition) -> list[Field]: - """Helper function for retrieving specific types of fields + """Helper function for retrieving specific types of fields. - Used to grab all headers or all trailers + Used to grab all headers or all trailers. """ ... From db119020005f831c743f8896a0ac383d1cdd10ce Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Thu, 19 Jan 2023 23:30:18 -0700 Subject: [PATCH 06/20] updated quoting & escaping rules --- .../smithy_python/_private/http/__init__.py | 17 ++++++++++------- .../tests/unit/test_http_fields.py | 19 ++++++++++++++----- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index ef88222e0..6a2e1e192 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -158,14 +158,17 @@ def remove(self, value: str) -> None: def _quote_and_escape_single_value(self, value: str) -> str: """Escapes and quotes a single value if necessary. - A value is surrounded by double quotes if it contains comma (,) or whitespace. - Any double quote characters present in the value (before quoting) are escaped - with a backslash. + A value that contains comma (,) is quoted (surrounded by double quotes) unless + it already starts and ends with double quotes. If a value gets quoted, any + double quotes contained within the value are escaped as a quoted-pair (prefixed + with a backslash). """ - escaped = value.replace('"', '\\"') - needs_quoting = any(char == "," or char.isspace() for char in escaped) - quoted = f'"{escaped}"' if needs_quoting else escaped - return quoted + if value.startswith('"') and value.endswith('"'): + return value + if not "," in value: + return value + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' def get_value(self) -> str: """ diff --git a/python-packages/smithy-python/tests/unit/test_http_fields.py b/python-packages/smithy-python/tests/unit/test_http_fields.py index e6dfd00b2..7a3234936 100644 --- a/python-packages/smithy-python/tests/unit/test_http_fields.py +++ b/python-packages/smithy-python/tests/unit/test_http_fields.py @@ -43,12 +43,21 @@ def test_field_multi_valued_basics() -> None: (["val1"], "val1"), (["val1", "val2"], "val1,val2"), (["©väl", "val2"], "©väl,val2"), - # Values with spaces or commas must be double-quoted. - ([" val1 ", "val2"], '" val1 ",val2'), + # Values containing commas must be double-quoted. + (["val1,", "val2"], '"val1,",val2'), (["v,a,l,1", "val2"], '"v,a,l,1",val2'), - # Double quotes are escaped with a single backslash. The second backslash below - # is for escaping the actual backslash in the string for Python. - (['"quotes"', "val2"], '\\"quotes\\",val2'), + # ... but if the value is already quoted, it will not be quoted again. + (['"v,a,l,1"', "val2"], '"v,a,l,1",val2'), + # In strings the got quoted, pre-existing double quotes are escaped with a + # single backslash. The second backslash below is for escaping the actual + # backslash in the string for Python. + (["slc", '4,196"'], 'slc,"4,196\\""'), + # ... unless they appear at the beginning AND end of the value, i.e. the value + # is already quoted. + (['"quoted"', "val2"], '"quoted",val2'), + # If the value is already quoted, additional quotes contained within do not get + # escaped. + (['"""', "val2"], '""",val2'), # Backslashes are also escaped. The following case is a single backslash getting # serialized into two backslashes. Python escaping accounts for each actual # backslash being written as two. From 09b52630ff19cfd01f9d845ac483fc206fb9cfcb Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Fri, 20 Jan 2023 00:13:02 -0700 Subject: [PATCH 07/20] updated equality rules, check for duplicated initial field names --- .../smithy_python/_private/http/__init__.py | 20 +++++++--------- .../tests/unit/test_http_fields.py | 23 +++++++++++++++---- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index 6a2e1e192..9c2a99ea3 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -165,7 +165,7 @@ def _quote_and_escape_single_value(self, value: str) -> str: """ if value.startswith('"') and value.endswith('"'): return value - if not "," in value: + if "," not in value: return value escaped = value.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' @@ -211,7 +211,11 @@ def __init__( :param encoding: The string encoding to be used when converting the ``Field`` name and value from ``str`` to ``bytes`` for transmission. """ - init_tuples = [] if initial is None else [(fld.name, fld) for fld in initial] + init_fields = [] if initial is None else [fld for fld in initial] + init_field_names = [fld.name for fld in init_fields] + if len(init_field_names) != len(set(init_field_names)): + raise ValueError("Field names of the initial list of fields must be unique") + init_tuples = zip(init_field_names, init_fields) self.entries: OrderedDict[str, interfaces.http.Field] = OrderedDict(init_tuples) self.encoding: str = encoding @@ -235,18 +239,10 @@ def get_by_type(self, kind: FieldPosition) -> list[interfaces.http.Field]: return [entry for entry in self.entries.values() if entry.kind is kind] def __eq__(self, other: object) -> bool: - """Encoding must match. Entries must match in values but not order.""" + """Encoding must match. Entries must match in values and order.""" if not isinstance(other, Fields): return False - if self.encoding != other.encoding: - return False - if set(self.entries.keys()) != set(other.entries.keys()): - return False - for field_name, self_field in self.entries.items(): - other_field = other.get_field(field_name) - if self_field != other_field: - return False - return True + return self.encoding == other.encoding and self.entries == other.entries @dataclass diff --git a/python-packages/smithy-python/tests/unit/test_http_fields.py b/python-packages/smithy-python/tests/unit/test_http_fields.py index 7a3234936..3f112a1d1 100644 --- a/python-packages/smithy-python/tests/unit/test_http_fields.py +++ b/python-packages/smithy-python/tests/unit/test_http_fields.py @@ -122,11 +122,6 @@ def test_field_inqueality(f1, f2) -> None: Fields([Field(name="fname", value=["fval1", "fval2"])]), Fields([Field(name="fname", value=["fval1", "fval2"])]), ), - # field order does not matter (but value-within-field order does) - ( - Fields([Field(name="f1"), Field(name="f2")]), - Fields([Field(name="f2"), Field(name="f1")]), - ), ], ) def test_fields_equality(fs1, fs2) -> None: @@ -140,6 +135,10 @@ def test_fields_equality(fs1, fs2) -> None: Fields(), Fields([Field(name="fname")]), ), + ( + Fields([Field(name="fname1")]), + Fields([Field(name="fname2")]), + ), ( Fields(encoding="utf-1"), Fields(encoding="utf-2"), @@ -152,7 +151,21 @@ def test_fields_equality(fs1, fs2) -> None: Fields([Field(name="fname", value=["val2", "val1"])]), Fields([Field(name="fname", value=["val1", "val2"])]), ), + ( + Fields([Field(name="f1"), Field(name="f2")]), + Fields([Field(name="f2"), Field(name="f1")]), + ), ], ) def test_fields_inequality(fs1, fs2) -> None: assert fs1 != fs2 + + +def test_repeated_initial_field_names() -> None: + with pytest.raises(ValueError): + Fields( + [ + Field(name="fname1", value=["val1"]), + Field(name="fname1", value=["val2"]), + ] + ) From 356cf1838fa17fc88880ff678e8ba4008586cd6a Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Fri, 20 Jan 2023 00:15:31 -0700 Subject: [PATCH 08/20] no return type annotation for __init__ --- .../smithy-python/smithy_python/_private/http/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index 9c2a99ea3..a402b4740 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -134,7 +134,7 @@ def __init__( name: str, value: list[str] | None = None, kind: FieldPosition = FieldPosition.HEADER, - ) -> None: + ): self.name = name self.value: list[str] = value if value is not None else [] self.kind = kind @@ -202,7 +202,7 @@ def __init__( initial: list[interfaces.http.Field] | None = None, *, encoding: str = "utf-8", - ) -> None: + ): """ Collection of header and trailer entries mapped by name. From ff24fcb5833c585a784277385a932ff9f19be283 Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Fri, 20 Jan 2023 00:19:27 -0700 Subject: [PATCH 09/20] ", " instead of "," as field separator --- .../smithy_python/_private/http/__init__.py | 2 +- .../tests/unit/test_http_fields.py | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index a402b4740..6c104cbfd 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -176,7 +176,7 @@ def get_value(self) -> str: Values with spaces or commas are double-quoted. """ - return ",".join(self._quote_and_escape_single_value(val) for val in self.value) + return ", ".join(self._quote_and_escape_single_value(val) for val in self.value) def get_value_list(self) -> list[str]: """Get values as a list of strings.""" diff --git a/python-packages/smithy-python/tests/unit/test_http_fields.py b/python-packages/smithy-python/tests/unit/test_http_fields.py index 3f112a1d1..35b282f19 100644 --- a/python-packages/smithy-python/tests/unit/test_http_fields.py +++ b/python-packages/smithy-python/tests/unit/test_http_fields.py @@ -33,7 +33,7 @@ def test_field_multi_valued_basics() -> None: assert field.name == "fname" assert field.kind == FieldPosition.HEADER assert field.value == ["fval1", "fval2"] - assert field.get_value() == "fval1,fval2" + assert field.get_value() == "fval1, fval2" assert field.get_value_list() == ["fval1", "fval2"] @@ -41,27 +41,27 @@ def test_field_multi_valued_basics() -> None: "values,expected", [ (["val1"], "val1"), - (["val1", "val2"], "val1,val2"), - (["©väl", "val2"], "©väl,val2"), + (["val1", "val2"], "val1, val2"), + (["©väl", "val2"], "©väl, val2"), # Values containing commas must be double-quoted. - (["val1,", "val2"], '"val1,",val2'), - (["v,a,l,1", "val2"], '"v,a,l,1",val2'), + (["val1,", "val2"], '"val1,", val2'), + (["v,a,l,1", "val2"], '"v,a,l,1", val2'), # ... but if the value is already quoted, it will not be quoted again. - (['"v,a,l,1"', "val2"], '"v,a,l,1",val2'), + (['"v,a,l,1"', "val2"], '"v,a,l,1", val2'), # In strings the got quoted, pre-existing double quotes are escaped with a # single backslash. The second backslash below is for escaping the actual # backslash in the string for Python. - (["slc", '4,196"'], 'slc,"4,196\\""'), + (["slc", '4,196"'], 'slc, "4,196\\""'), # ... unless they appear at the beginning AND end of the value, i.e. the value # is already quoted. - (['"quoted"', "val2"], '"quoted",val2'), + (['"quoted"', "val2"], '"quoted", val2'), # If the value is already quoted, additional quotes contained within do not get # escaped. - (['"""', "val2"], '""",val2'), + (['"""', "val2"], '""", val2'), # Backslashes are also escaped. The following case is a single backslash getting # serialized into two backslashes. Python escaping accounts for each actual # backslash being written as two. - (["foo,bar\\", "val2"], '"foo,bar\\\\",val2'), + (["foo,bar\\", "val2"], '"foo,bar\\\\", val2'), ], ) def test_field_serialization(values, expected): From 06e5c5f47fac08a8aee753ef3c53e36846d42b6d Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Fri, 20 Jan 2023 00:32:21 -0700 Subject: [PATCH 10/20] __iter__ and improved __repr__ for Fields --- .../smithy_python/_private/http/__init__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index 6c104cbfd..6a1cad0b7 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -16,7 +16,7 @@ from collections import OrderedDict from dataclasses import dataclass, field -from typing import Any, Protocol +from typing import Any, Iterable, Protocol from urllib.parse import urlparse, urlunparse from ... import interfaces @@ -188,12 +188,12 @@ def __eq__(self, other: object) -> bool: return False return ( self.name == other.name - and self.kind == other.kind + and self.kind is other.kind and self.value == other.value ) - def __str__(self) -> str: - return f"Field({self.kind.name} {self.name}: {self.get_value()})" + def __repr__(self) -> str: + return f'Field(name="{self.name}", value=[{self.value}], kind={self.kind})' class Fields(interfaces.http.Fields): @@ -244,6 +244,9 @@ def __eq__(self, other: object) -> bool: return False return self.encoding == other.encoding and self.entries == other.entries + def __iter__(self) -> Iterable[interfaces.http.Field]: + yield from self.entries.values() + @dataclass class Endpoint(interfaces.http.Endpoint): From d13ee5c479e95b2ab1741781577f4bd38cfdf3b6 Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Fri, 20 Jan 2023 00:41:38 -0700 Subject: [PATCH 11/20] move quote_and_escape_field_value to utility method --- .../smithy_python/_private/http/__init__.py | 33 ++++++++++--------- .../smithy_python/interfaces/http.py | 2 +- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index 6a1cad0b7..b91c6c124 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -155,28 +155,13 @@ def remove(self, value: str) -> None: except ValueError: return - def _quote_and_escape_single_value(self, value: str) -> str: - """Escapes and quotes a single value if necessary. - - A value that contains comma (,) is quoted (surrounded by double quotes) unless - it already starts and ends with double quotes. If a value gets quoted, any - double quotes contained within the value are escaped as a quoted-pair (prefixed - with a backslash). - """ - if value.startswith('"') and value.endswith('"'): - return value - if "," not in value: - return value - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - def get_value(self) -> str: """ Get comma-delimited string of values. Values with spaces or commas are double-quoted. """ - return ", ".join(self._quote_and_escape_single_value(val) for val in self.value) + return ", ".join(quote_and_escape_field_value(val) for val in self.value) def get_value_list(self) -> list[str]: """Get values as a list of strings.""" @@ -196,6 +181,22 @@ def __repr__(self) -> str: return f'Field(name="{self.name}", value=[{self.value}], kind={self.kind})' +def quote_and_escape_field_value(value: str) -> str: + """Escapes and quotes a single :class:`Field` value if necessary. + + A value that contains comma (,) is quoted (surrounded by double quotes) unless + it already starts and ends with double quotes. If a value gets quoted, any + double quotes contained within the value are escaped as a quoted-pair (prefixed + with a backslash). + """ + if value.startswith('"') and value.endswith('"'): + return value + if "," not in value: + return value + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + class Fields(interfaces.http.Fields): def __init__( self, diff --git a/python-packages/smithy-python/smithy_python/interfaces/http.py b/python-packages/smithy-python/smithy_python/interfaces/http.py index 6f38d133a..ac1fc4477 100644 --- a/python-packages/smithy-python/smithy_python/interfaces/http.py +++ b/python-packages/smithy-python/smithy_python/interfaces/http.py @@ -58,7 +58,7 @@ class Field(Protocol): kind: FieldPosition = FieldPosition.HEADER def add(self, value: str) -> None: - """Append a value to a field""" + """Append a value to a field.""" ... def set(self, value: list[str]) -> None: From cf3d21941270c69f7ff37a12550d2cafea35f351 Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Sat, 21 Jan 2023 21:10:30 -0700 Subject: [PATCH 12/20] updated field value quoting and escaping rules --- .../smithy_python/_private/http/__init__.py | 33 +++++++++++------- .../smithy_python/interfaces/http.py | 5 +-- .../tests/unit/test_http_fields.py | 34 ++++++++++--------- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index b91c6c124..9627d527a 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -155,12 +155,24 @@ def remove(self, value: str) -> None: except ValueError: return - def get_value(self) -> str: + def as_string(self) -> str: """ - Get comma-delimited string of values. + Get comma-delimited string of all values. - Values with spaces or commas are double-quoted. + If the ``Field`` has zero values, the empty string is returned. If the ``Field`` + has exactly one value, the value is returned unmodified. + + For ``Field``s with more than one value, the values are joined by a comma and a + space. For such multi-valued ``Field``s, any values that already contain + commata or double quotes will be surrounded by double quotes. Within any values + that get quoted, pre-existing double quotes and backslashes are escaped with a + backslash. """ + value_count = len(self.value) + if value_count == 0: + return "" + if value_count == 1: + return self.value[0] return ", ".join(quote_and_escape_field_value(val) for val in self.value) def get_value_list(self) -> list[str]: @@ -184,17 +196,14 @@ def __repr__(self) -> str: def quote_and_escape_field_value(value: str) -> str: """Escapes and quotes a single :class:`Field` value if necessary. - A value that contains comma (,) is quoted (surrounded by double quotes) unless - it already starts and ends with double quotes. If a value gets quoted, any - double quotes contained within the value are escaped as a quoted-pair (prefixed - with a backslash). + See :func:`Field.as_string` for quoting and escaping logic. """ - if value.startswith('"') and value.endswith('"'): - return value - if "," not in value: + CHARS_TO_QUOTE = (",", '"') + if any(char in CHARS_TO_QUOTE for char in value): + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + else: return value - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' class Fields(interfaces.http.Fields): diff --git a/python-packages/smithy-python/smithy_python/interfaces/http.py b/python-packages/smithy-python/smithy_python/interfaces/http.py index ac1fc4477..9e21ffc78 100644 --- a/python-packages/smithy-python/smithy_python/interfaces/http.py +++ b/python-packages/smithy-python/smithy_python/interfaces/http.py @@ -69,8 +69,9 @@ def remove(self, value: str) -> None: """Remove all matching entries from list.""" ... - def get_value(self) -> str: - """Get comma-delimited string. + def as_string(self) -> str: + """Serialize the ``Field``'s values into a single line string.""" + ... Values containing commas or quotes are double-quoted. """ diff --git a/python-packages/smithy-python/tests/unit/test_http_fields.py b/python-packages/smithy-python/tests/unit/test_http_fields.py index 35b282f19..4ee57ccf1 100644 --- a/python-packages/smithy-python/tests/unit/test_http_fields.py +++ b/python-packages/smithy-python/tests/unit/test_http_fields.py @@ -24,7 +24,7 @@ def test_field_single_valued_basics() -> None: assert field.name == "fname" assert field.kind == FieldPosition.HEADER assert field.value == ["fval"] - assert field.get_value() == "fval" + assert field.as_string() == "fval" assert field.get_value_list() == ["fval"] @@ -33,40 +33,42 @@ def test_field_multi_valued_basics() -> None: assert field.name == "fname" assert field.kind == FieldPosition.HEADER assert field.value == ["fval1", "fval2"] - assert field.get_value() == "fval1, fval2" + assert field.as_string() == "fval1, fval2" assert field.get_value_list() == ["fval1", "fval2"] @pytest.mark.parametrize( "values,expected", [ + # Single-valued fields are serialized without any quoting or escaping. (["val1"], "val1"), + (['"val1"'], '"val1"'), + (['"'], '"'), + (['val"1'], 'val"1'), + (["val\\1"], "val\\1"), + # Multi-valued fields are joined with one comma and one space as separator. (["val1", "val2"], "val1, val2"), + (["val1", "val2", "val3", "val4"], "val1, val2, val3, val4"), (["©väl", "val2"], "©väl, val2"), # Values containing commas must be double-quoted. - (["val1,", "val2"], '"val1,", val2'), + (["val1", "val2,val3", "val4"], 'val1, "val2,val3", val4'), (["v,a,l,1", "val2"], '"v,a,l,1", val2'), - # ... but if the value is already quoted, it will not be quoted again. - (['"v,a,l,1"', "val2"], '"v,a,l,1", val2'), - # In strings the got quoted, pre-existing double quotes are escaped with a + # In strings that get quoted, pre-existing double quotes are escaped with a # single backslash. The second backslash below is for escaping the actual # backslash in the string for Python. (["slc", '4,196"'], 'slc, "4,196\\""'), - # ... unless they appear at the beginning AND end of the value, i.e. the value - # is already quoted. - (['"quoted"', "val2"], '"quoted", val2'), - # If the value is already quoted, additional quotes contained within do not get - # escaped. - (['"""', "val2"], '""", val2'), - # Backslashes are also escaped. The following case is a single backslash getting - # serialized into two backslashes. Python escaping accounts for each actual - # backslash being written as two. + (['"val1"', "val2"], '"\\"val1\\"", val2'), + (["val1", '"'], 'val1, "\\""'), + (['val1:2",val3:4"', "val5"], '"val1:2\\",val3:4\\"", val5'), + # If quoting happens, backslashes are also escaped. The following case is a + # single backslash getting serialized into two backslashes. Python escaping + # accounts for each actual backslash being written as two. (["foo,bar\\", "val2"], '"foo,bar\\\\", val2'), ], ) def test_field_serialization(values, expected): field = Field(name="_", value=values) - assert field.get_value() == expected + assert field.as_string() == expected @pytest.mark.parametrize( From af481e3d78d990c2b8f94ee8a09c9eb89ec37d0b Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Sat, 21 Jan 2023 21:15:06 -0700 Subject: [PATCH 13/20] drop Field.get_value_list(), Field.add as_tuples() --- .../smithy_python/_private/http/__init__.py | 8 +++++--- .../smithy-python/smithy_python/interfaces/http.py | 10 ++++------ .../smithy-python/tests/unit/test_http_fields.py | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index 9627d527a..a8288c85c 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -175,9 +175,11 @@ def as_string(self) -> str: return self.value[0] return ", ".join(quote_and_escape_field_value(val) for val in self.value) - def get_value_list(self) -> list[str]: - """Get values as a list of strings.""" - return self.value + def as_tuples(self) -> list[tuple[str, str]]: + """ + Get list of ``name``, ``value`` tuples where each tuple represents one value. + """ + return [(self.name, val) for val in self.value] def __eq__(self, other: object) -> bool: """Name, values, and kind must match. Values order must match.""" diff --git a/python-packages/smithy-python/smithy_python/interfaces/http.py b/python-packages/smithy-python/smithy_python/interfaces/http.py index 9e21ffc78..04dee3201 100644 --- a/python-packages/smithy-python/smithy_python/interfaces/http.py +++ b/python-packages/smithy-python/smithy_python/interfaces/http.py @@ -54,7 +54,7 @@ class Field(Protocol): """ name: str - value: list[str] | None = None + value: list[str] kind: FieldPosition = FieldPosition.HEADER def add(self, value: str) -> None: @@ -73,12 +73,10 @@ def as_string(self) -> str: """Serialize the ``Field``'s values into a single line string.""" ... - Values containing commas or quotes are double-quoted. + def as_tuples(self) -> list[tuple[str, str]]: + """ + Get list of ``name``, ``value`` tuples where each tuple represents one value. """ - ... - - def get_value_list(self) -> list[str]: - """Get string values as a list.""" ... diff --git a/python-packages/smithy-python/tests/unit/test_http_fields.py b/python-packages/smithy-python/tests/unit/test_http_fields.py index 4ee57ccf1..5af0d461c 100644 --- a/python-packages/smithy-python/tests/unit/test_http_fields.py +++ b/python-packages/smithy-python/tests/unit/test_http_fields.py @@ -25,7 +25,7 @@ def test_field_single_valued_basics() -> None: assert field.kind == FieldPosition.HEADER assert field.value == ["fval"] assert field.as_string() == "fval" - assert field.get_value_list() == ["fval"] + assert field.as_tuples() == [("fname", "fval")] def test_field_multi_valued_basics() -> None: @@ -34,7 +34,7 @@ def test_field_multi_valued_basics() -> None: assert field.kind == FieldPosition.HEADER assert field.value == ["fval1", "fval2"] assert field.as_string() == "fval1, fval2" - assert field.get_value_list() == ["fval1", "fval2"] + assert field.as_tuples() == [("fname", "fval1"), ("fname", "fval2")] @pytest.mark.parametrize( From 8060ea44ace5a1612334d813c593c77b41356451 Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Sat, 21 Jan 2023 21:52:00 -0700 Subject: [PATCH 14/20] normalize field names in Fields --- .../smithy_python/_private/http/__init__.py | 32 ++++++++++++++----- .../tests/unit/test_http_fields.py | 23 +++++++++---- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index a8288c85c..b2c76e8e4 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -14,7 +14,7 @@ # TODO: move all of this out of _private -from collections import OrderedDict +from collections import Counter, OrderedDict from dataclasses import dataclass, field from typing import Any, Iterable, Protocol from urllib.parse import urlparse, urlunparse @@ -223,25 +223,37 @@ def __init__( :param encoding: The string encoding to be used when converting the ``Field`` name and value from ``str`` to ``bytes`` for transmission. """ - init_fields = [] if initial is None else [fld for fld in initial] - init_field_names = [fld.name for fld in init_fields] - if len(init_field_names) != len(set(init_field_names)): - raise ValueError("Field names of the initial list of fields must be unique") + init_fields = [fld for fld in initial] if initial is not None else [] + init_field_names = [self._normalize_field_name(fld.name) for fld in init_fields] + fname_counter = Counter(init_field_names) + repeated_names_exist = ( + len(init_fields) > 0 and fname_counter.most_common(1)[0][1] > 1 + ) + if repeated_names_exist: + non_unique_names = [name for name, cnt in fname_counter.items() if cnt > 1] + raise ValueError( + "Field names of the initial list of fields must be unique. The " + "following normalized field names appear more than once: " + f"{', '.join(non_unique_names)}." + ) init_tuples = zip(init_field_names, init_fields) self.entries: OrderedDict[str, interfaces.http.Field] = OrderedDict(init_tuples) self.encoding: str = encoding def set_field(self, field: interfaces.http.Field) -> None: """Set entry for a Field name.""" - self.entries[field.name] = field + normalized_name = self._normalize_field_name(field.name) + self.entries[normalized_name] = field def get_field(self, name: str) -> interfaces.http.Field: """Retrieve Field entry.""" - return self.entries[name] + normalized_name = self._normalize_field_name(name) + return self.entries[normalized_name] def remove_field(self, name: str) -> None: """Delete entry from collection.""" - del self.entries[name] + normalized_name = self._normalize_field_name(name) + del self.entries[normalized_name] def get_by_type(self, kind: FieldPosition) -> list[interfaces.http.Field]: """Helper function for retrieving specific types of fields. @@ -250,6 +262,10 @@ def get_by_type(self, kind: FieldPosition) -> list[interfaces.http.Field]: """ return [entry for entry in self.entries.values() if entry.kind is kind] + def _normalize_field_name(self, name: str) -> str: + """Normalize field names. For use as key in ``entries``.""" + return name.lower() + def __eq__(self, other: object) -> bool: """Encoding must match. Entries must match in values and order.""" if not isinstance(other, Fields): diff --git a/python-packages/smithy-python/tests/unit/test_http_fields.py b/python-packages/smithy-python/tests/unit/test_http_fields.py index 5af0d461c..39a97b429 100644 --- a/python-packages/smithy-python/tests/unit/test_http_fields.py +++ b/python-packages/smithy-python/tests/unit/test_http_fields.py @@ -163,11 +163,20 @@ def test_fields_inequality(fs1, fs2) -> None: assert fs1 != fs2 -def test_repeated_initial_field_names() -> None: +@pytest.mark.parametrize( + "initial_fields", + [ + [ + Field(name="fname1", value=["val1"]), + Field(name="fname1", value=["val2"]), + ], + # uniqueness is checked _after_ normaling field names + [ + Field(name="fNaMe1", value=["val1"]), + Field(name="fname1", value=["val2"]), + ], + ], +) +def test_repeated_initial_field_names(initial_fields: list[Field]) -> None: with pytest.raises(ValueError): - Fields( - [ - Field(name="fname1", value=["val1"]), - Field(name="fname1", value=["val2"]), - ] - ) + Fields(initial_fields) From 73e2079326f7345c6efd6b9d198af90fdfdc8da2 Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Sat, 21 Jan 2023 21:52:47 -0700 Subject: [PATCH 15/20] accept any iterable for field values in Field and fields in Fields --- .../smithy_python/_private/http/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index b2c76e8e4..e4f8b7281 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -15,8 +15,9 @@ from collections import Counter, OrderedDict +from collections.abc import Iterable from dataclasses import dataclass, field -from typing import Any, Iterable, Protocol +from typing import Any, Protocol from urllib.parse import urlparse, urlunparse from ... import interfaces @@ -132,11 +133,11 @@ class Field(interfaces.http.Field): def __init__( self, name: str, - value: list[str] | None = None, + value: Iterable[str] | None = None, kind: FieldPosition = FieldPosition.HEADER, ): self.name = name - self.value: list[str] = value if value is not None else [] + self.value: list[str] = [val for val in value] if value is not None else [] self.kind = kind def add(self, value: str) -> None: @@ -211,7 +212,7 @@ def quote_and_escape_field_value(value: str) -> str: class Fields(interfaces.http.Fields): def __init__( self, - initial: list[interfaces.http.Field] | None = None, + initial: Iterable[interfaces.http.Field] | None = None, *, encoding: str = "utf-8", ): From ad6d285dcef35a45c2fdf4ab3d5592a98103a218 Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Sat, 21 Jan 2023 21:52:58 -0700 Subject: [PATCH 16/20] type hints for tests --- .../smithy-python/tests/unit/test_http_fields.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python-packages/smithy-python/tests/unit/test_http_fields.py b/python-packages/smithy-python/tests/unit/test_http_fields.py index 39a97b429..2fb938bfe 100644 --- a/python-packages/smithy-python/tests/unit/test_http_fields.py +++ b/python-packages/smithy-python/tests/unit/test_http_fields.py @@ -66,7 +66,7 @@ def test_field_multi_valued_basics() -> None: (["foo,bar\\", "val2"], '"foo,bar\\\\", val2'), ], ) -def test_field_serialization(values, expected): +def test_field_serialization(values: list[str], expected: str): field = Field(name="_", value=values) assert field.as_string() == expected @@ -88,7 +88,7 @@ def test_field_serialization(values, expected): ), ], ) -def test_field_equality(f1, f2) -> None: +def test_field_equality(f1: Field, f2: Field) -> None: assert f1 == f2 @@ -113,7 +113,7 @@ def test_field_equality(f1, f2) -> None: ), ], ) -def test_field_inqueality(f1, f2) -> None: +def test_field_inqueality(f1: Field, f2: Field) -> None: assert f1 != f2 @@ -126,7 +126,7 @@ def test_field_inqueality(f1, f2) -> None: ), ], ) -def test_fields_equality(fs1, fs2) -> None: +def test_fields_equality(fs1: Fields, fs2: Fields) -> None: assert fs1 == fs2 @@ -159,7 +159,7 @@ def test_fields_equality(fs1, fs2) -> None: ), ], ) -def test_fields_inequality(fs1, fs2) -> None: +def test_fields_inequality(fs1: Fields, fs2: Fields) -> None: assert fs1 != fs2 From d3aed2a6d48379667abd687e73c74a542da5521d Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Mon, 23 Jan 2023 14:39:10 -0700 Subject: [PATCH 17/20] grammer, naming, reprs --- .../smithy_python/_private/http/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index e4f8b7281..2d19a965f 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -165,7 +165,7 @@ def as_string(self) -> str: For ``Field``s with more than one value, the values are joined by a comma and a space. For such multi-valued ``Field``s, any values that already contain - commata or double quotes will be surrounded by double quotes. Within any values + commas or double quotes will be surrounded by double quotes. Within any values that get quoted, pre-existing double quotes and backslashes are escaped with a backslash. """ @@ -193,7 +193,7 @@ def __eq__(self, other: object) -> bool: ) def __repr__(self) -> str: - return f'Field(name="{self.name}", value=[{self.value}], kind={self.kind})' + return f"Field(name={self.name!r}, value={self.value!r}, kind={self.kind!r})" def quote_and_escape_field_value(value: str) -> str: @@ -201,8 +201,8 @@ def quote_and_escape_field_value(value: str) -> str: See :func:`Field.as_string` for quoting and escaping logic. """ - CHARS_TO_QUOTE = (",", '"') - if any(char in CHARS_TO_QUOTE for char in value): + chars_to_quote = (",", '"') + if any(char in chars_to_quote for char in value): escaped = value.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' else: @@ -231,7 +231,7 @@ def __init__( len(init_fields) > 0 and fname_counter.most_common(1)[0][1] > 1 ) if repeated_names_exist: - non_unique_names = [name for name, cnt in fname_counter.items() if cnt > 1] + non_unique_names = [name for name, num in fname_counter.items() if num > 1] raise ValueError( "Field names of the initial list of fields must be unique. The " "following normalized field names appear more than once: " From 5d5afd2882a4ae2bde8a8ced766956780e5ec433 Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Mon, 23 Jan 2023 23:59:26 -0700 Subject: [PATCH 18/20] Field.value --> Field.values --- .../smithy_python/_private/http/__init__.py | 24 ++++++++--------- .../smithy_python/interfaces/http.py | 4 +-- .../tests/unit/test_http_fields.py | 26 +++++++++---------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index 2d19a965f..1a56fcabc 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -133,26 +133,26 @@ class Field(interfaces.http.Field): def __init__( self, name: str, - value: Iterable[str] | None = None, + values: Iterable[str] | None = None, kind: FieldPosition = FieldPosition.HEADER, ): self.name = name - self.value: list[str] = [val for val in value] if value is not None else [] + self.values: list[str] = [val for val in values] if values is not None else [] self.kind = kind def add(self, value: str) -> None: """Append a value to a field.""" - self.value.append(value) + self.values.append(value) - def set(self, value: list[str]) -> None: + def set(self, values: list[str]) -> None: """Overwrite existing field values.""" - self.value = value + self.values = values def remove(self, value: str) -> None: """Remove all matching entries from list.""" try: while True: - self.value.remove(value) + self.values.remove(value) except ValueError: return @@ -169,18 +169,18 @@ def as_string(self) -> str: that get quoted, pre-existing double quotes and backslashes are escaped with a backslash. """ - value_count = len(self.value) + value_count = len(self.values) if value_count == 0: return "" if value_count == 1: - return self.value[0] - return ", ".join(quote_and_escape_field_value(val) for val in self.value) + return self.values[0] + return ", ".join(quote_and_escape_field_value(val) for val in self.values) def as_tuples(self) -> list[tuple[str, str]]: """ Get list of ``name``, ``value`` tuples where each tuple represents one value. """ - return [(self.name, val) for val in self.value] + return [(self.name, val) for val in self.values] def __eq__(self, other: object) -> bool: """Name, values, and kind must match. Values order must match.""" @@ -189,11 +189,11 @@ def __eq__(self, other: object) -> bool: return ( self.name == other.name and self.kind is other.kind - and self.value == other.value + and self.values == other.values ) def __repr__(self) -> str: - return f"Field(name={self.name!r}, value={self.value!r}, kind={self.kind!r})" + return f"Field(name={self.name!r}, value={self.values!r}, kind={self.kind!r})" def quote_and_escape_field_value(value: str) -> str: diff --git a/python-packages/smithy-python/smithy_python/interfaces/http.py b/python-packages/smithy-python/smithy_python/interfaces/http.py index 04dee3201..73b68b48e 100644 --- a/python-packages/smithy-python/smithy_python/interfaces/http.py +++ b/python-packages/smithy-python/smithy_python/interfaces/http.py @@ -54,14 +54,14 @@ class Field(Protocol): """ name: str - value: list[str] + values: list[str] kind: FieldPosition = FieldPosition.HEADER def add(self, value: str) -> None: """Append a value to a field.""" ... - def set(self, value: list[str]) -> None: + def set(self, values: list[str]) -> None: """Overwrite existing field values.""" ... diff --git a/python-packages/smithy-python/tests/unit/test_http_fields.py b/python-packages/smithy-python/tests/unit/test_http_fields.py index 2fb938bfe..9520f4c46 100644 --- a/python-packages/smithy-python/tests/unit/test_http_fields.py +++ b/python-packages/smithy-python/tests/unit/test_http_fields.py @@ -23,7 +23,7 @@ def test_field_single_valued_basics() -> None: field = Field("fname", ["fval"], FieldPosition.HEADER) assert field.name == "fname" assert field.kind == FieldPosition.HEADER - assert field.value == ["fval"] + assert field.values == ["fval"] assert field.as_string() == "fval" assert field.as_tuples() == [("fname", "fval")] @@ -32,7 +32,7 @@ def test_field_multi_valued_basics() -> None: field = Field("fname", ["fval1", "fval2"], FieldPosition.HEADER) assert field.name == "fname" assert field.kind == FieldPosition.HEADER - assert field.value == ["fval1", "fval2"] + assert field.values == ["fval1", "fval2"] assert field.as_string() == "fval1, fval2" assert field.as_tuples() == [("fname", "fval1"), ("fname", "fval2")] @@ -67,7 +67,7 @@ def test_field_multi_valued_basics() -> None: ], ) def test_field_serialization(values: list[str], expected: str): - field = Field(name="_", value=values) + field = Field(name="_", values=values) assert field.as_string() == expected @@ -121,8 +121,8 @@ def test_field_inqueality(f1: Field, f2: Field) -> None: "fs1,fs2", [ ( - Fields([Field(name="fname", value=["fval1", "fval2"])]), - Fields([Field(name="fname", value=["fval1", "fval2"])]), + Fields([Field(name="fname", values=["fval1", "fval2"])]), + Fields([Field(name="fname", values=["fval1", "fval2"])]), ), ], ) @@ -146,12 +146,12 @@ def test_fields_equality(fs1: Fields, fs2: Fields) -> None: Fields(encoding="utf-2"), ), ( - Fields([Field(name="fname", value=["val1"])]), - Fields([Field(name="fname", value=["val2"])]), + Fields([Field(name="fname", values=["val1"])]), + Fields([Field(name="fname", values=["val2"])]), ), ( - Fields([Field(name="fname", value=["val2", "val1"])]), - Fields([Field(name="fname", value=["val1", "val2"])]), + Fields([Field(name="fname", values=["val2", "val1"])]), + Fields([Field(name="fname", values=["val1", "val2"])]), ), ( Fields([Field(name="f1"), Field(name="f2")]), @@ -167,13 +167,13 @@ def test_fields_inequality(fs1: Fields, fs2: Fields) -> None: "initial_fields", [ [ - Field(name="fname1", value=["val1"]), - Field(name="fname1", value=["val2"]), + Field(name="fname1", values=["val1"]), + Field(name="fname1", values=["val2"]), ], # uniqueness is checked _after_ normaling field names [ - Field(name="fNaMe1", value=["val1"]), - Field(name="fname1", value=["val2"]), + Field(name="fNaMe1", values=["val1"]), + Field(name="fname1", values=["val2"]), ], ], ) From 6aea01ada41d37dce6a5a65adf3b88a98f9c1d5f Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Fri, 27 Jan 2023 15:14:34 -0700 Subject: [PATCH 19/20] Apply suggestions from code review Co-authored-by: Nate Prewitt --- .../smithy-python/smithy_python/_private/http/__init__.py | 1 + python-packages/smithy-python/smithy_python/interfaces/http.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/python-packages/smithy-python/smithy_python/_private/http/__init__.py b/python-packages/smithy-python/smithy_python/_private/http/__init__.py index 1a56fcabc..b473bd632 100644 --- a/python-packages/smithy-python/smithy_python/_private/http/__init__.py +++ b/python-packages/smithy-python/smithy_python/_private/http/__init__.py @@ -132,6 +132,7 @@ class Field(interfaces.http.Field): def __init__( self, + *, name: str, values: Iterable[str] | None = None, kind: FieldPosition = FieldPosition.HEADER, diff --git a/python-packages/smithy-python/smithy_python/interfaces/http.py b/python-packages/smithy-python/smithy_python/interfaces/http.py index 73b68b48e..71559b233 100644 --- a/python-packages/smithy-python/smithy_python/interfaces/http.py +++ b/python-packages/smithy-python/smithy_python/interfaces/http.py @@ -43,7 +43,7 @@ class FieldPosition(Enum): class Field(Protocol): """ - A name-value pair representing a single field in a request or response + A name-value pair representing a single field in a request or response. The kind will dictate metadata placement within an the message, for example as header or trailer field in a HTTP request as defined in RFC 9114 Section 4.2. From c9df2f4c9fb056c6df2a5c1ea77a707cb594f03a Mon Sep 17 00:00:00 2001 From: Jonas Neubert Date: Fri, 27 Jan 2023 16:47:42 -0700 Subject: [PATCH 20/20] use kwargs everywhere --- .../tests/unit/test_http_fields.py | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/python-packages/smithy-python/tests/unit/test_http_fields.py b/python-packages/smithy-python/tests/unit/test_http_fields.py index 9520f4c46..e3e329bdc 100644 --- a/python-packages/smithy-python/tests/unit/test_http_fields.py +++ b/python-packages/smithy-python/tests/unit/test_http_fields.py @@ -20,7 +20,7 @@ def test_field_single_valued_basics() -> None: - field = Field("fname", ["fval"], FieldPosition.HEADER) + field = Field(name="fname", values=["fval"], kind=FieldPosition.HEADER) assert field.name == "fname" assert field.kind == FieldPosition.HEADER assert field.values == ["fval"] @@ -29,7 +29,7 @@ def test_field_single_valued_basics() -> None: def test_field_multi_valued_basics() -> None: - field = Field("fname", ["fval1", "fval2"], FieldPosition.HEADER) + field = Field(name="fname", values=["fval1", "fval2"], kind=FieldPosition.HEADER) assert field.name == "fname" assert field.kind == FieldPosition.HEADER assert field.values == ["fval1", "fval2"] @@ -75,16 +75,16 @@ def test_field_serialization(values: list[str], expected: str): "f1,f2", [ ( - Field("fname", ["fval1", "fval2"], FieldPosition.TRAILER), - Field("fname", ["fval1", "fval2"], FieldPosition.TRAILER), + Field(name="fname", values=["fval1", "fval2"], kind=FieldPosition.TRAILER), + Field(name="fname", values=["fval1", "fval2"], kind=FieldPosition.TRAILER), ), ( - Field("fname", ["fval1", "fval2"]), - Field("fname", ["fval1", "fval2"]), + Field(name="fname", values=["fval1", "fval2"]), + Field(name="fname", values=["fval1", "fval2"]), ), ( - Field("fname"), - Field("fname"), + Field(name="fname"), + Field(name="fname"), ), ], ) @@ -96,20 +96,20 @@ def test_field_equality(f1: Field, f2: Field) -> None: "f1,f2", [ ( - Field("fname", ["fval1", "fval2"], FieldPosition.HEADER), - Field("fname", ["fval1", "fval2"], FieldPosition.TRAILER), + Field(name="fname", values=["fval1", "fval2"], kind=FieldPosition.HEADER), + Field(name="fname", values=["fval1", "fval2"], kind=FieldPosition.TRAILER), ), ( - Field("fname", ["fval1", "fval2"], FieldPosition.HEADER), - Field("fname", ["fval2", "fval1"], FieldPosition.HEADER), + Field(name="fname", values=["fval1", "fval2"], kind=FieldPosition.HEADER), + Field(name="fname", values=["fval2", "fval1"], kind=FieldPosition.HEADER), ), ( - Field("fname", ["fval1", "fval2"], FieldPosition.HEADER), - Field("fname", ["fval1"], FieldPosition.HEADER), + Field(name="fname", values=["fval1", "fval2"], kind=FieldPosition.HEADER), + Field(name="fname", values=["fval1"], kind=FieldPosition.HEADER), ), ( - Field("fname1", ["fval1", "fval2"], FieldPosition.HEADER), - Field("fname2", ["fval1", "fval2"], FieldPosition.HEADER), + Field(name="fname1", values=["fval1", "fval2"], kind=FieldPosition.HEADER), + Field(name="fname2", values=["fval1", "fval2"], kind=FieldPosition.HEADER), ), ], )