Skip to content

Commit

Permalink
Updates for VS Code (#50)
Browse files Browse the repository at this point in the history
* Make daemon auto-start a parameter that defaults to off
* Dev container updates
* Updates to client and server logic to improve information sent
* Add --daemon-start-if-needed argument
  • Loading branch information
JamesHutchison authored Nov 30, 2023
1 parent 4a46204 commit e42e136
Show file tree
Hide file tree
Showing 10 changed files with 171 additions and 200 deletions.
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

0 comments on commit e42e136

Please sign in to comment.