pytest-fly 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_fly/__init__.py +1 -0
- pytest_fly/__main__.py +13 -0
- pytest_fly/__version__.py +2 -0
- pytest_fly/db.py +207 -0
- pytest_fly/plugin.py +19 -0
- pytest_fly/report_converter.py +67 -0
- pytest_fly/utilization.py +58 -0
- pytest_fly/visualization.py +85 -0
- pytest_fly-0.0.1.dist-info/METADATA +59 -0
- pytest_fly-0.0.1.dist-info/RECORD +13 -0
- pytest_fly-0.0.1.dist-info/WHEEL +4 -0
- pytest_fly-0.0.1.dist-info/entry_points.txt +2 -0
- pytest_fly-0.0.1.dist-info/licenses/LICENSE +22 -0
pytest_fly/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .plugin import pytest_addoption, pytest_runtest_logreport, pytest_sessionfinish
|
pytest_fly/__main__.py
ADDED
pytest_fly/db.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from msqlite import MSQLite
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from functools import cache
|
|
7
|
+
from logging import getLogger
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
|
|
12
|
+
from appdirs import user_data_dir
|
|
13
|
+
|
|
14
|
+
from _pytest.reports import BaseReport
|
|
15
|
+
|
|
16
|
+
from src.pytest_fly.report_converter import report_to_json
|
|
17
|
+
|
|
18
|
+
from .__version__ import author, application_name
|
|
19
|
+
|
|
20
|
+
g_table_name = "" # will get set at initialization
|
|
21
|
+
fly_db_path = Path(user_data_dir(application_name, author), f"{application_name}.db")
|
|
22
|
+
|
|
23
|
+
log = getLogger(application_name)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def set_db_path(db_path: Path | str):
|
|
27
|
+
global fly_db_path
|
|
28
|
+
fly_db_path = Path(db_path)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_db_path() -> Path:
|
|
32
|
+
fly_db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
return Path(fly_db_path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_table_name(table_name: str):
|
|
37
|
+
global g_table_name
|
|
38
|
+
g_table_name = table_name
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_table_name() -> str:
|
|
42
|
+
return g_table_name
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@cache
|
|
46
|
+
def _get_process_guid() -> str:
|
|
47
|
+
"""
|
|
48
|
+
Get a unique guid for this process by using functools.cache.
|
|
49
|
+
:return: GUID string
|
|
50
|
+
"""
|
|
51
|
+
return str(uuid.uuid4())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# "when" is a keyword in SQLite so use "pt_when"
|
|
55
|
+
fly_schema = {"id PRIMARY KEY": int, "ts": float, "uid": str, "pt_when": str, "nodeid": str, "report": json}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_table_name_from_report(report: BaseReport) -> str:
|
|
59
|
+
"""
|
|
60
|
+
Get the table name from the report file path
|
|
61
|
+
"""
|
|
62
|
+
table_name = Path(report.fspath).parts[0]
|
|
63
|
+
set_table_name(table_name)
|
|
64
|
+
return table_name
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write_report(report: BaseReport):
|
|
68
|
+
"""
|
|
69
|
+
Write a pytest report to the database
|
|
70
|
+
:param report: pytest report
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
testrun_uid = report.testrun_uid # pytest-xdist
|
|
74
|
+
is_xdist = True
|
|
75
|
+
except AttributeError:
|
|
76
|
+
testrun_uid = _get_process_guid() # single threaded
|
|
77
|
+
is_xdist = False
|
|
78
|
+
table_name = get_table_name_from_report(report)
|
|
79
|
+
pt_when = report.when
|
|
80
|
+
node_id = report.nodeid
|
|
81
|
+
setattr(report, "is_xdist", is_xdist) # signify if we're running pytest-xdist or not
|
|
82
|
+
db_path = get_db_path()
|
|
83
|
+
with MSQLite(db_path, table_name, fly_schema) as db:
|
|
84
|
+
report_json = report_to_json(report)
|
|
85
|
+
statement = f"INSERT OR REPLACE INTO {table_name} (ts, uid, pt_when, nodeid, report) VALUES ({time.time()}, '{testrun_uid}', '{pt_when}', '{node_id}', '{report_json}')"
|
|
86
|
+
try:
|
|
87
|
+
db.execute(statement)
|
|
88
|
+
except sqlite3.OperationalError as e:
|
|
89
|
+
log.error(f"{e}:{statement}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_json_objects_by_uid(uid: str, table_name: str = get_table_name()):
|
|
93
|
+
with MSQLite(get_db_path(), table_name) as db:
|
|
94
|
+
statement = f"SELECT * FROM {table_name} WHERE uid = {uid}"
|
|
95
|
+
rows = db.execute(statement)
|
|
96
|
+
return rows
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_all_test_run_ids(table_name: str = get_table_name()) -> set[str]:
|
|
100
|
+
with MSQLite(get_db_path(), table_name) as db:
|
|
101
|
+
test_run_ids = set()
|
|
102
|
+
rows = db.execute(f"SELECT * FROM {table_name}")
|
|
103
|
+
for row in rows:
|
|
104
|
+
run_id = row[2]
|
|
105
|
+
test_run_ids.add(run_id)
|
|
106
|
+
return test_run_ids
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
meta_session_table_name = "_session"
|
|
110
|
+
meta_session_schema = {"id PRIMARY KEY": int, "ts": float, "test_name": str, "state": str}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _get_most_recent_row_values(db) -> tuple[int | None, str | None, float | None]:
|
|
114
|
+
statement = f"SELECT * FROM {meta_session_table_name} ORDER BY ts DESC LIMIT 1"
|
|
115
|
+
rows = list(db.execute(statement))
|
|
116
|
+
row = rows[0] if len(rows) > 0 else None
|
|
117
|
+
if row is None:
|
|
118
|
+
id_state_ts = None, None, None
|
|
119
|
+
else:
|
|
120
|
+
id_state_ts = row[0], row[3], row[1]
|
|
121
|
+
return id_state_ts
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def write_start():
|
|
125
|
+
db_path = get_db_path()
|
|
126
|
+
with MSQLite(db_path, meta_session_table_name, meta_session_schema) as db:
|
|
127
|
+
# get the most recent state
|
|
128
|
+
id_value, state, ts = _get_most_recent_row_values(db)
|
|
129
|
+
if state != "start":
|
|
130
|
+
statement = f"INSERT OR REPLACE INTO {meta_session_table_name} (ts, state) VALUES ({time.time()}, 'start')"
|
|
131
|
+
db.execute(statement)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def write_finish(test_name: str):
|
|
135
|
+
db_path = get_db_path()
|
|
136
|
+
with MSQLite(db_path, meta_session_table_name, meta_session_schema) as db:
|
|
137
|
+
id_value, state, ts = _get_most_recent_row_values(db)
|
|
138
|
+
now = time.time()
|
|
139
|
+
if state == "start":
|
|
140
|
+
statement = f"INSERT INTO {meta_session_table_name} (ts, test_name, state) VALUES ({now}, '{test_name}', 'finish')"
|
|
141
|
+
else:
|
|
142
|
+
statement = f"UPDATE {meta_session_table_name} SET ts = {now}, state = 'finish' WHERE id = {id_value}"
|
|
143
|
+
db.execute(statement)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_most_recent_start_and_finish() -> tuple[str | None, float | None, float | None]:
|
|
147
|
+
db_path = get_db_path()
|
|
148
|
+
with MSQLite(db_path, meta_session_table_name, meta_session_schema) as db:
|
|
149
|
+
statement = f"SELECT * FROM {meta_session_table_name} ORDER BY ts DESC LIMIT 2"
|
|
150
|
+
rows = list(db.execute(statement))
|
|
151
|
+
start_ts = rows[1][1]
|
|
152
|
+
finish_ts = rows[0][1]
|
|
153
|
+
test_name = rows[0][2]
|
|
154
|
+
return test_name, start_ts, finish_ts
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class RunInfo:
|
|
159
|
+
worker_id: str | None = None
|
|
160
|
+
start: float | None = None
|
|
161
|
+
stop: float | None = None
|
|
162
|
+
passed: bool | None = None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class RunInfoKey:
|
|
167
|
+
test_id: str
|
|
168
|
+
when: str
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_most_recent_run_info() -> dict[str, dict[str, RunInfo]]:
|
|
172
|
+
test_name, start_ts, finish_ts = get_most_recent_start_and_finish()
|
|
173
|
+
db_path = get_db_path()
|
|
174
|
+
with MSQLite(db_path, test_name) as db:
|
|
175
|
+
statement = f"SELECT * FROM {test_name} WHERE ts >= {start_ts} and ts <= {finish_ts} ORDER BY ts"
|
|
176
|
+
rows = list(db.execute(statement))
|
|
177
|
+
run_infos = {}
|
|
178
|
+
for row in rows:
|
|
179
|
+
test_data = json.loads(row[-1])
|
|
180
|
+
test_id = test_data["nodeid"]
|
|
181
|
+
worker_id = test_data.get("worker_id")
|
|
182
|
+
when = test_data.get("when")
|
|
183
|
+
start = test_data.get("start")
|
|
184
|
+
stop = test_data.get("stop")
|
|
185
|
+
passed = test_data.get("passed")
|
|
186
|
+
if test_id in run_infos:
|
|
187
|
+
run_info = run_infos[test_id]
|
|
188
|
+
if start is not None:
|
|
189
|
+
if run_info[when].start is None:
|
|
190
|
+
run_info[when].start = start
|
|
191
|
+
else:
|
|
192
|
+
run_info[when].start = min(run_info[when].start, start)
|
|
193
|
+
if stop is not None:
|
|
194
|
+
if run_info[when].stop is None:
|
|
195
|
+
run_info[when].stop = stop
|
|
196
|
+
else:
|
|
197
|
+
run_info[when].stop = max(run_info[when].stop, stop)
|
|
198
|
+
if passed is not None:
|
|
199
|
+
run_info[when].passed = passed
|
|
200
|
+
if worker_id is not None:
|
|
201
|
+
run_info[when].worker_id = worker_id
|
|
202
|
+
else:
|
|
203
|
+
run_infos[test_id] = defaultdict(RunInfo)
|
|
204
|
+
run_infos[test_id][when] = RunInfo(worker_id, start, stop, passed)
|
|
205
|
+
# convert defaultdict to dict
|
|
206
|
+
run_infos = {test_id: dict(run_info) for test_id, run_info in run_infos.items()}
|
|
207
|
+
return run_infos
|
pytest_fly/plugin.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from _pytest.reports import BaseReport
|
|
3
|
+
from src.pytest_fly.db import write_report, write_start, write_finish
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pytest_addoption(parser):
|
|
7
|
+
write_start()
|
|
8
|
+
parser.addoption("--fly", action="store_true")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.hookimpl(tryfirst=True)
|
|
12
|
+
def pytest_runtest_logreport(report: BaseReport):
|
|
13
|
+
write_report(report)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.hookimpl(trylast=True)
|
|
17
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
18
|
+
table_name = session.startpath.name
|
|
19
|
+
write_finish(table_name)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from typing import Sized, Any
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
import platform
|
|
5
|
+
import getpass
|
|
6
|
+
from functools import cache
|
|
7
|
+
|
|
8
|
+
from _pytest.reports import BaseReport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@cache
|
|
12
|
+
def get_username() -> str:
|
|
13
|
+
"""
|
|
14
|
+
Get the username of the current user
|
|
15
|
+
"""
|
|
16
|
+
return getpass.getuser()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@cache
|
|
20
|
+
def get_computer_name() -> str:
|
|
21
|
+
"""
|
|
22
|
+
Get the computer name
|
|
23
|
+
"""
|
|
24
|
+
return platform.node()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _convert_report_to_dict(report: BaseReport) -> dict[str, Any]:
|
|
28
|
+
"""
|
|
29
|
+
Convert a pytest Report to a dict, excluding zero-length iterables, None values, and other data that's not serializable. This isn't perfect but it seems to preserve
|
|
30
|
+
the data we're interested in.
|
|
31
|
+
:param report: Pytest Report
|
|
32
|
+
:return: a dict representation of the report
|
|
33
|
+
"""
|
|
34
|
+
report_dict = {}
|
|
35
|
+
for attr in dir(report):
|
|
36
|
+
# Exclude private attributes and methods, None, and zero-length lists
|
|
37
|
+
if not attr.startswith("__") and (value := getattr(report, attr)) is not None:
|
|
38
|
+
has_size = isinstance(value, Sized)
|
|
39
|
+
if not has_size or has_size and len(value) > 0:
|
|
40
|
+
# Check if the attribute is serializable
|
|
41
|
+
try:
|
|
42
|
+
json.dumps(value)
|
|
43
|
+
report_dict[attr] = value
|
|
44
|
+
except TypeError:
|
|
45
|
+
# a string representation starting with "<" generally isn't a useful serialization, so ignore it
|
|
46
|
+
if not (s := str(value)).startswith("<"):
|
|
47
|
+
report_dict[attr] = s
|
|
48
|
+
return report_dict
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def report_to_json(report: BaseReport) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Convert Pytest Report object to a JSON string (as much as possible)
|
|
54
|
+
:param report: Pytest Report
|
|
55
|
+
:return: JSON string representation of the report
|
|
56
|
+
"""
|
|
57
|
+
d = _convert_report_to_dict(report)
|
|
58
|
+
# remove fields that we don't need that can have formatting issues with SQLite JSON
|
|
59
|
+
removes = ["sections", "capstdout", "capstderr"]
|
|
60
|
+
for remove in removes:
|
|
61
|
+
if remove in d:
|
|
62
|
+
del d[remove]
|
|
63
|
+
d["fly_timestamp"] = time.time() # pytest-fly's own timestamp
|
|
64
|
+
d["username"] = get_username()
|
|
65
|
+
d["computer_name"] = get_computer_name()
|
|
66
|
+
s = json.dumps(d)
|
|
67
|
+
return s
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from typing import List, Tuple, Dict
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
|
|
4
|
+
from .db import RunInfo
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def merge_intervals(intervals: List[Tuple[float, float]]) -> List[Tuple[float, float]]:
|
|
8
|
+
"""Merge overlapping intervals."""
|
|
9
|
+
if not intervals:
|
|
10
|
+
return []
|
|
11
|
+
|
|
12
|
+
# Sort intervals by the start time
|
|
13
|
+
intervals.sort(key=lambda x: x[0])
|
|
14
|
+
merged = [intervals[0]]
|
|
15
|
+
|
|
16
|
+
for current_start, current_end in intervals[1:]:
|
|
17
|
+
last_end = merged[-1][1]
|
|
18
|
+
|
|
19
|
+
if current_start <= last_end:
|
|
20
|
+
merged[-1] = (merged[-1][0], max(last_end, current_end))
|
|
21
|
+
else:
|
|
22
|
+
merged.append((current_start, current_end))
|
|
23
|
+
|
|
24
|
+
return merged
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def calculate_utilization(data: Dict[str, Dict[str, RunInfo]]) -> Tuple[Dict[str, float], float]:
|
|
28
|
+
"""
|
|
29
|
+
Calculate utilization for each worker and overall utilization.
|
|
30
|
+
:param data: Dictionary with test names as keys and dictionaries with phase names as keys and RunInfo objects as values.
|
|
31
|
+
:return: Tuple with a dictionary with worker IDs as keys and utilization as values and overall utilization.
|
|
32
|
+
"""
|
|
33
|
+
worker_intervals = defaultdict(list)
|
|
34
|
+
all_times = []
|
|
35
|
+
|
|
36
|
+
# Collect intervals for each worker and all timestamps
|
|
37
|
+
for test in data.values():
|
|
38
|
+
for phase in test.values():
|
|
39
|
+
worker_intervals[phase.worker_id].append((phase.start, phase.stop))
|
|
40
|
+
all_times.extend([phase.start, phase.stop])
|
|
41
|
+
|
|
42
|
+
# Calculate total time span
|
|
43
|
+
total_time_span = max(all_times) - min(all_times)
|
|
44
|
+
|
|
45
|
+
# Calculate utilization for each worker
|
|
46
|
+
utilization = {}
|
|
47
|
+
total_busy_time_all_workers = 0
|
|
48
|
+
for worker_id, intervals in worker_intervals.items():
|
|
49
|
+
merged = merge_intervals(intervals)
|
|
50
|
+
total_busy_time = sum(end - start for start, end in merged)
|
|
51
|
+
total_busy_time_all_workers += total_busy_time # Sum busy times for overall utilization
|
|
52
|
+
utilization[worker_id] = total_busy_time / total_time_span
|
|
53
|
+
|
|
54
|
+
# Calculate overall utilization
|
|
55
|
+
num_processors = len(worker_intervals)
|
|
56
|
+
overall_utilization = total_busy_time_all_workers / (total_time_span * num_processors)
|
|
57
|
+
|
|
58
|
+
return utilization, overall_utilization
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import tkinter as tk
|
|
2
|
+
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import numpy as np
|
|
5
|
+
from typing import Dict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .db import get_most_recent_run_info, RunInfo
|
|
9
|
+
from .utilization import calculate_utilization
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def visualize(plot_file_path: Path | None = None) -> None:
|
|
13
|
+
"""
|
|
14
|
+
Visualize a timeline of test phases per worker.
|
|
15
|
+
:param plot_file_path: Path to save the plot to.
|
|
16
|
+
"""
|
|
17
|
+
run_info = get_most_recent_run_info()
|
|
18
|
+
|
|
19
|
+
root = tk.Tk()
|
|
20
|
+
root.title("Test Phases Timeline")
|
|
21
|
+
|
|
22
|
+
fig, ax = plt.subplots(figsize=(20, 3 + len(run_info) * 0.5)) # Dynamic height based on the number of tests
|
|
23
|
+
_plot_timeline(run_info, fig, ax, plot_file_path)
|
|
24
|
+
|
|
25
|
+
canvas = FigureCanvasTkAgg(fig, master=root) # A tk.DrawingArea
|
|
26
|
+
canvas.draw()
|
|
27
|
+
canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=1)
|
|
28
|
+
|
|
29
|
+
tk.mainloop()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _plot_timeline(data: Dict[str, Dict[str, RunInfo]], fig, ax, plot_file_path: Path | None) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Plot a timeline of test phases per worker.
|
|
35
|
+
:param data: Dictionary with test names as keys and dictionaries with phase names as keys and RunInfo objects as values.
|
|
36
|
+
:param fig: Matplotlib figure object.
|
|
37
|
+
:param ax: Matplotlib axis object.
|
|
38
|
+
:param plot_file_path: Path to save the plot to.
|
|
39
|
+
"""
|
|
40
|
+
sorted_data = dict(sorted(data.items(), key=lambda x: x[0], reverse=True))
|
|
41
|
+
worker_utilization, overall_utilization = calculate_utilization(sorted_data)
|
|
42
|
+
earliest_start = min(phase.start for test in sorted_data.values() for phase in test.values())
|
|
43
|
+
|
|
44
|
+
workers = set(info.worker_id for test in sorted_data.values() for info in test.values())
|
|
45
|
+
colors = plt.cm.jet(np.linspace(0, 1, len(workers)))
|
|
46
|
+
worker_colors = dict(zip(workers, colors))
|
|
47
|
+
|
|
48
|
+
yticks, yticklabels = [], []
|
|
49
|
+
for i, (test_name, phases) in enumerate(sorted_data.items()):
|
|
50
|
+
for phase_name, phase_info in phases.items():
|
|
51
|
+
relative_start = phase_info.start - earliest_start
|
|
52
|
+
relative_stop = phase_info.stop - earliest_start
|
|
53
|
+
worker_id = phase_info.worker_id
|
|
54
|
+
|
|
55
|
+
ax.plot([relative_start, relative_stop], [i, i], color=worker_colors[worker_id], marker="o", markersize=4, label=worker_id if phase_name == "setup" else "")
|
|
56
|
+
|
|
57
|
+
if phase_name == list(phases.keys())[0]:
|
|
58
|
+
yticks.append(i)
|
|
59
|
+
yticklabels.append(test_name)
|
|
60
|
+
|
|
61
|
+
ax.set_yticks(yticks)
|
|
62
|
+
ax.set_yticklabels(yticklabels)
|
|
63
|
+
ax.set_xlabel("Time (seconds)")
|
|
64
|
+
ax.set_ylabel("Test Names")
|
|
65
|
+
ax.set_title("Timeline of Test Phases per Worker")
|
|
66
|
+
ax.grid(True)
|
|
67
|
+
|
|
68
|
+
ax.text(1.0, 1.02, f"Overall Utilization: {overall_utilization:.2%}", transform=ax.transAxes, horizontalalignment="right", fontsize=9)
|
|
69
|
+
text_position = 1.05
|
|
70
|
+
for worker, utilization in worker_utilization.items():
|
|
71
|
+
ax.text(1.0, text_position, f"{worker}: {utilization:.2%}", transform=ax.transAxes, horizontalalignment="right", fontsize=9)
|
|
72
|
+
text_position += 0.03
|
|
73
|
+
|
|
74
|
+
handles, labels = ax.get_legend_handles_labels()
|
|
75
|
+
by_label = dict(zip(labels, handles))
|
|
76
|
+
legend = ax.legend(by_label.values(), by_label.keys(), title="Workers", loc="upper left", bbox_to_anchor=(1.01, 1), fontsize=9)
|
|
77
|
+
|
|
78
|
+
plt.subplots_adjust(right=0.9) # Make space for the legend
|
|
79
|
+
|
|
80
|
+
# Use bbox_extra_artists to include the legend in the tight layout calculation
|
|
81
|
+
if plot_file_path is not None:
|
|
82
|
+
plot_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
fig.savefig(plot_file_path, bbox_extra_artists=[legend])
|
|
84
|
+
|
|
85
|
+
# plt.show()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pytest-fly
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: pytest observer
|
|
5
|
+
Project-URL: Home, https://github.com/jamesabel/pytest-fly
|
|
6
|
+
Project-URL: Repository, https://github.com/jamesabel/pytest-fly
|
|
7
|
+
Author-email: James Abel <j@abel.co>
|
|
8
|
+
Maintainer-email: James Abel <j@abel.co>
|
|
9
|
+
License:
|
|
10
|
+
The MIT License (MIT)
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2024 James Abel
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in
|
|
22
|
+
all copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
30
|
+
THE SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Framework :: Pytest
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Operating System :: OS Independent
|
|
37
|
+
Classifier: Programming Language :: Python
|
|
38
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Topic :: Software Development :: Testing
|
|
41
|
+
Requires-Python: >=3.12
|
|
42
|
+
Requires-Dist: appdirs
|
|
43
|
+
Requires-Dist: ismain
|
|
44
|
+
Requires-Dist: msqlite
|
|
45
|
+
Requires-Dist: pytest
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# pytest-fly
|
|
49
|
+
|
|
50
|
+
pytest observer
|
|
51
|
+
|
|
52
|
+
Installation
|
|
53
|
+
------------
|
|
54
|
+
|
|
55
|
+
You can install `pytest-fly` via `pip` from `PyPI`:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
pip install pytest-fly
|
|
59
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pytest_fly/__init__.py,sha256=eIUdSiN4Fg5Rge-MozltxW8SaifDIO35mT9Ni6WQ8qI,86
|
|
2
|
+
pytest_fly/__main__.py,sha256=UaJOPF1c6vlAuRFgxqsJvdPt5YMV9SBn0HmxH6G4jf0,240
|
|
3
|
+
pytest_fly/__version__.py,sha256=Vu3vOZ_k30PP5uukW7uhpYxCgHXh9yYl-eKzlb2D0N4,50
|
|
4
|
+
pytest_fly/db.py,sha256=dgwmy9Lb36W3yVkdrf1qRxGEdAfEJ9KMH3uaUz9fVJ8,7103
|
|
5
|
+
pytest_fly/plugin.py,sha256=z1XAyS86a2uNFcC9M4iS2AETdO82t5PPVwgdSUJDWeg,501
|
|
6
|
+
pytest_fly/report_converter.py,sha256=Zm-dRG8bhCHiCv4DgvKqO52pQsQr4gc-9y3NgSc1k1E,2253
|
|
7
|
+
pytest_fly/utilization.py,sha256=pzKpNWpK_FF1Ocfj7fkd-tZvmc1Jx1X60x3nVbVJsHs,2198
|
|
8
|
+
pytest_fly/visualization.py,sha256=DhaqOnzAUMTyNizVvgF0ayOJFTn7wziFnne-13UeYYk,3655
|
|
9
|
+
pytest_fly-0.0.1.dist-info/METADATA,sha256=hhHg3k41Zb9ryGJElMVI34VPrsreMfE-JxZKuC4gPAk,2279
|
|
10
|
+
pytest_fly-0.0.1.dist-info/WHEEL,sha256=as-1oFTWSeWBgyzh0O_qF439xqBe6AbBgt4MfYe5zwY,87
|
|
11
|
+
pytest_fly-0.0.1.dist-info/entry_points.txt,sha256=V1GWjglijPbLnVLJ5o6y7fSh75rIbgoT4ShTkF53xic,35
|
|
12
|
+
pytest_fly-0.0.1.dist-info/licenses/LICENSE,sha256=GRULexapWa5z0SwQbuxdiK3Kj-FPOWkXshLFGzkOWG4,1100
|
|
13
|
+
pytest_fly-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
The MIT License (MIT)
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2024 James Abel
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in
|
|
14
|
+
all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
22
|
+
THE SOFTWARE.
|