Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updates for VS Code #50

Merged
merged 11 commits into from
Nov 30, 2023
26 changes: 13 additions & 13 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,11 @@
"mhutchie.git-graph",
"eamodio.gitlens",
"charliermarsh.ruff",
"ms-azuretools.vscode-docker"
"ms-azuretools.vscode-docker",
"matangover.mypy"
],
"settings": {
"python.defaultInterpreterPath": "/workspaces/pytest-hot-reloading/.venv/bin/python",
"python.formatting.provider": "black",
"python.linting.mypyEnabled": true,
"python.linting.mypyPath": "dmypy",
"python.linting.mypyArgs": [
"run --",
"--ignore-missing-imports",
"--show-column-numbers",
"--no-pretty",
"--python-executable",
"/workspaces/pytest-hot-reloading/.venv/bin/python"
],
"python.testing.pytestArgs": [
"tests"
],
Expand All @@ -55,7 +45,17 @@
"autoDocstring.customTemplatePath": ".vscode/autodocstring.mustache",
"ruff.path": [
"/workspaces/pytest-hot-reloading/.venv/bin/ruff"
]
],
// use legacy test adapter
// "python.experiments.optOutFrom": [
// "pythonTestAdapter"
// ],
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.codeActionsOnSave": {
"source.organizeImports.ruff": true
}
}
},
"git.branchProtection": [
"main",
Expand Down
2 changes: 2 additions & 0 deletions .devcontainer/postStartBackground.sh
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
#!/usr/bin/env bash
poetry run pytest --daemon &

ptyme-track --standalone
2 changes: 1 addition & 1 deletion .github/actions/test/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ runs:
shell: bash
- name: Test with pytest
run: |
poetry run pytest
poetry run pytest --daemon-start-if-needed
shell: bash
- name: Rerun workaround tests to check for incompatibilities
run: |
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ The daemon can be stopped with `pytest --stop-daemon`. This can be used if it ge
- Default: `4852`.
- Command line: `--daemon-port`
- `PYTEST_DAEMON_PYTEST_NAME`
- The name of the pytest executable.
- The name of the pytest executable. Used for spawning the daemon.
- Default: `pytest`.
- Command line: `--pytest-name`
- `PYTEST_DAEMON_WATCH_GLOBS`
Expand All @@ -120,6 +120,10 @@ The daemon can be stopped with `pytest --stop-daemon`. This can be used if it ge
- The colon separated globs to ignore.
- Default: `./.venv/*`.
- Command line: `--daemon-ignore-watch-globs`
- `PYTEST_DAEMON_START_IF_NEEDED`
- Start the pytest daemon if it is not running.
- Default: `False`
- Command line: `--daemon-start-if-needed`

## Workarounds
Libraries that use mutated globals may need a workaround to work with this plugin. The preferred
Expand Down
203 changes: 58 additions & 145 deletions poetry.lock

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
[tool.ruff]
line-length = 120
line-length = 98

[tool.ruff.lint.pycodestyle]
max-line-length = 120

[tool.black]
line-length = 98
Expand All @@ -17,15 +20,14 @@ python = "^3.10"
jurigged = "^0.5.5"
cachetools = "^5.3.0"
types-cachetools = "^5.3.0.5"
megamock = "^0.1.0b9"

[tool.poetry.group.dev.dependencies]
mypy = "^1.2.0"
ruff = "^0.0.261"
black = "^23.3.0"
ruff = "^0.1.6"
pytest = "^7.2.2"
megamock = "^0.1.0b6"
pytest-django = "^4.5.2"
django = "^4.2.2"
django = "4.2.2"
psycopg2-binary = "^2.9.6"
pytest-env = "^0.8.1"
pytest-xdist = "^3.3.1"
Expand Down
38 changes: 29 additions & 9 deletions pytest_hot_reloading/client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import json
import os
import socket
import sys
import time
import xmlrpc.client
from pathlib import Path
from typing import cast


Expand All @@ -10,28 +13,42 @@ class PytestClient:
_daemon_host: str
_daemon_port: int
_pytest_name: str
_will_start_daemon_if_needed: bool

def __init__(
self, daemon_host: str = "localhost", daemon_port: int = 4852, pytest_name: str = "pytest"
self,
daemon_host: str = "localhost",
daemon_port: int = 4852,
pytest_name: str = "pytest",
start_daemon_if_needed: bool = False,
) -> None:
self._socket = None
self._daemon_host = daemon_host
self._daemon_port = daemon_port
self._pytest_name = pytest_name
self._will_start_daemon_if_needed = start_daemon_if_needed

def _get_server(self) -> xmlrpc.client.ServerProxy:
server_url = f"http://{self._daemon_host}:{self._daemon_port}"
server = xmlrpc.client.ServerProxy(server_url)

return server

def run(self, cwd: str, args: list[str]) -> int:
self._start_daemon_if_needed()
def run(self, cwd: Path, args: list[str]) -> int:
if self._will_start_daemon_if_needed:
self._start_daemon_if_needed()
elif not self._daemon_running():
raise Exception(
"Daemon is not running and must be started, or add --daemon-start-if-needed"
)

server = self._get_server()

env = os.environ.copy()
sys_path = sys.path

start = time.time()
result: dict = cast(dict, server.run_pytest(cwd, args))
result: dict = cast(dict, server.run_pytest(str(cwd), json.dumps(env), sys_path, args))
print(f"Daemon took {(time.time() - start):.3f} seconds to reply")

stdout = result["stdout"].data.decode("utf-8")
Expand Down Expand Up @@ -60,20 +77,23 @@ def abort(self) -> None:
if self._socket:
self._socket.close()

def _start_daemon_if_needed(self) -> None:
# check if the daemon is running on the expected host and port
# if not, start the daemon

def _daemon_running(self) -> bool:
# first, try to connect
try:
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._socket.connect((self._daemon_host, self._daemon_port))
# the daemon is running
# close the socket
self._socket.close()
return True
except ConnectionRefusedError:
# the daemon is not running
# start the daemon
return False

def _start_daemon_if_needed(self) -> None:
# check if the daemon is running on the expected host and port
# if not, start the daemon
if not self._daemon_running():
self._start_daemon()

def _start_daemon(self) -> None:
Expand Down
23 changes: 21 additions & 2 deletions pytest_hot_reloading/daemon.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import copy
import json
import os
import re
import socket
Expand Down Expand Up @@ -73,7 +74,7 @@ def stop(self) -> dict:
def wait_to_be_ready(host: str = "localhost", port: int = 4852) -> None:
# poll the connection to the daemon using sockets
# and return when it is ready
for _ in range(100):
for _ in range(1000):
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
Expand Down Expand Up @@ -119,7 +120,7 @@ def _kill_existing_daemon(self) -> None:
except FileNotFoundError:
raise Exception(f"Port {self._daemon_port} is already in use")

def run_pytest(self, cwd: str, args: list[str]) -> dict:
def run_pytest(self, cwd: str, env_json: str, sys_path: list[str], args: list[str]) -> dict:
# run pytest using command line args
# run the pytest main logic

Expand Down Expand Up @@ -152,15 +153,33 @@ def run_pytest(self, cwd: str, args: list[str]) -> dict:

# store current working directory
prev_cwd = os.getcwd()
# switch to client working directory
os.chdir(cwd)

# copy the environment
env_old = os.environ.copy()
# switch to client environment
new_env = json.loads(env_json)
os.environ.update(new_env)

# copy sys.path
sys_path_old = sys.path
# switch to client path
sys.path = sys_path

try:
# args must omit the calling program
status_code = pytest.main(["--color=yes"] + args)
finally:
os.chdir(prev_cwd)
self._workaround_library_issues_post(in_progress_workarounds)

# restore sys.path
sys.path = sys_path_old

# restore environment
os.environ.update(env_old)

# restore originals
_pytest.main._main = orig_main

Expand Down
44 changes: 21 additions & 23 deletions pytest_hot_reloading/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def pytest_addoption(parser) -> None:
"--pytest-name",
action="store",
default=os.getenv("PYTEST_DAEMON_PYTEST_NAME", "pytest"),
help="The name of the pytest executable or module",
help="The name of the pytest executable or module. This is used for starting the daemon.",
)
group.addoption(
"--daemon-timeout",
Expand All @@ -63,6 +63,15 @@ def pytest_addoption(parser) -> None:
default=False,
help="Stop the daemon",
)
group.addoption(
"--daemon-start-if-needed",
action="store_true",
default=os.getenv("PYTEST_DAEMON_START_IF_NEEDED", "False").lower() in ("true", "1"),
help=(
"Start the daemon if it is not running. To use this with VS Code, "
'you need add "python.experiments.optOutFrom": ["pythonTestAdapter"] to your config.'
),
)


# list of pytest hooks
Expand All @@ -78,6 +87,8 @@ def pytest_cmdline_main(config: Config) -> Optional[int]:
return None
if i_am_server:
return None
if config.option.help:
return None
status_code = _plugin_logic(config)
# dont do any more work. Don't let pytest continue
return status_code # status code 0
Expand Down Expand Up @@ -181,33 +192,20 @@ def _plugin_logic(config: Config) -> int:
sys.exit(0)
else:
pytest_name = config.option.pytest_name
client = PytestClient(daemon_port=daemon_port, pytest_name=pytest_name)
client = PytestClient(
daemon_port=daemon_port,
pytest_name=pytest_name,
start_daemon_if_needed=config.option.daemon_start_if_needed,
)

if config.option.stop_daemon:
client.stop()
return 0

# find the index of the first value that is not None
for idx, val in enumerate(
[
x.endswith(pytest_name) or x.endswith(f"{pytest_name}/__main__.py")
for x in sys.argv
]
):
if val:
pytest_name_index = idx
break
else:
if "pytest_runner" in sys.argv[0]:
pytest_name_index = 0
else:
print(sys.argv)
raise Exception(
"Could not find pytest name in args. "
"Check the configured name versus the actual name."
)
cwd = os.getcwd()
status_code = client.run(cwd, sys.argv[pytest_name_index + 1 :])
cwd = config.invocation_params.dir
args = list(config.invocation_params.args)

status_code = client.run(cwd, args)
return status_code


Expand Down
15 changes: 14 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import re
import socket
import xmlrpc.client
from pathlib import Path

import pytest
from megamock import Mega, MegaMock, MegaPatch
Expand All @@ -28,14 +29,26 @@ def test_run(self, capsys: pytest.CaptureFixture) -> None:
client = PytestClient()
args = ["foo", "bar"]

status_code = client.run(os.getcwd(), args)
status_code = client.run(Path(os.getcwd()), 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_when_sever_not_avaiable_then_raises_error(self) -> None:
client = PytestClient(start_daemon_if_needed=False)
MegaPatch.it(PytestClient._daemon_running, return_value=False)

with pytest.raises(Exception) as exc:
client.run(Path(), ["args"])

assert (
str(exc.value)
== "Daemon is not running and must be started, or add --daemon-start-if-needed"
)

def test_aborting_should_close_the_socket(self) -> None:
mock = MegaMock.it(PytestClient)
Mega(mock.abort).use_real_logic()
Expand Down