diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 32e16186815b..bcd796aea969 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -71,6 +71,9 @@ # PRLabel: %HDInsight /sdk/hdinsight/ @idear1203 +# PRLabel: %Models repository +/sdk/modelsrepository/ @cartertinney @digimaun + # PRLabel: %Machine Learning Compute /sdk/machinelearningcompute/ @shutchings diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/.flake8 b/sdk/modelsrepository/azure-iot-modelsrepository/.flake8 new file mode 100644 index 000000000000..dbf3f4fb8f1e --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/.flake8 @@ -0,0 +1,7 @@ +[flake8] +# E501: line length (black formatting will handle) +# W503, E203: Not PEP8 compliant (incompatible with black formatting) +ignore = E501,W503,E203 +exclude = + .git, + __pycache__, diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/CHANGELOG.md b/sdk/modelsrepository/azure-iot-modelsrepository/CHANGELOG.md new file mode 100644 index 000000000000..adbb33a3ee52 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/CHANGELOG.md @@ -0,0 +1,5 @@ +# Release History + +## 1.0.0b1 (Unreleased) + +* Initial (Preview) Release diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/MANIFEST.in b/sdk/modelsrepository/azure-iot-modelsrepository/MANIFEST.in new file mode 100644 index 000000000000..1071130923d5 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/MANIFEST.in @@ -0,0 +1,5 @@ +include *.md +include azure/__init__.py +include azure/iot/__init__.py +recursive-include samples *.py +recursive-include tests *.py \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/README.md b/sdk/modelsrepository/azure-iot-modelsrepository/README.md new file mode 100644 index 000000000000..a2c08c24f127 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/README.md @@ -0,0 +1,64 @@ +# Azure IoT Models Repository client library for Python + +The Azure IoT Models Repository Library for Python provides functionality for working with the Azure IoT Models Repository + +## Getting started + +### Install package + +Install the Azure IoT Models Repository library for Python with [pip][pip]: + +```Shell +pip install azure-iot-modelsrepository +``` + +### Prerequisites +* A models repository following [repo_conventions][Azure IoT conventions] + * The models repository can be hosted on the local filesystem or hosted on a webserver + * Azure IoT hosts the global [global_azure_repo][Azure IoT Models Repository] which the client will use if no custom location is provided + +### Authentication +Currently, no authentication mechanisms are supported. The global endpoint is not tied to an Azure subscription and does not support authentication. All models published are meant for anonymous public consumption. + +## Key concepts + +The Azure IoT Models Repository enables builders to manage and share digital twin models. The models are [json_ld][JSON-LD] documents defined using the Digital Twins Definition Language ([dtdl_spec][DTDL]). + +The repository defines a pattern to store DTDL interfaces in a directory structure based on the Digital Twin Model Identifier (DTMI). You can locate an interface in the repository by converting the DTMI to a relative path. For example, the DTMI `dtmi:com:example:Thermostat;1` translates to `/dtmi/com/example/thermostat-1.json`. + +## Examples + +## Troubleshooting + +### General +Models Repository clients raise exceptions defined in [azure_core_exceptions][azure-core]. + +### Logging +This library uses the standard [logging_doc][logging] library for logging. Information about HTTP sessions (URLs, headers, etc.) is logged at `DEBUG` level. + +## Next steps + +Several samples are available in the Azure SDK for Python GitHub repository. These provide example code for Models Repository Client scenarios: + +* [client_configuration_sample][client_configuration_sample] - Configure a ModelsRepositoryClient for a local or remote repository +* [get_models_sample][get_models_sample] - Retrieve models from a repository +* [dtmi_conventions_sample][dtmi_conventions_sample] - Use utility functions to generate and validate DTMIs + + +[azure_core_exceptions]: https://github.com/Azure/azure-sdk-for-python/tree/master/sdk/core/azure-core#azure-core-library-exceptions +[client_configuration_sample]: https://github.com/Azure/azure-sdk-for-python/tree/master/sdk/modelsrepository/azure-iot-modelsrepository/samples/client_configuration_sample.py +[dtdl_spec]: https://github.com/Azure/opendigitaltwins-dtdl/blob/master/DTDL/v2/dtdlv2.md +[dtmi_conventions_sample]: https://github.com/Azure/azure-sdk-for-python/tree/master/sdk/modelsrepository/azure-iot-modelsrepository/samples/dtmi_conventions_sample.py +[get_models_sample]: https://github.com/Azure/azure-sdk-for-python/tree/master/sdk/modelsrepository/azure-iot-modelsrepository/samples/get_models_sample.py +[global_azure_repo]: https://devicemodels.azure.com/ +[json_ld]: https://json-ld.org/ +[logging_doc]: https://docs.python.org/3.5/library/logging.html +[pip]: https://pypi.org/project/pip/ +[repo_conventions]: https://github.com/Azure/iot-plugandplay-models-tools/wiki + +## Contributing +This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/azure/__init__.py b/sdk/modelsrepository/azure-iot-modelsrepository/azure/__init__.py new file mode 100644 index 000000000000..8db66d3d0f0f --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/azure/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/__init__.py b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/__init__.py new file mode 100644 index 000000000000..8db66d3d0f0f --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/__init__.py b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/__init__.py new file mode 100644 index 000000000000..01149e8f128e --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/__init__.py @@ -0,0 +1,30 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +# Main Client +from ._client import ModelsRepositoryClient + +# Constants +from ._client import ( + DEPENDENCY_MODE_DISABLED, + DEPENDENCY_MODE_ENABLED, + DEPENDENCY_MODE_TRY_FROM_EXPANDED, +) + +# Error handling +from .exceptions import ModelError + +__all__ = [ + "ModelsRepositoryClient", + "ModelError", + "DEPENDENCY_MODE_DISABLED", + "DEPENDENCY_MODE_ENABLED", + "DEPENDENCY_MODE_TRY_FROM_EXPANDED", +] + +from ._constants import VERSION + +__version__ = VERSION diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/_client.py b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/_client.py new file mode 100644 index 000000000000..19f46f1cc52a --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/_client.py @@ -0,0 +1,236 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import six.moves.urllib as urllib +import re +import logging +import os +from azure.core.pipeline import Pipeline +from azure.core.tracing.decorator import distributed_trace +from azure.core.pipeline.transport import RequestsTransport +from azure.core.exceptions import ResourceNotFoundError +from azure.core.pipeline.policies import ( + UserAgentPolicy, + HeadersPolicy, + RetryPolicy, + RedirectPolicy, + NetworkTraceLoggingPolicy, + ProxyPolicy, +) +from . import ( + _resolver, + _pseudo_parser, + _constants, +) + +_LOGGER = logging.getLogger(__name__) + + +# Public constants exposed to consumers +DEPENDENCY_MODE_TRY_FROM_EXPANDED = "tryFromExpanded" +DEPENDENCY_MODE_DISABLED = "disabled" +DEPENDENCY_MODE_ENABLED = "enabled" + + +# Convention-private constants +_DEFAULT_LOCATION = "https://devicemodels.azure.com" +_REMOTE_PROTOCOLS = ["http", "https"] +_TRACE_NAMESPACE = "modelsrepository" + + +class ModelsRepositoryClient(object): + """Client providing APIs for Models Repository operations""" + + def __init__(self, **kwargs): # pylint: disable=missing-client-constructor-parameter-credential + # type: (Any) -> None + """ + :keyword str repository_location: Location of the Models Repository you wish to access. + This location can be a remote HTTP/HTTPS URL, or a local filesystem path. + If omitted, will default to using "https://devicemodels.azure.com". + :keyword str dependency_resolution: Dependency resolution mode. + Possible values: + - "disabled": Do not resolve model dependencies + - "enabled": Resolve model dependencies from the repository + - "tryFromExpanded": Attempt to resolve model and dependencies from an expanded + model DTDL document in the repository. If this is not successful, will fall + back on manually resolving dependencies in the repository + If using the default repository location, the default dependency resolution mode will + be "tryFromExpanded". If using a custom repository location, the default dependency + resolution mode will be "enabled". + :keyword str api_version: The API version for the Models Repository Service you wish to + access. + + For additional request configuration options, please see [core options](https://aka.ms/azsdk/python/options). + + :raises: ValueError if an invalid argument is provided + """ + repository_location = kwargs.get("repository_location", _DEFAULT_LOCATION) + _LOGGER.debug("Client configured for respository location %s", repository_location) + + self.resolution_mode = kwargs.get( + "dependency_resolution", + DEPENDENCY_MODE_TRY_FROM_EXPANDED + if repository_location == _DEFAULT_LOCATION + else DEPENDENCY_MODE_ENABLED, + ) + if self.resolution_mode not in [ + DEPENDENCY_MODE_ENABLED, + DEPENDENCY_MODE_DISABLED, + DEPENDENCY_MODE_TRY_FROM_EXPANDED, + ]: + raise ValueError("Invalid dependency resolution mode: {}".format(self.resolution_mode)) + _LOGGER.debug("Client configured for dependency mode %s", self.resolution_mode) + + # NOTE: depending on how this class develops over time, may need to adjust relationship + # between some of these objects + self.fetcher = _create_fetcher(location=repository_location, **kwargs) + self.resolver = _resolver.DtmiResolver(self.fetcher) + self._pseudo_parser = _pseudo_parser.PseudoParser(self.resolver) + + # Store api version here (for now). Currently doesn't do anything + self._api_version = kwargs.get("api_version", _constants.DEFAULT_API_VERSION) + + def __enter__(self): + self.fetcher.__enter__() + return self + + def __exit__(self, *exc_details): + self.fetcher.__exit__(*exc_details) + + def close(self): + # type: () -> None + """Close the client, preventing future operations""" + self.__exit__() + + @distributed_trace + def get_models(self, dtmis, **kwargs): + # type: (Union[List[str], str], Any) -> Dict[str, Any] + """Retrieve a model from the Models Repository. + + :param dtmis: The DTMI(s) for the model(s) you wish to retrieve + :type dtmis: str or list[str] + :keyword str dependency_resolution: Dependency resolution mode override. This value takes + precedence over the value set on the client. + Possible values: + - "disabled": Do not resolve model dependencies + - "enabled": Resolve model dependencies from the repository + - "tryFromExpanded": Attempt to resolve model and dependencies from an expanded + model DTDL document in the repository. If this is not successful, will fall + back on manually resolving dependencies in the repository + + :raises: ValueError if given an invalid dependency resolution mode + :raises: ~azure.iot.modelsrepository.ModelError if there is an error parsing the retrieved model(s) + :raises: ~azure.core.exceptions.ResourceNotFoundError if the model(s) cannot be found in the repository + :raises: ~azure.core.exceptions.ServiceRequestError if there is an error sending a request for the model(s) + :raises: ~azure.core.exceptions.ServiceResponseError if the model(s) cannot be retrieved + :raises: ~azure.core.exceptions.HttpResponseError if a failure response is received + + :returns: Dictionary mapping DTMIs to models + :rtype: dict + """ + if isinstance(dtmis, str): + dtmis = [dtmis] + + dependency_resolution = kwargs.get("dependency_resolution", self.resolution_mode) + + if dependency_resolution == DEPENDENCY_MODE_DISABLED: + # Simply retrieve the model(s) + _LOGGER.debug("Getting models w/ dependency resolution mode: disabled") + _LOGGER.debug("Retrieving model(s): %s...", dtmis) + model_map = self.resolver.resolve(dtmis) + elif dependency_resolution == DEPENDENCY_MODE_ENABLED: + # Manually resolve dependencies using pseudo-parser + _LOGGER.debug("Getting models w/ dependency resolution mode: enabled") + _LOGGER.debug("Retrieving model(s): %s...", dtmis) + base_model_map = self.resolver.resolve(dtmis) + base_model_list = list(base_model_map.values()) + _LOGGER.debug("Retrieving model dependencies for %s...", dtmis) + model_map = self._pseudo_parser.expand(base_model_list) + elif dependency_resolution == DEPENDENCY_MODE_TRY_FROM_EXPANDED: + _LOGGER.debug("Getting models w/ dependency resolution mode: tryFromExpanded") + # Try to use an expanded DTDL to resolve dependencies + try: + _LOGGER.debug("Retrieving expanded model(s): %s...", dtmis) + model_map = self.resolver.resolve(dtmis, expanded_model=True) + except ResourceNotFoundError: + # Fallback to manual dependency resolution + _LOGGER.debug( + "Could not retrieve model(s) from expanded model DTDL - " + "fallback to manual dependency resolution mode" + ) + _LOGGER.debug("Retrieving model(s): %s...", dtmis) + base_model_map = self.resolver.resolve(dtmis) + base_model_list = list(base_model_map.values()) + _LOGGER.debug("Retrieving model dependencies for %s...", dtmis) + model_map = self._pseudo_parser.expand(base_model_list) + else: + raise ValueError("Invalid dependency resolution mode: {}".format(dependency_resolution)) + return model_map + + +def _create_fetcher(location, **kwargs): + """Return a Fetcher based upon the type of location""" + scheme = urllib.parse.urlparse(location).scheme + if scheme in _REMOTE_PROTOCOLS: + # HTTP/HTTPS URL + _LOGGER.debug("Repository Location identified as HTTP/HTTPS endpoint - using HttpFetcher") + pipeline = _create_pipeline(**kwargs) + fetcher = _resolver.HttpFetcher(location, pipeline) + elif scheme == "file": + # Filesystem URI + _LOGGER.debug("Repository Location identified as filesystem URI - using FilesystemFetcher") + location = location[len("file://") :] + location = _sanitize_filesystem_path(location) + fetcher = _resolver.FilesystemFetcher(location) + elif scheme == "" and location.startswith("/"): + # POSIX filesystem path + _LOGGER.debug( + "Repository Location identified as POSIX fileystem path - using FilesystemFetcher" + ) + location = _sanitize_filesystem_path(location) + fetcher = _resolver.FilesystemFetcher(location) + elif scheme == "" and re.search( + r"\.[a-zA-z]{2,63}$", + location[: location.find("/") if location.find("/") >= 0 else len(location)], + ): + # Web URL with protocol unspecified - default to HTTPS + _LOGGER.debug( + "Repository Location identified as remote endpoint without protocol specified - using HttpFetcher" + ) + location = "https://" + location + pipeline = _create_pipeline(**kwargs) + fetcher = _resolver.HttpFetcher(location, pipeline) + elif scheme != "" and len(scheme) == 1 and scheme.isalpha(): + # Filesystem path using drive letters (e.g. "C:", "D:", etc.) + _LOGGER.debug( + "Repository Location identified as drive letter fileystem path - using FilesystemFetcher" + ) + location = _sanitize_filesystem_path(location) + fetcher = _resolver.FilesystemFetcher(location) + else: + raise ValueError("Unable to identify location: {}".format(location)) + return fetcher + + +def _create_pipeline(**kwargs): + """Creates and returns a PipelineClient configured for the provided base_url and kwargs""" + transport = kwargs.get("transport", RequestsTransport(**kwargs)) + policies = [ + kwargs.get("user_agent_policy", UserAgentPolicy(_constants.USER_AGENT, **kwargs)), + kwargs.get("headers_policy", HeadersPolicy(**kwargs)), + kwargs.get("authentication_policy"), + kwargs.get("retry_policy", RetryPolicy(**kwargs)), + kwargs.get("redirect_policy", RedirectPolicy(**kwargs)), + kwargs.get("logging_policy", NetworkTraceLoggingPolicy(**kwargs)), + kwargs.get("proxy_policy", ProxyPolicy(**kwargs)), + ] + return Pipeline(policies=policies, transport=transport) + + +def _sanitize_filesystem_path(path): + """Sanitize the filesystem path to be formatted correctly for the current OS""" + path = os.path.normcase(path) + path = os.path.normpath(path) + return path diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/_constants.py b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/_constants.py new file mode 100644 index 000000000000..fef30b347020 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/_constants.py @@ -0,0 +1,12 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import platform + +VERSION = "1.0.0b1" +USER_AGENT = "azsdk-python-modelsrepository/{pkg_version} Python/{py_version} ({platform})".format( + pkg_version=VERSION, py_version=(platform.python_version()), platform=platform.platform() +) +DEFAULT_API_VERSION = "2021-02-11" diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/_pseudo_parser.py b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/_pseudo_parser.py new file mode 100644 index 000000000000..00e8996b86e7 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/_pseudo_parser.py @@ -0,0 +1,87 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +"""This module contains a partial parsing implementation that is strictly +scoped for parsing dependencies. Ideally, this would be made obsolete by +a full Python parser implementation, as it is supposed to be a single source +of truth for the DTDL model specifications. + +Note that this implementation is not representative of what an eventual full +parser implementation would necessarily look like from an API perspective +""" +import logging +import six + +_LOGGER = logging.getLogger(__name__) + + +class PseudoParser(object): + def __init__(self, resolver): + """ + :param resolver: The resolver for the parser to use to resolve model dependencies + :type resolver: :class:`azure.iot.modelsrepository._resolver.DtmiResolver` + """ + self.resolver = resolver + + def expand(self, models): + """Return a dictionary containing all the provided models, as well as their dependencies, + indexed by DTMI + + :param list[str] models: List of models + + :returns: Dictionary containing models and their dependencies, indexed by DTMI + :rtype: dict + """ + expanded_map = {} + for model in models: + expanded_map[model["@id"]] = model + self._expand(model, expanded_map) + return expanded_map + + def _expand(self, model, model_map): + _LOGGER.debug("Expanding model: %s", model["@id"]) + dependencies = _get_model_dependencies(model) + dependencies_to_resolve = [ + dependency for dependency in dependencies if dependency not in model_map + ] + + if dependencies_to_resolve: + _LOGGER.debug("Outstanding dependencies found: %s", dependencies_to_resolve) + resolved_dependency_map = self.resolver.resolve(dependencies_to_resolve) + model_map.update(resolved_dependency_map) + for dependency_model in resolved_dependency_map.values(): + self._expand(dependency_model, model_map) + else: + _LOGGER.debug("No outstanding dependencies found") + + +def _get_model_dependencies(model): + """Return a list of dependency DTMIs for a given model""" + dependencies = [] + + if "contents" in model: + components = [item["schema"] for item in model["contents"] if item["@type"] == "Component"] + dependencies += components + + if "extends" in model: + # Models defined in a DTDL can implement extensions of up to two interfaces. + # These interfaces can be in the form of a DTMI reference, or a nested model (which would + # have it's own dependencies) + if isinstance(model["extends"], six.text_type): + # If it's just a string, that's a single DTMI reference, so just add that to our list + dependencies.append(model["extends"]) + elif isinstance(model["extends"], list): + # If it's a list, could have DTMIs or nested models + for item in model["extends"]: + if isinstance(item, six.text_type): + # If there are strings in the list, that's a DTMI reference, so add it + dependencies.append(item) + elif isinstance(item, dict): + # This is a nested model. Now go get its dependencies and add them + dependencies += _get_model_dependencies(item) + + # Remove duplicate dependencies + dependencies = list(set(dependencies)) + return dependencies diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/_resolver.py b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/_resolver.py new file mode 100644 index 000000000000..a2e3d22c7a57 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/_resolver.py @@ -0,0 +1,186 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import logging +import json +import abc +import os +import io +import six.moves.urllib as urllib +import six +from azure.core.pipeline.transport import HttpRequest +from azure.core.exceptions import ( + map_error, + ResourceNotFoundError, + HttpResponseError, + raise_with_traceback, +) +from azure.iot.modelsrepository.exceptions import ModelError +from . import dtmi_conventions + +_LOGGER = logging.getLogger(__name__) + + +class DtmiResolver(object): + def __init__(self, fetcher): + """ + :param fetcher: A Fetcher configured to an endpoint to resolve DTMIs from + :type fetcher: :class:`azure.iot.modelsrepository._resolver.Fetcher` + """ + self.fetcher = fetcher + + def resolve(self, dtmis, expanded_model=False): + """Resolve a DTMI from the configured endpoint and return the resulting JSON model. + + :param list[str] dtmis: DTMIs to resolve + :param bool expanded_model: Indicates whether to resolve a regular or expanded model + + :raises: ValueError if the DTMI is invalid. + :raises: ModelError if there is an error with the contents of the JSON model. + + :returns: A dictionary mapping DTMIs to models + :rtype: dict + """ + model_map = {} + for dtmi in dtmis: + # pylint: disable=protected-access + dtdl_path = dtmi_conventions._convert_dtmi_to_path(dtmi) + if expanded_model: + dtdl_path = dtdl_path.replace(".json", ".expanded.json") + _LOGGER.debug("Model %s located in repository at %s", dtmi, dtdl_path) + + # Errors raised here bubble up + dtdl = self.fetcher.fetch(dtdl_path) + + if expanded_model: + # Verify that the DTMI of the "root" model (i.e. the model we requested the + # expanded DTDL for) within the expanded DTDL matches the DTMI of the request + if True not in (model["@id"] == dtmi for model in dtdl): + raise ModelError("DTMI mismatch on expanded DTDL - Request: {}".format(dtmi)) + # Add all the models in the expanded DTDL to the map + for model in dtdl: + model_map[model["@id"]] = model + else: + model = dtdl + # Verify that the DTMI of the fetched model matches the DTMI of the request + if model["@id"] != dtmi: + raise ModelError( + "DTMI mismatch - Request: {}, Response: {}".format(dtmi, model["@id"]) + ) + # Add the model to the map + model_map[dtmi] = dtdl + return model_map + + +@six.add_metaclass(abc.ABCMeta) +class Fetcher(object): + """Interface for fetching from a generic location""" + + @abc.abstractmethod + def fetch(self, path): + pass + + @abc.abstractmethod + def __enter__(self): + pass + + @abc.abstractmethod + def __exit__(self, *exc_details): + pass + + +class HttpFetcher(Fetcher): + """Fetches JSON data from a web endpoint""" + + error_map = {404: ResourceNotFoundError} + + def __init__(self, base_url, pipeline): + """ + :param pipeline: Pipeline (pre-configured) + :type pipeline: :class:`azure.core.pipeline.Pipeline` + """ + self.pipeline = pipeline + self.base_url = base_url + + def __enter__(self): + self.pipeline.__enter__() + return self + + def __exit__(self, *exc_details): + self.pipeline.__exit__(*exc_details) + + def fetch(self, path): + """Fetch and return the contents of a JSON file at a given web path. + + :param str path: Path to JSON file (relative to the base_filepath of the Fetcher) + + :raises: ServiceRequestError if there is an error sending the request + :raises: ServiceResponseError if no response was received for the request + :raises: ResourceNotFoundError if the JSON file cannot be found + :raises: HttpResponseError if there is some other failure during fetch + + :returns: JSON data at the path + :rtype: JSON object + """ + _LOGGER.debug("Fetching %s from remote endpoint", path) + url = urllib.parse.urljoin(self.base_url, path) + + # Fetch + request = HttpRequest("GET", url) + _LOGGER.debug("GET %s", url) + response = self.pipeline.run(request).http_response + if response.status_code != 200: + map_error(status_code=response.status_code, response=response, error_map=self.error_map) + raise HttpResponseError( + "Failed to fetch from remote endpoint. Status code: {}".format(response.status_code) + ) + + json_response = json.loads(response.text()) + return json_response + + +class FilesystemFetcher(Fetcher): + """Fetches JSON data from a local filesystem endpoint""" + + def __init__(self, base_filepath): + """ + :param str base_filepath: The base filepath for fetching from + """ + self.base_filepath = base_filepath + + def __enter__(self): + # Nothing is required here for filesystem + return self + + def __exit__(self, *exc_details): + # Nothing is required here for filesystem + pass + + def fetch(self, path): + """Fetch and return the contents of a JSON file at a given filesystem path. + + :param str path: Path to JSON file (relative to the base_filepath of the Fetcher) + + :raises: ResourceNotFoundError if the JSON file cannot be found + + :returns: JSON data at the path + :rtype: JSON object + """ + _LOGGER.debug("Fetching %s from local filesystem", path) + abs_path = os.path.join(self.base_filepath, path) + abs_path = os.path.normpath(abs_path) + + # Fetch + try: + _LOGGER.debug("File open on %s", abs_path) + with io.open(abs_path, encoding="utf-8-sig") as f: + file_str = f.read() + except (OSError, IOError): + # In Python 3 a FileNotFoundError is raised when a file doesn't exist. + # In Python 2 an IOError is raised when a file doesn't exist. + # Both of these errors are inherited from OSError, so we use this to catch them both. + # The semantics would ideally be better, but this is the price of supporting both. + raise_with_traceback(ResourceNotFoundError, message="Could not open file") + return json.loads(file_str) diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/dtmi_conventions.py b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/dtmi_conventions.py new file mode 100644 index 000000000000..228d09545ee1 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/dtmi_conventions.py @@ -0,0 +1,61 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import re + + +def is_valid_dtmi(dtmi): + """Checks validity of a DTMI + + :param str dtmi: DTMI + + :returns: Boolean indicating if DTMI is valid + :rtype: bool + """ + pattern = re.compile( + "^dtmi:[A-Za-z](?:[A-Za-z0-9_]*[A-Za-z0-9])?(?::[A-Za-z](?:[A-Za-z0-9_]*[A-Za-z0-9])?)*;[1-9][0-9]{0,8}$" + ) + if not pattern.match(dtmi): + return False + return True + + +def get_model_uri(dtmi, repository_uri, expanded=False): + """Get the URI representing the absolute location of a model in a Models Repository + + :param str dtmi: DTMI for a model + :param str repository_uri: URI for a Models Repository + :param bool expanded: Indicates if the URI should be for an expanded model (Default: False) + + :raises: ValueError if given an invalid DTMI + + :returns: The URI for the model in the Models Repository + :rtype: str + """ + if not repository_uri.endswith("/"): + repository_uri += "/" + model_uri = repository_uri + _convert_dtmi_to_path(dtmi, expanded) + return model_uri + + +def _convert_dtmi_to_path(dtmi, expanded=False): + """Returns the relative path for a model given a DTMI + E.g: + dtmi:com:example:Thermostat;1 -> dtmi/com/example/thermostat-1.json + + :param str dtmi : DTMI for a model + :param bool expanded: Indicates if the relative path should be for an exapnded model + + :raises ValueError if DTMI is invalid + + :returns: Relative path of the model in a Models Repository + :rtype: str + """ + if not is_valid_dtmi(dtmi): + raise ValueError("Invalid DTMI") + dtmi_path = dtmi.lower().replace(":", "/").replace(";", "-") + ".json" + if expanded: + dtmi_path = dtmi_path.replace(".json", ".expanded.json") + return dtmi_path diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/exceptions.py b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/exceptions.py new file mode 100644 index 000000000000..d96158ffa434 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/azure/iot/modelsrepository/exceptions.py @@ -0,0 +1,9 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + + +class ModelError(Exception): + pass diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/dev_requirements.txt b/sdk/modelsrepository/azure-iot-modelsrepository/dev_requirements.txt new file mode 100644 index 000000000000..362326dd0248 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/dev_requirements.txt @@ -0,0 +1,6 @@ +-e ../../../tools/azure-devtools +-e ../../../tools/azure-sdk-tools +pytest-mock +pytest-testdox +parameterized +flake8 diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/pytest.ini b/sdk/modelsrepository/azure-iot-modelsrepository/pytest.ini new file mode 100644 index 000000000000..cff99159612d --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +addopts = --testdox +testdox_format = plaintext +norecursedirs=__pycache__, *.egg-info +filterwarnings = ignore::DeprecationWarning diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/samples/README.md b/sdk/modelsrepository/azure-iot-modelsrepository/samples/README.md new file mode 100644 index 000000000000..0852c58f6982 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/samples/README.md @@ -0,0 +1,12 @@ +# Azure IoT Models Repository Library Samples + +This directory contains samples showing how to use the features of the Azure IoT Models Repository Library. + +The pre-configured endpoints and DTMIs within the samples refer to example models that can be found on [devicemodels.azure.com](https://devicemodels.azure.com/). These values can be replaced to reflect the locations of your own models, wherever they may be. + +## ModelsRepositoryClient Samples +* [get_models_sample.py] - Retrieve a model/models (and possibly dependencies) from a Model Repository, given a DTMI or DTMIs + +* [client_configuration_sample.py] - Configure the client to work with local or remote repositories, as well as custom policies and transports + +* [dtmi_conventions_sample.py] - Use the `dtmi_conventions` module to manipulate and check DTMIs \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/samples/client_configuration_sample.py b/sdk/modelsrepository/azure-iot-modelsrepository/samples/client_configuration_sample.py new file mode 100644 index 000000000000..5f0f6dcbeab7 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/samples/client_configuration_sample.py @@ -0,0 +1,25 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +from azure.iot.modelsrepository import ModelsRepositoryClient +from azure.core.pipeline.transport import RequestsTransport +from azure.core.pipeline.policies import UserAgentPolicy, RetryPolicy + + +def default_client(): + # By default, this client will be configured for the Azure Device Models Repository + # i.e. https://devicemodels.azure.com/ + client = ModelsRepositoryClient() + + +def use_remote_repository(): + # You can specify a custom remote endpoint where your Models Repository is located + client = ModelsRepositoryClient(repository_location="https://fake.myrepository.com/") + + +def use_local_repository(): + # You can also specify a custom local filesystem path where your Models Repository is located. + # Paths can be specified as relative or absolute, as well as in URI format + client = ModelsRepositoryClient(repository_location="file:///home/fake/myrepository") diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/samples/dtmi_conventions_sample.py b/sdk/modelsrepository/azure-iot-modelsrepository/samples/dtmi_conventions_sample.py new file mode 100644 index 000000000000..c6b5c7cbb236 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/samples/dtmi_conventions_sample.py @@ -0,0 +1,34 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +from azure.iot.modelsrepository import dtmi_conventions + + +def sample_is_valid_dtmi(): + """Check if a DTMI is valid or not using the .is_valid_dtmi() function""" + # Returns True - this is a valid DTMI + dtmi_conventions.is_valid_dtmi("dtmi:com:example:Thermostat;1") + + # Returns False - this is NOT a valid DTMI + dtmi_conventions.is_valid_dtmi("dtmi:com:example:Thermostat") + + +def sample_get_model_uri(): + """Get a URI for a model in a Models Repository using the .get_model_uri() function""" + dtmi = "dtmi:com:example:Thermostat;1" + + # Local repository example + repo_uri = "file:///path/to/repository" + print(dtmi_conventions.get_model_uri(dtmi, repo_uri)) + # Prints: "file:///path/to/repository/dtmi/com/example/thermostat-1.json" + print(dtmi_conventions.get_model_uri(dtmi, repo_uri, expanded=True)) + # Prints: "file:///path/to/repository/dtmi/com/example/thermostat-1.expanded.json" + + # Remote repository example + repo_uri = "https://contoso.com/models/" + print(dtmi_conventions.get_model_uri(dtmi, repo_uri)) + # Prints: "https://contoso/com/models/dtmi/com/example/thermostat-1.json" + print(dtmi_conventions.get_model_uri(dtmi, repo_uri, expanded=True)) + # Prints: "https://contoso/com/models/dtmi/com/example/thermostat-1.expanded.json" diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/samples/get_models_sample.py b/sdk/modelsrepository/azure-iot-modelsrepository/samples/get_models_sample.py new file mode 100644 index 000000000000..1cb5a64c6379 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/samples/get_models_sample.py @@ -0,0 +1,84 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +from azure.iot.modelsrepository import ( + ModelsRepositoryClient, + DEPENDENCY_MODE_TRY_FROM_EXPANDED, + DEPENDENCY_MODE_ENABLED, +) +from azure.core.exceptions import ( + ResourceNotFoundError, + ServiceRequestError, + ServiceResponseError, + HttpResponseError, +) +import pprint + +dtmi = "dtmi:com:example:TemperatureController;1" +dtmi2 = "dtmi:com:example:TemperatureController;2" + +# By default the clients in this sample will use the Azure Device Models Repository endpoint +# i.e. https://devicemodels.azure.com/ +# See client_configuration_sample.py for examples of alternate configurations + + +def get_model(): + # This API call will return a dictionary mapping DTMI to its corresponding model from + # a DTDL document at the specified endpoint + # i.e. https://devicemodels.azure.com/dtmi/com/example/temperaturecontroller-1.json + with ModelsRepositoryClient() as client: + model_map = client.get_models(dtmi) + pprint.pprint(model_map) + + +def get_models(): + # This API call will return a dictionary mapping DTMIs to corresponding models for from the + # DTDL documents at the specified endpoint + # i.e. https://devicemodels.azure.com/dtmi/com/example/temperaturecontroller-1.json + # i.e. https://devicemodels.azure.com/dtmi/com/example/temperaturecontroller-2.json + with ModelsRepositoryClient() as client: + model_map = client.get_models([dtmi, dtmi2]) + pprint.pprint(model_map) + + +def get_model_expanded_dtdl(): + # This API call will return a dictionary mapping DTMIs to corresponding models for all elements + # of an expanded DTDL document at the specified endpoint + # i.e. https://devicemodels.azure.com/dtmi/com/example/temperaturecontroller-1.expanded.json + with ModelsRepositoryClient() as client: + model_map = client.get_models( + dtmis=[dtmi], dependency_resolution=DEPENDENCY_MODE_TRY_FROM_EXPANDED + ) + pprint.pprint(model_map) + + +def get_model_and_dependencies(): + # This API call will return a dictionary mapping the specified DTMI to its corresponding model, + # from a DTDL document at the specified endpoint, as well as the DTMIs and models for all + # dependencies on components and interfaces + # i.e. https://devicemodels.azure.com/dtmi/com/example/temperaturecontroller-1.json + with ModelsRepositoryClient() as client: + model_map = client.get_models(dtmis=[dtmi], dependency_resolution=DEPENDENCY_MODE_ENABLED) + pprint.pprint(model_map) + + +def get_model_error_handling(): + # Various errors that can be raised when fetching models + try: + with ModelsRepositoryClient() as client: + model_map = client.get_models(dtmi) + pprint.pprint(model_map) + except ResourceNotFoundError as e: + print("The model could not be found") + print("{}".format(e.message)) + except ServiceRequestError as e: + print("There was an error sending the request") + print("{}".format(e.message)) + except ServiceResponseError as e: + print("No response was received") + print("{}".format(e.message)) + except HttpResponseError as e: + print("HTTP Error Response received") + print("{}".format(e.message)) diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/setup.cfg b/sdk/modelsrepository/azure-iot-modelsrepository/setup.cfg new file mode 100644 index 000000000000..3c6e79cf31da --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/setup.py b/sdk/modelsrepository/azure-iot-modelsrepository/setup.py new file mode 100644 index 000000000000..dfa86d6cb0ca --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/setup.py @@ -0,0 +1,86 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +import re +from setuptools import setup, find_packages + +# azure v0.x is not compatible with this package +# azure v0.x used to have a __version__ attribute (newer versions don't) +try: + import azure + + try: + ver = azure.__version__ + raise Exception( + "This package is incompatible with azure=={}. ".format(ver) + + 'Uninstall it with "pip uninstall azure".' + ) + except AttributeError: + pass +except ImportError: + pass + + +# Fetch description +with open("README.md", "r") as fh: + _long_description = fh.read() + + +# Fetch version +with open("azure/iot/modelsrepository/_constants.py", "r") as fh: + VERSION = re.search(r"^VERSION\s=\s*[\"']([^\"']*)", fh.read(), re.MULTILINE).group(1) +if not VERSION: + raise RuntimeError("Cannot find version information") + + +setup( + name="azure-iot-modelsrepository", + version=VERSION, + description="Microsoft Azure IoT Models Repository Library", + license="MIT License", + author="Microsoft Corporation", + author_email="azpysdkhelp@microsoft.com", + url="https://github.com/Azure/azure-sdk-for-python", + project_urls={ + "Bug Tracker": "https://github.com/Azure/azure-sdk-for-python/issues", + "Source": "https://github.com/Azure/azure-sdk-for-python", + }, + long_description=_long_description, + long_description_content_type="text/markdown", + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + ], + install_requires=[ + "azure-core<2.0.0,>=1.2.2", + "six>=1.11.0", + ], + extras_require={":python_version<'3.0'": ["azure-iot-nspkg"]}, + python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3*, !=3.4.*", + packages=find_packages( + exclude=[ + "tests", + "tests.*", + "samples", + "samples.*", + # Exclude packages that will be covered by PEP420 or nspkg + "azure", + "azure.iot", + ] + ), + zip_safe=False, +) diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/__init__.py b/sdk/modelsrepository/azure-iot-modelsrepository/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/azure/devicemanagement/deviceinformation-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/azure/devicemanagement/deviceinformation-1.json new file mode 100644 index 000000000000..8a37e6d2c2c3 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/azure/devicemanagement/deviceinformation-1.json @@ -0,0 +1,64 @@ +{ + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:azure:DeviceManagement:DeviceInformation;1", + "@type": "Interface", + "displayName": "Device Information", + "contents": [ + { + "@type": "Property", + "name": "manufacturer", + "displayName": "Manufacturer", + "schema": "string", + "description": "Company name of the device manufacturer. This could be the same as the name of the original equipment manufacturer (OEM). Ex. Contoso." + }, + { + "@type": "Property", + "name": "model", + "displayName": "Device model", + "schema": "string", + "description": "Device model name or ID. Ex. Surface Book 2." + }, + { + "@type": "Property", + "name": "swVersion", + "displayName": "Software version", + "schema": "string", + "description": "Version of the software on your device. This could be the version of your firmware. Ex. 1.3.45" + }, + { + "@type": "Property", + "name": "osName", + "displayName": "Operating system name", + "schema": "string", + "description": "Name of the operating system on the device. Ex. Windows 10 IoT Core." + }, + { + "@type": "Property", + "name": "processorArchitecture", + "displayName": "Processor architecture", + "schema": "string", + "description": "Architecture of the processor on the device. Ex. x64 or ARM." + }, + { + "@type": "Property", + "name": "processorManufacturer", + "displayName": "Processor manufacturer", + "schema": "string", + "description": "Name of the manufacturer of the processor on the device. Ex. Intel." + }, + { + "@type": "Property", + "name": "totalStorage", + "displayName": "Total storage", + "schema": "double", + "description": "Total available storage on the device in kilobytes. Ex. 2048000 kilobytes." + }, + { + "@type": "Property", + "name": "totalMemory", + "displayName": "Total memory", + "schema": "double", + "description": "Total available memory on the device in kilobytes. Ex. 256000 kilobytes." + } + ] +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/azure/devicemanagement/deviceinformation-2.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/azure/devicemanagement/deviceinformation-2.json new file mode 100644 index 000000000000..d35b8a3e3a1d --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/azure/devicemanagement/deviceinformation-2.json @@ -0,0 +1,16 @@ +{ + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:azure:DeviceManagement:DeviceInformation;2", + "@type": "Interface", + "extends": "dtmi:azure:DeviceManagement:DeviceInformation;1", + "displayName": "Device Information", + "contents": [ + { + "@type": "Property", + "name": "osKernelVersion", + "displayName": "OS Kernel Version", + "schema": "string", + "description": "OS Kernel Version. Ex. Linux 4.15.0-54-generic x86_64." + } + ] +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/base-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/base-1.json new file mode 100644 index 000000000000..85424e8a229e --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/base-1.json @@ -0,0 +1,56 @@ +{ + "@id": "dtmi:com:example:base;1", + "@type": "Interface", + "contents": [ + { + "@type": "Property", + "name": "baseSerialNumber", + "schema": "string" + } + ], + "displayName": { + "en": "mybaseProp" + }, + "extends": [ + { + "@id": "dtmi:com:example:basic;1", + "@type": "Interface", + "contents": [ + { + "@type": "Property", + "name": "serialNumber", + "schema": "string", + "writable": false + }, + { + "@type": [ + "Telemetry", + "Temperature" + ], + "displayName": { + "en": "temperature" + }, + "name": "temperature", + "schema": "double", + "unit": "degreeCelsius" + }, + { + "@type": "Property", + "displayName": { + "en": "targetTemperature" + }, + "name": "targetTemperature", + "schema": "double", + "writable": true + } + ], + "displayName": { + "en": "Basic" + } + } + ], + "@context": [ + "dtmi:iotcentral:context;2", + "dtmi:dtdl:context;2" + ] +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/base-2.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/base-2.json new file mode 100644 index 000000000000..29accafcc252 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/base-2.json @@ -0,0 +1,57 @@ +{ + "@id": "dtmi:com:example:base;2", + "@type": "Interface", + "contents": [ + { + "@type": "Property", + "name": "baseSerialNumber", + "schema": "string" + } + ], + "displayName": { + "en": "mybaseProp" + }, + "extends": [ + { + "@id": "dtmi:com:example:basic;1", + "@type": "Interface", + "contents": [ + { + "@type": "Property", + "name": "serialNumber", + "schema": "string", + "writable": false + }, + { + "@type": [ + "Telemetry", + "Temperature" + ], + "displayName": { + "en": "temperature" + }, + "name": "temperature", + "schema": "double", + "unit": "degreeCelsius" + }, + { + "@type": "Property", + "displayName": { + "en": "targetTemperature" + }, + "name": "targetTemperature", + "schema": "double", + "writable": true + } + ], + "displayName": { + "en": "Basic" + } + }, + "dtmi:com:example:Freezer;1" + ], + "@context": [ + "dtmi:iotcentral:context;2", + "dtmi:dtdl:context;2" + ] +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/building-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/building-1.json new file mode 100644 index 000000000000..c8b6bc6f7375 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/building-1.json @@ -0,0 +1,19 @@ +{ + "@id": "dtmi:com:example:Building;1", + "@type": "Interface", + "displayName": "Building", + "contents": [ + { + "@type": "Property", + "name": "name", + "schema": "string", + "writable": true + }, + { + "@type": "Relationship", + "name": "contains", + "target": "dtmi:com:example:Room;1" + } + ], + "@context": "dtmi:dtdl:context;2" +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/camera-3.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/camera-3.json new file mode 100644 index 000000000000..f912746c0040 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/camera-3.json @@ -0,0 +1,13 @@ +{ + "@id": "dtmi:com:example:Camera;3", + "@type": "Interface", + "displayName": "Phone", + "contents": [ + { + "@type": "Component", + "name": "deviceInfo", + "schema": "dtmi:azure:DeviceManagement:DeviceInformation;1" + } + ], + "@context": "dtmi:dtdl:context;2" +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/coldstorage-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/coldstorage-1.json new file mode 100644 index 000000000000..a3b8466118a9 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/coldstorage-1.json @@ -0,0 +1,13 @@ +{ + "@id": "dtmi:com:example:ColdStorage;1", + "@type": "Interface", + "extends": ["dtmi:com:example:Room;1", "dtmi:com:example:Freezer;1"], + "contents": [ + { + "@type": "Property", + "name": "capacity", + "schema": "integer" + } + ], + "@context": "dtmi:dtdl:context;2" +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/conferenceroom-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/conferenceroom-1.json new file mode 100644 index 000000000000..2e756ee73b6e --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/conferenceroom-1.json @@ -0,0 +1,13 @@ +{ + "@id": "dtmi:com:example:ConferenceRoom;1", + "@type": "Interface", + "extends": "dtmi:com:example:Room;1", + "contents": [ + { + "@type": "Property", + "name": "capacity", + "schema": "integer" + } + ], + "@context": "dtmi:dtdl:context;2" +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/danglingexpanded-1.expanded.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/danglingexpanded-1.expanded.json new file mode 100644 index 000000000000..93126c749ce9 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/danglingexpanded-1.expanded.json @@ -0,0 +1,215 @@ +[ + { + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:com:example:DanglingExpanded;1", + "@type": "Interface", + "displayName": "Valid expanded model with no root model.", + "description": "Device with two thermostats and remote reboot.", + "contents": [ + { + "@type": [ + "Telemetry", + "DataSize" + ], + "name": "workingSet", + "displayName": "Working Set", + "description": "Current working set of the device memory in KiB.", + "schema": "double", + "unit": "kibibyte" + }, + { + "@type": "Property", + "name": "serialNumber", + "displayName": "Serial Number", + "description": "Serial number of the device.", + "schema": "string" + }, + { + "@type": "Command", + "name": "reboot", + "displayName": "Reboot", + "description": "Reboots the device after waiting the number of seconds specified.", + "request": { + "name": "delay", + "displayName": "Delay", + "description": "Number of seconds to wait before rebooting the device.", + "schema": "integer" + } + }, + { + "@type": "Component", + "schema": "dtmi:com:example:Thermostat;1", + "name": "thermostat1", + "displayName": "Thermostat One", + "description": "Thermostat One of Two." + }, + { + "@type": "Component", + "schema": "dtmi:com:example:Thermostat;1", + "name": "thermostat2", + "displayName": "Thermostat Two", + "description": "Thermostat Two of Two." + }, + { + "@type": "Component", + "schema": "dtmi:azure:DeviceManagement:DeviceInformation;1", + "name": "deviceInformation", + "displayName": "Device Information interface", + "description": "Optional interface with basic device hardware information." + } + ] + }, + { + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:com:example:Thermostat;1", + "@type": "Interface", + "displayName": "Thermostat", + "description": "Reports current temperature and provides desired temperature control.", + "contents": [ + { + "@type": [ + "Telemetry", + "Temperature" + ], + "name": "temperature", + "displayName": "Temperature", + "description": "Temperature in degrees Celsius.", + "schema": "double", + "unit": "degreeCelsius" + }, + { + "@type": [ + "Property", + "Temperature" + ], + "name": "targetTemperature", + "schema": "double", + "displayName": "Target Temperature", + "description": "Allows to remotely specify the desired target temperature.", + "unit": "degreeCelsius", + "writable": true + }, + { + "@type": [ + "Property", + "Temperature" + ], + "name": "maxTempSinceLastReboot", + "schema": "double", + "unit": "degreeCelsius", + "displayName": "Max temperature since last reboot.", + "description": "Returns the max temperature since last device reboot." + }, + { + "@type": "Command", + "name": "getMaxMinReport", + "displayName": "Get Max-Min report.", + "description": "This command returns the max, min and average temperature from the specified time to the current time.", + "request": { + "name": "since", + "displayName": "Since", + "description": "Period to return the max-min report.", + "schema": "dateTime" + }, + "response": { + "name": "tempReport", + "displayName": "Temperature Report", + "schema": { + "@type": "Object", + "fields": [ + { + "name": "maxTemp", + "displayName": "Max temperature", + "schema": "double" + }, + { + "name": "minTemp", + "displayName": "Min temperature", + "schema": "double" + }, + { + "name": "avgTemp", + "displayName": "Average Temperature", + "schema": "double" + }, + { + "name": "startTime", + "displayName": "Start Time", + "schema": "dateTime" + }, + { + "name": "endTime", + "displayName": "End Time", + "schema": "dateTime" + } + ] + } + } + } + ] + }, + { + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:azure:DeviceManagement:DeviceInformation;1", + "@type": "Interface", + "displayName": "Device Information", + "contents": [ + { + "@type": "Property", + "name": "manufacturer", + "displayName": "Manufacturer", + "schema": "string", + "description": "Company name of the device manufacturer. This could be the same as the name of the original equipment manufacturer (OEM). Ex. Contoso." + }, + { + "@type": "Property", + "name": "model", + "displayName": "Device model", + "schema": "string", + "description": "Device model name or ID. Ex. Surface Book 2." + }, + { + "@type": "Property", + "name": "swVersion", + "displayName": "Software version", + "schema": "string", + "description": "Version of the software on your device. This could be the version of your firmware. Ex. 1.3.45" + }, + { + "@type": "Property", + "name": "osName", + "displayName": "Operating system name", + "schema": "string", + "description": "Name of the operating system on the device. Ex. Windows 10 IoT Core." + }, + { + "@type": "Property", + "name": "processorArchitecture", + "displayName": "Processor architecture", + "schema": "string", + "description": "Architecture of the processor on the device. Ex. x64 or ARM." + }, + { + "@type": "Property", + "name": "processorManufacturer", + "displayName": "Processor manufacturer", + "schema": "string", + "description": "Name of the manufacturer of the processor on the device. Ex. Intel." + }, + { + "@type": "Property", + "name": "totalStorage", + "displayName": "Total storage", + "schema": "double", + "description": "Total available storage on the device in kilobytes. Ex. 2048000 kilobytes." + }, + { + "@type": "Property", + "name": "totalMemory", + "displayName": "Total memory", + "schema": "double", + "description": "Total available memory on the device in kilobytes. Ex. 256000 kilobytes." + } + ] + } +] diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/freezer-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/freezer-1.json new file mode 100644 index 000000000000..6006b6673299 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/freezer-1.json @@ -0,0 +1,12 @@ +{ + "@id": "dtmi:com:example:Freezer;1", + "@type": "Interface", + "contents": [ + { + "@type": "Component", + "name": "deviceInfo", + "schema": "dtmi:com:example:Thermostat;1" + } + ], + "@context": "dtmi:dtdl:context;2" +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/incompleteexpanded-1.expanded.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/incompleteexpanded-1.expanded.json new file mode 100644 index 000000000000..1688ef4c0e3c --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/incompleteexpanded-1.expanded.json @@ -0,0 +1,151 @@ +[ + { + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:com:example:IncompleteExpanded;1", + "@type": "Interface", + "displayName": "Incomplete expanded Temperature Controller", + "description": "Device with two thermostats and remote reboot.", + "contents": [ + { + "@type": [ + "Telemetry", + "DataSize" + ], + "name": "workingSet", + "displayName": "Working Set", + "description": "Current working set of the device memory in KiB.", + "schema": "double", + "unit": "kibibyte" + }, + { + "@type": "Property", + "name": "serialNumber", + "displayName": "Serial Number", + "description": "Serial number of the device.", + "schema": "string" + }, + { + "@type": "Command", + "name": "reboot", + "displayName": "Reboot", + "description": "Reboots the device after waiting the number of seconds specified.", + "request": { + "name": "delay", + "displayName": "Delay", + "description": "Number of seconds to wait before rebooting the device.", + "schema": "integer" + } + }, + { + "@type": "Component", + "schema": "dtmi:com:example:Thermostat;1", + "name": "thermostat1", + "displayName": "Thermostat One", + "description": "Thermostat One of Two." + }, + { + "@type": "Component", + "schema": "dtmi:com:example:Thermostat;1", + "name": "thermostat2", + "displayName": "Thermostat Two", + "description": "Thermostat Two of Two." + }, + { + "@type": "Component", + "schema": "dtmi:azure:DeviceManagement:DeviceInformation;1", + "name": "deviceInformation", + "displayName": "Device Information interface", + "description": "Optional interface with basic device hardware information." + } + ] + }, + { + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:com:example:Thermostat;1", + "@type": "Interface", + "displayName": "Thermostat", + "description": "Reports current temperature and provides desired temperature control.", + "contents": [ + { + "@type": [ + "Telemetry", + "Temperature" + ], + "name": "temperature", + "displayName": "Temperature", + "description": "Temperature in degrees Celsius.", + "schema": "double", + "unit": "degreeCelsius" + }, + { + "@type": [ + "Property", + "Temperature" + ], + "name": "targetTemperature", + "schema": "double", + "displayName": "Target Temperature", + "description": "Allows to remotely specify the desired target temperature.", + "unit": "degreeCelsius", + "writable": true + }, + { + "@type": [ + "Property", + "Temperature" + ], + "name": "maxTempSinceLastReboot", + "schema": "double", + "unit": "degreeCelsius", + "displayName": "Max temperature since last reboot.", + "description": "Returns the max temperature since last device reboot." + }, + { + "@type": "Command", + "name": "getMaxMinReport", + "displayName": "Get Max-Min report.", + "description": "This command returns the max, min and average temperature from the specified time to the current time.", + "request": { + "name": "since", + "displayName": "Since", + "description": "Period to return the max-min report.", + "schema": "dateTime" + }, + "response": { + "name": "tempReport", + "displayName": "Temperature Report", + "schema": { + "@type": "Object", + "fields": [ + { + "name": "maxTemp", + "displayName": "Max temperature", + "schema": "double" + }, + { + "name": "minTemp", + "displayName": "Min temperature", + "schema": "double" + }, + { + "name": "avgTemp", + "displayName": "Average Temperature", + "schema": "double" + }, + { + "name": "startTime", + "displayName": "Start Time", + "schema": "dateTime" + }, + { + "name": "endTime", + "displayName": "End Time", + "schema": "dateTime" + } + ] + } + } + } + ] + } +] diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/invalidmodel-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/invalidmodel-1.json new file mode 100644 index 000000000000..4f18d7b17658 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/invalidmodel-1.json @@ -0,0 +1,13 @@ +{ + "@id": "dtmi:com:example:invalidmodel;1", + "@type": "Interface", + "displayName": "Phone", + "contents": [ + { + "@type": "Component", + "name": "deviceInfo", + "schema": "dtmi:azure:fakeDeviceManagement:FakeDeviceInformation;2" + } + ], + "@context": "dtmi:dtdl:context;2" +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/invalidmodel-2.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/invalidmodel-2.json new file mode 100644 index 000000000000..61443734cd90 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/invalidmodel-2.json @@ -0,0 +1,23 @@ +{ + "@id": "dtmi:com:example:Phone;2", + "@type": "Interfacez", + "displayName": "Phone", + "contentsz": [ + { + "@type": "Component", + "name": "frontCamera", + "schema": "dtmi:com:example:Camera;3" + }, + { + "@type": "Component", + "name": "backCamera", + "schema": "dtmi:com:example:Camera;3" + }, + { + "@type": "Component", + "name": "deviceInfo", + "schema": "dtmi:azure:deviceManagement:DeviceInformation;2" + } + ], + "@context": "dtmi:dtdl:context;2" +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/phone-2.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/phone-2.json new file mode 100644 index 000000000000..26c7efbdedc0 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/phone-2.json @@ -0,0 +1,23 @@ +{ + "@id": "dtmi:com:example:Phone;2", + "@type": "Interface", + "displayName": "Phone", + "contents": [ + { + "@type": "Component", + "name": "frontCamera", + "schema": "dtmi:com:example:Camera;3" + }, + { + "@type": "Component", + "name": "backCamera", + "schema": "dtmi:com:example:Camera;3" + }, + { + "@type": "Component", + "name": "deviceInfo", + "schema": "dtmi:azure:DeviceManagement:DeviceInformation;2" + } + ], + "@context": "dtmi:dtdl:context;2" +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/room-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/room-1.json new file mode 100644 index 000000000000..1a07edec4d98 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/room-1.json @@ -0,0 +1,12 @@ +{ + "@id": "dtmi:com:example:Room;1", + "@type": "Interface", + "contents": [ + { + "@type": "Property", + "name": "occupied", + "schema": "boolean" + } + ], + "@context": "dtmi:dtdl:context;2" +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/temperaturecontroller-1.expanded.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/temperaturecontroller-1.expanded.json new file mode 100644 index 000000000000..14e8e294189e --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/temperaturecontroller-1.expanded.json @@ -0,0 +1,215 @@ +[ + { + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:com:example:TemperatureController;1", + "@type": "Interface", + "displayName": "Temperature Controller", + "description": "Device with two thermostats and remote reboot.", + "contents": [ + { + "@type": [ + "Telemetry", + "DataSize" + ], + "name": "workingSet", + "displayName": "Working Set", + "description": "Current working set of the device memory in KiB.", + "schema": "double", + "unit": "kibibyte" + }, + { + "@type": "Property", + "name": "serialNumber", + "displayName": "Serial Number", + "description": "Serial number of the device.", + "schema": "string" + }, + { + "@type": "Command", + "name": "reboot", + "displayName": "Reboot", + "description": "Reboots the device after waiting the number of seconds specified.", + "request": { + "name": "delay", + "displayName": "Delay", + "description": "Number of seconds to wait before rebooting the device.", + "schema": "integer" + } + }, + { + "@type": "Component", + "schema": "dtmi:com:example:Thermostat;1", + "name": "thermostat1", + "displayName": "Thermostat One", + "description": "Thermostat One of Two." + }, + { + "@type": "Component", + "schema": "dtmi:com:example:Thermostat;1", + "name": "thermostat2", + "displayName": "Thermostat Two", + "description": "Thermostat Two of Two." + }, + { + "@type": "Component", + "schema": "dtmi:azure:DeviceManagement:DeviceInformation;1", + "name": "deviceInformation", + "displayName": "Device Information interface", + "description": "Optional interface with basic device hardware information." + } + ] + }, + { + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:com:example:Thermostat;1", + "@type": "Interface", + "displayName": "Thermostat", + "description": "Reports current temperature and provides desired temperature control.", + "contents": [ + { + "@type": [ + "Telemetry", + "Temperature" + ], + "name": "temperature", + "displayName": "Temperature", + "description": "Temperature in degrees Celsius.", + "schema": "double", + "unit": "degreeCelsius" + }, + { + "@type": [ + "Property", + "Temperature" + ], + "name": "targetTemperature", + "schema": "double", + "displayName": "Target Temperature", + "description": "Allows to remotely specify the desired target temperature.", + "unit": "degreeCelsius", + "writable": true + }, + { + "@type": [ + "Property", + "Temperature" + ], + "name": "maxTempSinceLastReboot", + "schema": "double", + "unit": "degreeCelsius", + "displayName": "Max temperature since last reboot.", + "description": "Returns the max temperature since last device reboot." + }, + { + "@type": "Command", + "name": "getMaxMinReport", + "displayName": "Get Max-Min report.", + "description": "This command returns the max, min and average temperature from the specified time to the current time.", + "request": { + "name": "since", + "displayName": "Since", + "description": "Period to return the max-min report.", + "schema": "dateTime" + }, + "response": { + "name": "tempReport", + "displayName": "Temperature Report", + "schema": { + "@type": "Object", + "fields": [ + { + "name": "maxTemp", + "displayName": "Max temperature", + "schema": "double" + }, + { + "name": "minTemp", + "displayName": "Min temperature", + "schema": "double" + }, + { + "name": "avgTemp", + "displayName": "Average Temperature", + "schema": "double" + }, + { + "name": "startTime", + "displayName": "Start Time", + "schema": "dateTime" + }, + { + "name": "endTime", + "displayName": "End Time", + "schema": "dateTime" + } + ] + } + } + } + ] + }, + { + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:azure:DeviceManagement:DeviceInformation;1", + "@type": "Interface", + "displayName": "Device Information", + "contents": [ + { + "@type": "Property", + "name": "manufacturer", + "displayName": "Manufacturer", + "schema": "string", + "description": "Company name of the device manufacturer. This could be the same as the name of the original equipment manufacturer (OEM). Ex. Contoso." + }, + { + "@type": "Property", + "name": "model", + "displayName": "Device model", + "schema": "string", + "description": "Device model name or ID. Ex. Surface Book 2." + }, + { + "@type": "Property", + "name": "swVersion", + "displayName": "Software version", + "schema": "string", + "description": "Version of the software on your device. This could be the version of your firmware. Ex. 1.3.45" + }, + { + "@type": "Property", + "name": "osName", + "displayName": "Operating system name", + "schema": "string", + "description": "Name of the operating system on the device. Ex. Windows 10 IoT Core." + }, + { + "@type": "Property", + "name": "processorArchitecture", + "displayName": "Processor architecture", + "schema": "string", + "description": "Architecture of the processor on the device. Ex. x64 or ARM." + }, + { + "@type": "Property", + "name": "processorManufacturer", + "displayName": "Processor manufacturer", + "schema": "string", + "description": "Name of the manufacturer of the processor on the device. Ex. Intel." + }, + { + "@type": "Property", + "name": "totalStorage", + "displayName": "Total storage", + "schema": "double", + "description": "Total available storage on the device in kilobytes. Ex. 2048000 kilobytes." + }, + { + "@type": "Property", + "name": "totalMemory", + "displayName": "Total memory", + "schema": "double", + "description": "Total available memory on the device in kilobytes. Ex. 256000 kilobytes." + } + ] + } +] \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/temperaturecontroller-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/temperaturecontroller-1.json new file mode 100644 index 000000000000..c455ddf8bae6 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/temperaturecontroller-1.json @@ -0,0 +1,60 @@ +{ + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:com:example:TemperatureController;1", + "@type": "Interface", + "displayName": "Temperature Controller", + "description": "Device with two thermostats and remote reboot.", + "contents": [ + { + "@type": [ + "Telemetry", + "DataSize" + ], + "name": "workingSet", + "displayName": "Working Set", + "description": "Current working set of the device memory in KiB.", + "schema": "double", + "unit": "kibibyte" + }, + { + "@type": "Property", + "name": "serialNumber", + "displayName": "Serial Number", + "description": "Serial number of the device.", + "schema": "string" + }, + { + "@type": "Command", + "name": "reboot", + "displayName": "Reboot", + "description": "Reboots the device after waiting the number of seconds specified.", + "request": { + "name": "delay", + "displayName": "Delay", + "description": "Number of seconds to wait before rebooting the device.", + "schema": "integer" + } + }, + { + "@type": "Component", + "schema": "dtmi:com:example:Thermostat;1", + "name": "thermostat1", + "displayName": "Thermostat One", + "description": "Thermostat One of Two." + }, + { + "@type": "Component", + "schema": "dtmi:com:example:Thermostat;1", + "name": "thermostat2", + "displayName": "Thermostat Two", + "description": "Thermostat Two of Two." + }, + { + "@type": "Component", + "schema": "dtmi:azure:DeviceManagement:DeviceInformation;1", + "name": "deviceInformation", + "displayName": "Device Information interface", + "description": "Optional interface with basic device hardware information." + } + ] +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/thermostat-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/thermostat-1.json new file mode 100644 index 000000000000..315a307bbcb3 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/com/example/thermostat-1.json @@ -0,0 +1,19 @@ +{ + "@id": "dtmi:com:example:Thermostat;1", + "@type": "Interface", + "displayName": "Thermostat", + "contents": [ + { + "@type": "Telemetry", + "name": "temp", + "schema": "double" + }, + { + "@type": "Property", + "name": "setPointTemp", + "writable": true, + "schema": "double" + } + ], + "@context": "dtmi:dtdl:context;2" +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/company/demodevice-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/company/demodevice-1.json new file mode 100644 index 000000000000..33c9554664a6 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/company/demodevice-1.json @@ -0,0 +1,31 @@ +{ + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:company:demodevice;1", + "@type": "Interface", + "displayName": "demodevice", + "contents": [ + { + "@type": "Component", + "name": "c1", + "schema": "dtmi:azure:deviceManagement:DeviceInformation;1" + }, + { + "@type": "Telemetry", + "name": "temperature", + "schema": "double" + }, + { + "@type": "Property", + "name": "deviceStatus", + "schema": "string" + }, + { + "@type": "Command", + "name": "reboot", + "request": { + "name": "delay", + "schema": "integer" + } + } + ] +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/company/demodevice-2.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/company/demodevice-2.json new file mode 100644 index 000000000000..9d9b3bd2322a --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/company/demodevice-2.json @@ -0,0 +1,31 @@ +{ + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:company:demodevice;1", + "@type": "Interface", + "displayName": "demodevice", + "contents": [ + { + "@type": "Component", + "name": "c1", + "schema": "dtmi:azure:DeviceManagement:DeviceInformation;1" + }, + { + "@type": "Telemetry", + "name": "temperature", + "schema": "double" + }, + { + "@type": "Property", + "name": "deviceStatus", + "schema": "string" + }, + { + "@type": "Command", + "name": "reboot", + "request": { + "name": "delay", + "schema": "integer" + } + } + ] +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/badfilepath-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/badfilepath-1.json new file mode 100644 index 000000000000..6006b6673299 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/badfilepath-1.json @@ -0,0 +1,12 @@ +{ + "@id": "dtmi:com:example:Freezer;1", + "@type": "Interface", + "contents": [ + { + "@type": "Component", + "name": "deviceInfo", + "schema": "dtmi:com:example:Thermostat;1" + } + ], + "@context": "dtmi:dtdl:context;2" +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/emptyarray-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/emptyarray-1.json new file mode 100644 index 000000000000..0637a088a01e --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/emptyarray-1.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/namespaceconflict-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/namespaceconflict-1.json new file mode 100644 index 000000000000..6f2df812a67f --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/namespaceconflict-1.json @@ -0,0 +1,37 @@ +{ + "@id": "dtmi:strict:namespaceconflict;1", + "@type": "Interface", + "contents": [ + { + "@type": "Telemetry", + "name": "accelerometer1", + "schema": "dtmi:com:example:acceleration;1" + }, + { + "@type": "Telemetry", + "name": "accelerometer2", + "schema": "dtmi:com:example:acceleration;1" + } + ], + "schemas": [ + { + "@id": "dtmi:com:example:acceleration;1", + "@type": "Object", + "fields": [ + { + "name": "x", + "schema": "double" + }, + { + "name": "y", + "schema": "double" + }, + { + "name": "z", + "schema": "double" + } + ] + } + ], + "@context": "dtmi:dtdl:context;2" +} \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/nondtdl-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/nondtdl-1.json new file mode 100644 index 000000000000..b25ba2ac3af6 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/nondtdl-1.json @@ -0,0 +1 @@ +"content" \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/unsupportedrootarray-1.json b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/unsupportedrootarray-1.json new file mode 100644 index 000000000000..1f282307a8c3 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/local_repository/dtmi/strict/unsupportedrootarray-1.json @@ -0,0 +1,91 @@ +[ + { + "@context": "dtmi:dtdl:context;2", + "@id": "dtmi:strict:unsupportedrootarray;1", + "@type": "Interface", + "displayName": "Thermostat", + "description": "Reports current temperature and provides desired temperature control.", + "contents": [ + { + "@type": [ + "Telemetry", + "Temperature" + ], + "name": "temperature", + "displayName": "Temperature", + "description": "Temperature in degrees Celsius.", + "schema": "double", + "unit": "degreeCelsius" + }, + { + "@type": [ + "Property", + "Temperature" + ], + "name": "targetTemperature", + "schema": "double", + "displayName": "Target Temperature", + "description": "Allows to remotely specify the desired target temperature.", + "unit": "degreeCelsius", + "writable": true + }, + { + "@type": [ + "Property", + "Temperature" + ], + "name": "maxTempSinceLastReboot", + "schema": "double", + "unit": "degreeCelsius", + "displayName": "Max temperature since last reboot.", + "description": "Returns the max temperature since last device reboot." + }, + { + "@type": "Command", + "name": "getMaxMinReport", + "displayName": "Get Max-Min report.", + "description": "This command returns the max, min and average temperature from the specified time to the current time.", + "request": { + "name": "since", + "displayName": "Since", + "description": "Period to return the max-min report.", + "schema": "dateTime" + }, + "response": { + "name": "tempReport", + "displayName": "Temperature Report", + "schema": { + "@type": "Object", + "fields": [ + { + "name": "maxTemp", + "displayName": "Max temperature", + "schema": "double" + }, + { + "name": "minTemp", + "displayName": "Min temperature", + "schema": "double" + }, + { + "name": "avgTemp", + "displayName": "Average Temperature", + "schema": "double" + }, + { + "name": "startTime", + "displayName": "Start Time", + "schema": "dateTime" + }, + { + "name": "endTime", + "displayName": "End Time", + "schema": "dateTime" + } + ] + } + } + } + ] + } +] \ No newline at end of file diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/test_dtmi_conventions.py b/sdk/modelsrepository/azure-iot-modelsrepository/tests/test_dtmi_conventions.py new file mode 100644 index 000000000000..1ad81a500752 --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/test_dtmi_conventions.py @@ -0,0 +1,157 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import pytest +from azure.iot.modelsrepository import dtmi_conventions + + +@pytest.mark.describe(".is_valid_dtmi()") +class TestIsValidDTMI(object): + @pytest.mark.it("Returns True if given a valid DTMI") + @pytest.mark.parametrize( + "dtmi", + [ + pytest.param("dtmi:FooDTDL;1", id="Short DTMI"), + pytest.param("dtmi:com:somedomain:example:FooDTDL;1", id="Long DTMI"), + ], + ) + def test_valid_dtmi(self, dtmi): + assert dtmi_conventions.is_valid_dtmi(dtmi) + + @pytest.mark.it("Returns False if given an invalid DTMI") + @pytest.mark.parametrize( + "dtmi", + [ + pytest.param("", id="Empty string"), + pytest.param("not a dtmi", id="Not a DTMI"), + pytest.param("com:somedomain:example:FooDTDL;1", id="DTMI missing scheme"), + pytest.param("dtmi:com:somedomain:example:FooDTDL", id="DTMI missing version"), + pytest.param("dtmi:foo_bar:_16:baz33:qux;12", id="System DTMI"), + ], + ) + def test_invalid_dtmi(self, dtmi): + assert not dtmi_conventions.is_valid_dtmi(dtmi) + + +@pytest.mark.describe(".get_model_uri()") +class TestGetModelURI(object): + @pytest.mark.it("Returns the URI for a specified model at a specified repository") + @pytest.mark.parametrize( + "dtmi, repository_uri, expected_model_uri", + [ + pytest.param( + "dtmi:com:somedomain:example:FooDTDL;1", + "https://myrepository/", + "https://myrepository/dtmi/com/somedomain/example/foodtdl-1.json", + id="HTTPS repository URI", + ), + pytest.param( + "dtmi:com:somedomain:example:FooDTDL;1", + "http://myrepository/", + "http://myrepository/dtmi/com/somedomain/example/foodtdl-1.json", + id="HTTP repository URI", + ), + pytest.param( + "dtmi:com:somedomain:example:FooDTDL;1", + "file:///myrepository/", + "file:///myrepository/dtmi/com/somedomain/example/foodtdl-1.json", + id="POSIX Filesystem URI", + ), + pytest.param( + "dtmi:com:somedomain:example:FooDTDL;1", + "file://c:/myrepository/", + "file://c:/myrepository/dtmi/com/somedomain/example/foodtdl-1.json", + id="Drive Letter Filesystem URI", + ), + pytest.param( + "dtmi:com:somedomain:example:FooDTDL;1", + "file://server/myrepository", + "file://server/myrepository/dtmi/com/somedomain/example/foodtdl-1.json", + id="Windows UNC Filesystem URI", + ), + pytest.param( + "dtmi:com:somedomain:example:FooDTDL;1", + "file://localhost/myrepository/", + "file://localhost/myrepository/dtmi/com/somedomain/example/foodtdl-1.json", + id="Filesystem URI w/ host", + ), + pytest.param( + "dtmi:com:somedomain:example:FooDTDL;1", + "http://myrepository", + "http://myrepository/dtmi/com/somedomain/example/foodtdl-1.json", + id="Repository URI without trailing '/'", + ), + ], + ) + def test_uri(self, dtmi, repository_uri, expected_model_uri): + model_uri = dtmi_conventions.get_model_uri(dtmi, repository_uri) + assert model_uri == expected_model_uri + + @pytest.mark.it("Returns the URI for a specified expanded model at a specified repository") + @pytest.mark.parametrize( + "dtmi, repository_uri, expected_model_uri", + [ + pytest.param( + "dtmi:com:somedomain:example:FooDTDL;1", + "https://myfakerepository.com/", + "https://myfakerepository.com/dtmi/com/somedomain/example/foodtdl-1.expanded.json", + id="HTTPS repository URI", + ), + pytest.param( + "dtmi:com:somedomain:example:FooDTDL;1", + "http://myfakerepository.com/", + "http://myfakerepository.com/dtmi/com/somedomain/example/foodtdl-1.expanded.json", + id="HTTP repository URI", + ), + pytest.param( + "dtmi:com:somedomain:example:FooDTDL;1", + "file:///myrepository/", + "file:///myrepository/dtmi/com/somedomain/example/foodtdl-1.expanded.json", + id="POSIX Filesystem URI", + ), + pytest.param( + "dtmi:com:somedomain:example:FooDTDL;1", + "file://c:/myrepository/", + "file://c:/myrepository/dtmi/com/somedomain/example/foodtdl-1.expanded.json", + id="Drive Letter Filesystem URI", + ), + pytest.param( + "dtmi:com:somedomain:example:FooDTDL;1", + "file://server/myrepository", + "file://server/myrepository/dtmi/com/somedomain/example/foodtdl-1.expanded.json", + id="Windows UNC Filesystem URI", + ), + pytest.param( + "dtmi:com:somedomain:example:FooDTDL;1", + "file://localhost/myrepository/", + "file://localhost/myrepository/dtmi/com/somedomain/example/foodtdl-1.expanded.json", + id="Filesystem URI w/ host", + ), + pytest.param( + "dtmi:com:somedomain:example:FooDTDL;1", + "http://myrepository.com", + "http://myrepository.com/dtmi/com/somedomain/example/foodtdl-1.expanded.json", + id="Repository URI without trailing '/'", + ), + ], + ) + def test_uri_expanded(self, dtmi, repository_uri, expected_model_uri): + model_uri = dtmi_conventions.get_model_uri(dtmi, repository_uri, expanded=True) + assert model_uri == expected_model_uri + + @pytest.mark.it("Raises ValueError if given an invalid DTMI") + @pytest.mark.parametrize( + "dtmi", + [ + pytest.param("", id="Empty string"), + pytest.param("not a dtmi", id="Not a DTMI"), + pytest.param("com:somedomain:example:FooDTDL;1", id="DTMI missing scheme"), + pytest.param("dtmi:com:somedomain:example:FooDTDL", id="DTMI missing version"), + pytest.param("dtmi:foo_bar:_16:baz33:qux;12", id="System DTMI"), + ], + ) + def test_invalid_dtmi(self, dtmi): + with pytest.raises(ValueError): + dtmi_conventions.get_model_uri(dtmi, "https://myrepository/") diff --git a/sdk/modelsrepository/azure-iot-modelsrepository/tests/test_integration_client.py b/sdk/modelsrepository/azure-iot-modelsrepository/tests/test_integration_client.py new file mode 100644 index 000000000000..34ee17c071db --- /dev/null +++ b/sdk/modelsrepository/azure-iot-modelsrepository/tests/test_integration_client.py @@ -0,0 +1,536 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import os +from parameterized import parameterized +from devtools_testutils import AzureTestCase +from azure.core.exceptions import ResourceNotFoundError +from azure.iot.modelsrepository import ( + ModelsRepositoryClient, + DEPENDENCY_MODE_ENABLED, + DEPENDENCY_MODE_DISABLED, + DEPENDENCY_MODE_TRY_FROM_EXPANDED, + ModelError, +) + +LOCAL_REPO = "local repo" +REMOTE_REPO = "remote_repo" + + +################################ +# Client Fixture Mixin Classes # +################################ + + +class RemoteRepositoryMixin(object): + def setUp(self): + self.client = ModelsRepositoryClient() + self.client_type = REMOTE_REPO + + def tearDown(self): + self.client.close() + + +class LocalRepositoryMixin(object): + def setUp(self): + test_dir = os.path.dirname(os.path.abspath(__file__)) + local_repo = os.path.join(test_dir, "local_repository") + self.client = ModelsRepositoryClient(repository_location=local_repo) + self.client_type = LOCAL_REPO + + def tearDown(self): + self.client.close() + + +########################### +# Test Case Mixin Classes # +########################### + + +class GetModelsDependencyModeEnabledIntegrationTestCaseMixin(object): + def test_dtmi_mismatch_casing(self): + dtmi = "dtmi:com:example:thermostat;1" + with self.assertRaises(ModelError): + self.client.get_models(dtmi, dependency_resolution=DEPENDENCY_MODE_ENABLED) + + @parameterized.expand( + [ + ("No semicolon", "dtmi:com:example:Thermostat:1"), + ("Double colon", "dtmi:com:example::Thermostat;1"), + ("No DTMI prefix", "com:example:Thermostat;1"), + ] + ) + def test_invalid_dtmi_format(self, _, dtmi): + with self.assertRaises(ValueError): + self.client.get_models(dtmi, dependency_resolution=DEPENDENCY_MODE_ENABLED) + + def test_nonexistant_dtdl_doc(self): + dtmi = "dtmi:com:example:thermojax;999" + with self.assertRaises(ResourceNotFoundError): + self.client.get_models(dtmi) + + def test_nonexistent_dependency_dtdl_doc(self): + dtmi = "dtmi:com:example:invalidmodel;1" + with self.assertRaises(ResourceNotFoundError): + self.client.get_models(dtmi) + + def test_single_dtmi_no_components_no_extends(self): + dtmi = "dtmi:com:example:Thermostat;1" + model_map = self.client.get_models(dtmi, dependency_resolution=DEPENDENCY_MODE_ENABLED) + + self.assertTrue(len(model_map) == 1) + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_multiple_dtmis_no_components_no_extends(self): + dtmi1 = "dtmi:com:example:Thermostat;1" + dtmi2 = "dtmi:azure:DeviceManagement:DeviceInformation;1" + model_map = self.client.get_models( + [dtmi1, dtmi2], dependency_resolution=DEPENDENCY_MODE_ENABLED + ) + + self.assertTrue(len(model_map) == 2) + self.assertTrue(dtmi1 in model_map.keys()) + self.assertTrue(dtmi2 in model_map.keys()) + model1 = model_map[dtmi1] + model2 = model_map[dtmi2] + self.assertTrue(model1["@id"] == dtmi1) + self.assertTrue(model2["@id"] == dtmi2) + + def test_single_dtmi_with_component_deps(self): + root_dtmi = "dtmi:com:example:TemperatureController;1" + expected_deps = [ + "dtmi:com:example:Thermostat;1", + "dtmi:azure:DeviceManagement:DeviceInformation;1", + ] + expected_dtmis = [root_dtmi] + expected_deps + model_map = self.client.get_models(root_dtmi, dependency_resolution=DEPENDENCY_MODE_ENABLED) + + self.assertTrue(len(model_map) == len(expected_dtmis)) + for dtmi in expected_dtmis: + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_multiple_dtmis_with_component_deps(self): + if self.client_type == REMOTE_REPO: + self.skipTest("Insufficient data") + root_dtmi1 = "dtmi:com:example:Phone;2" + root_dtmi2 = "dtmi:com:example:TemperatureController;1" + expected_deps = [ + "dtmi:com:example:Thermostat;1", + "dtmi:azure:DeviceManagement:DeviceInformation;1", + "dtmi:azure:DeviceManagement:DeviceInformation;2", + "dtmi:com:example:Camera;3", + ] + expected_dtmis = [root_dtmi1, root_dtmi2] + expected_deps + model_map = self.client.get_models( + [root_dtmi1, root_dtmi2], dependency_resolution=DEPENDENCY_MODE_ENABLED + ) + + self.assertTrue(len(model_map) == len(expected_dtmis)) + for dtmi in expected_dtmis: + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_multiple_dtmis_with_extends_deps_single_dtmi(self): + if self.client_type == REMOTE_REPO: + self.skipTest("Insufficient data") + root_dtmi1 = "dtmi:com:example:TemperatureController;1" + root_dtmi2 = "dtmi:com:example:ConferenceRoom;1" + expected_deps = [ + "dtmi:com:example:Thermostat;1", + "dtmi:azure:DeviceManagement:DeviceInformation;1", + "dtmi:com:example:Room;1", + ] + expected_dtmis = [root_dtmi1, root_dtmi2] + expected_deps + model_map = self.client.get_models( + [root_dtmi1, root_dtmi2], dependency_resolution=DEPENDENCY_MODE_ENABLED + ) + + self.assertTrue(len(model_map) == len(expected_dtmis)) + for dtmi in expected_dtmis: + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_multiple_dtmis_with_extends_deps_multiple_dtmi(self): + if self.client_type == REMOTE_REPO: + self.skipTest("Insufficient data") + root_dtmi1 = "dtmi:com:example:TemperatureController;1" + root_dtmi2 = "dtmi:com:example:ColdStorage;1" + expected_deps = [ + "dtmi:com:example:Thermostat;1", + "dtmi:azure:DeviceManagement:DeviceInformation;1", + "dtmi:com:example:Room;1", + "dtmi:com:example:Freezer;1", + ] + expected_dtmis = [root_dtmi1, root_dtmi2] + expected_deps + model_map = self.client.get_models( + [root_dtmi1, root_dtmi2], dependency_resolution=DEPENDENCY_MODE_ENABLED + ) + + self.assertTrue(len(model_map) == len(expected_dtmis)) + for dtmi in expected_dtmis: + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_single_dtmi_with_extends_single_model_inline(self): + if self.client_type == REMOTE_REPO: + self.skipTest("Insufficient data") + dtmi = "dtmi:com:example:base;1" + model_map = self.client.get_models(dtmi, dependency_resolution=DEPENDENCY_MODE_ENABLED) + + self.assertTrue(len(model_map) == 1) + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_single_dtmi_with_extends_mixed_inline_and_dtmi(self): + if self.client_type == REMOTE_REPO: + self.skipTest("Insufficient data") + root_dtmi = "dtmi:com:example:base;2" + expected_deps = ["dtmi:com:example:Freezer;1", "dtmi:com:example:Thermostat;1"] + expected_dtmis = [root_dtmi] + expected_deps + model_map = self.client.get_models(root_dtmi, dependency_resolution=DEPENDENCY_MODE_ENABLED) + + self.assertTrue(len(model_map) == len(expected_dtmis)) + for dtmi in expected_dtmis: + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_duplicate_dtmi(self): + dtmi1 = "dtmi:azure:DeviceManagement:DeviceInformation;1" + dtmi2 = "dtmi:azure:DeviceManagement:DeviceInformation;1" + model_map = self.client.get_models( + [dtmi1, dtmi1], dependency_resolution=DEPENDENCY_MODE_ENABLED + ) + + self.assertTrue(len(model_map) == 1) + self.assertTrue(dtmi1 in model_map.keys()) + self.assertTrue(dtmi2 in model_map.keys()) + model = model_map[dtmi1] + self.assertTrue(model["@id"] == dtmi1 == dtmi2) + + +class GetModelsDependencyModeDisabledIntegrationTestCaseMixin(object): + def test_dtmi_mismatch_casing(self): + dtmi = "dtmi:com:example:thermostat;1" + with self.assertRaises(ModelError): + self.client.get_models(dtmi, dependency_resolution=DEPENDENCY_MODE_DISABLED) + + @parameterized.expand( + [ + ("No semicolon", "dtmi:com:example:Thermostat:1"), + ("Double colon", "dtmi:com:example::Thermostat;1"), + ("No DTMI prefix", "com:example:Thermostat;1"), + ] + ) + def test_invalid_dtmi_format(self, _, dtmi): + with self.assertRaises(ValueError): + self.client.get_models(dtmi, dependency_resolution=DEPENDENCY_MODE_DISABLED) + + def test_nonexistant_dtdl_doc(self): + dtmi = "dtmi:com:example:thermojax;999" + with self.assertRaises(ResourceNotFoundError): + self.client.get_models(dtmi) + + def test_nonexistent_dependency_dtdl_doc(self): + dtmi = "dtmi:com:example:invalidmodel;1" + with self.assertRaises(ResourceNotFoundError): + self.client.get_models(dtmi) + + def test_single_dtmi_no_components_no_extends(self): + dtmi = "dtmi:com:example:Thermostat;1" + model_map = self.client.get_models(dtmi, dependency_resolution=DEPENDENCY_MODE_DISABLED) + + self.assertTrue(len(model_map) == 1) + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_multiple_dtmis_no_components_no_extends(self): + dtmi1 = "dtmi:com:example:Thermostat;1" + dtmi2 = "dtmi:azure:DeviceManagement:DeviceInformation;1" + model_map = self.client.get_models( + [dtmi1, dtmi2], dependency_resolution=DEPENDENCY_MODE_DISABLED + ) + + self.assertTrue(len(model_map) == 2) + self.assertTrue(dtmi1 in model_map.keys()) + self.assertTrue(dtmi2 in model_map.keys()) + model1 = model_map[dtmi1] + model2 = model_map[dtmi2] + self.assertTrue(model1["@id"] == dtmi1) + self.assertTrue(model2["@id"] == dtmi2) + + def test_single_dtmi_with_component_deps(self): + dtmi = "dtmi:com:example:TemperatureController;1" + model_map = self.client.get_models(dtmi, dependency_resolution=DEPENDENCY_MODE_DISABLED) + + self.assertTrue(len(model_map) == 1) + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_multiple_dtmis_with_component_deps(self): + if self.client_type == REMOTE_REPO: + self.skipTest("Insufficient data") + dtmi1 = "dtmi:com:example:Phone;2" + dtmi2 = "dtmi:com:example:TemperatureController;1" + model_map = self.client.get_models( + [dtmi1, dtmi2], dependency_resolution=DEPENDENCY_MODE_DISABLED + ) + + self.assertTrue(len(model_map) == 2) + self.assertTrue(dtmi1 in model_map.keys()) + self.assertTrue(dtmi2 in model_map.keys()) + model1 = model_map[dtmi1] + model2 = model_map[dtmi2] + self.assertTrue(model1["@id"] == dtmi1) + self.assertTrue(model2["@id"] == dtmi2) + + def test_multiple_dtmis_with_extends_deps_single_dtmi(self): + if self.client_type == REMOTE_REPO: + self.skipTest("Insufficient data") + dtmi1 = "dtmi:com:example:TemperatureController;1" + dtmi2 = "dtmi:com:example:ConferenceRoom;1" + model_map = self.client.get_models( + [dtmi1, dtmi2], dependency_resolution=DEPENDENCY_MODE_DISABLED + ) + + self.assertTrue(len(model_map) == 2) + self.assertTrue(dtmi1 in model_map.keys()) + self.assertTrue(dtmi2 in model_map.keys()) + model1 = model_map[dtmi1] + model2 = model_map[dtmi2] + self.assertTrue(model1["@id"] == dtmi1) + self.assertTrue(model2["@id"] == dtmi2) + + def test_multiple_dtmis_with_extends_deps_multiple_dtmi(self): + if self.client_type == REMOTE_REPO: + self.skipTest("Insufficient data") + dtmi1 = "dtmi:com:example:TemperatureController;1" + dtmi2 = "dtmi:com:example:ColdStorage;1" + model_map = self.client.get_models( + [dtmi1, dtmi2], dependency_resolution=DEPENDENCY_MODE_DISABLED + ) + + self.assertTrue(len(model_map) == 2) + self.assertTrue(dtmi1 in model_map.keys()) + self.assertTrue(dtmi2 in model_map.keys()) + model1 = model_map[dtmi1] + model2 = model_map[dtmi2] + self.assertTrue(model1["@id"] == dtmi1) + self.assertTrue(model2["@id"] == dtmi2) + + def test_single_dtmi_with_extends_single_model_inline(self): + if self.client_type == REMOTE_REPO: + self.skipTest("Insufficient data") + dtmi = "dtmi:com:example:base;1" + model_map = self.client.get_models(dtmi, dependency_resolution=DEPENDENCY_MODE_DISABLED) + + self.assertTrue(len(model_map) == 1) + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_single_dtmi_with_extends_mixed_inline_and_dtmi(self): + if self.client_type == REMOTE_REPO: + self.skipTest("Insufficient data") + dtmi = "dtmi:com:example:base;2" + model_map = self.client.get_models(dtmi, dependency_resolution=DEPENDENCY_MODE_DISABLED) + + self.assertTrue(len(model_map) == 1) + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_duplicate_dtmi(self): + dtmi1 = "dtmi:azure:DeviceManagement:DeviceInformation;1" + dtmi2 = "dtmi:azure:DeviceManagement:DeviceInformation;1" + model_map = self.client.get_models( + [dtmi1, dtmi1], dependency_resolution=DEPENDENCY_MODE_DISABLED + ) + + self.assertTrue(len(model_map) == 1) + self.assertTrue(dtmi1 in model_map.keys()) + self.assertTrue(dtmi2 in model_map.keys()) + model = model_map[dtmi1] + self.assertTrue(model["@id"] == dtmi1 == dtmi2) + + +class GetModelsDependencyModeTryFromExpandedIntegrationTestCaseMixin(object): + def test_dtmi_mismatch_casing(self): + dtmi = "dtmi:com:example:thermostat;1" + with self.assertRaises(ModelError): + self.client.get_models(dtmi, dependency_resolution=DEPENDENCY_MODE_ENABLED) + + @parameterized.expand( + [ + ("No semicolon", "dtmi:com:example:Thermostat:1"), + ("Double colon", "dtmi:com:example::Thermostat;1"), + ("No DTMI prefix", "com:example:Thermostat;1"), + ] + ) + def test_invalid_dtmi_format(self, _, dtmi): + with self.assertRaises(ValueError): + self.client.get_models(dtmi, dependency_resolution=DEPENDENCY_MODE_ENABLED) + + def test_nonexistant_dtdl_doc(self): + dtmi = "dtmi:com:example:thermojax;999" + with self.assertRaises(ResourceNotFoundError): + self.client.get_models(dtmi) + + def test_single_dtmi_with_component_deps_expanded_json(self): + root_dtmi = "dtmi:com:example:TemperatureController;1" + expected_deps = [ + "dtmi:com:example:Thermostat;1", + "dtmi:azure:DeviceManagement:DeviceInformation;1", + ] + expected_dtmis = [root_dtmi] + expected_deps + model_map = self.client.get_models( + root_dtmi, dependency_resolution=DEPENDENCY_MODE_TRY_FROM_EXPANDED + ) + + self.assertTrue(len(model_map) == len(expected_dtmis)) + for dtmi in expected_dtmis: + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_single_dtmi_with_component_deps_no_expanded_json(self): + if self.client_type == REMOTE_REPO: + self.skipTest("Insufficient data") + # this tests the fallback procedure when no expanded doc exists + root_dtmi = "dtmi:com:example:Phone;2" + expected_deps = [ + "dtmi:azure:DeviceManagement:DeviceInformation;1", + "dtmi:azure:DeviceManagement:DeviceInformation;2", + "dtmi:com:example:Camera;3", + ] + expected_dtmis = [root_dtmi] + expected_deps + model_map = self.client.get_models( + root_dtmi, dependency_resolution=DEPENDENCY_MODE_TRY_FROM_EXPANDED + ) + + self.assertTrue(len(model_map) == len(expected_dtmis)) + for dtmi in expected_dtmis: + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_multiple_dtmis_with_component_deps_no_expanded_json(self): + if self.client_type == REMOTE_REPO: + self.skipTest("Insufficient data") + # this tests the fallback procedure when no expanded doc exists + root_dtmi1 = "dtmi:com:example:Phone;2" + root_dtmi2 = "dtmi:com:example:Freezer;1" + expected_deps = [ + "dtmi:azure:DeviceManagement:DeviceInformation;1", + "dtmi:azure:DeviceManagement:DeviceInformation;2", + "dtmi:com:example:Camera;3", + "dtmi:com:example:Thermostat;1", + ] + expected_dtmis = [root_dtmi1, root_dtmi2] + expected_deps + model_map = self.client.get_models( + [root_dtmi1, root_dtmi2], dependency_resolution=DEPENDENCY_MODE_TRY_FROM_EXPANDED + ) + + self.assertTrue(len(model_map) == len(expected_dtmis)) + for dtmi in expected_dtmis: + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_multiple_dtmis_with_component_deps_extends_deps_mixed_expanded_json(self): + if self.client_type == REMOTE_REPO: + self.skipTest("Insufficient data") + root_dtmi1 = "dtmi:com:example:ColdStorage;1" # no expanded doc + root_dtmi2 = "dtmi:com:example:TemperatureController;1" # has expanded doc + expected_deps = [ + "dtmi:com:example:Thermostat;1", + "dtmi:azure:DeviceManagement:DeviceInformation;1", + "dtmi:com:example:Room;1", + "dtmi:com:example:Freezer;1", + ] + expected_dtmis = [root_dtmi1, root_dtmi2] + expected_deps + model_map = self.client.get_models( + [root_dtmi1, root_dtmi2], dependency_resolution=DEPENDENCY_MODE_ENABLED + ) + + self.assertTrue(len(model_map) == len(expected_dtmis)) + for dtmi in expected_dtmis: + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + def test_dangling_expanded(self): + if self.client_type == REMOTE_REPO: + self.skipTest("Insufficient data") + root_dtmi = "dtmi:com:example:DanglingExpanded;1" + expected_deps = [ + "dtmi:com:example:Thermostat;1", + "dtmi:azure:DeviceManagement:DeviceInformation;1", + ] + expected_dtmis = [root_dtmi] + expected_deps + model_map = self.client.get_models( + root_dtmi, dependency_resolution=DEPENDENCY_MODE_TRY_FROM_EXPANDED + ) + + self.assertTrue(len(model_map) == len(expected_dtmis)) + for dtmi in expected_dtmis: + self.assertTrue(dtmi in model_map.keys()) + model = model_map[dtmi] + self.assertTrue(model["@id"] == dtmi) + + +####################### +# Actual Test Classes # +####################### + + +class TestIntegrationGetModelsDependencyModeEnabledLocalRepository( + GetModelsDependencyModeEnabledIntegrationTestCaseMixin, LocalRepositoryMixin, AzureTestCase +): + pass + + +class TestIntegrationGetModelsDependencyModeDisabledLocalRepository( + GetModelsDependencyModeDisabledIntegrationTestCaseMixin, LocalRepositoryMixin, AzureTestCase +): + pass + + +class TestIntegrationGetModelsDependencyModeTryFromExpandedLocalRepository( + GetModelsDependencyModeTryFromExpandedIntegrationTestCaseMixin, + LocalRepositoryMixin, + AzureTestCase, +): + pass + + +class TestIntegrationGetModelsDependencyModeEnabledRemoteRepository( + GetModelsDependencyModeEnabledIntegrationTestCaseMixin, RemoteRepositoryMixin, AzureTestCase +): + pass + + +class TestIntegrationGetModelsDependencyModeDisabledRemoteRepository( + GetModelsDependencyModeDisabledIntegrationTestCaseMixin, RemoteRepositoryMixin, AzureTestCase +): + pass + + +class TestIntegrationGetModelsDependencyModeTryFromExpandedRemoteRepository( + GetModelsDependencyModeTryFromExpandedIntegrationTestCaseMixin, + RemoteRepositoryMixin, + AzureTestCase, +): + pass diff --git a/sdk/modelsrepository/ci.yml b/sdk/modelsrepository/ci.yml new file mode 100644 index 000000000000..c1c314fe102f --- /dev/null +++ b/sdk/modelsrepository/ci.yml @@ -0,0 +1,34 @@ +# NOTE: Please refer to https://aka.ms/azsdk/engsys/ci-yaml before editing this file. + +trigger: + branches: + include: + - master + - main + - hotfix/* + - release/* + - restapi* + paths: + include: + - sdk/modelsrepository/ + +pr: + branches: + include: + - master + - main + - feature/* + - hotfix/* + - release/* + - restapi* + paths: + include: + - sdk/modelsrepository/ + +extends: + template: ../../eng/pipelines/templates/stages/archetype-sdk-client.yml + parameters: + ServiceDirectory: modelsrepository + Artifacts: + - name: azure-iot-modelsrepository + safeName: azureiotmodelsrepository diff --git a/sdk/modelsrepository/tests.yml b/sdk/modelsrepository/tests.yml new file mode 100644 index 000000000000..ecb958f6d6e9 --- /dev/null +++ b/sdk/modelsrepository/tests.yml @@ -0,0 +1,12 @@ +trigger: none + +stages: + - template: ../../eng/pipelines/templates/stages/archetype-sdk-tests.yml + parameters: + AllocateResourceGroup: 'false' + BuildTargetingString: $(BuildTargetingString) + ServiceDirectory: modelsrepository + EnvVars: + TEST_MODE: 'RunLiveNoRecord' + AZURE_SKIP_LIVE_RECORDING: 'True' + AZURE_TEST_RUN_LIVE: 'true'