diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..1dc77a6 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,4 @@ +# Good variable names which should always be accepted. +# We have to go with this because they haven't fixed this yet: +# https://github.com/PyCQA/pylint/issues/2018 +good-names=x diff --git a/poetry.lock b/poetry.lock index 35db60d..7451ff1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -8,21 +8,21 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "20.3.0" +version = "21.2.0" description = "Classes Without Boilerplate" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] -docs = ["furo", "sphinx", "zope.interface"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] [[package]] name = "certifi" -version = "2020.12.5" +version = "2021.5.30" description = "Python package for providing Mozilla's CA Bundle." category = "dev" optional = false @@ -66,7 +66,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "coverage" -version = "5.4" +version = "5.5" description = "Code coverage measurement for Python" category = "dev" optional = false @@ -85,7 +85,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "3.4.0" +version = "4.6.1" description = "Read metadata from Python packages" category = "dev" optional = false @@ -97,11 +97,12 @@ zipp = ">=0.5" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +perf = ["ipython"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] [[package]] name = "more-itertools" -version = "8.7.0" +version = "8.8.0" description = "More routines for operating on iterables, beyond itertools" category = "dev" optional = false @@ -109,11 +110,11 @@ python-versions = ">=3.5" [[package]] name = "packaging" -version = "20.9" +version = "21.0" description = "Core utilities for Python packages" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] pyparsing = ">=2.0.2" @@ -173,7 +174,7 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm [[package]] name = "pytest-cov" -version = "2.11.1" +version = "2.12.1" description = "Pytest plugin for measuring coverage." category = "dev" optional = false @@ -182,9 +183,10 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.dependencies] coverage = ">=5.2.1" pytest = ">=4.6" +toml = "*" [package.extras] -testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] [[package]] name = "requests" @@ -214,7 +216,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "typing-extensions" -version = "3.7.4.3" +version = "3.10.0.0" description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" optional = false @@ -222,7 +224,7 @@ python-versions = "*" [[package]] name = "urllib3" -version = "1.26.3" +version = "1.26.6" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "dev" optional = false @@ -243,15 +245,15 @@ python-versions = "*" [[package]] name = "zipp" -version = "3.4.0" +version = "3.5.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "dev" optional = false python-versions = ">=3.6" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [metadata] lock-version = "1.1" @@ -264,12 +266,12 @@ atomicwrites = [ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, - {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, + {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, + {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] certifi = [ - {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, - {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, + {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, + {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, ] chardet = [ {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, @@ -289,71 +291,74 @@ colorama = [ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] coverage = [ - {file = "coverage-5.4-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:6d9c88b787638a451f41f97446a1c9fd416e669b4d9717ae4615bd29de1ac135"}, - {file = "coverage-5.4-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:66a5aae8233d766a877c5ef293ec5ab9520929c2578fd2069308a98b7374ea8c"}, - {file = "coverage-5.4-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9754a5c265f991317de2bac0c70a746efc2b695cf4d49f5d2cddeac36544fb44"}, - {file = "coverage-5.4-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:fbb17c0d0822684b7d6c09915677a32319f16ff1115df5ec05bdcaaee40b35f3"}, - {file = "coverage-5.4-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:b7f7421841f8db443855d2854e25914a79a1ff48ae92f70d0a5c2f8907ab98c9"}, - {file = "coverage-5.4-cp27-cp27m-win32.whl", hash = "sha256:4a780807e80479f281d47ee4af2eb2df3e4ccf4723484f77da0bb49d027e40a1"}, - {file = "coverage-5.4-cp27-cp27m-win_amd64.whl", hash = "sha256:87c4b38288f71acd2106f5d94f575bc2136ea2887fdb5dfe18003c881fa6b370"}, - {file = "coverage-5.4-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:c6809ebcbf6c1049002b9ac09c127ae43929042ec1f1dbd8bb1615f7cd9f70a0"}, - {file = "coverage-5.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ba7ca81b6d60a9f7a0b4b4e175dcc38e8fef4992673d9d6e6879fd6de00dd9b8"}, - {file = "coverage-5.4-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:89fc12c6371bf963809abc46cced4a01ca4f99cba17be5e7d416ed7ef1245d19"}, - {file = "coverage-5.4-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a8eb7785bd23565b542b01fb39115a975fefb4a82f23d407503eee2c0106247"}, - {file = "coverage-5.4-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:7e40d3f8eb472c1509b12ac2a7e24158ec352fc8567b77ab02c0db053927e339"}, - {file = "coverage-5.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1ccae21a076d3d5f471700f6d30eb486da1626c380b23c70ae32ab823e453337"}, - {file = "coverage-5.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:755c56beeacac6a24c8e1074f89f34f4373abce8b662470d3aa719ae304931f3"}, - {file = "coverage-5.4-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:322549b880b2d746a7672bf6ff9ed3f895e9c9f108b714e7360292aa5c5d7cf4"}, - {file = "coverage-5.4-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:60a3307a84ec60578accd35d7f0c71a3a971430ed7eca6567399d2b50ef37b8c"}, - {file = "coverage-5.4-cp35-cp35m-win32.whl", hash = "sha256:1375bb8b88cb050a2d4e0da901001347a44302aeadb8ceb4b6e5aa373b8ea68f"}, - {file = "coverage-5.4-cp35-cp35m-win_amd64.whl", hash = "sha256:16baa799ec09cc0dcb43a10680573269d407c159325972dd7114ee7649e56c66"}, - {file = "coverage-5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:2f2cf7a42d4b7654c9a67b9d091ec24374f7c58794858bff632a2039cb15984d"}, - {file = "coverage-5.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b62046592b44263fa7570f1117d372ae3f310222af1fc1407416f037fb3af21b"}, - {file = "coverage-5.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:812eaf4939ef2284d29653bcfee9665f11f013724f07258928f849a2306ea9f9"}, - {file = "coverage-5.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:859f0add98707b182b4867359e12bde806b82483fb12a9ae868a77880fc3b7af"}, - {file = "coverage-5.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:04b14e45d6a8e159c9767ae57ecb34563ad93440fc1b26516a89ceb5b33c1ad5"}, - {file = "coverage-5.4-cp36-cp36m-win32.whl", hash = "sha256:ebfa374067af240d079ef97b8064478f3bf71038b78b017eb6ec93ede1b6bcec"}, - {file = "coverage-5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:84df004223fd0550d0ea7a37882e5c889f3c6d45535c639ce9802293b39cd5c9"}, - {file = "coverage-5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1b811662ecf72eb2d08872731636aee6559cae21862c36f74703be727b45df90"}, - {file = "coverage-5.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6b588b5cf51dc0fd1c9e19f622457cc74b7d26fe295432e434525f1c0fae02bc"}, - {file = "coverage-5.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:3fe50f1cac369b02d34ad904dfe0771acc483f82a1b54c5e93632916ba847b37"}, - {file = "coverage-5.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:32ab83016c24c5cf3db2943286b85b0a172dae08c58d0f53875235219b676409"}, - {file = "coverage-5.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:68fb816a5dd901c6aff352ce49e2a0ffadacdf9b6fae282a69e7a16a02dad5fb"}, - {file = "coverage-5.4-cp37-cp37m-win32.whl", hash = "sha256:a636160680c6e526b84f85d304e2f0bb4e94f8284dd765a1911de9a40450b10a"}, - {file = "coverage-5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:bb32ca14b4d04e172c541c69eec5f385f9a075b38fb22d765d8b0ce3af3a0c22"}, - {file = "coverage-5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4d7165a4e8f41eca6b990c12ee7f44fef3932fac48ca32cecb3a1b2223c21f"}, - {file = "coverage-5.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a565f48c4aae72d1d3d3f8e8fb7218f5609c964e9c6f68604608e5958b9c60c3"}, - {file = "coverage-5.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fff1f3a586246110f34dc762098b5afd2de88de507559e63553d7da643053786"}, - {file = "coverage-5.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:a839e25f07e428a87d17d857d9935dd743130e77ff46524abb992b962eb2076c"}, - {file = "coverage-5.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:6625e52b6f346a283c3d563d1fd8bae8956daafc64bb5bbd2b8f8a07608e3994"}, - {file = "coverage-5.4-cp38-cp38-win32.whl", hash = "sha256:5bee3970617b3d74759b2d2df2f6a327d372f9732f9ccbf03fa591b5f7581e39"}, - {file = "coverage-5.4-cp38-cp38-win_amd64.whl", hash = "sha256:03ed2a641e412e42cc35c244508cf186015c217f0e4d496bf6d7078ebe837ae7"}, - {file = "coverage-5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:14a9f1887591684fb59fdba8feef7123a0da2424b0652e1b58dd5b9a7bb1188c"}, - {file = "coverage-5.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9564ac7eb1652c3701ac691ca72934dd3009997c81266807aef924012df2f4b3"}, - {file = "coverage-5.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:0f48fc7dc82ee14aeaedb986e175a429d24129b7eada1b7e94a864e4f0644dde"}, - {file = "coverage-5.4-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:107d327071061fd4f4a2587d14c389a27e4e5c93c7cba5f1f59987181903902f"}, - {file = "coverage-5.4-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:0cdde51bfcf6b6bd862ee9be324521ec619b20590787d1655d005c3fb175005f"}, - {file = "coverage-5.4-cp39-cp39-win32.whl", hash = "sha256:c67734cff78383a1f23ceba3b3239c7deefc62ac2b05fa6a47bcd565771e5880"}, - {file = "coverage-5.4-cp39-cp39-win_amd64.whl", hash = "sha256:c669b440ce46ae3abe9b2d44a913b5fd86bb19eb14a8701e88e3918902ecd345"}, - {file = "coverage-5.4-pp36-none-any.whl", hash = "sha256:c0ff1c1b4d13e2240821ef23c1efb1f009207cb3f56e16986f713c2b0e7cd37f"}, - {file = "coverage-5.4-pp37-none-any.whl", hash = "sha256:cd601187476c6bed26a0398353212684c427e10a903aeafa6da40c63309d438b"}, - {file = "coverage-5.4.tar.gz", hash = "sha256:6d2e262e5e8da6fa56e774fb8e2643417351427604c2b177f8e8c5f75fc928ca"}, + {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, + {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, + {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, + {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, + {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, + {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, + {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, + {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, + {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, + {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, + {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, + {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, + {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, + {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, + {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, + {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, + {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, + {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, + {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, + {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, + {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, + {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, + {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, + {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, + {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, + {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, + {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, + {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, + {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, + {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, + {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, + {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, + {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, + {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, + {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, + {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, + {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, + {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, + {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, + {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, + {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, + {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, + {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, + {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, + {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, + {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, + {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, + {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, ] importlib-metadata = [ - {file = "importlib_metadata-3.4.0-py3-none-any.whl", hash = "sha256:ace61d5fc652dc280e7b6b4ff732a9c2d40db2c0f92bc6cb74e07b73d53a1771"}, - {file = "importlib_metadata-3.4.0.tar.gz", hash = "sha256:fa5daa4477a7414ae34e95942e4dd07f62adf589143c875c133c1e53c4eff38d"}, + {file = "importlib_metadata-4.6.1-py3-none-any.whl", hash = "sha256:9f55f560e116f8643ecf2922d9cd3e1c7e8d52e683178fecd9d08f6aa357e11e"}, + {file = "importlib_metadata-4.6.1.tar.gz", hash = "sha256:079ada16b7fc30dfbb5d13399a5113110dab1aa7c2bc62f66af75f0b717c8cac"}, ] more-itertools = [ - {file = "more-itertools-8.7.0.tar.gz", hash = "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713"}, - {file = "more_itertools-8.7.0-py3-none-any.whl", hash = "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced"}, + {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, + {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, ] packaging = [ - {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, - {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, + {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, + {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, @@ -372,8 +377,8 @@ pytest = [ {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, ] pytest-cov = [ - {file = "pytest-cov-2.11.1.tar.gz", hash = "sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7"}, - {file = "pytest_cov-2.11.1-py2.py3-none-any.whl", hash = "sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da"}, + {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, + {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, ] requests = [ {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, @@ -384,19 +389,19 @@ toml = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] typing-extensions = [ - {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, - {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, - {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, + {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, + {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, + {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, ] urllib3 = [ - {file = "urllib3-1.26.3-py2.py3-none-any.whl", hash = "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80"}, - {file = "urllib3-1.26.3.tar.gz", hash = "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"}, + {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"}, + {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, ] zipp = [ - {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, - {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, + {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"}, + {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"}, ] diff --git a/pyproject.toml b/pyproject.toml index 95afeb1..b844c5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyroll" -version = "2.0.0" +version = "2.1.0" description = "Dice roller with all of the features you could want." authors = ["Derek 'Vlek' McCammond "] license = "gpl-3.0" @@ -35,4 +35,4 @@ requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" [tool.poetry.scripts] -roll = 'roll.click_dice:roll_cli' +roll = 'roll.click.click_dice:roll_cli' diff --git a/roll/__init__.py b/roll/__init__.py index a81d894..ead7473 100644 --- a/roll/__init__.py +++ b/roll/__init__.py @@ -1 +1 @@ -from .roll import roll +from roll.roll import roll diff --git a/roll/click/__init__.py b/roll/click/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/roll/click_dice.py b/roll/click/click_dice.py similarity index 76% rename from roll/click_dice.py rename to roll/click/click_dice.py index ad251b0..2c7f9d7 100644 --- a/roll/click_dice.py +++ b/roll/click/click_dice.py @@ -15,10 +15,11 @@ from typing import List, Union -import click - +import roll.parser.operations as ops from roll import roll -from roll.diceparser import EvaluationResults, RollOption +from roll.parser.types import EvaluationResults, RollOption + +import click @click.command() @@ -57,18 +58,24 @@ def roll_cli(expression: List[str] = None, """ command_input = ' '.join(expression) if expression is not None else '' + # Because of how we are now evaluating during parsing, we have to go back + # to the old way of having some globalish variable for handing off the + # type of roll that we're attempting to do. + ops.ROLL_TYPE = roll_option + result: Union[int, float, EvaluationResults] = roll( command_input, verbose, roll_option, ) - if verbose and isinstance(result, dict): - for r in result['rolls']: + if verbose and isinstance(result, EvaluationResults): + for r in result.rolls: click.echo( - f"{r['dice']}: {r['rolls']}" + f"{r.dice}: {r.rolls}" ) - click.echo(result['total']) - + click.echo(result.total) + elif isinstance(result, EvaluationResults): + click.echo(result.total) else: click.echo(result) diff --git a/roll/diceparser.py b/roll/diceparser.py deleted file mode 100644 index 1069189..0000000 --- a/roll/diceparser.py +++ /dev/null @@ -1,342 +0,0 @@ -#!/user/bin/env python3 - -""" -Dice rolling PyParser grammar. - -Grammar: -Digit ::= [1234567890] -Number ::= ( '-' )? Digit Digit* ( '.' Digit Digit*)? -Add ::= Number '+' Number -Sub ::= Number '-' Number -AddOrSub ::= Add | Sub -Mult ::= Number '*' Number -Div ::= Number '/' Number -Mod ::= Number '%' Number -IntDiv ::= Number '//' Number -MultOrDiv ::= Mult | Div | Mod | IntDiv -Exponent ::= Number '**' Number -PercentDie ::= Number? 'd%' -Die ::= Number? 'd' Number -Dice ::= Die | PercentDie -Parens ::= Number? '(' Expression ')' -Expression ::= (Parens | Exponent | Dice | MultOrDiv | AddOrSub | Number)+ -Main ::= Expression - -Website used to do railroad diagrams: https://www.bottlecaps.de/rr/ui -""" - -from enum import Enum -from math import ceil, e, factorial, floor, pi, sqrt -from operator import add, floordiv, mod, mul, sub, truediv -from random import randint -from sys import version_info -from typing import Callable, Dict, List, Optional, Union - -# TypedDict was added in 3.8. -if version_info >= (3, 8): - from typing import TypedDict -else: - from typing_extensions import TypedDict - -from pyparsing import (CaselessKeyword, CaselessLiteral, Forward, Literal, - ParseException, ParserElement, ParseResults, oneOf, - opAssoc, operatorPrecedence, pyparsing_common) - -ParserElement.enablePackrat() - - -class RollResults(TypedDict): - total: Union[int, float] - dice: str - rolls: List[Union[int, float]] - - -class EvaluationResults(TypedDict): - total: Union[int, float] - rolls: List[RollResults] - - -class RollOption(Enum): - Minimum = 0 - Normal = 1 - Maximum = 2 - - -def _roll_dice( - num_dice: Union[int, float], - sides: Union[int, float], - roll_option: RollOption = RollOption.Normal, - ) -> RollResults: - """Calculate value of dice roll notation.""" - starting_num_dice = num_dice - starting_sides = sides - - # If it's the case that we were given a dice with negative sides, - # then that doesn't mean anything in the real world. I cannot - # for the life of me figure out a possible scenario where that - # would make sense. We will just error out. - if sides < 0: - raise ValueError('The sides of a die must be positive or zero.') - - result_is_negative = num_dice < 0 - - if result_is_negative: - num_dice = abs(num_dice) - - sides = ceil(sides) - - rolls: List[Union[int, float]] = [] - - if roll_option == RollOption.Minimum: - rolls = [1] * ceil(num_dice) - elif roll_option == RollOption.Maximum: - rolls = [floor(sides)] * floor(num_dice) - - if isinstance(num_dice, float) and (num_dice % 1) != 0: - rolls.append(sides * (num_dice % 1)) - elif sides != 0: - rolls = [randint(1, sides) for _ in range(floor(num_dice))] - - # If it's the case that the number of dice is a float, then - # we take that to mean that it is a dice where the sides should - # be lowered to reflect the float amount. - # - # We do not want this to effect all dice rolls however, only the - # last one (or the only one if there's only a decimal portion). - if isinstance(num_dice, float) and (num_dice % 1) != 0: - sides = ceil(sides * (num_dice % 1)) - rolls.append(randint(1, sides)) - - rolls_total = sum(rolls) - - if result_is_negative: - rolls_total *= -1 - - result: RollResults = { - 'total': rolls_total, - 'dice': f'{starting_num_dice}d{starting_sides}', - 'rolls': rolls - } - - return result - - -class DiceParser: - """Parser for evaluating dice strings.""" - - operations: Dict[ - str, - Union[ - Callable[ - [ - Union[int, float], - Union[int, float] - ], Union[int, float, RollResults]], - # This is needed for factorial and sqrt - Callable[ - [Union[int, float]], - Union[int, float] - ] - ]] = { - "+": add, - "-": sub, - "*": mul, - "/": truediv, - "//": floordiv, - "%": mod, - "^": pow, - "**": pow, - "d": _roll_dice, - "!": factorial, - "sqrt": sqrt - } - - constants = { - "pi": pi, - "e": e - } - - def __init__(self: "DiceParser") -> None: - """Initialize a parser to handle dice strings.""" - self._parser = self._create_parser() - - @staticmethod - def _create_parser() -> Forward: - """Create an instance of a dice roll string parser.""" - atom = ( - CaselessLiteral("d%") | - pyparsing_common.number | - CaselessKeyword("pi") | - CaselessKeyword("e") - ) - - expression = operatorPrecedence(atom, [ - (Literal('-'), 1, opAssoc.RIGHT), - (CaselessLiteral('sqrt'), 1, opAssoc.RIGHT), - (oneOf('^ **'), 2, opAssoc.RIGHT), - - (Literal('-'), 1, opAssoc.RIGHT), - (Literal('!'), 1, opAssoc.LEFT), - - (CaselessLiteral('d%'), 1, opAssoc.LEFT), - (CaselessLiteral('d'), 2, opAssoc.RIGHT), - - # This line causes the recursion debug to go off. - # Will have to find a way to have an optional left - # operator in this case. - (CaselessLiteral('d'), 1, opAssoc.RIGHT), - - (oneOf('* / % //'), 2, opAssoc.LEFT), - - (oneOf('+ -'), 2, opAssoc.LEFT), - ]) - - return expression - - def parse(self: "DiceParser", dice_string: str) -> List[Union[str, int]]: - """Parse well-formed dice roll strings.""" - try: - return self._parser.parseString(dice_string, parseAll=True) - except ParseException: - raise SyntaxError("Unable to parse input string: " + dice_string) - - def evaluate( - self: "DiceParser", - parsed_values: Union[List[Union[str, int]], str], - roll_option: RollOption = RollOption.Normal, - ) -> EvaluationResults: - """Evaluate the output parsed values from roll strings.""" - if isinstance(parsed_values, str): - parsed_values = self.parse(parsed_values) - - result: Union[int, float, None] = None - operator = None - dice_rolls = [] - - val: Union[str, int, float, Optional[float]] - for val in parsed_values: - - # In addition to dealing with values, we are also going to - # handle constants and nested lists here because, after they - # are evaluated, they are then used as values in the current - # evaluation context. - if ( - isinstance(val, (int, float, ParseResults)) or - val in self.constants - ): - if val in self.constants: - val = self.constants[str(val)] - elif isinstance(val, ParseResults): - evaluation: EvaluationResults = self.evaluate(val, - roll_option) - val = evaluation['total'] - dice_rolls.extend(evaluation['rolls']) - - if operator is not None: - # There are currently only two cases that could - # cause result to be none, either we're dealing - # with a dice roll that doesn't have a left-hand - # number or a unary minus. In either case, we have - # to initialize the result accordingly. - if result is None: - if operator is _roll_dice: - result = 1 - else: - result = 0 - - if operator is _roll_dice: - current_rolls = _roll_dice(result, val, roll_option) - - result = current_rolls['total'] - dice_rolls.append(current_rolls) - elif operator is sqrt: - result = operator(val) - else: - result = operator(result, val) - else: - result = val - - elif val in self.operations: - - # Since factorials are unary and the value is to the left- - # hand side, we will execute the factorial function here - # as we do not have to wait for any further input. - if val == "!": - result = factorial(result if result is not None else 0) - continue - - operator = self.operations[val] - - elif val in ["D%", "d%"]: - current_rolls = _roll_dice( - result if result is not None else 1, 100, roll_option) - - result = current_rolls['total'] - dice_rolls.append(current_rolls) - - total_results: EvaluationResults = { - 'total': result if result is not None else 0, - 'rolls': dice_rolls - } - - return total_results - - -if __name__ == "__main__": - parser = DiceParser() - - # print("Recursive issues:", parser._parser.validate()) - roll_strings = [ - "5-3", - "3-5", - "3--5", - "1d2d3", - "5^2d1", - "0!d20", - "5 + 2!", - "5**(2)", - "5**2 * 7", - "2 + 5 d 6", - "(2)d6", - "2d(6)", - "3", - "-3", - "--3", - "100.", - "1 2", - "--7", - "9.0", - "-12.05", - "1 + 2", - "2 - 1", - "100 - 3", - "12 // 4", - "3+2*4", - "2^4", - "3 + 2 * 2^1", - "9-1+27+(3-5)+9", - "1d%", - "d%", - "D%", - "1d20", - "d20", - "d20 + 5", - "2d6 + 1d8 + 4", - "5!", - "pi", - "pi + 2", - "pi * e", - "(2 + 8 / (9 - 5)) * 3", - "100 - 21 / 7", - "((((((3))))))", - ] - - for rs in roll_strings: - try: - parsed_string = parser.parse(rs) - - # print(rs) - # print(parsed_string) - print(parser.evaluate(parsed_string)) - except Exception as e: - print(f"Exception '{e}' occured parsing: " + rs) diff --git a/roll/parser/__init__.py b/roll/parser/__init__.py new file mode 100644 index 0000000..4d4e541 --- /dev/null +++ b/roll/parser/__init__.py @@ -0,0 +1 @@ +from roll.parser.diceparser import DiceParser diff --git a/roll/parser/diceparser.py b/roll/parser/diceparser.py new file mode 100644 index 0000000..ce513c7 --- /dev/null +++ b/roll/parser/diceparser.py @@ -0,0 +1,280 @@ +#!/user/bin/env python3 + +""" +Dice rolling PyParser grammar. + +Grammar: +Digit ::= [1234567890] +Number ::= ( '-' )? Digit Digit* ( '.' Digit Digit*)? +Add ::= Number '+' Number +Sub ::= Number '-' Number +AddOrSub ::= Add | Sub +Mult ::= Number '*' Number +Div ::= Number '/' Number +Mod ::= Number '%' Number +IntDiv ::= Number '//' Number +MultOrDiv ::= Mult | Div | Mod | IntDiv +Exponent ::= Number '**' Number +PercentDie ::= Number? 'd%' +Die ::= Number? 'd' Number +Dice ::= Die | PercentDie +Parens ::= Number? '(' Expression ')' +Expression ::= (Parens | Exponent | Dice | MultOrDiv | AddOrSub | Number)+ +Main ::= Expression + +Website used to do railroad diagrams: https://www.bottlecaps.de/rr/ui +""" + +from __future__ import annotations + +from math import e, factorial, pi +from typing import Callable, Dict, List, Union + +from pyparsing import (CaselessKeyword, CaselessLiteral, Forward, Literal, + ParseException, ParserElement, infixNotation, oneOf, + opAssoc, pyparsing_common) +from roll.parser.operations import (ROLL_TYPE, add, expo, floor_div, mod, mult, + roll_dice, sqrt, sub, true_div) +from roll.parser.types import EvaluationResults, RollOption, RollResults + +ParserElement.enablePackrat() + + +class DiceParser: + """Parser for evaluating dice strings.""" + + OPERATIONS: Dict[str, Callable] = { + "+": add, + "-": sub, + "*": mult, + "/": true_div, + "//": floor_div, + "%": mod, + "^": expo, + "d": roll_dice, + } + + def __init__(self: "DiceParser") -> None: + """Initialize a parser to handle dice strings.""" + self._parser = self._create_parser() + + @staticmethod + def _create_parser() -> Forward: + """Create an instance of a dice roll string parser.""" + atom = ( + CaselessLiteral("d%").setParseAction( + lambda: DiceParser._handle_roll(1, 100)) | + pyparsing_common.number | + CaselessKeyword("pi").setParseAction(lambda: pi) | + CaselessKeyword("e").setParseAction(lambda: e) + ) + + expression = infixNotation(atom, [ + # Unary minus + (Literal('-'), 1, opAssoc.RIGHT, DiceParser._handle_unary_minus), + # Square root + (CaselessLiteral('sqrt'), 1, opAssoc.RIGHT, + DiceParser._handle_sqrt), + + # Exponents + (oneOf('^ **'), 2, opAssoc.RIGHT, DiceParser._handle_expo), + + # Unary minus (#2) + (Literal('-'), 1, opAssoc.RIGHT, DiceParser._handle_unary_minus), + # Factorial + (Literal('!'), 1, opAssoc.LEFT, DiceParser._handle_factorial), + + # Dice notations + (CaselessLiteral('d%'), 1, opAssoc.LEFT, + lambda toks: DiceParser._handle_roll(toks[0][0], 100)), + (CaselessLiteral('d'), 2, opAssoc.RIGHT, + lambda toks: DiceParser._handle_roll(toks[0][0], toks[0][2])), + + # This line causes the recursion debug to go off. + # Will have to find a way to have an optional left + # operand in this case. + (CaselessLiteral('d'), 1, opAssoc.RIGHT, + lambda toks: DiceParser._handle_roll(1, toks[0][1])), + + # Keep notation + (oneOf('k K'), 2, opAssoc.LEFT, + DiceParser._handle_keep), + (oneOf('k K'), 1, opAssoc.LEFT, + DiceParser._handle_keep), + + # Multiplication and division + (oneOf('* / % //'), 2, opAssoc.LEFT, + DiceParser._handle_standard_operation), + + # Addition and subtraction + (oneOf('+ -'), 2, opAssoc.LEFT, + DiceParser._handle_standard_operation), + + # TODO: Use this to make a pretty exception message + # where we point out and explain the issue. + ]).setFailAction(lambda s, loc, expr, err: print(err)) + + return expression + + @staticmethod + def _handle_unary_minus( + toks: list[list[Union[int, float, EvaluationResults + ]]]) -> Union[int, float, EvaluationResults]: + return -toks[0][1] + + @staticmethod + def _handle_sqrt( + toks: list[list[Union[str, int, float, EvaluationResults + ]]]) -> EvaluationResults: + value: Union[int, float, EvaluationResults] = float(toks[0][1]) + return sqrt(value) + + @staticmethod + def _handle_expo( + toks: list[list[Union[int, float, EvaluationResults + ]]]) -> Union[int, float, EvaluationResults]: + return toks[0][0] ** toks[0][2] + + @staticmethod + def _handle_factorial( + toks: List[List[Union[int, float, EvaluationResults + ]]]) -> Union[int, float, EvaluationResults]: + return factorial(toks[0][0]) + + @staticmethod + def _handle_roll(sides: Union[int, float, EvaluationResults], + num: Union[int, float, EvaluationResults] + ) -> Union[int, float, EvaluationResults]: + roll_option = ROLL_TYPE + return roll_dice(sides, num, roll_option) + + @staticmethod + def _handle_keep( + toks: List[List[Union[int, float, EvaluationResults, str + ]]]) -> Union[int, float, EvaluationResults]: + tokens: List[Union[int, float, + EvaluationResults, str]] = toks[0] + + if not isinstance(tokens[0], EvaluationResults): + raise Exception("Left value must contain a dice roll.") + + # We initialize our result with the left-most value. + # As we perform operations, this value will be continuously + # updated and used as the left-hand side. + result: EvaluationResults = tokens[0] + + # If it's the case that we have an implied keep amount, we + # need to manually add it to the end here. + if len(tokens) % 2 == 0: + tokens.append(1) + + # Because we get things like [[1, "+", 2, "+", 3]], we have + # to be able to handle additional operations beyond a single + # left/right pair. + for i in range(1, len(tokens), 2): + + op_index = i + right_index = i + 1 + + operation_string: str = str(tokens[op_index]) + + if operation_string not in ['K', 'k']: + raise Exception( + f"Operator at index {op_index} was " + f"not in valid operations: {toks}") + + right: Union[EvaluationResults, float, int, + str] = tokens[right_index] + + if isinstance(right, str): + raise Exception(f"right value cannot be a string: {toks}") + elif isinstance(right, EvaluationResults): + result += right + result.total -= right.total + right = right.total + + last_roll: RollResults = result.rolls[-1] + lower_total_by: Union[int, float] = 0 + + if operation_string == 'k': + lower_total_by = last_roll.keep_lowest(right) + else: + lower_total_by = last_roll.keep_highest(right) + + result.total -= lower_total_by + + return result + + @staticmethod + def _handle_standard_operation( + toks: list[list[Union[str, int, float, EvaluationResults + ]]]) -> Union[int, float, EvaluationResults]: + # We initialize our result with the left-most value. + # As we perform operations, this value will be continuously + # updated and used as the left-hand side. + result: Union[int, float, EvaluationResults, str] = toks[0][0] + + if isinstance(result, str): + raise Exception(f"left value cannot be a string: {toks}") + + # Because we get things like [[1, "+", 2, "+", 3]], we have + # to be able to handle additional operations beyond a single + # left/right pair. + for pair in range(1, len(toks[0]), 2): + + right: Union[int, float, + EvaluationResults, str] = toks[0][pair + 1] + + if isinstance(right, str): + raise Exception(f"right value cannot be a string: {toks}") + + operation_string: str = str(toks[0][pair]) + + if operation_string not in DiceParser.OPERATIONS: + raise Exception( + f"Operator was not in valid operations: {toks}") + + op: Callable[ + [ + Union[int, float, EvaluationResults], + Union[int, float, EvaluationResults] + ], + EvaluationResults + ] = DiceParser.OPERATIONS[operation_string] + + result = op(result, right) + + return result + + def parse(self: DiceParser, + dice_string: str, + roll_option: RollOption = RollOption.Normal + ) -> List[Union[int, float, EvaluationResults]]: + """Parse well-formed dice roll strings.""" + try: + result: List[ + Union[ + int, + float, + EvaluationResults] + ] = self._parser.parseString(dice_string, parseAll=True) + except ParseException: + raise SyntaxError("Unable to parse input string: " + dice_string) + + if len(result) == 0: + raise Exception("Did not receive any value from evaluation") + elif len(result) > 1: + raise Exception( + f"Received more values than expected: {result}") + elif not all(isinstance(i, (int, float, + EvaluationResults)) for i in result): + raise Exception(f"Unexpected types in output: {result}") + + return result + + def evaluate(self: DiceParser, + dice_string: str, + roll_option: RollOption = RollOption.Normal, + ) -> Union[int, float, EvaluationResults]: + """Parse and evaluate the given dice string.""" + return self.parse(dice_string, roll_option)[0] diff --git a/roll/parser/operations.py b/roll/parser/operations.py new file mode 100644 index 0000000..c6cfdb0 --- /dev/null +++ b/roll/parser/operations.py @@ -0,0 +1,224 @@ +""" +Helper class that stores all available operations within the dice roller. + +Operations: + + Addition "+" + Subtraction "-" + Multiplication "*" + 'True' division "/" + 'Floor' divison "//" + Modulus "%" + Exponential "^" OR "**" + Factorial "!" + Square root "sqrt" + + Dice roll "d" + Keep lowest roll "k" + Keep highest roll "K" +""" +from __future__ import annotations + +from math import ceil +from math import factorial as fact +from math import floor +from math import sqrt as squareroot +from random import randint +from typing import List, Union + +from roll.parser.types import EvaluationResults, RollOption, RollResults + +ROLL_TYPE: RollOption = RollOption.Normal + + +def _to_eval_results( + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Change given object to an EvaluationResults object.""" + if not isinstance(x, EvaluationResults): + x = EvaluationResults(x) + + return x + + +def add(x: Union[int, float, EvaluationResults], + y: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Add x and y together with extended types.""" + return _to_eval_results(x + y) + + +def sub(x: Union[int, float, EvaluationResults], + y: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Subtract x and y together with extended types.""" + return _to_eval_results(x - y) + + +def mult(x: Union[int, float, EvaluationResults], + y: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Multiply x and y together with extended types.""" + return _to_eval_results(x * y) + + +def true_div(x: Union[int, float, EvaluationResults], + y: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Divide (true) x and y with extended types.""" + return _to_eval_results(x / y) + + +def floor_div(x: Union[int, float, EvaluationResults], + y: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Divide (floor) x and y with extended types.""" + return _to_eval_results(x // y) + + +def mod(x: Union[int, float, EvaluationResults], + y: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Divide (modulus) x and y with extended types.""" + # We get a false positive here for modulus string op + return _to_eval_results(x % y) # noqa: S001 + + +def expo(x: Union[int, float, EvaluationResults], + y: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Exponentiate x by y with extended types.""" + return _to_eval_results(x ** y) + + +def factorial( + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """ + Handle the factorial operation for int, float, and EvaluationResults. + + Depending on the passed datatype, we do different things: + int: No change required, it is able to use the built-in function + and then change the resulting value into an EvaluationResults. + float: The value is ceil'd and then passed as an int. + EvaluationResults: The total is ceil'd and passed. + """ + result: Union[int, EvaluationResults] + + if isinstance(x, EvaluationResults): + x.total = fact(ceil(x.total)) + result = x + else: + result = fact(ceil(x)) + + return _to_eval_results(result) + + +def sqrt(x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Perform sqrt on x with extended types.""" + result: Union[int, float, EvaluationResults] + + if isinstance(x, EvaluationResults): + x.total = squareroot(x.total) + result = x + else: + result = squareroot(x) + + return _to_eval_results(result) + + +def roll_dice( + num_dice: Union[int, float, EvaluationResults], + sides: Union[int, float, EvaluationResults], + roll_option: RollOption = RollOption.Normal, + ) -> EvaluationResults: + """Calculate value of dice roll notation.""" + result: EvaluationResults = EvaluationResults() + + global ROLL_TYPE + roll_option = ROLL_TYPE + + # In order to ensure our types later on, we are going to first + # take out all of the EvaluationResults objects and take their + # totals to be used down the line. Their history will be used + # as the basis for our returned value going forward. + # + # Since the totals for either are being used for our count + # and sides values, we do not include the totals in the final + # value. + if isinstance(num_dice, EvaluationResults): + result += num_dice + num_dice = num_dice.total + + if isinstance(sides, EvaluationResults): + result += sides + sides = sides.total + + result.total = 0 + + starting_num_dice: Union[int, float] = num_dice + starting_sides: Union[int, float] = sides + + # If it's the case that we were given a dice with negative sides, + # then that doesn't mean anything in the real world. I cannot + # for the life of me figure out a possible scenario where that + # would make sense. We will just error out. + if sides < 0: + raise ValueError('The sides of a die must be positive or zero.') + + result_is_negative: bool = num_dice < 0 + + if result_is_negative: + num_dice = abs(num_dice) + + sides = ceil(sides) + + rolls: List[Union[int, float]] = [] + + if roll_option == RollOption.Minimum: + rolls = [1] * ceil(num_dice) + elif roll_option == RollOption.Maximum: + # TODO: Ensure that this logic is correct. I am not sure that + # we want to floor either of these numbers. + # Example: 1.5d5.5 maxed should be the max of 1.5d6, or 9. + rolls = [floor(sides)] * floor(num_dice) + + if isinstance(num_dice, float) and (num_dice % 1) != 0: + rolls.append(sides * (num_dice % 1)) + elif sides != 0: + # Because this is not cryptographically secure, we have to noqa it + # otherwise we get an S311 warning. + rolls = [randint(1, sides) for _ in range(floor(num_dice))] # noqa + + # If it's the case that the number of dice is a float, then + # we take that to mean that it is a dice where the sides should + # be lowered to reflect the float amount. + # + # We do not want this to effect all dice rolls however, only the + # last one (or the only one if there's only a decimal portion). + if isinstance(num_dice, float) and (num_dice % 1) != 0: + sides = ceil(sides * (num_dice % 1)) + # Not cryptographically secure + rolls.append(randint(1, sides)) # noqa + + if result_is_negative: + for roll_num in range(len(rolls)): + rolls[roll_num] = -rolls[roll_num] + + result.add_roll( + RollResults(f'{starting_num_dice}d{starting_sides}', rolls)) + + return result + + +def keep_lowest_dice(results: EvaluationResults, + k: Union[int, float] = 1) -> EvaluationResults: + """Remove k number of lowest rolls from last roll.""" + if len(results.rolls) == 0: + return results + + results.rolls[-1].keep_lowest(k) + + return results + + +def keep_highest_dice(results: EvaluationResults, + k: Union[int, float] = 1) -> EvaluationResults: + """Trim the results of a roll based on the provided amount to keep.""" + if len(EvaluationResults.rolls) == 0: + return results + + results.rolls[-1].keep_highest(k) + + return results diff --git a/roll/parser/types/__init__.py b/roll/parser/types/__init__.py new file mode 100644 index 0000000..c8b145d --- /dev/null +++ b/roll/parser/types/__init__.py @@ -0,0 +1,3 @@ +from roll.parser.types.evaluationresults import EvaluationResults +from roll.parser.types.rolloption import RollOption +from roll.parser.types.rollresults import RollResults diff --git a/roll/parser/types/evaluationresults.py b/roll/parser/types/evaluationresults.py new file mode 100644 index 0000000..7cd3052 --- /dev/null +++ b/roll/parser/types/evaluationresults.py @@ -0,0 +1,353 @@ +""" +Defines EvaluationResults object to hold parsing state throughout evaluation. + +The EvaluationResults object is meant to solve an issue that was encountered +when attempting to add in dice roll modifiers. Instead of having the +heavy-lifting happening within our expression parser (which also caused +issues with recursive expressions and verbose expressions), we are going to +have an object that acts like an onion and coalesce all of the history +together into a comprehensible bundle. + +What this will allow us to do is have things like `4d6K3` be two separate +expressions to be parsed by our parser without having to state that there +is a dice expression and another for the keep (K) notation. The real +beauty of this comes from our ability to them chain these statements +together, allowing for things like 10d4K5k2K1 without having to write +our expression parser to handle this case. + +This method should also help with dealing with testing. By having the +ability to capture our history for math and dice rolls, we can see +whether it is the case that it is acting normally throughout the +process and not just relying on the end result being within a certain +acceptable range. Better, more informative tests can be made this way. +""" +from __future__ import annotations + +from enum import Enum +from typing import List, Optional, Union + +import roll.parser.types + + +class Operators(Enum): + """ + Helper operator names for handling operations. + + This makes it so that we don't have magic strings + everywhere. Instead, we can do things like: + Operators.add + """ + + add = "+" + sub = "-" + mul = "*" + truediv = "/" + floordiv = "//" + mod = "%" + expo = "^" + expo2 = "**" + die = "d" + + +class EvaluationResults(): + """Hold the current state of all rolls and the total from other ops.""" + + def __init__(self: EvaluationResults, + value: Union[int, float, EvaluationResults] = None, + rolls: List[roll.parser.types.RollResults] = None, + last_operation: Operators = None) -> None: + """Initialize an EvaluationResults object.""" + if rolls is None: + rolls = [] + + total: Optional[Union[int, float]] = None + + if isinstance(value, EvaluationResults): + rolls.extend(value.rolls) + total = value.total + else: + total = value + + self.total: Union[int, float] = total or 0 + self.rolls: List[roll.parser.types.RollResults] = rolls + self.last_operation: Union[Operators, None] = last_operation + + def add_roll(self: EvaluationResults, + roll: roll.parser.types.RollResults) -> None: + """Add the results of a roll to the total evaluation results.""" + self.total += roll.total() + self.rolls.append(roll) + + def _collect_rolls(self: EvaluationResults, er: EvaluationResults) -> None: + """ + Add all rolls together if both objects are EvaluationResults. + + The way that we do this is by extending the other object's rolls + in place for efficiency's sake and then we set our object to that + one instead of the other way around so that we hopefully are going + to have the most recently roll as our last. + """ + er.rolls.extend(self.rolls) + self.rolls = er.rolls + + def __str__(self: EvaluationResults) -> str: + """Return a string representation of the eval results.""" + return f"{self.total}" + + def __int__(self: EvaluationResults) -> int: + """Change the evaluation result total to an integer.""" + return int(self.total) + + def __float__(self: EvaluationResults) -> float: + """Change the evaluation result total to a float.""" + return float(self.total) + + def __len__(self: EvaluationResults) -> int: + """Return the number of rolls that have been rolled.""" + return len(self.rolls) + + def __eq__(self: EvaluationResults, other: object) -> bool: + """Return whether or not a given value is numerically equal.""" + if not isinstance(other, (int, float, EvaluationResults)): + return NotImplemented + + if isinstance(other, (int, float)): + return other == self.total + + return self.total == other.total + + def __neg__(self: EvaluationResults) -> EvaluationResults: + """Negate the total value.""" + self.total = -self.total + return self + + def __add__(self: EvaluationResults, + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Add a given value to the evaluation result total.""" + self.last_operation = Operators.add + + if isinstance(x, (int, float)): + self.total += x + elif isinstance(x, EvaluationResults): + self._collect_rolls(x) + self.total += x.total + else: + raise TypeError("The supplied type is not valid: " + type(x)) + + return self + + def __iadd__(self: EvaluationResults, + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Add a given value to the evaluation result total.""" + return self.__add__(x) + + def __radd__(self: EvaluationResults, + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Add a given value to the evaluation result total.""" + return self.__add__(x) + + def __sub__(self: EvaluationResults, + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Subtract a given value from the evaluation result total.""" + self.last_operation = Operators.sub + + if isinstance(x, (int, float)): + self.total -= x + elif isinstance(x, EvaluationResults): + self._collect_rolls(x) + self.total -= x.total + else: + raise TypeError("The supplied type is not valid: " + type(x)) + + return self + + def __isub__(self: EvaluationResults, + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Subtract a given value from the evaluation result total.""" + return self.__sub__(x) + + def __rsub__(self: EvaluationResults, + x: Union[int, float]) -> EvaluationResults: + """Subtract a given value from the evaluation result total.""" + self.last_operation = Operators.sub + + if isinstance(x, (int, float)): + self.total = x - self.total + elif isinstance(x, EvaluationResults): + self._collect_rolls(x) + self.total = x.total - self.total + else: + raise TypeError("The supplied type is not valid: " + type(x)) + + return self + + def __mul__(self: EvaluationResults, + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Multiply the evaluation result total by a given value.""" + self.last_operation = Operators.mul + + if isinstance(x, (int, float)): + self.total *= x + elif isinstance(x, EvaluationResults): + self._collect_rolls(x) + self.total *= x.total + else: + raise TypeError("The supplied type is not valid: " + type(x)) + + return self + + def __imul__(self: EvaluationResults, + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Multiply the evaluation result total by a given value.""" + return self.__mul__(x) + + def __rmul__(self: EvaluationResults, + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Multiply the evaluation result total by a given value.""" + return self.__mul__(x) + + def __truediv__(self: EvaluationResults, + x: Union[int, float, EvaluationResults] + ) -> EvaluationResults: + """Divide the evaluation result total by a given number.""" + self.last_operation = Operators.truediv + + if isinstance(x, (int, float)): + self.total /= x + elif isinstance(x, EvaluationResults): + self._collect_rolls(x) + self.total /= x.total + else: + raise TypeError("The supplied type is not valid: " + type(x)) + + return self + + def __itruediv__(self: EvaluationResults, + x: Union[int, float, EvaluationResults] + ) -> EvaluationResults: + """Divide the evaluation result total by a given number.""" + return self.__truediv__(x) + + def __rtruediv__(self: EvaluationResults, + x: Union[int, float, EvaluationResults] + ) -> EvaluationResults: + """Divide the evaluation result total by a given number.""" + self.last_operation = Operators.truediv + + if isinstance(x, (int, float)): + self.total = x / self.total + elif isinstance(x, EvaluationResults): + self._collect_rolls(x) + self.total = x.total / self.total + else: + raise TypeError("The supplied type is not valid: " + type(x)) + + return self + + def __floordiv__(self: EvaluationResults, + x: Union[int, float, EvaluationResults] + ) -> EvaluationResults: + """Divide the evaluation result total by a given number and floor.""" + self.last_operation = Operators.floordiv + + if isinstance(x, (int, float)): + self.total //= x + elif isinstance(x, EvaluationResults): + self._collect_rolls(x) + self.total //= x.total + else: + raise TypeError("The supplied type is not valid: " + type(x)) + + return self + + def __ifloordiv__(self: EvaluationResults, + x: Union[int, float, EvaluationResults] + ) -> EvaluationResults: + """Divide the evaluation result total by a given number and floor.""" + return self.__floordiv__(x) + + def __rfloordiv__(self: EvaluationResults, + x: Union[int, float, EvaluationResults] + ) -> EvaluationResults: + """Divide the evaluation result total by a given number and floor.""" + self.last_operation = Operators.floordiv + + if isinstance(x, (int, float)): + self.total = x // self.total + elif isinstance(x, EvaluationResults): + self._collect_rolls(x) + self.total = x.total // self.total + else: + raise TypeError("The supplied type is not valid: " + type(x)) + + return self + + def __mod__(self: EvaluationResults, + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Perform modulus divison on the evaluation total with given value.""" + self.last_operation = Operators.mod + + if isinstance(x, (int, float)): + self.total %= x + elif isinstance(x, EvaluationResults): + self._collect_rolls(x) + self.total %= x.total + else: + raise TypeError("The supplied type is not valid: " + type(x)) + + return self + + def __imod__(self: EvaluationResults, + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Perform modulus divison on the evaluation total with given value.""" + return self.__mod__(x) + + def __rmod__(self: EvaluationResults, + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Perform modulus divison on the evaluation total with given value.""" + self.last_operation = Operators.mod + + if isinstance(x, (int, float)): + self.total = float(x) % self.total + elif isinstance(x, EvaluationResults): + self._collect_rolls(x) + self.total = x.total % self.total + else: + raise TypeError("The supplied type is not valid: " + type(x)) + + return self + + def __pow__(self: EvaluationResults, + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Exponentiate the evaluation results by the given value.""" + self.last_operation = Operators.expo + + if isinstance(x, (int, float)): + self.total **= x + elif isinstance(x, EvaluationResults): + self._collect_rolls(x) + self.total **= x.total + else: + raise TypeError("The supplied type is not valid: " + type(x)) + + return self + + def __ipow__(self: EvaluationResults, + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Exponentiate the evaluation results by the given value.""" + return self.__pow__(x) + + def __rpow__(self: EvaluationResults, + x: Union[int, float, EvaluationResults]) -> EvaluationResults: + """Exponentiate the evaluation results by the given value.""" + self.last_operation = Operators.expo + + if isinstance(x, (int, float)): + self.total = x ** self.total + elif isinstance(x, EvaluationResults): + self._collect_rolls(x) + self.total = x.total ** self.total + else: + raise TypeError("The supplied type is not valid: " + type(x)) + + return self diff --git a/roll/parser/types/rolloption.py b/roll/parser/types/rolloption.py new file mode 100644 index 0000000..d0d8eda --- /dev/null +++ b/roll/parser/types/rolloption.py @@ -0,0 +1,31 @@ +""" +Create options that change the outcomes of dice rolls. + +In order to have the feature that we allow for minimum and maximum rolls, +we need to be able to flag that that is an option within our dice roller. +This feature is required to play some games, including D&D where critical +hits and misses are sometimes used with these options. +""" + +from enum import Enum + + +class RollOption(Enum): + """ + Enum for expressing dice roll behavior. + + Available roll options: + Minimum: The dice will return the lowest possible roll. + e.g. 1d6 -> 1 + + Normal: The dice are rolled normally, a random number + is given in range. + e.g. 1d6 -> (Number between 1 - 6 inclusive) + + Maximum: The dice will return the highest possible roll. + e.g. 1d6 -> 6 + """ + + Minimum = 0 + Normal = 1 + Maximum = 2 diff --git a/roll/parser/types/rollresults.py b/roll/parser/types/rollresults.py new file mode 100644 index 0000000..81c765a --- /dev/null +++ b/roll/parser/types/rollresults.py @@ -0,0 +1,49 @@ +""" +Roll result object for saving information for a group of rolls. + +The RollResult is an object that keeps information regarding a +roll (either a singular die roll or a group of like dice rolled). +""" + +from __future__ import annotations + +from math import ceil +from typing import List, Union + + +class RollResults: + """ + Saves results for a single or group of dice. + + dice: The dice string that was rolled + rolls: A collection of the values of all rolls + + Contains helper methods for handling changes to a given + roll in order to contain the logic for roll collections. + """ + + def __init__(self: RollResults, dice: str, + rolls: List[Union[int, float]]) -> None: + """Initialize a RollResults object.""" + self.dice: str = dice + self.rolls: List[Union[int, float]] = rolls + + def total(self: RollResults) -> Union[int, float]: + """Return the sum of the values of the rolls.""" + return sum(self.rolls) + + def keep_lowest(self: RollResults, + num: Union[int, float] = 1) -> Union[int, float]: + """Keep the lowest num rolls.""" + previous_sum: Union[int, float] = sum(self.rolls) + self.rolls = sorted(self.rolls)[:ceil(num)] + + return previous_sum - sum(self.rolls) + + def keep_highest(self: RollResults, + num: Union[int, float] = 1) -> Union[int, float]: + """Keep the highest num rolls.""" + previous_sum: Union[int, float] = sum(self.rolls) + self.rolls = sorted(self.rolls)[-ceil(num):] + + return previous_sum - sum(self.rolls) diff --git a/roll/roll.py b/roll/roll.py index 8043f0b..e7d939d 100644 --- a/roll/roll.py +++ b/roll/roll.py @@ -15,11 +15,12 @@ """ from typing import Union -from roll.diceparser import DiceParser, EvaluationResults, RollOption +from roll.parser import DiceParser +from roll.parser.types import EvaluationResults, RollOption _DICE_PARSER = DiceParser() -GOOD_CHARS: str = "0123456789d-/*() %+.!^piesqrt" +GOOD_CHARS: str = "0123456789d-/*() %+.!^pPiIeEsSqQrRtTkK" def roll(expression: str = '', @@ -35,12 +36,15 @@ def roll(expression: str = '', if expression.strip() == '': expression = "1d20" - result: EvaluationResults = _DICE_PARSER.evaluate(expression, roll_option) + result: Union[ + int, + float, + EvaluationResults] = _DICE_PARSER.evaluate(expression, roll_option) if verbose: return result - return result['total'] + return result.total if isinstance(result, EvaluationResults) else result if __name__ == "__main__": diff --git a/tests/test_basic_math.py b/tests/test_basic_math.py index fda325d..e70c98f 100644 --- a/tests/test_basic_math.py +++ b/tests/test_basic_math.py @@ -151,7 +151,11 @@ def test_add_explonential(): @pytest.mark.parametrize("equation,result", [ ('pi', math.pi), + ('Pi', math.pi), + ('PI', math.pi), + ('pI', math.pi), ('e', math.e), + ('E', math.e), ]) def test_constants(equation: str, result: Union[int, float]): assert roll(equation) == result @@ -159,6 +163,11 @@ def test_constants(equation: str, result: Union[int, float]): @pytest.mark.parametrize("equation,result", [ ('sqrt 25', 5), + ('Sqrt 25', 5), + ('sqrT 25', 5), + ('sQRt 25', 5), + ('sQRT 25', 5), + ('SQRT 25', 5), # Addition ('2 + sqrt 9', 5), ('sqrt 36 + 7', 13), diff --git a/tests/test_click.py b/tests/test_click.py index d02bd73..0b7beff 100644 --- a/tests/test_click.py +++ b/tests/test_click.py @@ -1,6 +1,6 @@ from click.testing import CliRunner -from roll.click_dice import roll_cli +from roll.click.click_dice import roll_cli runner = CliRunner() diff --git a/tests/test_dice_rolling.py b/tests/test_dice_rolling.py index ff8b508..b0891f5 100644 --- a/tests/test_dice_rolling.py +++ b/tests/test_dice_rolling.py @@ -19,6 +19,9 @@ def test_basic_roll(): ('2.0d100.0', 2, 200), ('100.5d6', 101, 603), ('1.0d6.5', 1, 7), + ('1d0', 0, 0), + ('1d1', 1, 1), + ('1d2', 1, 2), ]) def test_roll(equation: str, range_low: int, range_high: int): assert roll(equation) in range(range_low, range_high + 1) @@ -86,3 +89,29 @@ def test_float_sides1(): def test_float_sides2(): assert roll('2d19.99') in range(2, 41) + + +@pytest.mark.parametrize('equation,range_low,range_high', [ + # Implied number of dice, implied number to keep + ('d6K', 1, 6), + ('d10k', 1, 10), + ('d%k', 1, 100), + + # Verbose dice, implied number to keep + ('10d6K', 1, 6), + ('10d9k', 1, 9), + + # Implied number of dice, verbose number to keep + ('d100K1', 1, 100), + ('d1k5', 1, 1), + + # Verbose dice, verbose keep + ('10d6k1', 1, 6), + ('4d6K3', 3, 18), + + # Multiple keeps + ('10d5k4k3k2k1', 1, 5), + ('10d6K5k4', 4, 24), +]) +def test_keep(equation: str, range_low: int, range_high: int): + assert roll(equation) in range(range_low, range_high + 1) diff --git a/tests/test_dice_rolling_with_math.py b/tests/test_dice_rolling_with_math.py index f88950d..cedbb38 100644 --- a/tests/test_dice_rolling_with_math.py +++ b/tests/test_dice_rolling_with_math.py @@ -1,6 +1,9 @@ +from typing import Union + import pytest from roll import roll +from roll.parser.types import EvaluationResults def test_d6_plus_d8(): @@ -41,6 +44,10 @@ def test_dice_expo1(): ]) def test_dice_sqrt(equation: str, range_low: int, range_high: int): result = roll(equation, verbose=True) - print(result) - total = result['total'] + + if isinstance(result, EvaluationResults): + total: Union[int, float] = result.total + else: + total = result + assert total in range(range_low, range_high + 1) diff --git a/tests/test_evaluationresults.py b/tests/test_evaluationresults.py new file mode 100644 index 0000000..a2d0819 --- /dev/null +++ b/tests/test_evaluationresults.py @@ -0,0 +1,103 @@ +from roll.parser.types import EvaluationResults + + +def test_add() -> None: + er = EvaluationResults() + er = er + 2 + assert er == 2 + + +def test_unary_minus() -> None: + er = EvaluationResults() + er += 5 + er = -er + assert er == -5 + + +def test_add_reversed() -> None: + er = EvaluationResults() + er = 2 + er + assert er == 2 + + +def test_sub() -> None: + er = EvaluationResults() + er = er - 2 + assert er == -2 + + +def test_sub_reversed() -> None: + er = EvaluationResults() + er = 2 - er + assert er == 2 + + +def test_mul() -> None: + er = EvaluationResults() + er += 5 + er *= 2 + er = 7 * er + assert er == 70 + + +def test_mul_reversed() -> None: + er = EvaluationResults() + er += 5 + er = 7 * er + assert er == 35 + + +def test_truediv() -> None: + er = EvaluationResults() + er += 60 + er /= 10 + assert er == 6 + + +def test_truediv_reversed() -> None: + er = EvaluationResults() + er += 8 + er = 24 / er + assert er == 3 + + +def test_floordiv() -> None: + er = EvaluationResults() + er += 120 + er //= 10 + assert er == 12 + + +def test_floordiv_reversed() -> None: + er = EvaluationResults() + er += 14 + er = 112 // er + assert er == 8 + + +def test_modulus() -> None: + er = EvaluationResults() + er += 240 + er %= 9 + assert er == 6 + + +def test_modulus_reversed() -> None: + er = EvaluationResults() + er += 7 + er = 6 % er + assert er == 6 + + +def test_exponential() -> None: + er = EvaluationResults() + er += 2 + er **= 5 + assert er == 32 + + +def test_exponential_reversed() -> None: + er = EvaluationResults() + er += 8 + er = 7 ** er + assert er == 5764801 diff --git a/tests/test_expression_parsing.py b/tests/test_expression_parsing.py index 4e28c14..a198da6 100644 --- a/tests/test_expression_parsing.py +++ b/tests/test_expression_parsing.py @@ -1,7 +1,7 @@ # from roll import roll import pytest -import roll.diceparser as dp +import roll.parser.diceparser as dp from pyparsing import ParseException parser = dp.DiceParser() @@ -21,62 +21,62 @@ def test_interpret_number(): - assert parser.evaluate('42')['total'] == 42 + assert parser.evaluate('42') == 42 def test_interpret_neg_number(): - assert parser.evaluate('-64')['total'] == -64 + assert parser.evaluate('-64') == -64 def test_interpret_number_with_spaces(): - assert parser.evaluate(' 239 ')['total'] == 239 + assert parser.evaluate(' 239 ') == 239 def test_float1(): - assert parser.evaluate('1.0')['total'] == 1.0 + assert parser.evaluate('1.0') == 1.0 def test_float2(): - assert parser.evaluate('3.1415')['total'] == 3.1415 + assert parser.evaluate('3.1415') == 3.1415 def test_float3(): # I don't like this, but that's what pyparsing does. - assert parser.evaluate('9.')['total'] == 9.0 + assert parser.evaluate('9.') == 9.0 def test_float4(): # I don't like this either, but it handles this. - assert parser.evaluate('.098')['total'] == 0.098 + assert parser.evaluate('.098') == 0.098 def test_neg_float1(): - assert parser.evaluate('-2.0')['total'] == -2.0 + assert parser.evaluate('-2.0') == -2.0 def test_neg_float2(): - assert parser.evaluate('-700.')['total'] == -700.0 + assert parser.evaluate('-700.') == -700.0 def test_interpret_dice(): - assert parser.evaluate('d20')['total'] in range(1, 21) + assert parser.evaluate('d20') in range(1, 21) def test_interpret_dice2(): - assert parser.evaluate('1d20')['total'] in range(1, 21) + assert parser.evaluate('1d20') in range(1, 21) def test_interpret_dice_with_spaces(): - assert parser.evaluate(' 2 d 8 ')['total'] in range(2, 17) + assert parser.evaluate(' 2 d 8 ') in range(2, 17) def test_interpret_subtract_negative(): - assert parser.evaluate('1 - -5')['total'] == 6 + assert parser.evaluate('1 - -5') == 6 # This test currently fails on the master branch as well. # def test_unary_negative(): -# assert parser.evaluate('--10')['total'] == 10 +# assert parser.evaluate('--10') == 10 def test_bad_input1():