Skip to content

Commit

Permalink
v1.0.0: unit testing and continuous integration
Browse files Browse the repository at this point in the history
  • Loading branch information
loic-simon authored Dec 12, 2020
2 parents 3785d36 + 65fd663 commit 5f9873a
Show file tree
Hide file tree
Showing 10 changed files with 364 additions and 15 deletions.
31 changes: 31 additions & 0 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# This workflows will upload a Python Package using Twine when a release is created
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries

name: Upload Python Package

on:
release:
types: [created]

jobs:
deploy:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py sdist bdist_wheel
twine upload dist/*
8 changes: 8 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
os: linux
dist: xenial
language: python
python:
- "3.8"
- "3.9"
script:
- python -m unittest discover -v
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## 1.0.0 - 2020-12-12
### Added

- Unit testing and continuous integration (Travis CI)

### Fixed

- README: minimal Python versions + typos
- Docs: fixed typos/wording


## 0.1.0 - 2020-12-07

Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/asyncode)](https://pypi.org/project/asyncode)
[![PyPI - Wheel](https://img.shields.io/pypi/wheel/asyncode)](https://pypi.org/project/asyncode)
[![Read the Docs](https://img.shields.io/readthedocs/asyncode)](https://asyncode.readthedocs.io)
[![Travis CI](https://img.shields.io/travis/loic-simon/asyncode)](https://travis-ci.org/github/loic-simon/asyncode)

Python package for emulating Python's interactive interpreter in asynchronous contexts.

Expand All @@ -16,15 +17,15 @@ pip install asyncode

### Dependencies

* Python 3.7+
* Python **≥ 3.5** *(no CI for Python < 3.8)*



## Usage

This package's external API consists in a two classes, **`AsyncInteractiveInterpreter`** and **AsyncInteractiveConsole**, which subclass respectively [`code.InteractiveInterpreter`](https://docs.python.org/3/library/code.html#interactive-interpreter-objects) and [`code.InteractiveInterpreter`](https://docs.python.org/3/library/code.html#interactive-console-objects).
This package's external API consists in two classes, **`AsyncInteractiveInterpreter`** and **`AsyncInteractiveConsole`**, which subclass respectively [`code.InteractiveInterpreter`](https://docs.python.org/3/library/code.html#interactive-interpreter-objects) and [`code.InteractiveConsole`](https://docs.python.org/3/library/code.html#interactive-console-objects).

These classes are meant to be used in running asynchronous environments. Minimal working code will need to subclass provided classes to implement appropriate functions:
These classes are meant to be used in **already running asynchronous contexts**. Minimal useful code will need to subclass provided classes to implement specific functions:

```py
import asyncode
Expand All @@ -33,11 +34,11 @@ class MyAsyncConsole(asyncode.AsyncInteractiveConsole):
"""AsyncInteractiveConsole adapted to running environment"""

async def write(self, data):
"""Use appropriate method"""
"""Use specific function"""
await some_output_coroutine(data)

async def raw_input(self, prompt=""):
"""Use appropriate method"""
"""Use specific functions"""
if prompt:
await some_output_coroutine(prompt)

Expand All @@ -51,7 +52,7 @@ async def run_interpreter():
try:
await console.interact()
except SystemExit:
# Do not exit the whole program!
# Do not exit the whole program when sending "exit()" or "quit()"
await some_output_coroutine("Bye!")
```

Expand All @@ -66,6 +67,7 @@ Pull requests are welcome. Do not hesitate to get in touch with me (see below) f


## License

This work is shared under [the MIT license](LICENSE).

© 2020 Loïc Simon ([[email protected]](mailto:[email protected]))
15 changes: 8 additions & 7 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ Several issues prevent directly using Python's :mod:`code` standard module :
to await coroutines inside the interpreter (declare a asynchronous
function and use :func:`asyncio.get_running_loop`);

- information printed by functions and by :func:`repr` (writing only an
- information printed by :func:`print`, :func:`repr`... (like when sending an
object name) to :data:`sys.stdout` and :data:`sys.stderr` would be lost, as
an we need an asynchronous function to send content to the interpreter.
we need an asynchronous function to send content to the interpreter.


Implementation
Expand All @@ -67,21 +67,22 @@ This module provides two classes, :class:`AsyncInteractiveInterpreter` and
:class:`code.InteractiveInterpreter` and :class:`code.InteractiveConsole`,
except that:

- :meth:`~AsyncInteractiveInterpreter.write``,
:meth:`~AsyncInteractiveConsole.raw_input`` and other higher-level methods
- :meth:`~AsyncInteractiveInterpreter.write`,
:meth:`~AsyncInteractiveConsole.raw_input` and other higher-level methods
are asynchronous (they return coroutines, that have to be awaited).

.. warning::
Only text input/output is asnychronous: in particular, **code executed
in the interpreter will still be blocking**.

For example, sending ``time.sleep(10)`` in a running ``AsyncInteractiveConsole`` will block
every other concurrent tasks for 10 seconds.
For example, sending ``time.sleep(10)`` in a running
``AsyncInteractiveConsole`` will block every other concurrent task for
10 seconds.

- the compiler allows top-level ``await`` statements;

- instead of being directly executed, compiled code is wrapped in a function
object (:class:`types.FunctionType`) which is called (): if the result is a
object (:class:`types.FunctionType`) which is called: if the result is a
coroutine, it is then awaited; otherwise, this is strictly identical to
direct code execution.

Expand Down
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import setuptools

version = "0.1.0"
version = "1.0.0"

with open("README.md", "r", encoding="utf-8") as fh:
readme = fh.read()
Expand All @@ -16,7 +16,7 @@
url="https://github.com/loic-simon/asyncode",
py_modules=["asyncode"],
classifiers=[
"Development Status :: 4 - Beta",
"Development Status :: 5 - Production/Stable",
"Framework :: AsyncIO",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3.5",
Expand Down
Empty file added test/__init__.py
Empty file.
163 changes: 163 additions & 0 deletions test/test_code_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import sys
import unittest
from textwrap import dedent
from contextlib import ExitStack
from unittest import mock

sys.path.append("..")
import asyncode


class TestAsyncodeLikeCode(unittest.IsolatedAsyncioTestCase):
"""Do same tests as code standard module (but async)"""

def setUp(self):
self.console = asyncode.AsyncInteractiveConsole()
self.mock_sys()

def mock_sys(self):
"Mock system environment for AsyncInteractiveConsole"
# use exit stack to match patch context managers to addCleanup
stack = ExitStack()
self.addCleanup(stack.close)
self.infunc = stack.enter_context(mock.patch('asyncode.input',
create=True))
self.stdout = stack.enter_context(mock.patch('asyncode.sys.stdout'))
self.stderr = stack.enter_context(mock.patch('asyncode.sys.stderr'))
self.sysmod = stack.enter_context(mock.patch('asyncode.sys',
wraps=asyncode.sys,
spec=asyncode.sys))
if sys.excepthook is sys.__excepthook__:
self.sysmod.excepthook = self.sysmod.__excepthook__
del self.sysmod.ps1
del self.sysmod.ps2

async def test_ps1(self):
"""Check default / custom sys.ps1 are correctly set"""
# default
self.infunc.side_effect = EOFError('Finished')
await self.console.interact()
self.assertEqual(self.sysmod.ps1, '>>> ')
# custom
self.sysmod.ps1 = 'custom1> '
await self.console.interact()
self.assertEqual(self.sysmod.ps1, 'custom1> ')

async def test_ps2(self):
"""Check default / custom sys.ps2 are correctly set"""
# default
self.infunc.side_effect = EOFError('Finished')
await self.console.interact()
self.assertEqual(self.sysmod.ps2, '... ')
# custom
self.sysmod.ps1 = 'custom2> '
await self.console.interact()
self.assertEqual(self.sysmod.ps1, 'custom2> ')

async def test_console_stderr(self):
"""Check stdout is correctly printed"""
self.infunc.side_effect = ["'antioch'", "", EOFError('Finished')]
await self.console.interact()
for call in list(self.stdout.method_calls):
if 'antioch' in ''.join(call[1]):
break
else:
raise AssertionError("no console stdout")

async def test_syntax_error(self):
"""Check compiler exceptions are retrieved"""
self.infunc.side_effect = ["undefined", EOFError('Finished')]
await self.console.interact()
for call in self.stderr.method_calls:
if 'NameError' in ''.join(call[1]):
break
else:
raise AssertionError("No syntax error from console")

async def test_sysexcepthook(self):
"""Check custom excepthooks are called"""
self.infunc.side_effect = ["raise ValueError('')",
EOFError('Finished')]
hook = mock.Mock()
self.sysmod.excepthook = hook
await self.console.interact()
self.assertTrue(hook.called)

async def test_banner(self):
"""Check default/custom banner is correctly printed"""
# with banner ==> stderr called 3 times, custom banner in 1st call
self.infunc.side_effect = EOFError('Finished')
await self.console.interact(banner='Foo')
self.assertEqual(len(self.stderr.method_calls), 3)
banner_call = self.stderr.method_calls[0]
self.assertEqual(banner_call, ['write', ('Foo\n',), {}])

# no banner ==> stderr called 2 times
self.stderr.reset_mock()
self.infunc.side_effect = EOFError('Finished')
await self.console.interact(banner='')
self.assertEqual(len(self.stderr.method_calls), 2)

async def test_exit_msg(self):
"""Check default/custom exit message is correctly printed"""
# default exit message ==> stderr called 2 times and good msg
self.infunc.side_effect = EOFError('Finished')
await self.console.interact(banner='')
self.assertEqual(len(self.stderr.method_calls), 2)
err_msg = self.stderr.method_calls[1]
expected = 'now exiting AsyncInteractiveConsole...\n'
self.assertEqual(err_msg, ['write', (expected,), {}])

# no exit message ==> stderr called 1 time
self.stderr.reset_mock()
self.infunc.side_effect = EOFError('Finished')
await self.console.interact(banner='', exitmsg='')
self.assertEqual(len(self.stderr.method_calls), 1)

# custom exit message ==> stderr called 2 times and custom msg
self.stderr.reset_mock()
message = (
'bye! \N{GREEK SMALL LETTER ZETA}\N{CYRILLIC SMALL LETTER ZHE}'
)
self.infunc.side_effect = EOFError('Finished')
await self.console.interact(banner='', exitmsg=message)
self.assertEqual(len(self.stderr.method_calls), 2)
err_msg = self.stderr.method_calls[1]
expected = message + '\n'
self.assertEqual(err_msg, ['write', (expected,), {}])

async def test_cause_tb(self):
"""Check exceptions chaining is correctly printed"""
self.infunc.side_effect = ["raise ValueError('') from AttributeError",
EOFError('Finished')]
await self.console.interact()
output = ''.join(''.join(call[1]) for call in self.stderr.method_calls)
expected = dedent("""
AttributeError
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "<console>", line 1, in <module>
ValueError
""")
self.assertIn(expected, output)

async def test_context_tb(self):
"""Check exceptions inception is correctly printed"""
self.infunc.side_effect = ["try: ham\nexcept: eggs\n",
EOFError('Finished')]
await self.console.interact()
output = ''.join(''.join(call[1]) for call in self.stderr.method_calls)
expected = dedent("""
Traceback (most recent call last):
File "<console>", line 1, in <module>
NameError: name 'ham' is not defined
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "<console>", line 2, in <module>
NameError: name 'eggs' is not defined
""")
self.assertIn(expected, output)
54 changes: 54 additions & 0 deletions test/test_coroutines.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import sys
import unittest
from contextlib import ExitStack
from unittest import mock

sys.path.append("..")
import asyncode


class TestAsyncodeAsync(unittest.IsolatedAsyncioTestCase):
"""Do same tests as code standard module (but async)"""

def setUp(self):
self.console = asyncode.AsyncInteractiveConsole()
self.mock_sys()

def mock_sys(self):
"Mock system environment for AsyncInteractiveConsole"
# use exit stack to match patch context managers to addCleanup
stack = ExitStack()
self.addCleanup(stack.close)
self.infunc = stack.enter_context(mock.patch('asyncode.input',
create=True))
self.stdout = stack.enter_context(mock.patch('asyncode.sys.stdout'))
self.stderr = stack.enter_context(mock.patch('asyncode.sys.stderr'))
self.sysmod = stack.enter_context(mock.patch('asyncode.sys',
wraps=asyncode.sys,
spec=asyncode.sys))

async def test_toplevel_await(self):
"""Check top-level await statements are not rejected"""
self.infunc.side_effect = ["await lindbz",
EOFError('Finished')]
await self.console.interact()
output = ''.join(''.join(call[1]) for call in self.stderr.method_calls)
self.assertNotIn('SyntaxError', output)

async def test_coroutine(self):
"""Check coroutines are awaited"""
self.infunc.side_effect = ["import asyncio",
"await asyncio.sleep(0, 'grizzz')",
EOFError('Finished')]
await self.console.interact()
output = ''.join(''.join(call[1]) for call in self.stdout.method_calls)
self.assertIn('grizzz', output)

async def test_exc_in_coroutine(self):
"""Check exceptions occuring in coroutines are retrieved"""
self.infunc.side_effect = ["import asyncio",
"await asyncio.sleep(None)",
EOFError('Finished')]
await self.console.interact()
output = ''.join(''.join(call[1]) for call in self.stderr.method_calls)
self.assertIn('TypeError', output)
Loading

0 comments on commit 5f9873a

Please sign in to comment.