8
\$\begingroup\$

What started as a direct and simple fork of a CodeGolf.SE member's TioJ Java implementation to interact with the tio.run (we'll just call it TIO from herein) website with a bot or other command line script ended up becoming its own project for me to fuss around with to be more Pythonic and adaptive for how I would probably consider using it.

Essentially, this is a Python way to submit code snippets for running on https://tio.run and get back either a response from a valid code execution, or an error if any error happened to occur, either in the code being executed on TIO or some type of error on the computer's side.

It's effectively several separate files, all integrated as a part of a Python library. To that end, I've made all the subfiles containing classes start with _ to indicate they're protected files, and have the package __init__.py import the individual classes from within the subfiles so that we can do from pytio import {CLASSNAME} where we replace the actual name of the class/object type in the import statement.

Any improvements are welcome. Note that this is essentially a near-direct port of the TioJ code, with some Python 2/3 compatibility changes among other things. It's probably got a lot of useless stuff here, but meh, this is the first usable version of the library thus far.

However, keep in mind that both Python 2 and Python 3 need to be supported, so if you give any recommendations, please don't break cross-compatibility, since that's required for Universal-wheel PyPI packages like this one.

Python 2 compatibility is doable because typing exists in PyPI for Python 2, once it was introduced for Python 3.

It's got several parts. Most are within a directory called pytio, while the test_tio.py unit test test-suite is outside that directory.



pytio/__init__.py:

from ._Tio import Tio
from ._TioFile import TioFile
from ._TioRequest import TioRequest
from ._TioResponse import TioResponse
from ._TioResult import TioResult
from ._TioVariable import TioVariable
__title__ = 'PyTIO'
__author__ = 'Thomas Ward'
__version__ = '0.1.0'
__copyright__ = '2017 Thomas Ward'
__license__ = 'AGPLv3+'
__all__ = ('Tio', 'TioFile', 'TioRequest', 'TioResponse', 'TioResult', 'TioVariable')

pytio/_Tio.py:

import gzip
import io
import json
import platform
from typing import AnyStr, Union
from ._TioRequest import TioRequest
from ._TioResponse import TioResponse
# Version specific import handling.
if platform.python_version() <= '3.0':
 # Python 2: The specific URLLib sections are in urllib2.
 # noinspection PyCompatibility,PyUnresolvedReferences
 from urllib2 import urlopen
 # noinspection PyCompatibility,PyUnresolvedReferences
 from urllib2 import HTTPError, URLError
else:
 # Python 3: The specific URLLib sections are in urllib submodules.
 # noinspection PyCompatibility
 from urllib.request import urlopen
 # noinspection PyCompatibility
 from urllib.error import HTTPError, URLError
class Tio:
 backend = "cgi-bin/run/api/"
 json = "languages.json"
 def __init__(self, url="https://tio.run"):
 # type: (AnyStr) -> None
 self.backend = url + '/' + self.backend
 self.json = url + '/' + self.json
 @staticmethod
 def read_in_chunks(stream_object, chunk_size=1024):
 """Lazy function (generator) to read a file piece by piece.
 Default chunk size: 1k."""
 while True:
 data = stream_object.read(chunk_size)
 if not data:
 break
 yield data
 @staticmethod
 def new_request(*args, **kwargs):
 # type: () -> None
 raise DeprecationWarning("The Tio.new_request() method is to be removed in a later release; please call "
 "TioRequest and its constructor directly..")
 def query_languages(self):
 # type: () -> set
 # Used to get a set containing all supported languages on TIO.run.
 try:
 response = urlopen(self.json)
 rawdata = json.loads(response.read().decode('utf-8'))
 return set(rawdata.keys())
 except (HTTPError, URLError):
 return set()
 except Exception:
 return set()
 def send(self, fmt):
 # type: (TioRequest) -> TioResponse
 # Command alias to use send_bytes; this is more or less a TioJ cutover.
 return self.send_bytes(fmt.as_deflated_bytes())
 def send_bytes(self, message):
 # type: (bytes) -> TioResponse
 req = urlopen(self.backend, data=message)
 reqcode = req.getcode()
 if req.code == 200:
 if platform.python_version() >= '3.0':
 content_type = req.info().get_content_type()
 else:
 # URLLib requests/responses in Python 2 don't have info().get_content_type(),
 # so let's get it the old fashioned way.
 content_type = req.info()['content-type']
 # Specially handle GZipped responses from the server, and unzip them.
 if content_type == 'application/octet-stream':
 buf = io.BytesIO(req.read())
 gzip_f = gzip.GzipFile(fileobj=buf)
 fulldata = gzip_f.read()
 else:
 # However, if it's not compressed, just read it directly.
 fulldata = req.read()
 # Return a TioResponse object, containing the returned data from TIO.
 return TioResponse(reqcode, fulldata, None)
 else:
 # If the HTTP request failed, we need to give a TioResponse object with no data.
 return TioResponse(reqcode, None, None)

pytio/_TioFile.py:

from typing import AnyStr
class TioFile:
 _name = str()
 _content = bytes()
 def __init__(self, name, content):
 # type: (AnyStr, bytes) -> None
 self._name = name
 self._content = content
 def get_name(self):
 # type: () -> AnyStr
 return self.name
 def get_content(self):
 # type: () -> bytes
 return self.content
 @property
 def name(self):
 # type: () -> AnyStr
 return self._name
 @property
 def content(self):
 # type: () -> bytes
 return self._content

pytio/_TioRequest.py:

import platform
import zlib
from typing import List, AnyStr, Union
from ._TioFile import TioFile
from ._TioVariable import TioVariable
class TioRequest:
 def __init__(self, lang=None, code=None):
 # type: (AnyStr, Union[AnyStr, bytes]) -> None
 self._files = []
 self._variables = []
 self._bytes = bytes()
 if lang:
 self.set_lang(lang)
 if code:
 self.add_file_bytes('.code.tio', code)
 def add_file(self, file):
 # type: (TioFile) -> None
 if file in self._files:
 self._files.remove(file)
 self._files.append(file)
 def add_file_bytes(self, name, content):
 # type: (AnyStr, bytes) -> None
 self._files.append(TioFile(name, content))
 def add_variable(self, variable):
 # type: (TioVariable) -> None
 self._variables.append(variable)
 def add_variable_string(self, name, value):
 # type: (AnyStr, Union[List[AnyStr], AnyStr]) -> None
 self._variables.append(TioVariable(name, value))
 def set_lang(self, lang):
 # type: (AnyStr) -> None
 self.add_variable_string('lang', lang)
 def set_code(self, code):
 # type: (AnyStr) -> None
 self.add_file_bytes('.code.tio', code)
 def set_input(self, input_data):
 # type: (AnyStr) -> None
 self.add_file_bytes('.input.tio', input_data.encode('utf-8'))
 def set_compiler_flags(self, flags):
 # type: (AnyStr) -> None
 self.add_variable_string('TIO_CFLAGS', flags)
 def set_commandline_flags(self, flags):
 # type: (AnyStr) -> None
 self.add_variable_string('TIO_OPTIONS', flags)
 def set_arguments(self, args):
 # type: (AnyStr) -> None
 self.add_variable_string('args', args)
 def write_variable(self, name, content):
 # type: (AnyStr, AnyStr) -> None
 if content:
 if platform.python_version() >= '3.0':
 self._bytes += bytes("V" + name + '\x00' + str(len(content.split(' '))) + '\x00', 'utf-8')
 self._bytes += bytes(content + '\x00', 'utf-8')
 else:
 # Python 2 is weird - this is effectively a call to just 'str', somehow, so no encoding argument.
 self._bytes += bytes("V" + name + '\x00' + str(len(content.split(' '))) + '\x00')
 self._bytes += bytes(content + '\x00')
 def write_file(self, name, contents):
 # type: (AnyStr, AnyStr) -> None
 # noinspection PyUnresolvedReferences
 if platform.python_version() < '3.0' and isinstance(contents, str):
 # Python 2 has weirdness - if it's Unicode bytes or a string, len() properly detects bytes length, and
 # not # of chars in the specific item.
 length = len(contents)
 elif isinstance(contents, str):
 # However, for Python 3, we need to first encode it in UTF-8 bytes if it is just a plain string...
 length = len(contents.encode('utf-8'))
 elif isinstance(contents, (bytes, bytearray)):
 # ... and in Python 3, we can only call len() directly if we're working with bytes or bytearray directly.
 length = len(contents)
 else:
 # Any other type of value will result in a code failure for now.
 raise ValueError("Can only pass UTF-8 strings or bytes at this time.")
 if platform.python_version() >= '3.0':
 self._bytes += bytes("F" + name + '\x00' + str(length) + '\x00', 'utf-8')
 self._bytes += bytes(contents + '\x00', 'utf-8')
 else:
 # Python 2 is weird - this is effectively a call to just 'str', somehow, so no encoding argument.
 self._bytes += bytes("F" + name + '\x00' + str(length) + '\x00')
 self._bytes += bytes(contents + '\x00')
 def as_bytes(self):
 # type: () -> bytes
 try:
 for var in self._variables:
 if hasattr(var, 'name') and hasattr(var, 'content'):
 self.write_variable(var.name, var.content)
 for file in self._files:
 if hasattr(file, 'name') and hasattr(file, 'content'):
 self.write_file(file.name, file.content)
 self._bytes += b'R'
 except IOError:
 raise RuntimeError("IOError generated during bytes conversion.")
 return self._bytes
 def as_deflated_bytes(self):
 # type: () -> bytes
 # This returns a DEFLATE-compressed bytestring, which is what TIO.run's API requires for the request
 # to be proccessed properly.
 return zlib.compress(self.as_bytes(), 9)[2:-4]

pytio/_TioResponse.py:

from typing import Optional, Any, Union, AnyStr
class TioResponse:
 _code = 0
 _data = None
 _result = None
 _error = None
 def __init__(self, code, data=None, error=None):
 # type: (Union[int, AnyStr], Optional[Any], Optional[Any]) -> None
 self._code = code
 self._data = data
 if data is None:
 self._splitdata = [None, error]
 else:
 self._splitdata = self._data.split(self._data[:16])
 if not self._splitdata[1] or self._splitdata[1] == b'':
 self._error = b''.join(self._splitdata[2:])
 self._result = None
 else:
 self._error = None
 self._result = self._splitdata[1]
 @property
 def code(self):
 # type: () -> Union[int, AnyStr]
 return self._code.decode('utf-8')
 @property
 def result(self):
 # type: () -> Optional[AnyStr]
 if self._result:
 return self._result.decode('utf-8')
 else:
 return None
 @property
 def error(self):
 # type: () -> Optional[AnyStr]
 if self._error:
 return self._error.decode('utf-8')
 else:
 return None
 @property
 def raw(self):
 # type: () -> Any
 return self._data
 def get_code(self):
 # type: () -> Union[int, AnyStr]
 return self.code
 def get_result(self):
 # type: () -> Optional[AnyStr]
 return self.result
 def get_error(self):
 # type: () -> Optional[AnyStr]
 return self.error

pytio/_TioResult.py:

from typing import Any, AnyStr, List
class TioResult:
 _pieces = []
 def __init__(self, pieces):
 # type: (List[AnyStr]) -> None
 self._pieces = pieces
 raise NotImplementedError
 @property
 def pieces(self):
 # type: () -> List
 return self._pieces
 def has(self, field):
 # type: (AnyStr) -> bool
 try:
 if field.lower() == "output":
 return len(self._pieces) > 0
 elif field.lower() == "debug":
 return len(self._pieces) > 1
 else:
 return False
 except IndexError:
 return False
 def get(self, field):
 # type: (AnyStr) -> Any
 if self.has('output') and field.lower() == "output":
 return self._pieces[0]
 elif self.has('debug') and field.lower() == "debug":
 return self._pieces[1]

pytio/_TioVariable.py:

from typing import AnyStr, List, Union
class TioVariable:
 _name = str()
 _content = []
 def __init__(self, name, content):
 # type: (AnyStr, Union[List[AnyStr], AnyStr]) -> None
 self._name = name
 self._content = content
 @property
 def name(self):
 # type: () -> AnyStr
 return self._name
 @property
 def content(self):
 # type: () -> List
 return self._content
 def get_name(self):
 # type: () -> AnyStr
 return self.name
 def get_content(self):
 # type: () -> List
 return self.content

... And of course, a unittest test-suite, which can be run in any Python 2.7 or Python 3 shell with:

# Python 2.7:
python -m unittest -v test_tio
# Python 3.x:
python3 -m unittest -v test_tio

test_tio.py (which sits outside the pytio directory, and it's that outer directory you should run the previous unittest execution commands in):

import platform
import unittest
from pytio import Tio, TioRequest
class TestTIOResults(unittest.TestCase):
 tio = Tio()
 def test_valid_python3_request(self):
 request = TioRequest(lang='python3', code="print('Hello, World!')")
 response = self.tio.send(request)
 self.assertIsNone(response.error)
 if platform.python_version() >= '3.0':
 self.assertIsInstance(response.result, str)
 else:
 # noinspection PyUnresolvedReferences
 self.assertIsInstance(response.result, (unicode, str))
 self.assertEqual(response.result.strip('\n'), "Hello, World!")
 def test_invald_python3_request(self):
 request = TioRequest(lang='python3', code="I'm a teapot!")
 response = self.tio.send(request)
 self.assertIsNone(response.result)
 if platform.python_version() >= '3.0':
 self.assertIsInstance(response.error, str)
 else:
 # noinspection PyUnresolvedReferences
 self.assertIsInstance(response.error, (unicode, str))
 self.assertIn('EOL while scanning string literal', response.error)
 def test_valid_apl_request(self):
 request = TioRequest(lang='apl-dyalog', code="⎕←'Hello, World!'")
 response = self.tio.send(request)
 self.assertIsNone(response.error)
 if platform.python_version() >= '3.0':
 self.assertIsInstance(response.result, str)
 else:
 # noinspection PyUnresolvedReferences
 self.assertIsInstance(response.result, (unicode, str))
 self.assertEqual(response.result.strip('\n'), "Hello, World!")
 def test_invalid_apl_request(self):
 request = TioRequest(lang='apl-dyalog', code="I'm a teapot!")
 response = self.tio.send(request)
 self.assertIsNone(response.result)
 if platform.python_version() >= '3.0':
 self.assertIsInstance(response.error, str)
 else:
 # noinspection PyUnresolvedReferences
 self.assertIsInstance(response.error, (unicode, str))
 self.assertIn('error AC0607: unbalanced quotes detected', response.error)
if __name__ == '__main__':
 unittest.main(warnings='ignore')
Peilonrayz
44.4k7 gold badges80 silver badges157 bronze badges
asked Dec 9, 2017 at 1:20
\$\endgroup\$
7
  • \$\begingroup\$ How does this work in Python 2, if typing is new in 3.5? \$\endgroup\$ Commented Dec 15, 2017 at 18:19
  • \$\begingroup\$ Also do you have a git repo I could clone, so that I don't have to C&P your environment? \$\endgroup\$ Commented Dec 15, 2017 at 18:21
  • \$\begingroup\$ @Peilonrayz typing was backported to be available in pip/PyPI. I forgot to mention that (updated). Here is the GitHub; the only variation between that and this is that I added a couple additional tests to the test suite, though at the core everything should be the same. \$\endgroup\$ Commented Dec 15, 2017 at 18:36
  • \$\begingroup\$ If you'd like I can push my changes to your Git Hub, due to possible licence violations... \$\endgroup\$ Commented Dec 16, 2017 at 17:10
  • 1
    \$\begingroup\$ Remark from a few years later, split(' ') followed by + '\x00' later isn't really correct (TIO.run API expects the arguments separated by the null byte), so as a quick band-aid I replace the line in write_variable with self._bytes += bytes(content.replace(' ', '\x00') + '\x00', 'utf-8'). That's a terrible hack though (does not allow space in argument) \$\endgroup\$ Commented May 9, 2023 at 14:01

1 Answer 1

3
\$\begingroup\$

I'd highly recommend you use collections.namedtuple. Or since you're using typing, typing.NamedTuple. If we change TioFile to use this, then we'll get:

_TioFile = NamedTuple(
 '_TioFile',
 [
 ('name', AnyStr),
 ('content', bytes)
 ]
)
class TioFile(_TioFile):
 def get_name(self):
 # type: () -> AnyStr
 return self.name
 def get_content(self):
 # type: () -> bytes
 return self.content

From this, we know that get_name and get_content are actual not needed, and promote WET. Write it once for the property, once again for the method. And goes against PEP 20:

There should be one-- and preferably only one --obvious way to do it.

And so I'd also change the following classes to use NamedTuple.

  • TioVariable can easily be changed to use NamedTuple, I would also remove the get_* parts as highlighted above.
  • TioResult requires an EMPTY variable bound to the class. It should also have output and debug set to EMPTY by default. If we were 3.6.1 this would be super simple, as it allows setting default values. However as we have to support Python 2.7, it's simpler to just add a static method new that does this for you.
  • TioResponse requires a static method say from_raw, that contains the old __init__ and should convert result and error to be decoded. This is as wrapping __new__ is a little messy, and PyCharm complains a lot.

As I don't really want to look at Tio, this leaves TioRequest. Which I'd change:

  • You can use self.set_code rather than self.add_file_bytes('.code.tio', code).
  • I wouldn't use self._bytes as that means it will duplicate the state of TioRequest if you ever call as_bytes twice. Instead just make it a local variable in as_bytes.
  • I would move write_variable and write_file onto TioVariable and TioFile respectively, as as_byte.
  • I would add a function bytes_ that in Python 3 is functools.partial(bytes, encoding='utf-8'), and in Python 2 is bytes. This can greatly simplify write_variable and write_file.
  • I would move the if content out of write_variable into as_bytes.
  • I would change platform.python_version() < '3.0' to be inverted, as then you can group all the len(content)s together.
  • I would use str.format, rather than string concatenation in both write_variable and write_file.

And so I'd change your code to:

pytio/_TioObjects.py:

# coding=utf-8
from typing import NamedTuple, AnyStr, Union, List, Optional, Any
import platform
import functools
if platform.python_version() >= '3.0':
 bytes_ = functools.partial(bytes, encoding='utf-8')
else:
 bytes_ = bytes
_TioFile = NamedTuple(
 '_TioFile',
 [
 ('name', AnyStr),
 ('content', bytes)
 ]
)
_TioVariable = NamedTuple(
 '_TioVariable',
 [
 ('name', AnyStr),
 ('content', Union[List[AnyStr], AnyStr])
 ]
)
_TioResult = NamedTuple(
 '_TioResult',
 [
 ('output', Union[AnyStr, object]),
 ('debug', Union[AnyStr, object])
 ]
)
_TioResponse = NamedTuple(
 '_TioResponse',
 [
 ('code', Union[AnyStr, int]),
 ('result', Union[AnyStr, None]),
 ('error', Union[AnyStr, None]),
 ('raw', Any)
 ]
)
class TioFile(_TioFile):
 def as_bytes(self):
 # type: () -> bytes
 content = self.content
 if platform.python_version() >= '3.0' and isinstance(content, str):
 length = len(content.encode('utf-8'))
 elif isinstance(content, (str, bytes, bytearray)):
 length = len(content)
 else:
 raise ValueError("Can only pass UTF-8 strings or bytes at this time.")
 return bytes_(
 'F{name}\x00{length}\x00{content}\x00'
 .format(
 name=self.name,
 length=length,
 content=self.content
 )
 )
class TioVariable(_TioVariable):
 def as_bytes(self):
 # type: () -> bytes
 return bytes_(
 'V{name}\x00{length}\x00{content}\x00'
 .format(
 name=self.name,
 length=len(self.content.split(' ')),
 content=self.content
 )
 )
class TioResult(_TioResult):
 EMPTY = object()
 @staticmethod
 def new(output=EMPTY, debug=EMPTY):
 # type: (Optional[Union[AnyStr, object]], Optional[Union[AnyStr, object]]) -> TioResult
 return TioResult(output, debug)
class TioResponse(_TioResponse):
 @staticmethod
 def from_raw(code, data=None, error=None):
 # type: (Union[int, AnyStr], Optional[Any], Optional[Any]) -> TioResponse
 if data is None:
 splitdata = [None, error]
 else:
 splitdata = data.split(data[:16])
 if not splitdata[1] or splitdata[1] == b'':
 error = b''.join(splitdata[2:])
 result = None
 else:
 error = None
 result = splitdata[1]
 if result is not None:
 result = result.decode('utf-8')
 if error is not None:
 error = error.decode('utf-8')
 return TioResponse(code, result, error, data)

pytio/_TioRequest.py:

# coding=utf-8
import zlib
from typing import List, AnyStr, Union
from ._TioObjects import TioFile, TioVariable
class TioRequest:
 def __init__(self, lang=None, code=None):
 # type: (AnyStr, Union[AnyStr, bytes]) -> None
 self._files = []
 self._variables = []
 if lang:
 self.set_lang(lang)
 if code:
 self.set_code(code)
 def add_file(self, file):
 # type: (TioFile) -> None
 if file in self._files:
 self._files.remove(file)
 self._files.append(file)
 def add_file_bytes(self, name, content):
 # type: (AnyStr, bytes) -> None
 self._files.append(TioFile(name, content))
 def set_code(self, code):
 # type: (AnyStr) -> None
 self.add_file_bytes('.code.tio', code)
 def set_input(self, input_data):
 # type: (AnyStr) -> None
 self.add_file_bytes('.input.tio', input_data.encode('utf-8'))
 def add_variable(self, variable):
 # type: (TioVariable) -> None
 self._variables.append(variable)
 def add_variable_string(self, name, value):
 # type: (AnyStr, Union[List[AnyStr], AnyStr]) -> None
 self._variables.append(TioVariable(name, value))
 def set_lang(self, lang):
 # type: (AnyStr) -> None
 self.add_variable_string('lang', lang)
 def set_compiler_flags(self, flags):
 # type: (AnyStr) -> None
 self.add_variable_string('TIO_CFLAGS', flags)
 def set_commandline_flags(self, flags):
 # type: (AnyStr) -> None
 self.add_variable_string('TIO_OPTIONS', flags)
 def set_arguments(self, args):
 # type: (AnyStr) -> None
 self.add_variable_string('args', args)
 def as_bytes(self):
 # type: () -> bytes
 bytes_ = bytes()
 try:
 for var in self._variables:
 if var.content:
 bytes_ += var.as_bytes()
 for file in self._files:
 bytes_ += file.as_bytes()
 bytes_ += b'R'
 except IOError:
 raise RuntimeError("IOError generated during bytes conversion.")
 return bytes_
 def as_deflated_bytes(self):
 # type: () -> bytes
 # This returns a DEFLATE-compressed bytestring, which is what TIO.run's API requires for the request
 # to be proccessed properly.
 return zlib.compress(self.as_bytes(), 9)[2:-4]
answered Dec 16, 2017 at 17:06
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.