Initial support for tempest providers.

Instead of indexes, test runs are now identified by a source (e.g. test repository, file, or stdin) and any number of these can be mixed/matched. Export script now accepts any number of input repositories from different sources. Currently only the timeline has been patched with support for providers: other tempest views are currently broken.
This commit is contained in:
Tim Buckley 2015-08-19 14:05:17 -06:00
parent e141c24158
commit 4cf81555ee
10 changed files with 312 additions and 100 deletions

View File

@ -76,8 +76,16 @@ def init_django(args):
settings.USE_GZIP = args.gzip settings.USE_GZIP = args.gzip
settings.OFFLINE = True settings.OFFLINE = True
print(repr(args))
if args.repository: if args.repository:
settings.TEST_REPOSITORIES = (args.repository,) settings.TEST_REPOSITORIES = args.repository
if args.stream_file:
settings.TEST_STREAMS = args.stream_file
if args.stdin:
settings.TEST_STREAM_STDIN = True
if args.dstat: if args.dstat:
settings.DSTAT_CSV = args.dstat settings.DSTAT_CSV = args.dstat
@ -95,13 +103,21 @@ def main():
parser.add_argument("--ignore-bower", parser.add_argument("--ignore-bower",
help="Ignore missing Bower components.", help="Ignore missing Bower components.",
action="store_true") action="store_true")
parser.add_argument("--gzip", parser.add_argument("-z", "--gzip",
help="Enable gzip compression for data files.", help="Enable gzip compression for data files.",
action="store_true") action="store_true")
parser.add_argument("--repository", parser.add_argument("-f", "--stream-file",
help="The directory containing the `.testrepository` " action="append",
"to export. If not provided, the `settings.py` " help="Include the given direct subunit stream.")
"configured value will be used.") parser.add_argument("-r", "--repository",
action="append",
help="A directory containing a `.testrepository` to "
"include. If not provided, the `settings.py` "
"configured values will be used.")
parser.add_argument("-i", "--stdin",
help="Read a direct subunit stream from standard "
"input.",
action="store_true")
parser.add_argument("--dstat", parser.add_argument("--dstat",
help="The path to the DStat log file (CSV-formatted) " help="The path to the DStat log file (CSV-formatted) "
"to include. If not provided, the `settings.py` " "to include. If not provided, the `settings.py` "
@ -132,23 +148,23 @@ def main():
print("Rendering:", path) print("Rendering:", path)
export_single_page(path, args.path) export_single_page(path, args.path)
repos = tempest_subunit.get_repositories() for provider in tempest_subunit.get_providers().values():
if repos: for i in range(provider.count):
for run_id in range(repos[0].count()): param = (provider.name, i)
print("Rendering views for tempest run #%d" % (run_id))
export_single_page('/tempest_timeline_%d.html' % run_id, args.path)
export_single_page('/tempest_results_%d.html' % run_id, args.path)
print("Exporting data for tempest run #%d" % (run_id)) print("Rendering views for tempest run %s #%d" % param)
export_single_page('/tempest_api_tree_%d.json' % run_id, export_single_page('/tempest_timeline_%s_%d.html' % param,
args.path)
export_single_page('/tempest_results_%s_%d.html' % param,
args.path)
print("Exporting data for tempest run %s #%d" % param)
export_single_page('/tempest_api_tree_%s_%d.json' % param,
args.path, args.gzip) args.path, args.gzip)
export_single_page('/tempest_api_raw_%d.json' % run_id, export_single_page('/tempest_api_raw_%s_%d.json' % param,
args.path, args.gzip) args.path, args.gzip)
export_single_page('/tempest_api_details_%d.json' % run_id, export_single_page('/tempest_api_details_%s_%d.json' % param,
args.path, args.gzip) args.path, args.gzip)
else:
print("Warning: no test repository could be loaded, no data will "
"be available!")
print("Exporting DStat log: dstat_log.csv") print("Exporting DStat log: dstat_log.csv")
export_single_page('/dstat_log.csv', args.path, args.gzip) export_single_page('/dstat_log.csv', args.path, args.gzip)

View File

@ -12,7 +12,7 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
from stackviz.parser.tempest_subunit import get_repositories from stackviz.parser.tempest_subunit import get_providers
from stackviz.settings import OFFLINE from stackviz.settings import OFFLINE
from stackviz.settings import USE_GZIP from stackviz.settings import USE_GZIP
@ -20,14 +20,16 @@ from stackviz.settings import USE_GZIP
def inject_extra_context(request): def inject_extra_context(request):
ret = { ret = {
'use_gzip': USE_GZIP, 'use_gzip': USE_GZIP,
'offline' : OFFLINE 'offline': OFFLINE
} }
repos = get_repositories() providers = get_providers()
if repos: if providers:
default = providers.values()[0]
ret.update({ ret.update({
'tempest_latest_run': get_repositories()[0].latest_id(), 'tempest_providers': providers.values(),
'tempest_runs': range(get_repositories()[0].count()), 'tempest_default_provider': default,
}) })
return ret return ret

View File

@ -12,12 +12,15 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import os
import re import re
import shutil
import subunit import subunit
import sys
from functools import partial from functools import partial
from io import BytesIO
from subunit import ByteStreamToStreamResult
from testtools import CopyStreamResult from testtools import CopyStreamResult
from testtools import StreamResult from testtools import StreamResult
from testtools import StreamSummary from testtools import StreamSummary
@ -33,29 +36,188 @@ NAME_SCENARIO_PATTERN = re.compile(r'^(.+) \((.+)\)$')
NAME_TAGS_PATTERN = re.compile(r'^(.+)\[(.+)\]$') NAME_TAGS_PATTERN = re.compile(r'^(.+)\[(.+)\]$')
def get_repositories(): _provider_cache = None
"""Loads all test repositories from locations configured in settings
Where settings is found in`settings.TEST_REPOSITORIES`. Only locations
with a valid `.testrepository` subdirectory containing valid test entries
will be returned.
:return: a list of loaded :class:`Repository` instances class InvalidSubunitProvider(Exception):
:rtype: list[Repository] pass
class SubunitProvider(object):
@property
def name(self):
"""Returns a unique name for this provider, such that a valid URL
fragment pointing to a particular stream from this provider is
`name_index`, applicable for paths to pages and data files making use
of the stream.
:return: a path fragment referring to the stream at `index` from this
provider
"""
raise NotImplementedError()
@property
def description(self):
"""Returns a user-facing description for this provider.
This description may be used in UI contexts, but will not be used
within paths or other content-sensitive contexts.
:return: a description for this provider
"""
raise NotImplementedError()
@property
def count(self):
raise NotImplementedError()
def describe(self, index):
"""Returns a short, user-visible description for the contents of this
subunit stream provider.
:return: a description that can apply to all streams returned by this
provider
"""
raise NotImplementedError()
def get_stream(self, index):
"""Returns a file-like object representing the subunit stream at the
given index.
:param index: the index of the stream; must be between `0` and
`count - 1` (inclusive)
"""
raise NotImplementedError()
@property
def indexes(self):
# for the benefit of django templates
return range(self.count)
@property
def streams(self):
"""Creates a generator that iterates over each stream available in
this provider.
:return: each stream available from this generator
"""
for i in range(self.count):
yield self.get_stream(i)
class RepositoryProvider(SubunitProvider):
def __init__(self, repository_path):
self.repository_path = repository_path
self.repository = RepositoryFactory().open(repository_path)
@property
def name(self):
return "repo_%s" % os.path.basename(self.repository_path)
@property
def description(self):
return "Repository: %s" % os.path.basename(self.repository_path)
@property
def count(self):
return self.repository.count()
def describe(self, index):
return "Repository (%s): #%d" % (
os.path.basename(self.repository_path),
index
)
def get_stream(self, index):
return self.repository.get_latest_run().get_subunit_stream()
class FileProvider(SubunitProvider):
def __init__(self, path):
if not os.path.exists(path):
raise InvalidSubunitProvider("Stream doesn't exist: %s" % path)
self.path = path
@property
def name(self):
return "file_%s" % os.path.basename(self.path)
@property
def description(self):
return "Subunit File: %s" % os.path.basename(self.path)
@property
def count(self):
return 1
def describe(self, index):
return "File: %s" % os.path.basename(self.path)
def get_stream(self, index):
if index != 0:
raise IndexError("Index out of bounds: %d" % index)
return open(self.path, "r")
class StandardInputProvider(SubunitProvider):
def __init__(self):
self.buffer = BytesIO()
shutil.copyfileobj(sys.stdin, self.buffer)
@property
def name(self):
return "stdin"
@property
def description(self):
return "Subunit Stream (stdin)"
@property
def count(self):
return 1
def get_stream(self, index):
if index != 0:
raise IndexError()
return self.buffer
def get_providers():
"""Loads all test providers from locations configured in settings.
:return: a dict of loaded provider names and their associated
:class:`SubunitProvider` instances
:rtype: dict[str, SubunitProvider]
""" """
global _provider_cache
factory = RepositoryFactory() if _provider_cache is not None:
return _provider_cache
ret = [] _provider_cache = {}
for path in settings.TEST_REPOSITORIES: for path in settings.TEST_REPOSITORIES:
try: try:
ret.append(factory.open(path)) p = RepositoryProvider(path)
_provider_cache[p.name] = p
except (ValueError, RepositoryNotFound): except (ValueError, RepositoryNotFound):
# skip
continue continue
return ret for path in settings.TEST_STREAMS:
try:
p = FileProvider(path)
_provider_cache[p.name] = p
except InvalidSubunitProvider:
continue
if settings.TEST_STREAM_STDIN:
p = StandardInputProvider()
_provider_cache[p.name] = p
return _provider_cache
def _clean_name(name): def _clean_name(name):
@ -122,7 +284,6 @@ def convert_stream(stream_file, strip_details=False):
return ret return ret
def convert_run(test_run, strip_details=False): def convert_run(test_run, strip_details=False):
"""Converts the given test run into a raw list of test dicts. """Converts the given test run into a raw list of test dicts.

View File

@ -101,10 +101,19 @@ TEMPLATE_DIRS = [
os.path.join(BASE_DIR, 'stackviz', 'templates') os.path.join(BASE_DIR, 'stackviz', 'templates')
] ]
# If True, read a stream from stdin (only valid for exported sites)
TEST_STREAM_STDIN = False
# A list of files containing directly-accessible subunit streams.
TEST_STREAMS = []
# A list of test repositories containing (potentially) multiple subunit
# streams.
TEST_REPOSITORIES = [ TEST_REPOSITORIES = [
os.path.join(BASE_DIR, 'test_data') os.path.join(BASE_DIR, 'test_data')
] ]
# The input dstat file
DSTAT_CSV = 'dstat.log' DSTAT_CSV = 'dstat.log'
# If true, AJAX calls should attempt to load `*.json.gz` files rather than # If true, AJAX calls should attempt to load `*.json.gz` files rather than

View File

@ -22,10 +22,14 @@
<a href="#"><i class="fa fa-bar-chart-o fa-fw"></i> Tempest<span class="fa arrow"></span></a> <a href="#"><i class="fa fa-bar-chart-o fa-fw"></i> Tempest<span class="fa arrow"></span></a>
<ul class="nav nav-second-level"> <ul class="nav nav-second-level">
<li> <li>
<a href="tempest_results_{{ tempest_latest_run }}.html"><i class="fa fa-clock-o fa-fw"></i> Sunburst</a> <a href="tempest_results_{{ tempest_default_provider.name }}_{{ tempest_default_provider.count | add:'-1' }}.html">
<i class="fa fa-clock-o fa-fw"></i> Sunburst
</a>
</li> </li>
<li> <li>
<a href="tempest_timeline_{{ tempest_latest_run }}.html"><i class="fa fa-calendar fa-fw"></i> Timeline</a> <a href="tempest_timeline_{{ tempest_default_provider.name }}_{{ tempest_default_provider.count | add:'-1' }}.html">
<i class="fa fa-calendar fa-fw"></i> Timeline
</a>
</li> </li>
<!--<li> <!--<li>
<a href="/tempest/"><i class="fa fa-database fa-fw"></i> Compare</a> <a href="/tempest/"><i class="fa fa-database fa-fw"></i> Compare</a>

View File

@ -44,8 +44,11 @@
<span class="caret"></span> <span class="caret"></span>
</button> </button>
<ul class="dropdown-menu pull-right" role="menu"> <ul class="dropdown-menu pull-right" role="menu">
{% for run_id in tempest_runs %} {% for provider in tempest_providers %}
<li><a href="tempest_timeline_{{run_id}}.html">Run #{{run_id}}</a></li> <li class="dropdown-header">{{ provider.description }}</li>
{% for index in provider.indexes %}
<li><a href="tempest_timeline_{{ provider.name }}_{{ index }}.html">Run #{{ index }}</a></li>
{% endfor %}
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
@ -131,7 +134,7 @@ var loadDetails = function(callback) {
detailsWaiting.push(callback); detailsWaiting.push(callback);
if (!detailsInProgress) { if (!detailsInProgress) {
var url = "tempest_api_details_{{run_id}}.json"; var url = "tempest_api_details_{{provider_name}}_{{run_id}}.json";
if ("{{use_gzip}}" === "True") { if ("{{use_gzip}}" === "True") {
url += ".gz"; url += ".gz";
} }
@ -186,7 +189,7 @@ window.addEventListener('load', function() {
var selectedItem = null; var selectedItem = null;
var selectedValue = null; var selectedValue = null;
var url = "tempest_api_raw_{{run_id}}.json"; var url = "tempest_api_raw_{{provider_name}}_{{run_id}}.json";
if ("{{use_gzip}}" === "True") { if ("{{use_gzip}}" === "True") {
url += ".gz"; url += ".gz";
} }

View File

@ -15,8 +15,8 @@
from django.http import Http404 from django.http import Http404
from restless.views import Endpoint from restless.views import Endpoint
from stackviz.parser.tempest_subunit import convert_run from stackviz.parser.tempest_subunit import convert_stream
from stackviz.parser.tempest_subunit import get_repositories from stackviz.parser.tempest_subunit import get_providers
from stackviz.parser.tempest_subunit import reorganize from stackviz.parser.tempest_subunit import reorganize
#: Cached results from loaded subunit logs indexed by their run number #: Cached results from loaded subunit logs indexed by their run number
@ -34,6 +34,10 @@ class NoRunDataException(Http404):
pass pass
class ProviderNotFoundException(Http404):
pass
class RunNotFoundException(Http404): class RunNotFoundException(Http404):
pass pass
@ -42,61 +46,71 @@ class TestNotFoundException(Http404):
pass pass
def _load_run(run_id): def _load_run(provider_name, run_id):
if run_id in _cached_run: if (provider_name, run_id) in _cached_run:
return _cached_run[run_id] return _cached_run[provider_name, run_id]
repos = get_repositories() providers = get_providers()
if not repos: if not providers:
raise NoRunDataException("No test repositories could be loaded") raise NoRunDataException("No test providers could be loaded")
if provider_name not in providers:
raise ProviderNotFoundException("Requested subunit provider could not "
"be found")
p = providers[provider_name]
try: try:
# assume first repo for now # assume first repo for now
run = repos[0].get_test_run(run_id) stream = p.get_stream(run_id)
# strip details for now # strip details for now
# TODO(provide method for getting details on demand) # TODO(provide method for getting details on demand)
# (preferably for individual tests to avoid bloat) # (preferably for individual tests to avoid bloat)
converted_run = convert_run(run, strip_details=True) converted_run = convert_stream(stream, strip_details=True)
_cached_run[run_id] = converted_run _cached_run[provider_name, run_id] = converted_run
return converted_run return converted_run
except KeyError: except KeyError:
raise RunNotFoundException("Requested test run could not be found") raise RunNotFoundException("Requested test run could not be found")
def _load_tree(run_id): def _load_tree(provider, run_id):
if run_id in _cached_tree: if (provider, run_id) in _cached_tree:
return _cached_tree[run_id] return _cached_tree[provider, run_id]
run = _load_run(run_id) run = _load_run(provider, run_id)
tree = reorganize(run) tree = reorganize(run)
_cached_tree[run_id] = tree _cached_tree[provider, run_id] = tree
return tree return tree
def _load_details(run_id, test_name): def _load_details(provider_name, run_id, test_name):
if run_id not in _cached_details: if (provider_name, run_id) not in _cached_details:
repos = get_repositories() providers = get_providers()
if not repos: if not providers:
raise NoRunDataException("No test repositories could be loaded") raise NoRunDataException("No test providers could be loaded")
if provider_name not in providers:
raise ProviderNotFoundException("Requested subunit provider could "
"not be found")
provider = providers[provider_name]
try: try:
# assume first repo for now stream = provider.get_stream(run_id)
run = repos[0].get_test_run(run_id) converted_run = convert_stream(stream, strip_details=False)
converted_run = convert_run(run, strip_details=False)
# remap dict to allow direct access to details via test name # remap dict to allow direct access to details via test name
dest = {} dest = {}
for entry in converted_run: for entry in converted_run:
dest[entry['name']] = entry['details'] dest[entry['name']] = entry['details']
_cached_details[run_id] = dest _cached_details[provider_name, run_id] = dest
except KeyError: except (KeyError, IndexError):
raise RunNotFoundException("Requested test run could not be found") raise RunNotFoundException("Requested test run could not be found")
details_map = _cached_details[run_id] details_map = _cached_details[provider_name, run_id]
if test_name is None: if test_name is None:
return details_map return details_map
else: else:
@ -108,15 +122,15 @@ def _load_details(run_id, test_name):
class TempestRunRawEndpoint(Endpoint): class TempestRunRawEndpoint(Endpoint):
def get(self, request, run_id): def get(self, request, provider_name, run_id):
return _load_run(run_id) return _load_run(provider_name, int(run_id))
class TempestRunTreeEndpoint(Endpoint): class TempestRunTreeEndpoint(Endpoint):
def get(self, request, run_id): def get(self, request, provider_name, run_id):
return _load_tree(run_id) return _load_tree(provider_name, int(run_id))
class TempestRunDetailsEndpoint(Endpoint): class TempestRunDetailsEndpoint(Endpoint):
def get(self, request, run_id, test_name=None): def get(self, request, run_id, provider_name, test_name=None):
return _load_details(run_id, test_name) return _load_details(int(run_id), provider_name, test_name)

View File

@ -20,6 +20,7 @@ class ResultsView(TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(ResultsView, self).get_context_data(**kwargs) context = super(ResultsView, self).get_context_data(**kwargs)
context['provider_name'] = self.kwargs['provider_name']
context['run_id'] = self.kwargs['run_id'] context['run_id'] = self.kwargs['run_id']
return context return context

View File

@ -20,6 +20,7 @@ class TimelineView(TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(TimelineView, self).get_context_data(**kwargs) context = super(TimelineView, self).get_context_data(**kwargs)
context['provider_name'] = self.kwargs['provider_name']
context['run_id'] = self.kwargs['run_id'] context['run_id'] = self.kwargs['run_id']
return context return context

View File

@ -25,27 +25,28 @@ from api import TempestRunRawEndpoint
from api import TempestRunTreeEndpoint from api import TempestRunTreeEndpoint
urlpatterns = patterns('', urlpatterns = patterns(
url(r'^results_(?P<run_id>\d+).html$', '',
ResultsView.as_view(), url(r'^results_(?P<provider_name>[\w_\.]+)_(?P<run_id>\d+).html$',
name='tempest_results'), ResultsView.as_view(),
url(r'^timeline_(?P<run_id>\d+).html$', name='tempest_results'),
TimelineView.as_view(), url(r'^timeline_(?P<provider_name>[\w_\.]+)_(?P<run_id>\d+).html$',
name='tempest_timeline'), TimelineView.as_view(),
name='tempest_timeline'),
url(r'^api_tree_(?P<run_id>\d+).json$', url(r'^api_tree_(?P<provider_name>[\w_\.]+)_(?P<run_id>\d+).json$',
TempestRunTreeEndpoint.as_view(), TempestRunTreeEndpoint.as_view(),
name='tempest_api_tree'), name='tempest_api_tree'),
url(r'^api_raw_(?P<run_id>\d+).json$', url(r'^api_raw_(?P<provider_name>[\w_\.]+)_(?P<run_id>\d+).json$',
TempestRunRawEndpoint.as_view(), TempestRunRawEndpoint.as_view(),
name='tempest_api_raw'), name='tempest_api_raw'),
url(r'^api_details_(?P<run_id>\d+).json$', url(r'^api_details_(?P<provider_name>[\w_\.]+)_(?P<run_id>\d+).json$',
TempestRunDetailsEndpoint.as_view()), TempestRunDetailsEndpoint.as_view()),
url(r'^api_details_(?P<run_id>\d+)_(?P<test_name>[^/]+)' url(r'^api_details_(?P<provider_name>[\w_\.]+)_(?P<run_id>\d+)_'
r'.json$', r'(?P<test_name>[^/]+).json$',
TempestRunDetailsEndpoint.as_view()), TempestRunDetailsEndpoint.as_view()),
url(r'^aggregate.html$', url(r'^aggregate.html$',
AggregateResultsView.as_view(), AggregateResultsView.as_view(),
name='tempest_aggregate_results'), name='tempest_aggregate_results'),
) )