"""
Basic client-side components for performance testing. Typical clients should
only need to use `Test` class.
@copyright: (c) Schrodinger, LLC All rights reserved.
"""
import json
import numbers
import os
import platform
import re
import typing
from past.utils import old_div
from typing import Optional
import psutil
import requests
import schrodinger.job.util
from schrodinger.infra import mm
from schrodinger.test.stu import client as stu_client
from schrodinger.test.stu import common
from schrodinger.utils import fileutils
from schrodinger.utils import mmutil
from schrodinger.utils import sysinfo
from schrodinger.test.performance import client
from schrodinger.test.performance import exceptions
MB = 1048576.
BUILD_TYPES = ('OB', 'NB', 'CB', 'Dev')
# @deprecated 2021-10-26
HOST = common.BASE_URL
### PUBLIC API:
##############
[docs]class Test:
"""
A performance test. `name` and `product` must uniquely specify a test.
`product` is required to match an existing product name in the database.
New tests require descriptions when uploaded. The descriptions of existing
tests are not changed by result upload.
The baseClient must be an instance of PerformanceTestClient.
Invididual results are added with addResult(). All results are uploaded to
the database when report() is called.
Instantiate with `scival` set to `True` if you are working with scival
performance tests.
Typical pattern::
baseClient = create_performance_test_reporter(...)
test = performance.Test(
name = "distribution_size",
product = "shared components",
description = "Determine the size of the SCHRODINGER distribution and report it to the performance database.",
baseClient = baseClient,
)
# Result with a metric name and value
test.addResult('file count', 200000)
# Result with a metric name, value, and units
test.addResult('size', 20000, 'MB')
test.report()
"""
[docs] def __init__(
self,
name,
product,
description=None,
scival=False,
upload=True,
baseClient=None,
):
if not name or not product:
raise TypeError('name, product and baseClient are required')
if not isinstance(name, str):
raise TypeError('Name must be a string')
if not isinstance(product, str):
raise TypeError('Product name must be a string')
if description and not isinstance(description, str):
raise TypeError('Description must be a string')
if not isinstance(scival, bool):
raise TypeError('scival must be a boolean')
username = stu_client.get_stu_username()
# WARNING: we prefer that you use `create_performance_test_reporter`, rather than access this constructor directly
if not baseClient:
baseClient = client.PerformanceTestClient(username)
if upload:
test = baseClient.get_or_create_test(name,
description,
product,
scival=scival)
self.baseClient = baseClient
self.username = username
self.test = test
else:
self.username = None
self.test = None
self.results = []
[docs] def addResult(self, name: str, value: float, units: Optional[str] = None):
"""
Add a result to the current test. Results are not uploaded until
report() is called.
:param name: Name of the metric being reported
:param value: Current value of the metric
:param units: (optional) units of the value.
"""
# Validate data types before attempting upload to the server.
validate_types(name, value, units)
metric = dict(name=name, units=units)
result = dict(metric=metric, value=value)
self.results.append(result)
[docs] def report(self, build_id=None, buildtype=None, mmshare=None, release=None):
"""
Once all results have been added to the test, report them to the
database.
"""
if not self.results:
raise ValueError("No results to report")
if not self.test:
return
host_data = host_information()
host_uri = self.baseClient.get_or_create(api_url('host'), host_data)
system_data = system_information(resource_id(host_uri))
system_uri = self.baseClient.get_or_create(api_url('system'),
system_data)
build_data = install_information(build_id,
buildtype,
mmshare=mmshare,
release=release)
build_uri = self.baseClient.get_or_create(api_url('build'), build_data)
post_data = dict(test=self.test,
system=system_uri,
build=build_uri,
metrics=self.results)
post_data = json.dumps(post_data)
self.baseClient.post(performance_api_url('result'), data=post_data)
[docs]def validate_types(name, value, units=None):
"""Validate data types before attempting upload to the server."""
if not isinstance(name, str):
msg = f'Names of metrics values must be strings (found {name})'
raise TypeError(msg)
if not isinstance(value, numbers.Number):
msg = 'Result values must be numeric (found {!r} for {})'.format(
value, name)
raise TypeError(msg)
if units and not isinstance(units, str):
msg = f'Units must be strings (found {units!r} for {name})'
raise TypeError(msg)
### PRIVATE/SUPPORT code
###
### Everything below here is intended to support the public API above
#####################################################################
# @deprecated
[docs]def get_or_create_test(name,
description,
product_name,
username=None,
scival=False):
"""
Get or create a single test from the performance database.
Setting `scival` to `True` will add the 'scival' tag when creating a new test.
"""
if username is None:
username = stu_client.get_stu_username()
auth = schrodinger.test.stu.client.ApiKeyAuth(username)
product_url = api_url('product')
params = dict(name=product_name)
response = requests.get(product_url, params=params, auth=auth)
no_product_msg = ('No product named "{}". See the list of product names '
'at {}/products. File a JIRA case in SHARED if you need '
'to add a product.'.format(product_name, common.BASE_URL))
if response.status_code == 404:
raise exceptions.BadResponse(no_product_msg)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as http_error:
if response.status_code == 401:
raise exceptions.BadResponse(
f'{http_error}, please verify that the appropriate'
f' user is making this request: {username=}')
raise
data = response.json()
if not data['objects']:
raise exceptions.BadResponse(no_product_msg)
product = data['objects'][0]['resource_uri']
product_id = resource_id(product)
test_url = performance_api_url('test')
test_dict = dict(name=name, product=product_id)
# Get an existing test:
response = requests.get(test_url, params=test_dict, auth=auth)
objects = response.json()['objects']
if objects:
return objects[0]['resource_uri']
# Create a new test:
if not description:
raise ValueError("Description is required when uploading a new test.")
test_dict['description'] = description
if scival:
test_dict['tags'] = ['scival']
response = requests.post(test_url,
data=json.dumps(test_dict),
headers={'content-type': 'application/json'},
auth=auth)
response.raise_for_status()
location = response.headers['location']
return location.replace(common.BASE_URL, '')
[docs]def api_url(resource_name, item_id=None, host=None):
"""Get an address on the core server"""
host = host or common.BASE_URL
url = host + '/api/v1/' + resource_name + '/'
if item_id is not None:
url += str(item_id) + '/'
return url
[docs]def resource_id(uri):
"""Get the resource's ID number from a uri"""
match = re.search(r'(\d+)/?$', uri)
return match.group(1)
[docs]def get_or_create(url, auth, params):
"""Get or create a resource matching the parameters."""
response = requests.get(url, params=params, auth=auth)
objects = response.json()['objects']
if objects:
return objects[0]['resource_uri']
response = requests.post(url,
data=json.dumps(params),
headers={'content-type': 'application/json'},
auth=auth)
response.raise_for_status()
location = response.headers['location']
return location.replace(common.BASE_URL, '')
# @deprecated
[docs]def post_system(auth: schrodinger.test.stu.client.ApiKeyAuth, baseClient):
"""
Post the current host's system information to the performance test server.
:return URI for the new system.
"""
host_data = host_information()
host = get_or_create(api_url('host'), auth, host_data)
baseClient.get_or_create(api_url('host'), host_data)
system_data = system_information(resource_id(host))
system = get_or_create(api_url('system'), auth, system_data)
baseClient.get_or_create(api_url('system'), system_data)
return system
[docs]def guess_build_type_and_id(mmshare: int,
buildtype=None) -> typing.Tuple[str, str]:
"""
Provide reasonable default values for the buildtype and build_id. When
possible, reads from the environment variables SCHRODINGER_BUILDTYPE and
SCHRODINGER_BUILD_ID. If SCHRODINGER_BUILDTYPE is not set then we assume
the buildtype is NB.
:param mmshare: mmshare version number, e.g. 54053
:type mmshare: int
:param buildtype: NB or OB
:type buildtype: str
:return: Tuple of buildtype and build ID, e.g. 'NB', 'build-075'
:rtype: tuple
"""
build_id = os.environ.get('SCHRODINGER_BUILD_ID', None)
if not buildtype:
buildtype = os.environ.get('SCHRODINGER_BUILDTYPE', 'NB')
if not build_id:
build_id = 'build-' + str(mmshare)[2:]
return buildtype, build_id