Skip to content

Commit

Permalink
Merge pull request #173 from DLHub-Argonne/dev
Browse files Browse the repository at this point in the history
Dev -- easy_publish() and tests for input validation methods
  • Loading branch information
ascourtas committed Oct 21, 2022
2 parents 9f546d6 + 1bd1ef6 commit c02b307
Show file tree
Hide file tree
Showing 10 changed files with 384 additions and 28 deletions.
77 changes: 76 additions & 1 deletion dlhub_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import json
import os
from tempfile import mkstemp
from typing import Union, Any, Optional, Tuple, Dict
from typing import Sequence, Union, Any, Optional, Tuple, Dict, List
import requests
import globus_sdk

Expand All @@ -14,6 +14,13 @@
from funcx.sdk.client import FuncXClient
from globus_sdk.scopes import AuthScopes, SearchScopes

from dlhub_sdk.models.servables.keras import KerasModel
from dlhub_sdk.models.servables.pytorch import TorchModel
from dlhub_sdk.models.servables.python import PythonClassMethodModel
from dlhub_sdk.models.servables.python import PythonStaticMethodModel
from dlhub_sdk.models.servables.tensorflow import TensorFlowModel
from dlhub_sdk.models.servables.sklearn import ScikitLearnModel

from dlhub_sdk.config import DLHUB_SERVICE_ADDRESS, CLIENT_ID
from dlhub_sdk.utils.futures import DLHubFuture
from dlhub_sdk.utils.schemas import validate_against_dlhub_schema
Expand All @@ -26,6 +33,10 @@
logger = logging.getLogger(__name__)


class HelpMessage(Exception):
"""Raised from another error to provide the user an additional message"""


class DLHubClient(BaseClient):
"""Main class for interacting with the DLHub service
Expand Down Expand Up @@ -343,6 +354,70 @@ def get_result(self, task_id, verbose=False):
result = result[0]
return result

def easy_publish(self, title: str, creators: Union[str, List[str]], short_name: str, servable_type: str, serv_options: Dict[str, Any],
affiliations: List[Sequence[str]] = None, paper_doi: str = None):
"""Simplified publishing method for servables
Args:
title (string): title for the servable
creators (string | list): either the creator's name (FamilyName, GivenName) or a list of the creators' names
short_name (string): shorthand name for the servable
servable_type (string): the type of the servable, must be a member of ("static_method",
"class_method",
"keras",
"pytorch",
"tensorflow",
"sklearn") more information on servable types can be found here:
https://dlhub-sdk.readthedocs.io/en/latest/servable-types.html
serv_options (dict): the servable_type specific arguments that are necessary for publishing
affiliations (list): list of affiliations for each author
paper_doi (str): DOI of a paper that describes the servable
Returns:
(string): task id of this submission, can be used to check for success
Raises:
ValueError: If the given servable_type is not in the list of acceptable types
Exception: If the serv_options are incomplete or the request to publish results in an error
"""
# conversion table for model string names to classes
models = {"static_method": PythonStaticMethodModel,
"class_method": PythonClassMethodModel,
"keras": KerasModel,
"pytorch": TorchModel,
"tensorflow": TensorFlowModel,
"sklearn": ScikitLearnModel}

# raise an error if the provided servable_type is invalid
model = models.get(servable_type)
if model is None:
raise ValueError(f"dl.easy_publish given invalid servable type: {servable_type}, please refer to the docstring")

# attempt to construct the model and raise a helpful error if needed
try:
# if the servable is a python function, set the parameter to attempt to auto-generate the inputs/outputs
if servable_type in {"static_method", "class_method"}:
serv_options["auto_inspect"] = True

model_info = model.create_model(**serv_options)
except Exception as e:
help_err = HelpMessage(f"Help can be found here:\nhttps://dlhub-sdk.readthedocs.io/en/latest/source/dlhub_sdk.models.servables.html#"
f"{model.__module__}.{model.__name__}.create_model")
raise help_err from e

# set the required datacite fields
model_info.set_title(title)
creators = [creators] if isinstance(creators, str) else creators # handle the case where creators is a string
model_info.set_creators(creators, affiliations or []) # affiliations if provided, else empty list
model_info.set_name(short_name)

if paper_doi is not None:
model_info.add_related_resource(paper_doi, "DOI", "IsDescribedBy")

# perform the publish
task_id = self.publish_servable(model_info)

# return the id of the publish task
return task_id

def publish_servable(self, model):
"""Submit a servable to DLHub
Expand Down
5 changes: 3 additions & 2 deletions dlhub_sdk/models/datacite.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,9 @@ class DataciteRelationType(Enum):
class DataciteRelatedIdentifier(BaseModel):
"""Identifier of a related resource. Use this property to indicate subsets of properties, as appropriate."""
relatedIdentifier: str
relatedIdentifierType: DataciteRelatedIdentifierType
relatedMetadataScheme: str
relatedIdentifierType: Union[str, DataciteRelatedIdentifierType]
relationType: Union[str, DataciteRelationType]
relatedMetadataScheme: Optional[str]
schemeURI: Optional[AnyUrl] = None


Expand Down
54 changes: 48 additions & 6 deletions dlhub_sdk/models/servables/python.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Tools to annotate generic operations (e.g., class method calls) in Python"""
import pickle as pkl
import importlib
from inspect import Signature

from dlhub_sdk.models.servables import BaseServableModel, ArgumentTypeMetadata
from dlhub_sdk.utils.types import compose_argument_block
from dlhub_sdk.utils.inspect import signature_to_input, signature_to_output


class BasePythonServableModel(BaseServableModel):
Expand Down Expand Up @@ -113,14 +116,15 @@ class PythonClassMethodModel(BasePythonServableModel):
any arguments of the class that should be set as defaults."""

@classmethod
def create_model(cls, path, method, function_kwargs=None) -> 'PythonClassMethodModel':
def create_model(cls, path, method, function_kwargs=None, *, auto_inspect=False) -> 'PythonClassMethodModel':
"""Initialize a model for a python object
Args:
path (string): Path to a pickled Python file
method (string): Name of the method for this class
function_kwargs (dict): Names and values of any other argument of the function to set
the values must be JSON serializable.
auto_inspect (boolean): Whether or not to attempt to automatically extract inputs from the function
"""
output = super(PythonClassMethodModel, cls).create_model(method, function_kwargs)

Expand All @@ -135,6 +139,11 @@ def create_model(cls, path, method, function_kwargs=None) -> 'PythonClassMethodM
'class_name': class_name
})

if auto_inspect:
func = getattr(obj, method)

output = add_extracted_metadata(func, output)

return output

def _get_handler(self):
Expand All @@ -153,8 +162,8 @@ class PythonStaticMethodModel(BasePythonServableModel):
"""

@classmethod
def create_model(cls, module, method, autobatch=False, function_kwargs=None):
"""Initialize the method
def create_model(cls, module=None, method=None, autobatch=False, function_kwargs=None, *, f=None, auto_inspect=False):
"""Initialize the method based on the provided arguments
Args:
module (string): Name of the module holding the function
Expand All @@ -163,28 +172,61 @@ def create_model(cls, module, method, autobatch=False, function_kwargs=None):
Calls :code:`map(f, list)`
function_kwargs (dict): Names and values of any other argument of the function to set
the values must be JSON serializable.
f (object): function pointer to the desired python function
auto_inspect (boolean): Whether or not to attempt to automatically extract inputs from the function
Raises:
TypeError: If there is no valid way to process the given arguments
"""
# if a pointer is provided, get the module and method
if f is not None:
module, method = f.__module__, f.__name__
func = f
# if it is not, ensure both the module and method are provided and get the function pointer
elif module is not None and method is not None:
module_obj = importlib.import_module(module)
func = getattr(module_obj, method)
else:
raise TypeError("PythonStaticMethodModel.create_model was not provided valid arguments. Please provide either a funtion pointer"
" or the module and name of the desired static function")

output = super(PythonStaticMethodModel, cls).create_model(method, function_kwargs)

output.servable.methods["run"].method_details.update({
'module': module,
'autobatch': autobatch
})

if auto_inspect:
output = add_extracted_metadata(func, output)

return output

@classmethod
def from_function_pointer(cls, f, autobatch=False, function_kwargs=None):
"""Construct the module given a function pointer
Args:
f (object): A function pointer
f (object): Function pointer to the Python function to be published
autobatch (bool): Whether to run function on an iterable of entries
function_kwargs (dict): Any default options for this function
"""
return cls.create_model(f.__module__, f.__name__, autobatch, function_kwargs)
return cls.create_model(f=f, autobatch=autobatch, function_kwargs=function_kwargs)

def _get_handler(self):
return 'python.PythonStaticMethodServable'

def _get_type(self):
return 'Python static method'


def add_extracted_metadata(func, model: BasePythonServableModel) -> BasePythonServableModel:
"""Helper function for adding generated input/output metadata to a model object
Args:
func: a pointer to the function whose data is to be extracted
model (BasePythonServableModel): the model who needs its data to be updated
Returns:
(BasePythonServableModel): the model that was given after it is updated
"""
sig = Signature.from_callable(func)
model = model.set_inputs(**signature_to_input(sig))
model = model.set_outputs(**signature_to_output(sig))
return model
48 changes: 43 additions & 5 deletions dlhub_sdk/tests/test_dlhub_client.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import os
import pickle as pkl
from typing import Dict

import mdf_toolbox
from pytest import fixture, raises, mark
from pytest_mock import mocker # noqa: F401 (flake8 cannot detect usage)

from dlhub_sdk.models.servables.python import PythonStaticMethodModel
from dlhub_sdk.models.servables.python import PythonClassMethodModel, PythonStaticMethodModel
from dlhub_sdk.utils.futures import DLHubFuture
from dlhub_sdk.client import DLHubClient

Expand All @@ -13,6 +16,16 @@
client_secret = os.getenv('CLIENT_SECRET')
fx_scope = "https://auth.globus.org/scopes/facd7ccc-c5f4-42aa-916b-a0e270e2c2a9/all"
is_gha = os.getenv('GITHUB_ACTIONS')
_pickle_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "pickle.pkl"))


# make dummy reply for mocker patch to return
class DummyReply:
def __init__(self) -> None:
self.status_code = 200

def json(self) -> Dict[str, str]:
return {"task_id": "bf06d72e-0478-11ed-97f9-4b1381555b22"} # valid task id, status is known to be FAILED


@fixture()
Expand Down Expand Up @@ -78,9 +91,7 @@ def test_run(dl):
assert res.result(timeout=60) == 'Hello world!'


@mark.skipif(not is_gha, reason='Avoid running this test except on larger-scale tests of the system')
@mark.skip
def test_submit(dl):
def test_submit(dl, mocker): # noqa: F811 (flake8 does not understand usage)
# Make an example function
model = PythonStaticMethodModel.create_model('numpy.linalg', 'norm')
model.dlhub.test = True
Expand All @@ -89,8 +100,35 @@ def test_submit(dl):
model.set_inputs('ndarray', 'Array to be normed', shape=[None])
model.set_outputs('number', 'Norm of the array')

# patch requests.post
mocker.patch("requests.post", return_value=DummyReply())

# Submit the model
dl.publish_servable(model)
task_id = dl.publish_servable(model)
assert task_id == "bf06d72e-0478-11ed-97f9-4b1381555b22"

# pickle class method to test
with open(_pickle_path, 'wb') as fp:
pkl.dump(DummyReply(), fp)

# test auto_inspect for class methods
model = PythonClassMethodModel.create_model(_pickle_path, "json", auto_inspect=True)
model.dlhub.test = True
model.set_name("dummy_json")
model.set_title("Dummy JSON")

# Submit the model
task_id = dl.publish_servable(model)
assert task_id == "bf06d72e-0478-11ed-97f9-4b1381555b22"

# make sure invalid call raises proper error
with raises(TypeError):
model = PythonStaticMethodModel.create_model("dlhub_sdk.utils.validation")

# Submit the model using easy publish
task_id = dl.easy_publish("Validate dl.run Calls", "Darling, Isaac", "validate_run", "static_method",
{"module": "dlhub_sdk.utils.validation", "method": "validate"}, [["University of Chicago"]], "not-a-real-doi")
assert task_id == "bf06d72e-0478-11ed-97f9-4b1381555b22"


def test_describe_model(dl):
Expand Down

0 comments on commit c02b307

Please sign in to comment.