From 530ba903bf288d5726841dde47e1dcc74c82c5d7 Mon Sep 17 00:00:00 2001 From: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> Date: Thu, 15 Jun 2023 16:39:48 -0700 Subject: [PATCH] Add workarounds for pytest-xdist and pytest-django, add CI linting, fix types, fix behavior (#32) * Add workarounds for pytest-xdist and pytest-django * type fixes * Update line length for ruff to 120 * Add ptyme track secret to git ignore * Add linting to CI * Fix monkey patch * Crudely fix off-by-one in jurigged logic * Start postgres server on dev container start * Add support for unregistering workarounds from libraries * Update documentation * Have client match status code --- .devcontainer/postStartCommand.sh | 4 +- .github/actions/test/action.yaml | 8 + .github/workflows/pr.yaml | 9 + .github/workflows/publish.yaml | 5 + .gitignore | 3 + .vscode/tasks.json | 11 + README.md | 36 ++- poetry.lock | 286 +++++++++++++++--- pyproject.toml | 16 +- pytest_hot_reloading/client.py | 7 +- pytest_hot_reloading/daemon.py | 34 ++- pytest_hot_reloading/plugin.py | 47 ++- pytest_hot_reloading/workarounds.py | 72 +++++ tests/__init__.py | 0 tests/test_client.py | 6 +- tests/test_samples.py | 2 +- tests/test_workarounds.py | 76 +++++ tests/workarounds/__init__.py | 0 tests/workarounds/pytest_django/__init__.py | 0 .../pytest_django/alt_sqlite3_backend.py | 0 .../pytest_django/docker-compose.yml | 8 + tests/workarounds/pytest_django/settings.py | 10 + .../test_pytest_django_workaround.py | 12 + .../unregistered_workaround/__init__.py | 0 .../_clear_hot_reload_workarounds.py | 1 + 25 files changed, 565 insertions(+), 88 deletions(-) create mode 100644 .vscode/tasks.json create mode 100644 pytest_hot_reloading/workarounds.py create mode 100644 tests/__init__.py create mode 100644 tests/test_workarounds.py create mode 100644 tests/workarounds/__init__.py create mode 100644 tests/workarounds/pytest_django/__init__.py create mode 100644 tests/workarounds/pytest_django/alt_sqlite3_backend.py create mode 100644 tests/workarounds/pytest_django/docker-compose.yml create mode 100644 tests/workarounds/pytest_django/settings.py create mode 100644 tests/workarounds/pytest_django/test_pytest_django_workaround.py create mode 100644 tests/workarounds/unregistered_workaround/__init__.py create mode 100644 tests/workarounds/unregistered_workaround/_clear_hot_reload_workarounds.py diff --git a/.devcontainer/postStartCommand.sh b/.devcontainer/postStartCommand.sh index a50e8c8..35dc7f0 100755 --- a/.devcontainer/postStartCommand.sh +++ b/.devcontainer/postStartCommand.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # run in the background at startup -nohup bash .devcontainer/postStartBackground.sh > ".dev_container_logs/postStartBackground.out" & +nohup "bash -c .devcontainer/postStartBackground.sh & > .dev_container_logs/postStartBackground.out" -sleep 1 +docker compose -f tests/workarounds/pytest_django/docker-compose.yml up -d postgres diff --git a/.github/actions/test/action.yaml b/.github/actions/test/action.yaml index 0d5e42c..4253bfe 100644 --- a/.github/actions/test/action.yaml +++ b/.github/actions/test/action.yaml @@ -3,6 +3,10 @@ description: Install dependencies and run tests runs: using: composite steps: + - name: Start postgres for pytest-django test(s) + run: | + docker-compose -f tests/workarounds/pytest_django/docker-compose.yml up -d + shell: bash - name: Install dependencies run: | poetry install --with=dev @@ -15,3 +19,7 @@ runs: run: | poetry run pytest shell: bash + - name: Rerun workaround tests to check for incompatibilities + run: | + poetry run pytest tests/workarounds + shell: bash diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 5c5a281..b3e935a 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -15,6 +15,11 @@ permissions: jobs: test: runs-on: ubuntu-latest + # services: + # postgres: + # image: postgres:12.2 + # env: + # POSTGRES_HOST_AUTH_METHOD: trust steps: - uses: actions/checkout@v3 - name: Set up Python 3.10 @@ -26,3 +31,7 @@ jobs: uses: Gr1N/setup-poetry@12c727a3dcf8c1a548d8d041c9d5ef5cebb3ba2e - name: test uses: ./.github/actions/test + - name: lint using ruff + run: poetry run ruff pytest_hot_reloading tests + - name: lint using mypy + run: poetry run mypy pytest_hot_reloading tests diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 2dfc1a7..0fb67e5 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -17,6 +17,11 @@ jobs: if: github.repository == 'JamesHutchison/pytest-hot-reloading' runs-on: ubuntu-latest environment: production + # services: + # postgres: + # image: postgres:12.2 + # env: + # POSTGRES_HOST_AUTH_METHOD: trust steps: - uses: actions/checkout@v3 - name: Set up Python 3.10 diff --git a/.gitignore b/.gitignore index fc66522..209b48f 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,6 @@ dmypy.json *.pid .dev_container_logs/* + +# ptyme server secret +.ptyme.secret diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..38d7d4d --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,11 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Check", + "type": "shell", + "command": "poetry run dmypy check pytest_hot_reloading tests && poetry run ruff pytest_hot_reloading tests", + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 0978983..0d7ff7b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,10 @@ If it takes less than 5 seconds to do all of the imports necessary to run a unit test, then you probably don't need this. ## Installation -TBD +Do not install in production code. This is exclusively for the developer environment. + +pip: Add `pytest-hot-reloading` to your `dev-requirements.txt` file and `pip install -r dev-requirements.txt` +poetry: `poetry add --group=dev pytest-hot-reloading` ## Usage Add the plugin to the pytest arguments. Example using pyproject.toml: @@ -54,9 +57,40 @@ If the daemon is already running and you run pytest with `--daemon`, then the ol and a new one will be started. Note that `pytest --daemon` is NOT how you run tests. It is only used to start the daemon. +## Workarounds +Libraries that use mutated globals may need a workaround to work with this plugin. The preferred +route is to have the library update its code to not mutate globals in a test environment, or to +restore them after a test suite has ran. In some cases, that isn't possible, usually because +the person with the problem doesn't own the library and can't wait around for a fix. + +To register a workaround, create a function that is decorated by the +`pytest_hot_reloading.workaround.register_workaround` decorator. It may optionally yield. If it does, +then code after the yield is executed after the test suite has ran. + +Example: +```python +from pytest_hot_reloading.workaround import register_workaround + +@register_workaround("my_library") +def my_library_workaround(): + import my_library + + yield + + my_library.some_global = BackToOriginalValue() +``` + +If you are a library author, you can disable any workarounds for your library by creating an empty +module `_clear_hot_reload_workarounds.py`. If this is successfully imported, then workarounds for +the given module will not be executed. + ## Known Issues - This is early alpha - The jurigged library is not perfect and sometimes it gets in a bad state - Some libraries were not written with hot reloading in mind, and will not work without some changes. There is going to be logic to work around other issues with other libraries, such as pytest-django's mutation of the settings module that runs every session, but it hasn't been implemented yet. + +## Notes +- pytest-xdist will have its logic disabled, even if args are passed in to enable it +- pytest-django will not create test database suffixes for multiworker runs such as tox. diff --git a/poetry.lock b/poetry.lock index 13258c9..0690a35 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "ansicon" version = "1.89.0" description = "Python wrapper for loading Jason Hood's ANSICON" -category = "main" optional = false python-versions = "*" files = [ @@ -12,11 +11,27 @@ files = [ {file = "ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1"}, ] +[[package]] +name = "asgiref" +version = "3.7.2" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.7" +files = [ + {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"}, + {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + [[package]] name = "asttokens" version = "2.2.1" description = "Annotate AST trees with source code positions" -category = "dev" optional = false python-versions = "*" files = [ @@ -34,7 +49,6 @@ test = ["astroid", "pytest"] name = "black" version = "23.3.0" description = "The uncompromising code formatter." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -83,7 +97,6 @@ uvloop = ["uvloop (>=0.15.2)"] name = "blessed" version = "1.20.0" description = "Easy, practical library for making terminal apps, by providing an elegant, well-documented interface to Colors, Keyboard input, and screen Positioning capabilities." -category = "main" optional = false python-versions = ">=2.7" files = [ @@ -98,21 +111,19 @@ wcwidth = ">=0.1.4" [[package]] name = "cachetools" -version = "5.3.0" +version = "5.3.1" description = "Extensible memoizing collections and decorators" -category = "main" optional = false -python-versions = "~=3.7" +python-versions = ">=3.7" files = [ - {file = "cachetools-5.3.0-py3-none-any.whl", hash = "sha256:429e1a1e845c008ea6c85aa35d4b98b65d6a9763eeef3e37e92728a12d1de9d4"}, - {file = "cachetools-5.3.0.tar.gz", hash = "sha256:13dfddc7b8df938c21a940dfa6557ce6e94a2f1cdfa58eb90c805721d58f2c14"}, + {file = "cachetools-5.3.1-py3-none-any.whl", hash = "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590"}, + {file = "cachetools-5.3.1.tar.gz", hash = "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b"}, ] [[package]] name = "click" version = "8.1.3" description = "Composable command line interface toolkit" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -127,7 +138,6 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "codefind" version = "0.1.3" description = "Find code objects and their referents" -category = "main" optional = false python-versions = ">=3.8,<4.0" files = [ @@ -139,7 +149,6 @@ files = [ name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -147,11 +156,30 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "django" +version = "4.2.2" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Django-4.2.2-py3-none-any.whl", hash = "sha256:672b3fa81e1f853bb58be1b51754108ab4ffa12a77c06db86aa8df9ed0c46fe5"}, + {file = "Django-4.2.2.tar.gz", hash = "sha256:2a6b6fbff5b59dd07bef10bcb019bee2ea97a30b2a656d51346596724324badf"}, +] + +[package.dependencies] +asgiref = ">=3.6.0,<4" +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + [[package]] name = "exceptiongroup" version = "1.1.1" description = "Backport of PEP 654 (exception groups)" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -162,11 +190,24 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "execnet" +version = "1.9.0" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "execnet-1.9.0-py2.py3-none-any.whl", hash = "sha256:a295f7cc774947aac58dde7fdc85f4aa00c42adf5d8f5468fc630c1acf30a142"}, + {file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"}, +] + +[package.extras] +testing = ["pre-commit"] + [[package]] name = "executing" version = "1.2.0" description = "Get the currently executing AST node of a frame, and other information" -category = "dev" optional = false python-versions = "*" files = [ @@ -181,7 +222,6 @@ tests = ["asttokens", "littleutils", "pytest", "rich"] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -193,7 +233,6 @@ files = [ name = "jinxed" version = "1.2.0" description = "Jinxed Terminal Library" -category = "main" optional = false python-versions = "*" files = [ @@ -208,7 +247,6 @@ ansicon = {version = "*", markers = "platform_system == \"Windows\""} name = "jurigged" version = "0.5.5" description = "Live update of Python functions" -category = "main" optional = false python-versions = ">=3.8,<4.0" files = [ @@ -227,14 +265,13 @@ develoop = ["giving (>=0.4.1,<0.5.0)", "rich (>=10.13.0)"] [[package]] name = "megamock" -version = "0.1.0b3" +version = "0.1.0b4" description = "Mega mocking capabilities - stop using dot-notated paths!" -category = "dev" optional = false python-versions = ">=3.10,<4.0" files = [ - {file = "megamock-0.1.0b3-py3-none-any.whl", hash = "sha256:2eea4619ded8a204b0f9a67f40620d5aa8fb6323fc25976cc27f580476f4a7ac"}, - {file = "megamock-0.1.0b3.tar.gz", hash = "sha256:11ff71ee1725f49a63cb7f540231ac626ab6e530b02f0ea4fd75b4c5e0a12aa8"}, + {file = "megamock-0.1.0b4-py3-none-any.whl", hash = "sha256:ca5cdae7d09f5a0991787115907641c5f19a4df960c597b9d042973965a08998"}, + {file = "megamock-0.1.0b4.tar.gz", hash = "sha256:db5e07fbf3f68beb02fd5073719a47abcec5afe1ca4f326fa8fe1eae9e4c3d00"}, ] [package.dependencies] @@ -245,7 +282,6 @@ varname = {version = ">=0.10.0,<0.11.0", extras = ["asttokens"]} name = "mypy" version = "1.3.0" description = "Optional static typing for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -292,7 +328,6 @@ reports = ["lxml"] name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -304,7 +339,6 @@ files = [ name = "ovld" version = "0.3.2" description = "Overloading Python functions" -category = "main" optional = false python-versions = ">=3.6,<4.0" files = [ @@ -316,7 +350,6 @@ files = [ name = "packaging" version = "23.1" description = "Core utilities for Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -328,7 +361,6 @@ files = [ name = "pathspec" version = "0.11.1" description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -338,25 +370,23 @@ files = [ [[package]] name = "platformdirs" -version = "3.5.1" +version = "3.5.3" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.5.1-py3-none-any.whl", hash = "sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5"}, - {file = "platformdirs-3.5.1.tar.gz", hash = "sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f"}, + {file = "platformdirs-3.5.3-py3-none-any.whl", hash = "sha256:0ade98a4895e87dc51d47151f7d2ec290365a585151d97b4d8d6312ed6132fed"}, + {file = "platformdirs-3.5.3.tar.gz", hash = "sha256:e48fabd87db8f3a7df7150a4a5ea22c546ee8bc39bc2473244730d4b56d2cc4e"}, ] [package.extras] -docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.2.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"] [[package]] name = "pluggy" version = "1.0.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -368,16 +398,86 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "psycopg2-binary" +version = "2.9.6" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.6" +files = [ + {file = "psycopg2-binary-2.9.6.tar.gz", hash = "sha256:1f64dcfb8f6e0c014c7f55e51c9759f024f70ea572fbdef123f85318c297947c"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d26e0342183c762de3276cca7a530d574d4e25121ca7d6e4a98e4f05cb8e4df7"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c48d8f2db17f27d41fb0e2ecd703ea41984ee19362cbce52c097963b3a1b4365"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe9dc0a884a8848075e576c1de0290d85a533a9f6e9c4e564f19adf8f6e54a7"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a76e027f87753f9bd1ab5f7c9cb8c7628d1077ef927f5e2446477153a602f2c"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6460c7a99fc939b849431f1e73e013d54aa54293f30f1109019c56a0b2b2ec2f"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae102a98c547ee2288637af07393dd33f440c25e5cd79556b04e3fca13325e5f"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9972aad21f965599ed0106f65334230ce826e5ae69fda7cbd688d24fa922415e"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7a40c00dbe17c0af5bdd55aafd6ff6679f94a9be9513a4c7e071baf3d7d22a70"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:cacbdc5839bdff804dfebc058fe25684cae322987f7a38b0168bc1b2df703fb1"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7f0438fa20fb6c7e202863e0d5ab02c246d35efb1d164e052f2f3bfe2b152bd0"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-win32.whl", hash = "sha256:b6c8288bb8a84b47e07013bb4850f50538aa913d487579e1921724631d02ea1b"}, + {file = "psycopg2_binary-2.9.6-cp310-cp310-win_amd64.whl", hash = "sha256:61b047a0537bbc3afae10f134dc6393823882eb263088c271331602b672e52e9"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:964b4dfb7c1c1965ac4c1978b0f755cc4bd698e8aa2b7667c575fb5f04ebe06b"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afe64e9b8ea66866a771996f6ff14447e8082ea26e675a295ad3bdbffdd72afb"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15e2ee79e7cf29582ef770de7dab3d286431b01c3bb598f8e05e09601b890081"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dfa74c903a3c1f0d9b1c7e7b53ed2d929a4910e272add6700c38f365a6002820"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b83456c2d4979e08ff56180a76429263ea254c3f6552cd14ada95cff1dec9bb8"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0645376d399bfd64da57148694d78e1f431b1e1ee1054872a5713125681cf1be"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e99e34c82309dd78959ba3c1590975b5d3c862d6f279f843d47d26ff89d7d7e1"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4ea29fc3ad9d91162c52b578f211ff1c931d8a38e1f58e684c45aa470adf19e2"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:4ac30da8b4f57187dbf449294d23b808f8f53cad6b1fc3623fa8a6c11d176dd0"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e78e6e2a00c223e164c417628572a90093c031ed724492c763721c2e0bc2a8df"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-win32.whl", hash = "sha256:1876843d8e31c89c399e31b97d4b9725a3575bb9c2af92038464231ec40f9edb"}, + {file = "psycopg2_binary-2.9.6-cp311-cp311-win_amd64.whl", hash = "sha256:b4b24f75d16a89cc6b4cdff0eb6a910a966ecd476d1e73f7ce5985ff1328e9a6"}, + {file = "psycopg2_binary-2.9.6-cp36-cp36m-win32.whl", hash = "sha256:498807b927ca2510baea1b05cc91d7da4718a0f53cb766c154c417a39f1820a0"}, + {file = "psycopg2_binary-2.9.6-cp36-cp36m-win_amd64.whl", hash = "sha256:0d236c2825fa656a2d98bbb0e52370a2e852e5a0ec45fc4f402977313329174d"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:34b9ccdf210cbbb1303c7c4db2905fa0319391bd5904d32689e6dd5c963d2ea8"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84d2222e61f313c4848ff05353653bf5f5cf6ce34df540e4274516880d9c3763"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30637a20623e2a2eacc420059be11527f4458ef54352d870b8181a4c3020ae6b"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8122cfc7cae0da9a3077216528b8bb3629c43b25053284cc868744bfe71eb141"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38601cbbfe600362c43714482f43b7c110b20cb0f8172422c616b09b85a750c5"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c7e62ab8b332147a7593a385d4f368874d5fe4ad4e341770d4983442d89603e3"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2ab652e729ff4ad76d400df2624d223d6e265ef81bb8aa17fbd63607878ecbee"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:c83a74b68270028dc8ee74d38ecfaf9c90eed23c8959fca95bd703d25b82c88e"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d4e6036decf4b72d6425d5b29bbd3e8f0ff1059cda7ac7b96d6ac5ed34ffbacd"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-win32.whl", hash = "sha256:a8c28fd40a4226b4a84bdf2d2b5b37d2c7bd49486b5adcc200e8c7ec991dfa7e"}, + {file = "psycopg2_binary-2.9.6-cp37-cp37m-win_amd64.whl", hash = "sha256:51537e3d299be0db9137b321dfb6a5022caaab275775680e0c3d281feefaca6b"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cf4499e0a83b7b7edcb8dabecbd8501d0d3a5ef66457200f77bde3d210d5debb"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7e13a5a2c01151f1208d5207e42f33ba86d561b7a89fca67c700b9486a06d0e2"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e0f754d27fddcfd74006455b6e04e6705d6c31a612ec69ddc040a5468e44b4e"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d57c3fd55d9058645d26ae37d76e61156a27722097229d32a9e73ed54819982a"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71f14375d6f73b62800530b581aed3ada394039877818b2d5f7fc77e3bb6894d"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:441cc2f8869a4f0f4bb408475e5ae0ee1f3b55b33f350406150277f7f35384fc"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:65bee1e49fa6f9cf327ce0e01c4c10f39165ee76d35c846ade7cb0ec6683e303"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:af335bac6b666cc6aea16f11d486c3b794029d9df029967f9938a4bed59b6a19"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:cfec476887aa231b8548ece2e06d28edc87c1397ebd83922299af2e051cf2827"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:65c07febd1936d63bfde78948b76cd4c2a411572a44ac50719ead41947d0f26b"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-win32.whl", hash = "sha256:4dfb4be774c4436a4526d0c554af0cc2e02082c38303852a36f6456ece7b3503"}, + {file = "psycopg2_binary-2.9.6-cp38-cp38-win_amd64.whl", hash = "sha256:02c6e3cf3439e213e4ee930308dc122d6fb4d4bea9aef4a12535fbd605d1a2fe"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e9182eb20f41417ea1dd8e8f7888c4d7c6e805f8a7c98c1081778a3da2bee3e4"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8a6979cf527e2603d349a91060f428bcb135aea2be3201dff794813256c274f1"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8338a271cb71d8da40b023a35d9c1e919eba6cbd8fa20a54b748a332c355d896"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3ed340d2b858d6e6fb5083f87c09996506af483227735de6964a6100b4e6a54"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f81e65376e52f03422e1fb475c9514185669943798ed019ac50410fb4c4df232"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfb13af3c5dd3a9588000910178de17010ebcccd37b4f9794b00595e3a8ddad3"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4c727b597c6444a16e9119386b59388f8a424223302d0c06c676ec8b4bc1f963"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4d67fbdaf177da06374473ef6f7ed8cc0a9dc640b01abfe9e8a2ccb1b1402c1f"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0892ef645c2fabb0c75ec32d79f4252542d0caec1d5d949630e7d242ca4681a3"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:02c0f3757a4300cf379eb49f543fb7ac527fb00144d39246ee40e1df684ab514"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-win32.whl", hash = "sha256:c3dba7dab16709a33a847e5cd756767271697041fbe3fe97c215b1fc1f5c9848"}, + {file = "psycopg2_binary-2.9.6-cp39-cp39-win_amd64.whl", hash = "sha256:f6a88f384335bb27812293fdb11ac6aee2ca3f51d3c7820fe03de0a304ab6249"}, +] + [[package]] name = "pytest" -version = "7.3.1" +version = "7.3.2" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, - {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, + {file = "pytest-7.3.2-py3-none-any.whl", hash = "sha256:cdcbd012c9312258922f8cd3f1b62a6580fdced17db6014896053d47cddf9295"}, + {file = "pytest-7.3.2.tar.gz", hash = "sha256:ee990a3cc55ba808b80795a79944756f315c67c12b56abd3ac993a7b8c17030b"}, ] [package.dependencies] @@ -389,13 +489,67 @@ pluggy = ">=0.12,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-django" +version = "4.5.2" +description = "A Django plugin for pytest." +optional = false +python-versions = ">=3.5" +files = [ + {file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"}, + {file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"}, +] + +[package.dependencies] +pytest = ">=5.4.0" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +testing = ["Django", "django-configurations (>=2.0)"] + +[[package]] +name = "pytest-env" +version = "0.8.1" +description = "py.test plugin that allows you to add environment variables." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest_env-0.8.1-py3-none-any.whl", hash = "sha256:8c0605ae09a5b7e41c20ebcc44f2c906eea9654095b4b0c342b3814bcc3a8492"}, + {file = "pytest_env-0.8.1.tar.gz", hash = "sha256:d7b2f5273ec6d1e221757998bc2f50d2474ed7d0b9331b92556011fadc4e9abf"}, +] + +[package.dependencies] +pytest = ">=7.1.3" + +[package.extras] +test = ["coverage (>=6.5)", "pytest-mock (>=3.10)"] + +[[package]] +name = "pytest-xdist" +version = "3.3.1" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-xdist-3.3.1.tar.gz", hash = "sha256:d5ee0520eb1b7bcca50a60a518ab7a7707992812c578198f8b44fdfac78e8c93"}, + {file = "pytest_xdist-3.3.1-py3-none-any.whl", hash = "sha256:ff9daa7793569e6a68544850fd3927cd257cc03a7ef76c95e86915355e82b5f2"}, +] + +[package.dependencies] +execnet = ">=1.1" +pytest = ">=6.2.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] [[package]] name = "ruff" version = "0.0.261" description = "An extremely fast Python linter, written in Rust." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -422,7 +576,6 @@ files = [ name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -430,11 +583,26 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "sqlparse" +version = "0.4.4" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.5" +files = [ + {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, + {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, +] + +[package.extras] +dev = ["build", "flake8"] +doc = ["sphinx"] +test = ["pytest", "pytest-cov"] + [[package]] name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -442,23 +610,43 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "types-cachetools" +version = "5.3.0.5" +description = "Typing stubs for cachetools" +optional = false +python-versions = "*" +files = [ + {file = "types-cachetools-5.3.0.5.tar.gz", hash = "sha256:67fa46d51a650896770aee0ba80f0e61dc4a7d1373198eec1bc0622263eaa256"}, + {file = "types_cachetools-5.3.0.5-py3-none-any.whl", hash = "sha256:c0c5fa00199017d974c935bf043c467d5204e4f835141e489b48765b5ac1d960"}, +] + [[package]] name = "typing-extensions" -version = "4.5.0" +version = "4.6.3" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, - {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, + {file = "typing_extensions-4.6.3-py3-none-any.whl", hash = "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26"}, + {file = "typing_extensions-4.6.3.tar.gz", hash = "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5"}, +] + +[[package]] +name = "tzdata" +version = "2023.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, + {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, ] [[package]] name = "varname" version = "0.10.0" description = "Dark magics about variable names in python." -category = "dev" optional = false python-versions = ">=3.6,<4.0" files = [ @@ -476,7 +664,6 @@ all = ["asttokens (>=2.0.0,<3.0.0)", "pure_eval (<1.0.0)"] name = "watchdog" version = "3.0.0" description = "Filesystem events monitoring" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -516,7 +703,6 @@ watchmedo = ["PyYAML (>=3.10)"] name = "wcwidth" version = "0.2.6" description = "Measures the displayed width of unicode strings in a terminal" -category = "main" optional = false python-versions = "*" files = [ @@ -527,4 +713,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "80c57a29e5686573e503bb8d2acb4a128ca4f64d33b528d048a0c3585996eddf" +content-hash = "c57a0b4debe77acb57c1d0df28009212dd49c01309e37639af172b75d56c3138" diff --git a/pyproject.toml b/pyproject.toml index d25e6e0..5ace331 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [tool.ruff] -line-length = 98 +line-length = 120 [tool.black] line-length = 98 [tool.poetry] name = "pytest-hot-reloading" -version = "0.1.0-alpha.5" +version = "0.1.0-alpha.6" description = "" authors = ["James Hutchison "] readme = "README.md" @@ -16,6 +16,7 @@ packages = [{ include = "pytest_hot_reloading" }] python = "^3.10" jurigged = "^0.5.5" cachetools = "^5.3.0" +types-cachetools = "^5.3.0.5" [tool.poetry.group.dev.dependencies] mypy = "^1.2.0" @@ -23,10 +24,19 @@ ruff = "^0.0.261" black = "^23.3.0" pytest = "^7.2.2" megamock = "^0.1.0b3" +pytest-django = "^4.5.2" +django = "^4.2.2" +psycopg2-binary = "^2.9.6" +pytest-env = "^0.8.1" +pytest-xdist = "^3.3.1" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] -addopts = "-p pytest_hot_reloading.plugin -p megamock.plugins.pytest" +DJANGO_SETTINGS_MODULE = "tests.workarounds.pytest_django.settings" +# xdist is not supported. Enable it to check that workaround works +addopts = "-n 1 -p pytest_hot_reloading.plugin -p megamock.plugins.pytest" +# TOX is incompatible with pytest-django +env = ["TOX_PARALLEL_ENV = tox"] diff --git a/pytest_hot_reloading/client.py b/pytest_hot_reloading/client.py index ad851fc..6a30273 100644 --- a/pytest_hot_reloading/client.py +++ b/pytest_hot_reloading/client.py @@ -2,6 +2,7 @@ import sys import time import xmlrpc.client +from typing import cast class PytestClient: @@ -18,14 +19,14 @@ def __init__( self._daemon_port = daemon_port self._pytest_name = pytest_name - def run(self, args: list[str]) -> str: + def run(self, args: list[str]) -> int: self._start_daemon_if_needed() server_url = f"http://{self._daemon_host}:{self._daemon_port}" server = xmlrpc.client.ServerProxy(server_url) start = time.time() - result = server.run_pytest(args) + result: dict = cast(dict, server.run_pytest(args)) print(f"Daemon took {(time.time() - start):.3f} seconds to reply") stdout = result["stdout"].data.decode("utf-8") @@ -34,6 +35,8 @@ def run(self, args: list[str]) -> str: print(stdout, file=sys.stdout) print(stderr, file=sys.stderr) + return result["status_code"] + def abort(self) -> None: # Close the socket if self._socket: diff --git a/pytest_hot_reloading/daemon.py b/pytest_hot_reloading/daemon.py index dff2aa9..801f249 100644 --- a/pytest_hot_reloading/daemon.py +++ b/pytest_hot_reloading/daemon.py @@ -5,12 +5,17 @@ import subprocess import sys import time -from typing import Counter +from typing import Counter, Generator from xmlrpc.server import SimpleXMLRPCServer import pytest from cachetools import TTLCache +from pytest_hot_reloading.workarounds import ( + run_workarounds_post, + run_workarounds_pre, +) + class PytestDaemon: def __init__(self, daemon_host: str = "localhost", daemon_port: int = 4852) -> None: @@ -80,7 +85,7 @@ def run_forever(self) -> None: # create an XML-RPC server self._write_pid_file() # register the 'run_pytest' function - server.register_function(self.run_pytest, "run_pytest") + server.register_function(self.run_pytest, "run_pytest") # type: ignore server.serve_forever() @@ -100,7 +105,7 @@ def run_pytest(self, args: list[str]) -> dict: # run pytest using command line args # run the pytest main logic - self._workaround_library_issues(args) + in_progress_workarounds = self._workaround_library_issues_pre() import pytest_hot_reloading.plugin as plugin @@ -129,8 +134,10 @@ def run_pytest(self, args: list[str]) -> dict: try: # args must omit the calling program - pytest.main(["--color=yes"] + args) + status_code = pytest.main(["--color=yes"] + args) finally: + self._workaround_library_issues_post(in_progress_workarounds) + # restore originals _pytest.main._main = orig_main @@ -147,20 +154,23 @@ def run_pytest(self, args: list[str]) -> dict: return { "stdout": self._remove_ansi_escape(stdout_str).encode("utf-8"), "stderr": self._remove_ansi_escape(stderr_str).encode("utf-8"), + "status_code": int(status_code), } def _remove_ansi_escape(self, s: str) -> str: return re.sub(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))", "", s, flags=re.MULTILINE) - def _workaround_library_issues(self, args: list[str]) -> None: - # load modules that workaround library issues, as needed - pass + def _workaround_library_issues_pre(self) -> list[Generator]: + return run_workarounds_pre() + + def _workaround_library_issues_post(self, in_progress_workarounds: list[Generator]) -> None: + run_workarounds_post(in_progress_workarounds) -session_item_cache = TTLCache(16, 500) +session_item_cache: TTLCache[tuple, tuple] = TTLCache(16, 500) # hack: keeping a session cache since pytest has session references # littered everywhere on objects -prior_sessions = set() +prior_sessions: set[pytest.Session] = set() def _manage_prior_session_garbage(session: pytest.Session) -> None: @@ -209,7 +219,7 @@ def _pytest_main(config: pytest.Config, session: pytest.Session): import _pytest.capture - _pytest.capture.CaptureManager.stop_global_capturing = lambda self: None + _pytest.capture.CaptureManager.stop_global_capturing = lambda self: None # type: ignore start_global_capturing = _pytest.capture.CaptureManager.start_global_capturing resume_global_capture = _pytest.capture.CaptureManager.resume_global_capture @@ -218,7 +228,7 @@ def start_global_capture_if_needed(self: _pytest.capture.CaptureManager): start_global_capturing(self) return resume_global_capture(self) - _pytest.capture.CaptureManager.resume_global_capture = start_global_capture_if_needed + _pytest.capture.CaptureManager.resume_global_capture = start_global_capture_if_needed # type: ignore def best_effort_copy(item, depth_remaining=2): """ @@ -257,7 +267,7 @@ def best_effort_copy(item, depth_remaining=2): else: print("Pytest Daemon: Using cached collection") # Assign the prior test items (tests to run) and config to the current session - session.items = items + session.items = items # type: ignore session.config = config for i in items: # Items have references to the config and the session diff --git a/pytest_hot_reloading/plugin.py b/pytest_hot_reloading/plugin.py index 4a61bfb..2482c2a 100644 --- a/pytest_hot_reloading/plugin.py +++ b/pytest_hot_reloading/plugin.py @@ -5,14 +5,15 @@ import os import sys -from typing import TYPE_CHECKING, Callable +from pathlib import Path +from typing import TYPE_CHECKING, Callable, Optional from pytest_hot_reloading.client import PytestClient # this is modified by the daemon so that the pytest_collection hooks does not run i_am_server = False -seen_paths = set() +seen_paths: set[Path] = set() if TYPE_CHECKING: from pytest import Config, Item, Session @@ -62,22 +63,23 @@ def pytest_addoption(parser) -> None: # https://docs.pytest.org/en/stable/reference.html#_pytest.hookspec.pytest_addhooks -def pytest_cmdline_main(config: Config) -> None: +def pytest_cmdline_main(config: Config) -> Optional[int]: """ This hook is called by pytest and is one of the first hooks. """ # early escapes if config.option.collectonly: - return + return None if i_am_server: - return - _plugin_logic(config) + return None + status_code = _plugin_logic(config) # dont do any more work. Don't let pytest continue - return 0 # status code 0 + return status_code # status code 0 def monkey_patch_jurigged_function_definition(): - import jurigged.codetools as jurigged_codetools + import jurigged.codetools as jurigged_codetools # type: ignore + import jurigged.utils as jurigged_utils # type: ignore OrigFunctionDefinition = jurigged_codetools.FunctionDefinition @@ -86,17 +88,18 @@ def monkey_patch_jurigged_function_definition(): class NewFunctionDefinition(OrigFunctionDefinition): def reevaluate(self, new_node, glb): new_node = self.apply_assertion_rewrite(new_node, glb) - return super().reevaluate(new_node, glb) + obj = super().reevaluate(new_node, glb) + return obj def apply_assertion_rewrite(self, ast_func, glb): from _pytest.assertion.rewrite import AssertionRewriter - nodes: list[ast.AST] = [ast_func] + nodes: list[ast.AST] = [ast_func] # type: ignore while nodes: node = nodes.pop() for name, field in ast.iter_fields(node): if isinstance(field, list): - new: list[ast.AST] = [] + new: list[ast.AST] = [] # type: ignore for i, child in enumerate(field): if isinstance(child, ast.Assert): # Transform assert. @@ -117,6 +120,16 @@ def apply_assertion_rewrite(self, ast_func, glb): nodes.append(field) return ast_func + def stash(self, lineno=1, col_offset=0): + if not isinstance(self.parent, OrigFunctionDefinition): + co = self.get_object() + if co and (delta := lineno - co.co_firstlineno): + delta -= 1 # fix off-by-one + if delta > 0: + self.recode(jurigged_utils.shift_lineno(co, delta), use_cache=False) + + return super(OrigFunctionDefinition, self).stash(lineno, col_offset) + # monkey patch in new definition jurigged_codetools.FunctionDefinition = NewFunctionDefinition @@ -133,12 +146,14 @@ def _jurigged_logger(x: str) -> None: import jurigged + monkey_patch_jurigged_function_definition() + pattern = _get_pattern_filters(config) # TODO: intelligently use poll versus watchman (https://github.com/JamesHutchison/pytest-hot-reloading/issues/16) jurigged.watch(pattern=pattern, logger=_jurigged_logger, poll=True) -def _plugin_logic(config: Config) -> None: +def _plugin_logic(config: Config) -> int: """ The core plugin logic. This is where it splits based on whether we are the server or client. @@ -157,6 +172,7 @@ def _plugin_logic(config: Config) -> None: daemon = PytestDaemon(daemon_port=daemon_port) daemon.run_forever() + raise Exception("Daemon should never exit") else: pytest_name = config.option.pytest_name client = PytestClient(daemon_port=daemon_port, pytest_name=pytest_name) @@ -176,7 +192,8 @@ def _plugin_logic(config: Config) -> None: "Could not find pytest name in args. " "Check the configured name versus the actual name." ) - client.run(sys.argv[pytest_name_index + 1 :]) + status_code = client.run(sys.argv[pytest_name_index + 1 :]) + return status_code def _get_pattern_filters(config: Config) -> str | Callable[[str], bool]: @@ -218,10 +235,10 @@ def normalize(glob: str) -> str: else: ignore_regex_matches = [] - def matcher(filename) -> bool: + def matcher(filename: str) -> bool: if filename in seen_paths: return False - seen_paths.add(filename) + seen_paths.add(Path(filename)) if any(regex_match(filename) for regex_match in regex_matches): if not any( ignore_regex_match(filename) for ignore_regex_match in ignore_regex_matches diff --git a/pytest_hot_reloading/workarounds.py b/pytest_hot_reloading/workarounds.py new file mode 100644 index 0000000..b10eeaf --- /dev/null +++ b/pytest_hot_reloading/workarounds.py @@ -0,0 +1,72 @@ +from typing import Callable, Generator, NamedTuple, Optional + +Workaround = NamedTuple( + "Workaround", [("module", str), ("func", Callable[[], Optional[Generator]])] +) + +workarounds: list[Workaround] = [] + + +def register_workaround(module_name: str): + def _register_workaround(func: Callable[[], Optional[Generator]]) -> None: + """ + Register a workaround. A workaround is a function that takes in + a list of the arguments passed into pytest. The function may + be a generator function, and if so, yield will separate the pre + and post calls. + """ + workarounds.append(Workaround(module_name, func)) + + return _register_workaround + + +@register_workaround("xdist") +def xdist_workaround() -> None: + """ + pytest-xdist is not supported. The test collection behaves differently + and some libraries such as pytest-django may have bugs when its enabled. + """ + from xdist import plugin # type: ignore + + # monkey patch to force zero processes + plugin.parse_numprocesses = lambda s: None + + +@register_workaround("pytest_django") +def pytest_django_tox_workaround() -> None: + """ + pytest-django will attempt to add a suffix and they will accumulate with each run. + Note that running tox with hot reloading doesn't make sense anyways. + """ + from pytest_django import fixtures + + # monkey patch to disable suffix logic used by xdist and tox + fixtures._set_suffix_to_test_databases = lambda suffix: None + + +def run_workarounds_pre() -> list[Generator]: + in_progress_workarounds = [] + for module_name, workaround in workarounds: + try: + __import__(module_name) + except ImportError: + continue # not installed + try: + __import__(f"{module_name}._clear_hot_reload_workarounds") + except ImportError: + pass + else: + continue # workaround no longer needed + result = workaround() + if result is not None: + next(result) + in_progress_workarounds.append(result) + return in_progress_workarounds + + +def run_workarounds_post(in_progress_workarounds: list[Generator]) -> None: + for workaround in in_progress_workarounds: + try: + next(workaround) + except StopIteration: + pass diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_client.py b/tests/test_client.py index bc20ce9..8e2b1b5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,7 +3,7 @@ import xmlrpc.client import pytest -from megamock import Mega, MegaMock, MegaPatch +from megamock import Mega, MegaMock, MegaPatch # type: ignore from pytest_hot_reloading.client import PytestClient @@ -21,17 +21,19 @@ def test_run(self, capsys: pytest.CaptureFixture) -> None: return_value={ "stdout": xmlrpc.client.Binary("stdout".encode("utf-8")), "stderr": xmlrpc.client.Binary("stderr".encode("utf-8")), + "status_code": 1, } ) client = PytestClient() args = ["foo", "bar"] - client.run(args) + status_code = client.run(args) out, err = capsys.readouterr() assert re.match(r"Daemon took \S+ seconds to reply\nstdout\n", out) assert err == "stderr\n" + assert status_code == 1 def test_aborting_should_close_the_socket(self) -> None: mock = MegaMock.it(PytestClient) diff --git a/tests/test_samples.py b/tests/test_samples.py index 6376be1..464f413 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -1,7 +1,7 @@ import time from functools import lru_cache -from megamock import MegaMock, MegaPatch +from megamock import MegaMock, MegaPatch # type: ignore @lru_cache() diff --git a/tests/test_workarounds.py b/tests/test_workarounds.py new file mode 100644 index 0000000..6593360 --- /dev/null +++ b/tests/test_workarounds.py @@ -0,0 +1,76 @@ +from typing import Callable + +import pytest # type: ignore +from megamock import MegaPatch # type: ignore + +from pytest_hot_reloading import workarounds + + +@pytest.fixture(autouse=True) +def clear_workarounds() -> None: + new_workarounds: list[Callable] = [] + MegaPatch.it(workarounds.workarounds, new=new_workarounds) + + +def test_single_shot_workaround() -> None: + was_called = False + + @workarounds.register_workaround("tests.test_workarounds") + def my_workaround(): + nonlocal was_called + + was_called = True + + in_progress = workarounds.run_workarounds_pre() + assert len(in_progress) == 0 + workarounds.run_workarounds_post(in_progress) + + assert was_called + + +def test_multi_part_workaround() -> None: + status = 0 + + @workarounds.register_workaround("tests.test_workarounds") + def my_workaround(): + nonlocal status + + status += 1 + yield + status += 1 + + in_progress = workarounds.run_workarounds_pre() + assert len(in_progress) == 1 + workarounds.run_workarounds_post(in_progress) + + assert status == 2 + + +def test_not_installed_workaround() -> None: + was_called = False + + @workarounds.register_workaround("this.doesnt.exist") + def my_workaround(): + nonlocal was_called + + was_called = True + + in_progress = workarounds.run_workarounds_pre() + assert len(in_progress) == 0 + workarounds.run_workarounds_post(in_progress) + + assert was_called is False + + +def test_unregistered_workaround() -> None: + was_called = False + + @workarounds.register_workaround("tests.workarounds.unregistered_workaround") + def my_workaround(): + nonlocal was_called + + was_called = True + + workarounds.run_workarounds_pre() + + assert was_called is False diff --git a/tests/workarounds/__init__.py b/tests/workarounds/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/workarounds/pytest_django/__init__.py b/tests/workarounds/pytest_django/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/workarounds/pytest_django/alt_sqlite3_backend.py b/tests/workarounds/pytest_django/alt_sqlite3_backend.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/workarounds/pytest_django/docker-compose.yml b/tests/workarounds/pytest_django/docker-compose.yml new file mode 100644 index 0000000..7324364 --- /dev/null +++ b/tests/workarounds/pytest_django/docker-compose.yml @@ -0,0 +1,8 @@ +# a postgres container +services: + postgres: + image: postgres:12.2 + ports: + - "5432:5432" + environment: + - POSTGRES_HOST_AUTH_METHOD=trust diff --git a/tests/workarounds/pytest_django/settings.py b/tests/workarounds/pytest_django/settings.py new file mode 100644 index 0000000..1887238 --- /dev/null +++ b/tests/workarounds/pytest_django/settings.py @@ -0,0 +1,10 @@ +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "postgres", + "HOST": "localhost", + "PORT": 5432, + "USER": "postgres", + "PASSWORD": "", + } +} diff --git a/tests/workarounds/pytest_django/test_pytest_django_workaround.py b/tests/workarounds/pytest_django/test_pytest_django_workaround.py new file mode 100644 index 0000000..7218cc3 --- /dev/null +++ b/tests/workarounds/pytest_django/test_pytest_django_workaround.py @@ -0,0 +1,12 @@ +def test_settings_mutation_is_worked_around(django_db_setup, db): + from django.conf import settings # type: ignore + + databases = settings.DATABASES + + test_db_name: str = databases["default"]["NAME"] # type: ignore + assert ( + test_db_name.count("test_") == 1 + ), f"Counted {test_db_name.count('test_')} occurences of 'test_' in {test_db_name}" + assert ( + test_db_name.count("_tox") == 0 + ), f"Counted {test_db_name.count('_tox')} occurences of '_tox' in {test_db_name}" diff --git a/tests/workarounds/unregistered_workaround/__init__.py b/tests/workarounds/unregistered_workaround/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/workarounds/unregistered_workaround/_clear_hot_reload_workarounds.py b/tests/workarounds/unregistered_workaround/_clear_hot_reload_workarounds.py new file mode 100644 index 0000000..0011cde --- /dev/null +++ b/tests/workarounds/unregistered_workaround/_clear_hot_reload_workarounds.py @@ -0,0 +1 @@ +# blank module to signal that workarounds should NOT be executed