diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7112f197..4b70e335 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -35,5 +35,7 @@ jobs: run: poetry install -E crypto - name: Lint with flake8 run: poetry run flake8 + - name: Generate rest sync code and tests + run: poetry run unasync - name: Test with pytest run: poetry run pytest diff --git a/.gitignore b/.gitignore index 71554b60..90697255 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ app_spec app_spec.pkl ably/types/options.py.orig test/ably/restsetup.py.orig + +.idea/**/* +**/ably/sync/*** diff --git a/README.md b/README.md index cd12649e..392b640a 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ introduced by version 1.2.0. ### Using the Rest API +> [!NOTE] +> Please note that since version 2.0.2 we also provide a synchronous variant of the REST interface which is can be accessed as `from ably.sync import AblyRestSync`. + All examples assume a client and/or channel has been created in one of the following ways: With closing the client manually: diff --git a/UPDATING.md b/UPDATING.md index b30a7f94..fff56553 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -72,6 +72,7 @@ These include: - Deprecation of support for Python versions 3.4, 3.5 and 3.6 - New, asynchronous API + - Deprecated synchronous API ### Deprecation of Python 3.4, 3.5 and 3.6 @@ -85,6 +86,21 @@ To see which versions of Python we test the SDK against, please look at our The 1.2.0 version introduces a breaking change, which changes the way of interacting with the SDK from synchronous to asynchronous, using [the `asyncio` foundational library](https://docs.python.org/3.7/library/asyncio.html) to provide support for `async`/`await` syntax. Because of this breaking change, every call that interacts with the Ably REST API must be refactored to this asynchronous way. +For backwards compatibility, in ably-python 2.0.2 we have added a backwards compatible REST client so that you can still use the synchronous version of the REST interface if you are migrating forwards from version 1.1. +In order to use the synchronous variant, you can import the `AblyRestSync` constructor from `ably.sync`: + +```python +from ably.sync import AblyRestSync + +def main(): + ably = AblyRestSync('api:key') + channel = ably.channels.get("channel_name") + channel.publish('event', 'message') + +if __name__ == "__main__": + main() +``` + #### Publishing Messages This old style, synchronous example: diff --git a/ably/scripts/__init__.py b/ably/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ably/scripts/unasync.py b/ably/scripts/unasync.py new file mode 100644 index 00000000..ed148742 --- /dev/null +++ b/ably/scripts/unasync.py @@ -0,0 +1,293 @@ +import glob +import os +import tokenize as std_tokenize + +import tokenize_rt + +rename_classes = [ + "AblyRest", + "Push", + "PushAdmin", + "Channel", + "Channels", + "Auth", + "Http", + "PaginatedResult", + "HttpPaginatedResponse" +] + +_TOKEN_REPLACE = { + "__aenter__": "__enter__", + "__aexit__": "__exit__", + "__aiter__": "__iter__", + "__anext__": "__next__", + "asynccontextmanager": "contextmanager", + "AsyncIterable": "Iterable", + "AsyncIterator": "Iterator", + "AsyncGenerator": "Generator", + "StopAsyncIteration": "StopIteration", +} + +_IMPORTS_REPLACE = { +} + +_STRING_REPLACE = { +} + +_CLASS_RENAME = { +} + + +class Rule: + """A single set of rules for 'unasync'ing file(s)""" + + def __init__(self, fromdir, todir, output_file_prefix="", additional_replacements=None): + self.fromdir = fromdir.replace("/", os.sep) + self.todir = todir.replace("/", os.sep) + self.ouput_file_prefix = output_file_prefix + + # Add any additional user-defined token replacements to our list. + self.token_replacements = _TOKEN_REPLACE.copy() + for key, val in (additional_replacements or {}).items(): + self.token_replacements[key] = val + + def _match(self, filepath): + """Determines if a Rule matches a given filepath and if so + returns a higher comparable value if the match is more specific. + """ + file_segments = [x for x in filepath.split(os.sep) if x] + from_segments = [x for x in self.fromdir.split(os.sep) if x] + len_from_segments = len(from_segments) + + if len_from_segments > len(file_segments): + return False + + for i in range(len(file_segments) - len_from_segments + 1): + if file_segments[i: i + len_from_segments] == from_segments: + return len_from_segments, i + + return False + + def _unasync_file(self, filepath): + with open(filepath, "rb") as f: + encoding, _ = std_tokenize.detect_encoding(f.readline) + + with open(filepath, "rt", encoding=encoding) as f: + tokens = tokenize_rt.src_to_tokens(f.read()) + tokens = self._unasync_tokens(tokens) + result = tokenize_rt.tokens_to_src(tokens) + new_file_path = os.path.join(os.path.dirname(filepath), + self.ouput_file_prefix + os.path.basename(filepath)) + outfilepath = new_file_path.replace(self.fromdir, self.todir) + os.makedirs(os.path.dirname(outfilepath), exist_ok=True) + with open(outfilepath, "wb") as f: + f.write(result.encode(encoding)) + + def _unasync_tokens(self, tokens: list): + new_tokens = [] + token_counter = 0 + async_await_block_started = False + async_await_char_diff = -6 # (len("async") or len("await") is 6) + async_await_offset = 0 + + renamed_class_call_started = False + renamed_class_char_diff = 0 + renamed_class_offset = 0 + + while token_counter < len(tokens): + token = tokens[token_counter] + + if async_await_block_started or renamed_class_call_started: + # Fix indentation issues for async/await fn definition/call + if token.src == '\n': + new_tokens.append(token) + token_counter = token_counter + 1 + next_newline_token = tokens[token_counter] + new_tab_src = next_newline_token.src + + if (renamed_class_call_started and + tokens[token_counter + 1].utf8_byte_offset >= renamed_class_offset): + if renamed_class_char_diff < 0: + new_tab_src = new_tab_src[:renamed_class_char_diff] + else: + new_tab_src = new_tab_src + renamed_class_char_diff * " " + + if (async_await_block_started and len(next_newline_token.src) >= 6 and + tokens[token_counter + 1].utf8_byte_offset >= async_await_offset + 6): + new_tab_src = new_tab_src[:async_await_char_diff] # remove last 6 white spaces + + next_newline_token = next_newline_token._replace(src=new_tab_src) + new_tokens.append(next_newline_token) + token_counter = token_counter + 1 + continue + + if token.src == ')': + async_await_block_started = False + async_await_offset = 0 + renamed_class_call_started = False + renamed_class_char_diff = 0 + + if token.src in ["async", "await"]: + # When removing async or await, we want to skip the following whitespace + token_counter = token_counter + 2 + is_async_start = tokens[token_counter].src == 'def' + is_await_start = False + for i in range(token_counter, token_counter + 6): + if tokens[i].src == '(': + is_await_start = True + break + if is_async_start or is_await_start: + # Fix indentation issues for async/await fn definition/call + async_await_offset = token.utf8_byte_offset + async_await_block_started = True + continue + + elif token.name == "NAME": + if token.src == "from": + if tokens[token_counter + 1].src == " ": + token_counter = self._replace_import(tokens, token_counter, new_tokens) + continue + else: + token_new_src = self._unasync_name(token.src) + if token.src == token_new_src: + token_new_src = self._class_rename(token.src) + if token.src != token_new_src: + renamed_class_offset = token.utf8_byte_offset + renamed_class_char_diff = len(token_new_src) - len(token.src) + for i in range(token_counter, token_counter + 6): + if tokens[i].src == '(': + renamed_class_call_started = True + break + + token = token._replace(src=token_new_src) + elif token.name == "STRING": + src_token = token.src.replace("'", "") + if _STRING_REPLACE.get(src_token) is not None: + new_token = f"'{_STRING_REPLACE[src_token]}'" + token = token._replace(src=new_token) + else: + src_token = token.src.replace("\"", "") + if _STRING_REPLACE.get(src_token) is not None: + new_token = f"\"{_STRING_REPLACE[src_token]}\"" + token = token._replace(src=new_token) + + new_tokens.append(token) + token_counter = token_counter + 1 + + return new_tokens + + def _replace_import(self, tokens, token_counter, new_tokens: list): + new_tokens.append(tokens[token_counter]) + new_tokens.append(tokens[token_counter + 1]) + + full_lib_name = '' + lib_name_counter = token_counter + 2 + if len(_IMPORTS_REPLACE.keys()) == 0: + return lib_name_counter + + while True: + if tokens[lib_name_counter].src == " ": + break + full_lib_name = full_lib_name + tokens[lib_name_counter].src + lib_name_counter = lib_name_counter + 1 + + for key, value in _IMPORTS_REPLACE.items(): + if key in full_lib_name: + updated_lib_name = full_lib_name.replace(key, value) + for lib_name_part in updated_lib_name.split("."): + lib_name_part = self._class_rename(lib_name_part) + new_tokens.append(tokenize_rt.Token("NAME", lib_name_part)) + new_tokens.append(tokenize_rt.Token("OP", ".")) + new_tokens.pop() + return lib_name_counter + + lib_name_counter = token_counter + 2 + return lib_name_counter + + def _class_rename(self, name): + if name in _CLASS_RENAME: + return _CLASS_RENAME[name] + return name + + def _unasync_name(self, name): + if name in self.token_replacements: + return self.token_replacements[name] + return name + + +def unasync_files(fpath_list, rules): + for f in fpath_list: + found_rule = None + found_weight = None + + for rule in rules: + weight = rule._match(f) + if weight and (found_weight is None or weight > found_weight): + found_rule = rule + found_weight = weight + + if found_rule: + found_rule._unasync_file(f) + + +def find_files(dir_path, file_name_regex): + return glob.glob(os.path.join(dir_path, "**", file_name_regex), recursive=True) + + +def run(): + # Source files ========================================== + + _TOKEN_REPLACE["AsyncClient"] = "Client" + _TOKEN_REPLACE["aclose"] = "close" + + _IMPORTS_REPLACE["ably"] = "ably.sync" + + # here... + for class_name in rename_classes: + _CLASS_RENAME[class_name] = f"{class_name}Sync" + + _STRING_REPLACE["Auth"] = "AuthSync" + + src_dir_path = os.path.join(os.getcwd(), "ably") + dest_dir_path = os.path.join(os.getcwd(), "ably", "sync") + + relevant_src_files = (set(find_files(src_dir_path, "*.py")) - + set(find_files(dest_dir_path, "*.py"))) + + unasync_files(list(relevant_src_files), [Rule(fromdir=src_dir_path, todir=dest_dir_path)]) + + # Test files ============================================== + + _TOKEN_REPLACE["asyncSetUp"] = "setUp" + _TOKEN_REPLACE["asyncTearDown"] = "tearDown" + _TOKEN_REPLACE["AsyncMock"] = "Mock" + + _TOKEN_REPLACE["_Channel__publish_request_body"] = "_ChannelSync__publish_request_body" + _TOKEN_REPLACE["_Http__client"] = "_HttpSync__client" + + _IMPORTS_REPLACE["test.ably"] = "test.ably.sync" + + _STRING_REPLACE['/../assets/testAppSpec.json'] = '/../../assets/testAppSpec.json' + _STRING_REPLACE['ably.rest.auth.Auth.request_token'] = 'ably.sync.rest.auth.AuthSync.request_token' + _STRING_REPLACE['ably.rest.auth.TokenRequest'] = 'ably.sync.rest.auth.TokenRequest' + _STRING_REPLACE['ably.rest.rest.Http.post'] = 'ably.sync.rest.rest.HttpSync.post' + _STRING_REPLACE['httpx.AsyncClient.send'] = 'httpx.Client.send' + _STRING_REPLACE['ably.util.exceptions.AblyException.raise_for_response'] = \ + 'ably.sync.util.exceptions.AblyException.raise_for_response' + _STRING_REPLACE['ably.rest.rest.AblyRest.time'] = 'ably.sync.rest.rest.AblyRestSync.time' + _STRING_REPLACE['ably.rest.auth.Auth._timestamp'] = 'ably.sync.rest.auth.AuthSync._timestamp' + + # round 1 + src_dir_path = os.path.join(os.getcwd(), "test", "ably") + dest_dir_path = os.path.join(os.getcwd(), "test", "ably", "sync") + src_files = [os.path.join(os.getcwd(), "test", "ably", "testapp.py"), + os.path.join(os.getcwd(), "test", "ably", "utils.py")] + + unasync_files(src_files, [Rule(fromdir=src_dir_path, todir=dest_dir_path)]) + + # round 2 + src_dir_path = os.path.join(os.getcwd(), "test", "ably", "rest") + dest_dir_path = os.path.join(os.getcwd(), "test", "ably", "sync", "rest") + src_files = find_files(src_dir_path, "*.py") + + unasync_files(src_files, [Rule(fromdir=src_dir_path, todir=dest_dir_path, output_file_prefix="sync_")]) diff --git a/poetry.lock b/poetry.lock index 1510fa0e..a07edf83 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "anyio" version = "3.7.1" description = "High level compatibility layer for multiple asynchronous event loop implementations" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -27,7 +26,6 @@ trio = ["trio (<0.22)"] name = "async-case" version = "10.1.0" description = "Backport of Python 3.8's unittest.async_case" -category = "dev" optional = false python-versions = "*" files = [ @@ -38,7 +36,6 @@ files = [ name = "certifi" version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -50,7 +47,6 @@ files = [ name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -62,7 +58,6 @@ files = [ name = "coverage" version = "7.2.7" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -135,7 +130,6 @@ toml = ["tomli"] name = "exceptiongroup" version = "1.1.3" description = "Backport of PEP 654 (exception groups)" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -150,7 +144,6 @@ test = ["pytest (>=6)"] name = "execnet" version = "2.0.2" description = "execnet: rapid multi-Python deployment" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -165,7 +158,6 @@ testing = ["hatch", "pre-commit", "pytest", "tox"] name = "flake8" version = "3.9.2" description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" files = [ @@ -183,7 +175,6 @@ pyflakes = ">=2.3.0,<2.4.0" name = "h11" version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -198,7 +189,6 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""} name = "h2" version = "4.1.0" description = "HTTP/2 State-Machine based protocol implementation" -category = "main" optional = false python-versions = ">=3.6.1" files = [ @@ -214,7 +204,6 @@ hyperframe = ">=6.0,<7" name = "hpack" version = "4.0.0" description = "Pure-Python HPACK header compression" -category = "main" optional = false python-versions = ">=3.6.1" files = [ @@ -226,7 +215,6 @@ files = [ name = "httpcore" version = "0.17.3" description = "A minimal low-level HTTP client." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -238,17 +226,16 @@ files = [ anyio = ">=3.0,<5.0" certifi = "*" h11 = ">=0.13,<0.15" -sniffio = ">=1.0.0,<2.0.0" +sniffio = "==1.*" [package.extras] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] +socks = ["socksio (==1.*)"] [[package]] name = "httpx" version = "0.24.1" description = "The next generation HTTP client." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -265,15 +252,14 @@ sniffio = "*" [package.extras] brotli = ["brotli", "brotlicffi"] -cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] +socks = ["socksio (==1.*)"] [[package]] name = "hyperframe" version = "6.0.1" description = "HTTP/2 framing layer for Python" -category = "main" optional = false python-versions = ">=3.6.1" files = [ @@ -285,7 +271,6 @@ files = [ name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -297,7 +282,6 @@ files = [ name = "importlib-metadata" version = "4.13.0" description = "Read metadata from Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -318,7 +302,6 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -330,7 +313,6 @@ files = [ name = "mccabe" version = "0.6.1" description = "McCabe checker, plugin for flake8" -category = "dev" optional = false python-versions = "*" files = [ @@ -342,7 +324,6 @@ files = [ name = "methoddispatch" version = "3.0.2" description = "singledispatch decorator for class methods." -category = "main" optional = false python-versions = "*" files = [ @@ -354,7 +335,6 @@ files = [ name = "mock" version = "4.0.3" description = "Rolling backport of unittest.mock for all Pythons" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -371,7 +351,6 @@ test = ["pytest (<5.4)", "pytest-cov"] name = "msgpack" version = "1.0.5" description = "MessagePack serializer" -category = "main" optional = false python-versions = "*" files = [ @@ -442,21 +421,19 @@ files = [ [[package]] name = "packaging" -version = "23.1" +version = "23.2" description = "Core utilities for Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, - {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] [[package]] name = "pep8-naming" version = "0.4.1" description = "Check PEP-8 naming conventions, plugin for flake8" -category = "dev" optional = false python-versions = "*" files = [ @@ -468,7 +445,6 @@ files = [ name = "pluggy" version = "1.2.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -487,7 +463,6 @@ testing = ["pytest", "pytest-benchmark"] name = "py" version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -499,7 +474,6 @@ files = [ name = "pycodestyle" version = "2.7.0" description = "Python style guide checker" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -511,7 +485,6 @@ files = [ name = "pycrypto" version = "2.6.1" description = "Cryptographic modules for Python." -category = "main" optional = true python-versions = "*" files = [ @@ -520,51 +493,49 @@ files = [ [[package]] name = "pycryptodome" -version = "3.18.0" +version = "3.19.0" description = "Cryptographic library for Python" -category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ - {file = "pycryptodome-3.18.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:d1497a8cd4728db0e0da3c304856cb37c0c4e3d0b36fcbabcc1600f18504fc54"}, - {file = "pycryptodome-3.18.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:928078c530da78ff08e10eb6cada6e0dff386bf3d9fa9871b4bbc9fbc1efe024"}, - {file = "pycryptodome-3.18.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:157c9b5ba5e21b375f052ca78152dd309a09ed04703fd3721dce3ff8ecced148"}, - {file = "pycryptodome-3.18.0-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:d20082bdac9218649f6abe0b885927be25a917e29ae0502eaf2b53f1233ce0c2"}, - {file = "pycryptodome-3.18.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:e8ad74044e5f5d2456c11ed4cfd3e34b8d4898c0cb201c4038fe41458a82ea27"}, - {file = "pycryptodome-3.18.0-cp27-cp27m-win32.whl", hash = "sha256:62a1e8847fabb5213ccde38915563140a5b338f0d0a0d363f996b51e4a6165cf"}, - {file = "pycryptodome-3.18.0-cp27-cp27m-win_amd64.whl", hash = "sha256:16bfd98dbe472c263ed2821284118d899c76968db1a6665ade0c46805e6b29a4"}, - {file = "pycryptodome-3.18.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:7a3d22c8ee63de22336679e021c7f2386f7fc465477d59675caa0e5706387944"}, - {file = "pycryptodome-3.18.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:78d863476e6bad2a592645072cc489bb90320972115d8995bcfbee2f8b209918"}, - {file = "pycryptodome-3.18.0-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:b6a610f8bfe67eab980d6236fdc73bfcdae23c9ed5548192bb2d530e8a92780e"}, - {file = "pycryptodome-3.18.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:422c89fd8df8a3bee09fb8d52aaa1e996120eafa565437392b781abec2a56e14"}, - {file = "pycryptodome-3.18.0-cp35-abi3-macosx_10_9_universal2.whl", hash = "sha256:9ad6f09f670c466aac94a40798e0e8d1ef2aa04589c29faa5b9b97566611d1d1"}, - {file = "pycryptodome-3.18.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:53aee6be8b9b6da25ccd9028caf17dcdce3604f2c7862f5167777b707fbfb6cb"}, - {file = "pycryptodome-3.18.0-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:10da29526a2a927c7d64b8f34592f461d92ae55fc97981aab5bbcde8cb465bb6"}, - {file = "pycryptodome-3.18.0-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f21efb8438971aa16924790e1c3dba3a33164eb4000106a55baaed522c261acf"}, - {file = "pycryptodome-3.18.0-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4944defabe2ace4803f99543445c27dd1edbe86d7d4edb87b256476a91e9ffa4"}, - {file = "pycryptodome-3.18.0-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:51eae079ddb9c5f10376b4131be9589a6554f6fd84f7f655180937f611cd99a2"}, - {file = "pycryptodome-3.18.0-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:83c75952dcf4a4cebaa850fa257d7a860644c70a7cd54262c237c9f2be26f76e"}, - {file = "pycryptodome-3.18.0-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:957b221d062d5752716923d14e0926f47670e95fead9d240fa4d4862214b9b2f"}, - {file = "pycryptodome-3.18.0-cp35-abi3-win32.whl", hash = "sha256:795bd1e4258a2c689c0b1f13ce9684fa0dd4c0e08680dcf597cf9516ed6bc0f3"}, - {file = "pycryptodome-3.18.0-cp35-abi3-win_amd64.whl", hash = "sha256:b1d9701d10303eec8d0bd33fa54d44e67b8be74ab449052a8372f12a66f93fb9"}, - {file = "pycryptodome-3.18.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:cb1be4d5af7f355e7d41d36d8eec156ef1382a88638e8032215c215b82a4b8ec"}, - {file = "pycryptodome-3.18.0-pp27-pypy_73-win32.whl", hash = "sha256:fc0a73f4db1e31d4a6d71b672a48f3af458f548059aa05e83022d5f61aac9c08"}, - {file = "pycryptodome-3.18.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f022a4fd2a5263a5c483a2bb165f9cb27f2be06f2f477113783efe3fe2ad887b"}, - {file = "pycryptodome-3.18.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:363dd6f21f848301c2dcdeb3c8ae5f0dee2286a5e952a0f04954b82076f23825"}, - {file = "pycryptodome-3.18.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12600268763e6fec3cefe4c2dcdf79bde08d0b6dc1813887e789e495cb9f3403"}, - {file = "pycryptodome-3.18.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4604816adebd4faf8810782f137f8426bf45fee97d8427fa8e1e49ea78a52e2c"}, - {file = "pycryptodome-3.18.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:01489bbdf709d993f3058e2996f8f40fee3f0ea4d995002e5968965fa2fe89fb"}, - {file = "pycryptodome-3.18.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3811e31e1ac3069988f7a1c9ee7331b942e605dfc0f27330a9ea5997e965efb2"}, - {file = "pycryptodome-3.18.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4b967bb11baea9128ec88c3d02f55a3e338361f5e4934f5240afcb667fdaec"}, - {file = "pycryptodome-3.18.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:9c8eda4f260072f7dbe42f473906c659dcbadd5ae6159dfb49af4da1293ae380"}, - {file = "pycryptodome-3.18.0.tar.gz", hash = "sha256:c9adee653fc882d98956e33ca2c1fb582e23a8af7ac82fee75bd6113c55a0413"}, + {file = "pycryptodome-3.19.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3006c44c4946583b6de24fe0632091c2653d6256b99a02a3db71ca06472ea1e4"}, + {file = "pycryptodome-3.19.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:7c760c8a0479a4042111a8dd2f067d3ae4573da286c53f13cf6f5c53a5c1f631"}, + {file = "pycryptodome-3.19.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:08ce3558af5106c632baf6d331d261f02367a6bc3733086ae43c0f988fe042db"}, + {file = "pycryptodome-3.19.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45430dfaf1f421cf462c0dd824984378bef32b22669f2635cb809357dbaab405"}, + {file = "pycryptodome-3.19.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:a9bcd5f3794879e91970f2bbd7d899780541d3ff439d8f2112441769c9f2ccea"}, + {file = "pycryptodome-3.19.0-cp27-cp27m-win32.whl", hash = "sha256:190c53f51e988dceb60472baddce3f289fa52b0ec38fbe5fd20dd1d0f795c551"}, + {file = "pycryptodome-3.19.0-cp27-cp27m-win_amd64.whl", hash = "sha256:22e0ae7c3a7f87dcdcf302db06ab76f20e83f09a6993c160b248d58274473bfa"}, + {file = "pycryptodome-3.19.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:7822f36d683f9ad7bc2145b2c2045014afdbbd1d9922a6d4ce1cbd6add79a01e"}, + {file = "pycryptodome-3.19.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:05e33267394aad6db6595c0ce9d427fe21552f5425e116a925455e099fdf759a"}, + {file = "pycryptodome-3.19.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:829b813b8ee00d9c8aba417621b94bc0b5efd18c928923802ad5ba4cf1ec709c"}, + {file = "pycryptodome-3.19.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:fc7a79590e2b5d08530175823a242de6790abc73638cc6dc9d2684e7be2f5e49"}, + {file = "pycryptodome-3.19.0-cp35-abi3-macosx_10_9_universal2.whl", hash = "sha256:542f99d5026ac5f0ef391ba0602f3d11beef8e65aae135fa5b762f5ebd9d3bfb"}, + {file = "pycryptodome-3.19.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:61bb3ccbf4bf32ad9af32da8badc24e888ae5231c617947e0f5401077f8b091f"}, + {file = "pycryptodome-3.19.0-cp35-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d49a6c715d8cceffedabb6adb7e0cbf41ae1a2ff4adaeec9432074a80627dea1"}, + {file = "pycryptodome-3.19.0-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e249a784cc98a29c77cea9df54284a44b40cafbfae57636dd2f8775b48af2434"}, + {file = "pycryptodome-3.19.0-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d033947e7fd3e2ba9a031cb2d267251620964705a013c5a461fa5233cc025270"}, + {file = "pycryptodome-3.19.0-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:84c3e4fffad0c4988aef0d5591be3cad4e10aa7db264c65fadbc633318d20bde"}, + {file = "pycryptodome-3.19.0-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:139ae2c6161b9dd5d829c9645d781509a810ef50ea8b657e2257c25ca20efe33"}, + {file = "pycryptodome-3.19.0-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5b1986c761258a5b4332a7f94a83f631c1ffca8747d75ab8395bf2e1b93283d9"}, + {file = "pycryptodome-3.19.0-cp35-abi3-win32.whl", hash = "sha256:536f676963662603f1f2e6ab01080c54d8cd20f34ec333dcb195306fa7826997"}, + {file = "pycryptodome-3.19.0-cp35-abi3-win_amd64.whl", hash = "sha256:04dd31d3b33a6b22ac4d432b3274588917dcf850cc0c51c84eca1d8ed6933810"}, + {file = "pycryptodome-3.19.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:8999316e57abcbd8085c91bc0ef75292c8618f41ca6d2b6132250a863a77d1e7"}, + {file = "pycryptodome-3.19.0-pp27-pypy_73-win32.whl", hash = "sha256:a0ab84755f4539db086db9ba9e9f3868d2e3610a3948cbd2a55e332ad83b01b0"}, + {file = "pycryptodome-3.19.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0101f647d11a1aae5a8ce4f5fad6644ae1b22bb65d05accc7d322943c69a74a6"}, + {file = "pycryptodome-3.19.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c1601e04d32087591d78e0b81e1e520e57a92796089864b20e5f18c9564b3fa"}, + {file = "pycryptodome-3.19.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:506c686a1eee6c00df70010be3b8e9e78f406af4f21b23162bbb6e9bdf5427bc"}, + {file = "pycryptodome-3.19.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7919ccd096584b911f2a303c593280869ce1af9bf5d36214511f5e5a1bed8c34"}, + {file = "pycryptodome-3.19.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:560591c0777f74a5da86718f70dfc8d781734cf559773b64072bbdda44b3fc3e"}, + {file = "pycryptodome-3.19.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cc2f2ae451a676def1a73c1ae9120cd31af25db3f381893d45f75e77be2400"}, + {file = "pycryptodome-3.19.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17940dcf274fcae4a54ec6117a9ecfe52907ed5e2e438fe712fe7ca502672ed5"}, + {file = "pycryptodome-3.19.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d04f5f623a280fbd0ab1c1d8ecbd753193ab7154f09b6161b0f857a1a676c15f"}, + {file = "pycryptodome-3.19.0.tar.gz", hash = "sha256:bc35d463222cdb4dbebd35e0784155c81e161b9284e567e7e933d722e533331e"}, ] [[package]] name = "pyee" version = "9.1.1" description = "A port of node.js's EventEmitter to python." -category = "main" optional = false python-versions = "*" files = [ @@ -579,7 +550,6 @@ typing-extensions = "*" name = "pyflakes" version = "2.3.1" description = "passive checker of Python programs" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -589,14 +559,13 @@ files = [ [[package]] name = "pytest" -version = "7.4.0" +version = "7.4.2" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, - {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, + {file = "pytest-7.4.2-py3-none-any.whl", hash = "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002"}, + {file = "pytest-7.4.2.tar.gz", hash = "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069"}, ] [package.dependencies] @@ -615,7 +584,6 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "pytest-cov" version = "2.12.1" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -635,7 +603,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "pytest-flake8" version = "1.1.0" description = "pytest plugin to check FLAKE8 requirements" -category = "dev" optional = false python-versions = "*" files = [ @@ -651,7 +618,6 @@ pytest = ">=3.5" name = "pytest-forked" version = "1.6.0" description = "run tests in isolated forked subprocesses" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -667,7 +633,6 @@ pytest = ">=3.10" name = "pytest-timeout" version = "2.1.0" description = "pytest plugin to abort hanging tests" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -682,7 +647,6 @@ pytest = ">=5.0.0" name = "pytest-xdist" version = "1.34.0" description = "pytest xdist plugin for distributed testing and loop-on-failing modes" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -703,7 +667,6 @@ testing = ["filelock"] name = "respx" version = "0.20.2" description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -718,7 +681,6 @@ httpx = ">=0.21.0" name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -730,7 +692,6 @@ files = [ name = "sniffio" version = "1.3.0" description = "Sniff out which async library your code is running under" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -738,11 +699,21 @@ files = [ {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, ] +[[package]] +name = "tokenize-rt" +version = "5.0.0" +description = "A wrapper around the stdlib `tokenize` which roundtrips." +optional = false +python-versions = ">=3.7" +files = [ + {file = "tokenize_rt-5.0.0-py2.py3-none-any.whl", hash = "sha256:c67772c662c6b3dc65edf66808577968fb10badfc2042e3027196bed4daf9e5a"}, + {file = "tokenize_rt-5.0.0.tar.gz", hash = "sha256:3160bc0c3e8491312d0485171dea861fc160a240f5f5766b72a1165408d10740"}, +] + [[package]] name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -754,7 +725,6 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -766,7 +736,6 @@ files = [ name = "typing-extensions" version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -778,7 +747,6 @@ files = [ name = "websockets" version = "10.4" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -857,7 +825,6 @@ files = [ name = "zipp" version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -876,4 +843,4 @@ oldcrypto = ["pycrypto"] [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "885ad9d7e6a0adc96cae0dcf69a7c8d7af8dbbf3651b7cce29deed789ad581e7" +content-hash = "a6ee4818d5e151e0149c60bb77a2c74aa9f8e676ffd99277af588ad06031c67d" diff --git a/pyproject.toml b/pyproject.toml index 3cb26fb9..042de6e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,9 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Topic :: Software Development :: Libraries :: Python Modules", ] +include = [ + 'ably/**/*.py' +] [tool.poetry.dependencies] python = "^3.7" @@ -50,6 +53,7 @@ respx = "^0.20.0" importlib-metadata = "^4.12" pytest-timeout = "^2.1.0" async-case = { version = "^10.1.0", python = "~3.7" } +tokenize_rt = "*" [build-system] requires = ["poetry-core>=1.0.0"] @@ -57,3 +61,6 @@ build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] timeout = 30 + +[tool.poetry.scripts] +unasync = 'ably.scripts.unasync:run' diff --git a/test/ably/rest/restauth_test.py b/test/ably/rest/restauth_test.py index a6ac0ceb..5e647920 100644 --- a/test/ably/rest/restauth_test.py +++ b/test/ably/rest/restauth_test.py @@ -346,7 +346,7 @@ async def test_with_key(self): @dont_vary_protocol @respx.mock - async def test_with_auth_url_headers_and_params_POST(self): # noqa: N802 + async def test_with_auth_url_headers_and_params_http_post(self): # noqa: N802 url = 'http://www.example.com' headers = {'foo': 'bar'} ably = await TestApp.get_ably_rest(key=None, auth_url=url) @@ -381,7 +381,7 @@ def call_back(request): @dont_vary_protocol @respx.mock - async def test_with_auth_url_headers_and_params_GET(self): # noqa: N802 + async def test_with_auth_url_headers_and_params_http_get(self): # noqa: N802 url = 'http://www.example.com' headers = {'foo': 'bar'} ably = await TestApp.get_ably_rest( diff --git a/test/ably/rest/restcapability_test.py b/test/ably/rest/restcapability_test.py index 0182dcb0..f7c761ab 100644 --- a/test/ably/rest/restcapability_test.py +++ b/test/ably/rest/restcapability_test.py @@ -21,8 +21,7 @@ def per_protocol_setup(self, use_binary_protocol): async def test_blanket_intersection_with_key(self): key = self.test_vars['keys'][1] - token_details = await self.ably.auth.request_token(key_name=key['key_name'], - key_secret=key['key_secret']) + token_details = await self.ably.auth.request_token(key_name=key['key_name'], key_secret=key['key_secret']) expected_capability = Capability(key["capability"]) assert token_details.token is not None, "Expected token" assert expected_capability == token_details.capability, "Unexpected capability." diff --git a/test/ably/rest/restchannelpublish_test.py b/test/ably/rest/restchannelpublish_test.py index 6cf458eb..882bedc4 100644 --- a/test/ably/rest/restchannelpublish_test.py +++ b/test/ably/rest/restchannelpublish_test.py @@ -16,6 +16,7 @@ from ably.types.message import Message from ably.types.tokendetails import TokenDetails from ably.util import case +from test.ably import utils from test.ably.testapp import TestApp from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseAsyncTestCase @@ -385,8 +386,7 @@ async def test_interoperability(self): 'binary': bytearray, } - root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) - path = os.path.join(root_dir, 'submodules', 'test-resources', 'messages-encoding.json') + path = os.path.join(utils.get_submodule_dir(__file__), 'test-resources', 'messages-encoding.json') with open(path) as f: data = json.load(f) for input_msg in data['messages']: diff --git a/test/ably/rest/restcrypto_test.py b/test/ably/rest/restcrypto_test.py index 18bf69ac..b6ea577b 100644 --- a/test/ably/rest/restcrypto_test.py +++ b/test/ably/rest/restcrypto_test.py @@ -11,6 +11,7 @@ from Crypto import Random +from test.ably import utils from test.ably.testapp import TestApp from test.ably.utils import dont_vary_protocol, VaryByProtocolTestsMetaclass, BaseTestCase, BaseAsyncTestCase @@ -204,7 +205,7 @@ class AbstractTestCryptoWithFixture: @classmethod def setUpClass(cls): - resources_path = os.path.dirname(__file__) + '/../../../submodules/test-resources/%s' % cls.fixture_file + resources_path = os.path.join(utils.get_submodule_dir(__file__), 'test-resources', cls.fixture_file) with open(resources_path, 'r') as f: cls.fixture = json.loads(f.read()) cls.params = { diff --git a/test/ably/rest/resttoken_test.py b/test/ably/rest/resttoken_test.py index a50c5ea4..9e74e695 100644 --- a/test/ably/rest/resttoken_test.py +++ b/test/ably/rest/resttoken_test.py @@ -40,7 +40,7 @@ async def test_request_token_null_params(self): post_time = await self.server_time() assert token_details.token is not None, "Expected token" assert token_details.issued + 300 >= pre_time, "Unexpected issued time" - assert token_details.issued <= post_time, "Unexpected issued time" + assert token_details.issued <= post_time + 500, "Unexpected issued time" assert self.permit_all == str(token_details.capability), "Unexpected capability" async def test_request_token_explicit_timestamp(self): diff --git a/test/ably/utils.py b/test/ably/utils.py index cb0a5b0d..0edddb90 100644 --- a/test/ably/utils.py +++ b/test/ably/utils.py @@ -1,8 +1,10 @@ import functools +import os import random import string import unittest import sys + if sys.version_info >= (3, 8): from unittest import IsolatedAsyncioTestCase else: @@ -90,8 +92,8 @@ async def test_decorated(self, *args, **kwargs): await fn(self, *args, **kwargs) unpatch(patcher) - assert len(responses) >= 1,\ - "If your test doesn't make any requests, use the @dont_vary_protocol decorator" + assert len(responses) >= 1, \ + "If your test doesn't make any requests, use the @dont_vary_protocol decorator" for response in responses: # In HTTP/2 some header fields are optional in case of 204 status code @@ -107,6 +109,7 @@ async def test_decorated(self, *args, **kwargs): msgpack.unpackb(response.content) return test_decorated + return test_decorator @@ -122,11 +125,11 @@ def per_protocol_setup(self, use_binary_protocol): is called * exclude tests with the @dont_vary_protocol decorator """ + def __new__(cls, clsname, bases, dct): for key, value in tuple(dct.items()): if key.startswith('test') and not getattr(value, 'dont_vary_protocol', False): - wrapper_bin = cls.wrap_as('bin', key, value) wrapper_text = cls.wrap_as('text', key, value) @@ -145,6 +148,7 @@ async def wrapper(self): if hasattr(self, 'per_protocol_setup'): self.per_protocol_setup(ttype == 'bin') await old_func(self) + wrapper.__name__ = old_name + '_' + ttype return wrapper @@ -166,3 +170,11 @@ def new_dict(src, **kw): def get_random_key(d): return random.choice(list(d)) + + +def get_submodule_dir(filepath): + root_dir = os.path.dirname(filepath) + while True: + if os.path.exists(os.path.join(root_dir, 'submodules')): + return os.path.join(root_dir, 'submodules') + root_dir = os.path.dirname(root_dir)