diff --git a/.gitignore b/.gitignore index f1745af..532dbb3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,187 @@ -build +# Byte-compiled / optimized / DLL files +__pycache__/ +__pycache__ +*.py[cod] +*$py.class -.tox -*.egg-info +# C extensions +*.so -.mypy_cache +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +#lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST -.pytest_cache -env +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec -*.pyc -*.pyo +# Installer logs +pip-log.txt +pip-delete-this-directory.txt -*.tmp -*.swp -*.swo +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.venv*/ +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# ruff +.ruff_cache/ + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# coverage +coverage/ + +# Project root +project_root_directory/ + +# Monkeytype +monkeytype.sqlite3 + +# pycharm +.idea/ +qodana.yaml + +# Trash +*#*.* + +# Typings +typings/ + +# database files +*.db +*.sqlite3 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 465e975..0000000 --- a/.travis.yml +++ /dev/null @@ -1,32 +0,0 @@ -language: python -dist: focal -sudo: required -services: -- docker -python: -- '3.6' -- '3.7' -before_install: -- sudo apt-get install -y libev4 libpcre3-dev -install: -- bash <(curl -sL get.comby.dev) -- pip install -r requirements.dev.txt -- pip install tox-travis==0.12 -# https://travis-ci.community/t/cant-deploy-to-pypi-anymore-pkg-resources-contextualversionconflict-importlib-metadata-0-18/10494/4 -- pip install keyring==21.4.0 -# see #337: flag potential twine issues before attempting to upload to PyPI -- python setup.py sdist -- python setup.py bdist_wheel -- twine check dist/* -script: -- tox -notifications: - email: false -deploy: - - provider: pypi - user: ChrisTimperley - on: - tags: true - python: 3.6 - password: - secure: DRaJkyVgjaZwu1qHMTIn5fP4uwSpd94JFHL8j5/mKzP8dVWNRHXlYVqDkoYxOx//PYSatb76thDX0jETHJ3xqrnRymBick+PN5Ak+mzJ2iP75d5ELHdSJZmNYo2UqTEip8VOlOic6722w53pO37zDqAfuk3Qs70x9nVS0SOz4KFymIfcMjrajruPL7+cSu79mlbn5xuKV9sPl+JM4UF0X/EwPd1KURjIoRXH3BBj98mfjgrx/5kjIbtuh80fPPh9JHgx2T/dMEZMMHANgtfarCXvMJ0eDv9UbWo6u/Y/Pl1/rxS51y7Bu8okudQUXc2N9CZxKJmGpZNnX0tU+vzIRXvuKV13ok1JmLdxdRl+wNOih6jZHiDjPOhyAFrMVrnK2Ho8t+nsp+y9IYLKcC/LOIbB+sE0FpJ/DWwaw2KTGfDnBafnoeb/MO80e1VOgEA+x/0CmTzqUUFpP3X6TKSUVcLAEyoLlvhu948OCuQD6MJmFK1BH261COmGscB4QwI6yWq9qIjsQjF62PApUrokWS+YN48JEIu633D8wzPObv/xaOyRlwhkscu/lSLXXX9UYzGCr56mu9hZSkUheAPN4pkE+4TeCwWGzWKATIABJR8ZEhuve0VjV7SBdWrD2mbK55KKKsB7UXzpe5niRZP6GlkjjDS20gAZdud+UT86HR0= diff --git a/Pipfile b/Pipfile deleted file mode 100644 index f4ae6c9..0000000 --- a/Pipfile +++ /dev/null @@ -1,16 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -comby = {editable = true, path = "."} - -[dev-packages] -tox = "*" -mypy = "*" -flake8 = "*" -pytest = "*" - -[requires] -python_version = "3.9" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 97f1cc8..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,291 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "17c449f2f1bfe247bbd5ebfe5f88ec751650c139694292ae404048b401e9a334" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.9" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "attrs": { - "hashes": [ - "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6", - "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c" - ], - "markers": "python_version >= '3.5'", - "version": "==22.1.0" - }, - "certifi": { - "hashes": [ - "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14", - "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382" - ], - "markers": "python_full_version >= '3.6.0'", - "version": "==2022.9.24" - }, - "charset-normalizer": { - "hashes": [ - "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", - "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" - ], - "markers": "python_full_version >= '3.6.0'", - "version": "==2.1.1" - }, - "comby": { - "editable": true, - "path": "." - }, - "idna": { - "hashes": [ - "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", - "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" - ], - "markers": "python_version >= '3.5'", - "version": "==3.4" - }, - "loguru": { - "hashes": [ - "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c", - "sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3" - ], - "markers": "python_version >= '3.5'", - "version": "==0.6.0" - }, - "requests": { - "hashes": [ - "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", - "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" - ], - "markers": "python_version >= '3.7' and python_version < '4'", - "version": "==2.28.1" - }, - "typing": { - "hashes": [ - "sha256:1187fb9c82fd670d10aa07bbb6cfcfe4bdda42d6fab8d5134f04e8c4d0b71cc9", - "sha256:283d868f5071ab9ad873e5e52268d611e851c870a2ba354193026f2dfb29d8b5" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==3.7.4.3" - }, - "urllib3": { - "hashes": [ - "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc", - "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==1.26.13" - } - }, - "develop": { - "attrs": { - "hashes": [ - "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6", - "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c" - ], - "markers": "python_version >= '3.5'", - "version": "==22.1.0" - }, - "distlib": { - "hashes": [ - "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46", - "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e" - ], - "version": "==0.3.6" - }, - "exceptiongroup": { - "hashes": [ - "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828", - "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec" - ], - "markers": "python_version < '3.11'", - "version": "==1.0.4" - }, - "filelock": { - "hashes": [ - "sha256:7565f628ea56bfcd8e54e42bdc55da899c85c1abfe1b5bcfd147e9188cebb3b2", - "sha256:8df285554452285f79c035efb0c861eb33a4bcfa5b7a137016e32e6a90f9792c" - ], - "markers": "python_version >= '3.7'", - "version": "==3.8.2" - }, - "flake8": { - "hashes": [ - "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7", - "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181" - ], - "index": "pypi", - "version": "==6.0.0" - }, - "iniconfig": { - "hashes": [ - "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", - "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" - ], - "version": "==1.1.1" - }, - "mccabe": { - "hashes": [ - "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", - "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" - ], - "markers": "python_version >= '3.6'", - "version": "==0.7.0" - }, - "mypy": { - "hashes": [ - "sha256:0714258640194d75677e86c786e80ccf294972cc76885d3ebbb560f11db0003d", - "sha256:0c8f3be99e8a8bd403caa8c03be619544bc2c77a7093685dcf308c6b109426c6", - "sha256:0cca5adf694af539aeaa6ac633a7afe9bbd760df9d31be55ab780b77ab5ae8bf", - "sha256:1c8cd4fb70e8584ca1ed5805cbc7c017a3d1a29fb450621089ffed3e99d1857f", - "sha256:1f7d1a520373e2272b10796c3ff721ea1a0712288cafaa95931e66aa15798813", - "sha256:209ee89fbb0deed518605edddd234af80506aec932ad28d73c08f1400ef80a33", - "sha256:26efb2fcc6b67e4d5a55561f39176821d2adf88f2745ddc72751b7890f3194ad", - "sha256:37bd02ebf9d10e05b00d71302d2c2e6ca333e6c2a8584a98c00e038db8121f05", - "sha256:3a700330b567114b673cf8ee7388e949f843b356a73b5ab22dd7cff4742a5297", - "sha256:3c0165ba8f354a6d9881809ef29f1a9318a236a6d81c690094c5df32107bde06", - "sha256:3d80e36b7d7a9259b740be6d8d906221789b0d836201af4234093cae89ced0cd", - "sha256:4175593dc25d9da12f7de8de873a33f9b2b8bdb4e827a7cae952e5b1a342e243", - "sha256:4307270436fd7694b41f913eb09210faff27ea4979ecbcd849e57d2da2f65305", - "sha256:5e80e758243b97b618cdf22004beb09e8a2de1af481382e4d84bc52152d1c476", - "sha256:641411733b127c3e0dab94c45af15fea99e4468f99ac88b39efb1ad677da5711", - "sha256:652b651d42f155033a1967739788c436491b577b6a44e4c39fb340d0ee7f0d70", - "sha256:6d7464bac72a85cb3491c7e92b5b62f3dcccb8af26826257760a552a5e244aa5", - "sha256:74e259b5c19f70d35fcc1ad3d56499065c601dfe94ff67ae48b85596b9ec1461", - "sha256:7d17e0a9707d0772f4a7b878f04b4fd11f6f5bcb9b3813975a9b13c9332153ab", - "sha256:901c2c269c616e6cb0998b33d4adbb4a6af0ac4ce5cd078afd7bc95830e62c1c", - "sha256:98e781cd35c0acf33eb0295e8b9c55cdbef64fcb35f6d3aa2186f289bed6e80d", - "sha256:a12c56bf73cdab116df96e4ff39610b92a348cc99a1307e1da3c3768bbb5b135", - "sha256:ac6e503823143464538efda0e8e356d871557ef60ccd38f8824a4257acc18d93", - "sha256:b8472f736a5bfb159a5e36740847808f6f5b659960115ff29c7cecec1741c648", - "sha256:b86ce2c1866a748c0f6faca5232059f881cda6dda2a893b9a8373353cfe3715a", - "sha256:bc9ec663ed6c8f15f4ae9d3c04c989b744436c16d26580eaa760ae9dd5d662eb", - "sha256:c9166b3f81a10cdf9b49f2d594b21b31adadb3d5e9db9b834866c3258b695be3", - "sha256:d13674f3fb73805ba0c45eb6c0c3053d218aa1f7abead6e446d474529aafc372", - "sha256:de32edc9b0a7e67c2775e574cb061a537660e51210fbf6006b0b36ea695ae9bb", - "sha256:e62ebaad93be3ad1a828a11e90f0e76f15449371ffeecca4a0a0b9adc99abcef" - ], - "index": "pypi", - "version": "==0.991" - }, - "mypy-extensions": { - "hashes": [ - "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", - "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" - ], - "version": "==0.4.3" - }, - "packaging": { - "hashes": [ - "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", - "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" - ], - "markers": "python_version >= '3.6'", - "version": "==21.3" - }, - "platformdirs": { - "hashes": [ - "sha256:1a89a12377800c81983db6be069ec068eee989748799b946cce2a6e80dcc54ca", - "sha256:b46ffafa316e6b83b47489d240ce17173f123a9b9c83282141c3daf26ad9ac2e" - ], - "markers": "python_version >= '3.7'", - "version": "==2.6.0" - }, - "pluggy": { - "hashes": [ - "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", - "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" - ], - "markers": "python_version >= '3.6'", - "version": "==1.0.0" - }, - "py": { - "hashes": [ - "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", - "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.11.0" - }, - "pycodestyle": { - "hashes": [ - "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053", - "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610" - ], - "markers": "python_version >= '3.6'", - "version": "==2.10.0" - }, - "pyflakes": { - "hashes": [ - "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf", - "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd" - ], - "markers": "python_version >= '3.6'", - "version": "==3.0.1" - }, - "pyparsing": { - "hashes": [ - "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", - "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc" - ], - "markers": "python_full_version >= '3.6.8'", - "version": "==3.0.9" - }, - "pytest": { - "hashes": [ - "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71", - "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59" - ], - "index": "pypi", - "version": "==7.2.0" - }, - "six": { - "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" - }, - "tomli": { - "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" - ], - "markers": "python_version < '3.11'", - "version": "==2.0.1" - }, - "tox": { - "hashes": [ - "sha256:b2a920e35a668cc06942ffd1cf3a4fb221a4d909ca72191fb6d84b0b18a7be04", - "sha256:f52ca66eae115fcfef0e77ef81fd107133d295c97c52df337adedb8dfac6ab84" - ], - "index": "pypi", - "version": "==3.27.1" - }, - "typing-extensions": { - "hashes": [ - "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa", - "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e" - ], - "markers": "python_version >= '3.7'", - "version": "==4.4.0" - }, - "virtualenv": { - "hashes": [ - "sha256:ce3b1684d6e1a20a3e5ed36795a97dfc6af29bc3970ca8dab93e11ac6094b3c4", - "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058" - ], - "markers": "python_version >= '3.6'", - "version": "==20.17.1" - } - } -} diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..f0b6c71 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,240 @@ +"""My noxfile.py.""" +from __future__ import annotations + +import re +from pathlib import Path +from tempfile import NamedTemporaryFile + +import nox +from nox_poetry import Session, session + +__all__ = [ + "fmt", + "fmt_check", + "lint", + "lint_fix", + "type_check", + "test", +] + + +nox.options.error_on_external_run = True +nox.options.reuse_existing_virtualenvs = True +nox.options.sessions = [ + "fmt_check", + "lint", + "type_check", + "test", +] + +FILES: list[str] = [ + "src/comby/binary.py", + "src/comby/core.py", + "src/comby/exceptions.py", + "src/comby/interface.py", + "tests", + "noxfile.py", +] + + +@nox.session(venv_backend="none") +def test(project: Session) -> None: + """ + Run project's tests. + + :param project: + :type project: Session + """ + _ = project.run( + # "-m", + "pytest", + "--cov=src", + "--cov-report=html", + "--cov-report=term", + "tests", + # *project.posargs, + ) + + +@nox.session(venv_backend="none") +@nox.parametrize("file_path", FILES) +def fmt(project: Session, file_path: str) -> None: + """ + Run the project's formatting tools. + + :param project: + :type project: Session + :param file_path: + :type file_path: str + """ + _ = project.run( + "pyment", + "--first-line", + "False", + "--output", + "reST", + "--ignore-private", + "False", + "--init2class", + "--skip-empty", + "--write", + file_path, + ) + + _ = project.run( + "ruff", + "check", + file_path, + "--select", + "I", + "--fix", + ) + _ = project.run( + "black", + file_path, + ) + + +@nox.session(venv_backend="none") +@nox.parametrize("file_path", FILES) +def fmt_check(project: Session, file_path: str) -> None: + """ + Run the project's formatter checking tools. + + :param project: + :type project: Session + :param file_path: + :type file_path: str + """ + _ = project.run( + "ruff", + "check", + file_path, + "--select", + "I", + ) + _ = project.run( + "black", + "--check", + file_path, + ) + + +@session(venv_backend="none") +@nox.parametrize("file_path", FILES) +def lint(project: Session, file_path: str) -> None: + """ + Run the project's linter tools. + + :param project: + :type project: Session + :param file_path: + :type file_path: str + """ + _ = project.run( + "ruff", + "check", + file_path, + ) + _ = project.run("flake8", file_path) + + +@session(venv_backend="none") +@nox.parametrize("file_path", FILES) +def lint_fix(project: Session, file_path: str) -> None: + """ + Run the project's lint fixing tools. + + :param project: + :type project: Session + :param file_path: + :type file_path: str + """ + _ = project.run( + "ruff", + "check", + file_path, + "--fix", + ) + + +@session(venv_backend="none") +def update_typing(project: Session) -> None: + """ + Run the project's type checking tools. + + :param project: + :type project: Session + """ + files = get_file_paths(include_pattern=r"\.py$", exclude_pattern=r"^\..*$") + for ext in files: + _ = project.run("python-typing-update", str(ext)) + + +@session(venv_backend="none") +@nox.parametrize("file_path", FILES) +def type_check(project: Session, file_path: str) -> None: + """ + Run the project's type checking tools. + + :param project: + :type project: Session + :param file_path: + :type file_path: str + """ + _ = project.run("mypy", file_path) + + +doc_env = {"PYTHONPATH": "src"} + + +@session(reuse_venv=False) +def licenses(project: Session) -> None: + """ + Run the project's license tools. + + :param project: + :type project: Session + """ + # Generate a unique temporary file name. Poetry cannot write to the temp file directly on + # Windows, so only use the name and allow Poetry to re-create it. + with NamedTemporaryFile() as t: + requirements_file = Path(t.name) + + # Install dependencies without installing the package itself: + # https://github.com/cjolowicz/nox-poetry/issues/680 + _ = project.run_always( + "poetry", + "export", + "--without-hashes", + f"--output={requirements_file}", + external=True, + ) + project.install("pip-licenses", "-r", str(requirements_file)) + _ = project.run("pip-licenses", *project.posargs) + requirements_file.unlink() + + +# Note: This reuse_venv does not yet have affect due to: +# https://github.com/wntrblm/nox/issues/488 +def get_file_paths(include_pattern: str = "", exclude_pattern: str = "") -> list[str]: + """ + Get a list of file paths that match the given include and exclude patterns. + + :param include_pattern: (Default value = "") + :type include_pattern: str + :param exclude_pattern: (Default value = "") + :type exclude_pattern: str + """ + base_path = Path() + file_paths = [] + include_regex = re.compile(include_pattern) + exclude_regex = re.compile(exclude_pattern) + for path in base_path.rglob("*"): + path_str = str(path) + if exclude_regex.search(path_str): + continue + if not include_regex.search(path_str): + continue + file_paths.append(path_str) + return file_paths diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..946a428 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,260 @@ +[tool.poetry] +name = "comby" +version = "0.0.4" +description = "Lightweight language-independent syntax rewriting." +authors = ["Christopher Timperley "] +license = "MIT" +readme = "README.rst" +repository = "https://github.com/ChrisTimperley/comby-python" +classifiers = [ + "Natural Language :: English", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Typing :: Typed", + # Include this classifier to prevent accidently publishing private code to PyPI. + # https://pypi.org/classifiers/ + "Private :: Do Not Upload", +] + +[tool.poetry.dependencies] +python = "^3.9, <3.13" +loguru = "^0.7.2" +attrs = "^23.1.0" + +[tool.poetry.group.nox.dependencies] +nox-poetry = ">=1.0.3" + +[tool.poetry.group.test.dependencies] +pytest = "*" +pytest-cov = "*" +pytest-randomly = "*" + +[tool.poetry.group.type_check.dependencies] +mypy = "*" +typeguard = ">=4.1.5" +pydantic = ">=2.4.2" +[tool.poetry.group.lint.dependencies] +ruff = "*" +Flake8-pyproject = "*" +flake8 = "*" +pylint = "*" + +dlint = ">=0.14.1" +flake8-aaa = ">=0.17.0" +flake8-absolute-import = ">=1.0.0.2" +flake8-annotations = ">=3.0.1" +flake8-annotations-complexity = ">=0.0.8" +flake8-annotations-coverage = ">=0.0.6" +flake8-black = ">=0.3.6" +flake8-broken-line = ">=1.0.0" +flake8-class-attributes-order = ">=0.1.3" +flake8-cognitive-complexity = ">=0.1.0" +flake8-cohesion = ">=1.0.1" +flake8-comprehensions = ">=3.14.0" +flake8-deprecated = ">=2.1.0" +flake8-docstrings = ">=1.7.0" +flake8-dunder-all = ">=0.3.0" +flake8-eradicate = ">=1.5.0" +flake8-expression-complexity = ">=0.0.11" +flake8-fixme = ">=1.1.1" +flake8-functions = ">=0.0.8" +flake8-functions-names = ">=0.4.0" +flake8-future-annotations = ">=1.1.0" +flake8-markdown = ">=0.5.0" +flake8-mutable = ">=1.2.0" +flake8-newspaper-style = ">=0.4.3" +flake8-no-implicit-concat = ">=0.3.4" +flake8-print = ">=5.0.0" +flake8-printf-formatting = ">=1.1.2" +flake8-pylint = ">=0.2.1" +flake8-pytest-style = ">=1.7.2" +flake8-quotes = ">=3.3.2" +flake8-rst-docstrings = ">=0.3.0" +flake8-scrapy = ">=0.0.1" +flake8-secure-coding-standard = ">=1.4.0" +flake8-string-format = ">=0.3.0" +flake8-todos = ">=0.3.0" +flake8-tuple = ">=0.4.1" +flake8-typing-imports = ">=1.15.0" +flake8-unused-arguments = ">=0.0.13" +flake8-use-fstring = ">=1.4" +flake8-use-pathlib = ">=0.3.0" +flake8-variables-names = ">=0.0.6" +flake8-warnings = ">=0.4.1" +mccabe = ">=0.7.0" +tryceratops = ">=2.3.2" +types-flake8-2020 = ">=1.8.0.1" +types-flake8-builtins = ">=2.1.0.3" + +python-typing-update = ">=0.6.0" + + +[tool.poetry.group.fmt.dependencies] +black = "*" +docformatter = ">=1.7.5" +pyment = { git = "https://github.com/dadadel/pyment.git" } + +[tool.poetry.group.docs.dependencies] +mkdocs-material = "*" +mkdocs-htmlproofer-plugin = "*" +mkdocstrings = { version = "*", extras = ["python"] } +mkdocs-gen-files = "*" +mkdocs-literate-nav = "*" + +[tool.mypy] +ignore_missing_imports = true +strict = true + +[tool.ruff] +line-length = 100 +target-version = "py38" +preview = true +extend-select = [ + "F", # Pyflakes + "E", # pycodestyle + "W", # Warning + "E", # Error + "C90", # mccabe + "I", # isort + "N", # pep8-naming + "D", # pydocstyle + "UP", # pyupgrade + "YTT", # flake8-2020 + "ANN", # flake8-annotations + "ASYNC", # flake8-async + "S", # flake8-bandit + "BLE", # flake8-blind-except + "FBT", # flake8-boolean-trap + "B", # flake8-bugbear + "A", # flake8-builtins + # "COM", # flake8-commas + # "CPY", # flake8-copyright + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "T10", # flake8-debugger + "DJ", # flake8-django + "EM", # flake8-errmsg + "EXE", # flake8-executable + "FA", # flake8-future-annotations + "ISC", # flake8-implicit-str-concat + "ICN", # flake8-import-conventions + "G", # flake8-logging-format + "INP", # flake8-no-pep420 + "PIE", # flake8-pie + "T20", # flake8-print + "PYI", # flake8-pyi + "PT", # flake8-pytest-style + "Q", # flake8-quotes + "RSE", # flake8-raise + "RET", # flake8-return + "SLF", # flake8-self + "SLOT", # flake8-slots + "SIM", # flake8-simplify + "TID", # flake8-tidy-imports + "TCH", # flake8-type-checking + "INT", # flake8-gettext + "ARG", # flake8-unused-arguments + "PTH", # flake8-use-pathlib + "TD", # flake8-todos + "FIX", #flake8-fixme + 'ERA', # eradicate + 'PD', # pandas-vet + 'PGH', # pygrep-hooks + 'PL', # Pylint + 'TRY', # tryceratops + # 'FLY', # flynt + 'NPY', # NumPy-specific rules + 'AIR', # Airflow + 'PERF', # Perflint + 'FURB', # refurb + 'LOG', #flake8-logging + 'RUF', # Ruff-specific rules +] +extend-ignore = [ + "RUF005", + "D200", # One-line docstring should fit on one line with quotes + "D212", # Multi-line docstring summary should start at the first line + "D410", # Missing blank line after section + "D411", # Missing blank line before section + "Q000", # Double quotes found but single quotes preferred +] +src = ["src"] + +[tool.ruff.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.flake8-bugbear] +extend-immutable-calls = ["typer.Argument"] + +[tool.ruff.isort] +force-sort-within-sections = false +split-on-trailing-comma = true + +[tool.ruff.pydocstyle] +convention = "google" + + +[tool.ruff.per-file-ignores] +"docs/gen_ref_pages.py" = ["INP001", "PGH003"] +"src/comby/core.py" = ["A003"] +"src/comby/binary.py" = ["PLR0913", "S602"] +"src/comby/interface.py" = ["PLR0913"] +"**/test_*.py" = ["S101", "E501"] +"noxfile.py" = ["PIE800"] +[tool.flake8] +max-line-length = 100 +docstring-convention = "google" + +extend-exclude = ["venv", ".venv", "build"] +skip-overridden-methods = true +unused-arguments-ignore-abstract-functions = true +unused-arguments-ignore-overload-functions = true +unused-arguments-ignore-override-functions = true +unused-arguments-ignore-stub-functions = true +unused-arguments-ignore-variadic-names = true +unused-arguments-ignore-lambdas = true + +ignore = [ + "D200", # One-line docstring should fit on one line with quotes + "D212", # Multi-line docstring summary should start at the first line + "D410", # Missing blank line after section + "D411", # Missing blank line before section + "FNE002", # The method has a @property decorator, but has a verb in it's name (version + "RST203", # Definition list ends without a blank line; unexpected unindent. + "RST213", # Inline emphasis start-string without end-string. + "TYP001", # guard import by `if False: # TYPE_CHECKING`: override + "PLW301", # Unnecessary ellipsis constant (unnecessary-ellipsis) + "Q000", # Double quotes found but single quotes preferred +] + +per-file-ignores = [ + "test_*.py: D103 DALL000 E501 PLC301 PLC116 PLW621 S101 SCS108", + "core.py: PLR903 VNE003", + "binary.py: CFQ002 PLR913 DUO116 SCS103 PLR914 CCR001 H601", + "interface.py: CFQ002 PLR913", + "noxfile.py: PIE800", +] +[tool.pylint] +max-line-length = 100 + +[tool.black] +line-length = 100 +target-version = ["py39", "py310", "py311", "py312"] + +[tool.pytest.ini_options] +addopts = ["--strict-config", "--strict-markers"] +xfail_strict = true +filterwarnings = [ + "error", +] + +[tool.coverage.run] +branch = true diff --git a/requirements.dev.txt b/requirements.dev.txt deleted file mode 100644 index 8375f25..0000000 --- a/requirements.dev.txt +++ /dev/null @@ -1,6 +0,0 @@ -flake8==3.8.4 -mypy==0.790 -pytest==6.1.2 -ptpython==3.0.7 -tox==3.20.1 -twine==3.1.1 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 3ef259b..0000000 --- a/setup.cfg +++ /dev/null @@ -1,60 +0,0 @@ -[metadata] -name = comby -version = 0.0.4 -author = Christopher Timperley -author-email = christimperley@googlemail.com -url = https://github.com/ChrisTimperley/comby-python -description = Lightweight language-independent syntax rewriting. -long-description = file: README.rst, LICENSE -long-descripion-content-type = text/reStructuredText -keywords = rewrite, syntax, comby, transformation -license = MIT -classifiers = - Natural Language :: English - Intended Audience :: Developers - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - -[options] -python_requires = >= 3.6 -install_requires = - attrs >= 19.2.0 - loguru >= 0.4 - requests ~= 2.0 - typing >= 0.4 -package_dir = - =src -packages = find: - -[options.packages.find] -where = src - -[flake8] -per-file-ignores = - src/comby/__init__.py:F401 - -[mypy] -ignore_missing_imports = True - -[aliases] -test = pytest - -[tool:pytest] -addopts = -rx -v - -[tox:tox] -envlist = py37, py38 - -[testenv] -deps = - mypy - flake8 - pytest -commands = - mypy src - flake8 src - pytest diff --git a/setup.py b/setup.py deleted file mode 100644 index cb89f27..0000000 --- a/setup.py +++ /dev/null @@ -1,2 +0,0 @@ -# -*- coding: utf-8 -*- -import setuptools; setuptools.setup() diff --git a/src/comby/__init__.py b/src/comby/__init__.py index e9e88b1..a6cf228 100644 --- a/src/comby/__init__.py +++ b/src/comby/__init__.py @@ -1,16 +1,28 @@ -# -*- coding: utf-8 -*- """ -This package provides a set of Python bindings for Comby, a general purpose -tool for matching and rewriting code in arbitrary languages. +Python bindings for Comby. + +This package provides a set of Python bindings for Comby, a general purpose tool for +matching and rewriting code in arbitrary languages. + """ from loguru import logger as _logger -from . import exceptions -from .core import Location, LocationRange, BoundTerm, Environment, Match -from .binary import CombyBinary +from comby import exceptions +from comby.binary import CombyBinary +from comby.core import BoundTerm, Environment, Location, LocationRange, Match Comby = CombyBinary -__version__ = '0.0.2' +__version__ = "0.0.2" + +_logger.disable("comby") -_logger.disable('comby') +__all__ = [ + "exceptions", + "Location", + "LocationRange", + "BoundTerm", + "Environment", + "Match", + "CombyBinary", +] diff --git a/src/comby/binary.py b/src/comby/binary.py index 6147058..09979ce 100644 --- a/src/comby/binary.py +++ b/src/comby/binary.py @@ -1,126 +1,232 @@ -# -*- coding: utf-8 -*- -""" -This module implements an binary-based interface for communicating with Comby. -""" -__all__ = ('CombyBinary',) +"""This module provides access to the Comby api.""" +from __future__ import annotations + +__all__ = ("CombyBinary",) -from typing import Iterator, Optional, Dict import json import os -import subprocess import shlex +import subprocess +from typing import TYPE_CHECKING, override -from loguru import logger import attr +from attrs import field +from loguru import logger +from typeguard import typechecked + +from comby.core import ConfigJson, ConfigMatch, Environment, LocationRange, Match +from comby.exceptions import CombyBinaryError +from comby.interface import CombyInterface + +if TYPE_CHECKING: + from typing import Iterator, Sequence -from .core import Match -from .exceptions import CombyBinaryError -from .interface import CombyInterface _LINE_SEPARATOR = os.linesep _LINE_SEPARATOR_LENGTH = len(_LINE_SEPARATOR) -@attr.s(frozen=True, slots=True) +@typechecked +@attr.s(auto_attribs=True, frozen=True, slots=True) class CombyBinary(CombyInterface): - """Provides an interface to the Comby binary. - - Attributes - ---------- - location: str - The location of the Comby binary that should be used. - language: str - The default language that should be assumed when dealing with source - text where no specific language is specified. - version: str - The version of Comby that is used by this binary. """ - location = attr.ib(type=str, default='comby') - language = attr.ib(type=str, default='.c') + Provides an interface to the Comby binary. + + location: The location of the Comby binary that should be used. + language: The default language that should be assumed when dealing with source text where no + specific language is specified. + + """ + + location: str = field(init=False, default="comby") + language: str = field(init=False, default=".c") @property - def version(self) -> str: - return self.call('-version').strip() - - def call(self, args: str, text: Optional[str] = None) -> str: - """Calls the Comby binary. - - Arguments - --------- - args: str - the arguments that should be supplied to the binary. - text: Optional[str] - the optional input text that should be supplied to the binary. - - Returns - ------- - str - the output of the execution. - - Raises - ------ - CombyBinaryError - if the binary produces a non-zero return code. + @override + def version(self: CombyInterface) -> str: """ - logger.debug(f'calling comby with args: {args}') + Retrieve the version number of the Comby binary. + + This method accesses the version information of the Comby binary and returns it as a string. + If the object is not a CombyBinary instance, it raises a TypeError. + + + :returns: The version number of the Comby binary. + + :rtype: str + :raises TypeError: If the object is not an instance of CombyBinary. + + """ + if not isinstance(self, CombyBinary): + raise TypeError + return self.call("-version").strip() + + def call(self: CombyBinary, args: str, text: str | None = None) -> str: + """ + Call the Comby binary. + + :param args: the arguments that should be supplied to the binary. + :type args: str + :param text: (Default value = None) the arguments that should be supplied to the binary. + :type text: str | None + :raises CombyBinaryError: if the binary produces a non-zero return code. + + """ + logger.debug(f"calling comby with args: {args}") input_ = None if text: - input_ = text.encode('utf8') - logger.debug(f'supplying input text: {text}') - - cmd_s = f'{self.location} {args}' - p = subprocess.run(cmd_s, - shell=True, - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - input=input_) - - err = p.stderr.decode('utf8') - out = p.stdout.decode('utf8') - logger.debug(f'stderr: {err}') - logger.debug(f'stdout: {out}') - - if p.returncode != 0: - raise CombyBinaryError(p.returncode, err) + input_ = text.encode("utf8") + logger.debug(f"supplying input text: {text}") + + cmd_s = f"{self.location} {args}" + subprocess_result = subprocess.run( + cmd_s, + shell=True, + capture_output=True, + input=input_, + check=True, + ) + + err = subprocess_result.stderr.decode("utf8") + out = subprocess_result.stdout.decode("utf8") + logger.debug(f"stderr: {err}") + logger.debug(f"stdout: {out}") + + if subprocess_result.returncode != 0: + raise CombyBinaryError(code=subprocess_result.returncode, message=err) return out - def matches(self, - source: str, - template: str, - *, - language: Optional[str] = None - ) -> Iterator[Match]: - logger.info(f"finding matches of template [{template}] " - f"in source: {source}") + @override + def matches( + self: CombyInterface, + source: str, + template: str, + *, + language: str | None = None, + ) -> Iterator[Match]: + """ + List of matches of a given template within a source text. + + :param source: the source text to search for matches. + :type source: str + :param template: the comby template to use to search for matches. + :type template: str + :param *: + :param language: (Default value = None) the language to use for the matcher. + :type language: str | None + """ + if not isinstance(self, CombyBinary): + raise TypeError + match_log: str = " ".join( + [ + "finding matches of template", + template, + " in source:", + source, + ], + ) + + logger.info(match_log) if language: logger.info(f"using language override: {language}") else: language = self.language logger.info(f"using default language: {language}") - cmd = ('-stdin', '-json-lines', '-match-only', - '-matcher', shlex.quote(language), - shlex.quote(template), 'foo') - cmd_s = ' '.join(cmd) + cmd = ( + "-stdin", + "-json-lines", + "-match-only", + "-matcher", + shlex.quote(language), + shlex.quote(template), + "foo", + ) + cmd_s: str = " ".join(cmd) + + jsn: ConfigJson | None = json.loads(self.call(cmd_s, text=source) or "null") - jsn = json.loads(self.call(cmd_s, text=source) or 'null') if jsn is not None: - jsn = jsn['matches'] - for jsn_match in jsn: - yield Match.from_dict(jsn_match) - - def rewrite(self, - source: str, - match: str, - rewrite: str, - args: Optional[Dict[str, str]] = None, - *, - diff: bool = False, - language: Optional[str] = None - ) -> str: - logger.info(f"performing rewriting of source ({source}) using match " - f"template ({match}), rewrite template ({rewrite}), " - f"and arguments ({repr(args)})") + jsn_matches: list[ConfigMatch] | None = jsn.get("matches") + if jsn_matches is None: + raise TypeError + jsn_match: ConfigMatch + for jsn_match in jsn_matches: + matched_raw: str | None = jsn_match.get("matched") + if matched_raw is None: + raise TypeError + matched: str = matched_raw + + location_raw: dict[str, dict[str, int]] | None = jsn_match.get("range") + if location_raw is None: + raise TypeError + location: LocationRange = LocationRange.from_dict(location_raw) + + environment_raw: Sequence[ + dict[str, str | dict[str, dict[str, int]]] + ] | None = jsn_match.get("environment") + if environment_raw is None: + raise TypeError + environment: Environment = Environment.from_dict( + environment_raw + ) # Assuming Environment.from_dict exists + + # Create a new dictionary with converted types + converted_jsn_match: dict[str, str | LocationRange | Environment] = { + "matched": matched, + "location": location, + "environment": environment, + } + + yield Match.from_dict(converted_jsn_match) + + @override + def rewrite( + self: CombyInterface, + source: str, + match: str, + rewrite: str, + args: dict[str, str] | None = None, + *, + diff: bool = False, + language: str | None = None, + match_newline_at_toplevel: bool = False, + ) -> str: + """ + Rewrite all matches in source with rewrite template. + + :param source: the source text to rewrite. + :type source: str + :param match: the comby template to use to search for matches. + :type match: str + :param rewrite: the comby template to use to rewrite matches. + :type rewrite: str + :param args: (Default value = None) + :type args: dict[str, str] | None + :param *: + :param diff: (Default value = False) + :type diff: bool + :param language: (Default value = None) the language to use for the matcher. + :type language: str | None + :param match_newline_at_toplevel: (Default value = False) whether to match newline + at toplevel. + :type match_newline_at_toplevel: bool + """ + if not isinstance(self, CombyBinary): + raise TypeError + rewrite_log = " ".join( + [ + "performing rewriting of source", + source, + "using match template", + match, + "rewrite template", + rewrite, + "and arguments", + f"{args!r}", + ], + ) + logger.info(rewrite_log) if language: logger.info(f"using language override: {language}") else: @@ -130,38 +236,60 @@ def rewrite(self, if args is None: args = {} if args: - raise NotImplementedError("args are not currently supported") + raise NotImplementedError - cmd = ['-stdin', shlex.quote(match), shlex.quote(rewrite)] - cmd += ['-matcher', shlex.quote(language)] - cmd += ['-diff' if diff else '-stdout'] - cmd_s = ' '.join(cmd) + cmd = ["-stdin", shlex.quote(match), shlex.quote(rewrite)] + cmd += ["-matcher", shlex.quote(language)] + cmd += ["-diff" if diff else "-stdout"] + if match_newline_at_toplevel: + cmd += ["-match-newline-at-toplevel"] + cmd_s = " ".join(cmd) return self.call(cmd_s, text=source) - def substitute(self, - template: str, - args: Dict[str, str], - *, - language: Optional[str] = None - ) -> str: - logger.info(f"performing substitution of arguments ({args}) " - f"into template ({template})") + @override + def substitute( + self: CombyInterface, + template: str, + args: dict[str, str], + *, + language: str | None = None, + ) -> str: + """ + Substitutes a set of terms into a given template. + + :param template: + :type template: str + :param args: + :type args: dict[str, str] + :param *: + :param language: (Default value = None) the language to use for the matcher. + :type language: str | None + """ + if not isinstance(self, CombyBinary): + raise TypeError + logger.info(f"performing substitution of arguments ({args}) into template ({template})") if language: logger.info(f"using language override: {language}") else: language = self.language logger.info(f"using default language: {language}") - substitutions = [{'variable': variable, 'value': value} - for (variable, value) in args.items()] + substitutions = [ + {"variable": variable, "value": value} for (variable, value) in args.items() + ] encoded_substitutions = shlex.quote(json.dumps(substitutions)) logger.debug(f"encoded substitutions: {encoded_substitutions}") - cmd = ('IGNORE_MATCHED_TEMPLATE', shlex.quote(template), - '-matcher', shlex.quote(language), - '-substitute-only', encoded_substitutions) - cmd_string = ' '.join(cmd) + cmd = ( + "IGNORE_MATCHED_TEMPLATE", + shlex.quote(template), + "-matcher", + shlex.quote(language), + "-substitute-only", + encoded_substitutions, + ) + cmd_string = " ".join(cmd) result = self.call(cmd_string) # remove any trailing line separator diff --git a/src/comby/core.py b/src/comby/core.py index 5389fa7..70c2bae 100644 --- a/src/comby/core.py +++ b/src/comby/core.py @@ -1,162 +1,309 @@ -# -*- coding: utf-8 -*- -""" -This module defines several common data structures for describing code -transformations, source locations, environments, matches, and templates. -""" -__all__ = ( - 'Location', - 'LocationRange', - 'BoundTerm', - 'Environment', - 'Match' -) - -from typing import Dict, Iterator, Any, Mapping, Sequence +"""This module provides the core data structures.""" +from __future__ import annotations + +__all__ = ("Location", "LocationRange", "BoundTerm", "Environment", "Match") + +from typing import Iterator, Mapping, Sequence, TypedDict, override import attr +from typeguard import typechecked + +# Create a new Location object. -@attr.s(frozen=True, slots=True, str=False) +@typechecked +@attr.s(auto_attribs=True, frozen=True, slots=True) class Location: """ - Represents the location of a single character within a source text by its - zero-indexed line and column numbers. + Represent a location in source code, with line, column, and byte offset. + + This is an immutable data class. + + """ - Attributes - ---------- line: int - Zero-indexed line number. col: int - Zero-indexed column number. offset: int - Zero-indexed character offset. - """ - line = attr.ib(type=int) - col = attr.ib(type=int) - offset = attr.ib(type=int) @staticmethod - def from_dict(d: Dict[str, Any]) -> 'Location': - return Location(line=d['line'], - col=d['column'], - offset=d['offset']) + def from_dict(location_dict: dict[str, int]) -> Location: + """ + Create a Location object from a dictionary. + + :param location_dict: A dictionary containing the keys "line", "column", and "offset". + :type location_dict: dict[str, int] + :returns: A Location object. + :rtype: Location + """ + return Location( + line=location_dict["line"], + col=location_dict["column"], + offset=location_dict["offset"], + ) -@attr.s(frozen=True, slots=True, str=False) +@typechecked +@attr.s(auto_attribs=True, frozen=True, slots=True, str=False) class LocationRange: - """ - Represents a contiguous range of locations within a given source text as a - (non-inclusive) range of character positions. + """Represent a range of locations in a source code.""" - Attributes - ---------- start: Location - The start of the range. stop: Location - The (non-inclusive) end of the range. - """ - start = attr.ib(type=Location) - stop = attr.ib(type=Location) @staticmethod - def from_dict(d: Dict[str, Any]) -> 'LocationRange': - return LocationRange(Location.from_dict(d['start']), - Location.from_dict(d['end'])) + def from_dict(location_range_dict: dict[str, dict[str, int]]) -> LocationRange: + """ + Create a LocationRange object from a dictionary representation. + + :param location_range_dict: + :type location_range_dict: dict[str, dict[str, int]] + :returns: The created LocationRange object. + :rtype: LocationRange + """ + return LocationRange( + start=Location.from_dict(location_range_dict["start"]), + stop=Location.from_dict( + location_range_dict["end"], + ), + ) -@attr.s(frozen=True, slots=True) +@typechecked +@attr.s(auto_attribs=True, frozen=True, slots=True) class BoundTerm: - """Represents a binding of a named term to a fragment of source code. + """Represent a binding of a named term to a fragment of source code.""" - Attributes - ---------- term: str - The name of the term. location: LocationRange - The location range to which the term is bound. fragment: str - The source code to which the term is bound. - """ - term = attr.ib(type=str) - location = attr.ib(type=LocationRange) - fragment = attr.ib(type=str) @staticmethod - def from_dict(d: Dict[str, Any]) -> 'BoundTerm': - """Constructs a bound term from a dictionary-based description.""" - return BoundTerm(term=d['variable'], - location=LocationRange.from_dict(d['range']), - fragment=d['value']) + def from_dict(bound_term_dict: dict[str, str | dict[str, dict[str, int]]]) -> BoundTerm: + """ + Create a BoundTerm object from a dictionary representation. + + :param bound_term_dict: + :type bound_term_dict: dict[str, str | dict[str, dict[str, int]]] + """ + term = bound_term_dict["variable"] + location = bound_term_dict["range"] + fragment = bound_term_dict["value"] + + if not isinstance(term, str): + raise TypeError + + if not isinstance(location, dict): + raise TypeError + + if not isinstance(fragment, str): + raise TypeError + + return BoundTerm( + term=term, + location=LocationRange.from_dict(location), + fragment=fragment, + ) +@typechecked class Environment(Mapping[str, BoundTerm]): - @staticmethod - def from_dict(ts: Sequence[Dict[str, Any]]) -> 'Environment': - return Environment([BoundTerm.from_dict(bt) for bt in ts]) + """ + The `Environment` class represents a mapping of string keys to `BoundTerm` values. + + It provides methods to access and manipulate the mappings. + + """ + + def __init__(self: Environment, bindings: Sequence[BoundTerm]) -> None: + """ + Initialize an instance of the Environment class. - def __init__(self, bindings: Sequence[BoundTerm]) -> None: + :param bindings: + :type bindings: Sequence[BoundTerm] + """ + super().__init__() self.__bindings = {b.term: b for b in bindings} - def __repr__(self) -> str: - s = "comby.Environment([{}])" - return s.format(', '.join([repr(self[t]) for t in self])) + @staticmethod + def from_dict(ts: Sequence[dict[str, str | dict[str, dict[str, int]]]]) -> Environment: + """ + Create an Environment object from a list of dictionaries. + + :param ts: A list of dictionaries representing the bindings. + :type ts: Sequence[dict[str, str | dict[str, dict[str, int]]]] + :returns: An Environment object. + :rtype: Environment + """ + return Environment( + bindings=[BoundTerm.from_dict(bt) for bt in ts], + ) + + @override + def __repr__(self: Environment) -> str: + """ + Return a string representation of the Environment object. + + The string representation is a list of the bindings in the environment + + + :returns: A string representation of the Environment object. + + :rtype: str + """ + environment_str = "".join( + [ + "comby.Environment([])", + ], + ) + return environment_str.format( + ", ".join([repr(self[t]) for t in self]), + ) + + @override + def __len__(self: Environment) -> int: + """ + Return the number of bindings in the environment. + + Length of the environment. + - def __len__(self) -> int: - """Returns the number of bindings in this environment.""" + :returns: The number of bindings in the environment. + + :rtype: int + """ return len(self.__bindings) - def __iter__(self) -> Iterator[str]: - """Returns an iterator over the term names in this environment.""" - return self.__bindings.keys().__iter__() + @override + def __iter__(self: Environment) -> Iterator[str]: + """ + Return an iterator over the term names in the environment. + + The iterator yields the names of the bindings in the environment. + - def __getitem__(self, term: str) -> BoundTerm: - """Fetches details of a particular term within this environment. + :returns: An iterator over the term names in the environment. - Parameters: - term: the name of the term. + :rtype: Iterator[str] + """ + return self.__bindings.keys().__iter__() - Returns: - details of the source to which the term was bound. + @override + def __getitem__(self: Environment, term: str) -> BoundTerm: + """ + Return the `BoundTerm` value associated with the given term name. - Raises: - KeyError: if no term is found with the given name. + :param term: + :type term: str """ return self.__bindings[term] -@attr.s(slots=True, frozen=True) +@typechecked +@attr.s(auto_attribs=True, slots=True, frozen=True) class Match(Mapping[str, BoundTerm]): """ - Describes a single match of a given template in a source text as a mapping - of template terms to snippets of source code. + The `Match` class represents a mapping of string keys to `BoundTerm` values. + + It provides methods to access and manipulate the mappings. + + """ - Attributes - ---------- matched: str - the source text that was matched. location: LocationRange - the range of location range that was matched. environment: Environment - the associated environment, mapping template terms to snippets in the - source text, for the match. - """ - matched = attr.ib(type=str) - location = attr.ib(type=LocationRange) - environment = attr.ib(type=Environment) @staticmethod - def from_dict(d: Dict[str, Any]) -> 'Match': - return Match(matched=d['matched'], - location=LocationRange.from_dict(d['range']), - environment=Environment.from_dict(d['environment'])) + def from_dict(match_dict: dict[str, str | LocationRange | Environment]) -> Match: + """ + Create a `Match` object from a dictionary representation. + + :param match_dict: The dictionary representation of the `Match` object. + :type match_dict: dict[str, str | LocationRange | Environment] + :returns: The created `Match` object. + :rtype: Match + :raises TypeError: If the types of the values in the dictionary are not valid. + + """ + matched = match_dict["matched"] + location = match_dict["location"] + environment = match_dict["environment"] + + if not isinstance(matched, str): + raise TypeError + + if not isinstance(location, LocationRange): + raise TypeError + + if not isinstance(environment, Environment): + raise TypeError + + return Match( + matched=matched, + location=location, + environment=environment, + ) + + @override + def __len__(self: Match) -> int: + """ + Return the number of bindings in the environment. - def __len__(self) -> int: - """Returns the number of bindings in the environment.""" + The number of bindings in the environment. + + + :returns: The number of bindings in the environment. + + :rtype: int + """ return len(self.environment) - def __iter__(self) -> Iterator[str]: - """Returns an iterator over the term names in the environment.""" + @override + def __iter__(self: Match) -> Iterator[str]: + """ + Return an iterator over the term names in the environment. + + The iterator yields the names of the bindings in the environment. + + + :returns: An iterator over the term names in the environment. + + :rtype: Iterator[str] + """ yield from self.environment - def __getitem__(self, term: str) -> BoundTerm: + @override + def __getitem__(self: Match, term: str) -> BoundTerm: + """ + Return the `BoundTerm` value associated with the given term name from the environment. + + :param term: The term name. + :type term: str + :returns: The `BoundTerm` value associated with the term name. + :rtype: BoundTerm + """ return self.environment[term] + + +@typechecked +class ConfigMatch(TypedDict): + """Config match.""" + + matched: str + range: dict[str, dict[str, int]] + environment: Sequence[dict[str, str | dict[str, dict[str, int]]]] + + +@typechecked +class ConfigJson(TypedDict): + """Config json.""" + + matches: list[ConfigMatch] + uri: None + + +@typechecked +class ConfigLocation(TypedDict): + """Config location.""" + + start: dict[str, int] + end: dict[str, int] diff --git a/src/comby/exceptions.py b/src/comby/exceptions.py index 3157962..3a7d00a 100644 --- a/src/comby/exceptions.py +++ b/src/comby/exceptions.py @@ -1,31 +1,29 @@ -# -*- coding: utf-8 -*- -""" -This module provides definitions for the exceptions raised by Comby. -""" +"""This module provides definitions for the exceptions raised by Comby.""" import attr +__all__ = ["CombyExceptionError", "ConnectionFailureError"] -class CombyException(Exception): + +class CombyExceptionError(Exception): """Base class used by all Comby exceptions.""" -class ConnectionFailure(CombyException): +class ConnectionFailureError(CombyExceptionError): """ - The client failed to establish a connection to the server within the - allotted connection timeout window. + The client failed to establish a connection to the server. + + The client failed to establish a connection to the server within the allotted + connection timeout window. + + + :raises ConnectionFailureError: always + """ -@attr.s(auto_exc=True) -class CombyBinaryError(CombyException): - """An error was produced by the Comby binary. +@attr.s(auto_attribs=True) +class CombyBinaryError(CombyExceptionError): + """An error was produced by the Comby binary.""" - Attributes - ---------- code: int - The exit code that was produced by the binary. message: str - The error message that was produced by the binary. - """ - code = attr.ib(type=int) - message = attr.ib(type=str) diff --git a/src/comby/interface.py b/src/comby/interface.py index 6ba9830..d4a4f63 100644 --- a/src/comby/interface.py +++ b/src/comby/interface.py @@ -1,84 +1,126 @@ -# -*- coding: utf-8 -*- """ This module defines a standard interface for interacting with Comby. """ -__all__ = ('CombyInterface',) +from __future__ import annotations -from typing import Iterator, Dict, Optional import abc +from typing import TYPE_CHECKING, Iterator -from .core import Match +from typeguard import typechecked +__all__ = ("CombyInterface",) + +if TYPE_CHECKING: + from comby.core import Match + + +@typechecked class CombyInterface(abc.ABC): - """Provides a standard interface for interacting with Comby. - - Attributes - ---------- - version: str - The version of Comby that is provided by this interface. - language: str - The default language that should be assumed when dealing with source - text where no specific language is specified. - """ - @property - @abc.abstractmethod - def version(self) -> str: - ... + """Provides a standard interface for interacting with Comby.""" @property @abc.abstractmethod - def language(self) -> str: + def version(self: CombyInterface) -> str: + """ + Retrieve the version number of the Comby binary. + + This method accesses the version information of the Comby binary and returns it as a string. + If the object is not a CombyBinary instance, it raises a TypeError. + + + :returns: The version number of the Comby binary. + + :rtype: str + :raises TypeError: If the object is not an instance of CombyBinary. + + """ ... @abc.abstractmethod - def matches(self, - source: str, - template: str, - *, - language: Optional[str] = None - ) -> Iterator[Match]: - """Finds all matches of a given template within a source text. - - Parameters - ---------- - source: str - The source text to be searched. - template: str - The template that should be used for matching. - language: str, optional - Specifies the language of the source text. If no language is - specified, then the default language associated with this + def matches( + self: CombyInterface, + source: str, + template: str, + *, + language: str | None = None, + ) -> Iterator[Match]: + """ + Finds all matches of a given template within a source text. + + :param source: The source text to be searched. + :type source: str + :param template: The template that should be used for matching. + :type template: str + :param *: + :param language: Specifies the language of the source text. + If no language is specified, then the default language associated with this interface will be used. + :type language: str | None + :returns: An iterator over all matches of the given template within + :rtype: Iterator[Match] + :raises None: - Yields - ------ - All matches of the given template within in the text. """ ... @abc.abstractmethod - def substitute(self, - template: str, - args: Dict[str, str], - *, - language: Optional[str] = None - ) -> str: - """Substitutes a set of terms into a given template.""" + def substitute( + self: CombyInterface, + template: str, + args: dict[str, str], + *, + language: str | None = None, + ) -> str: + """ + Substitutes a set of terms into a given template. + + :param template: + :type template: str + :param args: + :type args: dict[str, str] + :param *: + :param language: (Default value = None) + :type language: str | None + :raises None: + + """ ... @abc.abstractmethod - def rewrite(self, - source: str, - match: str, - rewrite: str, - args: Optional[Dict[str, str]] = None, - *, - diff: bool = False, - language: Optional[str] = None - ) -> str: + def rewrite( + self: CombyInterface, + source: str, + match: str, + rewrite: str, + args: dict[str, str] | None = None, + *, + diff: bool = False, + language: str | None = None, + match_newline_at_toplevel: bool = False, + ) -> str: """ - Rewrites all matches of a template in a source text using a rewrite - template and an optional set of arguments to that rewrite template. + Rewrite all matches in source with rewrite template. + + Rewrites all matches of a template in a source text using a rewrite template and + an optional set of arguments to that rewrite template. + + :param source: + :type source: str + :param match: + :type match: str + :param rewrite: + :type rewrite: str + :param args: (Default value = None) + :type args: dict[str, str] | None + :param *: + :param diff: (Default value = False) + :type diff: bool + :param language: (Default value = None) + :type language: str | None + :param match_newline_at_toplevel: (Default value = False) + :type match_newline_at_toplevel: bool + :raises None: + """ ... diff --git a/src/comby/py.typed b/src/comby/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/test/test_binary.py b/test/test_binary.py deleted file mode 100644 index 42165b0..0000000 --- a/test/test_binary.py +++ /dev/null @@ -1,81 +0,0 @@ -# -*- coding: utf-8 -*- -import logging - -import pytest - -from comby import CombyBinary - - -@pytest.fixture -def comby(): - return CombyBinary() - - -def test_backslash_escapes_issue_32(comby): - source = " if \'charset\' in response.headers.get(\"Content-Type\", \"\")\n" - lhs = ":[ spaces]:[a]:[newline~[\n]]" - rhs = ":[spaces]:[a]:[newline]:[spaces]break;:[newline]" - matches = list(comby.matches(source, lhs, language=".py")) - assert len(matches) == 1 - match = matches[0] - - environment = {entry: match[entry].fragment for entry in match.environment} - mutated = comby.substitute(rhs, environment) - expected = " if 'charset' in response.headers.get(\"Content-Type\", \"\")\n break;\n" - assert mutated == expected - - -def test_match(comby): - source = "print('hello world')" - template = "print(:[1])" - matches = list(comby.matches(source, template)) - assert len(matches) == 1 - print(matches[0]) - - -def test_no_match(comby): - source = "foo" - template = "bar" - matches = list(comby.matches(source, template)) - print(matches) - assert len(matches) == 0 - - -def test_rewrite(comby): - source = "print('hello world')" - template = "print(:[1])" - rewrite = "println(:[1])" - expected = "println('hello world')" - actual = comby.rewrite(source, template, rewrite) - assert actual == expected - - source = """ - switch (name) { - case "WALL-E": - System.out.println("Hey! Stop that droid!"); - break; - default: - System.out.println("These aren't the droids we're looking for..."); - } - """.strip() - template = 'case "WALL-E"' - rewrite = 'case "C3PO"' - expected = """ - switch (name) { - case "C3PO": - System.out.println("Hey! Stop that droid!"); - break; - default: - System.out.println("These aren't the droids we're looking for..."); - } - """.strip() - actual = comby.rewrite(source, template, rewrite, language='.java') - assert actual == expected - - -def test_substitute(comby): - template = "my name is :[1]" - args = {'1': 'very secret'} - expected = 'my name is very secret' - actual = comby.substitute(template, args) - assert actual == expected diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..9a2574d --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test Comby API.""" diff --git a/tests/test_binary.py b/tests/test_binary.py new file mode 100644 index 0000000..59946a3 --- /dev/null +++ b/tests/test_binary.py @@ -0,0 +1,227 @@ +"""Test the Comby binary.""" +import pytest + +from comby import CombyBinary + + +@pytest.fixture() +def get_comby_binary() -> CombyBinary: + """ + Create the Comby binary. + + """ + return CombyBinary() + + +def test_matches_should_return_single_match_when_pattern_contains_backslashes( + get_comby_binary: CombyBinary, +) -> None: + """ + Test that the 'matches' method accurately identifies patterns with backslashes in the source code. + + Verifies that the 'matches' method of a CombyBinary object can successfully find a pattern + that includes backslash escapes in a source string exactly once. + + :param get_comby_binary: A callable that returns a CombyBinary object. + :type get_comby_binary: CombyBinary + """ + source = ' if \'charset\' in response.headers.get("Content-Type", "")\n' + template = ":[ spaces]:[a]:[newline~[\n]]" + + result = list(get_comby_binary.matches(source, template, language=".py")) + + assert len(result) == 1 + + +def test_matches_should_return_correct_substitution_when_backslash_escapes_present( + get_comby_binary: CombyBinary, +) -> None: + """ + Test that the 'matches' method correctly substitutes patterns that include backslash escapes. + + Verifies that the 'matches' method of a CombyBinary object can correctly perform a substitution + when the source string contains backslash escapes. The substitution is considered correct if the + result matches the expected string exactly. + + :param get_comby_binary: A callable that returns a CombyBinary object. + :type get_comby_binary: CombyBinary + """ + source = ' if \'charset\' in response.headers.get("Content-Type", "")\n' + template = ":[ spaces]:[a]:[newline~[\n]]" + replacement = ":[spaces]:[a]:[newline]:[spaces]break;:[newline]" + matches = list(get_comby_binary.matches(source, template, language=".py")) + match = matches[0] + environment = {entry: match[entry].fragment for entry in match.environment} + expected = ' if \'charset\' in response.headers.get("Content-Type", "")\n break;\n' + + result = get_comby_binary.substitute(replacement, environment) + + assert result == expected + + +def test_matches_should_return_single_match_when_pattern_exists_once( + get_comby_binary: CombyBinary, +) -> None: + """ + Test that the 'matches' method successfully finds patterns in the source code. + + Verifies that the 'matches' method of a CombyBinary object can identify when a specified pattern + (template) occurs in a source string. A successful match is when the source contains the exact + pattern described by the template exactly once. + + :param get_comby_binary: A callable that returns a CombyBinary object. + :type get_comby_binary: CombyBinary + """ + source = "print('hello world')" + template = "print(:[1])" + + result = list(get_comby_binary.matches(source, template)) + + assert len(result) == 1 + + +def test_matches_should_return_no_matches_when_pattern_does_not_exist( + get_comby_binary: CombyBinary, +) -> None: + """ + Test that the 'matches' method returns no matches when the pattern does not exist in the source string. + + Verifies that the 'matches' method of a CombyBinary object accurately identifies the absence of a specified pattern + (template) within a source string. A successful test is when the source does not contain the specified pattern, + resulting in zero matches. + + :param get_comby_binary: A callable that returns a CombyBinary object. + :type get_comby_binary: CombyBinary + """ + source = "foo" + template = "bar" + + result = list(get_comby_binary.matches(source, template)) + + assert len(result) == 0 + + +def test_rewrite_should_succeed_when_template_matches_once( + get_comby_binary: CombyBinary, +) -> None: + """ + Test that the 'rewrite' method successfully applies transformations. + + Verifies that the 'rewrite' method of a CombyBinary object can apply a specified transformation + when the source string contains the pattern described by the template exactly once. + + :param get_comby_binary: A callable that returns a CombyBinary object. + :type get_comby_binary: CombyBinary + """ + source = "print('hello world')" + template = "print(:[1])" + rewrite = "println(:[1])" + expected = "println('hello world')" + + result = get_comby_binary.rewrite(source, template, rewrite) + + assert result == expected + + +def test_rewrite_switch_case_to_c3po( + get_comby_binary: CombyBinary, +) -> None: + """ + Test that the 'rewrite' method can successfully replace a specific case in a switch statement. + + Verifies that the 'rewrite' method of a CombyBinary object can identify and replace a specified pattern + (template) in a source string with a desired replacement pattern. The test checks for a correct rewrite + when replacing the case label "WALL-E" with "C3PO" within a switch statement. + + :param get_comby_binary: A callable that returns a CombyBinary object. + :type get_comby_binary: CombyBinary + """ + source: str = """ + switch (name) { + case "WALL-E": + System.out.println("Hey! Stop that droid!"); + break; + default: + System.out.println("These aren't the droids we're looking for..."); + } + """.strip() + template = 'case "WALL-E"' + rewrite = 'case "C3PO"' + expected = """ + switch (name) { + case "C3PO": + System.out.println("Hey! Stop that droid!"); + break; + default: + System.out.println("These aren't the droids we're looking for..."); + } + """.strip() + + result = get_comby_binary.rewrite(source, template, rewrite, language=".java") + + assert result == expected + + +def test_rewrite_should_match_newline_at_top_of_source( + get_comby_binary: CombyBinary, +) -> None: + """ + Test that the 'rewrite' method matches and rewrites code at the top level of the source code. + + Verifies that the 'rewrite' method of a CombyBinary object can identify and transform a specified pattern + (template) that occurs at the top level of a source string, where the pattern includes a newline character + at the beginning of the source string. + + :param get_comby_binary: A callable that returns a CombyBinary object. + :type get_comby_binary: CombyBinary + """ + source = "\n".join( + [ + "def foo():", + " print('hello world')", + "", + ], + ) + template = "def :[fn_name]::[body]:[next_fn~(\n\\z)]" + rewrite = "\n".join( + [ + "def :[fn_name]::[body]", + " print('hello mars'):[next_fn]", + ], + ) + expected = "\n".join( + [ + "def foo():", + " print('hello world')", + " print('hello mars')", + "", + ], + ) + + result = get_comby_binary.rewrite( + source, template, rewrite, language=".py", match_newline_at_toplevel=True + ) + + assert result == expected + + +def test_substitute_should_replace_pattern_with_arguments( + get_comby_binary: CombyBinary, +) -> None: + """ + Test that the 'substitute' method replaces patterns with provided arguments. + + Verifies that the 'substitute' method of a CombyBinary object can accurately replace a specified pattern + (template) with the arguments provided. A successful substitution is when the template is replaced by + the arguments exactly as specified. + + :param get_comby_binary: A callable that returns a CombyBinary object. + :type get_comby_binary: CombyBinary + """ + template = "my name is :[1]" + args = {"1": "very secret"} + expected = "my name is very secret" + + result = get_comby_binary.substitute(template, args) + + assert result == expected