├── .gitignore ├── LICENSE ├── README.md ├── bin └── create_sample_src.sh ├── dev_achievements ├── __init__.py ├── achievements.py ├── processing │ ├── __init__.py │ ├── tree.py │ └── visitor.py └── utilities │ ├── __init__.py │ ├── constants.py │ └── utils.py ├── setup.py ├── test_src.py └── tests ├── __init__.py ├── samples ├── invalid │ ├── AssignAchievement.py │ ├── BitwiseOperatorsAchievement.py │ ├── ClassAchievement.py │ ├── ComprehensionsAchievement.py │ ├── ConditionalAchievement.py │ ├── DictAchievement.py │ ├── FunctionAchievement.py │ ├── HelloWorldAchievement.py │ ├── LambdaAchievement.py │ ├── ListAchievement.py │ ├── LoopsAchievement.py │ ├── MathOperatorsAchievement.py │ └── PassAchievement.py └── valid │ ├── AssignAchievement.py │ ├── BitwiseOperatorsAchievement.py │ ├── ClassAchievement.py │ ├── ComprehensionsAchievement.py │ ├── ConditionalAchievement.py │ ├── DictAchievement.py │ ├── FunctionAchievement.py │ ├── HelloWorldAchievement.py │ ├── LambdaAchievement.py │ ├── ListAchievement.py │ ├── LoopsAchievement.py │ ├── MathOperatorsAchievement.py │ └── PassAchievement.py └── test.py /.gitignore: -------------------------------------------------------------------------------- 1 | dev_achievements/store.json 2 | 3 | # TODO: temp files - remove later 4 | ast_list.txt 5 | 6 | # MacOS files 7 | .DS_STORE 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | pip-wheel-metadata/ 32 | share/python-wheels/ 33 | *.egg-info/ 34 | .installed.cfg 35 | *.egg 36 | MANIFEST 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .nox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | *.py,cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | 62 | # Translations 63 | *.mo 64 | *.pot 65 | 66 | # Django stuff: 67 | *.log 68 | local_settings.py 69 | db.sqlite3 70 | db.sqlite3-journal 71 | 72 | # Flask stuff: 73 | instance/ 74 | .webassets-cache 75 | 76 | # Scrapy stuff: 77 | .scrapy 78 | 79 | # Sphinx documentation 80 | docs/_build/ 81 | 82 | # PyBuilder 83 | target/ 84 | 85 | # Jupyter Notebook 86 | .ipynb_checkpoints 87 | 88 | # IPython 89 | profile_default/ 90 | ipython_config.py 91 | 92 | # pyenv 93 | .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Celery stuff 106 | celerybeat-schedule 107 | celerybeat.pid 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ravi Patel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dev-achievements 2 | Earn Achievements while learning how to code 3 | 4 | 5 | Game-ify your experience while learning to code and get achievements for different concepts as you use them. Start off at a simple `print` statement and work your way up to `functions` and `classes` (and more to come). 6 | 7 | 8 |
9 | 10 | 11 | ### Example 12 | 13 | Just `import` the package at the top of your script (example): 14 | 15 | ```python 16 | # my_script.py 17 | 18 | import dev_achievements 19 | 20 | 21 | def cumulative_sum(x): 22 | # calculates sum of numbers from 0 to x 23 | total = 0 24 | for i in range(x): 25 | total += i 26 | return total 27 | 28 | 29 | result = cumulative_sum(4) 30 | print('value: ', result) 31 | ``` 32 | 33 | And run it in terminal as you normally would: 34 | ```shell 35 | $ python3 my_script.py 36 | 37 | ┌──────────────────────────────────┐ 38 | │ Achievement Unlocked: Loops! │ 39 | │ Achievement Unlocked: Functions! │ 40 | └──────────────────────────────────┘ 41 | 42 | value: 6 43 | ``` 44 | 45 | 46 |
47 | 48 | 49 | ### Usage 50 | 51 | To install and use: 52 | 1. Install the package with `pip install dev-achievements` 53 | 1. Use `import dev_achievements` at the top of your script 54 | 1. Run your `python` script as normal 55 | 56 | To uninstall: 57 | 1. Uninstall the package with `pip uninstall dev-achievements` 58 | 1. Optionally, delete the directory `~/.dev_achievements` to remove any achievement progress. Keep this directory to save progress through installs. 59 | 60 | 61 |
62 | 63 | 64 | ### Some things to note 65 | 66 | 1. Currently this _only_ works on single file scripts - if you import your own module (e.g. for utility functions) that module will _not_ be parsed (planning on fixing this though) 67 | 1. Some achievements have _dependencies_, and will only be unlocked once previous ones have been unlocked 68 | 1. Unlocked achievements will remain unlocked, so those "Achievement Unlocked" messages will should only show once per achievement 69 | 70 | 71 |
72 | 73 | 74 | ### Future plans and contributing 75 | 1. The bare bones achievements are currently implemented (using `"hello world"`, `for` loops, `lists`, `functions`, etc.) - more achievements are in development (see issue [#1](https://github.com/raviolliii/dev-achievements/issues/1)) 76 | 1. More details on how to contribute (along with code docs to help with the development process) are coming soon 77 | -------------------------------------------------------------------------------- /bin/create_sample_src.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | # quick way to make sample src files for an Achievement 5 | # usage: create_sample_src.sh achievement_name 6 | # it'll create the files under valid and invalid subdirs 7 | # using the achievement_name 8 | 9 | 10 | VALID_PATH="./tests/samples/valid/$1.py" 11 | INVALID_PATH="./tests/samples/invalid/$1.py" 12 | 13 | touch $VALID_PATH 14 | touch $INVALID_PATH 15 | 16 | printf "# cases where $1 should unlock\n\n# >> CASE\n" > $VALID_PATH 17 | printf "# cases where $1 should not unlock\n\n# >> CASE\n" > $INVALID_PATH 18 | -------------------------------------------------------------------------------- /dev_achievements/__init__.py: -------------------------------------------------------------------------------- 1 | # __init__.py 2 | # ----------- 3 | # Primary executor of dev_achievements. Runs syntax processing 4 | # and Achievement checking on the file that imported the package. 5 | 6 | import ast 7 | import os 8 | import sys 9 | 10 | from dev_achievements.processing.visitor import Visitor 11 | from dev_achievements.utilities.utils import bordered 12 | 13 | 14 | # package version 15 | __version__ = '1.0.3' 16 | 17 | 18 | def process_tree(tree): 19 | """ Creates an AST Node Visitor to process the built 20 | syntax tree. 21 | 22 | Args: 23 | tree (ast.AST): AST syntax tree 24 | """ 25 | v = Visitor() 26 | v.visit(tree) 27 | unlocked = v.check_achievements() 28 | if unlocked: 29 | text = '\n'.join(a.unlock_message for a in unlocked) 30 | print('\n' + bordered(text) + '\n') 31 | return 32 | 33 | 34 | def build_tree(file_path): 35 | """ Creates an AST syntax tree from the source file. 36 | 37 | Args: 38 | file_path (str): path of source file 39 | 40 | Returns: 41 | ast.AST: Root of syntax tree 42 | """ 43 | tree = None 44 | with open(file_path, 'r') as file: 45 | tree = ast.parse(file.read()) 46 | return tree 47 | 48 | 49 | def process_file(file_path): 50 | """ Builds an AST syntax tree and visits each node to 51 | process for Achievements. 52 | 53 | Args: 54 | file_path (str): path of file 55 | """ 56 | tree = build_tree(file_path) 57 | process_tree(tree) 58 | return 59 | 60 | 61 | # run the whole Achievement process on package import 62 | # if the passed script path exists 63 | if __name__ != '__main__': 64 | if len(sys.argv) > 0 and os.path.isfile(sys.argv[0]): 65 | process_file(sys.argv[0]) 66 | -------------------------------------------------------------------------------- /dev_achievements/achievements.py: -------------------------------------------------------------------------------- 1 | # achievements.py 2 | # --------------- 3 | # Contains all implemented Achievements. 4 | 5 | import ast 6 | from abc import abstractmethod 7 | 8 | 9 | # Base classes 10 | # ------------ 11 | 12 | 13 | class Achievement: 14 | """ Base Achievement with common unlock and dependency fields. 15 | 16 | New Achievements should sublass this base, implementing the 17 | _check_condition method to specify when it should be unlocked. 18 | 19 | Attributes: 20 | unlocked (bool): unlock state 21 | dependencies (list[Achievements]): list of Achievements to unlock first 22 | on_unlock (function): callback on Achievement unlock 23 | unlock_message (str): pretty formatted message after being unlocked 24 | 25 | Args: 26 | unlocked (bool): unlock state 27 | on_unlock (function): Achievement unlock handler 28 | """ 29 | def __init__(self, unlocked=False, on_unlock=None): 30 | # unlocked state and dependencies 31 | self._unlocked = unlocked 32 | self.dependencies = [] 33 | # state change handlers 34 | self.on_unlock = on_unlock 35 | 36 | @property 37 | def unlocked(self): 38 | """ Get unlocked state """ 39 | return self._unlocked 40 | 41 | @unlocked.setter 42 | def unlocked(self, status): 43 | """ Sets the unlocked state, and calls the event handler 44 | accordingly. 45 | 46 | Args: 47 | status (bool): new unlocked state 48 | """ 49 | self._unlocked = status 50 | if self._unlocked and callable(self.on_unlock): 51 | self.on_unlock() 52 | return 53 | 54 | @property 55 | def unlock_message(self): 56 | """ Gives the message to show when unlocked based on the 57 | Achievement title. 58 | 59 | Returns: 60 | str: Pretty formatted unlock message 61 | """ 62 | if not hasattr(self, 'title'): 63 | return 'Achievement Unlocked!' 64 | return f'Achievement Unlocked: {self.title}' 65 | 66 | @abstractmethod 67 | def _check_condition(self, nodes): 68 | """ When implemented, specifies whether or not this Achievement 69 | should be unlocked based on the given AST node table. Must be 70 | implemented for every subclass of Achievement. 71 | 72 | Args: 73 | nodes (dict): table of ast.AST nodes in tree 74 | 75 | Returns: 76 | bool: True if this Achievement should be unlocked, False otherwise 77 | 78 | Raises: 79 | NotImplementedError: If not implemented in subclasses 80 | """ 81 | msg = 'Must implement _check_condition method for' \ 82 | + f' class {self.__class__.__name__}' 83 | raise NotImplementedError(msg) 84 | 85 | def check(self, nodes): 86 | """ Calls the _check_condition method with the given AST nodes 87 | and updates the unlocked state accordingly. 88 | 89 | Args: 90 | nodes (dict): table of ast.AST nodes in tree 91 | 92 | Returns: 93 | bool: Unlocked state after checking condition 94 | """ 95 | if not self.unlocked: 96 | self.unlocked = self._check_condition(nodes) 97 | return self.unlocked 98 | 99 | @classmethod 100 | def subclasses(cls): 101 | """ Returns a list of subclasses to Achievement """ 102 | return cls.__subclasses__() 103 | 104 | def __init_subclass__(cls, /, **kwargs): 105 | """ Each subclass to this Achievement will get a UID as a class 106 | variable. The UID is just sequentially generated on every subclass. 107 | 108 | Reference: 109 | https://docs.python.org/3/reference/datamodel.html#customizing-class-creation 110 | """ 111 | super().__init_subclass__(**kwargs) 112 | cls.uid = len(Achievement.__subclasses__()) - 1 113 | 114 | def __repr__(self): 115 | """ Representation version of the Achievement """ 116 | res = f'{self.__class__.__name__}(dependencies={self.dependencies})' 117 | return res 118 | 119 | def __str__(self): 120 | """ String version of the Achievement """ 121 | ulock_str = 'unlocked' if self.unlocked else 'locked' 122 | res = f'Achievement {self.uid}: {self.__class__.__name__}' \ 123 | + f' ({ulock_str})' 124 | return res 125 | 126 | 127 | # All unlockable achievements 128 | # --------------------------- 129 | 130 | 131 | class SampleAchievement(Achievement): 132 | """ Sample Achievement for reference """ 133 | def __init__(self, **kwargs): 134 | super().__init__(**kwargs) 135 | self.title = 'SAMPLE' 136 | 137 | def _check_condition(self, nodes): 138 | """ Check something """ 139 | return False 140 | 141 | 142 | class HelloWorldAchievement(Achievement): 143 | """ Unlocks on printing Hello World """ 144 | def __init__(self, **kwargs): 145 | super().__init__(**kwargs) 146 | self.title = 'Hello Hello!' 147 | self.dependencies = [] 148 | 149 | def _check_condition(self, nodes): 150 | """ Checks for print call with "hello world" """ 151 | for call in nodes.get(ast.Call, []): 152 | # has to call "print" function 153 | if call.func.id != print.__name__: 154 | continue 155 | # has to have at least 1 str literal (constant) argument 156 | if not len(call.args): 157 | continue 158 | arg = call.args[0] 159 | is_str = isinstance(arg, ast.Constant) and type(arg.value) == str 160 | # check "hello world" in first arg 161 | if is_str and 'hello world' in arg.value.lower(): 162 | return True 163 | return False 164 | 165 | 166 | class AssignAchievement(Achievement): 167 | """ Unlocks on variable assignment """ 168 | def __init__(self, **kwargs): 169 | super().__init__(**kwargs) 170 | self.title = 'Variables!' 171 | 172 | def _check_condition(self, nodes): 173 | """ Checks for assignment operator """ 174 | return bool(ast.Assign in nodes) 175 | 176 | 177 | class MathOperatorsAchievement(Achievement): 178 | """ Unlocks on using any math (non binary) operator """ 179 | def __init__(self, **kwargs): 180 | super().__init__(**kwargs) 181 | self.title = 'Operators!' 182 | 183 | def _check_condition(self, nodes): 184 | """ Checks for any non-binary operator """ 185 | ops = [ast.Add, ast.Sub, ast.Mult, ast.Div, 186 | ast.FloorDiv, ast.Mod, ast.Pow, ast.MatMult] 187 | return any([o in nodes for o in ops]) 188 | 189 | 190 | class BitwiseOperatorsAchievement(Achievement): 191 | """ Unlocks on using any bitwise operators """ 192 | def __init__(self, **kwargs): 193 | super().__init__(**kwargs) 194 | self.title = 'Bitwise!' 195 | self.dependencies = [MathOperatorsAchievement] 196 | 197 | def _check_condition(self, nodes): 198 | """ Checks for any bitwise operators """ 199 | ops = [ast.LShift, ast.RShift, ast.BitOr, 200 | ast.BitAnd, ast.BitXor, ast.Invert] 201 | return any([o in nodes for o in ops]) 202 | 203 | 204 | class ConditionalAchievement(Achievement): 205 | """ Unlocks on using if statements """ 206 | def __init__(self, **kwargs): 207 | super().__init__(**kwargs) 208 | self.title = 'If statements!' 209 | 210 | def _check_condition(self, nodes): 211 | """ Checks for if statements (regular and ternary) """ 212 | reg = bool(ast.If in nodes) 213 | tern = bool(ast.IfExp in nodes) 214 | return reg or tern 215 | 216 | 217 | class LoopsAchievement(Achievement): 218 | """ Unlocks on loops (for and while) """ 219 | def __init__(self, **kwargs): 220 | super().__init__(**kwargs) 221 | self.title = 'Loops!' 222 | self.dependencies = [AssignAchievement, ConditionalAchievement] 223 | 224 | def _check_condition(self, nodes): 225 | """ Checks for loop keywords """ 226 | has_for = bool(ast.For in nodes) 227 | has_while = bool(ast.While in nodes) 228 | return has_for or has_while 229 | 230 | 231 | class ComprehensionsAchievement(Achievement): 232 | """ Unlocks on any form of comprehension (list, set, etc.) """ 233 | def __init__(self, **kwargs): 234 | super().__init__(**kwargs) 235 | self.title = 'Comprehensions!' 236 | self.dependencies = [LoopsAchievement] 237 | 238 | def _check_condition(self, nodes): 239 | """ Checks for any form of comprehension """ 240 | comps = [ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp] 241 | return any([c in nodes for c in comps]) 242 | 243 | 244 | class PassAchievement(Achievement): 245 | """ Unlocks on using pass """ 246 | def __init__(self, **kwargs): 247 | super().__init__(**kwargs) 248 | self.title = 'Pass!' 249 | self.dependencies = [LoopsAchievement] 250 | 251 | def _check_condition(self, nodes): 252 | """ Checks for pass keyword """ 253 | return bool(ast.Pass in nodes) 254 | 255 | 256 | class FunctionAchievement(Achievement): 257 | """ Unlocks on defining and calling a function """ 258 | def __init__(self, **kwargs): 259 | super().__init__(**kwargs) 260 | self.title = 'Functions!' 261 | self.dependencies = [ConditionalAchievement, LoopsAchievement] 262 | 263 | def _check_condition(self, nodes): 264 | """ Checks for user defined function calls """ 265 | fn_defs = nodes.get(ast.FunctionDef, []) 266 | fn_calls = nodes.get(ast.Call, []) 267 | # unique names of functions defined/called 268 | def_names = set([fn.name for fn in fn_defs]) 269 | call_names = set([c.func.id for c in fn_calls]) 270 | # at least one function must be defined and called 271 | return len(def_names & call_names) > 0 272 | 273 | 274 | class LambdaAchievement(Achievement): 275 | """ Unlocks on using lambda functions """ 276 | def __init__(self, **kwargs): 277 | super().__init__(**kwargs) 278 | self.title = 'Lambdas!' 279 | self.dependencies = [FunctionAchievement] 280 | 281 | def _check_condition(self, nodes): 282 | """ Checks for lambda functions """ 283 | return bool(ast.Lambda in nodes) 284 | 285 | 286 | class ListAchievement(Achievement): 287 | """ Unlocks on using lists """ 288 | def __init__(self, **kwargs): 289 | super().__init__(**kwargs) 290 | self.title = 'Lists!' 291 | 292 | def _check_condition(self, nodes): 293 | """ Checks for list data type """ 294 | return bool(ast.List in nodes) 295 | 296 | 297 | class DictAchievement(Achievement): 298 | """ Unlocks on using a dict """ 299 | def __init__(self, **kwargs): 300 | super().__init__(**kwargs) 301 | self.title = 'Dictionaries!' 302 | 303 | def _check_condition(self, nodes): 304 | """ Checks for dict data type """ 305 | return bool(ast.Dict in nodes) 306 | 307 | 308 | class ClassAchievement(Achievement): 309 | """ Unlocks on declaring and creating an instance of a class """ 310 | def __init__(self, **kwargs): 311 | super().__init__(**kwargs) 312 | self.title = 'Classes!' 313 | self.dependencies = [FunctionAchievement] 314 | 315 | def _check_condition(self, nodes): 316 | """ Checks for creating a class and creating an instance """ 317 | cls_defs = nodes.get(ast.ClassDef, []) 318 | cls_calls = nodes.get(ast.Call, []) 319 | # unique names of functions defined/called 320 | cls_names = set([fn.name for fn in cls_defs]) 321 | call_names = set([c.func.id for c in cls_calls]) 322 | # at least one function must be defined and called 323 | return len(cls_names & call_names) > 0 324 | 325 | -------------------------------------------------------------------------------- /dev_achievements/processing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raviolliii/dev-achievements/da0a92f0d53b20192ef68ee1222cbf01fb0c0ed0/dev_achievements/processing/__init__.py -------------------------------------------------------------------------------- /dev_achievements/processing/tree.py: -------------------------------------------------------------------------------- 1 | # tree.py 2 | # ------- 3 | # Contains utilities for building and traversing an Achievement 4 | # tree (for finding Achievements to unlock next, etc.). 5 | 6 | from dev_achievements.achievements import * 7 | from dev_achievements.utilities.utils import load_store, save_completed 8 | 9 | 10 | class AchievementTree: 11 | """ Tree of Achievements with dependencies as edges. 12 | 13 | Attributes: 14 | nodes (list[Achievement]): all Achievements 15 | queue (list[Achievement]): list of unlockable Achievements 16 | """ 17 | def __init__(self): 18 | self._init_nodes() 19 | 20 | def _init_nodes(self): 21 | """ Creates list of all Achievements according to their unlocked 22 | state. Each one prints its unlock message and saves to the store 23 | upon being unlocked. 24 | """ 25 | unlocked_ach = load_store(field='unlocked') 26 | 27 | def _create_node(ach): 28 | # helper to create achievement with unlocked state and handler 29 | unlocked = ach.__name__ in unlocked_ach 30 | save_ach = lambda: save_completed(ach.__name__) 31 | return ach(unlocked=unlocked, on_unlock=save_ach) 32 | 33 | self.nodes = [_create_node(a) for a in Achievement.subclasses()] 34 | # set node dependencies as references to initialized nodes 35 | for node in self.nodes: 36 | node.dependencies = [self.nodes[d.uid] for d in node.dependencies] 37 | return 38 | 39 | @property 40 | def queue(self): 41 | """ Returns a list of unlockable Achievements. 42 | An Achievement is unlockable if it's still locked, and all its 43 | dependencies are unlocked. 44 | 45 | Returns: 46 | list: All unlockable Achievements 47 | """ 48 | def _is_unlockable(node): 49 | # helper to determine if given node is unlockable 50 | par_unlocked = all([p.unlocked for p in node.dependencies]) 51 | is_locked = not node.unlocked 52 | return par_unlocked and is_locked 53 | # filter nodes to unlockable ones 54 | return [n for n in self.nodes if _is_unlockable(n)] 55 | -------------------------------------------------------------------------------- /dev_achievements/processing/visitor.py: -------------------------------------------------------------------------------- 1 | # visitor.py 2 | # ---------- 3 | # Contains utilities for traversing and processing a built ast.AST 4 | # syntax tree (for building custom ast.AST table, etc.). 5 | 6 | import ast 7 | 8 | from dev_achievements.processing.tree import AchievementTree 9 | 10 | 11 | class Visitor(ast.NodeVisitor): 12 | """ Traverses the AST syntax tree and processes each node 13 | accordingly. 14 | 15 | Attributes: 16 | ach_tree (AchievementTree): Achievements in tree structure 17 | table (dict): table of ast.AST nodes in tree 18 | """ 19 | def __init__(self): 20 | super().__init__() 21 | self.ach_tree = AchievementTree() 22 | self.table = {} 23 | 24 | def generic_visit(self, node): 25 | """ Processes any general AST node type, checking all 26 | Achievements in queue for any unlock state changes. 27 | 28 | Args: 29 | node (ast.AST): AST syntax tree node 30 | """ 31 | node_class = node.__class__ 32 | curr = self.table.get(node_class, []) 33 | self.table[node_class] = curr + [node] 34 | # call default visit traversal on node 35 | super().generic_visit(node) 36 | return 37 | 38 | def check_achievements(self): 39 | """ Checks and unlocks all possible Achievements. """ 40 | unlocked = [] 41 | changed = True 42 | check_ach = lambda ach: ach.check(self.table) 43 | while changed: 44 | # check if any Achievements have been unlocked 45 | checks = [(a, check_ach(a)) for a in self.ach_tree.queue] 46 | changed = any(c[1] for c in checks) 47 | # accumulate and return unlocked Achievements 48 | unlocked += [a for a, ch in checks if ch] 49 | return unlocked 50 | -------------------------------------------------------------------------------- /dev_achievements/utilities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raviolliii/dev-achievements/da0a92f0d53b20192ef68ee1222cbf01fb0c0ed0/dev_achievements/utilities/__init__.py -------------------------------------------------------------------------------- /dev_achievements/utilities/constants.py: -------------------------------------------------------------------------------- 1 | # constants.py 2 | # ------------ 3 | # Just a bunch of constants values for use throughout the codebase. 4 | 5 | import os 6 | 7 | 8 | # Achievement store path 9 | _ROOT_PATH = os.path.expanduser('~') 10 | STORE_PATH = os.path.join(_ROOT_PATH, '.dev_achievements/store.json') 11 | 12 | 13 | # default Achievement store data 14 | DEFAULT_STORE = { 15 | 'unlocked': [], 16 | } 17 | -------------------------------------------------------------------------------- /dev_achievements/utilities/utils.py: -------------------------------------------------------------------------------- 1 | # utils.py 2 | # -------- 3 | # Contains a bunch of utility functions to make life easier 4 | # throughout the rest of the codebase. 5 | 6 | import json 7 | import os 8 | import pathlib 9 | 10 | from dev_achievements.utilities.constants import STORE_PATH, DEFAULT_STORE 11 | 12 | 13 | def load_json(file_path): 14 | """ Loads in a JSON file as a dict 15 | 16 | Args: 17 | file_path (str): path to JSON file 18 | 19 | Returns: 20 | dict: Data in file 21 | """ 22 | data = {} 23 | with open(file_path, 'r') as file: 24 | data = json.load(file) 25 | return data 26 | 27 | 28 | def write_json(file_path, data): 29 | """ Writes given dict to a JSON file 30 | 31 | Args: 32 | file_path (str): path of JSON file 33 | data (dict): data to write 34 | """ 35 | with open(file_path, 'w+') as file: 36 | json.dump(data, file, indent=4) 37 | return 38 | 39 | 40 | def load_store(field=None): 41 | """ Loads in Achievement store as a dict. If a field value 42 | is specified, the data in the field is returned. If there 43 | is no file in the configured STORE_PATH, the configured 44 | DEFAULT_STORE is used. 45 | 46 | Args: 47 | field (str, optional): dictionary field 48 | 49 | Returns: 50 | The whole data store, or the data in the field if one is given. 51 | """ 52 | store = DEFAULT_STORE 53 | # load in store if saved 54 | if os.path.isfile(STORE_PATH): 55 | store = load_json(STORE_PATH) 56 | # get field if specified 57 | if field is not None: 58 | return store.get(field, None) 59 | return store 60 | 61 | 62 | def write_store(data): 63 | """ Writes given data to the Achievement store. 64 | 65 | Creates the full nested directory path of STORE_DIR 66 | if it doesn't exist, before writing/creating the store. 67 | 68 | Args: 69 | data (dict): updated Achievement store to write 70 | """ 71 | store_dir = os.path.dirname(STORE_PATH) 72 | pathlib.Path(store_dir).mkdir(parents=True, exist_ok=True) 73 | return write_json(STORE_PATH, data) 74 | 75 | 76 | def save_completed(ach_name): 77 | """ Marks the given Achievement as unlocked in the store. 78 | 79 | Args: 80 | ach_name (Achievement): class of Achievement 81 | """ 82 | store = load_store() 83 | store['unlocked'].append(ach_name) 84 | write_store(store) 85 | return 86 | 87 | 88 | def bordered(text): 89 | """ Pretty formats the given text in a solid box outline. 90 | 91 | Args: 92 | text (str): text within the box 93 | 94 | Returns: 95 | str: Boxed text 96 | """ 97 | lines = text.splitlines() 98 | width = max([len(s) for s in lines]) 99 | res = ['┌' + ('─' * (width + 2)) + '┐'] 100 | for s in lines: 101 | sub = (s + (' ' * width))[:width] 102 | res.append('│ ' + sub + ' │') 103 | res.append('└' + ('─' * (width + 2)) + '┘') 104 | return '\n'.join(res) 105 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # setup.py 2 | # -------- 3 | # Specifies installation config for building/pypi. 4 | 5 | from setuptools import find_packages, setup 6 | 7 | 8 | # ignore unit tests 9 | excluded_packages = ['tests', 'tests.*'] 10 | 11 | # load in README as long description 12 | with open('README.md', 'r') as file: 13 | long_description = file.read() 14 | 15 | 16 | setup( 17 | name='dev_achievements', 18 | version='1.0.3', 19 | author='Ravi Patel', 20 | author_email='ravi.patel1245@gmail.com', 21 | description='Earn Achievements while learning how to code', 22 | long_description=long_description, 23 | long_description_content_type='text/markdown', 24 | url='https://github.com/raviolliii/dev-achievements', 25 | packages=find_packages(exclude=excluded_packages), 26 | python_requires='>=3.5', 27 | install_requires=[], 28 | classifiers=[ 29 | 'Programming Language :: Python :: 3', 30 | 'License :: OSI Approved :: MIT License', 31 | 'Operating System :: OS Independent', 32 | ], 33 | ) 34 | -------------------------------------------------------------------------------- /test_src.py: -------------------------------------------------------------------------------- 1 | # test_src.py 2 | # ----------- 3 | # Basically a sample script that can be run to unlock any 4 | # Achievements specified below 5 | 6 | import dev_achievements 7 | 8 | 9 | # HelloWorldAchievement 10 | print('Helo world', end='') 11 | print('\nHello World!') 12 | 13 | 14 | # AssignAchievement 15 | x = 4 16 | 17 | 18 | # MathOperatorsAchievement 19 | x += 1 20 | 21 | 22 | # BitwiseOperatorsAchievement 23 | x << 2 24 | x ^ 2 25 | 26 | 27 | # ConditionalAchievement 28 | if x < 4: 29 | x = 4 30 | else: 31 | x = 5 32 | 33 | 34 | # LoopsAchievement 35 | for i in range(2): 36 | x + i 37 | 38 | x = 0 39 | while x < 5: 40 | x += 1 41 | 42 | 43 | # ComprehensionAchievement 44 | x = [i for i in range(10)] 45 | 46 | 47 | # PassAchievement 48 | for _ in range(2): 49 | pass 50 | 51 | 52 | # FunctionAchievement 53 | def some_func(): 54 | pass 55 | 56 | def some_other_func(): 57 | pass 58 | 59 | some_func() 60 | 61 | 62 | # LambdaAchievement 63 | lf = lambda x: x + 1 64 | 65 | 66 | # ListAchievement 67 | x = [4, 5, 6] 68 | 69 | 70 | # DictAchievement 71 | x = {'name': 'John Doe'} 72 | 73 | 74 | # ClassAchievement 75 | class Test: 76 | pass 77 | 78 | class OtherTest: 79 | pass 80 | 81 | t = Test() 82 | 83 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raviolliii/dev-achievements/da0a92f0d53b20192ef68ee1222cbf01fb0c0ed0/tests/__init__.py -------------------------------------------------------------------------------- /tests/samples/invalid/AssignAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where AssignAchievement should not unlock 2 | 3 | # >> CASE 4 | 5 | # technically this isn't allowed, but we're only really 6 | # checking syntax here, nothing else 7 | x 8 | 9 | # >> CASE 10 | print(x) 11 | -------------------------------------------------------------------------------- /tests/samples/invalid/BitwiseOperatorsAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where BitwiseOperatorsAchievement should not unlock 2 | 3 | # >> CASE 4 | 4 + 4 5 | 6 | # >> CASE 7 | 4 * 5 8 | 9 | # >> CASE 10 | 4 - 5 11 | 12 | # >> CASE 13 | 4 / 5 14 | 15 | # >> CASE 16 | 4 // 5 17 | 18 | # >> CASE 19 | 4 % 5 20 | 21 | # >> CASE 22 | 2 ** 3 23 | 24 | # >> CASE 25 | [4, 5] @ [4, 5] 26 | 27 | # >> CASE 28 | [4 + 5 - 4 * 5 / 4 // 5 % 5 ** 1] @ [4] 29 | -------------------------------------------------------------------------------- /tests/samples/invalid/ClassAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where ClassAchievement should not unlock 2 | 3 | # >> CASE 4 | class Foo: 5 | pass 6 | 7 | # >> CASE 8 | class Foo: 9 | pass 10 | 11 | Foo 12 | 13 | # >> CASE 14 | class Foo: 15 | pass 16 | 17 | f = Foo 18 | f() 19 | 20 | # >> CASE 21 | Foo() 22 | 23 | # >> CASE 24 | Foo 25 | -------------------------------------------------------------------------------- /tests/samples/invalid/ComprehensionsAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where ComprehensionsAchievement should not unlock 2 | 3 | # >> CASE 4 | for _ in range(10): 5 | pass 6 | 7 | # >> CASE 8 | set([4, 5, 6]) 9 | 10 | # >> CASE 11 | {4, 5, 6} 12 | -------------------------------------------------------------------------------- /tests/samples/invalid/ConditionalAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where ConditionalAchievement should not unlock 2 | 3 | # >> CASE 4 | [i for i in range(10) if i % 2 == 0] 5 | 6 | # >> CASE 7 | pass 8 | -------------------------------------------------------------------------------- /tests/samples/invalid/DictAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where DictAchievement should not unlock 2 | 3 | # >> CASE 4 | {i: i for i in range(10)} 5 | 6 | # >> CASE 7 | {4, 5, 6} 8 | -------------------------------------------------------------------------------- /tests/samples/invalid/FunctionAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where FunctionAchievement should not unlock 2 | 3 | # >> CASE 4 | def test(): 5 | pass 6 | 7 | # >> CASE 8 | def func(): 9 | pass 10 | 11 | func 12 | 13 | # >> CASE 14 | def func(): 15 | pass 16 | 17 | f = func 18 | f() 19 | 20 | # >> CASE 21 | func() 22 | 23 | # >> CASE 24 | func 25 | -------------------------------------------------------------------------------- /tests/samples/invalid/HelloWorldAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where HelloWorldAchievement should not unlock 2 | 3 | # >> CASE 4 | print(123) 5 | 6 | # >> CASE 7 | print('hello wrold') 8 | 9 | # >> CASE 10 | print('helol world') 11 | 12 | # >> CASE 13 | print('helloworld', end='') 14 | -------------------------------------------------------------------------------- /tests/samples/invalid/LambdaAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where LambdaAchievement should not unlock 2 | 3 | # >> CASE 4 | def test(): 5 | return 6 | 7 | test() 8 | -------------------------------------------------------------------------------- /tests/samples/invalid/ListAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where ListAchievement should not unlock 2 | 3 | # >> CASE 4 | [i for i in range(10)] 5 | 6 | # >> CASE 7 | [i for i in range(10) if i % 2 == 0] 8 | 9 | # >> CASE 10 | x = 4, 5, 6 11 | 12 | # >> CASE 13 | x = (4, 5, 6) 14 | 15 | # >> CASE 16 | x = (i for i in range(10)) 17 | -------------------------------------------------------------------------------- /tests/samples/invalid/LoopsAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where LoopsAchievement should not unlock 2 | 3 | # >> CASE 4 | [None for _ in range(4)] 5 | 6 | # >> CASE 7 | pass 8 | -------------------------------------------------------------------------------- /tests/samples/invalid/MathOperatorsAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where MathOperatorsAchievement should not unlock 2 | 3 | # >> CASE 4 | True and False 5 | 6 | # >> CASE 7 | True or False 8 | 9 | # >> CASE 10 | not True 11 | 12 | # >> CASE 13 | 4 << 1 14 | 15 | # >> CASE 16 | 4 >> 1 17 | 18 | # >> CASE 19 | 4 >> 1 20 | 21 | # >> CASE 22 | 4 & 5 23 | 24 | # >> CASE 25 | 4 | 5 26 | 27 | # >> CASE 28 | 4 ^ 5 29 | 30 | # >> CASE 31 | ~4 32 | -------------------------------------------------------------------------------- /tests/samples/invalid/PassAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where PassAchievement should not unlock 2 | 3 | # >> CASE 4 | for _ in range(4): 5 | x = 4 6 | 7 | # >> CASE 8 | def test(): 9 | return 10 | 11 | # >> CASE 12 | class Test: 13 | value = 4 14 | -------------------------------------------------------------------------------- /tests/samples/valid/AssignAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where AssignAchievement should unlock 2 | 3 | # >> CASE 4 | x = 4 5 | 6 | # >> CASE 7 | x = [4, 5, 6] 8 | -------------------------------------------------------------------------------- /tests/samples/valid/BitwiseOperatorsAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where BitwiseOperatorsAchievement should unlock 2 | 3 | # >> CASE 4 | 4 << 1 5 | 6 | # >> CASE 7 | 4 >> 1 8 | 9 | # >> CASE 10 | 4 & 5 11 | 12 | # >> CASE 13 | 4 | 5 14 | 15 | # >> CASE 16 | 4 ^ 5 17 | 18 | # >> CASE 19 | ~4 20 | 21 | # >> CASE 22 | ~(1 << 1 >> 1 & 1 | 0 ^ 1) 23 | -------------------------------------------------------------------------------- /tests/samples/valid/ClassAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where ClassAchievement should unlock 2 | 3 | # >> CASE 4 | class Foo: 5 | pass 6 | 7 | t = Foo() 8 | 9 | # >> CASE 10 | class Bar(): 11 | pass 12 | 13 | Bar() 14 | -------------------------------------------------------------------------------- /tests/samples/valid/ComprehensionsAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where ComprehensionsAchievement should unlock 2 | 3 | # >> CASE 4 | [None for _ in range(10)] 5 | 6 | # >> CASE 7 | [None for i in range(10) if i % 2 == 0] 8 | 9 | # >> CASE 10 | {i: i for i in range(10)} 11 | 12 | # >> CASE 13 | {i: i for i in range(10) if i % 2 == 0} 14 | 15 | # >> CASE 16 | {i for i in range(10)} 17 | 18 | # >> CASE 19 | {i for i in range(10) if i % 2 == 0} 20 | 21 | # >> CASE 22 | (True for _ in range(10)) 23 | 24 | # >> CASE 25 | (True for i in range(10) if i % 2 == 0) 26 | -------------------------------------------------------------------------------- /tests/samples/valid/ConditionalAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where ConditionalAchievement should unlock 2 | 3 | # >> CASE 4 | if True: 5 | pass 6 | 7 | # >> CASE 8 | if False: 9 | pass 10 | else: 11 | pass 12 | 13 | # >> CASE 14 | if False: 15 | pass 16 | elif True: 17 | pass 18 | else: 19 | pass 20 | 21 | # >> CASE 22 | 4 if True else 5 23 | -------------------------------------------------------------------------------- /tests/samples/valid/DictAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where DictAchievement should unlock 2 | 3 | # >> CASE 4 | {'name': 'John Doe', 'age': 24} 5 | 6 | # >> CASE 7 | { 8 | 'name': 'John Doe', 9 | 'age': 24 10 | } 11 | 12 | # >> CASE 13 | func({'name': 'John Doe', 'age': 24}) 14 | -------------------------------------------------------------------------------- /tests/samples/valid/FunctionAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where FunctionAchievement should unlock 2 | 3 | # >> CASE 4 | def test(): 5 | pass 6 | 7 | test() 8 | 9 | # >> CASE 10 | def func(): 11 | pass 12 | 13 | func() 14 | -------------------------------------------------------------------------------- /tests/samples/valid/HelloWorldAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where HelloWorldAchievement should unlock 2 | 3 | # >> CASE 4 | print('hello world!') 5 | 6 | # >> CASE 7 | print('Hello world', end='') 8 | 9 | # >> CASE 10 | print('Hello World') 11 | -------------------------------------------------------------------------------- /tests/samples/valid/LambdaAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where LambdaAchievement should unlock 2 | 3 | # >> CASE 4 | lambda x: x 5 | 6 | # >> CASE 7 | f = lambda x: x 8 | 9 | # >> CASE 10 | [lambda x: x for x in range(4)] 11 | 12 | # >> CASE 13 | def test(f): 14 | return f() 15 | 16 | test(lambda _: None) 17 | -------------------------------------------------------------------------------- /tests/samples/valid/ListAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where ListAchievement should unlock 2 | 3 | # >> CASE 4 | [4, 5, 6] 5 | 6 | # >> CASE 7 | x = [4, 5, 6] 8 | 9 | # >> CASE 10 | x = [4, '5', None, False, 1 + 2] 11 | 12 | # >> CASE 13 | [x, y] = [4, 5] 14 | -------------------------------------------------------------------------------- /tests/samples/valid/LoopsAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where LoopsAchievement should unlock 2 | 3 | # >> CASE 4 | for _ in range(4): 5 | pass 6 | 7 | # >> CASE 8 | while False: 9 | pass 10 | 11 | # >> CASE 12 | for _ in range(4): 13 | pass 14 | else: 15 | pass 16 | 17 | # >> CASE 18 | while False: 19 | pass 20 | else: 21 | pass 22 | -------------------------------------------------------------------------------- /tests/samples/valid/MathOperatorsAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where MathOperatorsAchievement should unlock 2 | 3 | # >> CASE 4 | 4 + 4 5 | 6 | # >> CASE 7 | 4 * 5 8 | 9 | # >> CASE 10 | 4 - 5 11 | 12 | # >> CASE 13 | 4 / 5 14 | 15 | # >> CASE 16 | 4 // 5 17 | 18 | # >> CASE 19 | 4 % 5 20 | 21 | # >> CASE 22 | 2 ** 3 23 | 24 | # >> CASE 25 | [4, 5] @ [4, 5] 26 | 27 | # >> CASE 28 | [4 + 5 - 4 * 5 / 4 // 5 % 5 ** 1] @ [4] 29 | -------------------------------------------------------------------------------- /tests/samples/valid/PassAchievement.py: -------------------------------------------------------------------------------- 1 | # cases where PassAchievement should unlock 2 | 3 | # >> CASE 4 | pass 5 | 6 | # >> CASE 7 | for _ in range(4): 8 | pass 9 | 10 | # >> CASE 11 | while False: 12 | pass 13 | 14 | # >> CASE 15 | def test(): 16 | pass 17 | 18 | # >> CASE 19 | class Test: 20 | pass 21 | -------------------------------------------------------------------------------- /tests/test.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import itertools 3 | import os 4 | import unittest 5 | 6 | from dev_achievements.achievements import * 7 | 8 | 9 | # some constants 10 | SAMPLE_SRCS_DIR = 'tests/samples/{valid}/{ach}.py' 11 | VALIDITY_DIR_NAMES = ['valid', 'invalid'] 12 | CASE_DELIM = '>> CASE' 13 | 14 | ALL_ACHIEVEMENTS = Achievement.subclasses() 15 | 16 | 17 | class TestAchievements(unittest.TestCase): 18 | """ Checks all Achievements using sample source code 19 | 20 | Unit tests are created and set dynamically, based on Achievements 21 | with written sample source code (in tests/samples directory). 22 | """ 23 | pass 24 | 25 | 26 | def _build_table(src): 27 | """ Builds AST tree table from given source. 28 | 29 | Args: 30 | src (str): source code 31 | 32 | Returns: 33 | dict: table of ast.AST nodes in tree 34 | """ 35 | table = {} 36 | tree = ast.parse(src) 37 | for node in ast.walk(tree): 38 | curr = table.get(node.__class__, []) 39 | table[node.__class__] = curr + [node] 40 | return table 41 | 42 | 43 | def _read_file(file_path): 44 | """ Reads returns file contents. 45 | 46 | Args: 47 | file_path (str): path of file 48 | 49 | Returns: 50 | str: file contents 51 | """ 52 | lines = [] 53 | with open(file_path, 'r') as file: 54 | lines = file.readlines() 55 | return ''.join(lines) 56 | 57 | 58 | def _parse_sample_src(src): 59 | """ Splits sample source string based on CASE_DELIM. 60 | 61 | Args: 62 | src (str): sample source code 63 | 64 | Returns: 65 | list: sample source split by test case 66 | """ 67 | cases = src.split(CASE_DELIM) 68 | if len(cases) > 1: 69 | return cases[1:] 70 | return cases 71 | 72 | 73 | def create_achievement_test(ach, cases, exp_result): 74 | """ Creates a unit test out of the source cases. 75 | 76 | The Achievement's _check_condition method is called on each case 77 | given (as a subtest), and compared against the expected result. 78 | 79 | Args: 80 | ach (Achievement): Achievement for test 81 | cases (list): sample source cases 82 | exp_result (bool): expected _check_condition result 83 | 84 | Returns: 85 | function: created unit test 86 | """ 87 | def _test(self): 88 | for i, case in enumerate(cases): 89 | nodes = _build_table(case) 90 | res = ach()._check_condition(nodes) 91 | with self.subTest(case=i): 92 | self.assertEqual(res, exp_result) 93 | return _test 94 | 95 | 96 | # dynamically create and set unit tests for TestAchievements 97 | 98 | # combinations of Achievement names and valid/invalid sources 99 | v_ach_combos = itertools.product(VALIDITY_DIR_NAMES, ALL_ACHIEVEMENTS) 100 | for valid, ach in v_ach_combos: 101 | file_path = SAMPLE_SRCS_DIR.format(valid=valid, ach=ach.__name__) 102 | if not os.path.isfile(file_path): 103 | continue 104 | # get all cases in source file 105 | sample_src = _read_file(file_path) 106 | cases = _parse_sample_src(sample_src) 107 | exp_result = valid == 'valid' 108 | # create and set unit test method 109 | test_method = create_achievement_test(ach, cases, exp_result) 110 | test_method.__name__ = f'test_{ach.__name__}_{valid}' 111 | setattr(TestAchievements, test_method.__name__, test_method) 112 | --------------------------------------------------------------------------------