Skip to content

Commit 53505de

Browse files
committed
Improve CI
* Use teamcity-messages package to make TeamCity understand test results. * Run unit tests in parallel for each Python version to greatly reduce overall CI run-time.
1 parent 624d3c4 commit 53505de

File tree

7 files changed

+237
-4
lines changed

7 files changed

+237
-4
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ asyncio_mode = "auto"
128128
[[tool.mypy.overrides]]
129129
module = [
130130
"pandas.*",
131+
"teamcity.*",
131132
"neo4j._codec.packstream._rust",
132133
"neo4j._codec.packstream._rust.*",
133134
]

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ pytest-cov>=3.0.0
2525
pytest-mock>=3.6.1
2626
teamcity-messages>=1.29
2727
tox>=4.0.0
28+
teamcity-messages>=1.32
2829

2930
# needed for building docs
3031
sphinx

testkit/testkit.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"testkit": {
33
"uri": "https://github.com/neo4j-drivers/testkit.git",
4-
"ref": "5.0"
4+
"ref": "pass-teamcity-env-vars-to-containers"
55
}
66
}

testkit/unittests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@
1818

1919

2020
if __name__ == "__main__":
21-
run_python(["-m", "tox", "-vv", "-f", "unit"])
21+
run_python(["-m", "tox", "-vv", "--parallel-no-spinner", "-f", "unit"])

tests/_teamcity.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
# Copyright (c) "Neo4j"
2+
# Neo4j Sweden AB [https://neo4j.com]
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# https://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
17+
from __future__ import annotations
18+
19+
import os
20+
import re
21+
import time
22+
import typing as t
23+
24+
25+
if t.TYPE_CHECKING:
26+
import pytest
27+
28+
29+
__all__ = [
30+
"pytest_collection_finish",
31+
"pytest_runtest_logreport",
32+
"pytest_sessionstart",
33+
"pytest_unconfigure",
34+
]
35+
36+
37+
_ENABLED = os.environ.get("TEST_IN_TEAMCITY", "").upper() in {
38+
"TRUE",
39+
"1",
40+
"Y",
41+
"YES",
42+
"ON",
43+
}
44+
45+
46+
def _compute_suite_name() -> str | None:
47+
suite_name = os.environ.get("TEST_SUITE_NAME")
48+
neo4j_suite_name = os.environ.get("TEST_NEO4J_SUITE_NAME")
49+
if not suite_name:
50+
return neo4j_suite_name
51+
if not neo4j_suite_name:
52+
return suite_name
53+
return f"{neo4j_suite_name}-{suite_name}"
54+
55+
56+
_SUITE_NAME = _compute_suite_name()
57+
58+
59+
def _escape(s: object) -> str:
60+
s = str(s)
61+
s = s.replace("|", "||")
62+
s = s.replace("\n", "|n")
63+
s = s.replace("\r", "|r")
64+
s = s.replace("'", "|'")
65+
s = s.replace("[", "|[")
66+
s = s.replace("]", "|]")
67+
return s # noqa: RET504 - subjectively easier to read this way
68+
69+
70+
def _message(title: str, **entries: object) -> None:
71+
if "timestamp" not in entries:
72+
now = time.time()
73+
now_s, now_sub_s = divmod(now, 1)
74+
now_tuple = time.localtime(now_s)
75+
entries["timestamp"] = (
76+
time.strftime("%Y-%m-%dT%H:%M:%S", now_tuple)
77+
+ f".{int(now_sub_s * 1000):03}"
78+
)
79+
str_entries = " ".join(f"{k}='{_escape(v)}'" for k, v in entries.items())
80+
if str_entries:
81+
str_entries = " " + str_entries
82+
83+
print(f"\n##teamcity[{title}{str_entries}]", flush=True) # noqa: T201
84+
# [noqa] to allow print as that's the whole purpose of this
85+
# make-shift pytest plugin
86+
87+
88+
def pytest_sessionstart(session: pytest.Session) -> None:
89+
if not (_ENABLED and _SUITE_NAME):
90+
return
91+
_message("testSuiteStarted", name=_SUITE_NAME)
92+
93+
94+
def pytest_unconfigure(config: pytest.Config) -> None:
95+
if not (_ENABLED and _SUITE_NAME):
96+
return
97+
_message("testSuiteFinished", name=_SUITE_NAME)
98+
99+
100+
def pytest_collection_finish(session: pytest.Session) -> None:
101+
if not _ENABLED:
102+
return
103+
_message("testCount", count=len(session.items))
104+
105+
106+
# taken from teamcity-messages package
107+
# Copyright JetBrains, licensed under Apache 2.0
108+
# changes applied:
109+
# - non-functional changes (e.g., formatting, removed dead code)
110+
# - removed support for pep8-check and pylint
111+
def format_test_id(nodeid: str) -> str:
112+
test_id = nodeid
113+
114+
if test_id:
115+
if test_id.find("::") < 0:
116+
test_id += "::top_level"
117+
else:
118+
test_id = "top_level"
119+
120+
first_bracket = test_id.find("[")
121+
if first_bracket > 0:
122+
# [] -> (), make it look like nose parameterized tests
123+
params = "(" + test_id[first_bracket + 1 :]
124+
if params.endswith("]"):
125+
params = params[:-1] + ")"
126+
test_id = test_id[:first_bracket]
127+
if test_id.endswith("::"):
128+
test_id = test_id[:-2]
129+
else:
130+
params = ""
131+
132+
test_id = test_id.replace("::()::", "::")
133+
test_id = re.sub(r"\.pyc?::", r"::", test_id)
134+
test_id = test_id.replace(".", "_")
135+
test_id = test_id.replace(os.sep, ".")
136+
test_id = test_id.replace("/", ".")
137+
test_id = test_id.replace("::", ".")
138+
139+
if params:
140+
params = params.replace(".", "_")
141+
test_id += params
142+
143+
return test_id
144+
145+
146+
def _report_output(test_id: str, stdout: str, stderr: str) -> None:
147+
block_name = None
148+
if stdout or stderr:
149+
block_name = f"{test_id} output"
150+
_message("blockOpened", name=block_name)
151+
if stdout:
152+
_message("testStdOut", name=test_id, out=stdout)
153+
if stderr:
154+
_message("testStdErr", name=test_id, out=stderr)
155+
if block_name:
156+
_message("blockClosed", name=block_name)
157+
158+
159+
def _skip_reason(report: pytest.TestReport) -> str | None:
160+
if isinstance(report.longrepr, tuple):
161+
return report.longrepr[2]
162+
if isinstance(report.longrepr, str):
163+
return report.longrepr
164+
return None
165+
166+
167+
def _report_skip(test_id: str, reason: str | None) -> None:
168+
if reason is None:
169+
_message("testIgnored", name=test_id)
170+
else:
171+
_message("testIgnored", name=test_id, message=reason)
172+
173+
174+
def pytest_runtest_logreport(report: pytest.TestReport) -> None:
175+
if not _ENABLED:
176+
return
177+
178+
test_id = format_test_id(report.nodeid)
179+
180+
test_stdouts = []
181+
test_stderrs = []
182+
for section_name, section_data in report.sections:
183+
if not section_data:
184+
continue
185+
if "stdout" in section_name:
186+
test_stdouts.append(
187+
f"===== [{section_name}] =====\n{section_data}"
188+
)
189+
if "stderr" in section_name:
190+
test_stderrs.append(
191+
f"===== [{section_name}] =====\n{section_data}"
192+
)
193+
test_stdout = "\n".join(test_stdouts)
194+
test_stderr = "\n".join(test_stderrs)
195+
196+
if report.when == "teardown":
197+
_report_output(test_id, test_stdout, test_stderr)
198+
test_duration_ms = int(report.duration * 1000)
199+
_message("testFinished", name=test_id, duration=test_duration_ms)
200+
if report.outcome == "skipped":
201+
# a little late to skip the test, eh?
202+
test_stage_id = f"{test_id}___teardown"
203+
_report_skip(test_stage_id, _skip_reason(report))
204+
205+
if report.when in {"setup", "teardown"} and report.outcome == "failed":
206+
test_stage_id = f"{test_id}__{report.when}"
207+
_message("testStarted", name=test_stage_id)
208+
_report_output(test_stage_id, test_stdout, test_stderr)
209+
_message(
210+
"testFailed",
211+
name=test_stage_id,
212+
message=f"{report.when.capitalize()} failed",
213+
details=report.longreprtext,
214+
)
215+
_message("testFinished", name=test_stage_id)
216+
217+
if report.when == "setup":
218+
_message("testStarted", name=test_id)
219+
if report.outcome == "skipped":
220+
_report_skip(test_id, _skip_reason(report))
221+
222+
if report.when == "call":
223+
if report.outcome == "failed":
224+
_message("testFailed", name=test_id, message=report.longreprtext)
225+
elif report.outcome == "skipped":
226+
_report_skip(test_id, _skip_reason(report))

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from neo4j.debug import watch
3131

3232
from . import env
33+
from ._teamcity import * # noqa - needed for pytest to pick up the hooks
3334

3435

3536
# from neo4j.debug import watch

tox.ini

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ envlist = py{37,38,39,310,311,312,313}-{unit,integration,performance}
44
requires = virtualenv<20.22.0
55

66
[testenv]
7-
passenv = TEST_NEO4J_*
7+
passenv =
8+
TEST_*
89
deps = -r requirements-dev.txt
9-
setenv = COVERAGE_FILE={envdir}/.coverage
10+
setenv =
11+
COVERAGE_FILE={envdir}/.coverage
12+
TEST_SUITE_NAME={envname}
1013
usedevelop = true
1114
warnargs =
1215
py{37,38,39,310,311,312}: -W error
16+
parallel_show_output = true
1317
commands =
1418
coverage erase
1519
unit: coverage run -m pytest {[testenv]warnargs} -v {posargs} tests/unit

0 commit comments

Comments
 (0)