diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5614e1b..5892c57 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python: [2.7, 3.6, 3.7, 3.8, "3.10"] + python: [3.6, 3.7, 3.8, "3.10"] name: Python ${{ matrix.python }} Test diff --git a/.gitignore b/.gitignore index 088376d..a361666 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ var/ pip-log.txt pip-delete-this-directory.txt + +.idea diff --git a/README.md b/README.md index 4dd701c..2f7b1d4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ In order to use this library, you need to have a free account on The user id | +|event `String`| **Required**
The name of the event you wish to trigger. | +|data `JSONable data` | **Required**
The event's payload | + +|Return Values |Description | +|:-:|:-:| +|buffered_events `Dict` | A parsed response that includes the event_id for each event published to a channel. See example. | + +`Pusher::trigger` will throw a `TypeError` if called with parameters of the wrong type; or a `ValueError` if called on more than 100 channels, with an event name longer than 200 characters, or with more than 10240 characters of data (post JSON serialisation). + +##### Example + +This call will send a message to the user with id `'123'`. + +```python +pusher_client.send_to_user( u'123', u'some_event', {u'message': u'hello worlds'}) +``` + ## Querying Application State ### Getting Information For All Channels @@ -288,6 +314,40 @@ auth = pusher_client.authenticate( # return `auth` as a response ``` +## Authenticating User + +#### `Pusher::authenticate_user` + +To authenticate users on Pusher Channels on your application, you can use the authenticate_user function: + +|Argument |Description | +|:-:|:-:| +|socket_id `String` | **Required**
The channel's socket_id, also sent to you in the POST request | +|user_data `Dict` |**Required for presence channels**
This will be a dictionary containing the data you want associated with a user. An `"id"` key is *required* | + +|Return Values |Description | +|:-:|:-:| +|response `Dict` | A dictionary to send as a response to the authentication request.| + +For more information see: +* [authenticating users](https://pusher.com/docs/channels/server_api/authenticating-users/) +* [auth-signatures](https://pusher.com/docs/channels/library_auth_reference/auth-signatures/) + +##### Example + +###### User Authentication + +```python +auth = pusher_client.authenticate_user( + socket_id=u"1234.12", + user_data = { + u'id': u'123', + u'name': u'John Smith' + } +) +# return `auth` as a response +``` + ## Terminating user connections TIn order to terminate a user's connections, the user must have been authenticated. Check the [Server user authentication docs](http://pusher.com/docs/authenticating_users) for the information on how to create a user authentication endpoint. @@ -406,9 +466,11 @@ Feature | Supported -------------------------------------------| :-------: Trigger event on single channel | *✔* Trigger event on multiple channels | *✔* +Trigger event to a specifc user | *✔* Excluding recipients from events | *✔* Authenticating private channels | *✔* Authenticating presence channels | *✔* +Authenticating users | *✔* Get the list of channels in an application | *✔* Get the state of a single channel | *✔* Get a list of users in a presence channel | *✔* diff --git a/pusher/authentication_client.py b/pusher/authentication_client.py index 129e77c..6f688ad 100644 --- a/pusher/authentication_client.py +++ b/pusher/authentication_client.py @@ -20,8 +20,9 @@ ensure_binary, validate_channel, validate_socket_id, + validate_user_data, channel_name_re - ) +) from pusher.client import Client from pusher.http import GET, POST, Request, request_method @@ -31,21 +32,21 @@ class AuthenticationClient(Client): def __init__( - self, - app_id, - key, - secret, - ssl=True, - host=None, - port=None, - timeout=5, - cluster=None, - encryption_master_key=None, - encryption_master_key_base64=None, - json_encoder=None, - json_decoder=None, - backend=None, - **backend_options): + self, + app_id, + key, + secret, + ssl=True, + host=None, + port=None, + timeout=5, + cluster=None, + encryption_master_key=None, + encryption_master_key_base64=None, + json_encoder=None, + json_decoder=None, + backend=None, + **backend_options): super(AuthenticationClient, self).__init__( app_id, @@ -63,7 +64,6 @@ def __init__( backend, **backend_options) - def authenticate(self, channel, socket_id, custom_data=None): """Used to generate delegated client subscription token. @@ -89,7 +89,7 @@ def authenticate(self, channel, socket_id, custom_data=None): signature = sign(self.secret, string_to_sign) auth = "%s:%s" % (self.key, signature) - response_payload = { "auth": auth } + response_payload = {"auth": auth} if is_encrypted_channel(channel): shared_secret = generate_shared_secret( @@ -102,6 +102,25 @@ def authenticate(self, channel, socket_id, custom_data=None): return response_payload + def authenticate_user(self, socket_id, user_data=None): + """Creates a user authentication signature. + + :param socket_id: id of the socket that requires authorization + :param user_data: used to provide user info + """ + validate_user_data(user_data) + socket_id = validate_socket_id(socket_id) + + user_data_encoded = json.dumps(user_data, cls=self._json_encoder) + + string_to_sign = "%s::user::%s" % (socket_id, user_data_encoded) + + signature = sign(self.secret, string_to_sign) + + auth_response = "%s:%s" % (self.key, signature) + response_payload = {"auth": auth_response, 'user_data': user_data_encoded} + + return response_payload def validate_webhook(self, key, signature, body): """Used to validate incoming webhook messages. When used it guarantees @@ -131,7 +150,8 @@ def validate_webhook(self, key, signature, body): if not time_ms: return None - if abs(time.time()*1000 - time_ms) > 300000: + if abs(time.time() * 1000 - time_ms) > 300000: return None return body_data + diff --git a/pusher/pusher.py b/pusher/pusher.py index c82828e..dd6d6c0 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -16,7 +16,7 @@ from pusher.util import ( ensure_text, pusher_url_re, - doc_string) + doc_string, validate_user_id) from pusher.pusher_client import PusherClient from pusher.authentication_client import AuthenticationClient @@ -45,21 +45,21 @@ class Pusher(object): :param backend_options: additional backend """ def __init__( - self, - app_id, - key, - secret, - ssl=True, - host=None, - port=None, - timeout=5, - cluster=None, - encryption_master_key=None, - encryption_master_key_base64=None, - json_encoder=None, - json_decoder=None, - backend=None, - **backend_options): + self, + app_id, + key, + secret, + ssl=True, + host=None, + port=None, + timeout=5, + cluster=None, + encryption_master_key=None, + encryption_master_key_base64=None, + json_encoder=None, + json_decoder=None, + backend=None, + **backend_options): self._pusher_client = PusherClient( app_id, @@ -93,7 +93,6 @@ def __init__( backend, **backend_options) - @classmethod def from_url(cls, url, **options): """Alternative constructor that extracts the information from a URL. @@ -123,7 +122,6 @@ def from_url(cls, url, **options): return cls(**options_) - @classmethod def from_env(cls, env='PUSHER_URL', **options): """Alternative constructor that extracts the information from an URL @@ -143,12 +141,16 @@ def from_env(cls, env='PUSHER_URL', **options): return cls.from_url(val, **options) - @doc_string(PusherClient.trigger.__doc__) def trigger(self, channels, event_name, data, socket_id=None): return self._pusher_client.trigger( channels, event_name, data, socket_id) + @doc_string(PusherClient.trigger.__doc__) + def send_to_user(self, user_id, event_name, data): + validate_user_id(user_id) + user_server_string = "#server-to-user-%s" % user_id + return self._pusher_client.trigger([user_server_string], event_name, data) @doc_string(PusherClient.trigger_batch.__doc__) def trigger_batch(self, batch=[], already_encoded=False): @@ -158,7 +160,6 @@ def trigger_batch(self, batch=[], already_encoded=False): def channels_info(self, prefix_filter=None, attributes=[]): return self._pusher_client.channels_info(prefix_filter, attributes) - @doc_string(PusherClient.channel_info.__doc__) def channel_info(self, channel, attributes=[]): return self._pusher_client.channel_info(channel, attributes) @@ -176,6 +177,11 @@ def authenticate(self, channel, socket_id, custom_data=None): return self._authentication_client.authenticate( channel, socket_id, custom_data) + @doc_string(AuthenticationClient.authenticate_user.__doc__) + def authenticate_user(self, socket_id, user_data=None): + return self._authentication_client.authenticate_user( + socket_id, user_data + ) @doc_string(AuthenticationClient.validate_webhook.__doc__) def validate_webhook(self, key, signature, body): diff --git a/pusher/pusher_client.py b/pusher/pusher_client.py index e8ab6cf..8c35fd2 100644 --- a/pusher/pusher_client.py +++ b/pusher/pusher_client.py @@ -7,8 +7,9 @@ division) import sys + # Abstract Base Classes were moved into collections.abc in Python 3.3 -if sys.version_info >= (3,3): +if sys.version_info >= (3, 3): import collections.abc as collections else: import collections @@ -37,38 +38,37 @@ class PusherClient(Client): def __init__( - self, + self, + app_id, + key, + secret, + ssl=True, + host=None, + port=None, + timeout=5, + cluster=None, + encryption_master_key=None, + encryption_master_key_base64=None, + json_encoder=None, + json_decoder=None, + backend=None, + **backend_options): + + super(PusherClient, self).__init__( app_id, key, secret, - ssl=True, - host=None, - port=None, - timeout=5, - cluster=None, - encryption_master_key=None, - encryption_master_key_base64=None, - json_encoder=None, - json_decoder=None, - backend=None, - **backend_options): - - super(PusherClient, self).__init__( - app_id, - key, - secret, - ssl, - host, - port, - timeout, - cluster, - encryption_master_key, - encryption_master_key_base64, - json_encoder, - json_decoder, - backend, - **backend_options) - + ssl, + host, + port, + timeout, + cluster, + encryption_master_key, + encryption_master_key_base64, + json_encoder, + json_decoder, + backend, + **backend_options) @request_method def trigger(self, channels, event_name, data, socket_id=None): @@ -80,7 +80,7 @@ def trigger(self, channels, event_name, data, socket_id=None): channels = [channels] if isinstance(channels, dict) or not isinstance( - channels, (collections.Sized, collections.Iterable)): + channels, (collections.Sized, collections.Iterable)): raise TypeError("Expected a single or a list of channels") if len(channels) > 100: @@ -159,7 +159,6 @@ def channels_info(self, prefix_filter=None, attributes=[]): return Request( self, GET, six.text_type("/apps/%s/channels") % self.app_id, params) - @request_method def channel_info(self, channel, attributes=[]): """Get information on a specific channel, see: @@ -175,7 +174,6 @@ def channel_info(self, channel, attributes=[]): return Request( self, GET, "/apps/%s/channels/%s" % (self.app_id, channel), params) - @request_method def users_info(self, channel): """Fetch user ids currently subscribed to a presence channel diff --git a/pusher/util.py b/pusher/util.py index 1043eca..da4b6cb 100644 --- a/pusher/util.py +++ b/pusher/util.py @@ -11,8 +11,10 @@ import six import sys import base64 +SERVER_TO_USER_PREFIX = "#server-to-user-" channel_name_re = re.compile(r'\A[-a-zA-Z0-9_=@,.;]+\Z') +server_to_user_channel_re = re.compile(rf'\A{SERVER_TO_USER_PREFIX}[-a-zA-Z0-9_=@,.;]+\Z') app_id_re = re.compile(r'\A[0-9]+\Z') pusher_url_re = re.compile(r'\A(http|https)://(.*):(.*)@(.*)/apps/([0-9]+)\Z') socket_id_re = re.compile(r'\A\d+\.\d+\Z') @@ -27,18 +29,20 @@ else: byte_type = 'a python3 bytes' + def ensure_text(obj, name): if isinstance(obj, six.text_type): return obj if isinstance(obj, six.string_types): - return six.text_type(obj) + return six.text_type(obj) if isinstance(obj, six.binary_type): - return bytes(obj).decode('utf-8') + return bytes(obj).decode('utf-8') raise TypeError("%s should be %s instead it is a %s" % (name, text, type(obj))) + def ensure_binary(obj, name): """ ensure_binary() ensures that the value is a @@ -46,10 +50,10 @@ def ensure_binary(obj, name): more on this here: https://pythonhosted.org/six/#six.binary_type """ if isinstance(obj, six.binary_type): - return obj + return obj if isinstance(obj, six.text_type) or isinstance(obj, six.string_types): - return obj.encode("utf-8") + return obj.encode("utf-8") raise TypeError("%s should be %s instead it is a %s" % (name, byte_type, type(obj))) @@ -65,6 +69,7 @@ def is_base64(s): except Exception as e: return False + def validate_user_id(user_id): user_id = ensure_text(user_id, "user_id") @@ -80,13 +85,17 @@ def validate_user_id(user_id): return user_id + def validate_channel(channel): channel = ensure_text(channel, "channel") if len(channel) > 200: raise ValueError("Channel too long: %s" % channel) - if not channel_name_re.match(channel): + if channel.startswith(SERVER_TO_USER_PREFIX): + if not server_to_user_channel_re.match(channel): + raise ValueError("Invalid server to user Channel: %s" % channel) + elif not channel_name_re.match(channel): raise ValueError("Invalid Channel: %s" % channel) return channel @@ -101,6 +110,14 @@ def validate_socket_id(socket_id): return socket_id +def validate_user_data(user_data: dict): + if user_data is None: + raise ValueError('user_data is null') + if user_data.get('id') is None: + raise ValueError('user_data has no id field') + validate_user_id(user_data.get('id')) + + def join_attributes(attributes): return six.text_type(',').join(attributes) diff --git a/pusher/version.py b/pusher/version.py index 45722af..3613411 100644 --- a/pusher/version.py +++ b/pusher/version.py @@ -1,2 +1,2 @@ # Don't change the format of this line: the version is extracted by ../setup.py -VERSION = '3.3.2' +VERSION = '3.3.3' diff --git a/pusher_tests/test_authentication_client.py b/pusher_tests/test_authentication_client.py index 251fe13..0a3302e 100644 --- a/pusher_tests/test_authentication_client.py +++ b/pusher_tests/test_authentication_client.py @@ -59,14 +59,14 @@ def test_authenticate_for_private_channels(self): def test_authenticate_for_private_encrypted_channels(self): # The authentication client receives the decoded bytes of the key # not the base64 representation - master_key=u'OHRXNUZRTG5pUTFzQlFGd3J3N3Q2VFZFc0paZDEweVk=' + master_key = u'OHRXNUZRTG5pUTFzQlFGd3J3N3Q2VFZFc0paZDEweVk=' authenticationClient = AuthenticationClient( - key=u'foo', - secret=u'bar', - host=u'host', - app_id=u'4', - encryption_master_key_base64=master_key, - ssl=True) + key=u'foo', + secret=u'bar', + host=u'host', + app_id=u'4', + encryption_master_key_base64=master_key, + ssl=True) expected = { u'auth': u'foo:fff0503dfe4929f5162efe4d1dacbce524b0d8e7e1331117a8651c0e74d369e3', @@ -83,7 +83,6 @@ def test_authenticate_types(self): self.assertRaises(TypeError, lambda: authenticationClient.authenticate(u'plah', 234234)) self.assertRaises(ValueError, lambda: authenticationClient.authenticate(u'::', u'345345')) - def test_authenticate_for_presence_channels(self): authenticationClient = AuthenticationClient( key=u'foo', secret=u'bar', host=u'host', app_id=u'4', ssl=True) @@ -106,6 +105,27 @@ def test_authenticate_for_presence_channels(self): self.assertEqual(actual, expected) dumps_mock.assert_called_once_with(custom_data, cls=None) + def test_authenticate_for_user(self): + authentication_client = AuthenticationClient( + key=u'thisisaauthkey', + secret=u'thisisasecret', + app_id=u'4') + + user_data = { + u'id': u'123', + u'name': u'John Smith' + } + + expected = { + 'auth': 'thisisaauthkey:0dddb208b53c7649f3fbbb86254a6e1986bc6f8b566423ea690c9ca773497373', + "user_data": u"{\"id\":\"123\",\"name\":\"John Smith\"}" + } + + with mock.patch('json.dumps', return_value=expected[u'user_data']) as dumps_mock: + actual = authentication_client.authenticate_user(u'12345.6789', user_data) + + self.assertEqual(actual, expected) + dumps_mock.assert_called_once_with(user_data, cls=None) def test_validate_webhook_success_case(self): authenticationClient = AuthenticationClient( @@ -134,7 +154,6 @@ def test_validate_webhook_bad_types(self): time_mock.assert_not_called() - def test_validate_webhook_bad_key(self): authenticationClient = AuthenticationClient( key=u'foo', secret=u'bar', host=u'host', app_id=u'4', ssl=True) @@ -147,7 +166,6 @@ def test_validate_webhook_bad_key(self): time_mock.assert_not_called() - def test_validate_webhook_bad_signature(self): authenticationClient = AuthenticationClient( key=u'foo', secret=u'bar', host=u'host', app_id=u'4', ssl=True) @@ -162,7 +180,6 @@ def test_validate_webhook_bad_signature(self): time_mock.assert_not_called() - def test_validate_webhook_bad_time(self): authenticationClient = AuthenticationClient( key=u'foo', secret=u'bar', host=u'host', app_id=u'4', ssl=True) @@ -198,7 +215,6 @@ def __init__(self, **kwargs): "4", "key", "secret", host="somehost", json_encoder=JSONEncoder, json_decoder=JSONDecoder) - def test_custom_json_decoder(self): t = 1000 * time.time() body = u'{"nan": NaN, "time_ms": %f}' % t @@ -207,7 +223,6 @@ def test_custom_json_decoder(self): self.authentication_client.key, signature, body) self.assertEqual({u"nan": 99999, u"time_ms": t}, data) - def test_custom_json_encoder(self): expected = { u'channel_data': '{"money": "1.32"}', diff --git a/pusher_tests/test_pusher_client.py b/pusher_tests/test_pusher_client.py index ed75d34..23e36c7 100644 --- a/pusher_tests/test_pusher_client.py +++ b/pusher_tests/test_pusher_client.py @@ -23,6 +23,7 @@ except ImportError: import mock + class TestPusherClient(unittest.TestCase): def setUp(self): self.pusher_client = PusherClient(app_id=u'4', key=u'key', secret=u'secret', host=u'somehost') @@ -68,7 +69,7 @@ def test_trigger_with_channels_list_success_case(self): self.assertEqual(request.params, expected_params) # FIXME: broken - #json_dumps_mock.assert_called_once_with({u'message': u'hello world'}) + # json_dumps_mock.assert_called_once_with({u'message': u'hello world'}) def test_trigger_with_channel_string_success_case(self): json_dumped = u'{"message": "hello worlds"}' @@ -89,7 +90,6 @@ def test_trigger_batch_success_case(self): json_dumped = u'{"message": "something"}' with mock.patch('json.dumps', return_value=json_dumped) as json_dumps_mock: - request = self.pusher_client.trigger_batch.make_request([{ u'channel': u'my-chan', u'name': u'my-event', @@ -229,14 +229,13 @@ def test_trigger_with_public_channel_with_encryption_master_key_specified_succes json_dumped = u'{"message": "something"}' pc = PusherClient( - app_id=u'4', - key=u'key', - secret=u'secret', - encryption_master_key_base64=u'OHRXNUZRTG5pUTFzQlFGd3J3N3Q2VFZFc0paZDEweVk=', - ssl=True) + app_id=u'4', + key=u'key', + secret=u'secret', + encryption_master_key_base64=u'OHRXNUZRTG5pUTFzQlFGd3J3N3Q2VFZFc0paZDEweVk=', + ssl=True) with mock.patch('json.dumps', return_value=json_dumped) as json_dumps_mock: - request = pc.trigger.make_request(u'donuts', u'some_event', {u'message': u'hello worlds'}) expected_params = { u'channels': [u'donuts'], @@ -251,11 +250,11 @@ def test_trigger_with_private_encrypted_channel_success(self): master_key = b'8tW5FQLniQ1sBQFwrw7t6TVEsJZd10yY' master_key_base64 = base64.b64encode(master_key) pc = PusherClient( - app_id=u'4', - key=u'key', - secret=u'secret', - encryption_master_key_base64=master_key_base64, - ssl=True) + app_id=u'4', + key=u'key', + secret=u'secret', + encryption_master_key_base64=master_key_base64, + ssl=True) # trigger a request to a private-encrypted channel and capture the request to assert equality chan = "private-encrypted-tst" @@ -281,7 +280,7 @@ def test_trigger_with_private_encrypted_channel_success(self): cipher_text_b64 = base64.b64encode(cipher_text) # format expected output - json_dumped = json.dumps({ "nonce" : nonce_b64.decode("utf-8"), "ciphertext": cipher_text_b64.decode("utf-8") }) + json_dumped = json.dumps({"nonce": nonce_b64.decode("utf-8"), "ciphertext": cipher_text_b64.decode("utf-8")}) expected_params = { u'channels': [u'private-encrypted-tst'], @@ -290,28 +289,13 @@ def test_trigger_with_private_encrypted_channel_success(self): } self.assertEqual(request.params, expected_params) - def test_trigger_with_channel_string_success_case(self): - json_dumped = u'{"message": "hello worlds"}' - - with mock.patch('json.dumps', return_value=json_dumped) as json_dumps_mock: - - request = self.pusher_client.trigger.make_request(u'some_channel', u'some_event', {u'message': u'hello worlds'}) - - expected_params = { - u'channels': [u'some_channel'], - u'data': json_dumped, - u'name': u'some_event' - } - - self.assertEqual(request.params, expected_params) - def test_trigger_disallow_non_string_or_list_channels(self): self.assertRaises(TypeError, lambda: self.pusher_client.trigger.make_request({u'channels': u'test_channel'}, u'some_event', {u'message': u'hello world'})) def test_trigger_disallow_invalid_channels(self): self.assertRaises(ValueError, lambda: - self.pusher_client.trigger.make_request([u'so/me_channel!'], u'some_event', {u'message': u'hello world'})) + self.pusher_client.trigger.make_request([u'so/me_channel!'], u'some_event', {u'message': u'hello world'})) def test_trigger_disallow_private_encrypted_channel_with_multiple_channels(self): pc = PusherClient( diff --git a/pusher_tests/test_util.py b/pusher_tests/test_util.py index 492e4c1..5125217 100644 --- a/pusher_tests/test_util.py +++ b/pusher_tests/test_util.py @@ -2,6 +2,7 @@ import pusher.util + class TestUtil(unittest.TestCase): def test_validate_user_id(self): valid_user_ids = ["1", "12", "abc", "ab12", "ABCDEFG1234"] @@ -14,5 +15,28 @@ def test_validate_user_id(self): with self.assertRaises(ValueError): pusher.util.validate_user_id(user_id) + def test_validate_channel(self): + valid_channels = ["123", "xyz", "xyz123", "xyz_123", "xyz-123", "Channel@123", "channel_xyz", "channel-xyz", "channel,456", "channel;asd", "-abc_ABC@012.xpto,987;654"] + + invalid_channels = ["#123", "x" * 201, "abc%&*", "#server-to-user1234", "#server-to-users"] + + for channel in valid_channels: + self.assertEqual(channel, pusher.util.validate_channel(channel)) + + for invalid_channel in invalid_channels: + with self.assertRaises(ValueError): + pusher.util.validate_channel(invalid_channel) + + def test_validate_server_to_user_channel(self): + self.assertEqual("#server-to-user-123", pusher.util.validate_channel("#server-to-user-123")) + self.assertEqual("#server-to-user-user123", pusher.util.validate_channel("#server-to-user-user123")) + self.assertEqual("#server-to-user-ID-123", pusher.util.validate_channel("#server-to-user-ID-123")) + + with self.assertRaises(ValueError): + pusher.util.validate_channel("#server-to-useR-123") + pusher.util.validate_channel("#server-to-user1234") + pusher.util.validate_channel("#server-to-users") + + if __name__ == '__main__': unittest.main() diff --git a/requirements.txt b/requirements.txt index 7cdd39b..852ba03 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ attrs==19.1.0; python_version >= '3.5' and python_version < '3.10' attrs==21.4.0; python_version >= '3.10' certifi==2021.10.8; python_version >= '3.10' charset-normalizer==2.0.12; python_version >= '3.10' -cryptography==37.0.1; python_version >= '3.10' +cryptography==41.0.0; python_version >= '3.10' frozenlist==1.3.0; python_version >= '3.10' httpretty==1.1.4; python_version >= '3.10' idna-ssl==1.1.0; python_version >= '3.5' and python_version < '3.7' @@ -42,4 +42,4 @@ tornado==5.1.1; python_version < '3.5' tornado==6.0.2; python_version >= '3.5' and python_version < '3.10' urllib3==1.26.9; python_version >= '3.10' yarl==1.3.0; python_version >= '3.5' and python_version < '3.10' -yarl==1.7.2; python_version >= '3.10' \ No newline at end of file +yarl==1.7.2; python_version >= '3.10'