Skip to content

Commit 66751ff

Browse files
committed
check_async_code.py utility added.
1 parent 1e868d3 commit 66751ff

File tree

2 files changed

+202
-1
lines changed

2 files changed

+202
-1
lines changed

TUTORIAL.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ examples below.
8080

8181
4.4 [Coroutines with timeouts](./TUTORIAL.md#44-coroutines-with-timeouts)
8282

83-
5. [Device driver examples](./TUTORIAL.md#5-device-driver-examples)
83+
5. [Device driver examples](./TUTORIAL.md#5-device-driver-examples)
8484

8585
5.1 [The IORead mechnaism](./TUTORIAL.md#51-the-ioread-mechanism)
8686

@@ -100,6 +100,8 @@ examples below.
100100

101101
6.4 [Testing](./TUTORIAL.md#64-testing)
102102

103+
6.5 [A common hard to find error](./TUTORIAL.md#65-a-common-error)
104+
103105
7. [Notes for beginners](./TUTORIAL.md#7-notes-for-beginners)
104106

105107
7.1 [Why Scheduling?](./TUTORIAL.md#71-why-scheduling)
@@ -150,6 +152,8 @@ hardware.
150152
12. ``auart.py`` Demo of streaming I/O via a Pyboard UART.
151153
13. ``asyncio_priority.py`` An version of uasyncio with a simple priority
152154
mechanism. See [this doc](./FASTPOLL.md).
155+
14. `check_async_code.py` A Python3 utility to locate a particular coding
156+
error which can be hard to find. See [this para](./TUTORIAL.md#65-a-common-error).
153157

154158
The ``benchmarks`` directory contains scripts to test and characterise the
155159
uasyncio scheduler. See [this doc](./FASTPOLL.md).
@@ -1044,6 +1048,23 @@ the outer loop:
10441048
It is perhaps worth noting that this error would not have been apparent had
10451049
data been sent to the UART at a slow rate rather than via a loopback test.
10461050

1051+
## 6.5 A common error
1052+
1053+
If a function or method is defined with `async def` and subsequently called as
1054+
if it were a regular (synchronous) callable, MicroPython does not issue an
1055+
error message. This is [by design](https://github.com/micropython/micropython/issues/3241).
1056+
It typically leads to a program silently failing to run correctly. The script
1057+
`check_async_code.py` attempts to locate such errors. It is intended to be run
1058+
on a PC and uses Python3. It takes a single argument, a path to a MicroPython
1059+
sourcefile (or `--help`).
1060+
1061+
Note it is somewhat crude and intended to be used on otherwise correct files:
1062+
use a tool such as pylint for general syntax checking (pylint currently misses
1063+
this error). Under certain circumstances it can throw up the occasional false
1064+
positive.
1065+
1066+
I find it useful as-is but improvements are always welcome.
1067+
10471068
###### [Jump to Contents](./TUTORIAL.md#contents)
10481069

10491070
# 7 Notes for beginners

check_async_code.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
#! /usr/bin/python3
2+
# -*- coding: utf-8 -*-
3+
# check_async_code.py
4+
# A simple script to identify a common error which causes silent failures under
5+
# MicroPython (issue #3241).
6+
# This is where a task is declared with async def and then called as if it were
7+
# a regular function.
8+
# Copyright Peter Hinch 2017
9+
# Issued under the MIT licence
10+
11+
import sys
12+
import re
13+
14+
tasks = set()
15+
mismatch = False
16+
17+
def pass1(part, lnum):
18+
global mismatch
19+
opart = part
20+
sysnames = ('__aenter__', '__aexit__', '__aiter__', '__anext__')
21+
# These are the commonest system functions declared with async def.
22+
# Mimimise spurious duplicate function definition error messages.
23+
good = True
24+
if not part.startswith('#'):
25+
mismatch = False
26+
part = stripquotes(part, lnum) # Remove quoted strings (which might contain code)
27+
good &= not mismatch
28+
if part.startswith('async'):
29+
pos = part.find('def')
30+
if pos >= 0:
31+
part = part[pos + 3:]
32+
part = part.lstrip()
33+
pos = part.find('(')
34+
if pos >= 0:
35+
fname = part[:pos].strip()
36+
if fname in tasks and fname not in sysnames:
37+
# Note this gives a false positive if a method of the same name
38+
# exists in more than one class.
39+
print('Duplicate function declaration "{}" in line {}'.format(fname, lnum))
40+
print(opart)
41+
good = False
42+
else:
43+
tasks.add(fname)
44+
return good
45+
46+
# Strip quoted strings (which may contain code)
47+
def stripquotes(part, lnum=0):
48+
global mismatch
49+
for qchar in ('"', "'"):
50+
pos = part.find(qchar)
51+
if pos >= 0:
52+
part = part[:pos] + part[pos + 1:] # strip 1st qchar
53+
pos1 = part.find(qchar)
54+
if pos > 0:
55+
part = part[:pos] + part[pos1+1:] # Strip whole quoted string
56+
part = stripquotes(part, lnum)
57+
else:
58+
print('Mismatched quotes in line', lnum)
59+
mismatch = True
60+
return part # for what it's worth
61+
return part
62+
63+
def pass2(part, lnum):
64+
global mismatch
65+
opart = part
66+
good = True
67+
if not part.startswith('#') and not part.startswith('async'):
68+
mismatch = False
69+
part = stripquotes(part, lnum) # Remove quoted strings (which might contain code)
70+
good &= not mismatch
71+
for task in tasks:
72+
sstr = ''.join((task, r'\w*'))
73+
match = re.search(sstr, part)
74+
if match is None: # No match
75+
continue
76+
if match.group(0) != task: # No exact match
77+
continue
78+
# Accept assignments e.g. a = mytask or
79+
# after = asyncio.after if p_version else asyncio.sleep
80+
# or comparisons thistask == thattask
81+
#sstr = ''.join((r'[^=]=', task, r'|[^=]=[ \t]*', task))
82+
if re.search(r'=', part):
83+
continue
84+
# Accept await task, await task(args), a = await task(args)
85+
sstr = ''.join((r'.*await[ \t]+', task))
86+
if re.search(sstr, part):
87+
continue
88+
# Accept await obj.task, await obj.task(args), a = await obj.task(args)
89+
sstr = ''.join((r'.*await[ \t]+\w+\.', task))
90+
if re.search(sstr, part):
91+
continue
92+
# Not awaited but could be passed to function e.g.
93+
# run_until_complete(mytask(args))
94+
# or func(mytask, more_args)
95+
sstr = ''.join((r'.*\w+[ \t]*\([ \t]*', task))
96+
if re.search(sstr, part):
97+
continue
98+
# Might be a method. Discard object.
99+
sstr = ''.join((r'.*\w+[ \t]*\([ \t]*\w+\.', task))
100+
if re.search(sstr, part):
101+
continue
102+
print('Error in line {}: async function "{}" is not awaited.'.format(lnum, task))
103+
print(opart)
104+
good = False
105+
return good
106+
107+
txt = '''check_async_code.py
108+
usage: check_async_code.py sourcefile.py
109+
110+
This rather crude script is designed to locate a single type of coding error
111+
which leads to silent runtime failure and hence can be hard to locate.
112+
113+
It is intended to be used on otherwise correct source files and is not robust
114+
in the face of syntax errors. Use pylint or other tools for general syntax
115+
checking.
116+
117+
It assumes code is written in the style advocated in the tutorial where coros
118+
are declared with "async def".
119+
120+
Under certain circumstances it can produce false positives. For example where a
121+
task has the same name as a synchronous bound method, a call to the latter will
122+
produce an erroneous error message. This is because the code does not parse
123+
class definitions. A rigorous solution is non-trivial. Contributions welcome!
124+
125+
In practice the odd false positive is easily spotted in the code.
126+
'''
127+
128+
def usage(code=0):
129+
print(txt)
130+
sys.exit(code)
131+
132+
# Process a line
133+
in_triple_quote = False
134+
def do_line(line, passn, lnum):
135+
global in_triple_quote
136+
ignore = False
137+
good = True
138+
# TODO The following isn't strictly correct. A line might be of the form
139+
# erroneous Python ; ''' start of string
140+
# It could therefore miss the error.
141+
if re.search(r'[^"]*"""|[^\']*\'\'\'', line):
142+
if in_triple_quote:
143+
# Discard rest of line which terminates triple quote
144+
ignore = True
145+
in_triple_quote = not in_triple_quote
146+
if not in_triple_quote and not ignore:
147+
parts = line.split(';')
148+
for part in parts:
149+
# discard comments and whitespace at start and end
150+
part = part.split('#')[0].strip()
151+
if part:
152+
good &= passn(part, lnum)
153+
return good
154+
155+
def main(fn):
156+
global in_triple_quote
157+
good = True
158+
try:
159+
with open(fn, 'r') as f:
160+
for passn in (pass1, pass2):
161+
in_triple_quote = False
162+
lnum = 1
163+
for line in f:
164+
good &= do_line(line, passn, lnum)
165+
lnum += 1
166+
f.seek(0)
167+
168+
except FileNotFoundError:
169+
print('File {} does not exist.'.format(fn))
170+
return
171+
if good:
172+
print('No errors found!')
173+
174+
if __name__ == "__main__":
175+
if len(sys.argv) !=2:
176+
usage(1)
177+
arg = sys.argv[1].strip()
178+
if arg == '--help' or arg == '-h':
179+
usage()
180+
main(arg)

0 commit comments

Comments
 (0)