diff --git a/README.md b/README.md index 4f139c246e6435a3d801dd7b62ab66c6672c946e..dad70b00d8235112f3cc2a260b6875df3d26868e 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ PyI40AAS requires the following Python packages to be installed for production u `setup.py` to be fetched automatically when installing with `pip`: * `python-dateutil` (BSD 3-clause License) * `lxml` (BSD 3-clause License, using `libxml2` under MIT License) +* `urllib3` (MIT License) * `pyecma376-2` (Apache License v2.0) Optional production usage dependencies: diff --git a/aas/backend/couchdb.py b/aas/backend/couchdb.py index 07a5cb0319bd26269154a395555d92557b619318..daf30ae7761b62e32bfffb27a9fee40ad6fc6cdd 100644 --- a/aas/backend/couchdb.py +++ b/aas/backend/couchdb.py @@ -13,14 +13,12 @@ Todo: Add module docstring """ import threading import weakref -from typing import List, Dict, Any, Optional, Iterator, Iterable, Union -import re +from typing import List, Dict, Any, Optional, Iterator, Iterable, Union, Tuple import urllib.parse -import urllib.request -import urllib.error import logging import json -import http.client + +import urllib3 # type: ignore from . import backends from aas.adapter.json import json_deserialization, json_serialization @@ -30,6 +28,9 @@ from aas import model logger = logging.getLogger(__name__) +_http_pool_manager = urllib3.PoolManager() + + class CouchDBBackend(backends.Backend): """ This Backend stores each Identifiable object as a single JSON document in the configured CouchDB database. Each @@ -39,18 +40,16 @@ class CouchDBBackend(backends.Backend): """ @classmethod def update_object(cls, - updated_object: "Referable", # type: ignore - store_object: "Referable", # type: ignore + updated_object: model.Referable, + store_object: model.Referable, relative_path: List[str]) -> None: if not isinstance(store_object, model.Identifiable): raise CouchDBSourceError("The given store_object is not Identifiable, therefore cannot be found " "in the CouchDB") url = CouchDBBackend._parse_source(store_object.source) - request = urllib.request.Request(url, - headers={'Accept': 'application/json'}) try: - data = CouchDBBackend.do_request(request) + data = CouchDBBackend.do_request(url) except CouchDBServerError as e: if e.code == 404: raise KeyError("No Identifiable found in CouchDB at {}".format(url)) from e @@ -62,8 +61,8 @@ class CouchDBBackend(backends.Backend): @classmethod def commit_object(cls, - committed_object: "Referable", # type: ignore - store_object: "Referable", # type: ignore + committed_object: model.Referable, + store_object: model.Referable, relative_path: List[str]) -> None: if not isinstance(store_object, model.Identifiable): raise CouchDBSourceError("The given store_object is not Identifiable, therefore cannot be found " @@ -75,13 +74,9 @@ class CouchDBBackend(backends.Backend): data = json.dumps({'data': store_object, "_rev": get_couchdb_revision(url)}, cls=json_serialization.AASToJsonEncoder) - request = urllib.request.Request( - url, - headers={'Content-type': 'application/json'}, - method='PUT', - data=data.encode()) try: - response = CouchDBBackend.do_request(request) + response = CouchDBBackend.do_request( + url, method='PUT', additional_headers={'Content-type': 'application/json'}, body=data.encode('utf-8')) set_couchdb_revision(url, response["rev"]) except CouchDBServerError as e: if e.code == 409: @@ -101,69 +96,82 @@ class CouchDBBackend(backends.Backend): :return: URL to the document :raises CouchDBBackendSourceError, if the source has the wrong format """ - couchdb_s = re.match("couchdbs://", source) # Note: Works, since match only checks the beginning of the string - if couchdb_s: + if source.startswith("couchdbs://"): url = source.replace("couchdbs://", "https://", 1) + elif source.startswith("couchdb://"): + url = source.replace("couchdb://", "http://", 1) else: - couchdb_wo_s = re.match("couchdb://", source) - if couchdb_wo_s: - url = source.replace("couchdb://", "http://", 1) - else: - raise CouchDBSourceError("Source has wrong format. " - "Expected to start with {couchdb://, couchdbs://}, got {" + source + "}") + raise CouchDBSourceError("Source has wrong format. " + "Expected to start with {couchdb://, couchdbs://}, got {" + source + "}") return url @classmethod - def do_request(cls, request: urllib.request.Request) -> Dict[str, Any]: + def do_request(cls, url: str, method: str = "GET", additional_headers: Dict[str, str] = {}, + body: Optional[bytes] = None) -> Dict[str, Any]: """ - Perform an HTTP request to the CouchDBServer, parse the result and handle errors - - :param request: - :return: + Perform an HTTP(S) request to the CouchDBServer, parse the result and handle errors + + :param url: The HTTP or HTTPS URL to request + :param method: The HTTP method for the request + :param additional_headers: Additional headers to insert into the request. The default headers include + 'connection: keep-alive', 'accept-encoding: ...', 'authorization: basic ...', 'Accept: ...'. + :param body: Request body for POST, PUT, and PATCH requests + :return: The parsed JSON data if the request `method` is other than 'HEAD' or the response headers for 'HEAD' + requests """ - opener = urllib.request.build_opener(urllib.request.HTTPBasicAuthHandler(_credentials_store)) + url_parts = urllib.parse.urlparse(url) + host = url_parts.scheme + url_parts.netloc + auth = _credentials_store.get(host) + headers = urllib3.make_headers(keep_alive=True, accept_encoding=True, + basic_auth="{}:{}".format(*auth) if auth else None) + headers['Accept'] = 'application/json' + headers.update(additional_headers) + try: - response = opener.open(request) - except urllib.error.HTTPError as e: - with e: # close the reponse (socket) when done - logger.debug("Request %s %s finished with HTTP status code %s.", - request.get_method(), request.full_url, e.code) - if e.headers.get('Content-type', None) != 'application/json': - raise CouchDBResponseError("Unexpected Content-type header {} of response from CouchDB server" - .format(e.headers.get('Content-type', None))) - - if request.get_method() == 'HEAD': - raise CouchDBServerError(e.code, "", "", "HTTP {}") from e - - try: - data = json.load(e) - except json.JSONDecodeError: - raise CouchDBResponseError("Could not parse error message of HTTP {}" - .format(e.code)) - raise CouchDBServerError(e.code, data['error'], data['reason'], - "HTTP {}: {} (reason: {})".format(e.code, data['error'], data['reason']))\ - from e - except urllib.error.URLError as e: + response = _http_pool_manager.request(method, url, headers=headers, body=body) + except (urllib3.exceptions.TimeoutError, urllib3.exceptions.SSLError, urllib3.exceptions.ProtocolError) as e: raise CouchDBConnectionError("Error while connecting to the CouchDB server: {}".format(e)) from e + except urllib3.exceptions.HTTPError as e: + raise CouchDBResponseError("Error while connecting to the CouchDB server: {}".format(e)) from e + + if not (200 <= response.status < 300): + logger.debug("Request %s %s finished with HTTP status code %s.", + method, url, response.status) + if response.headers.get('Content-type', None) != 'application/json': + raise CouchDBResponseError("Unexpected Content-type header {} of response from CouchDB server" + .format(response.headers.get('Content-type', None))) + + if method == 'HEAD': + raise CouchDBServerError(response.status, "", "", "HTTP {}".format(response.status)) - # Check response & parse data - assert (isinstance(response, http.client.HTTPResponse)) - with response: # close the reponse (socket) when done - logger.debug("Request %s %s finished successfully.", request.get_method(), request.full_url) - if request.get_method() == 'HEAD': - return {} - - if response.getheader('Content-type') != 'application/json': - raise CouchDBResponseError("Unexpected Content-type header") try: - data = json.load(response, cls=json_deserialization.AASFromJsonDecoder) - except json.JSONDecodeError as e: - raise CouchDBResponseError("Could not parse CouchDB server response as JSON data.") from e - return data + data = json.loads(response.data.decode('utf-8')) + except json.JSONDecodeError: + raise CouchDBResponseError("Could not parse error message of HTTP {}" + .format(response.status)) + raise CouchDBServerError(response.status, data['error'], data['reason'], + "HTTP {}: {} (reason: {})".format(response.status, data['error'], data['reason'])) + + # Check response & parse data + logger.debug("Request %s %s finished successfully.", method, url) + if method == 'HEAD': + return response.headers + + if response.getheader('Content-type') != 'application/json': + raise CouchDBResponseError("Unexpected Content-type header") + try: + data = json.loads(response.data.decode('utf-8'), cls=json_deserialization.AASFromJsonDecoder) + except json.JSONDecodeError as e: + raise CouchDBResponseError("Could not parse CouchDB server response as JSON data.") from e + return data + + +backends.register_backend("couchdb", CouchDBBackend) +backends.register_backend("couchdbs", CouchDBBackend) # Global registry for credentials for CouchDB Servers -_credentials_store: urllib.request.HTTPPasswordMgrWithPriorAuth = urllib.request.HTTPPasswordMgrWithPriorAuth() +_credentials_store: Dict[str, Tuple[str, str]] = {} # Note: The HTTPPasswordMgr is not thread safe during writing, should be thread safe for reading only. @@ -178,7 +186,8 @@ def register_credentials(url: str, username: str, password: str): :param username: Username to that CouchDB instance :param password: Password to the Username """ - _credentials_store.add_password(None, url, username, password, is_authenticated=True) + url_parts = urllib.parse.urlparse(url) + _credentials_store[url_parts.scheme + url_parts.netloc] = (username, password) # Global registry for CouchDB Revisions @@ -253,12 +262,8 @@ class CouchDBObjectStore(model.AbstractObjectStore): :param create: If True and the database does not exist, try to create it :raises CouchDBError: If error occur during the request to the CouchDB server (see `_do_request()` for details) """ - request = urllib.request.Request( - "{}/{}".format(self.url, self.database_name), - headers={'Accept': 'application/json'}, - method='HEAD') try: - CouchDBBackend.do_request(request) + CouchDBBackend.do_request("{}/{}".format(self.url, self.database_name), 'HEAD') except CouchDBServerError as e: # If an HTTPError is raised, re-raise it, unless it is a 404 error and we are requested to create the # database @@ -270,11 +275,7 @@ class CouchDBObjectStore(model.AbstractObjectStore): # Create database logger.info("Creating CouchDB database %s/%s ...", self.url, self.database_name) - request = urllib.request.Request( - "{}/{}".format(self.url, self.database_name), - headers={'Accept': 'application/json'}, - method='PUT') - CouchDBBackend.do_request(request) + CouchDBBackend.do_request("{}/{}".format(self.url, self.database_name), 'PUT') def get_identifiable(self, identifier: Union[str, model.Identifier]) -> model.Identifiable: """ @@ -290,11 +291,9 @@ class CouchDBObjectStore(model.AbstractObjectStore): identifier = self._transform_id(identifier, False) # Create and issue HTTP request (raises HTTPError on status != 200) - request = urllib.request.Request( - "{}/{}/{}".format(self.url, self.database_name, urllib.parse.quote(identifier, safe='')), - headers={'Accept': 'application/json'}) try: - data = CouchDBBackend.do_request(request) + data = CouchDBBackend.do_request( + "{}/{}/{}".format(self.url, self.database_name, urllib.parse.quote(identifier, safe=''))) except CouchDBServerError as e: if e.code == 404: raise KeyError("No Identifiable with id {} found in CouchDB database".format(identifier)) from e @@ -335,13 +334,12 @@ class CouchDBObjectStore(model.AbstractObjectStore): data = json.dumps({'data': x}, cls=json_serialization.AASToJsonEncoder) # Create and issue HTTP request (raises HTTPError on status != 200) - request = urllib.request.Request( - "{}/{}/{}".format(self.url, self.database_name, self._transform_id(x.identification)), - headers={'Content-type': 'application/json'}, - method='PUT', - data=data.encode()) try: - response = CouchDBBackend.do_request(request) + response = CouchDBBackend.do_request( + "{}/{}/{}".format(self.url, self.database_name, self._transform_id(x.identification)), + 'PUT', + {'Content-type': 'application/json'}, + data.encode('utf-8')) set_couchdb_revision("{}/{}/{}".format(self.url, self.database_name, self._transform_id(x.identification)), response["rev"]) except CouchDBServerError as e: @@ -379,25 +377,19 @@ class CouchDBObjectStore(model.AbstractObjectStore): # ETag response header try: logger.debug("fetching the current object revision for deletion ...") - request = urllib.request.Request( - "{}/{}/{}".format(self.url, self.database_name, self._transform_id(x.identification)), - headers={'Accept': 'application/json'}, - method='HEAD') - opener = urllib.request.build_opener(urllib.request.HTTPBasicAuthHandler(_credentials_store)) - response = opener.open(request) - rev = response.getheader('ETag')[1:-1] - except urllib.error.HTTPError as e: + headers = CouchDBBackend.do_request( + "{}/{}/{}".format(self.url, self.database_name, self._transform_id(x.identification)), 'HEAD') + rev = headers['ETag'][1:-1] + except CouchDBServerError as e: if e.code == 404: raise KeyError("No AAS object with id {} exists in CouchDB database".format(x.identification))\ from e raise - request = urllib.request.Request( - "{}/{}/{}?rev={}".format(self.url, self.database_name, self._transform_id(x.identification), rev), - headers={'Content-type': 'application/json'}, - method='DELETE') try: - CouchDBBackend.do_request(request) + CouchDBBackend.do_request( + "{}/{}/{}?rev={}".format(self.url, self.database_name, self._transform_id(x.identification), rev), + 'DELETE') except CouchDBServerError as e: if e.code == 404: raise KeyError("No AAS object with id {} exists in CouchDB database".format(x.identification)) from e @@ -429,12 +421,9 @@ class CouchDBObjectStore(model.AbstractObjectStore): else: return False logger.debug("Checking existence of object with id %s in database ...", repr(x)) - request = urllib.request.Request( - "{}/{}/{}".format(self.url, self.database_name, self._transform_id(identifier)), - headers={'Accept': 'application/json'}, - method='HEAD') try: - CouchDBBackend.do_request(request) + CouchDBBackend.do_request( + "{}/{}/{}".format(self.url, self.database_name, self._transform_id(identifier)), 'HEAD') except CouchDBServerError as e: if e.code == 404: return False @@ -449,10 +438,7 @@ class CouchDBObjectStore(model.AbstractObjectStore): :raises CouchDBError: If error occur during the request to the CouchDB server (see `_do_request()` for details) """ logger.debug("Fetching number of documents from database ...") - request = urllib.request.Request( - "{}/{}".format(self.url, self.database_name), - headers={'Accept': 'application/json'}) - data = CouchDBBackend.do_request(request) + data = CouchDBBackend.do_request("{}/{}".format(self.url, self.database_name)) return data['doc_count'] def __iter__(self) -> Iterator[model.Identifiable]: @@ -477,10 +463,7 @@ class CouchDBObjectStore(model.AbstractObjectStore): # Fetch a list of all ids and construct Iterator object logger.debug("Creating iterator over objects in database ...") - request = urllib.request.Request( - "{}/{}/_all_docs".format(self.url, self.database_name), - headers={'Accept': 'application/json'}) - data = CouchDBBackend.do_request(request) + data = CouchDBBackend.do_request("{}/{}/_all_docs".format(self.url, self.database_name)) return CouchDBIdentifiableIterator(self, (row['id'] for row in data['rows'])) @staticmethod diff --git a/aas/examples/tutorial_backend_couchdb.py b/aas/examples/tutorial_backend_couchdb.py new file mode 100755 index 0000000000000000000000000000000000000000..5d71852ce090fb96a80de9211a6eed8d3b1a220d --- /dev/null +++ b/aas/examples/tutorial_backend_couchdb.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +# This work is licensed under a Creative Commons CCZero 1.0 Universal License. +# See http://creativecommons.org/publicdomain/zero/1.0/ for more information. +""" +Tutorial for storing Asset Administration Shells, Submodels and Assets in a CouchDB database server, using the +CouchDBObjectStore and CouchDB Backend. + +This tutorial also shows the usage of the commit()/update() mechanism for synchronizing objects with an external data +source. +""" + +from configparser import ConfigParser +from pathlib import Path + +import aas.examples.data.example_aas +import aas.backend.couchdb + +# To execute this tutorial, you'll need a running CouchDB server, including an empty database and a user account with +# access to that database. +# After installing CouchDB, you can use the CouchDB web interface "Fauxton" (typically at +# http://localhost:5984/_utils/) to create a new database. Optionally, you can create a new user by adding a document +# to the `_users` database with the following contents (notice that the username is required in two positions): +# +# {"_id": "org.couchdb.user:<your username>", +# "name": "<your username>", +# "password": "<your password>", +# "roles": [], +# "type": "user"} +# +# Afterwards you can add the new user to the set of "Members" of your new database (via the "Permissions" section in the +# user interface). Alternatively, you can use the admin credentials with PyI40AAS (see below). + +# Step by Step Guide: +# step 1: connecting to a CouchDB server +# step 2: storing objects in the CouchDBObjectStore +# step 3: updating objects from the CouchDB and committing changes + + +########################################## +# Step 1: Connecting to a CouchDB Server # +########################################## + +# Well, actually, connections to the CouchDB server are created by the CouchDB backend, as required. However, we need +# to provide the login credentials to the server for this to work. +# +# Here, we take the test configuration to work with PyI40AAS development environments. You should replace these with +# the url of your CouchDB server (typically http://localhost:5984), the name of the empty database, and the name and +# password of a CouchDB user account which is "member" of this database (see above). Alternatively, you can provide +# your CouchDB server's admin credentials. +config = ConfigParser() +config.read([Path(__file__).parent.parent.parent / 'test' / 'test_config.default.ini', + Path(__file__).parent.parent.parent / 'test' / 'test_config.ini']) + +couchdb_url = config['couchdb']['url'] +couchdb_database = config['couchdb']['database'] +couchdb_user = config['couchdb']['user'] +couchdb_password = config['couchdb']['password'] + + +# Provide the login credentials to the CouchDB backend. +# These credetials are used, whenever communication with this CouchDB server is required (either via the +# CouchDBObjectStore or via the update()/commit() backend. +aas.backend.couchdb.register_credentials(couchdb_url, couchdb_user, couchdb_password) + +# Now, we create a CouchDBObjectStore as an interface for managing the objects in the CouchDB server. +object_store = aas.backend.couchdb.CouchDBObjectStore(couchdb_url, couchdb_database) + + +##################################################### +# Step 2: Storing objects in the CouchDBObjectStore # +##################################################### + +# Create some example objects +example_submodel1 = aas.examples.data.example_aas.create_example_asset_identification_submodel() +example_submodel2 = aas.examples.data.example_aas.create_example_bill_of_material_submodel() + +# The CouchDBObjectStore behaves just like other ObjectStore implementations (see `tutorial_storage.py`). The objects +# are transferred to the CouchDB immediately. Additionally, the `source` attribute is set automatically, so update() and +# commit() will work automatically (see below). +object_store.add(example_submodel1) +object_store.add(example_submodel2) + + +################################################################### +# Step 3: Updating Objects from the CouchDB and Commiting Changes # +################################################################### + +# Since the CouchDBObjectStore has set the `source` attribute of our Submodel objects, we can now use update() and +# commit() to synchronize changes to these objects with the database. The `source` indicates (via its URI scheme) that +# the CouchDB backend is used for the synchronization and references the correct CouchDB server url and database. For +# this to work, we must make sure to `import aas.backend.couchdb` at least once in this Python application, so the +# CouchDB backend is loaded. + +# Fetch recent updates from the server +example_submodel1.update() + +# Make some changes to a Property within the submodel +prop = example_submodel1.get_referable('ManufacturerName') +assert(isinstance(prop, aas.model.Property)) + +prop.value = "RWTH Aachen" + +# Commit (upload) these changes to the CouchDB server +# We can simply call commit() on the Property object. It will check the `source` attribute of the object itself as well +# as the source attribute of all ancestors in the object hierarchy (including the Submodel) and commit the changes to +# all of these external data sources. +prop.commit() + + +############ +# Clean up # +############ + +# Let's delete the Submodels from the CouchDB to leave it in a clean state +object_store.discard(example_submodel1) +object_store.discard(example_submodel2) diff --git a/aas/examples/tutorial_storage.py b/aas/examples/tutorial_storage.py index f9d4f74d07abd4df19c09a57c2acf2bae1f08d08..2a924ab62f00f2b91f9c8fbca20484925266be03 100755 --- a/aas/examples/tutorial_storage.py +++ b/aas/examples/tutorial_storage.py @@ -67,9 +67,10 @@ aas = AssetAdministrationShell( # objects using a dict. # This may not be a suitable solution, if you need to manage large numbers of objects or objects must kept in a # persistent memory (i.e. on hard disk). In this case, you may chose the `CouchDBObjectStore` from -# `aas.adapter.couchdb` to use a CouchDB database server as persistent storage. Both ObjectStore implementations provide -# the same interface. Therefore, all the methods shown in this tutorial, can be realized with a CouchDBObjectStore as -# well. +# `aas.backends.couchdb` to use a CouchDB database server as persistent storage. Both ObjectStore implementations +# provide the same interface. In addition, the CouchDBObjectStores allows synchronizing the local object with the +# database via a Backend and the update()/commit() mechanism. See the `tutorial_backend_couchdb.py` for more +# information. obj_store: model.DictObjectStore[model.Identifiable] = model.DictObjectStore() # step 2.2: add asset, submodel and asset administration shell to store diff --git a/requirements.txt b/requirements.txt index f547fa1516079d9953211bd7aa4fe799e3a448f0..4eb68b02cbb193a804c54acfe8cd2dabb1a6983c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ lxml>=4.2,<5 python-dateutil>=2.8,<3.0 pyecma376-2>=0.2.4 psutil>=5.6 +urllib3>=1.26<2.0 diff --git a/setup.py b/setup.py index a7f5bec1250b8b7190b727ce9f81a334b49fe6cb..ca637e77ba5ecf33d42a2c1af79e000304d7e84b 100755 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ setuptools.setup( install_requires=[ 'python-dateutil>=2.8,<3', 'lxml>=4.2,<5', + 'urllib3>=1.26<2.0', 'pyecma376-2>=0.2.4', ] ) diff --git a/test/_helper/test_helpers.py b/test/_helper/test_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..24be3bc7f6f7b00dbb6a34f50011c653ad6fdd3e --- /dev/null +++ b/test/_helper/test_helpers.py @@ -0,0 +1,27 @@ +import configparser +import os.path +import urllib.request +import urllib.error +import base64 + +TEST_CONFIG = configparser.ConfigParser() +TEST_CONFIG.read((os.path.join(os.path.dirname(__file__), "..", "test_config.default.ini"), + os.path.join(os.path.dirname(__file__), "..", "test_config.ini"))) + + +# Check if CouchDB database is available. Otherwise, skip tests. +try: + request = urllib.request.Request( + "{}/{}".format(TEST_CONFIG['couchdb']['url'], TEST_CONFIG['couchdb']['database']), + headers={ + 'Authorization': 'Basic %s' % base64.b64encode( + ('%s:%s' % (TEST_CONFIG['couchdb']['user'], TEST_CONFIG['couchdb']['password'])) + .encode('ascii')).decode("ascii") + }, + method='HEAD') + urllib.request.urlopen(request) + COUCHDB_OKAY = True + COUCHDB_ERROR = None +except urllib.error.URLError as e: + COUCHDB_OKAY = False + COUCHDB_ERROR = e diff --git a/test/backend/test_backends.py b/test/backend/test_backends.py index 78b801c9fd369722ae425ad877e9bbcd2e7681c4..d1dfe8b2bbb525ee2cbb0c7300efff70d6221dd6 100644 --- a/test/backend/test_backends.py +++ b/test/backend/test_backends.py @@ -1,21 +1,29 @@ -from unittest import mock +from typing import List import unittest from aas.backend import backends +from aas.model import Referable + + +class ExampleBackend(backends.Backend): + @classmethod + def commit_object(cls, committed_object: Referable, store_object: Referable, relative_path: List[str]) -> None: + raise NotImplementedError("This is a mock") + + @classmethod + def update_object(cls, updated_object: Referable, store_object: Referable, relative_path: List[str]) -> None: + raise NotImplementedError("This is a mock") class BackendsTest(unittest.TestCase): def test_backend_store(self): - with mock.patch("aas.backend.backends.Backend") as mock_backend: - backends.register_backend("mockScheme", mock_backend) - self.assertEqual(backends.get_backend("mockScheme:x-test:test_backend"), mock_backend) - - backends._backends_map = {} - backends.register_backend("<this is totally a valid uri>", mock_backend) - with self.assertRaises(ValueError) as cm: - backends.get_backend("<this is totally a valid uri>") - self.assertEqual("<this is totally a valid uri> is not a valid URL with URI scheme.", str(cm.exception)) + backends.register_backend("mockScheme", ExampleBackend) + self.assertIs(backends.get_backend("mockScheme:x-test:test_backend"), ExampleBackend) + backends.register_backend("<this is totally a valid uri>", ExampleBackend) + with self.assertRaises(ValueError) as cm: + backends.get_backend("<this is totally a valid uri>") + self.assertEqual("<this is totally a valid uri> is not a valid URL with URI scheme.", str(cm.exception)) -if __name__ == '__main__': - unittest.main() + with self.assertRaises(backends.UnknownBackendException): + backends.get_backend("some-unkown-scheme://example.com") diff --git a/test/backend/test_couchdb.py b/test/backend/test_couchdb.py index 13fb208f72e196d80a0e4ef3950987c6c8af1e5f..6fb40fd1fb86fc9fc55e0b54352964a5804ff8cf 100644 --- a/test/backend/test_couchdb.py +++ b/test/backend/test_couchdb.py @@ -8,45 +8,20 @@ # Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the # specific language governing permissions and limitations under the License. -import base64 -import configparser -import copy -import os import unittest import unittest.mock -import urllib.request import urllib.error -from aas.backend import backends, couchdb +from aas.backend import couchdb from aas.examples.data.example_aas import * +from test._helper.test_helpers import TEST_CONFIG, COUCHDB_OKAY, COUCHDB_ERROR -TEST_CONFIG = configparser.ConfigParser() -TEST_CONFIG.read((os.path.join(os.path.dirname(__file__), "..", "test_config.default.ini"), - os.path.join(os.path.dirname(__file__), "..", "test_config.ini"))) source_core: str = "couchdb://" + TEST_CONFIG["couchdb"]["url"].lstrip("http://") + "/" + \ TEST_CONFIG["couchdb"]["database"] + "/" -# Check if CouchDB database is available. Otherwise, skip tests. -try: - request = urllib.request.Request( - "{}/{}".format(TEST_CONFIG['couchdb']['url'], TEST_CONFIG['couchdb']['database']), - headers={ - 'Authorization': 'Basic %s' % base64.b64encode( - ('%s:%s' % (TEST_CONFIG['couchdb']['user'], TEST_CONFIG['couchdb']['password'])) - .encode('ascii')).decode("ascii") - }, - method='HEAD') - urllib.request.urlopen(request) - COUCHDB_OKAY = True - COUCHDB_ERROR = None -except urllib.error.URLError as e: - COUCHDB_OKAY = False - COUCHDB_ERROR = e - - class CouchDBBackendOfflineMethodsTest(unittest.TestCase): def test_parse_source(self): couchdb.register_credentials(url="couchdb.plt.rwth-aachen.de:5984", @@ -83,7 +58,6 @@ class CouchDBBackendTest(unittest.TestCase): couchdb.register_credentials(TEST_CONFIG["couchdb"]["url"], TEST_CONFIG["couchdb"]["user"], TEST_CONFIG["couchdb"]["password"]) - backends.register_backend("couchdb", couchdb.CouchDBBackend) self.object_store.check_database() def tearDown(self) -> None: diff --git a/test/examples/test_tutorials.py b/test/examples/test_tutorials.py index a9d692837f2a382b8dc4c9a4a02cf05a5147b964..a583c2513a4decad33ea18c61924feb65cf85b59 100644 --- a/test/examples/test_tutorials.py +++ b/test/examples/test_tutorials.py @@ -14,13 +14,12 @@ Tests for the tutorials Functions to test if a tutorial is executable """ import os -import re import tempfile import unittest from contextlib import contextmanager from aas import model -from aas.adapter.json import read_aas_json_file +from .._helper.test_helpers import COUCHDB_OKAY, TEST_CONFIG, COUCHDB_ERROR class TutorialTest(unittest.TestCase): @@ -34,6 +33,12 @@ class TutorialTest(unittest.TestCase): from aas.examples import tutorial_storage # The tutorial already includes assert statements for the relevant points. So no further checks are required. + @unittest.skipUnless(COUCHDB_OKAY, "No CouchDB is reachable at {}/{}: {}".format(TEST_CONFIG['couchdb']['url'], + TEST_CONFIG['couchdb']['database'], + COUCHDB_ERROR)) + def test_tutorial_backend_couchdb(self): + from aas.examples import tutorial_backend_couchdb + def test_tutorial_serialization_deserialization_json(self): with temporary_workingdirectory(): from aas.examples import tutorial_serialization_deserialization