Skip to content

Commit

Permalink
Implement support for few handler stats files
Browse files Browse the repository at this point in the history
  • Loading branch information
Themanwhosmellslikesugar committed Dec 26, 2024
1 parent 386834e commit ebfba18
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 37 deletions.
17 changes: 17 additions & 0 deletions hammett/viz/color.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""The method contains functions for working with the std output style."""

from enum import Enum


class Style(Enum):
"""Contains output styles."""

RESET = '\x1b[0m'
ERROR = '\x1b[31m'
SUCCESS = '\x1b[32m'
WARNING = '\x1b[33m'


def colorize(style: 'Style', text: str) -> str:
"""Return string with the selected style and resetting it at the end."""
return f'{style.value}{text}{Style.RESET.value}'
64 changes: 53 additions & 11 deletions hammett/viz/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,12 @@
from pathlib import Path
from typing import TYPE_CHECKING, cast

from hammett.viz.stats import avg_stats_table_rows
from hammett.viz.stats import Stats, avg_stats_table_rows
from hammett.viz.template import Template

if TYPE_CHECKING:
from typing import Any

from hammett.viz.stats import Stats


def allocate_port() -> int:
"""Allocate a free port and return it."""
Expand All @@ -28,17 +26,47 @@ def allocate_port() -> int:
class VizRequestHandler(SimpleHTTPRequestHandler):
"""Viz HTTP request handler."""

STATS: 'Stats | None' = None
platforms: dict[str, tuple[str, Path]] = {}
directory_path = Path.cwd() / 'hammett/viz/'

def __init__(self, *args: 'Any', **kwargs: 'Any') -> None:
"""Set the working directory with templates and static files."""
kwargs['directory'] = f'{self.directory_path}'

self.cur_platform = ''
if self.platforms:
# it's ok on python 3.7+ where dicts are ordered
self.cur_platform = next(iter(self.platforms))

super().__init__(*args, **kwargs)

@staticmethod
def _parse_platform_name(raw_platform_name: str) -> tuple[str, str]:
"""Return the readable names of the platform."""
platform_list = raw_platform_name.split(';')
platform_fullname = ' '.join(platform_list)
platform = platform_list[0]
return platform, platform_fullname

def _render_platforms(self) -> str:
"""Return HTML links to other platforms."""
platforms = ''
for platform_hash, (raw_platform_name, _) in self.platforms.items():
classes = ['tab', 'active'] if self.cur_platform == platform_hash else ['tab']

platform, platform_fullname = self._parse_platform_name(raw_platform_name)
platforms += (
f'<a href="/stats/{platform_hash}" class="{" ".join(classes)}" '
f'title="{platform_fullname}">'
f'{platform}</a>\n'
)

return platforms

def _send_headers(self) -> None:
"""Send headers and closes their sending."""
self.send_header('Content-Type', 'text/html')
self.send_header('Last-Modified', self.date_time_string())
self.end_headers()

def process_static_file(self) -> None:
Expand All @@ -52,13 +80,18 @@ def process_static_file(self) -> None:

def index(self) -> None:
"""Render index page and write it to response."""
if self.STATS is None:
msg = 'Stats is not available'
raise RuntimeError(msg)
stat_table = ''
if self.platforms and self.cur_platform:
_, file_path = self.platforms[self.cur_platform]

avg_stats = Stats(file_path).load().avg_stats()
stat_table = avg_stats_table_rows(avg_stats)

stat_table = avg_stats_table_rows(self.STATS.avg_stats)
template_path = self.directory_path / 'templates/index.html'
template = Template(template_path).load().render(stat_table=stat_table)
template = Template(template_path).load().render(
stat_table=stat_table,
platforms=self._render_platforms(),
)
self.copyfile(BytesIO(template), self.wfile) # type: ignore[misc]

def do_GET(self) -> None: # noqa: N802
Expand All @@ -67,11 +100,20 @@ def do_GET(self) -> None: # noqa: N802
self.send_response(HTTPStatus.OK)
self._send_headers()
return self.index()
if re.match(r'^/static', self.path):
return self.process_static_file()
if re.match(r'^/favicon.ico', self.path):
self.path = '/static/favicon.ico'
return self.process_static_file()
if re.match(r'^/stats/\w{32}', self.path):
try:
self.cur_platform = re.findall(r'\w{32}', self.path)[0]
except IndexError:
self.send_error(HTTPStatus.NOT_FOUND)
return None
self.send_response(HTTPStatus.OK)
self._send_headers()
return self.index()
if re.match(r'^/static', self.path):
return self.process_static_file()

self.send_error(HTTPStatus.NOT_FOUND)
return None
40 changes: 25 additions & 15 deletions hammett/viz/main.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
"""The module contains an entry point to start http server."""

import argparse
import json
import sys
import webbrowser
from http.server import HTTPServer
from pathlib import Path

from hammett.viz import constants
from hammett.viz.color import Style, colorize
from hammett.viz.http_server import VizRequestHandler, allocate_port
from hammett.viz.stats import Stats
from hammett.viz.stats import detect_stats_files, get_platforms


def parser_builder() -> 'argparse.ArgumentParser':
"""Return the configured `ArgumentParser`."""
parser = argparse.ArgumentParser(prog='hammett.viz', usage='%(prog)s [options]')

parser.add_argument('filepath', help='Stats file, e.g "handler_stats.json"')
parser.add_argument(
'directory',
help=(
'directory that contains files '
'such as "handler-stats-87181a567d439062fcd6d94cc194a8b7.json"'
),
)
parser.add_argument(
'-v', '--version', action='version', version=f'%(prog)s {constants.VERSION}',
)
Expand Down Expand Up @@ -47,22 +53,24 @@ def main() -> None:
parser = parser_builder()
args = parser.parse_args()

stat_filepath = Path(args.filepath)
if not stat_filepath.exists():
parser.error(f"{stat_filepath} doesn't exist")
dir_path = Path(args.directory)
if not dir_path.exists():
parser.error(f"{dir_path} doesn't exist")

if not stat_filepath.is_file():
parser.error(f"{stat_filepath} isn't a file")
if not dir_path.is_dir():
parser.error(f"{dir_path} isn't a directory")

if not 0 <= args.port <= constants.MAX_PORT_VALUE:
parser.error(f'{args.port} is out of range')

with stat_filepath.open('r', encoding='utf-8') as f:
try:
stats = json.load(f)
except json.decoder.JSONDecodeError:
parser.error(f'{stat_filepath} is not valid JSON file')
stats_files = detect_stats_files(dir_path)
if not stats_files:
sys.stdout.write(colorize(
Style.WARNING,
'No files with statistics found in the directory\n',
))

VizRequestHandler.platforms = get_platforms(stats_files)
port = args.port if args.port != 0 else allocate_port()

url = f'http://{args.hostname}:{port}/'
Expand All @@ -74,10 +82,12 @@ def main() -> None:
# if new is 2, a new browser page (“tab”) is opened if possible
browser.open(url, new=2)

VizRequestHandler.STATS = Stats(stats)
server = HTTPServer((args.hostname, port), VizRequestHandler)

sys.stdout.write(f'Hammett.Viz starting on {url}, press Ctrl+C to exit.\n')
sys.stdout.write(colorize(
Style.SUCCESS,
f'Hammett.Viz starting on {url}, press Ctrl+C to exit.\n',
))
try:
server.serve_forever()
except (KeyboardInterrupt, SystemExit):
Expand Down
51 changes: 46 additions & 5 deletions hammett/viz/static/hammett_viz.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@
margin: 0;
}

:root {
--bg-color: #f7f7f7;
--primary-text-color: #e7e5dc;
--primary-bg-color: #b06bc7;
--secondary-bg-color: #763b8c;
--secondary-text-color: #fcce22;
--primary-button-color: white;
}

body {
line-height: 1.5;
min-height: 100vh;
margin: 0;
display: flex;
flex-direction: column;
background: #f7f7f7;
background: var(--bg-color);
}

main {
Expand All @@ -21,18 +30,18 @@ main {

header {
padding: 1rem;
background: #b06bc7;
color: #e7e5dc;
background: var(--primary-bg-color);
color: var(--primary-text-color);
}

footer {
padding: 1rem;
min-height: 50px;
background: #763b8c;
background: var(--secondary-bg-color);
}

footer a {
color: #fcce22;
color: var(--secondary-text-color);
}

h1, h2, h3, h4, h5, h6 {
Expand Down Expand Up @@ -61,6 +70,38 @@ img, picture, video, canvas, svg {
padding-left: 1.5rem;
}

.tabbed-set {
display: flex;
gap: 0.5rem;
padding-top: 1rem;
padding-bottom: 1rem;
overflow-x: auto;
}

a.tab {
border-radius: 4px;
padding: 2px 6px;
text-decoration: none;
color: buttontext;
background-color: buttonface;
border: 1px solid grey;
}

a.tab:hover {
background-color: gray;
}

a.tab.active {
color: var(--primary-button-color);
background: var(--primary-bg-color);
border: none;
}

a.tab.active:hover {
color: var(--secondary-text-color);
background: var(--secondary-bg-color);
}

#stat-table {
width: 100%;
}
52 changes: 46 additions & 6 deletions hammett/viz/stats.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
"""The module contains methods for working with handler statistics."""

import json
import re
import statistics
from functools import cached_property
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast

if TYPE_CHECKING:
from pathlib import Path
from typing import Final

from hammett.types import StopwatchStats
from hammett.viz.types import AverageStats


def detect_stats_files(directory: 'Path') -> 'list[Path]':
"""Detect files with statistics and returns them."""
return [
stat_file for stat_file in directory.iterdir()
if stat_file.is_file() and re.match(r'^handler-stats-\w{32}\.json', stat_file.name)
]


def get_platforms(file_paths: 'list[Path]') -> 'dict[str, tuple[str, Path]]':
"""Return dict with a hash from filename, platform and a file that
contains statistics for it.
"""
platforms = {}
for file_path in file_paths:
stat = Stats(file_path).load()
platforms.update({stat.filename_hash(): (stat.platform, file_path)})

return platforms


def avg_stats_table_rows(stats: 'AverageStats') -> str:
"""Return the table columns from the arithmetic mean values of the handler statistics."""
rows = ''
Expand All @@ -27,13 +49,31 @@ def avg_stats_table_rows(stats: 'AverageStats') -> str:
class Stats:
"""The class contains methods for handling handler statistics."""

def __init__(self, stats: 'StopwatchStats') -> None:
"""Save handler statistics."""
self.stats: Final[StopwatchStats] = stats
def __init__(self, file_path: 'Path') -> None:
"""Save the path to the handler statistics file."""
self.file_path: Final[Path] = file_path
self.platform = ''
self.stats: StopwatchStats | None = None

def load(self) -> 'Stats':
"""Load the stats file."""
with self.file_path.open('r', encoding='utf-8') as f:
stats = json.load(f)
self.platform = stats[0]
self.stats = stats[1]

return self

def filename_hash(self) -> str:
"""Return hash from filename."""
return cast('str', re.findall(r'\w{32}', self.file_path.name)[0])

@cached_property
def avg_stats(self) -> 'AverageStats':
"""Return the arithmetic mean for each statistic for each handler."""
if not self.stats:
msg = 'Stats file is not loaded'
raise FileNotFoundError(msg)

avg_stats = []
for handler_name, stats in self.stats.items():
cpu_stats = []
Expand Down
4 changes: 4 additions & 0 deletions hammett/viz/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ <h1>Hammett.Viz</h1>
</header>

<section class="container">
<div class="tabbed-set">
{{ platforms }}
</div>

<table id="stat-table" class="display nowrap">
<thead>
<tr>
Expand Down

0 comments on commit ebfba18

Please sign in to comment.