Initial chekin post-discontinuity.

This commit is contained in:
Cassowary 2023-09-25 18:29:07 -07:00
commit eacebb0106
15 changed files with 900 additions and 0 deletions

61
.gitignore vendored Normal file
View File

@ -0,0 +1,61 @@
*.py[cod]
# C extensions
*.so
# Packages
*.egg
*.egg-info
dist
build
eggs
parts
bin
var
sdist
develop-eggs
.installed.cfg
lib
lib64
__pycache__
# Installer logs
pip-log.txt
# Unit test / coverage reports
.coverage
.tox
nosetests.xml
# Translations
*.mo
# Mr Developer
.mr.developer.cfg
.project
.pydevproject
# Emacs git ignore
# -*- mode: gitignore; -*-
*~
\#*\#
/.emacs.desktop
/.emacs.desktop.lock
*.elc
auto-save-list
tramp
.\#*
# Org-mode
.org-id-locations
*_archive
# flymake-mode
*_flymake.*
# eshell files
/eshell/history
/eshell/lastdir
# elpa packages
/elpa/

6
README.md Normal file
View File

@ -0,0 +1,6 @@
PYOO
=====
A Python text-adventure engine inspired by MOO server.
See testverb.py and testrooms.py for an example mini-adventures.

4
TODO.md Normal file
View File

@ -0,0 +1,4 @@
To Do
---------
--- Something someday.

25
license.txt Normal file
View File

@ -0,0 +1,25 @@
Boost Software License - Version 1.0 - August 17th, 2003
Copyright (c) 2014, 2019, 2020, Cas Rusnov
Permission is hereby granted, free of charge, to any person or organization
obtaining a copy of the software and accompanying documentation covered by
this license (the "Software") to use, reproduce, display, distribute,
execute, and transmit the Software, and to prepare derivative works of the
Software, and to permit third-parties to whom the Software is furnished to
do so, all subject to the following:
The copyright notices in the Software and this entire statement, including
the above license grant, this restriction and the following disclaimer,
must be included in all copies of the Software, in whole or in part, and
all derivative works of the Software, unless such copies or derivative
works are solely in the form of machine-executable object code generated by
a source language processor.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

0
pyoo/__init__.py Normal file
View File

122
pyoo/base.py Normal file
View File

@ -0,0 +1,122 @@
"""
Base data and types for supporting Pyoo operations.
"""
from collections import namedtuple
from typing import Callable, List, Tuple, Type, Sequence, Union, Iterable, Protocol
PREPOSITIONS = (
"with/using",
"at/to",
# 'in front of',
"in/inside/into",
# 'on top of/on/onto/upon',
"on/onto/upon",
# 'out of/from inside/from',
"from",
"over",
"through",
"under/underneath/beneath",
"behind",
"beside",
"for/about",
"is",
"as",
"off",
)
"""`tuple[str]`: A list of preposition strings (equivilancies are / separated)"""
NORMALIZED_PREPS = tuple([x.split("/") for x in PREPOSITIONS])
"""A list of prepositions with equiviliants split."""
class PyooError(Exception):
"""Generic Pyoo error."""
class PyooVerbNotFound(PyooError):
"""Pyoo Error which indicates a verb was not found."""
class PyooObjectNotFound(PyooError):
"""Pyoo Error which indicates an object was not found."""
VerbCallFrame = namedtuple("VerbCallFrame", "environment,player,verbname,dobj,dobjstr,prepstr,iobj,iobjstr,argstr")
"""Namedtuple which contains the call frame (all arguments) for a verb call."""
class Verb:
"""This is a wrapper for a callable to give it the properties required for verbs."""
def __init__(self,
function: Callable,
names: List[str],
callspec: Tuple[str, List[str], str]):
self.function = function
self.name = names[0]
self.names = names
self.callspec = callspec
def __call__(self, *args, **kwargs) -> None:
return self.function(*args, **kwargs)
def __get__(self, obj, objtype: Type):
class VerbFunction:
def __init__(self, func: Callable, obj, typ: Type, verb: "Verb"):
self._func = func
self._obj = obj
self._typ = typ
self.is_verb = True
self.name = verb.name
self.names = verb.names
self.callspec = verb.callspec
def __call__(self, *args, **kwargs):
return self._func.__get__(self._obj, self._typ)(*args, **kwargs)
return VerbFunction(self.function, obj, objtype, self)
def __repr__(self) -> str:
return f"<Verb {self.name} {self.callspec}>"
def make_verb(verbname: str, dobjspec: str, prepspec: str, iobjspec: str) -> Callable[..., Verb]:
"""This simple decorator adds verb metadata to a method or function.
Arguments:
verbname (str): List of verb names, comma separated, with wildcards.
dobjspec (str): 'this' or 'that' or 'none' or 'any'
(this = the object which defines the verb, that = an object in the soup, any = any string,
none = blank)
iobjspec (str): 'this' or 'that' or 'none' or 'any' (as above)
prepspec (str): one of prepositions strings (as in the PREPOSITIONS list)
"""
def verb_decorate(verbfunc: Callable[[VerbCallFrame, ], None]) -> Verb:
names = [x.strip() for x in verbname.split(",")]
ps: List[str] = []
if prepspec.find("/") > 0:
ps = prepspec.split("/")
else:
for prep in NORMALIZED_PREPS:
if prepspec in prep:
ps = prep
break
ps = [prepspec]
return Verb(verbfunc, names, (dobjspec, ps, iobjspec))
return verb_decorate
#
# type things
#
class InterpreterProtocol(Protocol):
"""Defines minimal protocol for interpreter objects."""
def update(self) -> None:
...
SingleMultiString = Union[Sequence[str], str, Iterable[str]]

139
pyoo/interpret.py Normal file
View File

@ -0,0 +1,139 @@
"""This module defines the basic interpreter system."""
import fnmatch
from typing import Optional, List, Set, Tuple, cast
from .things import Thing, Container, Place, Player
from .base import VerbCallFrame, PyooVerbNotFound, PyooObjectNotFound
class Interpreter:
"""Base interpreter class.
Manages a collection of objects, and invoking verbs on them.
"""
def __init__(self, contents: Optional[List[Thing]] = None):
"""Initialize an interpreter.
Arguments:
contents (optional list of things): The initial contents to populate the interpreter with.
"""
if contents is None:
contents = []
self.contents: Set[Thing] = set(contents)
for cont in self.contents:
cont.interpreter = self
self.content_cache: List[Tuple[str, Thing]] = []
self.update()
def add_player(self, player_object: Player) -> None:
"""Add a player to the interpreter."""
self.contents.add(player_object)
player_object.interpreter = self
self.update()
def remove_player(self, player_object: Player) -> None:
"""Remove a player from the interpreter."""
self.contents.remove(player_object)
self.update()
def update(self) -> None:
"""Do any updates required when objects are added or removed."""
self.update_caches()
def update_caches(self) -> None:
"""Update the caches for objects contained in the interpreter, as well as updating all of the caches
on contained containers.
"""
self.content_cache = []
for obj in self.contents:
for name in obj.names:
self.content_cache.append((name, obj))
try:
cast(Container, obj).update_caches()
except AttributeError:
pass
def handle_move(self, newroom: Place, player: Player) -> None:
"""Move a player to a new contained room."""
if player.location:
player.location.handle_exit(player)
newroom.handle_enter(player)
def lookup_global_object(self, objstr: str) -> List[Tuple[str, Thing]]:
"""Find an object within the soup by objstr."""
return [x for x in self.content_cache if fnmatch.fnmatch(objstr, x[0])]
def lookup_object(self, player: Player, objstr: str) -> Optional[Thing]:
"""Find an object relative to a player
This first checks the player's inventory, and then the container which contains the
player.
"""
m: Optional[List[Tuple[str, Thing]]] = None
try:
m = player.get_name_matches(objstr)
except PyooObjectNotFound:
m = None
if not m and player.location:
m = player.location.get_name_matches(objstr)
if m:
return m[0][1]
else:
return None
def interpret(self, command: str, player: Player) -> None:
"""Interpret a player's command by splitting it into components and finding matching verbs."""
# FIXME make this better to support mulitple word objstrs and prepstr
if not command:
return
cmd_comps = command.split()
verbstr = cmd_comps[0]
dobjstr = ""
prepstr = ""
iobjstr = ""
argstr = ""
try:
argstr = " ".join(cmd_comps[1:])
except IndexError:
pass
try:
dobjstr = cmd_comps[1]
prepstr = cmd_comps[2]
iobjstr = cmd_comps[3]
except IndexError:
pass
try:
cmdmatches = player.get_command_matches(command)
except PyooVerbNotFound:
if player.location:
cmdmatches = player.location.get_command_matches(command)
else:
raise PyooVerbNotFound
cmd = cmdmatches[0]
# glob = cmd[0]
# comps = cmd[1]
verb = cmd[2]
this = cmd[3]
dobj = None
if verb.callspec[0] == "this":
dobj = this
elif verb.callspec[0] == "that":
# lookup object
dobj = self.lookup_object(player, dobjstr)
iobj = None
if verb.callspec[2] == "this":
iobj = this
elif verb.callspec[2] == "that":
# lookp object
iobj = self.lookup_object(player, iobjstr)
return verb(VerbCallFrame(self, player, verbstr, dobj, dobjstr, prepstr, iobj, iobjstr, argstr))

87
pyoo/placeloader.py Normal file
View File

@ -0,0 +1,87 @@
"""PlaceLoader is a system to load simple plain text data to build a room-based space."""
from enum import Enum
from typing import Dict, List, TextIO, Type
from .things import Place
from .interpret import Interpreter
class PlaceLoaderStates(Enum):
START = 0
DESC = 1
EXITS = 2
class PlaceLoader:
"""
A factory which loads a simple data format containing a description of rooms, and how
they are connected, and produces room objects for each one.
The format is a plain text file containing a series of room definitions:
ROOMNAME
DESCRIPTION (multiple lines)
.
direction,alias ROOMNAME2
.
ROOMNAME2 ...
"""
def __init__(self, in_file: TextIO, baseplace: Type[Place] = Place):
"""Initialize the factory by loading a description file.
Arguments:
in_file: A file to load the places from.
baseplace: A type to initialize the rooms from.
"""
self.base = baseplace
self.places: Dict[str, Place] = {} # indexed by name
if in_file:
self.process_file(in_file)
def process_file(self, in_file: TextIO) -> None:
"""Load any room information from the passed in file.
Arguments:
in_file: The file to load the rooms from.
"""
state = PlaceLoaderStates.START
desc: List[str] = []
temp_exits: Dict[Place, List[str]] = {}
for line in in_file:
line = line.strip()
if state == PlaceLoaderStates.START:
rm = self.base(line)
temp_exits[rm] = []
state = PlaceLoaderStates.DESC
desc = []
elif state == PlaceLoaderStates.DESC:
if line == ".":
state = PlaceLoaderStates.EXITS
rm.description = desc
else:
desc.append(line)
elif state == PlaceLoaderStates.EXITS:
if line == ".":
state = PlaceLoaderStates.START
self.places[rm.name] = rm
else:
temp_exits[rm].append(line)
# assemble the exits
for place in list(self.places.values()):
for ext in temp_exits[place]:
names, destination = ext.split(" ", 1)
for nm in names.split(","):
place.ways[nm] = self.places[destination]
place.update_go()
def interpreter_from_placeloader(placeloader: PlaceLoader) -> Interpreter:
"""Return an interpreter intialized with the contents of a placeloader."""
return Interpreter(list(placeloader.places.values()))

241
pyoo/things.py Normal file
View File

@ -0,0 +1,241 @@
"""Base (Pyoo) classes which implement basic protocols."""
import fnmatch
import itertools
from typing import cast, List, Optional, Tuple, Set, Dict
from .base import make_verb, PyooVerbNotFound, InterpreterProtocol, SingleMultiString, Verb, VerbCallFrame
class Thing:
"""The base of all Pyoo objects."""
def __init__(self, name: str, description: SingleMultiString = "A nondescript object."):
"""Create a Thing
Arguments:
name (str): A string of comma-separated names for this object.
description (str): A string that describes this object.
"""
names = [x.strip() for x in name.split(",")]
self.name = names[0]
self.names: Tuple[str, ...] = tuple(names)
self.description = description
self.location: Optional["Container"] = None
self.interpreter: Optional[InterpreterProtocol] = None
def tell(self, message: SingleMultiString) -> None:
print("<tell stub>", self, message)
def verbs(self) -> List[Verb]:
"""Return a list of bound methods which denote themselves as verbs."""
verbs: List[Verb] = []
for item in dir(self):
try:
v = self.__getattribute__(item)
if v.is_verb:
verbs.append(v)
except AttributeError:
continue
return verbs
def verb_globs(self) -> List[Tuple[str, Tuple, Verb, "Thing"]]:
"""Return a list of (globstr, bound method) where commands matching globstr should call method (given that
'that' matches an object in the soup).
"""
verbglobs: List[Tuple[str, Tuple, Verb, "Thing"]] = []
for vrb in self.verbs():
vvars: List[Tuple] = [tuple(vrb.names)]
if vrb.callspec[0] == "this":
vvars.append(self.names)
elif vrb.callspec[0] in ("that", "any"):
vvars.append(("*",))
if vrb.callspec[1] != ["none"]:
vvars.append(tuple(vrb.callspec[1]))
if vrb.callspec[2] == "this":
vvars.append(self.names)
elif vrb.callspec[2] in ("that", "any"):
vvars.append(("*",))
for combo in itertools.product(*vvars):
globstr = " ".join(combo)
verbglobs.append((globstr, tuple(combo), vrb, self))
return verbglobs
def handle_move(self, newlocation: "Container") -> None:
"""Handle moving this object to a new location.
Acts as a way for children to hook this occurance too.
"""
self.location = newlocation
def handle_remove(self, oldlocation: "Container") -> None:
"""Handle removing this object from a container.
Acts as a way for children to hook this occurance too.
"""
self.location = None
def __repr__(self) -> str:
return "<Thing '%s' object at 0x%x>" % (self.name, self.__hash__())
class Container(Thing):
"""A Pyoo object which contains other Pyoo objects."""
def __init__(self, names: str, description: SingleMultiString = ""):
"""Create a Container.
Arguments:
names (str): A comma-separated list of names.
description (str): A description for this object.
"""
super().__init__(names, description)
self.contents: Set[Thing] = set()
self.name_cache: List[Tuple[str, Thing]] = []
self.command_cache: List[Tuple[str, Tuple, Verb, Thing]] = []
def update_caches(self) -> None:
"""Update the internal cache of contained object's verbs and names."""
self.name_cache = []
self.command_cache = []
for obj in self.contents:
for name in obj.names:
self.name_cache.append((name, obj))
for verbglob in obj.verb_globs():
if verbglob[0][0] == "#":
continue
self.command_cache.append(verbglob)
for verbglob in self.verb_globs():
if verbglob[0][0] == "#":
continue
self.command_cache.append(verbglob)
def get_command_matches(self, command_spec: str) -> List[Tuple[str, Tuple, Verb, Thing]]:
"""Return a list of commands which match a command_spec ordered by specificity.
Arguments:
command_spec (str): A command
Returns:
array[tuple]: A list of command specifiers.
"""
res = [x for x in self.command_cache if fnmatch.fnmatch(command_spec, x[0])]
# sort by ambiguity (percentage of *)
res.sort(key=lambda a: a[0].count("*") / float(len(a[0])))
if (len(res) < 1):
raise PyooVerbNotFound
return res
def get_name_matches(self, name: str) -> List[Tuple[str, Thing]]:
"""Return a list of objects which match a name spec.
Arguments:
name (str): A name of an object
Returns:
array[tuple]: A list of name,thing tuples.
"""
return [x for x in self.name_cache if fnmatch.fnmatch(name, x[0])]
def handle_exit(self, oldobj: Thing) -> None:
"""Handle an object leaving the container.
This allows children to hook this occurence also.
Arguments:
oldobj (`pyoo.things.Thing`): The object which is exiting.
"""
self.contents.remove(oldobj)
oldobj.handle_remove(self)
def handle_enter(self, newobj: Thing) -> None:
"""Handle an object entering the container.
This allows children to hook this occurence also.
Arguments:
newobj (`pyoo.things.Thing`): The object which is entering.
"""
self.contents.add(newobj)
newobj.handle_move(self)
try:
cast("Container", newobj).update_caches()
except AttributeError:
pass
self.update_caches()
def handle_tell(self, msg: SingleMultiString, who: Set[Thing]) -> None:
"""Handle processing a tell to a list of objects.
"""
for obj in who:
obj.tell(msg)
def tell(self, msg: SingleMultiString) -> None:
"""Handle telling all content objects."""
self.handle_tell(msg, self.contents)
def __repr__(self) -> str:
return "<Container '%s' object at 0x%x>" % (self.name, self.__hash__())
class Place(Container):
"""A specialized container which models a place."""
def __init__(self, names: str, description: str = "") -> None:
"""Create a place."""
super().__init__(names, description)
self.ways: Dict[str, Container] = dict()
def tell_only(self, message: SingleMultiString, verb_callframe: VerbCallFrame) -> None:
self.handle_tell(message, self.contents - {verb_callframe.player})
# this verb expects to be annotated from update_go. We never want it ot be at the top of a match list by its deault
# name either
@make_verb("#go", "none", "none", "none")
def go(self, verb_callframe: VerbCallFrame) -> None:
self.do_go(verb_callframe.verbname, verb_callframe)
@make_verb("go,move,walk,run", "any", "none", "none")
def go_dir(self, verb_callframe: VerbCallFrame) -> None:
self.do_go(verb_callframe.dobjstr, verb_callframe)
def do_go(self, direction: str, verb_callframe: VerbCallFrame) -> None:
if direction in self.ways:
self.tell_only("{} moves {}".format(verb_callframe.player.name, direction), verb_callframe)
verb_callframe.player.tell("You move {}".format(direction))
verb_callframe.environment.handle_move(self.ways[direction], verb_callframe.player)
def handle_enter(self, newobj: Thing) -> None:
super().handle_enter(newobj)
self.handle_tell("{} arrives".format(newobj.name), self.contents - {newobj})
def update_go(self) -> None:
# note does no actually remove items from the go verb in case the descender is overloading.
# also note, the interpreter needs to have update() called after this is called.
for direction in self.ways:
if direction not in self.go.names:
self.go.names.append(direction)
if self.interpreter:
self.interpreter.update()
def __repr__(self) -> str:
return "<Place '%s' object at 0x%x>" % (self.name, self.__hash__())
class Player(Container):
def __init__(self, names: str, description: str = ""):
super().__init__(names, description)
def __repr__(self) -> str:
return "<Player '%s' object at 0x%x>" % (self.name, self.__hash__())

0
requirements.txt Normal file
View File

40
roomtest.txt Normal file
View File

@ -0,0 +1,40 @@
Porch
This is a small, fairly nondescript porch covered on two sides with a neck-high fence. It is made of old boards nailed down instead of screwed.
To the north is the front door, slightly ajar.
.
n,north Play Room
.
Play Room
This is a medium-sized room with light wood parcade floor. There are toys of every description strewn about willy-nilly, two large baskets of stuffed animals and various tables adorned with books and drawing supplies. Large south-facing windows stream bright winter sunlight, warming the room.
To the south is the front door. To the east the room empties into the living room, to the north it continues into the hallway.
.
s,south Porch
n,north Hallway
e,east Living Room
.
Hallway
This is a narrow wood-floored hallway, echoing with every sound.
Several doors connect to the hallway, all closed. It also contiues to the south into the play room, and east into the kitchen.
.
s,south Play Room
e,east Kitchen
.
Kitchen
This is a small linolium-floored kitchen, with red counters, a double stainless steel sink filled with dishes and a noisy refridgerator. It is illuminated from above by recessed flourescent lights, although only one bulb seems to be working.
The room contiues to the south into the living room, and to the west into the hallway.
.
s,south Living Room
w,west Hallway
.
Living Room
This small intimate livingroom with light wood parkade flooring is dominated by a large wampa rug on the floor and a TV opposite a three seat red couch. A window to the south has the shades draw for enhanced viewing.
The room continues to the west past the couch into the play room, and to the north into the kitchen.
.
w,west Play Room
n,north Kitchen
.

21
setup.py Normal file
View File

@ -0,0 +1,21 @@
"""Module setup."""
import os.path
from setuptools import setup, find_packages
PACKAGE_NAME = "pyoo"
VERSION = "0.0.3"
srcpath = os.path.dirname(__file__)
if __name__ == "__main__":
setup(
name=PACKAGE_NAME,
version=VERSION,
packages=find_packages(),
python_requires=">=3.6.3",
# scripts=["scripts/adventure.py"]
description="A simple LambdaMOO-like command interpreter for Python",
long_description=open(os.path.join(srcpath, "README.md"), "r").read(),
long_description_content_type="text/markdown",
)

49
testrooms.py Normal file
View File

@ -0,0 +1,49 @@
from pyoo.things import Place, Player
from pyoo.placeloader import interpreter_from_placeloader, PlaceLoader
from pyoo.interpret import PyooVerbNotFound
from pyoo.base import make_verb
class DescriptivePlace(Place):
def handle_enter(self, player):
super().handle_enter(player)
self.do_look()
@make_verb("look,l", "none", "none", "none")
def look(self, verb_callframe):
self.do_look()
def do_look(self):
print(self.name)
if isinstance(self.description, str):
print(self.description)
else:
for line in self.description:
print(line)
loader = PlaceLoader(open("roomtest.txt", "r"), DescriptivePlace)
player = Player("player")
game = interpreter_from_placeloader(loader)
porch = game.lookup_global_object("Porch")[0][1]
run = True
game.update()
game.handle_move(porch, player)
# REPL
if __name__ == "__main__":
while run:
cmd = ""
try:
cmd = input(">")
except EOFError:
run = False
if cmd.startswith("quit"):
run = False
else:
try:
game.interpret(cmd, player)
except PyooVerbNotFound:
print("I don't understand that.")
print("Bye!")

91
testverb.py Normal file
View File

@ -0,0 +1,91 @@
from pyoo.interpret import Interpreter
from pyoo.things import Thing, Place, Player
from pyoo.base import make_verb, PyooVerbNotFound
class Hammer(Thing):
def __init__(self):
Thing.__init__(self, "hammer", "a heavy ball-peen hammer.")
@make_verb("hit", "that", "with", "this")
def hit(self, verb_callframe):
try:
verb_callframe.dobj.handle_hit(self)
except AttributeError:
pass
@make_verb("drop", "this", "none", "none")
def drop(self, verb_callframe):
print(verb_callframe)
@make_verb("get", "this", "none", "none")
def get(self, verb_callframe):
print(verb_callframe)
class Nail(Thing):
def __init__(self):
Thing.__init__(self, "nail", "a nine inch nail.")
self.depth = 1
def handle_hit(self, hitter):
if self.depth > 0:
print("bang! the nail is hammered.")
self.depth -= 1
else:
print("ping! the nail won't go deeper.")
def contents_desc_hook(self):
if self.depth > 0:
return "You see a nail sticking out "+str(self.depth)+"cm."
else:
return "You see a nail fully hammered in."
class HammerTime(Place):
def __init__(self):
Place.__init__(self, "HAMMERTIME")
self.handle_enter(Hammer())
self.handle_enter(Nail())
@make_verb("look,l", "none", "none", "none")
def look(self, verb_callframe):
for cont in self.contents:
try:
print(cont.contents_desc_hook())
except AttributeError:
continue
print("You see a hammer.")
@make_verb("look,l", "that", "none", "none")
def look_at(self, verb_callframe):
if verb_callframe.dobj:
dobj = verb_callframe.dobj
print("%s: %s" % (dobj.name, dobj.description))
else:
print("That doesn't appear to be here.")
hammertime = HammerTime()
game = Interpreter([hammertime])
player = Player("player")
game.add_player(player)
game.handle_move(hammertime, player)
game.update()
run = True
if __name__ == "__main__":
while run:
cmd = ""
try:
cmd = input(">")
except EOFError:
run = False
if cmd.startswith("quit"):
run = False
else:
try:
game.interpret(cmd, player)
except PyooVerbNotFound:
print("I don't understand that.")
print("Bye!")

14
tox.ini Normal file
View File

@ -0,0 +1,14 @@
[tox]
envlist = py36, py37, py38, py39
[testenv]
deps =
flake8
mypy
commands =
flake8
mypy -p pyoo
[flake8]
max-line-length = 120
max-complexity = 15