test_utils.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. #
  2. # Copyright 2018-2022 Elyra Authors
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. import copy
  17. import errno
  18. import io
  19. import json
  20. import os
  21. from typing import Any
  22. from typing import Dict
  23. from typing import List
  24. from typing import Optional
  25. from traitlets.config import LoggingConfigurable
  26. from elyra.metadata.error import MetadataExistsError
  27. from elyra.metadata.error import MetadataNotFoundError
  28. from elyra.metadata.metadata import Metadata
  29. from elyra.metadata.schema import METADATA_TEST_SCHEMASPACE
  30. from elyra.metadata.schema import METADATA_TEST_SCHEMASPACE_ID
  31. from elyra.metadata.schema import Schemaspace
  32. from elyra.metadata.schema import SchemasProvider
  33. from elyra.metadata.storage import FileMetadataStore
  34. from elyra.metadata.storage import MetadataStore
  35. NON_EXISTENT_SCHEMASPACE_ID = "9ab68f6f-000c-470e-814d-2af59ea0956e"
  36. valid_metadata_json = {
  37. "schema_name": "metadata-test",
  38. "display_name": "valid metadata instance",
  39. "metadata": {
  40. "uri_test": "http://localhost:31823/v1/models?version=2017-02-13",
  41. "number_range_test": 8,
  42. "required_test": "required_value",
  43. },
  44. }
  45. valid_metadata2_json = {
  46. "schema_name": "metadata-test2",
  47. "display_name": "valid metadata2 instance",
  48. "metadata": {
  49. "uri_test": "http://localhost:31823/v1/models?version=2017-02-13",
  50. "number_range_test": 8,
  51. "required_test": "required_value",
  52. },
  53. }
  54. another_metadata_json = {
  55. "schema_name": "metadata-test",
  56. "name": "another_instance",
  57. "display_name": "Another Metadata Instance (2)",
  58. "metadata": {"uri_test": "http://localhost:8081/", "required_test": "required_value"},
  59. }
  60. invalid_metadata_json = {
  61. "schema_name": "metadata-test",
  62. "display_name": "Invalid Metadata Instance - bad uri",
  63. "metadata": {"uri_test": "//localhost:8081/", "required_test": "required_value"},
  64. }
  65. invalid_json = "{\
  66. 'schema_name': 'metadata-test',\
  67. 'display_name': 'Invalid Metadata Instance - missing comma'\
  68. 'metadata': {\
  69. 'uri_test': '//localhost:8081/',\
  70. 'required_test': 'required_value'\
  71. }\
  72. }"
  73. invalid_no_display_name_json = {
  74. "schema_name": "metadata-test",
  75. "metadata": {"uri_test": "//localhost:8081/", "required_test": "required_value"},
  76. }
  77. valid_display_name_json = {
  78. "schema_name": "metadata-test",
  79. "display_name": '1 teste "rápido"',
  80. "metadata": {"required_test": "required_value"},
  81. }
  82. invalid_schema_name_json = {
  83. "schema_name": "metadata-testxxx",
  84. "display_name": "invalid schema name",
  85. "metadata": {
  86. "uri_test": "http://localhost:31823/v1/models?version=2017-02-13",
  87. "number_range_test": 8,
  88. "required_test": "required_value",
  89. },
  90. }
  91. # Contains all values corresponding to test schema...
  92. complete_metadata_json = {
  93. "schema_name": "metadata-test",
  94. "display_name": "complete metadata instance",
  95. "metadata": {
  96. "required_test": "required_value",
  97. "uri_test": "http://localhost:31823/v1/models?version=2017-02-13",
  98. "integer_exclusivity_test": 7,
  99. "integer_multipleOf7_test": 42,
  100. "number_range_test": 8,
  101. # purposely missing "number_default_test": 42
  102. "const_test": 3.14,
  103. "string_length_test": "1234567",
  104. "enum_test": "rocks",
  105. "array_test": ["elyra", "rocks", "the", "world"],
  106. "object_test": {
  107. "property1": "first prop",
  108. "property2": "second_prop",
  109. "property3": "third prop",
  110. "property4": "fourth prop",
  111. },
  112. "boolean_test": True,
  113. "null_test": "null",
  114. },
  115. }
  116. # Minimal json to be built upon for each property test. Only
  117. # required values are specified.
  118. minimal_metadata_json = {
  119. "schema_name": "metadata-test",
  120. "display_name": "complete metadata instance",
  121. "metadata": {"required_test": "required_value"},
  122. }
  123. # Bring-your-own metadata template used to test hierarchical writes. The
  124. # display_name field will be updated to reflect the location's instance
  125. # in the hierarchy
  126. byo_metadata_json = {
  127. "schema_name": "metadata-test",
  128. "display_name": "location",
  129. "metadata": {"required_test": "required_value"},
  130. }
  131. # Used in test_install_and_replace_complex to test --file option
  132. one_of_json = {
  133. "schema_name": "metadata-test",
  134. "display_name": "oneOf Testing",
  135. "metadata": {
  136. "required_test": "required_value",
  137. "oneOf_test": {"obj2_prop1": 42, "obj2_prop2": 24, "obj_switch": "obj2"},
  138. },
  139. }
  140. # Used in test_install_and_replace_complex to test --allOf_test option (i.e., ovp option)
  141. all_of_json = {
  142. "obj1_prop1": "allOf-test-val1",
  143. "obj1_prop2": "allOf-test-val2",
  144. "obj1_switch": "obj1",
  145. "obj2_prop1": 42,
  146. "obj2_prop2": 24,
  147. "obj2_switch": "obj2",
  148. "obj3_prop1": 42.7,
  149. "obj3_prop2": True,
  150. "obj3_switch": "obj3",
  151. }
  152. def create_json_file(location: Any, file_name: str, content: Dict) -> str:
  153. return create_file(location, file_name, json.dumps(content))
  154. def create_file(location: Any, file_name: str, content: str) -> str:
  155. try:
  156. os.makedirs(location)
  157. except OSError as e:
  158. if e.errno != errno.EEXIST:
  159. raise
  160. resource = os.path.join(location, file_name)
  161. with open(resource, "w", encoding="utf-8") as f:
  162. f.write(content)
  163. return resource
  164. def create_instance(metadata_store: MetadataStore, location: str, name: str, content: Any) -> str:
  165. resource = name
  166. if isinstance(metadata_store, FileMetadataStore):
  167. if isinstance(content, dict):
  168. create_json_file(location, name + ".json", content)
  169. else:
  170. create_file(location, name + ".json", content)
  171. resource = os.path.join(location, name + ".json")
  172. elif isinstance(metadata_store, MockMetadataStore):
  173. instances = metadata_store.instances
  174. if instances is None:
  175. setattr(metadata_store, "instances", dict())
  176. instances = metadata_store.instances
  177. if not isinstance(content, dict):
  178. content = {"display_name": name, "reason": f"JSON failed to load for instance '{name}'"}
  179. instances[name] = content
  180. return resource
  181. def get_instance(instances, field, value):
  182. """Given a list of instances (dicts), return the dictionary where field == value."""
  183. for inst in instances:
  184. if inst[field] == value:
  185. return inst
  186. assert False, f"Value '{value}' for field '{field}' was not found in instances!"
  187. class PropertyTester(object):
  188. """Helper class used by elyra_md tests to test each of the properties in the test.json schema."""
  189. name = None # prefixed with 'test_' is test name, post-fixed with '_test' is schema property name
  190. negative_res = False # expected success of first test
  191. negative_value = None # value to use in first test (usually negative test)
  192. negative_stdout = None # expected string to find in first test's stdout
  193. negative_stderr = None # expected string to find in second test's stdout
  194. positive_res = True # expected success of second test
  195. positive_value = None # value to use in second test (usually successful)
  196. def __init__(self, name):
  197. self.name = name
  198. self.property = name + "_test"
  199. def run(self, script_runner, mock_data_dir):
  200. expected_file = os.path.join(mock_data_dir, "metadata", METADATA_TEST_SCHEMASPACE, self.name + ".json")
  201. # Cleanup from any potential previous failures
  202. if os.path.exists(expected_file):
  203. os.remove(expected_file)
  204. # First test
  205. ret = script_runner.run(
  206. "elyra-metadata",
  207. "install",
  208. METADATA_TEST_SCHEMASPACE,
  209. "--schema_name=metadata-test",
  210. "--name=" + self.name,
  211. "--display_name=" + self.name,
  212. "--required_test=required_value",
  213. "--" + self.property + "=" + str(self.negative_value),
  214. )
  215. assert ret.success is self.negative_res
  216. assert self.negative_stdout in ret.stdout
  217. assert self.negative_stderr in ret.stderr
  218. # Second test
  219. ret = script_runner.run(
  220. "elyra-metadata",
  221. "install",
  222. METADATA_TEST_SCHEMASPACE,
  223. "--schema_name=metadata-test",
  224. "--name=" + self.name,
  225. "--display_name=" + self.name,
  226. "--required_test=required_value",
  227. "--" + self.property + "=" + str(self.positive_value),
  228. )
  229. assert ret.success is self.positive_res
  230. assert "Metadata instance '" + self.name + "' for schema 'metadata-test' has been written" in ret.stdout
  231. assert os.path.isdir(os.path.join(mock_data_dir, "metadata", METADATA_TEST_SCHEMASPACE))
  232. assert os.path.isfile(expected_file)
  233. with open(expected_file, "r") as fd:
  234. instance_json = json.load(fd)
  235. assert instance_json["schema_name"] == "metadata-test"
  236. assert instance_json["display_name"] == self.name
  237. assert instance_json["metadata"][self.property] == self.positive_value
  238. class MockMetadataStore(MetadataStore):
  239. """Hypothetical class used to demonstrate (and test) use of custom storage classes."""
  240. def __init__(self, schemaspace: str, **kwargs: Any) -> None:
  241. super().__init__(schemaspace, **kwargs)
  242. self.instances = None
  243. def schemaspace_exists(self) -> bool:
  244. """Returns True if the schemaspace for this instance exists"""
  245. return self.instances is not None
  246. def fetch_instances(self, name: Optional[str] = None, include_invalid: bool = False) -> List[dict]:
  247. """Fetch metadata instances"""
  248. if name:
  249. if self.instances is not None and name in self.instances:
  250. instance = self.instances.get(name)
  251. if instance.get("reason"):
  252. raise ValueError(instance.get("reason"))
  253. instance["name"] = name
  254. return [instance]
  255. raise MetadataNotFoundError(self.schemaspace, name)
  256. # all instances are wanted, filter based on include-invalid and reason ...
  257. instance_list = []
  258. if self.instances is not None:
  259. for name, instance in self.instances.items():
  260. if include_invalid or not instance.get("reason"):
  261. instance["name"] = name
  262. instance_list.append(instance)
  263. return instance_list
  264. def store_instance(self, name: str, metadata: dict, for_update: bool = False) -> dict:
  265. """Stores the named metadata instance."""
  266. try:
  267. instance = self.fetch_instances(name)
  268. if not for_update: # Create - already exists
  269. raise MetadataExistsError(self.schemaspace, instance[0].get("resource"))
  270. except MetadataNotFoundError as mnfe:
  271. if for_update: # Update - doesn't exist
  272. raise mnfe from mnfe
  273. if self.instances is None:
  274. self.instances = dict()
  275. self.instances[name] = metadata # persisted, now fetch
  276. instance = self.fetch_instances(name) # confirm persistence
  277. return instance[0]
  278. def delete_instance(self, metadata: dict) -> None:
  279. """Deletes the metadata instance."""
  280. name = metadata.get("name")
  281. self.instances.pop(name)
  282. class MockMetadataTest(Metadata):
  283. """Hypothetical class used to demonstrate (and test) use of custom instance classes.
  284. This class name is referenced in the metadata-test schema.
  285. """
  286. pre_property = None
  287. post_property = None
  288. def __init__(self, **kwargs: Any) -> None:
  289. super().__init__(**kwargs)
  290. self.pre_property = kwargs.get("pre_property")
  291. self.post_property = kwargs.get("post_property")
  292. def to_dict(self, trim: bool = False) -> dict:
  293. d = super().to_dict(trim=trim)
  294. if self.pre_property is not None:
  295. d["pre_property"] = self.pre_property
  296. if self.post_property is not None:
  297. d["post_property"] = self.post_property
  298. return d
  299. def on_load(self, **kwargs: Any) -> None:
  300. super().on_load(**kwargs)
  301. self.post_property = self.display_name
  302. def pre_save(self, **kwargs: Any) -> None:
  303. super().pre_save(**kwargs)
  304. self.pre_property = self.metadata["required_test"]
  305. def post_save(self, **kwargs: Any) -> None:
  306. super().post_save(**kwargs)
  307. self.post_property = self.display_name
  308. def pre_delete(self, **kwargs: Any) -> None:
  309. super().pre_delete(**kwargs)
  310. self.pre_property = self.metadata["required_test"]
  311. def post_delete(self, **kwargs: Any) -> None:
  312. super().post_delete(**kwargs)
  313. self.post_property = self.display_name
  314. class MockMetadataTestRollback(MockMetadataTest):
  315. """Used by metadata tests to validate rollback behaviors when post-save/delete hooks throw exceptions."""
  316. def post_save(self, **kwargs: Any) -> None:
  317. super().post_save(**kwargs)
  318. for_update = kwargs["for_update"]
  319. if os.getenv("METADATA_TEST_HOOK_OP", "skipped") == "create" and not for_update:
  320. raise NotImplementedError
  321. if os.getenv("METADATA_TEST_HOOK_OP", "skipped") == "update" and for_update:
  322. raise ModuleNotFoundError
  323. self.post_property = self.display_name
  324. def post_delete(self, **kwargs: Any) -> None:
  325. super().post_delete(**kwargs)
  326. if os.getenv("METADATA_TEST_HOOK_OP", "skipped") == "delete":
  327. raise FileNotFoundError
  328. self.post_property = self.display_name
  329. class MockMetadataTestInvalid(object):
  330. """Invalid metadata instance class that doesn't derive from Metadata.
  331. This requires an update to schema and is only used for manual testing.
  332. """
  333. def __init__(self, **kwargs: Any) -> None:
  334. pass
  335. class MetadataTestSchemaspace(Schemaspace):
  336. def __init__(self, *args, **kwargs):
  337. super().__init__(
  338. schemaspace_id=METADATA_TEST_SCHEMASPACE_ID,
  339. name=METADATA_TEST_SCHEMASPACE,
  340. description="Schemaspace for instances of metadata for testing",
  341. **kwargs,
  342. )
  343. class BYOSchemaspaceBadId(Schemaspace):
  344. def __init__(self, *args, **kwargs):
  345. super().__init__(schemaspace_id="byo_schemaspace_bad_id", name="byo-schemaspace-bad-id", **kwargs)
  346. class BYOSchemaspaceBadName(Schemaspace):
  347. def __init__(self, *args, **kwargs):
  348. super().__init__(
  349. schemaspace_id="b5b391d7-24f5-4b62-93bb-5e5423e651b8", name="byo.schemaspace-bad.name", **kwargs
  350. )
  351. class BYOSchemaspaceBadClass(LoggingConfigurable):
  352. """Class is not a subclass of Schemaspace"""
  353. def __init__(self, **kwargs):
  354. super().__init__(**kwargs)
  355. class BYOSchemaspaceCaseSensitiveName(Schemaspace):
  356. def __init__(self, *args, **kwargs):
  357. super().__init__(
  358. schemaspace_id="1b1e461a-c7fa-40f2-a3a3-bf1f2fd48eeA", name="byo-schemaspace_CaseSensitiveName", **kwargs
  359. )
  360. class BYOSchemaspaceThrows(Schemaspace):
  361. BYO_SCHEMASPACE_ID = "20c98d38-36f6-4f05-a4dc-9b0a6c2cb734"
  362. BYO_SCHEMASPACE_NAME = "byo-schemaspace-throws"
  363. def __init__(self, *args, **kwargs):
  364. super().__init__(
  365. schemaspace_id=BYOSchemaspace.BYO_SCHEMASPACE_ID, name=BYOSchemaspace.BYO_SCHEMASPACE_NAME, **kwargs
  366. )
  367. raise NotImplementedError("Test that throw from constructor is not harmful.")
  368. class BYOSchemaspace(Schemaspace):
  369. BYO_SCHEMASPACE_ID = "20c98d38-36f6-4f05-a4dc-9b0a6c2cb733"
  370. BYO_SCHEMASPACE_NAME = "byo-schemaspace"
  371. def __init__(self, *args, **kwargs):
  372. super().__init__(
  373. schemaspace_id=BYOSchemaspace.BYO_SCHEMASPACE_ID, name=BYOSchemaspace.BYO_SCHEMASPACE_NAME, **kwargs
  374. )
  375. class MetadataTestSchemasProvider(SchemasProvider):
  376. """Returns schemas relative to Runtime Images schemaspace."""
  377. def get_schemas(self) -> List[Dict]:
  378. schemas = []
  379. parent_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
  380. schema_dir = os.path.join(parent_dir, "metadata", "schemas")
  381. schema_files = [
  382. json_file
  383. for json_file in os.listdir(schema_dir)
  384. if json_file.endswith(".json") and json_file.startswith("metadata-test")
  385. ]
  386. for json_file in schema_files:
  387. schema_file = os.path.join(schema_dir, json_file)
  388. with io.open(schema_file, "r", encoding="utf-8") as f:
  389. schema_json = json.load(f)
  390. if json_file == "metadata-test.json": # Apply filtering
  391. # Update multipleOf from 7 to 6 and and value 'added' to enum-valued property
  392. multiple_of: int = schema_json["properties"]["metadata"]["properties"]["integer_multiple_test"][
  393. "multipleOf"
  394. ]
  395. assert multiple_of == 7
  396. schema_json["properties"]["metadata"]["properties"]["integer_multiple_test"]["multipleOf"] = 6
  397. enum: list = schema_json["properties"]["metadata"]["properties"]["enum_test"]["enum"]
  398. assert len(enum) == 2
  399. enum.append("added")
  400. schema_json["properties"]["metadata"]["properties"]["enum_test"]["enum"] = enum
  401. schemas.append(schema_json)
  402. return schemas
  403. def schema_factory(schemaspace_id: str, schemaspace_name: str, num_good: int, bad_reasons: List[str]) -> List[Dict]:
  404. # get the metadata test schema as a primary copy
  405. parent_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
  406. schema_file = os.path.join(parent_dir, "metadata", "schemas", "metadata-test.json")
  407. with io.open(schema_file, "r", encoding="utf-8") as f:
  408. primary_schema = json.load(f)
  409. def create_base_schema(primary: Dict, tag: str, ss_name: str, ss_id: str) -> Dict:
  410. base_schema: Dict = copy.deepcopy(primary)
  411. base_schema["title"] = f"BYO Test {tag}"
  412. base_schema["name"] = f"byo-test-{tag}"
  413. base_schema["display_name"] = base_schema["title"]
  414. base_schema["schemaspace"] = ss_name
  415. base_schema["schemaspace_id"] = ss_id
  416. base_schema["properties"]["schema_name"]["const"] = base_schema["name"]
  417. base_schema.pop("metadata_class_name")
  418. return base_schema
  419. schemas = []
  420. # Gather bad schemas
  421. for reason in bad_reasons:
  422. schema = create_base_schema(primary_schema, reason, schemaspace_name, schemaspace_id)
  423. if reason == "missing_required": # remove display_name
  424. schema["properties"].pop("display_name") # This will trigger a validation error
  425. elif reason == "unknown_schemaspace": # update schemaspace_id to a non-existent schemaspace
  426. schema["schemaspace_id"] = NON_EXISTENT_SCHEMASPACE_ID
  427. schemas.append(schema)
  428. # Gather good schemas
  429. for i in range(num_good):
  430. schemas.append(create_base_schema(primary_schema, str(i), schemaspace_name, schemaspace_id))
  431. return schemas
  432. class BYOSchemasProvider(SchemasProvider):
  433. """Test SchemasProvider that loads the metadata-test schema and adjusts its values to match BYOSchemaspace."""
  434. def get_schemas(self) -> List[Dict]:
  435. # We'll create 2 good schemas and 2 bad schemas for BYOSchemaspace
  436. schemas = schema_factory(
  437. BYOSchemaspace.BYO_SCHEMASPACE_ID,
  438. BYOSchemaspace.BYO_SCHEMASPACE_NAME,
  439. 2,
  440. ["missing_required", "unknown_schemaspace"],
  441. )
  442. return schemas
  443. class BYOSchemasProviderThrows(SchemasProvider):
  444. """Test SchemasProvider that raises an exception to ensure the exception doesn't mess things up."""
  445. def get_schemas(self) -> List[Dict]:
  446. raise ModuleNotFoundError("Exception to ensure bad providers are not side-effecting.")
  447. class BYOSchemasProviderBadClass(object):
  448. """Test SchemasProvider that is of the wrong subclass."""
  449. def get_schemas(self) -> List[Dict]:
  450. schemas = schema_factory(BYOSchemaspace.BYO_SCHEMASPACE_ID, BYOSchemaspace.BYO_SCHEMASPACE_NAME, 2, [])
  451. return schemas