diff --git a/core/correctness/validation.py b/core/correctness/validation.py index 6e97ffd..c0d89ff 100644 --- a/core/correctness/validation.py +++ b/core/correctness/validation.py @@ -1,4 +1,5 @@ +from abc import ABCMeta from typing import Any, _SpecialForm def check_input(variable:Any, expected_type:type, alt_types:list[type]=[], @@ -70,14 +71,18 @@ def valid_string(variable:str, valid_chars:str, min_length:int=1)->None: def valid_dict(variable:dict[Any, Any], key_type:type, value_type:type, required_keys:list[Any]=[], optional_keys:list[Any]=[], - strict:bool=True)->None: + strict:bool=True, min_length:int=1)->None: check_input(variable, dict) - check_input(key_type, type, alt_types=[_SpecialForm]) - check_input(value_type, type, alt_types=[_SpecialForm]) + check_input(key_type, type, alt_types=[_SpecialForm, ABCMeta]) + check_input(value_type, type, alt_types=[_SpecialForm, ABCMeta]) check_input(required_keys, list) check_input(optional_keys, list) check_input(strict, bool) + if len(variable) < min_length: + raise ValueError(f"Dictionary '{variable}' is below minimum length of " + f"{min_length}") + for k, v in variable.items(): if key_type != Any and not isinstance(k, key_type): raise TypeError(f"Key {k} had unexpected type '{type(k)}' " @@ -96,3 +101,12 @@ def valid_dict(variable:dict[Any, Any], key_type:type, value_type:type, if k not in required_keys and k not in optional_keys: raise ValueError(f"Unexpected key '{k}' should not be present " f"in dict '{variable}'") + +def valid_list(variable:list[Any], entry_type:type, + alt_types:list[type]=[], min_length:int=1)->None: + check_input(variable, list) + if len(variable) < min_length: + raise ValueError(f"List '{variable}' is too short. Should be at least " + f"of length {min_length}") + for entry in variable: + check_input(entry, entry_type, alt_types=alt_types) diff --git a/core/functionality.py b/core/functionality.py new file mode 100644 index 0000000..124d7af --- /dev/null +++ b/core/functionality.py @@ -0,0 +1,78 @@ + +import sys +import inspect + +from typing import Union +from random import SystemRandom + +from core.meow import BasePattern, BaseRecipe, BaseRule +from core.correctness.validation import check_input, valid_dict, valid_list +from core.correctness.vars import CHAR_LOWERCASE, CHAR_UPPERCASE +from patterns import * +from recipes import * +from rules import * + +def check_pattern_dict(patterns, min_length=1): + valid_dict(patterns, str, BasePattern, strict=False, min_length=min_length) + for k, v in patterns.items(): + if k != v.name: + raise KeyError(f"Key '{k}' indexes unexpected Pattern '{v.name}' " + "Pattern dictionaries must be keyed with the name of the " + "Pattern.") + +def check_recipe_dict(recipes, min_length=1): + valid_dict(recipes, str, BaseRecipe, strict=False, min_length=min_length) + for k, v in recipes.items(): + if k != v.name: + raise KeyError(f"Key '{k}' indexes unexpected Recipe '{v.name}' " + "Recipe dictionaries must be keyed with the name of the " + "Recipe.") + +def generate_id(prefix:str="", length:int=16, existing_ids:list[str]=[], + charset:str=CHAR_UPPERCASE+CHAR_LOWERCASE, attempts:int=24): + random_length = max(length - len(prefix), 0) + for _ in range(attempts): + id = prefix + ''.join(SystemRandom().choice(charset) + for _ in range(random_length)) + if id not in existing_ids: + return id + raise ValueError(f"Could not generate ID unique from '{existing_ids}' " + f"using values '{charset}' and length of '{length}'.") + +def create_rules(patterns:Union[dict[str,BasePattern],list[BasePattern]], + recipes:Union[dict[str,BaseRecipe],list[BaseRecipe]], + new_rules:list[BaseRule]=[])->dict[str,BaseRule]: + check_input(patterns, dict, alt_types=[list]) + check_input(recipes, dict, alt_types=[list]) + valid_list(new_rules, BaseRule, min_length=0) + + if isinstance(patterns, list): + valid_list(patterns, BasePattern, min_length=0) + patterns = {pattern.name:pattern for pattern in patterns} + else: + check_pattern_dict(patterns, min_length=0) + + if isinstance(recipes, list): + valid_list(recipes, BaseRecipe, min_length=0) + recipes = {recipe.name:recipe for recipe in recipes} + else: + check_recipe_dict(recipes, min_length=0) + + rules = {} + + all_rules ={(r.pattern_type, r.recipe_type):r for r in [r[1] \ + for r in inspect.getmembers(sys.modules["rules"], inspect.isclass) \ + if (issubclass(r[1], BaseRule))]} + + for pattern in patterns.values(): + if pattern.recipe in recipes: + key = (type(pattern).__name__, + type(recipes[pattern.recipe]).__name__) + if (key) in all_rules: + rule = all_rules[key]( + generate_id(prefix="Rule_"), + pattern, + recipes[pattern.recipe] + ) + rules[rule.name] = rule + return rules \ No newline at end of file diff --git a/core/meow.py b/core/meow.py index b09bd5d..c4b3fca 100644 --- a/core/meow.py +++ b/core/meow.py @@ -2,6 +2,8 @@ import core.correctness.vars import core.correctness.validation +from abc import ABC, abstractmethod + from typing import Any class BaseRecipe: @@ -20,21 +22,6 @@ class BaseRecipe: self._is_valid_requirements(requirements) self.requirements = requirements - def __init_subclass__(cls, **kwargs) -> None: - if cls._is_valid_recipe == BaseRecipe._is_valid_recipe: - raise NotImplementedError( - f"Recipe '{cls.__name__}' has not implemented " - "'_is_valid_recipe(self, recipe)' function.") - if cls._is_valid_parameters == BaseRecipe._is_valid_parameters: - raise NotImplementedError( - f"Recipe '{cls.__name__}' has not implemented " - "'_is_valid_parameters(self, parameters)' function.") - if cls._is_valid_requirements == BaseRecipe._is_valid_requirements: - raise NotImplementedError( - f"Recipe '{cls.__name__}' has not implemented " - "'_is_valid_requirements(self, requirements)' function.") - super().__init_subclass__(**kwargs) - def __new__(cls, *args, **kwargs): if cls is BaseRecipe: raise TypeError("BaseRecipe may not be instantiated directly") @@ -44,17 +31,20 @@ class BaseRecipe: core.correctness.validation.valid_string( name, core.correctness.vars.VALID_RECIPE_NAME_CHARS) + @abstractmethod def _is_valid_recipe(self, recipe:Any)->None: pass + @abstractmethod def _is_valid_parameters(self, parameters:Any)->None: pass + @abstractmethod def _is_valid_requirements(self, requirements:Any)->None: pass -class BasePattern: +class BasePattern(ABC): name:str recipe:str parameters:dict[str, Any] @@ -70,21 +60,6 @@ class BasePattern: self._is_valid_output(outputs) self.outputs = outputs - def __init_subclass__(cls, **kwargs) -> None: - if cls._is_valid_recipe == BasePattern._is_valid_recipe: - raise NotImplementedError( - f"Pattern '{cls.__name__}' has not implemented " - "'_is_valid_recipe(self, recipe)' function.") - if cls._is_valid_parameters == BasePattern._is_valid_parameters: - raise NotImplementedError( - f"Pattern '{cls.__name__}' has not implemented " - "'_is_valid_parameters(self, parameters)' function.") - if cls._is_valid_output == BasePattern._is_valid_output: - raise NotImplementedError( - f"Pattern '{cls.__name__}' has not implemented " - "'_is_valid_output(self, outputs)' function.") - super().__init_subclass__(**kwargs) - def __new__(cls, *args, **kwargs): if cls is BasePattern: raise TypeError("BasePattern may not be instantiated directly") @@ -94,20 +69,25 @@ class BasePattern: core.correctness.validation.valid_string( name, core.correctness.vars.VALID_PATTERN_NAME_CHARS) + @abstractmethod def _is_valid_recipe(self, recipe:Any)->None: pass + @abstractmethod def _is_valid_parameters(self, parameters:Any)->None: pass + @abstractmethod def _is_valid_output(self, outputs:Any)->None: pass -class BaseRule: +class BaseRule(ABC): name:str pattern:BasePattern recipe:BaseRecipe + pattern_type:str="" + recipe_type:str="" def __init__(self, name:str, pattern:BasePattern, recipe:BaseRecipe): self._is_valid_name(name) self.name = name @@ -115,29 +95,29 @@ class BaseRule: self.pattern = pattern self._is_valid_recipe(recipe) self.recipe = recipe + self.__check_types_set() def __new__(cls, *args, **kwargs): if cls is BaseRule: raise TypeError("BaseRule may not be instantiated directly") return object.__new__(cls) - def __init_subclass__(cls, **kwargs) -> None: - if cls._is_valid_pattern == BaseRule._is_valid_pattern: - raise NotImplementedError( - f"Rule '{cls.__name__}' has not implemented " - "'_is_valid_pattern(self, pattern)' function.") - if cls._is_valid_recipe == BaseRule._is_valid_recipe: - raise NotImplementedError( - f"Pattern '{cls.__name__}' has not implemented " - "'_is_valid_recipe(self, recipe)' function.") - super().__init_subclass__(**kwargs) - def _is_valid_name(self, name:str)->None: core.correctness.validation.valid_string( name, core.correctness.vars.VALID_RULE_NAME_CHARS) + @abstractmethod def _is_valid_pattern(self, pattern:Any)->None: pass + @abstractmethod def _is_valid_recipe(self, recipe:Any)->None: pass + + def __check_types_set(self)->None: + if self.pattern_type == "": + raise AttributeError(f"Rule Class '{self.__class__.__name__}' " + "does not set a pattern_type.") + if self.recipe_type == "": + raise AttributeError(f"Rule Class '{self.__class__.__name__}' " + "does not set a recipe_type.") diff --git a/patterns/__init__.py b/patterns/__init__.py new file mode 100644 index 0000000..9ae5407 --- /dev/null +++ b/patterns/__init__.py @@ -0,0 +1,2 @@ + +from patterns.file_event_pattern import FileEventPattern diff --git a/patterns/file_event_pattern.py b/patterns/file_event_pattern.py index 33422e0..157bfdf 100644 --- a/patterns/file_event_pattern.py +++ b/patterns/file_event_pattern.py @@ -34,11 +34,11 @@ class FileEventPattern(BasePattern): valid_string(triggering_file, VALID_VARIABLE_NAME_CHARS) def _is_valid_parameters(self, parameters:dict[str,Any])->None: - valid_dict(parameters, str, Any, strict=False) + valid_dict(parameters, str, Any, strict=False, min_length=0) for k in parameters.keys(): valid_string(k, VALID_VARIABLE_NAME_CHARS) def _is_valid_output(self, outputs:dict[str,str])->None: - valid_dict(outputs, str, str, strict=False) + valid_dict(outputs, str, str, strict=False, min_length=0) for k in outputs.keys(): valid_string(k, VALID_VARIABLE_NAME_CHARS) diff --git a/recipes/__init__.py b/recipes/__init__.py new file mode 100644 index 0000000..e456f1f --- /dev/null +++ b/recipes/__init__.py @@ -0,0 +1,2 @@ + +from recipes.jupyter_notebook_recipe import JupyterNotebookRecipe diff --git a/recipes/jupyter_notebook_recipe.py b/recipes/jupyter_notebook_recipe.py index bb6d4eb..095f310 100644 --- a/recipes/jupyter_notebook_recipe.py +++ b/recipes/jupyter_notebook_recipe.py @@ -36,11 +36,11 @@ class JupyterNotebookRecipe(BaseRecipe): nbformat.validate(recipe) def _is_valid_parameters(self, parameters:dict[str,Any])->None: - valid_dict(parameters, str, Any, strict=False) + valid_dict(parameters, str, Any, strict=False, min_length=0) for k in parameters.keys(): valid_string(k, VALID_VARIABLE_NAME_CHARS) def _is_valid_requirements(self, requirements:dict[str,Any])->None: - valid_dict(requirements, str, Any, strict=False) + valid_dict(requirements, str, Any, strict=False, min_length=0) for k in requirements.keys(): valid_string(k, VALID_VARIABLE_NAME_CHARS) diff --git a/rules/__init__.py b/rules/__init__.py new file mode 100644 index 0000000..69081c5 --- /dev/null +++ b/rules/__init__.py @@ -0,0 +1,2 @@ + +from rules.file_event_jupyter_notebook_rule import FileEventJupyterNotebookRule diff --git a/rules/file_event_jupyter_notebook_rule.py b/rules/file_event_jupyter_notebook_rule.py index 827a5ef..b5037ea 100644 --- a/rules/file_event_jupyter_notebook_rule.py +++ b/rules/file_event_jupyter_notebook_rule.py @@ -5,6 +5,8 @@ from patterns.file_event_pattern import FileEventPattern from recipes.jupyter_notebook_recipe import JupyterNotebookRecipe class FileEventJupyterNotebookRule(BaseRule): + pattern_type = "FileEventPattern" + recipe_type = "JupyterNotebookRecipe" def __init__(self, name: str, pattern:FileEventPattern, recipe:JupyterNotebookRecipe): super().__init__(name, pattern, recipe) @@ -18,3 +20,6 @@ class FileEventJupyterNotebookRule(BaseRule): def _is_valid_recipe(self, recipe:JupyterNotebookRecipe) -> None: check_input(recipe, JupyterNotebookRecipe) + + def _set_pattern_type(self)->None: + self.pattern_type diff --git a/tests/test_functionality.py b/tests/test_functionality.py new file mode 100644 index 0000000..1ffdcda --- /dev/null +++ b/tests/test_functionality.py @@ -0,0 +1,95 @@ + +import unittest + +from core.correctness.vars import CHAR_LOWERCASE, CHAR_UPPERCASE +from core.functionality import create_rules, generate_id +from core.meow import BaseRule +from patterns.file_event_pattern import FileEventPattern +from recipes.jupyter_notebook_recipe import JupyterNotebookRecipe + +BAREBONES_NOTEBOOK = { + "cells": [], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 4 +} + +valid_pattern_one = FileEventPattern( + "pattern_one", "path_one", "recipe_one", "file_one") +valid_pattern_two = FileEventPattern( + "pattern_two", "path_two", "recipe_two", "file_two") + +valid_recipe_one = JupyterNotebookRecipe( + "recipe_one", BAREBONES_NOTEBOOK) +valid_recipe_two = JupyterNotebookRecipe( + "recipe_two", BAREBONES_NOTEBOOK) + +class CorrectnessTests(unittest.TestCase): + def setUp(self) -> None: + return super().setUp() + + def tearDown(self) -> None: + return super().tearDown() + + def testCreateRulesMinimum(self)->None: + create_rules({}, {}) + + def testGenerateIDWorking(self)->None: + id = generate_id() + self.assertEqual(len(id), 16) + for i in range(len(id)): + self.assertIn(id[i], CHAR_UPPERCASE+CHAR_LOWERCASE) + + # In extrememly rare cases this may fail due to randomness in algorithm + new_id = generate_id(existing_ids=[id]) + self.assertNotEqual(id, new_id) + + another_id = generate_id(length=32) + self.assertEqual(len(another_id), 32) + + again_id = generate_id(charset="a") + for i in range(len(again_id)): + self.assertIn(again_id[i], "a") + + with self.assertRaises(ValueError): + generate_id(length=2, charset="a", existing_ids=["aa"]) + + prefix_id = generate_id(length=4, prefix="Test") + self.assertEqual(prefix_id, "Test") + + prefix_id = generate_id(prefix="Test") + self.assertEqual(len(prefix_id), 16) + self.assertTrue(prefix_id.startswith("Test")) + + def testCreateRulesPatternsAndRecipesDicts(self)->None: + patterns = { + valid_pattern_one.name: valid_pattern_one, + valid_pattern_two.name: valid_pattern_two + } + recipes = { + valid_recipe_one.name: valid_recipe_one, + valid_recipe_two.name: valid_recipe_two + } + rules = create_rules(patterns, recipes) + self.assertIsInstance(rules, dict) + self.assertEqual(len(rules), 2) + for k, rule in rules.items(): + self.assertIsInstance(k, str) + self.assertIsInstance(rule, BaseRule) + self.assertEqual(k, rule.name) + + def testCreateRulesMisindexedPatterns(self)->None: + patterns = { + valid_pattern_two.name: valid_pattern_one, + valid_pattern_one.name: valid_pattern_two + } + with self.assertRaises(KeyError): + create_rules(patterns, {}) + + def testCreateRulesMisindexedRecipes(self)->None: + recipes = { + valid_recipe_two.name: valid_recipe_one, + valid_recipe_one.name: valid_recipe_two + } + with self.assertRaises(KeyError): + create_rules({}, recipes) diff --git a/tests/test_rules.py b/tests/test_rules.py index c374857..17c1709 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -76,3 +76,4 @@ class CorrectnessTests(unittest.TestCase): fejnr = FileEventJupyterNotebookRule("name", fep, jnr) self.assertEqual(fejnr.recipe, jnr) + diff --git a/tests/test_validation.py b/tests/test_validation.py index ec5c6c8..7bdbbd7 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -3,7 +3,8 @@ import unittest from typing import Any -from core.correctness.validation import check_input, valid_string, valid_dict +from core.correctness.validation import check_input, valid_string, \ + valid_dict, valid_list from core.correctness.vars import VALID_NAME_CHARS @@ -85,3 +86,23 @@ class CorrectnessTests(unittest.TestCase): def testValidDictOverlyStrict(self)->None: with self.assertRaises(ValueError): valid_dict({"a": 0, "b": 1}, str, int) + + def testValidListMinimum(self)->None: + valid_list([1, 2, 3], int) + valid_list(["1", "2", "3"], str) + valid_list([1], int) + + def testValidListAltTypes(self)->None: + valid_list([1, "2", 3], int, alt_types=[str]) + + def testValidListMismatchedNotList(self)->None: + with self.assertRaises(TypeError): + valid_list((1, 2, 3), int) + + def testValidListMismatchedType(self)->None: + with self.assertRaises(TypeError): + valid_list([1, 2, 3], str) + + def testValidListMinLength(self)->None: + with self.assertRaises(ValueError): + valid_list([1, 2, 3], str, min_length=10)