diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e3a2867 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 89 diff --git a/duo_hmac/duo_hmac.py b/duo_hmac/duo_hmac.py index 2bce2f4..7d7af07 100644 --- a/duo_hmac/duo_hmac.py +++ b/duo_hmac/duo_hmac.py @@ -37,7 +37,8 @@ def get_authentication_components( Use the provided request components and calculate - The final url (host + path + query string) - The request body (if any) - - The request headers (including the authorization header per Duo's HMAC specification) + - The request headers (including the authorization + header per Duo's HMAC specification) """ duo_hmac_validation.validate_headers(in_headers) @@ -50,7 +51,8 @@ def get_authentication_components( # We need the request timestamp in RFC 2822 format date_string = self.date_string_provider.get_rfc_2822_date_string() - # Duo does not currently support splitting parameters between the query string and body. + # Duo does not currently support splitting parameters + # between the query string and body. # Put parameters in the correct place depending on the http method # (body for POST, PUT, and PATCH, query string otherwise) params_go_in_body = http_method.upper() in ("POST", "PUT", "PATCH") @@ -75,7 +77,8 @@ def get_authentication_components( if query_string: uri = f"{uri}?{query_string}" - # Assemble final headers from input headers, authorization header, and content-type header + # Assemble final headers from input headers, authorization header, and + # content-type header out_headers = dict(in_headers) out_headers["Authorization"] = authn_header if params_go_in_body: @@ -120,7 +123,10 @@ def _generate_authentication_header( return f"Basic {b64}" def _sign_canonical_string(self, canon_string: str) -> hmac.HMAC: - """Generate the SHA512 signature of the canonical string using the SKEY as the shared secret""" + """ + Generate the SHA512 signature of the canonical string + using the SKEY as the shared secret + """ skey_bytes = self.skey.encode("utf-8") canon_bytes = canon_string.encode("utf-8") diff --git a/duo_hmac/duo_hmac_utils.py b/duo_hmac/duo_hmac_utils.py index 0baf896..354c7fe 100644 --- a/duo_hmac/duo_hmac_utils.py +++ b/duo_hmac/duo_hmac_utils.py @@ -29,7 +29,8 @@ def prepare_parameters( def jsonize_parameters(parameters: dict) -> str: """Turn a parameter dictionary into a JSON string""" if parameters is None: - # Is this the best choice? Should we return None instead (or allow json.dumps to return None)? + # Is this the best choice? Should we return None instead (or allow + # json.dumps to return None)? parameters = {} return json.dumps(parameters, sort_keys=True, separators=(",", ":")) diff --git a/duo_hmac/duo_hmac_validation.py b/duo_hmac/duo_hmac_validation.py index 76bc6e4..d025eea 100644 --- a/duo_hmac/duo_hmac_validation.py +++ b/duo_hmac/duo_hmac_validation.py @@ -28,7 +28,8 @@ def validate_headers(headers: dict[str, str]): if key_lower.startswith("x-duo"): if key_lower in headers_seen: problems.append( - f"Duplicate x-duo headers are not supported, {key_lower} is duplicated." + f"Duplicate x-duo headers are not supported, \ + {key_lower} is duplicated." ) else: headers_seen.add(key_lower) diff --git a/generate_curl_call.py b/generate_curl_call.py index 743734f..52c363a 100755 --- a/generate_curl_call.py +++ b/generate_curl_call.py @@ -35,8 +35,12 @@ def main(): parser = argparse.ArgumentParser( prog="Duo API call generator for curl", - description="Generates a curl call for a Duo API call. Provide the HTTP method (default 'get'), the API path, and the call parameters as key=value pairs", - epilog="CLI flags: -m -a -p key1=value1 key2=value2 ...", + description="""Generates a curl call for a Duo API call. + Provide the HTTP method (default 'get'), + the API path, and the call parameters as + key=value pairs""", + epilog="""CLI flags: -m -a + -p key1=value1 key2=value2 ...""", ) args_dict = get_arguments(parser) diff --git a/requirements-dev.txt b/requirements-dev.txt index 8c9bfb8..d6532ad 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1 @@ -requests >= 2.32.0 \ No newline at end of file +requests >= 2.32.0 diff --git a/test.sh b/test.sh index 59a28ba..d56af1d 100755 --- a/test.sh +++ b/test.sh @@ -1,3 +1,3 @@ #! /bin/bash -python3 -m unittest discover test/ \ No newline at end of file +python3 - m unittest discover test/ diff --git a/test/test_canonicalization.py b/test/test_canonicalization.py index 79384aa..44776ef 100644 --- a/test/test_canonicalization.py +++ b/test/test_canonicalization.py @@ -15,9 +15,9 @@ API_PATH = "/test/the/api" EMPTY_STRING = "" # sha512 hash of the empty string; calculated with an external tool -EMPTY_STRING_HASH = "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" +EMPTY_STRING_HASH = "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" # noqa: E501 # sha512 hash of the "empty json" string '{}'; calculated with an external tool -EMPTY_JSON_HASH = "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd" +EMPTY_JSON_HASH = "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd" # noqa: E501 # With GET and no parameters, body, or header, expect: # provided date string @@ -27,14 +27,16 @@ # an empty line since there are no query string parameters # the sha512 of the empty string since there is no body # the sha512 of the empty string since there are no headers -EXPECTED_GET_NO_PARAMS = f"{DATE_STRING}\n{HTTP_GET}\n{API_HOST}\n{API_PATH}\n{EMPTY_STRING}\n{EMPTY_STRING_HASH}\n{EMPTY_STRING_HASH}" -# For POST with no parameters, empty body, no headers, the main difference is line 6, where we expect the hash of empty json -EXPECTED_POST_NO_PARAMS = f"{DATE_STRING}\n{HTTP_POST}\n{API_HOST}\n{API_PATH}\n{EMPTY_STRING}\n{EMPTY_JSON_HASH}\n{EMPTY_STRING_HASH}" +EXPECTED_GET_NO_PARAMS = f"{DATE_STRING}\n{HTTP_GET}\n{API_HOST}\n{API_PATH}\n{EMPTY_STRING}\n{EMPTY_STRING_HASH}\n{EMPTY_STRING_HASH}" # noqa: E501 +# For POST with no parameters, empty body, no headers, the main difference +# is line 6, where we expect the hash of empty json +EXPECTED_POST_NO_PARAMS = f"{DATE_STRING}\n{HTTP_POST}\n{API_HOST}\n{API_PATH}\n{EMPTY_STRING}\n{EMPTY_JSON_HASH}\n{EMPTY_STRING_HASH}" # noqa: E501 class TestGenerateCanonicalStringBasics(unittest.TestCase): # Test the handling of the date string, method, host, and path. - # The parameter, body, and headers all have dedicated canonicalization methods, tested separately + # The parameter, body, and headers all have dedicated canonicalization + # methods, tested separately def test_get_no_parameter(self): actual = duo_canonicalize.generate_canonical_string( DATE_STRING, HTTP_GET, API_HOST, API_PATH, None, None, None @@ -103,7 +105,7 @@ def test_empty_parameters(self): b"punctuation": [b"!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"], b"whitespace": [b"\t\n\x0b\x0c\r "], }, - "digits=0123456789&letters=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ&punctuation=%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~&whitespace=%09%0A%0B%0C%0D%20", + "digits=0123456789&letters=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ&punctuation=%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~&whitespace=%09%0A%0B%0C%0D%20", # noqa: E501 ), ( "Test unicode", @@ -121,7 +123,7 @@ def test_empty_parameters(self): "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" ], }, - "%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU", + "%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU", # noqa: E501 ), ] @@ -175,19 +177,19 @@ def test_empty_body(self): ( "Ascii string body", "I am an ascii string", - "c1710f1224e4973bfbb9ca1a297e63756a4b1736ebd1b646ba3d63a392b73d24a52ac8b4afb6eafca6dfe91f09e2e75117c377398a7d2f22136b05c038b94151", + "c1710f1224e4973bfbb9ca1a297e63756a4b1736ebd1b646ba3d63a392b73d24a52ac8b4afb6eafca6dfe91f09e2e75117c377398a7d2f22136b05c038b94151", # noqa: E501 ), ( "Unicode string body", "î ❤ ựṉịʗƠΔѤ", - "b713c8cc2bfe672cf55133d81ca6fa802628c7c8968d444c186434146bcd0275a510d3fd725b0a8132882c4a60d8457420f252f84e4edd00fcf12aa7c4eb2246", + "b713c8cc2bfe672cf55133d81ca6fa802628c7c8968d444c186434146bcd0275a510d3fd725b0a8132882c4a60d8457420f252f84e4edd00fcf12aa7c4eb2246", # noqa: E501 ), ( "JSON string body", json.dumps( {"foo": "bar", "baz": 1, "nested": {"objects": {"are": {"neat": True}}}} ), - "cd97c6ef2f1db6a660f2b7b71235d16902d532e3a4b54afc07acebbf34a265d8f77c344706f3222bcb28009d8c4c5259daa388bddb7dcc1b163982dce6a0c1ec", + "cd97c6ef2f1db6a660f2b7b71235d16902d532e3a4b54afc07acebbf34a265d8f77c344706f3222bcb28009d8c4c5259daa388bddb7dcc1b163982dce6a0c1ec", # noqa: E501 ), ] @@ -217,7 +219,7 @@ def test_case_insensitive(self): } # Calculated with an external tool expecting lowercase header keys - expected = "60be11a30e0756f2ee2afdce1db849b987dcf86c1133394bd7bbbc9877920330c4d78aceacbb377ab8cbd9a8efe6a410fed4047376635ac71226ab46ca10d2b1" + expected = "60be11a30e0756f2ee2afdce1db849b987dcf86c1133394bd7bbbc9877920330c4d78aceacbb377ab8cbd9a8efe6a410fed4047376635ac71226ab46ca10d2b1" # noqa: E501 actual = duo_canonicalize.canonicalize_x_duo_headers(mixed_case_headers) self.assertEqual(expected, actual) diff --git a/test/test_hmac.py b/test/test_hmac.py index 51ac161..1dbb057 100644 --- a/test/test_hmac.py +++ b/test/test_hmac.py @@ -37,14 +37,15 @@ def assert_components( self.assertEqual(expected_body, actual_body) self.assertDictEqual(expected_headers, actual_headers) - # For all tests, the expected Authorization header was calculated using duo_client_python + # For all tests, the expected Authorization header was calculated using + # duo_client_python def test_get_no_params_no_headers(self): expected_uri = f"{API_HOST}{API_PATH}" expected_body = None expected_headers = { "x-duo-date": DATE_STRING, - "Authorization": "Basic RElBQkNERUZHSElKS0xNTk9QUVI6YzQ1NDYzOWQ3NWI0ZTNkOTliOGIxZDVlNDFjZDdiYjJkMmQ4YmE1NWY2ODExZjc4NmRmYjBlZGQ0ZmFjZDJmM2E1ZTZkNmM4MzdmMzFmNjgyNjcwNjMyNWI0ZWQ3ZGNkYzVmMTExNjQ5NDhlNTdhNzAzMmE1MjQ5OTBlMDE1ODM=", + "Authorization": "Basic RElBQkNERUZHSElKS0xNTk9QUVI6YzQ1NDYzOWQ3NWI0ZTNkOTliOGIxZDVlNDFjZDdiYjJkMmQ4YmE1NWY2ODExZjc4NmRmYjBlZGQ0ZmFjZDJmM2E1ZTZkNmM4MzdmMzFmNjgyNjcwNjMyNWI0ZWQ3ZGNkYzVmMTExNjQ5NDhlNTdhNzAzMmE1MjQ5OTBlMDE1ODM=", # noqa: E501 } self.assert_components( @@ -57,7 +58,7 @@ def test_post_no_params_no_headers(self): expected_headers = { "x-duo-date": DATE_STRING, "Content-type": "application/json", - "Authorization": "Basic RElBQkNERUZHSElKS0xNTk9QUVI6MTJhMDVkNzgzYjJlNThlMzZmMzdkZjhhNjkwNzgzNTQ5NmZiZTIwZmIzZDA0YjE1MDM2YzgyYjE2OTRmYzU4ZDFjMDQ1MWI5MzdmYjliYTZlN2MyYjQ0ZDg5YjQ3M2FmNzA4MTY2MTgzZDIxNmFlYTEzZTUyNzQyYTU3ZjIzOWY=", + "Authorization": "Basic RElBQkNERUZHSElKS0xNTk9QUVI6MTJhMDVkNzgzYjJlNThlMzZmMzdkZjhhNjkwNzgzNTQ5NmZiZTIwZmIzZDA0YjE1MDM2YzgyYjE2OTRmYzU4ZDFjMDQ1MWI5MzdmYjliYTZlN2MyYjQ0ZDg5YjQ3M2FmNzA4MTY2MTgzZDIxNmFlYTEzZTUyNzQyYTU3ZjIzOWY=", # noqa: E501 } self.assert_components( @@ -69,7 +70,7 @@ def test_get_one_param_no_headers(self): expected_body = None expected_headers = { "x-duo-date": DATE_STRING, - "Authorization": "Basic RElBQkNERUZHSElKS0xNTk9QUVI6ODhmZTgwZjNiMjQyYjk5MmY0YjMwMTQwOGQ1MjRhODg2Mjc0ZDNlZDBjNGM3YmQxODRlMWI0ZmYzNzhlNjhlYTA1ZDk0MzNjMDk5MzgwNzhjNDk1MTdhNmM0MjY0Yzk1MGJlOWZmNWNjMjhhZDJkNDQ4Y2VhMjRiYzkzODg3Y2E=", + "Authorization": "Basic RElBQkNERUZHSElKS0xNTk9QUVI6ODhmZTgwZjNiMjQyYjk5MmY0YjMwMTQwOGQ1MjRhODg2Mjc0ZDNlZDBjNGM3YmQxODRlMWI0ZmYzNzhlNjhlYTA1ZDk0MzNjMDk5MzgwNzhjNDk1MTdhNmM0MjY0Yzk1MGJlOWZmNWNjMjhhZDJkNDQ4Y2VhMjRiYzkzODg3Y2E=", # noqa: E501 } in_params = {"foo": "bar"} @@ -83,7 +84,7 @@ def test_post_one_param_no_headers(self): expected_headers = { "x-duo-date": DATE_STRING, "Content-type": "application/json", - "Authorization": "Basic RElBQkNERUZHSElKS0xNTk9QUVI6NjQzNzcwYWYzMTAwNmM1ODRhNzU4ZDkyNjI1MGU0NmE5MGQ3OTEwMGQyMWY3YTAzMTNjM2U3N2Q2NGZhM2M1ZDJjOTRlMmM5MDgxYTJiNjUzNDNjYzNkNWYyZWQyMWY3MzAwZWE1MGIwMDY0MGNiMTc2MGYzMjMxOTIzMDdkMzc=", + "Authorization": "Basic RElBQkNERUZHSElKS0xNTk9QUVI6NjQzNzcwYWYzMTAwNmM1ODRhNzU4ZDkyNjI1MGU0NmE5MGQ3OTEwMGQyMWY3YTAzMTNjM2U3N2Q2NGZhM2M1ZDJjOTRlMmM5MDgxYTJiNjUzNDNjYzNkNWYyZWQyMWY3MzAwZWE1MGIwMDY0MGNiMTc2MGYzMjMxOTIzMDdkMzc=", # noqa: E501 } in_params = {"foo": "bar"} @@ -99,7 +100,7 @@ def test_get_multi_param_multi_headers(self): "x-duo-bar": "foo", "non-duo-bar": "duo", "x-duo-date": DATE_STRING, - "Authorization": "Basic RElBQkNERUZHSElKS0xNTk9QUVI6ZDQ2NTU2NzI2ODAwNDE1ZGM3OGNlZmMzZmI0ZTExZGNmM2VlMTM0MjkwNGYyNzZlZDVjOGUzNDI3ODc4YmQ1Mzc2ZWE2YzU1NTFiOTBiZjcwN2ZhYjUzZjZmMWQyMGExMTQ4OTg4OTg3MDVkMjgyNjg4MjRlZGQwYmU1ZjFkNTM=", + "Authorization": "Basic RElBQkNERUZHSElKS0xNTk9QUVI6ZDQ2NTU2NzI2ODAwNDE1ZGM3OGNlZmMzZmI0ZTExZGNmM2VlMTM0MjkwNGYyNzZlZDVjOGUzNDI3ODc4YmQ1Mzc2ZWE2YzU1NTFiOTBiZjcwN2ZhYjUzZjZmMWQyMGExMTQ4OTg4OTg3MDVkMjgyNjg4MjRlZGQwYmU1ZjFkNTM=", # noqa: E501 } in_params = {"foo": "bar", "one": "1", "bool": "true"} @@ -126,7 +127,7 @@ def test_post_multi_params_multi_headers(self): "non-duo-bar": "duo", "x-duo-date": DATE_STRING, "Content-type": "application/json", - "Authorization": "Basic RElBQkNERUZHSElKS0xNTk9QUVI6ZGVmN2I3MzU5YjAzOTk1NDNiNjFkM2QxYTQyNTJjMTkwOGViNDg2MDM5MTY4YWE3ZDFjOTM1NDVmMDUyZTEyMTA2MmU0ZDBkM2NhZTgwZjBmMTI1ZGM0OTdjNDNjNTNiNjJjOWRiNThjYWNkMjEzOTRhM2IxN2FkOTcyZTM3OTM=", + "Authorization": "Basic RElBQkNERUZHSElKS0xNTk9QUVI6ZGVmN2I3MzU5YjAzOTk1NDNiNjFkM2QxYTQyNTJjMTkwOGViNDg2MDM5MTY4YWE3ZDFjOTM1NDVmMDUyZTEyMTA2MmU0ZDBkM2NhZTgwZjBmMTI1ZGM0OTdjNDNjNTNiNjJjOWRiNThjYWNkMjEzOTRhM2IxN2FkOTcyZTM3OTM=", # noqa: E501 } in_params = {"foo": "bar", "one": "1", "bool": "true"} @@ -150,7 +151,7 @@ def test_post_non_string_parameter_types(self): expected_headers = { "x-duo-date": DATE_STRING, "Content-type": "application/json", - "Authorization": "Basic RElBQkNERUZHSElKS0xNTk9QUVI6NjhjYWI2YTQyYmUyMTZhMGUwNTU3NzJlZDhkODg3MGIzODNmYzk4NmVlNGJkN2I5MjM0Njg5ZTIzOWJlNjc3YzhlZGY2MWUzM2VhZGRkODNlMmI5NDE0Yzk4ZmYzMGJmY2EwYmYyZTFmNDQ4MzEwNzRmNWM0NzRiZjRhZjlmZDc=", + "Authorization": "Basic RElBQkNERUZHSElKS0xNTk9QUVI6NjhjYWI2YTQyYmUyMTZhMGUwNTU3NzJlZDhkODg3MGIzODNmYzk4NmVlNGJkN2I5MjM0Njg5ZTIzOWJlNjc3YzhlZGY2MWUzM2VhZGRkODNlMmI5NDE0Yzk4ZmYzMGJmY2EwYmYyZTFmNDQ4MzEwNzRmNWM0NzRiZjRhZjlmZDc=", # noqa: E501 } in_params = {"foo": "bar", "one": "1", "bool": "true"} @@ -158,7 +159,8 @@ def test_post_non_string_parameter_types(self): HTTP_POST, in_params, None, expected_uri, expected_body, expected_headers ) - # As written, non-string parameters don't work for GET calls. This is arguably a bug but test the existing behavior for now + # As written, non-string parameters don't work for GET calls. This is + # arguably a bug but test the existing behavior for now def test_unsupported_get_parameter_types(self): in_params1 = { "foo": "bar", diff --git a/test/test_hmac_utils.py b/test/test_hmac_utils.py index 48357d1..701a27b 100644 --- a/test/test_hmac_utils.py +++ b/test/test_hmac_utils.py @@ -105,7 +105,8 @@ def test_multiple_parameters(self): actual = duo_hmac_utils.normalize_parameters(test_input) self.assertDictEqual(expected, actual) - # None of these make any sense, but they work! Hopefully we can break them all some day by enforcing sensible typing + # None of these make any sense, but they work! Hopefully we can break + # them all some day by enforcing sensible typing edge_case_test_cases = [ ("None 1-item list value", {"string": [None]}, {b"string": [None]}), (