Skip to content

Commit 0de24e4

Browse files
authored
Merge pull request #537 from ably/feature/add-sync-using-unasync
[SDK-3862] Add sync support using unasync
2 parents 716e0b9 + b6b463b commit 0de24e4

14 files changed

Lines changed: 403 additions & 100 deletions

.github/workflows/check.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,7 @@ jobs:
3535
run: poetry install -E crypto
3636
- name: Lint with flake8
3737
run: poetry run flake8
38+
- name: Generate rest sync code and tests
39+
run: poetry run unasync
3840
- name: Test with pytest
3941
run: poetry run pytest

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,6 @@ app_spec
5353
app_spec.pkl
5454
ably/types/options.py.orig
5555
test/ably/restsetup.py.orig
56+
57+
.idea/**/*
58+
**/ably/sync/***

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ introduced by version 1.2.0.
5656

5757
### Using the Rest API
5858

59+
> [!NOTE]
60+
> 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`.
61+
5962
All examples assume a client and/or channel has been created in one of the following ways:
6063

6164
With closing the client manually:

UPDATING.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ These include:
7272

7373
- Deprecation of support for Python versions 3.4, 3.5 and 3.6
7474
- New, asynchronous API
75+
- Deprecated synchronous API
7576

7677
### Deprecation of Python 3.4, 3.5 and 3.6
7778

@@ -85,6 +86,21 @@ To see which versions of Python we test the SDK against, please look at our
8586
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.
8687
Because of this breaking change, every call that interacts with the Ably REST API must be refactored to this asynchronous way.
8788

89+
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.
90+
In order to use the synchronous variant, you can import the `AblyRestSync` constructor from `ably.sync`:
91+
92+
```python
93+
from ably.sync import AblyRestSync
94+
95+
def main():
96+
ably = AblyRestSync('api:key')
97+
channel = ably.channels.get("channel_name")
98+
channel.publish('event', 'message')
99+
100+
if __name__ == "__main__":
101+
main()
102+
```
103+
88104
#### Publishing Messages
89105

90106
This old style, synchronous example:

ably/scripts/__init__.py

Whitespace-only changes.

ably/scripts/unasync.py

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import glob
2+
import os
3+
import tokenize as std_tokenize
4+
5+
import tokenize_rt
6+
7+
rename_classes = [
8+
"AblyRest",
9+
"Push",
10+
"PushAdmin",
11+
"Channel",
12+
"Channels",
13+
"Auth",
14+
"Http",
15+
"PaginatedResult",
16+
"HttpPaginatedResponse"
17+
]
18+
19+
_TOKEN_REPLACE = {
20+
"__aenter__": "__enter__",
21+
"__aexit__": "__exit__",
22+
"__aiter__": "__iter__",
23+
"__anext__": "__next__",
24+
"asynccontextmanager": "contextmanager",
25+
"AsyncIterable": "Iterable",
26+
"AsyncIterator": "Iterator",
27+
"AsyncGenerator": "Generator",
28+
"StopAsyncIteration": "StopIteration",
29+
}
30+
31+
_IMPORTS_REPLACE = {
32+
}
33+
34+
_STRING_REPLACE = {
35+
}
36+
37+
_CLASS_RENAME = {
38+
}
39+
40+
41+
class Rule:
42+
"""A single set of rules for 'unasync'ing file(s)"""
43+
44+
def __init__(self, fromdir, todir, output_file_prefix="", additional_replacements=None):
45+
self.fromdir = fromdir.replace("/", os.sep)
46+
self.todir = todir.replace("/", os.sep)
47+
self.ouput_file_prefix = output_file_prefix
48+
49+
# Add any additional user-defined token replacements to our list.
50+
self.token_replacements = _TOKEN_REPLACE.copy()
51+
for key, val in (additional_replacements or {}).items():
52+
self.token_replacements[key] = val
53+
54+
def _match(self, filepath):
55+
"""Determines if a Rule matches a given filepath and if so
56+
returns a higher comparable value if the match is more specific.
57+
"""
58+
file_segments = [x for x in filepath.split(os.sep) if x]
59+
from_segments = [x for x in self.fromdir.split(os.sep) if x]
60+
len_from_segments = len(from_segments)
61+
62+
if len_from_segments > len(file_segments):
63+
return False
64+
65+
for i in range(len(file_segments) - len_from_segments + 1):
66+
if file_segments[i: i + len_from_segments] == from_segments:
67+
return len_from_segments, i
68+
69+
return False
70+
71+
def _unasync_file(self, filepath):
72+
with open(filepath, "rb") as f:
73+
encoding, _ = std_tokenize.detect_encoding(f.readline)
74+
75+
with open(filepath, "rt", encoding=encoding) as f:
76+
tokens = tokenize_rt.src_to_tokens(f.read())
77+
tokens = self._unasync_tokens(tokens)
78+
result = tokenize_rt.tokens_to_src(tokens)
79+
new_file_path = os.path.join(os.path.dirname(filepath),
80+
self.ouput_file_prefix + os.path.basename(filepath))
81+
outfilepath = new_file_path.replace(self.fromdir, self.todir)
82+
os.makedirs(os.path.dirname(outfilepath), exist_ok=True)
83+
with open(outfilepath, "wb") as f:
84+
f.write(result.encode(encoding))
85+
86+
def _unasync_tokens(self, tokens: list):
87+
new_tokens = []
88+
token_counter = 0
89+
async_await_block_started = False
90+
async_await_char_diff = -6 # (len("async") or len("await") is 6)
91+
async_await_offset = 0
92+
93+
renamed_class_call_started = False
94+
renamed_class_char_diff = 0
95+
renamed_class_offset = 0
96+
97+
while token_counter < len(tokens):
98+
token = tokens[token_counter]
99+
100+
if async_await_block_started or renamed_class_call_started:
101+
# Fix indentation issues for async/await fn definition/call
102+
if token.src == '\n':
103+
new_tokens.append(token)
104+
token_counter = token_counter + 1
105+
next_newline_token = tokens[token_counter]
106+
new_tab_src = next_newline_token.src
107+
108+
if (renamed_class_call_started and
109+
tokens[token_counter + 1].utf8_byte_offset >= renamed_class_offset):
110+
if renamed_class_char_diff < 0:
111+
new_tab_src = new_tab_src[:renamed_class_char_diff]
112+
else:
113+
new_tab_src = new_tab_src + renamed_class_char_diff * " "
114+
115+
if (async_await_block_started and len(next_newline_token.src) >= 6 and
116+
tokens[token_counter + 1].utf8_byte_offset >= async_await_offset + 6):
117+
new_tab_src = new_tab_src[:async_await_char_diff] # remove last 6 white spaces
118+
119+
next_newline_token = next_newline_token._replace(src=new_tab_src)
120+
new_tokens.append(next_newline_token)
121+
token_counter = token_counter + 1
122+
continue
123+
124+
if token.src == ')':
125+
async_await_block_started = False
126+
async_await_offset = 0
127+
renamed_class_call_started = False
128+
renamed_class_char_diff = 0
129+
130+
if token.src in ["async", "await"]:
131+
# When removing async or await, we want to skip the following whitespace
132+
token_counter = token_counter + 2
133+
is_async_start = tokens[token_counter].src == 'def'
134+
is_await_start = False
135+
for i in range(token_counter, token_counter + 6):
136+
if tokens[i].src == '(':
137+
is_await_start = True
138+
break
139+
if is_async_start or is_await_start:
140+
# Fix indentation issues for async/await fn definition/call
141+
async_await_offset = token.utf8_byte_offset
142+
async_await_block_started = True
143+
continue
144+
145+
elif token.name == "NAME":
146+
if token.src == "from":
147+
if tokens[token_counter + 1].src == " ":
148+
token_counter = self._replace_import(tokens, token_counter, new_tokens)
149+
continue
150+
else:
151+
token_new_src = self._unasync_name(token.src)
152+
if token.src == token_new_src:
153+
token_new_src = self._class_rename(token.src)
154+
if token.src != token_new_src:
155+
renamed_class_offset = token.utf8_byte_offset
156+
renamed_class_char_diff = len(token_new_src) - len(token.src)
157+
for i in range(token_counter, token_counter + 6):
158+
if tokens[i].src == '(':
159+
renamed_class_call_started = True
160+
break
161+
162+
token = token._replace(src=token_new_src)
163+
elif token.name == "STRING":
164+
src_token = token.src.replace("'", "")
165+
if _STRING_REPLACE.get(src_token) is not None:
166+
new_token = f"'{_STRING_REPLACE[src_token]}'"
167+
token = token._replace(src=new_token)
168+
else:
169+
src_token = token.src.replace("\"", "")
170+
if _STRING_REPLACE.get(src_token) is not None:
171+
new_token = f"\"{_STRING_REPLACE[src_token]}\""
172+
token = token._replace(src=new_token)
173+
174+
new_tokens.append(token)
175+
token_counter = token_counter + 1
176+
177+
return new_tokens
178+
179+
def _replace_import(self, tokens, token_counter, new_tokens: list):
180+
new_tokens.append(tokens[token_counter])
181+
new_tokens.append(tokens[token_counter + 1])
182+
183+
full_lib_name = ''
184+
lib_name_counter = token_counter + 2
185+
if len(_IMPORTS_REPLACE.keys()) == 0:
186+
return lib_name_counter
187+
188+
while True:
189+
if tokens[lib_name_counter].src == " ":
190+
break
191+
full_lib_name = full_lib_name + tokens[lib_name_counter].src
192+
lib_name_counter = lib_name_counter + 1
193+
194+
for key, value in _IMPORTS_REPLACE.items():
195+
if key in full_lib_name:
196+
updated_lib_name = full_lib_name.replace(key, value)
197+
for lib_name_part in updated_lib_name.split("."):
198+
lib_name_part = self._class_rename(lib_name_part)
199+
new_tokens.append(tokenize_rt.Token("NAME", lib_name_part))
200+
new_tokens.append(tokenize_rt.Token("OP", "."))
201+
new_tokens.pop()
202+
return lib_name_counter
203+
204+
lib_name_counter = token_counter + 2
205+
return lib_name_counter
206+
207+
def _class_rename(self, name):
208+
if name in _CLASS_RENAME:
209+
return _CLASS_RENAME[name]
210+
return name
211+
212+
def _unasync_name(self, name):
213+
if name in self.token_replacements:
214+
return self.token_replacements[name]
215+
return name
216+
217+
218+
def unasync_files(fpath_list, rules):
219+
for f in fpath_list:
220+
found_rule = None
221+
found_weight = None
222+
223+
for rule in rules:
224+
weight = rule._match(f)
225+
if weight and (found_weight is None or weight > found_weight):
226+
found_rule = rule
227+
found_weight = weight
228+
229+
if found_rule:
230+
found_rule._unasync_file(f)
231+
232+
233+
def find_files(dir_path, file_name_regex):
234+
return glob.glob(os.path.join(dir_path, "**", file_name_regex), recursive=True)
235+
236+
237+
def run():
238+
# Source files ==========================================
239+
240+
_TOKEN_REPLACE["AsyncClient"] = "Client"
241+
_TOKEN_REPLACE["aclose"] = "close"
242+
243+
_IMPORTS_REPLACE["ably"] = "ably.sync"
244+
245+
# here...
246+
for class_name in rename_classes:
247+
_CLASS_RENAME[class_name] = f"{class_name}Sync"
248+
249+
_STRING_REPLACE["Auth"] = "AuthSync"
250+
251+
src_dir_path = os.path.join(os.getcwd(), "ably")
252+
dest_dir_path = os.path.join(os.getcwd(), "ably", "sync")
253+
254+
relevant_src_files = (set(find_files(src_dir_path, "*.py")) -
255+
set(find_files(dest_dir_path, "*.py")))
256+
257+
unasync_files(list(relevant_src_files), [Rule(fromdir=src_dir_path, todir=dest_dir_path)])
258+
259+
# Test files ==============================================
260+
261+
_TOKEN_REPLACE["asyncSetUp"] = "setUp"
262+
_TOKEN_REPLACE["asyncTearDown"] = "tearDown"
263+
_TOKEN_REPLACE["AsyncMock"] = "Mock"
264+
265+
_TOKEN_REPLACE["_Channel__publish_request_body"] = "_ChannelSync__publish_request_body"
266+
_TOKEN_REPLACE["_Http__client"] = "_HttpSync__client"
267+
268+
_IMPORTS_REPLACE["test.ably"] = "test.ably.sync"
269+
270+
_STRING_REPLACE['/../assets/testAppSpec.json'] = '/../../assets/testAppSpec.json'
271+
_STRING_REPLACE['ably.rest.auth.Auth.request_token'] = 'ably.sync.rest.auth.AuthSync.request_token'
272+
_STRING_REPLACE['ably.rest.auth.TokenRequest'] = 'ably.sync.rest.auth.TokenRequest'
273+
_STRING_REPLACE['ably.rest.rest.Http.post'] = 'ably.sync.rest.rest.HttpSync.post'
274+
_STRING_REPLACE['httpx.AsyncClient.send'] = 'httpx.Client.send'
275+
_STRING_REPLACE['ably.util.exceptions.AblyException.raise_for_response'] = \
276+
'ably.sync.util.exceptions.AblyException.raise_for_response'
277+
_STRING_REPLACE['ably.rest.rest.AblyRest.time'] = 'ably.sync.rest.rest.AblyRestSync.time'
278+
_STRING_REPLACE['ably.rest.auth.Auth._timestamp'] = 'ably.sync.rest.auth.AuthSync._timestamp'
279+
280+
# round 1
281+
src_dir_path = os.path.join(os.getcwd(), "test", "ably")
282+
dest_dir_path = os.path.join(os.getcwd(), "test", "ably", "sync")
283+
src_files = [os.path.join(os.getcwd(), "test", "ably", "testapp.py"),
284+
os.path.join(os.getcwd(), "test", "ably", "utils.py")]
285+
286+
unasync_files(src_files, [Rule(fromdir=src_dir_path, todir=dest_dir_path)])
287+
288+
# round 2
289+
src_dir_path = os.path.join(os.getcwd(), "test", "ably", "rest")
290+
dest_dir_path = os.path.join(os.getcwd(), "test", "ably", "sync", "rest")
291+
src_files = find_files(src_dir_path, "*.py")
292+
293+
unasync_files(src_files, [Rule(fromdir=src_dir_path, todir=dest_dir_path, output_file_prefix="sync_")])

0 commit comments

Comments
 (0)