mirror of
https://github.com/clearml/clearml
synced 2025-04-18 13:24:41 +00:00
Add support for API v2.23 and attaching plots and scalars to models
This commit is contained in:
parent
b34063dd3f
commit
309b09f8a9
0
clearml/backend_api/services/v2_23/__init__.py
Normal file
0
clearml/backend_api/services/v2_23/__init__.py
Normal file
764
clearml/backend_api/services/v2_23/auth.py
Normal file
764
clearml/backend_api/services/v2_23/auth.py
Normal file
@ -0,0 +1,764 @@
|
||||
"""
|
||||
auth service
|
||||
|
||||
This service provides authentication management and authorization
|
||||
validation for the entire system.
|
||||
"""
|
||||
import six
|
||||
from datetime import datetime
|
||||
|
||||
from dateutil.parser import parse as parse_datetime
|
||||
|
||||
from clearml.backend_api.session import (
|
||||
Request,
|
||||
Response,
|
||||
NonStrictDataModel,
|
||||
schema_property,
|
||||
)
|
||||
|
||||
|
||||
class Credentials(NonStrictDataModel):
|
||||
"""
|
||||
:param access_key: Credentials access key
|
||||
:type access_key: str
|
||||
:param secret_key: Credentials secret key
|
||||
:type secret_key: str
|
||||
:param label: Optional credentials label
|
||||
:type label: str
|
||||
"""
|
||||
|
||||
_schema = {
|
||||
"properties": {
|
||||
"access_key": {
|
||||
"description": "Credentials access key",
|
||||
"type": ["string", "null"],
|
||||
},
|
||||
"label": {
|
||||
"description": "Optional credentials label",
|
||||
"type": ["string", "null"],
|
||||
},
|
||||
"secret_key": {
|
||||
"description": "Credentials secret key",
|
||||
"type": ["string", "null"],
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, access_key=None, secret_key=None, label=None, **kwargs):
|
||||
super(Credentials, self).__init__(**kwargs)
|
||||
self.access_key = access_key
|
||||
self.secret_key = secret_key
|
||||
self.label = label
|
||||
|
||||
@schema_property("access_key")
|
||||
def access_key(self):
|
||||
return self._property_access_key
|
||||
|
||||
@access_key.setter
|
||||
def access_key(self, value):
|
||||
if value is None:
|
||||
self._property_access_key = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "access_key", six.string_types)
|
||||
self._property_access_key = value
|
||||
|
||||
@schema_property("secret_key")
|
||||
def secret_key(self):
|
||||
return self._property_secret_key
|
||||
|
||||
@secret_key.setter
|
||||
def secret_key(self, value):
|
||||
if value is None:
|
||||
self._property_secret_key = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "secret_key", six.string_types)
|
||||
self._property_secret_key = value
|
||||
|
||||
@schema_property("label")
|
||||
def label(self):
|
||||
return self._property_label
|
||||
|
||||
@label.setter
|
||||
def label(self, value):
|
||||
if value is None:
|
||||
self._property_label = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "label", six.string_types)
|
||||
self._property_label = value
|
||||
|
||||
|
||||
class CredentialKey(NonStrictDataModel):
|
||||
"""
|
||||
:param access_key:
|
||||
:type access_key: str
|
||||
:param label: Optional credentials label
|
||||
:type label: str
|
||||
:param last_used:
|
||||
:type last_used: datetime.datetime
|
||||
:param last_used_from:
|
||||
:type last_used_from: str
|
||||
"""
|
||||
|
||||
_schema = {
|
||||
"properties": {
|
||||
"access_key": {"description": "", "type": ["string", "null"]},
|
||||
"label": {
|
||||
"description": "Optional credentials label",
|
||||
"type": ["string", "null"],
|
||||
},
|
||||
"last_used": {
|
||||
"description": "",
|
||||
"format": "date-time",
|
||||
"type": ["string", "null"],
|
||||
},
|
||||
"last_used_from": {"description": "", "type": ["string", "null"]},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, access_key=None, label=None, last_used=None, last_used_from=None, **kwargs):
|
||||
super(CredentialKey, self).__init__(**kwargs)
|
||||
self.access_key = access_key
|
||||
self.label = label
|
||||
self.last_used = last_used
|
||||
self.last_used_from = last_used_from
|
||||
|
||||
@schema_property("access_key")
|
||||
def access_key(self):
|
||||
return self._property_access_key
|
||||
|
||||
@access_key.setter
|
||||
def access_key(self, value):
|
||||
if value is None:
|
||||
self._property_access_key = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "access_key", six.string_types)
|
||||
self._property_access_key = value
|
||||
|
||||
@schema_property("label")
|
||||
def label(self):
|
||||
return self._property_label
|
||||
|
||||
@label.setter
|
||||
def label(self, value):
|
||||
if value is None:
|
||||
self._property_label = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "label", six.string_types)
|
||||
self._property_label = value
|
||||
|
||||
@schema_property("last_used")
|
||||
def last_used(self):
|
||||
return self._property_last_used
|
||||
|
||||
@last_used.setter
|
||||
def last_used(self, value):
|
||||
if value is None:
|
||||
self._property_last_used = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "last_used", six.string_types + (datetime,))
|
||||
if not isinstance(value, datetime):
|
||||
value = parse_datetime(value)
|
||||
self._property_last_used = value
|
||||
|
||||
@schema_property("last_used_from")
|
||||
def last_used_from(self):
|
||||
return self._property_last_used_from
|
||||
|
||||
@last_used_from.setter
|
||||
def last_used_from(self, value):
|
||||
if value is None:
|
||||
self._property_last_used_from = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "last_used_from", six.string_types)
|
||||
self._property_last_used_from = value
|
||||
|
||||
|
||||
class CreateCredentialsRequest(Request):
|
||||
"""
|
||||
Creates a new set of credentials for the authenticated user.
|
||||
New key/secret is returned.
|
||||
Note: Secret will never be returned in any other API call.
|
||||
If a secret is lost or compromised, the key should be revoked
|
||||
and a new set of credentials can be created.
|
||||
|
||||
:param label: Optional credentials label
|
||||
:type label: str
|
||||
"""
|
||||
|
||||
_service = "auth"
|
||||
_action = "create_credentials"
|
||||
_version = "2.23"
|
||||
_schema = {
|
||||
"additionalProperties": False,
|
||||
"definitions": {},
|
||||
"properties": {
|
||||
"label": {
|
||||
"description": "Optional credentials label",
|
||||
"type": ["string", "null"],
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, label=None, **kwargs):
|
||||
super(CreateCredentialsRequest, self).__init__(**kwargs)
|
||||
self.label = label
|
||||
|
||||
@schema_property("label")
|
||||
def label(self):
|
||||
return self._property_label
|
||||
|
||||
@label.setter
|
||||
def label(self, value):
|
||||
if value is None:
|
||||
self._property_label = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "label", six.string_types)
|
||||
self._property_label = value
|
||||
|
||||
|
||||
class CreateCredentialsResponse(Response):
|
||||
"""
|
||||
Response of auth.create_credentials endpoint.
|
||||
|
||||
:param credentials: Created credentials
|
||||
:type credentials: Credentials
|
||||
"""
|
||||
|
||||
_service = "auth"
|
||||
_action = "create_credentials"
|
||||
_version = "2.23"
|
||||
|
||||
_schema = {
|
||||
"definitions": {
|
||||
"credentials": {
|
||||
"properties": {
|
||||
"access_key": {
|
||||
"description": "Credentials access key",
|
||||
"type": ["string", "null"],
|
||||
},
|
||||
"label": {
|
||||
"description": "Optional credentials label",
|
||||
"type": ["string", "null"],
|
||||
},
|
||||
"secret_key": {
|
||||
"description": "Credentials secret key",
|
||||
"type": ["string", "null"],
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"credentials": {
|
||||
"description": "Created credentials",
|
||||
"oneOf": [{"$ref": "#/definitions/credentials"}, {"type": "null"}],
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, credentials=None, **kwargs):
|
||||
super(CreateCredentialsResponse, self).__init__(**kwargs)
|
||||
self.credentials = credentials
|
||||
|
||||
@schema_property("credentials")
|
||||
def credentials(self):
|
||||
return self._property_credentials
|
||||
|
||||
@credentials.setter
|
||||
def credentials(self, value):
|
||||
if value is None:
|
||||
self._property_credentials = None
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
value = Credentials.from_dict(value)
|
||||
else:
|
||||
self.assert_isinstance(value, "credentials", Credentials)
|
||||
self._property_credentials = value
|
||||
|
||||
|
||||
class EditCredentialsRequest(Request):
|
||||
"""
|
||||
Updates the label of the existing credentials for the authenticated user.
|
||||
|
||||
:param access_key: Existing credentials key
|
||||
:type access_key: str
|
||||
:param label: New credentials label
|
||||
:type label: str
|
||||
"""
|
||||
|
||||
_service = "auth"
|
||||
_action = "edit_credentials"
|
||||
_version = "2.23"
|
||||
_schema = {
|
||||
"definitions": {},
|
||||
"properties": {
|
||||
"access_key": {"description": "Existing credentials key", "type": "string"},
|
||||
"label": {"description": "New credentials label", "type": "string"},
|
||||
},
|
||||
"required": ["access_key"],
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, access_key, label=None, **kwargs):
|
||||
super(EditCredentialsRequest, self).__init__(**kwargs)
|
||||
self.access_key = access_key
|
||||
self.label = label
|
||||
|
||||
@schema_property("access_key")
|
||||
def access_key(self):
|
||||
return self._property_access_key
|
||||
|
||||
@access_key.setter
|
||||
def access_key(self, value):
|
||||
if value is None:
|
||||
self._property_access_key = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "access_key", six.string_types)
|
||||
self._property_access_key = value
|
||||
|
||||
@schema_property("label")
|
||||
def label(self):
|
||||
return self._property_label
|
||||
|
||||
@label.setter
|
||||
def label(self, value):
|
||||
if value is None:
|
||||
self._property_label = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "label", six.string_types)
|
||||
self._property_label = value
|
||||
|
||||
|
||||
class EditCredentialsResponse(Response):
|
||||
"""
|
||||
Response of auth.edit_credentials endpoint.
|
||||
|
||||
:param updated: Number of credentials updated
|
||||
:type updated: int
|
||||
"""
|
||||
|
||||
_service = "auth"
|
||||
_action = "edit_credentials"
|
||||
_version = "2.23"
|
||||
|
||||
_schema = {
|
||||
"definitions": {},
|
||||
"properties": {
|
||||
"updated": {
|
||||
"description": "Number of credentials updated",
|
||||
"enum": [0, 1],
|
||||
"type": ["integer", "null"],
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, updated=None, **kwargs):
|
||||
super(EditCredentialsResponse, self).__init__(**kwargs)
|
||||
self.updated = updated
|
||||
|
||||
@schema_property("updated")
|
||||
def updated(self):
|
||||
return self._property_updated
|
||||
|
||||
@updated.setter
|
||||
def updated(self, value):
|
||||
if value is None:
|
||||
self._property_updated = None
|
||||
return
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
value = int(value)
|
||||
|
||||
self.assert_isinstance(value, "updated", six.integer_types)
|
||||
self._property_updated = value
|
||||
|
||||
|
||||
class EditUserRequest(Request):
|
||||
"""
|
||||
Edit a users' auth data properties
|
||||
|
||||
:param user: User ID
|
||||
:type user: str
|
||||
:param role: The new user's role within the company
|
||||
:type role: str
|
||||
"""
|
||||
|
||||
_service = "auth"
|
||||
_action = "edit_user"
|
||||
_version = "2.23"
|
||||
_schema = {
|
||||
"definitions": {},
|
||||
"properties": {
|
||||
"role": {
|
||||
"description": "The new user's role within the company",
|
||||
"enum": ["admin", "superuser", "user", "annotator"],
|
||||
"type": ["string", "null"],
|
||||
},
|
||||
"user": {"description": "User ID", "type": ["string", "null"]},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, user=None, role=None, **kwargs):
|
||||
super(EditUserRequest, self).__init__(**kwargs)
|
||||
self.user = user
|
||||
self.role = role
|
||||
|
||||
@schema_property("user")
|
||||
def user(self):
|
||||
return self._property_user
|
||||
|
||||
@user.setter
|
||||
def user(self, value):
|
||||
if value is None:
|
||||
self._property_user = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "user", six.string_types)
|
||||
self._property_user = value
|
||||
|
||||
@schema_property("role")
|
||||
def role(self):
|
||||
return self._property_role
|
||||
|
||||
@role.setter
|
||||
def role(self, value):
|
||||
if value is None:
|
||||
self._property_role = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "role", six.string_types)
|
||||
self._property_role = value
|
||||
|
||||
|
||||
class EditUserResponse(Response):
|
||||
"""
|
||||
Response of auth.edit_user endpoint.
|
||||
|
||||
:param updated: Number of users updated (0 or 1)
|
||||
:type updated: float
|
||||
:param fields: Updated fields names and values
|
||||
:type fields: dict
|
||||
"""
|
||||
|
||||
_service = "auth"
|
||||
_action = "edit_user"
|
||||
_version = "2.23"
|
||||
|
||||
_schema = {
|
||||
"definitions": {},
|
||||
"properties": {
|
||||
"fields": {
|
||||
"additionalProperties": True,
|
||||
"description": "Updated fields names and values",
|
||||
"type": ["object", "null"],
|
||||
},
|
||||
"updated": {
|
||||
"description": "Number of users updated (0 or 1)",
|
||||
"enum": [0, 1],
|
||||
"type": ["number", "null"],
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, updated=None, fields=None, **kwargs):
|
||||
super(EditUserResponse, self).__init__(**kwargs)
|
||||
self.updated = updated
|
||||
self.fields = fields
|
||||
|
||||
@schema_property("updated")
|
||||
def updated(self):
|
||||
return self._property_updated
|
||||
|
||||
@updated.setter
|
||||
def updated(self, value):
|
||||
if value is None:
|
||||
self._property_updated = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "updated", six.integer_types + (float,))
|
||||
self._property_updated = value
|
||||
|
||||
@schema_property("fields")
|
||||
def fields(self):
|
||||
return self._property_fields
|
||||
|
||||
@fields.setter
|
||||
def fields(self, value):
|
||||
if value is None:
|
||||
self._property_fields = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "fields", (dict,))
|
||||
self._property_fields = value
|
||||
|
||||
|
||||
class GetCredentialsRequest(Request):
|
||||
"""
|
||||
Returns all existing credential keys for the authenticated user.
|
||||
Note: Only credential keys are returned.
|
||||
|
||||
"""
|
||||
|
||||
_service = "auth"
|
||||
_action = "get_credentials"
|
||||
_version = "2.23"
|
||||
_schema = {
|
||||
"additionalProperties": False,
|
||||
"definitions": {},
|
||||
"properties": {},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
|
||||
class GetCredentialsResponse(Response):
|
||||
"""
|
||||
Response of auth.get_credentials endpoint.
|
||||
|
||||
:param credentials: List of credentials, each with an empty secret field.
|
||||
:type credentials: Sequence[CredentialKey]
|
||||
"""
|
||||
|
||||
_service = "auth"
|
||||
_action = "get_credentials"
|
||||
_version = "2.23"
|
||||
|
||||
_schema = {
|
||||
"definitions": {
|
||||
"credential_key": {
|
||||
"properties": {
|
||||
"access_key": {"description": "", "type": ["string", "null"]},
|
||||
"label": {
|
||||
"description": "Optional credentials label",
|
||||
"type": ["string", "null"],
|
||||
},
|
||||
"last_used": {
|
||||
"description": "",
|
||||
"format": "date-time",
|
||||
"type": ["string", "null"],
|
||||
},
|
||||
"last_used_from": {"description": "", "type": ["string", "null"]},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"credentials": {
|
||||
"description": "List of credentials, each with an empty secret field.",
|
||||
"items": {"$ref": "#/definitions/credential_key"},
|
||||
"type": ["array", "null"],
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, credentials=None, **kwargs):
|
||||
super(GetCredentialsResponse, self).__init__(**kwargs)
|
||||
self.credentials = credentials
|
||||
|
||||
@schema_property("credentials")
|
||||
def credentials(self):
|
||||
return self._property_credentials
|
||||
|
||||
@credentials.setter
|
||||
def credentials(self, value):
|
||||
if value is None:
|
||||
self._property_credentials = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "credentials", (list, tuple))
|
||||
if any(isinstance(v, dict) for v in value):
|
||||
value = [CredentialKey.from_dict(v) if isinstance(v, dict) else v for v in value]
|
||||
else:
|
||||
self.assert_isinstance(value, "credentials", CredentialKey, is_array=True)
|
||||
self._property_credentials = value
|
||||
|
||||
|
||||
class LoginRequest(Request):
|
||||
"""
|
||||
Get a token based on supplied credentials (key/secret).
|
||||
Intended for use by users with key/secret credentials that wish to obtain a token
|
||||
for use with other services.
|
||||
|
||||
:param expiration_sec: Requested token expiration time in seconds. Not
|
||||
guaranteed, might be overridden by the service
|
||||
:type expiration_sec: int
|
||||
"""
|
||||
|
||||
_service = "auth"
|
||||
_action = "login"
|
||||
_version = "2.23"
|
||||
_schema = {
|
||||
"definitions": {},
|
||||
"properties": {
|
||||
"expiration_sec": {
|
||||
"description": (
|
||||
"Requested token expiration time in seconds. \n Not guaranteed, might be "
|
||||
"overridden by the service"
|
||||
),
|
||||
"type": ["integer", "null"],
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, expiration_sec=None, **kwargs):
|
||||
super(LoginRequest, self).__init__(**kwargs)
|
||||
self.expiration_sec = expiration_sec
|
||||
|
||||
@schema_property("expiration_sec")
|
||||
def expiration_sec(self):
|
||||
return self._property_expiration_sec
|
||||
|
||||
@expiration_sec.setter
|
||||
def expiration_sec(self, value):
|
||||
if value is None:
|
||||
self._property_expiration_sec = None
|
||||
return
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
value = int(value)
|
||||
|
||||
self.assert_isinstance(value, "expiration_sec", six.integer_types)
|
||||
self._property_expiration_sec = value
|
||||
|
||||
|
||||
class LoginResponse(Response):
|
||||
"""
|
||||
Response of auth.login endpoint.
|
||||
|
||||
:param token: Token string
|
||||
:type token: str
|
||||
"""
|
||||
|
||||
_service = "auth"
|
||||
_action = "login"
|
||||
_version = "2.23"
|
||||
|
||||
_schema = {
|
||||
"definitions": {},
|
||||
"properties": {"token": {"description": "Token string", "type": ["string", "null"]}},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, token=None, **kwargs):
|
||||
super(LoginResponse, self).__init__(**kwargs)
|
||||
self.token = token
|
||||
|
||||
@schema_property("token")
|
||||
def token(self):
|
||||
return self._property_token
|
||||
|
||||
@token.setter
|
||||
def token(self, value):
|
||||
if value is None:
|
||||
self._property_token = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "token", six.string_types)
|
||||
self._property_token = value
|
||||
|
||||
|
||||
class RevokeCredentialsRequest(Request):
|
||||
"""
|
||||
Revokes (and deletes) a set (key, secret) of credentials for
|
||||
the authenticated user.
|
||||
|
||||
:param access_key: Credentials key
|
||||
:type access_key: str
|
||||
"""
|
||||
|
||||
_service = "auth"
|
||||
_action = "revoke_credentials"
|
||||
_version = "2.23"
|
||||
_schema = {
|
||||
"definitions": {},
|
||||
"properties": {"access_key": {"description": "Credentials key", "type": ["string", "null"]}},
|
||||
"required": ["key_id"],
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, access_key=None, **kwargs):
|
||||
super(RevokeCredentialsRequest, self).__init__(**kwargs)
|
||||
self.access_key = access_key
|
||||
|
||||
@schema_property("access_key")
|
||||
def access_key(self):
|
||||
return self._property_access_key
|
||||
|
||||
@access_key.setter
|
||||
def access_key(self, value):
|
||||
if value is None:
|
||||
self._property_access_key = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "access_key", six.string_types)
|
||||
self._property_access_key = value
|
||||
|
||||
|
||||
class RevokeCredentialsResponse(Response):
|
||||
"""
|
||||
Response of auth.revoke_credentials endpoint.
|
||||
|
||||
:param revoked: Number of credentials revoked
|
||||
:type revoked: int
|
||||
"""
|
||||
|
||||
_service = "auth"
|
||||
_action = "revoke_credentials"
|
||||
_version = "2.23"
|
||||
|
||||
_schema = {
|
||||
"definitions": {},
|
||||
"properties": {
|
||||
"revoked": {
|
||||
"description": "Number of credentials revoked",
|
||||
"enum": [0, 1],
|
||||
"type": ["integer", "null"],
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, revoked=None, **kwargs):
|
||||
super(RevokeCredentialsResponse, self).__init__(**kwargs)
|
||||
self.revoked = revoked
|
||||
|
||||
@schema_property("revoked")
|
||||
def revoked(self):
|
||||
return self._property_revoked
|
||||
|
||||
@revoked.setter
|
||||
def revoked(self, value):
|
||||
if value is None:
|
||||
self._property_revoked = None
|
||||
return
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
value = int(value)
|
||||
|
||||
self.assert_isinstance(value, "revoked", six.integer_types)
|
||||
self._property_revoked = value
|
||||
|
||||
|
||||
response_mapping = {
|
||||
LoginRequest: LoginResponse,
|
||||
CreateCredentialsRequest: CreateCredentialsResponse,
|
||||
GetCredentialsRequest: GetCredentialsResponse,
|
||||
EditCredentialsRequest: EditCredentialsResponse,
|
||||
RevokeCredentialsRequest: RevokeCredentialsResponse,
|
||||
EditUserRequest: EditUserResponse,
|
||||
}
|
5969
clearml/backend_api/services/v2_23/events.py
Normal file
5969
clearml/backend_api/services/v2_23/events.py
Normal file
File diff suppressed because it is too large
Load Diff
4840
clearml/backend_api/services/v2_23/models.py
Normal file
4840
clearml/backend_api/services/v2_23/models.py
Normal file
File diff suppressed because it is too large
Load Diff
166
clearml/backend_api/services/v2_23/organization.py
Normal file
166
clearml/backend_api/services/v2_23/organization.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""
|
||||
organization service
|
||||
|
||||
This service provides organization level operations
|
||||
"""
|
||||
import six
|
||||
|
||||
from clearml.backend_api.session import Request, Response, schema_property
|
||||
|
||||
|
||||
class GetTagsRequest(Request):
|
||||
"""
|
||||
Get all the user and system tags used for the company tasks and models
|
||||
|
||||
:param include_system: If set to 'true' then the list of the system tags is
|
||||
also returned. The default value is 'false'
|
||||
:type include_system: bool
|
||||
:param filter: Filter on entities to collect tags from
|
||||
:type filter: dict
|
||||
"""
|
||||
|
||||
_service = "organization"
|
||||
_action = "get_tags"
|
||||
_version = "2.23"
|
||||
_schema = {
|
||||
"definitions": {},
|
||||
"properties": {
|
||||
"filter": {
|
||||
"description": "Filter on entities to collect tags from",
|
||||
"properties": {
|
||||
"system_tags": {
|
||||
"description": (
|
||||
"The list of system tag values to filter by. Use 'null' value to specify empty system tags."
|
||||
" Use '__Snot' value to specify that the following value should be excluded"
|
||||
),
|
||||
"items": {"type": "string"},
|
||||
"type": "array",
|
||||
},
|
||||
"tags": {
|
||||
"description": (
|
||||
"The list of tag values to filter by. Use 'null' value to specify empty tags. Use '__Snot'"
|
||||
" value to specify that the following value should be excluded"
|
||||
),
|
||||
"items": {"type": "string"},
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"type": ["object", "null"],
|
||||
},
|
||||
"include_system": {
|
||||
"default": False,
|
||||
"description": (
|
||||
"If set to 'true' then the list of the system tags is also returned. The default value is 'false'"
|
||||
),
|
||||
"type": ["boolean", "null"],
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, include_system=False, filter=None, **kwargs):
|
||||
super(GetTagsRequest, self).__init__(**kwargs)
|
||||
self.include_system = include_system
|
||||
self.filter = filter
|
||||
|
||||
@schema_property("include_system")
|
||||
def include_system(self):
|
||||
return self._property_include_system
|
||||
|
||||
@include_system.setter
|
||||
def include_system(self, value):
|
||||
if value is None:
|
||||
self._property_include_system = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "include_system", (bool,))
|
||||
self._property_include_system = value
|
||||
|
||||
@schema_property("filter")
|
||||
def filter(self):
|
||||
return self._property_filter
|
||||
|
||||
@filter.setter
|
||||
def filter(self, value):
|
||||
if value is None:
|
||||
self._property_filter = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "filter", (dict,))
|
||||
self._property_filter = value
|
||||
|
||||
|
||||
class GetTagsResponse(Response):
|
||||
"""
|
||||
Response of organization.get_tags endpoint.
|
||||
|
||||
:param tags: The list of unique tag values
|
||||
:type tags: Sequence[str]
|
||||
:param system_tags: The list of unique system tag values. Returned only if
|
||||
'include_system' is set to 'true' in the request
|
||||
:type system_tags: Sequence[str]
|
||||
"""
|
||||
|
||||
_service = "organization"
|
||||
_action = "get_tags"
|
||||
_version = "2.23"
|
||||
|
||||
_schema = {
|
||||
"definitions": {},
|
||||
"properties": {
|
||||
"system_tags": {
|
||||
"description": (
|
||||
"The list of unique system tag values. Returned only if 'include_system' is set to 'true' in the"
|
||||
" request"
|
||||
),
|
||||
"items": {"type": "string"},
|
||||
"type": ["array", "null"],
|
||||
},
|
||||
"tags": {
|
||||
"description": "The list of unique tag values",
|
||||
"items": {"type": "string"},
|
||||
"type": ["array", "null"],
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, tags=None, system_tags=None, **kwargs):
|
||||
super(GetTagsResponse, self).__init__(**kwargs)
|
||||
self.tags = tags
|
||||
self.system_tags = system_tags
|
||||
|
||||
@schema_property("tags")
|
||||
def tags(self):
|
||||
return self._property_tags
|
||||
|
||||
@tags.setter
|
||||
def tags(self, value):
|
||||
if value is None:
|
||||
self._property_tags = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "tags", (list, tuple))
|
||||
|
||||
self.assert_isinstance(value, "tags", six.string_types, is_array=True)
|
||||
self._property_tags = value
|
||||
|
||||
@schema_property("system_tags")
|
||||
def system_tags(self):
|
||||
return self._property_system_tags
|
||||
|
||||
@system_tags.setter
|
||||
def system_tags(self, value):
|
||||
if value is None:
|
||||
self._property_system_tags = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "system_tags", (list, tuple))
|
||||
|
||||
self.assert_isinstance(value, "system_tags", six.string_types, is_array=True)
|
||||
self._property_system_tags = value
|
||||
|
||||
|
||||
response_mapping = {
|
||||
GetTagsRequest: GetTagsResponse,
|
||||
}
|
169
clearml/backend_api/services/v2_23/pipelines.py
Normal file
169
clearml/backend_api/services/v2_23/pipelines.py
Normal file
@ -0,0 +1,169 @@
|
||||
"""
|
||||
pipelines service
|
||||
|
||||
Provides a management API for pipelines in the system.
|
||||
"""
|
||||
import six
|
||||
|
||||
from clearml.backend_api.session import (
|
||||
Request,
|
||||
Response,
|
||||
schema_property,
|
||||
)
|
||||
|
||||
|
||||
class StartPipelineRequest(Request):
|
||||
"""
|
||||
Start a pipeline
|
||||
|
||||
:param task: ID of the task on which the pipeline will be based
|
||||
:type task: str
|
||||
:param queue: Queue ID in which the created pipeline task will be enqueued
|
||||
:type queue: str
|
||||
:param args: Task arguments, name/value to be placed in the hyperparameters
|
||||
Args section
|
||||
:type args: Sequence[dict]
|
||||
"""
|
||||
|
||||
_service = "pipelines"
|
||||
_action = "start_pipeline"
|
||||
_version = "2.23"
|
||||
_schema = {
|
||||
"definitions": {},
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "Task arguments, name/value to be placed in the hyperparameters Args section",
|
||||
"items": {
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"value": {"type": ["string", "null"]},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"queue": {
|
||||
"description": "Queue ID in which the created pipeline task will be enqueued",
|
||||
"type": "string",
|
||||
},
|
||||
"task": {
|
||||
"description": "ID of the task on which the pipeline will be based",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": ["task"],
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, task, queue=None, args=None, **kwargs):
|
||||
super(StartPipelineRequest, self).__init__(**kwargs)
|
||||
self.task = task
|
||||
self.queue = queue
|
||||
self.args = args
|
||||
|
||||
@schema_property("task")
|
||||
def task(self):
|
||||
return self._property_task
|
||||
|
||||
@task.setter
|
||||
def task(self, value):
|
||||
if value is None:
|
||||
self._property_task = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "task", six.string_types)
|
||||
self._property_task = value
|
||||
|
||||
@schema_property("queue")
|
||||
def queue(self):
|
||||
return self._property_queue
|
||||
|
||||
@queue.setter
|
||||
def queue(self, value):
|
||||
if value is None:
|
||||
self._property_queue = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "queue", six.string_types)
|
||||
self._property_queue = value
|
||||
|
||||
@schema_property("args")
|
||||
def args(self):
|
||||
return self._property_args
|
||||
|
||||
@args.setter
|
||||
def args(self, value):
|
||||
if value is None:
|
||||
self._property_args = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "args", (list, tuple))
|
||||
|
||||
self.assert_isinstance(value, "args", (dict,), is_array=True)
|
||||
self._property_args = value
|
||||
|
||||
|
||||
class StartPipelineResponse(Response):
|
||||
"""
|
||||
Response of pipelines.start_pipeline endpoint.
|
||||
|
||||
:param pipeline: ID of the new pipeline task
|
||||
:type pipeline: str
|
||||
:param enqueued: True if the task was successfully enqueued
|
||||
:type enqueued: bool
|
||||
"""
|
||||
|
||||
_service = "pipelines"
|
||||
_action = "start_pipeline"
|
||||
_version = "2.23"
|
||||
|
||||
_schema = {
|
||||
"definitions": {},
|
||||
"properties": {
|
||||
"enqueued": {
|
||||
"description": "True if the task was successfully enqueued",
|
||||
"type": ["boolean", "null"],
|
||||
},
|
||||
"pipeline": {
|
||||
"description": "ID of the new pipeline task",
|
||||
"type": ["string", "null"],
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def __init__(self, pipeline=None, enqueued=None, **kwargs):
|
||||
super(StartPipelineResponse, self).__init__(**kwargs)
|
||||
self.pipeline = pipeline
|
||||
self.enqueued = enqueued
|
||||
|
||||
@schema_property("pipeline")
|
||||
def pipeline(self):
|
||||
return self._property_pipeline
|
||||
|
||||
@pipeline.setter
|
||||
def pipeline(self, value):
|
||||
if value is None:
|
||||
self._property_pipeline = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "pipeline", six.string_types)
|
||||
self._property_pipeline = value
|
||||
|
||||
@schema_property("enqueued")
|
||||
def enqueued(self):
|
||||
return self._property_enqueued
|
||||
|
||||
@enqueued.setter
|
||||
def enqueued(self, value):
|
||||
if value is None:
|
||||
self._property_enqueued = None
|
||||
return
|
||||
|
||||
self.assert_isinstance(value, "enqueued", (bool,))
|
||||
self._property_enqueued = value
|
||||
|
||||
|
||||
response_mapping = {
|
||||
StartPipelineRequest: StartPipelineResponse,
|
||||
}
|
4785
clearml/backend_api/services/v2_23/projects.py
Normal file
4785
clearml/backend_api/services/v2_23/projects.py
Normal file
File diff suppressed because it is too large
Load Diff
3095
clearml/backend_api/services/v2_23/queues.py
Normal file
3095
clearml/backend_api/services/v2_23/queues.py
Normal file
File diff suppressed because it is too large
Load Diff
13600
clearml/backend_api/services/v2_23/tasks.py
Normal file
13600
clearml/backend_api/services/v2_23/tasks.py
Normal file
File diff suppressed because it is too large
Load Diff
2701
clearml/backend_api/services/v2_23/workers.py
Normal file
2701
clearml/backend_api/services/v2_23/workers.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -89,7 +89,13 @@ class CompoundRequest(Request):
|
||||
return item
|
||||
|
||||
def to_dict(self):
|
||||
return self._get_item().to_dict()
|
||||
dict_ = self._get_item().to_dict()
|
||||
dict_properties = super(Request, self).to_dict()
|
||||
if self._item_prop_name in dict_properties:
|
||||
del dict_properties[self._item_prop_name]
|
||||
dict_.update(dict_properties)
|
||||
|
||||
return dict_
|
||||
|
||||
def validate(self):
|
||||
return self._get_item().validate(self._schema)
|
||||
|
@ -77,7 +77,7 @@ class MetricsEventAdapter(object):
|
||||
def variant(self):
|
||||
return self._variant
|
||||
|
||||
def __init__(self, metric, variant, iter=None, timestamp=None, task=None, gen_timestamp_if_none=True):
|
||||
def __init__(self, metric, variant, iter=None, timestamp=None, task=None, gen_timestamp_if_none=True, , model_event=None):
|
||||
if not timestamp and gen_timestamp_if_none:
|
||||
timestamp = int(time.time() * 1000)
|
||||
self._metric = metric
|
||||
@ -85,6 +85,7 @@ class MetricsEventAdapter(object):
|
||||
self._iter = iter
|
||||
self._timestamp = timestamp
|
||||
self._task = task
|
||||
self._model_event = model_event
|
||||
|
||||
# Try creating an event just to trigger validation
|
||||
_ = self.get_api_event()
|
||||
@ -119,6 +120,8 @@ class MetricsEventAdapter(object):
|
||||
)
|
||||
if self._iter is not None:
|
||||
res.update(iter=self._iter)
|
||||
if self._model_event is not None:
|
||||
res.update(model_event=self._model_event)
|
||||
return res
|
||||
|
||||
@classmethod
|
||||
|
@ -47,7 +47,7 @@ class Metrics(InterfaceBase):
|
||||
finally:
|
||||
self._storage_lock.release()
|
||||
|
||||
def __init__(self, session, task, storage_uri, storage_uri_suffix='metrics', iteration_offset=0, log=None):
|
||||
def __init__(self, session, task, storage_uri, storage_uri_suffix='metrics', iteration_offset=0, log=None, for_model=False):
|
||||
super(Metrics, self).__init__(session, log=log)
|
||||
self._task_id = task.id
|
||||
self._task_iteration_offset = iteration_offset
|
||||
@ -56,6 +56,7 @@ class Metrics(InterfaceBase):
|
||||
self._file_related_event_time = None
|
||||
self._file_upload_time = None
|
||||
self._offline_log_filename = None
|
||||
self._for_model = for_model
|
||||
if self._offline_mode:
|
||||
offline_folder = Path(task.get_offline_mode_folder())
|
||||
offline_folder.mkdir(parents=True, exist_ok=True)
|
||||
@ -213,7 +214,10 @@ class Metrics(InterfaceBase):
|
||||
|
||||
if good_events:
|
||||
_events = [ev.get_api_event() for ev in good_events]
|
||||
batched_requests = [api_events.AddRequest(event=ev) for ev in _events if ev]
|
||||
additional_kwargs = {}
|
||||
if Session.check_min_api_version("2.23"):
|
||||
additional_kwargs["model_event"] = self._for_model
|
||||
batched_requests = [api_events.AddRequest(event=ev, **additional_kwargs) for ev in _events if ev]
|
||||
if batched_requests:
|
||||
if self._offline_mode:
|
||||
with open(self._offline_log_filename.as_posix(), 'at') as f:
|
||||
|
@ -33,9 +33,8 @@ except ImportError:
|
||||
class BackgroundReportService(BackgroundMonitor, AsyncManagerMixin):
|
||||
__daemon_live_check_timeout = 10.0
|
||||
|
||||
def __init__(self, task, async_enable, metrics, flush_frequency, flush_threshold):
|
||||
super(BackgroundReportService, self).__init__(
|
||||
task=task, wait_period=flush_frequency)
|
||||
def __init__(self, task, async_enable, metrics, flush_frequency, flush_threshold, for_model=False):
|
||||
super(BackgroundReportService, self).__init__(task=task, wait_period=flush_frequency, for_model=for_model)
|
||||
self._flush_threshold = flush_threshold
|
||||
self._flush_event = ForkEvent()
|
||||
self._empty_state_event = ForkEvent()
|
||||
@ -250,7 +249,7 @@ class Reporter(InterfaceBase, AbstractContextManager, SetupUploadMixin, AsyncMan
|
||||
reporter.flush()
|
||||
"""
|
||||
|
||||
def __init__(self, metrics, task, async_enable=False):
|
||||
def __init__(self, metrics, task, async_enable=False, for_model=False):
|
||||
"""
|
||||
Create a reporter
|
||||
:param metrics: A Metrics manager instance that handles actual reporting, uploads etc.
|
||||
@ -269,10 +268,12 @@ class Reporter(InterfaceBase, AbstractContextManager, SetupUploadMixin, AsyncMan
|
||||
self._async_enable = async_enable
|
||||
self._flush_frequency = 5.0
|
||||
self._max_iteration = 0
|
||||
self._for_model = for_model
|
||||
flush_threshold = config.get("development.worker.report_event_flush_threshold", 100)
|
||||
self._report_service = BackgroundReportService(
|
||||
task=task, async_enable=async_enable, metrics=metrics,
|
||||
flush_frequency=self._flush_frequency, flush_threshold=flush_threshold)
|
||||
flush_frequency=self._flush_frequency,
|
||||
flush_threshold=flush_threshold, for_model=for_model)
|
||||
self._report_service.start()
|
||||
|
||||
def _set_storage_uri(self, value):
|
||||
|
636
clearml/model.py
636
clearml/model.py
@ -6,7 +6,14 @@ import shutil
|
||||
from tempfile import mkdtemp, mkstemp
|
||||
|
||||
import six
|
||||
from typing import List, Dict, Union, Optional, Mapping, TYPE_CHECKING, Sequence, Any
|
||||
import math
|
||||
from typing import List, Dict, Union, Optional, Mapping, TYPE_CHECKING, Sequence, Any, Tuple
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
pd = None
|
||||
|
||||
from .backend_api import Session
|
||||
from .backend_api.services import models, projects
|
||||
@ -14,11 +21,13 @@ from pathlib2 import Path
|
||||
|
||||
from .utilities.config import config_dict_to_text, text_to_config_dict
|
||||
from .utilities.proxy_object import cast_basic_type
|
||||
from .utilities.plotly_reporter import SeriesInfo
|
||||
|
||||
from .backend_interface.util import (
|
||||
validate_dict, get_single_result, mutually_exclusive, exact_match_regex,
|
||||
get_or_create_project, )
|
||||
from .debugging.log import get_logger
|
||||
from .errors import UsageError
|
||||
from .storage.cache import CacheManager
|
||||
from .storage.helper import StorageHelper
|
||||
from .storage.util import get_common_path
|
||||
@ -26,6 +35,7 @@ from .utilities.enum import Options
|
||||
from .backend_interface import Task as _Task
|
||||
from .backend_interface.model import create_dummy_model, Model as _Model
|
||||
from .backend_interface.session import SendError
|
||||
from .backend_interface.metrics import Reporter, Metrics
|
||||
from .config import running_remotely, get_cache_dir
|
||||
|
||||
|
||||
@ -329,6 +339,7 @@ class BaseModel(object):
|
||||
self._log = get_logger()
|
||||
self._task = None
|
||||
self._reload_required = False
|
||||
self._reporter = None
|
||||
self._set_task(task)
|
||||
|
||||
def get_weights(self, raise_on_error=False, force_download=False):
|
||||
@ -400,6 +411,616 @@ class BaseModel(object):
|
||||
target_files = list(Path(target_folder).glob('*'))
|
||||
return target_files
|
||||
|
||||
def report_scalar(self, title, series, value, iteration):
|
||||
# type: (str, str, float, int) -> None
|
||||
"""
|
||||
For explicit reporting, plot a scalar series.
|
||||
|
||||
:param str title: The title (metric) of the plot. Plot more than one scalar series on the same plot by using
|
||||
the same ``title`` for each call to this method.
|
||||
:param str series: The series name (variant) of the reported scalar.
|
||||
:param float value: The value to plot per iteration.
|
||||
:param int iteration: The reported iteration / step (x-axis of the reported time series)
|
||||
"""
|
||||
self._init_reporter()
|
||||
return self._reporter.report_scalar(title=title, series=series, value=float(value), iter=iteration)
|
||||
|
||||
def report_single_value(self, name, value):
|
||||
# type: (str, float) -> None
|
||||
"""
|
||||
Reports a single value metric (for example, total experiment accuracy or mAP)
|
||||
|
||||
:param name: Metric's name
|
||||
:param value: Metric's value
|
||||
"""
|
||||
self._init_reporter()
|
||||
return self._reporter.report_scalar(title="Summary", series=name, value=float(value), iter=-2**31)
|
||||
|
||||
def report_histogram(
|
||||
self,
|
||||
title, # type: str
|
||||
series, # type: str
|
||||
values, # type: Sequence[Union[int, float]]
|
||||
iteration=None, # type: Optional[int]
|
||||
labels=None, # type: Optional[List[str]]
|
||||
xlabels=None, # type: Optional[List[str]]
|
||||
xaxis=None, # type: Optional[str]
|
||||
yaxis=None, # type: Optional[str]
|
||||
mode=None, # type: Optional[str]
|
||||
data_args=None, # type: Optional[dict]
|
||||
extra_layout=None # type: Optional[dict]
|
||||
):
|
||||
"""
|
||||
For explicit reporting, plot a (default grouped) histogram.
|
||||
Notice this function will not calculate the histogram,
|
||||
it assumes the histogram was already calculated in `values`
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
vector_series = np.random.randint(10, size=10).reshape(2,5)
|
||||
model.report_histogram(title='histogram example', series='histogram series',
|
||||
values=vector_series, iteration=0, labels=['A','B'], xaxis='X axis label', yaxis='Y axis label')
|
||||
|
||||
:param title: The title (metric) of the plot.
|
||||
:param series: The series name (variant) of the reported histogram.
|
||||
:param values: The series values. A list of floats, or an N-dimensional Numpy array containing
|
||||
data for each histogram bar.
|
||||
:param iteration: The reported iteration / step. Each ``iteration`` creates another plot.
|
||||
:param labels: Labels for each bar group, creating a plot legend labeling each series. (Optional)
|
||||
:param xlabels: Labels per entry in each bucket in the histogram (vector), creating a set of labels
|
||||
for each histogram bar on the x-axis. (Optional)
|
||||
:param xaxis: The x-axis title. (Optional)
|
||||
:param yaxis: The y-axis title. (Optional)
|
||||
:param mode: Multiple histograms mode, stack / group / relative. Default is 'group'.
|
||||
:param data_args: optional dictionary for data configuration, passed directly to plotly
|
||||
See full details on the supported configuration: https://plotly.com/javascript/reference/bar/
|
||||
example: data_args={'orientation': 'h', 'marker': {'color': 'blue'}}
|
||||
:param extra_layout: optional dictionary for layout configuration, passed directly to plotly
|
||||
See full details on the supported configuration: https://plotly.com/javascript/reference/bar/
|
||||
example: extra_layout={'xaxis': {'type': 'date', 'range': ['2020-01-01', '2020-01-31']}}
|
||||
"""
|
||||
self._init_reporter()
|
||||
|
||||
if not isinstance(values, np.ndarray):
|
||||
values = np.array(values)
|
||||
|
||||
return self._reporter.report_histogram(
|
||||
title=title,
|
||||
series=series,
|
||||
histogram=values,
|
||||
iter=iteration or 0,
|
||||
labels=labels,
|
||||
xlabels=xlabels,
|
||||
xtitle=xaxis,
|
||||
ytitle=yaxis,
|
||||
mode=mode or "group",
|
||||
data_args=data_args,
|
||||
layout_config=extra_layout
|
||||
)
|
||||
|
||||
def report_vector(
|
||||
self,
|
||||
title, # type: str
|
||||
series, # type: str
|
||||
values, # type: Sequence[Union[int, float]]
|
||||
iteration=None, # type: Optional[int]
|
||||
labels=None, # type: Optional[List[str]]
|
||||
xlabels=None, # type: Optional[List[str]]
|
||||
xaxis=None, # type: Optional[str]
|
||||
yaxis=None, # type: Optional[str]
|
||||
mode=None, # type: Optional[str]
|
||||
extra_layout=None # type: Optional[dict]
|
||||
):
|
||||
"""
|
||||
For explicit reporting, plot a vector as (default stacked) histogram.
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
vector_series = np.random.randint(10, size=10).reshape(2,5)
|
||||
model.report_vector(title='vector example', series='vector series', values=vector_series, iteration=0,
|
||||
labels=['A','B'], xaxis='X axis label', yaxis='Y axis label')
|
||||
|
||||
:param title: The title (metric) of the plot.
|
||||
:param series: The series name (variant) of the reported histogram.
|
||||
:param values: The series values. A list of floats, or an N-dimensional Numpy array containing
|
||||
data for each histogram bar.
|
||||
:param iteration: The reported iteration / step. Each ``iteration`` creates another plot.
|
||||
:param labels: Labels for each bar group, creating a plot legend labeling each series. (Optional)
|
||||
:param xlabels: Labels per entry in each bucket in the histogram (vector), creating a set of labels
|
||||
for each histogram bar on the x-axis. (Optional)
|
||||
:param xaxis: The x-axis title. (Optional)
|
||||
:param yaxis: The y-axis title. (Optional)
|
||||
:param mode: Multiple histograms mode, stack / group / relative. Default is 'group'.
|
||||
:param extra_layout: optional dictionary for layout configuration, passed directly to plotly
|
||||
See full details on the supported configuration: https://plotly.com/javascript/reference/layout/
|
||||
example: extra_layout={'showlegend': False, 'plot_bgcolor': 'yellow'}
|
||||
"""
|
||||
self._init_reporter()
|
||||
return self.report_histogram(
|
||||
title,
|
||||
series,
|
||||
values,
|
||||
iteration or 0,
|
||||
labels=labels,
|
||||
xlabels=xlabels,
|
||||
xaxis=xaxis,
|
||||
yaxis=yaxis,
|
||||
mode=mode,
|
||||
extra_layout=extra_layout,
|
||||
)
|
||||
|
||||
def report_table(
|
||||
self,
|
||||
title, # type: str
|
||||
series, # type: str
|
||||
iteration=None, # type: Optional[int]
|
||||
table_plot=None, # type: Optional[pd.DataFrame, Sequence[Sequence]]
|
||||
csv=None, # type: Optional[str]
|
||||
url=None, # type: Optional[str]
|
||||
extra_layout=None # type: Optional[dict]
|
||||
):
|
||||
"""
|
||||
For explicit report, report a table plot.
|
||||
|
||||
One and only one of the following parameters must be provided.
|
||||
|
||||
- ``table_plot`` - Pandas DataFrame or Table as list of rows (list)
|
||||
- ``csv`` - CSV file
|
||||
- ``url`` - URL to CSV file
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
df = pd.DataFrame({'num_legs': [2, 4, 8, 0],
|
||||
'num_wings': [2, 0, 0, 0],
|
||||
'num_specimen_seen': [10, 2, 1, 8]},
|
||||
index=['falcon', 'dog', 'spider', 'fish'])
|
||||
|
||||
model.report_table(title='table example',series='pandas DataFrame',iteration=0,table_plot=df)
|
||||
|
||||
:param title: The title (metric) of the table.
|
||||
:param series: The series name (variant) of the reported table.
|
||||
:param iteration: The reported iteration / step.
|
||||
:param table_plot: The output table plot object
|
||||
:param csv: path to local csv file
|
||||
:param url: A URL to the location of csv file.
|
||||
:param extra_layout: optional dictionary for layout configuration, passed directly to plotly
|
||||
See full details on the supported configuration: https://plotly.com/javascript/reference/layout/
|
||||
example: extra_layout={'height': 600}
|
||||
"""
|
||||
mutually_exclusive(
|
||||
UsageError, _check_none=True,
|
||||
table_plot=table_plot, csv=csv, url=url
|
||||
)
|
||||
table = table_plot
|
||||
if url or csv:
|
||||
if not pd:
|
||||
raise UsageError(
|
||||
"pandas is required in order to support reporting tables using CSV or a URL, "
|
||||
"please install the pandas python package"
|
||||
)
|
||||
if url:
|
||||
table = pd.read_csv(url, index_col=[0])
|
||||
elif csv:
|
||||
table = pd.read_csv(csv, index_col=[0])
|
||||
|
||||
def replace(dst, *srcs):
|
||||
for src in srcs:
|
||||
reporter_table.replace(src, dst, inplace=True)
|
||||
|
||||
if isinstance(table, (list, tuple)):
|
||||
reporter_table = table
|
||||
else:
|
||||
reporter_table = table.fillna(str(np.nan))
|
||||
replace("NaN", np.nan, math.nan if six.PY3 else float("nan"))
|
||||
replace("Inf", np.inf, math.inf if six.PY3 else float("inf"))
|
||||
replace("-Inf", -np.inf, np.NINF, -math.inf if six.PY3 else -float("inf"))
|
||||
self._init_reporter()
|
||||
return self._reporter.report_table(
|
||||
title=title,
|
||||
series=series,
|
||||
table=reporter_table,
|
||||
iteration=iteration or 0,
|
||||
layout_config=extra_layout
|
||||
)
|
||||
|
||||
def report_line_plot(
|
||||
self,
|
||||
title, # type: str
|
||||
series, # type: Sequence[SeriesInfo]
|
||||
xaxis, # type: str
|
||||
yaxis, # type: str
|
||||
mode="lines", # type: str
|
||||
iteration=None, # type: Optional[int]
|
||||
reverse_xaxis=False, # type: bool
|
||||
comment=None, # type: Optional[str]
|
||||
extra_layout=None # type: Optional[dict]
|
||||
):
|
||||
"""
|
||||
For explicit reporting, plot one or more series as lines.
|
||||
|
||||
:param str title: The title (metric) of the plot.
|
||||
:param list series: All the series data, one list element for each line in the plot.
|
||||
:param int iteration: The reported iteration / step.
|
||||
:param str xaxis: The x-axis title. (Optional)
|
||||
:param str yaxis: The y-axis title. (Optional)
|
||||
:param str mode: The type of line plot.
|
||||
|
||||
The values are:
|
||||
|
||||
- ``lines`` (default)
|
||||
- ``markers``
|
||||
- ``lines+markers``
|
||||
|
||||
:param bool reverse_xaxis: Reverse the x-axis
|
||||
|
||||
The values are:
|
||||
|
||||
- ``True`` - The x-axis is high to low (reversed).
|
||||
- ``False`` - The x-axis is low to high (not reversed). (default)
|
||||
|
||||
:param str comment: A comment displayed with the plot, underneath the title.
|
||||
:param dict extra_layout: optional dictionary for layout configuration, passed directly to plotly
|
||||
See full details on the supported configuration: https://plotly.com/javascript/reference/scatter/
|
||||
example: extra_layout={'xaxis': {'type': 'date', 'range': ['2020-01-01', '2020-01-31']}}
|
||||
"""
|
||||
self._init_reporter()
|
||||
|
||||
# noinspection PyArgumentList
|
||||
series = [SeriesInfo(**s) if isinstance(s, dict) else s for s in series]
|
||||
|
||||
return self._reporter.report_line_plot(
|
||||
title=title,
|
||||
series=series,
|
||||
iter=iteration or 0,
|
||||
xtitle=xaxis,
|
||||
ytitle=yaxis,
|
||||
mode=mode,
|
||||
reverse_xaxis=reverse_xaxis,
|
||||
comment=comment,
|
||||
layout_config=extra_layout
|
||||
)
|
||||
|
||||
def report_scatter2d(
|
||||
self,
|
||||
title, # type: str
|
||||
series, # type: str
|
||||
scatter, # type: Union[Sequence[Tuple[float, float]], np.ndarray]
|
||||
iteration=None, # type: Optional[int]
|
||||
xaxis=None, # type: Optional[str]
|
||||
yaxis=None, # type: Optional[str]
|
||||
labels=None, # type: Optional[List[str]]
|
||||
mode="line", # type: str
|
||||
comment=None, # type: Optional[str]
|
||||
extra_layout=None, # type: Optional[dict]
|
||||
):
|
||||
"""
|
||||
For explicit reporting, report a 2d scatter plot.
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
scatter2d = np.hstack((np.atleast_2d(np.arange(0, 10)).T, np.random.randint(10, size=(10, 1))))
|
||||
model.report_scatter2d(title="example_scatter", series="series", iteration=0, scatter=scatter2d,
|
||||
xaxis="title x", yaxis="title y")
|
||||
|
||||
Plot multiple 2D scatter series on the same plot by passing the same ``title`` and ``iteration`` values
|
||||
to this method:
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
scatter2d_1 = np.hstack((np.atleast_2d(np.arange(0, 10)).T, np.random.randint(10, size=(10, 1))))
|
||||
model.report_scatter2d(title="example_scatter", series="series_1", iteration=1, scatter=scatter2d_1,
|
||||
xaxis="title x", yaxis="title y")
|
||||
|
||||
scatter2d_2 = np.hstack((np.atleast_2d(np.arange(0, 10)).T, np.random.randint(10, size=(10, 1))))
|
||||
model.report_scatter2d("example_scatter", "series_2", iteration=1, scatter=scatter2d_2,
|
||||
xaxis="title x", yaxis="title y")
|
||||
|
||||
:param str title: The title (metric) of the plot.
|
||||
:param str series: The series name (variant) of the reported scatter plot.
|
||||
:param list scatter: The scatter data. numpy.ndarray or list of (pairs of x,y) scatter:
|
||||
:param int iteration: The reported iteration / step.
|
||||
:param str xaxis: The x-axis title. (Optional)
|
||||
:param str yaxis: The y-axis title. (Optional)
|
||||
:param list(str) labels: Labels per point in the data assigned to the ``scatter`` parameter. The labels must be
|
||||
in the same order as the data.
|
||||
:param str mode: The type of scatter plot. The values are:
|
||||
|
||||
- ``lines``
|
||||
- ``markers``
|
||||
- ``lines+markers``
|
||||
|
||||
:param str comment: A comment displayed with the plot, underneath the title.
|
||||
:param dict extra_layout: optional dictionary for layout configuration, passed directly to plotly
|
||||
See full details on the supported configuration: https://plotly.com/javascript/reference/scatter/
|
||||
example: extra_layout={'xaxis': {'type': 'date', 'range': ['2020-01-01', '2020-01-31']}}
|
||||
"""
|
||||
self._init_reporter()
|
||||
|
||||
if not isinstance(scatter, np.ndarray):
|
||||
if not isinstance(scatter, list):
|
||||
scatter = list(scatter)
|
||||
scatter = np.array(scatter)
|
||||
|
||||
return self._reporter.report_2d_scatter(
|
||||
title=title,
|
||||
series=series,
|
||||
data=scatter,
|
||||
iter=iteration or 0,
|
||||
mode=mode,
|
||||
xtitle=xaxis,
|
||||
ytitle=yaxis,
|
||||
labels=labels,
|
||||
comment=comment,
|
||||
layout_config=extra_layout,
|
||||
)
|
||||
|
||||
def report_scatter3d(
|
||||
self,
|
||||
title, # type: str
|
||||
series, # type: str
|
||||
scatter, # type: Union[Sequence[Tuple[float, float, float]], np.ndarray]
|
||||
iteration=None, # type: Optional[int]
|
||||
xaxis=None, # type: Optional[str]
|
||||
yaxis=None, # type: Optional[str]
|
||||
zaxis=None, # type: Optional[str]
|
||||
labels=None, # type: Optional[List[str]]
|
||||
mode="markers", # type: str
|
||||
fill=False, # type: bool
|
||||
comment=None, # type: Optional[str]
|
||||
extra_layout=None # type: Optional[dict]
|
||||
):
|
||||
"""
|
||||
For explicit reporting, plot a 3d scatter graph (with markers).
|
||||
|
||||
:param str title: The title (metric) of the plot.
|
||||
:param str series: The series name (variant) of the reported scatter plot.
|
||||
:param Union[numpy.ndarray, list] scatter: The scatter data.
|
||||
list of (pairs of x,y,z), list of series [[(x1,y1,z1)...]], or numpy.ndarray
|
||||
:param int iteration: The reported iteration / step.
|
||||
:param str xaxis: The x-axis title. (Optional)
|
||||
:param str yaxis: The y-axis title. (Optional)
|
||||
:param str zaxis: The z-axis title. (Optional)
|
||||
:param list(str) labels: Labels per point in the data assigned to the ``scatter`` parameter. The labels must be
|
||||
in the same order as the data.
|
||||
:param str mode: The type of scatter plot. The values are:
|
||||
|
||||
- ``lines``
|
||||
- ``markers``
|
||||
- ``lines+markers``
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
scatter3d = np.random.randint(10, size=(10, 3))
|
||||
model.report_scatter3d(title="example_scatter_3d", series="series_xyz", iteration=1, scatter=scatter3d,
|
||||
xaxis="title x", yaxis="title y", zaxis="title z")
|
||||
|
||||
:param bool fill: Fill the area under the curve. The values are:
|
||||
|
||||
- ``True`` - Fill
|
||||
- ``False`` - Do not fill (default)
|
||||
|
||||
:param str comment: A comment displayed with the plot, underneath the title.
|
||||
:param dict extra_layout: optional dictionary for layout configuration, passed directly to plotly
|
||||
See full details on the supported configuration: https://plotly.com/javascript/reference/scatter3d/
|
||||
example: extra_layout={'xaxis': {'type': 'date', 'range': ['2020-01-01', '2020-01-31']}}
|
||||
"""
|
||||
self._init_reporter()
|
||||
|
||||
# check if multiple series
|
||||
multi_series = (
|
||||
isinstance(scatter, list)
|
||||
and (
|
||||
isinstance(scatter[0], np.ndarray)
|
||||
or (
|
||||
scatter[0]
|
||||
and isinstance(scatter[0], list)
|
||||
and isinstance(scatter[0][0], list)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if not multi_series:
|
||||
if not isinstance(scatter, np.ndarray):
|
||||
if not isinstance(scatter, list):
|
||||
scatter = list(scatter)
|
||||
scatter = np.array(scatter)
|
||||
try:
|
||||
scatter = scatter.astype(np.float32)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return self._reporter.report_3d_scatter(
|
||||
title=title,
|
||||
series=series,
|
||||
data=scatter,
|
||||
iter=iteration or 0,
|
||||
labels=labels,
|
||||
mode=mode,
|
||||
fill=fill,
|
||||
comment=comment,
|
||||
xtitle=xaxis,
|
||||
ytitle=yaxis,
|
||||
ztitle=zaxis,
|
||||
layout_config=extra_layout
|
||||
)
|
||||
|
||||
def report_confusion_matrix(
|
||||
self,
|
||||
title, # type: str
|
||||
series, # type: str
|
||||
matrix, # type: np.ndarray
|
||||
iteration=None, # type: Optional[int]
|
||||
xaxis=None, # type: Optional[str]
|
||||
yaxis=None, # type: Optional[str]
|
||||
xlabels=None, # type: Optional[List[str]]
|
||||
ylabels=None, # type: Optional[List[str]]
|
||||
yaxis_reversed=False, # type: bool
|
||||
comment=None, # type: Optional[str]
|
||||
extra_layout=None # type: Optional[dict]
|
||||
):
|
||||
"""
|
||||
For explicit reporting, plot a heat-map matrix.
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
confusion = np.random.randint(10, size=(10, 10))
|
||||
model.report_confusion_matrix("example confusion matrix", "ignored", iteration=1, matrix=confusion,
|
||||
xaxis="title X", yaxis="title Y")
|
||||
|
||||
:param str title: The title (metric) of the plot.
|
||||
:param str series: The series name (variant) of the reported confusion matrix.
|
||||
:param numpy.ndarray matrix: A heat-map matrix (example: confusion matrix)
|
||||
:param int iteration: The reported iteration / step.
|
||||
:param str xaxis: The x-axis title. (Optional)
|
||||
:param str yaxis: The y-axis title. (Optional)
|
||||
:param list(str) xlabels: Labels for each column of the matrix. (Optional)
|
||||
:param list(str) ylabels: Labels for each row of the matrix. (Optional)
|
||||
:param bool yaxis_reversed: If False 0,0 is at the bottom left corner. If True, 0,0 is at the top left corner
|
||||
:param str comment: A comment displayed with the plot, underneath the title.
|
||||
:param dict extra_layout: optional dictionary for layout configuration, passed directly to plotly
|
||||
See full details on the supported configuration: https://plotly.com/javascript/reference/heatmap/
|
||||
example: extra_layout={'xaxis': {'type': 'date', 'range': ['2020-01-01', '2020-01-31']}}
|
||||
"""
|
||||
self._init_reporter()
|
||||
|
||||
if not isinstance(matrix, np.ndarray):
|
||||
matrix = np.array(matrix)
|
||||
|
||||
return self._reporter.report_value_matrix(
|
||||
title=title,
|
||||
series=series,
|
||||
data=matrix.astype(np.float32),
|
||||
iter=iteration or 0,
|
||||
xtitle=xaxis,
|
||||
ytitle=yaxis,
|
||||
xlabels=xlabels,
|
||||
ylabels=ylabels,
|
||||
yaxis_reversed=yaxis_reversed,
|
||||
comment=comment,
|
||||
layout_config=extra_layout
|
||||
)
|
||||
|
||||
def report_matrix(
|
||||
self,
|
||||
title, # type: str
|
||||
series, # type: str
|
||||
matrix, # type: np.ndarray
|
||||
iteration=None, # type: Optional[int]
|
||||
xaxis=None, # type: Optional[str]
|
||||
yaxis=None, # type: Optional[str]
|
||||
xlabels=None, # type: Optional[List[str]]
|
||||
ylabels=None, # type: Optional[List[str]]
|
||||
yaxis_reversed=False, # type: bool
|
||||
extra_layout=None # type: Optional[dict]
|
||||
):
|
||||
"""
|
||||
For explicit reporting, plot a confusion matrix.
|
||||
|
||||
.. note::
|
||||
This method is the same as :meth:`Model.report_confusion_matrix`.
|
||||
|
||||
:param str title: The title (metric) of the plot.
|
||||
:param str series: The series name (variant) of the reported confusion matrix.
|
||||
:param numpy.ndarray matrix: A heat-map matrix (example: confusion matrix)
|
||||
:param int iteration: The reported iteration / step.
|
||||
:param str xaxis: The x-axis title. (Optional)
|
||||
:param str yaxis: The y-axis title. (Optional)
|
||||
:param list(str) xlabels: Labels for each column of the matrix. (Optional)
|
||||
:param list(str) ylabels: Labels for each row of the matrix. (Optional)
|
||||
:param bool yaxis_reversed: If False, 0,0 is at the bottom left corner. If True, 0,0 is at the top left corner
|
||||
:param dict extra_layout: optional dictionary for layout configuration, passed directly to plotly
|
||||
See full details on the supported configuration: https://plotly.com/javascript/reference/heatmap/
|
||||
example: extra_layout={'xaxis': {'type': 'date', 'range': ['2020-01-01', '2020-01-31']}}
|
||||
"""
|
||||
self._init_reporter()
|
||||
return self.report_confusion_matrix(
|
||||
title,
|
||||
series,
|
||||
matrix,
|
||||
iteration or 0,
|
||||
xaxis=xaxis,
|
||||
yaxis=yaxis,
|
||||
xlabels=xlabels,
|
||||
ylabels=ylabels,
|
||||
yaxis_reversed=yaxis_reversed,
|
||||
extra_layout=extra_layout
|
||||
)
|
||||
|
||||
def report_surface(
|
||||
self,
|
||||
title, # type: str
|
||||
series, # type: str
|
||||
matrix, # type: np.ndarray
|
||||
iteration=None, # type: Optional[int]
|
||||
xaxis=None, # type: Optional[str]
|
||||
yaxis=None, # type: Optional[str]
|
||||
zaxis=None, # type: Optional[str]
|
||||
xlabels=None, # type: Optional[List[str]]
|
||||
ylabels=None, # type: Optional[List[str]]
|
||||
camera=None, # type: Optional[Sequence[float]]
|
||||
comment=None, # type: Optional[str]
|
||||
extra_layout=None # type: Optional[dict]
|
||||
):
|
||||
"""
|
||||
For explicit reporting, report a 3d surface plot.
|
||||
|
||||
.. note::
|
||||
This method plots the same data as :meth:`Model.report_confusion_matrix`, but presents the
|
||||
data as a surface diagram not a confusion matrix.
|
||||
|
||||
.. code-block:: py
|
||||
|
||||
surface_matrix = np.random.randint(10, size=(10, 10))
|
||||
model.report_surface("example surface", "series", iteration=0, matrix=surface_matrix,
|
||||
xaxis="title X", yaxis="title Y", zaxis="title Z")
|
||||
|
||||
:param str title: The title (metric) of the plot.
|
||||
:param str series: The series name (variant) of the reported surface.
|
||||
:param numpy.ndarray matrix: A heat-map matrix (example: confusion matrix)
|
||||
:param int iteration: The reported iteration / step.
|
||||
:param str xaxis: The x-axis title. (Optional)
|
||||
:param str yaxis: The y-axis title. (Optional)
|
||||
:param str zaxis: The z-axis title. (Optional)
|
||||
:param list(str) xlabels: Labels for each column of the matrix. (Optional)
|
||||
:param list(str) ylabels: Labels for each row of the matrix. (Optional)
|
||||
:param list(float) camera: X,Y,Z coordinates indicating the camera position. The default value is ``(1,1,1)``.
|
||||
:param str comment: A comment displayed with the plot, underneath the title.
|
||||
:param dict extra_layout: optional dictionary for layout configuration, passed directly to plotly
|
||||
See full details on the supported configuration: https://plotly.com/javascript/reference/surface/
|
||||
example: extra_layout={'xaxis': {'type': 'date', 'range': ['2020-01-01', '2020-01-31']}}
|
||||
"""
|
||||
self._init_reporter()
|
||||
|
||||
if not isinstance(matrix, np.ndarray):
|
||||
matrix = np.array(matrix)
|
||||
|
||||
return self._reporter.report_value_surface(
|
||||
title=title,
|
||||
series=series,
|
||||
data=matrix.astype(np.float32),
|
||||
iter=iteration or 0,
|
||||
xlabels=xlabels,
|
||||
ylabels=ylabels,
|
||||
xtitle=xaxis,
|
||||
ytitle=yaxis,
|
||||
ztitle=zaxis,
|
||||
camera=camera,
|
||||
comment=comment,
|
||||
layout_config=extra_layout
|
||||
)
|
||||
|
||||
def publish(self):
|
||||
# type: () -> ()
|
||||
"""
|
||||
@ -410,6 +1031,19 @@ class BaseModel(object):
|
||||
if not self.published:
|
||||
self._get_base_model().publish()
|
||||
|
||||
def _init_reporter(self):
|
||||
if self._reporter:
|
||||
return
|
||||
|
||||
metrics_manager = Metrics(
|
||||
session=_Model._get_default_session(),
|
||||
storage_uri=None,
|
||||
task=self, # this is fine, the ID of the model will be fetched here
|
||||
for_model=True
|
||||
)
|
||||
|
||||
self._reporter = Reporter(metrics=metrics_manager, task=self, for_model=True)
|
||||
|
||||
def _running_remotely(self):
|
||||
# type: () -> ()
|
||||
return bool(running_remotely() and self._task is not None)
|
||||
|
@ -491,7 +491,7 @@ class BackgroundMonitor(object):
|
||||
_at_exit = False
|
||||
_instances = {} # type: Dict[int, List[BackgroundMonitor]]
|
||||
|
||||
def __init__(self, task, wait_period):
|
||||
def __init__(self, task, wait_period, for_model=False):
|
||||
self._event = ForkEvent()
|
||||
self._done_ev = ForkEvent()
|
||||
self._start_ev = ForkEvent()
|
||||
@ -499,7 +499,7 @@ class BackgroundMonitor(object):
|
||||
self._thread = None
|
||||
self._thread_pid = None
|
||||
self._wait_timeout = wait_period
|
||||
self._subprocess = None if task.is_main_task() else False
|
||||
self._subprocess = None if not for_model and task.is_main_task() else False
|
||||
self._task_id = task.id
|
||||
self._task_obj_id = id(task.id)
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user