tests: speedups, lowest-version, ... (#6812)

This commit is contained in:
Maximilian Hils 2024-04-21 23:44:09 +02:00 committed by GitHub
parent 2c96c96e75
commit aedbde938a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 216 additions and 281 deletions

View File

@ -77,6 +77,19 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
test-old-dependencies:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version-file: .github/python-version.txt
- run: pip install tox-uv
- run: tox -e old-dependencies
build:
strategy:
fail-fast: false
@ -208,8 +221,9 @@ jobs:
- mypy
- individual-coverage
- test
- build
- test-old-dependencies
- test-web-ui
- build
- docs
uses: mhils/workflows/.github/workflows/alls-green.yml@main
with:

View File

@ -13,9 +13,9 @@ def get_local_ip(reachable: str = "8.8.8.8") -> str | None:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect((reachable, 80))
return s.getsockname()[0]
return s.getsockname()[0] # pragma: no cover
except OSError:
return None
return None # pragma: no cover
finally:
s.close()
@ -29,8 +29,8 @@ def get_local_ip6(reachable: str = "2001:4860:4860::8888") -> str | None:
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
try:
s.connect((reachable, 80))
return s.getsockname()[0]
return s.getsockname()[0] # pragma: no cover
except OSError:
return None
return None # pragma: no cover
finally:
s.close()

View File

@ -204,7 +204,9 @@ class ServerInstance(Generic[M], metaclass=ABCMeta):
else:
handler.layer.context.client.sockname = original_dst
handler.layer.context.server.address = original_dst
elif isinstance(self.mode, (mode_specs.WireGuardMode, mode_specs.LocalMode)):
elif isinstance(
self.mode, (mode_specs.WireGuardMode, mode_specs.LocalMode)
): # pragma: no cover on platforms without wg-test-client
handler.layer.context.server.address = writer.get_extra_info(
"remote_endpoint", handler.layer.context.client.sockname
)
@ -325,7 +327,9 @@ class WireGuardServerInstance(ServerInstance[mode_specs.WireGuardMode]):
server_key: str
client_key: str
def make_top_layer(self, context: Context) -> Layer:
def make_top_layer(
self, context: Context
) -> Layer: # pragma: no cover on platforms without wg-test-client
return layers.modes.TransparentProxy(context)
@property
@ -418,7 +422,9 @@ class WireGuardServerInstance(ServerInstance[mode_specs.WireGuardMode]):
finally:
self._server = None
async def wg_handle_stream(self, stream: mitmproxy_rs.Stream) -> None:
async def wg_handle_stream(
self, stream: mitmproxy_rs.Stream
) -> None: # pragma: no cover on platforms without wg-test-client
await self.handle_stream(stream, stream)

View File

@ -36,7 +36,7 @@ dependencies = [
"Brotli>=1.0,<1.2",
"certifi>=2019.9.11", # no semver here - this should always be on the last release!
"cryptography>=42.0,<42.1",
"flask>=1.1.1,<3.1",
"flask>=3.0,<3.1",
"h11>=0.11,<0.15",
"h2>=4.1,<5",
"hyperframe>=6.0,<7",
@ -57,7 +57,7 @@ dependencies = [
"urwid-mitmproxy>=2.1.1,<2.2",
"wsproto>=1.0,<1.3",
"publicsuffix2>=2.20190812,<3",
"zstandard>=0.11,<0.23",
"zstandard>=0.15,<0.23",
]
[project.optional-dependencies]
@ -66,11 +66,11 @@ dev = [
"hypothesis>=5.8,<7",
"pdoc>=4.0.0",
"pyinstaller==6.5.0",
"pytest-asyncio>=0.23,<0.24",
"pytest-cov>=2.7.1,<5.1",
"pytest-timeout>=1.3.3,<2.4",
"pytest-xdist>=2.1.0,<3.6",
"pytest>=6.1.0,<9",
"pytest-asyncio>=0.23.6,<0.24",
"pytest-cov>=5.0.0,<5.1",
"pytest-timeout>=2.3.1,<2.4",
"pytest-xdist>=3.6.0,<3.7",
"pytest>=8.1.1,<9",
"requests>=2.9.1,<3",
"tox>=3.5,<5",
"wheel>=0.36.2,<0.44",
@ -137,6 +137,93 @@ filterwarnings = [
"default:coroutine 'ConnectionHandler.hook_task' was never awaited:RuntimeWarning",
]
[tool.pytest.individual_coverage]
exclude = [
"mitmproxy/addons/__init__.py",
"mitmproxy/addons/onboarding.py",
"mitmproxy/addons/onboardingapp/__init__.py",
"mitmproxy/contentviews/__init__.py",
"mitmproxy/contentviews/base.py",
"mitmproxy/contentviews/grpc.py",
"mitmproxy/contentviews/image/__init__.py",
"mitmproxy/contrib/*",
"mitmproxy/ctx.py",
"mitmproxy/exceptions.py",
"mitmproxy/flow.py",
"mitmproxy/io/__init__.py",
"mitmproxy/io/io.py",
"mitmproxy/io/tnetstring.py",
"mitmproxy/log.py",
"mitmproxy/master.py",
"mitmproxy/net/check.py",
"mitmproxy/net/http/cookies.py",
"mitmproxy/net/http/http1/__init__.py",
"mitmproxy/net/http/multipart.py",
"mitmproxy/net/tls.py",
"mitmproxy/platform/__init__.py",
"mitmproxy/platform/linux.py",
"mitmproxy/platform/openbsd.py",
"mitmproxy/platform/osx.py",
"mitmproxy/platform/pf.py",
"mitmproxy/platform/windows.py",
"mitmproxy/proxy/__init__.py",
"mitmproxy/proxy/layers/__init__.py",
"mitmproxy/proxy/layers/http/__init__.py",
"mitmproxy/proxy/layers/http/_base.py",
"mitmproxy/proxy/layers/http/_events.py",
"mitmproxy/proxy/layers/http/_hooks.py",
"mitmproxy/proxy/layers/http/_http1.py",
"mitmproxy/proxy/layers/http/_http2.py",
"mitmproxy/proxy/layers/http/_http3.py",
"mitmproxy/proxy/layers/http/_http_h2.py",
"mitmproxy/proxy/layers/http/_http_h3.py",
"mitmproxy/proxy/layers/http/_upstream_proxy.py",
"mitmproxy/proxy/layers/tls.py",
"mitmproxy/proxy/server.py",
"mitmproxy/script/__init__.py",
"mitmproxy/test/taddons.py",
"mitmproxy/test/tflow.py",
"mitmproxy/test/tutils.py",
"mitmproxy/tools/console/__init__.py",
"mitmproxy/tools/console/commander/commander.py",
"mitmproxy/tools/console/commandexecutor.py",
"mitmproxy/tools/console/commands.py",
"mitmproxy/tools/console/common.py",
"mitmproxy/tools/console/consoleaddons.py",
"mitmproxy/tools/console/eventlog.py",
"mitmproxy/tools/console/flowdetailview.py",
"mitmproxy/tools/console/flowlist.py",
"mitmproxy/tools/console/flowview.py",
"mitmproxy/tools/console/grideditor/__init__.py",
"mitmproxy/tools/console/grideditor/base.py",
"mitmproxy/tools/console/grideditor/col_bytes.py",
"mitmproxy/tools/console/grideditor/col_subgrid.py",
"mitmproxy/tools/console/grideditor/col_text.py",
"mitmproxy/tools/console/grideditor/col_viewany.py",
"mitmproxy/tools/console/grideditor/editors.py",
"mitmproxy/tools/console/help.py",
"mitmproxy/tools/console/keybindings.py",
"mitmproxy/tools/console/keymap.py",
"mitmproxy/tools/console/layoutwidget.py",
"mitmproxy/tools/console/master.py",
"mitmproxy/tools/console/options.py",
"mitmproxy/tools/console/overlay.py",
"mitmproxy/tools/console/quickhelp.py",
"mitmproxy/tools/console/searchable.py",
"mitmproxy/tools/console/signals.py",
"mitmproxy/tools/console/statusbar.py",
"mitmproxy/tools/console/tabs.py",
"mitmproxy/tools/console/window.py",
"mitmproxy/tools/main.py",
"mitmproxy/tools/web/__init__.py",
"mitmproxy/tools/web/app.py",
"mitmproxy/tools/web/master.py",
"mitmproxy/tools/web/webaddons.py",
"mitmproxy/utils/bits.py",
"mitmproxy/utils/pyinstaller/*",
"mitmproxy/utils/vt_codes.py",
]
[tool.mypy]
check_untyped_defs = true
ignore_missing_imports = true
@ -164,12 +251,14 @@ module = "test.*"
ignore_errors = true
[tool.ruff]
select = ["E", "F", "I"]
extend-exclude = ["mitmproxy/contrib/"]
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = ["F541", "E501"]
[tool.ruff.isort]
[tool.ruff.lint.isort]
# these rules are a bit weird, but they mimic our existing reorder_python_imports style.
# if we break compatibility here, consider removing all customization + enforce absolute imports.
force-single-line = true
@ -191,15 +280,18 @@ deps =
setenv = HOME = {envtmpdir}
commands =
mitmdump --version
pytest --timeout 60 -vv --cov-report xml \
pytest --timeout 60 -vv \
--cov-report xml \
--continue-on-collection-errors \
--cov=mitmproxy --cov=release \
--full-cov=mitmproxy/ \
{posargs}
[testenv:old-dependencies]
uv_resolution = lowest-direct
[testenv:lint]
deps =
ruff>=0.1.3,<0.2
ruff>=0.4.1,<0.5
commands =
ruff .

View File

@ -1,31 +0,0 @@
[tool:full_coverage]
exclude =
mitmproxy/tools/
release/hooks
[tool:individual_coverage]
exclude =
mitmproxy/addons/onboarding.py
mitmproxy/connections.py
mitmproxy/contentviews/base.py
mitmproxy/contentviews/grpc.py
mitmproxy/ctx.py
mitmproxy/exceptions.py
mitmproxy/flow.py
mitmproxy/io/io.py
mitmproxy/io/tnetstring.py
mitmproxy/log.py
mitmproxy/master.py
mitmproxy/net/check.py
mitmproxy/net/http/cookies.py
mitmproxy/net/http/message.py
mitmproxy/net/http/multipart.py
mitmproxy/net/tls.py
mitmproxy/net/udp_wireguard.py
mitmproxy/options.py
mitmproxy/proxy/config.py
mitmproxy/proxy/server.py
mitmproxy/proxy/layers/tls.py
mitmproxy/utils/bits.py
mitmproxy/utils/vt_codes.py
mitmproxy/utils/pyinstaller

View File

@ -9,8 +9,6 @@ import pytest
from mitmproxy.utils import data
pytest_plugins = ("test.full_coverage_plugin",)
skip_windows = pytest.mark.skipif(os.name == "nt", reason="Skipping due to Windows")
skip_not_windows = pytest.mark.skipif(

View File

@ -1,144 +0,0 @@
import configparser
import os
import sys
import pytest
here = os.path.abspath(os.path.dirname(__file__))
enable_coverage = False
coverage_values = []
coverage_passed = True
no_full_cov = []
def pytest_addoption(parser):
parser.addoption(
"--full-cov",
action="append",
dest="full_cov",
default=[],
help="Require full test coverage of 100%% for this module/path/filename (multi-allowed). Default: none",
)
parser.addoption(
"--no-full-cov",
action="append",
dest="no_full_cov",
default=[],
help="Exclude file from a parent 100%% coverage requirement (multi-allowed). Default: none",
)
def pytest_configure(config):
global enable_coverage
global no_full_cov
enable_coverage = (
config.getoption("file_or_dir")
and len(config.getoption("file_or_dir")) == 0
and config.getoption("full_cov")
and len(config.getoption("full_cov")) > 0
and config.pluginmanager.getplugin("_cov") is not None
and config.pluginmanager.getplugin("_cov").cov_controller is not None
and config.pluginmanager.getplugin("_cov").cov_controller.cov is not None
)
c = configparser.ConfigParser()
c.read(os.path.join(here, "..", "setup.cfg"))
fs = c["tool:full_coverage"]["exclude"].split("\n")
no_full_cov = config.option.no_full_cov + [f.strip() for f in fs]
@pytest.hookimpl(hookwrapper=True)
def pytest_runtestloop(session):
global enable_coverage
global coverage_values
global coverage_passed
global no_full_cov
if not enable_coverage:
yield
return
cov = session.config.pluginmanager.getplugin("_cov").cov_controller.cov
if os.name == "nt":
cov.exclude("pragma: windows no cover")
if sys.platform == "darwin":
cov.exclude("pragma: osx no cover")
if os.environ.get("OPENSSL") == "old":
cov.exclude("pragma: openssl-old no cover")
yield
coverage_values = {name: 0 for name in session.config.option.full_cov}
prefix = os.getcwd()
excluded_files = [os.path.normpath(f) for f in no_full_cov]
measured_files = [
os.path.normpath(os.path.relpath(f, prefix))
for f in cov.get_data().measured_files()
]
measured_files = [
f
for f in measured_files
if not any(f.startswith(excluded_f) for excluded_f in excluded_files)
]
for name in coverage_values.keys():
files = [f for f in measured_files if f.startswith(os.path.normpath(name))]
try:
with open(os.devnull, "w") as null:
overall = cov.report(files, ignore_errors=True, file=null)
singles = [
(s, cov.report(s, ignore_errors=True, file=null)) for s in files
]
coverage_values[name] = (overall, singles)
except Exception:
pass
if any(v < 100 for v, _ in coverage_values.values()):
# make sure we get the EXIT_TESTSFAILED exit code
session.testsfailed += 1
coverage_passed = False
def pytest_terminal_summary(terminalreporter, exitstatus, config):
global enable_coverage
global coverage_values
global coverage_passed
global no_full_cov
if not enable_coverage:
return
terminalreporter.write("\n")
if not coverage_passed:
markup = {"red": True, "bold": True}
msg = "FAIL: Full test coverage not reached!\n"
terminalreporter.write(msg, **markup)
for name in sorted(coverage_values.keys()):
msg = f"Coverage for {name}: {coverage_values[name][0]:.2f}%\n"
if coverage_values[name][0] < 100:
markup = {"red": True, "bold": True}
for s, v in sorted(coverage_values[name][1]):
if v < 100:
msg += f" {s}: {v:.2f}%\n"
else:
markup = {"green": True}
terminalreporter.write(msg, **markup)
else:
msg = "SUCCESS: Full test coverage reached in modules and files:\n"
msg += "{}\n\n".format("\n".join(config.option.full_cov))
terminalreporter.write(msg, green=True)
msg = "\nExcluded files:\n"
for s in sorted(no_full_cov):
msg += f" {s}\n"
terminalreporter.write(msg)

View File

@ -1,108 +1,104 @@
#!/usr/bin/env python3
import configparser
import contextlib
import glob
import io
import itertools
import multiprocessing
import asyncio
import fnmatch
import os
import re
import subprocess
import sys
from pathlib import Path
import pytest
import tomllib
root = Path(__file__).parent.parent.absolute()
def run_tests(src, test, fail):
stderr = io.StringIO()
stdout = io.StringIO()
with contextlib.redirect_stderr(stderr):
with contextlib.redirect_stdout(stdout):
e = pytest.main(
[
async def main():
with open("pyproject.toml", "rb") as f:
data = tomllib.load(f)
exclude = re.compile(
"|".join(
f"({fnmatch.translate(x)})"
for x in data["tool"]["pytest"]["individual_coverage"]["exclude"]
)
)
sem = asyncio.Semaphore(os.cpu_count() or 1)
async def run_tests(f: Path, should_fail: bool) -> None:
if f.name == "__init__.py":
test_file = Path("test") / f.parent.with_name(f"test_{f.parent.name}.py")
else:
test_file = Path("test") / f.with_name(f"test_{f.name}")
coverage_file = f".coverage-{str(f).replace('/','-')}"
async with sem:
try:
proc = await asyncio.create_subprocess_exec(
"pytest",
"-qq",
"--disable-pytest-warnings",
"--cov",
src.replace(".py", "").replace("/", "."),
str(f.with_suffix("")).replace("/", "."),
"--cov-fail-under",
"100",
"--cov-report",
"term-missing:skip-covered",
"-o",
"faulthandler_timeout=0",
test,
]
)
test_file,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={
"COVERAGE_FILE": coverage_file,
**os.environ,
},
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), 60)
except TimeoutError:
raise RuntimeError(f"{f}: timeout")
finally:
Path(coverage_file).unlink(missing_ok=True)
if e == 0:
if fail:
print(
"FAIL DUE TO UNEXPECTED SUCCESS:",
src,
"Please remove this file from setup.cfg tool:individual_coverage/exclude.",
)
e = 42
else:
print(".")
else:
if fail:
print("Ignoring allowed fail:", src)
e = 0
else:
cov = [
line
for line in stdout.getvalue().split("\n")
if (src in line) or ("was never imported" in line)
]
if len(cov) == 1:
print("FAIL:", cov[0])
if should_fail:
if proc.returncode != 0:
print(f"{f}: excluded")
else:
raise RuntimeError(
f"{f} is now fully covered by {test_file}. Remove it from tool.pytest.individual_coverage in pyproject.toml."
)
else:
print("FAIL:", src, test, stdout.getvalue(), stdout.getvalue())
print(stderr.getvalue())
print(stdout.getvalue())
if proc.returncode == 0:
print(f"{f}: ok")
else:
raise RuntimeError(
f"{f} is not fully covered by {test_file}:\n{stdout.decode(errors='ignore')}\n{stderr.decode(errors='ignore')}"
)
sys.exit(e)
tasks = []
for f in (root / "mitmproxy").glob("**/*.py"):
f = f.relative_to(root)
if len(sys.argv) > 1 and sys.argv[1] not in str(f):
continue
def start_pytest(src, test, fail):
# run pytest in a new process, otherwise imports and modules might conflict
proc = multiprocessing.Process(target=run_tests, args=(src, test, fail))
proc.start()
proc.join()
return (src, test, proc.exitcode)
if f.name == "__init__.py" and f.stat().st_size == 0:
print(f"{f}: empty")
continue
def main():
c = configparser.ConfigParser()
c.read("setup.cfg")
fs = c["tool:individual_coverage"]["exclude"].strip().split("\n")
no_individual_cov = [f.strip() for f in fs]
excluded = [
"mitmproxy/contrib/",
"mitmproxy/test/",
"mitmproxy/tools/",
"mitmproxy/platform/",
]
src_files = glob.glob("mitmproxy/**/*.py", recursive=True)
src_files = [f for f in src_files if os.path.basename(f) != "__init__.py"]
src_files = [
f for f in src_files if not any(os.path.normpath(p) in f for p in excluded)
]
if len(sys.argv) > 1:
src_files = [f for f in src_files if sys.argv[1] in str(f)]
ps = []
for src in sorted(src_files):
test = os.path.join(
"test", os.path.dirname(src), "test_" + os.path.basename(src)
tasks.append(
asyncio.create_task(run_tests(f, should_fail=exclude.match(str(f))))
)
if os.path.isfile(test):
ps.append((src, test, src in no_individual_cov))
result = list(itertools.starmap(start_pytest, ps))
exit_code = 0
for task in asyncio.as_completed(tasks):
try:
await task
except RuntimeError as e:
print(e)
exit_code = 1
if any(e != 0 for _, _, e in result):
sys.exit(1)
sys.exit(exit_code)
if __name__ == "__main__":
main()
asyncio.run(main())

View File

@ -1 +1,5 @@
# TODO: write tests
from mitmproxy import options
def test_simple():
assert options.Options()