LGTM (40 Part Series)
1 LGTM Devlog 0: Teaching Git through playing a game
2 LGTM Devlog 1: New Logo, and jumping the gun by buying a domain
… 36 more parts…
3 LGTM Devlog 2: Design Ideas for the game I
4 LGTM Devlog 3: Design Ideas for the game 2
5 LGTM Devlog 4: Technical Decisions – GitHub API and Python
6 LGTM Devlog 5: Technical Decisions – Serverless Architecture
7 LGTM Devlog 6: Sprint 1 plans
8 LGTM Devlog 7: Creating a Firebase project
9 LGTM Devlog 8: Starting a git repo for an open source project from scratch
10 LGTM Devlog 9: Python Google Cloud Functions with Unit Tests and Linting
11 LGTM Devlog 10: Capturing the GitHub webhook for fork requests
12 LGTM Devlog 11: Writing the Serverless Function for receiving GitHub webhooks with Pydantic validation
13 LGTM Devlog 12: CI/CD with GitHub Actions to run Unit Tests and deploy Firebase Functions
14 LGTM Devlog 13: GitHub Branch Protection and Security
15 LGTM Devlog 14: Sprint 1 Retrospective
16 LGTM Devlog 15: Sprint 2 plans
17 LGTM Devlog 16: A serverless data base access rule conundrum
18 LGTM Devlog 17: Website and GitHub OAuth
19 LGTM Devlog 18: Python Serverless functions using GitHub API to validate users
20 LGTM Devlog 19: Game data/quest storage
21 LGTM Devlog 20: Python Abstract Base Class-based data/quest storage
22 LGTM Devlog 21: Deploying Pub/Sub-triggered Python Google Cloud Functions
23 LGTM Devlog 22: Modularization
24 LGTM Devlog 23: Sprint 2 Retrospective
25 LGTM Devlog 24: Sprint 3 plans
26 LGTM Devlog 25: Some cleanup
27 LGTM Devlog 26: Python Graphlib DAGs for Quest Stages
28 LGTM Devlog 27: Branching quests
29 LGTM Devlog 28: Game event loop using Google Cloud Scheduler and PubSub
30 LGTM Devlog 29: ORM for Firestore and __init__subclass__ dunders and metaclasses
31 LGTM Devlog 30: Sprint 3 Retrospective
32 LGTM Devlog 31: Sprint 4 Plan
33 LGTM Devlog 32: Secrets Management to avoid storing API keys in services
34 LGTM Devlog 33: Using PyGithub to post GitHub issues and comments
35 LGTM Devlog 34: Characters Posting on GitHub Issues
36 LGTM Devlog 35: Responding to the player’s answers on GitHub Issues Comments
37 LGTM Devlog 36: Character profiles! On GitHub
38 LGTM Devlog 37: Sprint 4 Retrospective
39 LGTM Devlog 38: Sprint 5 Plan
40 LGTM Devlog 39: Planning the story
I changed my mind about quest storage. Previously, I wanted to instantiate each quest as an object of the Quest
class, so something like this:
<span>intro_quest</span> <span>=</span> <span>Quest</span><span>(</span><span>"intro"</span><span>)</span><span>intro_quest</span><span>.</span><span>add_stage</span><span>(...)</span><span>intro_quest</span> <span>=</span> <span>Quest</span><span>(</span><span>"intro"</span><span>)</span> <span>intro_quest</span><span>.</span><span>add_stage</span><span>(...)</span>intro_quest = Quest("intro") intro_quest.add_stage(...)
Enter fullscreen mode Exit fullscreen mode
However, I realized a downside to this, which is that this abstraction is wrong for loading/unloading data. Since the game core loop has to process multiple users’ quests in a loop, I would have to somehow copy this instantiated object, or have it cleanly unload user data:
<span># inside game loop </span><span>intro_quest</span><span>.</span><span>load</span><span>(</span><span>user1_data</span><span>)</span><span>intro_quest</span><span>.</span><span>process_whatever</span><span>()</span><span>intro_quest</span><span>.</span><span>load</span><span>(</span><span>user2_data</span><span>)</span><span>intro_quest</span><span>.</span><span>process_whatever</span><span>()</span><span># inside game loop </span> <span>intro_quest</span><span>.</span><span>load</span><span>(</span><span>user1_data</span><span>)</span> <span>intro_quest</span><span>.</span><span>process_whatever</span><span>()</span> <span>intro_quest</span><span>.</span><span>load</span><span>(</span><span>user2_data</span><span>)</span> <span>intro_quest</span><span>.</span><span>process_whatever</span><span>()</span># inside game loop intro_quest.load(user1_data) intro_quest.process_whatever() intro_quest.load(user2_data) intro_quest.process_whatever()
Enter fullscreen mode Exit fullscreen mode
While this works, I feel this is the wrong abstraction. Instead, I feel it is better if intro_quest
was instead an actual Class, which means we would be able to instantiate user data as an object of this quest class, and not have to deal with unloadign data:
For example, if we subclassed Quest as IntroQuest:
<span>IntroQuest</span><span>(</span><span>Quest</span><span>):</span><span>def</span> <span>_init_</span><span>(</span><span>self</span><span>):</span><span>...</span><span>IntroQuest</span><span>(</span><span>Quest</span><span>):</span> <span>def</span> <span>_init_</span><span>(</span><span>self</span><span>):</span> <span>...</span>IntroQuest(Quest): def _init_(self): ...
Enter fullscreen mode Exit fullscreen mode
# inside game loopuser1_quest = IntroQuest(user1_data)user2_quest = IntroQuest(user2_data)# inside game loop user1_quest = IntroQuest(user1_data) user2_quest = IntroQuest(user2_data)# inside game loop user1_quest = IntroQuest(user1_data) user2_quest = IntroQuest(user2_data)
Enter fullscreen mode Exit fullscreen mode
ABCs
To do this, I’m going to turn the parent Quest
class into an Abstract Base Classes, which lets me define certain properties and methods which subclasses should have. ABCs were actually in one of my earliest blog post on Dev.to!
The benefit of using an Abstract Base Class (ABC) in Python, is helps ensure the implementation for each Quest is correct – any errors in implementation – if the concrete implementation of a quest is missing needed methods that our game loop will call later, then the code will error out on instantiation, letting us know a function is missing.
So, I can re-define the same Quest from last post as an ABC, declaring some of the metadata I need as abstract class properties. I’ve also defined a Difficulty enum. semver_safe
is the same as last time.
<span>from</span> <span>copy</span> <span>import</span> <span>deepcopy</span><span>from</span> <span>typing</span> <span>import</span> <span>Any</span><span>,</span> <span>Dict</span><span>,</span> <span>ClassVar</span><span>from</span> <span>abc</span> <span>import</span> <span>ABC</span><span>,</span> <span>abstractmethod</span><span>from</span> <span>enum</span> <span>import</span> <span>Enum</span><span>from</span> <span>semver</span> <span>import</span> <span>VersionInfo</span> <span># type: ignore </span><span>from</span> <span>.exceptions</span> <span>import</span> <span>QuestLoadError</span><span>class</span> <span>Difficulty</span><span>(</span><span>Enum</span><span>):</span><span>RESERVED</span> <span>=</span> <span>0</span><span>BEGINNER</span> <span>=</span> <span>1</span><span>INTERMEDIATE</span> <span>=</span> <span>2</span><span>ADVANCED</span> <span>=</span> <span>3</span><span>EXPERT</span> <span>=</span> <span>4</span><span>HACKER</span> <span>=</span> <span>5</span><span>def</span> <span>semver_safe</span><span>(</span><span>start</span><span>:</span> <span>VersionInfo</span><span>,</span> <span>dest</span><span>:</span> <span>VersionInfo</span><span>)</span> <span>-></span> <span>bool</span><span>:</span><span>""" whether semver loading is going to be safe """</span><span>if</span> <span>start</span><span>.</span><span>major</span> <span>!=</span> <span>dest</span><span>.</span><span>major</span><span>:</span><span>return</span> <span>False</span><span># check it's not a downgrade of minor version </span> <span>if</span> <span>start</span><span>.</span><span>minor</span> <span>></span> <span>dest</span><span>.</span><span>minor</span><span>:</span><span>return</span> <span>False</span><span>return</span> <span>True</span><span>class</span> <span>Quest</span><span>(</span><span>ABC</span><span>):</span><span>@</span><span>property</span><span>@</span><span>abstractmethod</span><span>def</span> <span>version</span><span>(</span><span>cls</span><span>)</span> <span>-></span> <span>VersionInfo</span><span>:</span><span>...</span><span>@</span><span>property</span><span>@</span><span>abstractmethod</span><span>def</span> <span>difficulty</span><span>(</span><span>cls</span><span>)</span> <span>-></span> <span>Difficulty</span><span>:</span><span>return</span> <span>NotImplemented</span><span>@</span><span>property</span><span>@</span><span>abstractmethod</span><span>def</span> <span>description</span><span>(</span><span>cls</span><span>)</span> <span>-></span> <span>str</span><span>:</span><span>return</span> <span>NotImplemented</span><span>default_data</span><span>:</span> <span>ClassVar</span><span>[</span><span>Dict</span><span>[</span><span>str</span><span>,</span> <span>Any</span><span>]]</span> <span>=</span> <span>{}</span><span>quest_data</span><span>:</span> <span>Dict</span><span>[</span><span>str</span><span>,</span> <span>Any</span><span>]</span> <span>=</span> <span>{}</span><span>VERSION_KEY</span> <span>=</span> <span>"_version"</span><span>def</span> <span>__init_subclass__</span><span>(</span><span>self</span><span>):</span><span>self</span><span>.</span><span>quest_data</span> <span>=</span> <span>deepcopy</span><span>(</span><span>self</span><span>.</span><span>default_data</span><span>)</span><span>def</span> <span>load</span><span>(</span><span>self</span><span>,</span> <span>save_data</span><span>:</span> <span>Dict</span><span>[</span><span>str</span><span>,</span> <span>Any</span><span>])</span> <span>-></span> <span>None</span><span>:</span><span>""" Load save data back into structure """</span><span># check save version is safe before upgrading </span> <span>save_semver</span> <span>=</span> <span>VersionInfo</span><span>.</span><span>parse</span><span>(</span><span>save_data</span><span>[</span><span>self</span><span>.</span><span>VERSION_KEY</span><span>])</span><span>if</span> <span>not</span> <span>semver_safe</span><span>(</span><span>save_semver</span><span>,</span> <span>self</span><span>.</span><span>version</span><span>):</span><span>raise</span> <span>QuestLoadError</span><span>(</span><span>f</span><span>"Unsafe version mismatch! </span><span>{</span><span>save_semver</span><span>}</span><span> -> </span><span>{</span><span>self</span><span>.</span><span>version</span><span>}</span><span>"</span><span>)</span><span>self</span><span>.</span><span>quest_data</span><span>.</span><span>update</span><span>(</span><span>save_data</span><span>)</span><span>def</span> <span>get_save_data</span><span>(</span><span>self</span><span>)</span> <span>-></span> <span>Dict</span><span>[</span><span>str</span><span>,</span> <span>Any</span><span>]:</span><span>""" Updates save data with new version and output """</span><span>self</span><span>.</span><span>quest_data</span><span>[</span><span>self</span><span>.</span><span>VERSION_KEY</span><span>]</span> <span>=</span> <span>str</span><span>(</span><span>self</span><span>.</span><span>version</span><span>)</span><span>return</span> <span>self</span><span>.</span><span>quest_data</span><span>from</span> <span>copy</span> <span>import</span> <span>deepcopy</span> <span>from</span> <span>typing</span> <span>import</span> <span>Any</span><span>,</span> <span>Dict</span><span>,</span> <span>ClassVar</span> <span>from</span> <span>abc</span> <span>import</span> <span>ABC</span><span>,</span> <span>abstractmethod</span> <span>from</span> <span>enum</span> <span>import</span> <span>Enum</span> <span>from</span> <span>semver</span> <span>import</span> <span>VersionInfo</span> <span># type: ignore </span> <span>from</span> <span>.exceptions</span> <span>import</span> <span>QuestLoadError</span> <span>class</span> <span>Difficulty</span><span>(</span><span>Enum</span><span>):</span> <span>RESERVED</span> <span>=</span> <span>0</span> <span>BEGINNER</span> <span>=</span> <span>1</span> <span>INTERMEDIATE</span> <span>=</span> <span>2</span> <span>ADVANCED</span> <span>=</span> <span>3</span> <span>EXPERT</span> <span>=</span> <span>4</span> <span>HACKER</span> <span>=</span> <span>5</span> <span>def</span> <span>semver_safe</span><span>(</span><span>start</span><span>:</span> <span>VersionInfo</span><span>,</span> <span>dest</span><span>:</span> <span>VersionInfo</span><span>)</span> <span>-></span> <span>bool</span><span>:</span> <span>""" whether semver loading is going to be safe """</span> <span>if</span> <span>start</span><span>.</span><span>major</span> <span>!=</span> <span>dest</span><span>.</span><span>major</span><span>:</span> <span>return</span> <span>False</span> <span># check it's not a downgrade of minor version </span> <span>if</span> <span>start</span><span>.</span><span>minor</span> <span>></span> <span>dest</span><span>.</span><span>minor</span><span>:</span> <span>return</span> <span>False</span> <span>return</span> <span>True</span> <span>class</span> <span>Quest</span><span>(</span><span>ABC</span><span>):</span> <span>@</span><span>property</span> <span>@</span><span>abstractmethod</span> <span>def</span> <span>version</span><span>(</span><span>cls</span><span>)</span> <span>-></span> <span>VersionInfo</span><span>:</span> <span>...</span> <span>@</span><span>property</span> <span>@</span><span>abstractmethod</span> <span>def</span> <span>difficulty</span><span>(</span><span>cls</span><span>)</span> <span>-></span> <span>Difficulty</span><span>:</span> <span>return</span> <span>NotImplemented</span> <span>@</span><span>property</span> <span>@</span><span>abstractmethod</span> <span>def</span> <span>description</span><span>(</span><span>cls</span><span>)</span> <span>-></span> <span>str</span><span>:</span> <span>return</span> <span>NotImplemented</span> <span>default_data</span><span>:</span> <span>ClassVar</span><span>[</span><span>Dict</span><span>[</span><span>str</span><span>,</span> <span>Any</span><span>]]</span> <span>=</span> <span>{}</span> <span>quest_data</span><span>:</span> <span>Dict</span><span>[</span><span>str</span><span>,</span> <span>Any</span><span>]</span> <span>=</span> <span>{}</span> <span>VERSION_KEY</span> <span>=</span> <span>"_version"</span> <span>def</span> <span>__init_subclass__</span><span>(</span><span>self</span><span>):</span> <span>self</span><span>.</span><span>quest_data</span> <span>=</span> <span>deepcopy</span><span>(</span><span>self</span><span>.</span><span>default_data</span><span>)</span> <span>def</span> <span>load</span><span>(</span><span>self</span><span>,</span> <span>save_data</span><span>:</span> <span>Dict</span><span>[</span><span>str</span><span>,</span> <span>Any</span><span>])</span> <span>-></span> <span>None</span><span>:</span> <span>""" Load save data back into structure """</span> <span># check save version is safe before upgrading </span> <span>save_semver</span> <span>=</span> <span>VersionInfo</span><span>.</span><span>parse</span><span>(</span><span>save_data</span><span>[</span><span>self</span><span>.</span><span>VERSION_KEY</span><span>])</span> <span>if</span> <span>not</span> <span>semver_safe</span><span>(</span><span>save_semver</span><span>,</span> <span>self</span><span>.</span><span>version</span><span>):</span> <span>raise</span> <span>QuestLoadError</span><span>(</span> <span>f</span><span>"Unsafe version mismatch! </span><span>{</span><span>save_semver</span><span>}</span><span> -> </span><span>{</span><span>self</span><span>.</span><span>version</span><span>}</span><span>"</span> <span>)</span> <span>self</span><span>.</span><span>quest_data</span><span>.</span><span>update</span><span>(</span><span>save_data</span><span>)</span> <span>def</span> <span>get_save_data</span><span>(</span><span>self</span><span>)</span> <span>-></span> <span>Dict</span><span>[</span><span>str</span><span>,</span> <span>Any</span><span>]:</span> <span>""" Updates save data with new version and output """</span> <span>self</span><span>.</span><span>quest_data</span><span>[</span><span>self</span><span>.</span><span>VERSION_KEY</span><span>]</span> <span>=</span> <span>str</span><span>(</span><span>self</span><span>.</span><span>version</span><span>)</span> <span>return</span> <span>self</span><span>.</span><span>quest_data</span>from copy import deepcopy from typing import Any, Dict, ClassVar from abc import ABC, abstractmethod from enum import Enum from semver import VersionInfo # type: ignore from .exceptions import QuestLoadError class Difficulty(Enum): RESERVED = 0 BEGINNER = 1 INTERMEDIATE = 2 ADVANCED = 3 EXPERT = 4 HACKER = 5 def semver_safe(start: VersionInfo, dest: VersionInfo) -> bool: """ whether semver loading is going to be safe """ if start.major != dest.major: return False # check it's not a downgrade of minor version if start.minor > dest.minor: return False return True class Quest(ABC): @property @abstractmethod def version(cls) -> VersionInfo: ... @property @abstractmethod def difficulty(cls) -> Difficulty: return NotImplemented @property @abstractmethod def description(cls) -> str: return NotImplemented default_data: ClassVar[Dict[str, Any]] = {} quest_data: Dict[str, Any] = {} VERSION_KEY = "_version" def __init_subclass__(self): self.quest_data = deepcopy(self.default_data) def load(self, save_data: Dict[str, Any]) -> None: """ Load save data back into structure """ # check save version is safe before upgrading save_semver = VersionInfo.parse(save_data[self.VERSION_KEY]) if not semver_safe(save_semver, self.version): raise QuestLoadError( f"Unsafe version mismatch! {save_semver} -> {self.version}" ) self.quest_data.update(save_data) def get_save_data(self) -> Dict[str, Any]: """ Updates save data with new version and output """ self.quest_data[self.VERSION_KEY] = str(self.version) return self.quest_data
Enter fullscreen mode Exit fullscreen mode
Our concrete implementation now looks like this:
<span>from</span> <span>typing</span> <span>import</span> <span>TYPE_CHECKING</span><span>from</span> <span>semver</span> <span>import</span> <span>VersionInfo</span> <span># type: ignore </span><span>from</span> <span>..quest_system</span> <span>import</span> <span>Quest</span><span>,</span> <span>Difficulty</span><span>class</span> <span>IntroQuest</span><span>(</span><span>Quest</span><span>):</span><span>version</span> <span>=</span> <span>VersionInfo</span><span>.</span><span>parse</span><span>(</span><span>"0.1.0"</span><span>)</span><span>difficulty</span> <span>=</span> <span>Difficulty</span><span>.</span><span>BEGINNER</span><span>description</span> <span>=</span> <span>"The intro quest"</span><span>if</span> <span>TYPE_CHECKING</span><span>:</span><span>IntroQuest</span><span>()</span><span>from</span> <span>typing</span> <span>import</span> <span>TYPE_CHECKING</span> <span>from</span> <span>semver</span> <span>import</span> <span>VersionInfo</span> <span># type: ignore </span><span>from</span> <span>..quest_system</span> <span>import</span> <span>Quest</span><span>,</span> <span>Difficulty</span> <span>class</span> <span>IntroQuest</span><span>(</span><span>Quest</span><span>):</span> <span>version</span> <span>=</span> <span>VersionInfo</span><span>.</span><span>parse</span><span>(</span><span>"0.1.0"</span><span>)</span> <span>difficulty</span> <span>=</span> <span>Difficulty</span><span>.</span><span>BEGINNER</span> <span>description</span> <span>=</span> <span>"The intro quest"</span> <span>if</span> <span>TYPE_CHECKING</span><span>:</span> <span>IntroQuest</span><span>()</span>from typing import TYPE_CHECKING from semver import VersionInfo # type: ignore from ..quest_system import Quest, Difficulty class IntroQuest(Quest): version = VersionInfo.parse("0.1.0") difficulty = Difficulty.BEGINNER description = "The intro quest" if TYPE_CHECKING: IntroQuest()
Enter fullscreen mode Exit fullscreen mode
The if TYPE_CHECKING
at the end is there because until you instantiate a class, it won’t be checked, so I have to actually instantiate the class in the code, but also I only want to do this at type-checking time. The typing
library therefore provides us with a TYPE_CHECKING
boolean for this purpose.
An example of an incorrect implementation, where the version
is missing
<span>class</span> <span>BrokenQuest</span><span>(</span><span>Quest</span><span>):</span><span>difficulty</span> <span>=</span> <span>Difficulty</span><span>.</span><span>BEGINNER</span><span>description</span> <span>=</span> <span>"This quest is broken"</span><span>if</span> <span>TYPE_CHECKING</span><span>:</span><span>BrokenQuest</span><span>()</span><span>class</span> <span>BrokenQuest</span><span>(</span><span>Quest</span><span>):</span> <span>difficulty</span> <span>=</span> <span>Difficulty</span><span>.</span><span>BEGINNER</span> <span>description</span> <span>=</span> <span>"This quest is broken"</span> <span>if</span> <span>TYPE_CHECKING</span><span>:</span> <span>BrokenQuest</span><span>()</span>class BrokenQuest(Quest): difficulty = Difficulty.BEGINNER description = "This quest is broken" if TYPE_CHECKING: BrokenQuest()
Enter fullscreen mode Exit fullscreen mode
Running mypy on this would give us the following error, telling us we’re missing version
error: Cannot instantiate abstract class 'BrokenQuest' with abstract attribute 'version'error: Cannot instantiate abstract class 'BrokenQuest' with abstract attribute 'version'error: Cannot instantiate abstract class 'BrokenQuest' with abstract attribute 'version'
Enter fullscreen mode Exit fullscreen mode
The load/save tests have been updated accordingly:
<span>def</span> <span>test_quest_load_fail</span><span>():</span><span>""" Tests a quest load fail due to semver mismatch """</span><span># generate a bad save data version </span> <span>save_data</span> <span>=</span> <span>deepcopy</span><span>(</span><span>DebugQuest</span><span>.</span><span>default_data</span><span>)</span><span>save_data</span><span>[</span><span>DebugQuest</span><span>.</span><span>VERSION_KEY</span><span>]</span> <span>=</span> <span>str</span><span>(</span><span>DebugQuest</span><span>.</span><span>version</span><span>.</span><span>bump_major</span><span>())</span><span># create a new game and try to load with the bad version </span> <span>quest</span> <span>=</span> <span>DebugQuest</span><span>()</span><span>with</span> <span>pytest</span><span>.</span><span>raises</span><span>(</span><span>QuestLoadError</span><span>):</span><span>quest</span><span>.</span><span>load</span><span>(</span><span>save_data</span><span>)</span><span>def</span> <span>test_quest_load_save</span><span>():</span><span>""" Tests a successful load with matching semvar """</span><span># generate save data version </span> <span>save_data</span> <span>=</span> <span>deepcopy</span><span>(</span><span>DebugQuest</span><span>.</span><span>default_data</span><span>)</span><span>save_data</span><span>[</span><span>DebugQuest</span><span>.</span><span>VERSION_KEY</span><span>]</span> <span>=</span> <span>str</span><span>(</span><span>DebugQuest</span><span>.</span><span>version</span><span>)</span><span># create a new game and load the good version </span> <span>quest</span> <span>=</span> <span>DebugQuest</span><span>()</span><span>quest</span><span>.</span><span>load</span><span>(</span><span>save_data</span><span>)</span><span>assert</span> <span>quest</span><span>.</span><span>get_save_data</span><span>()</span> <span>==</span> <span>save_data</span><span>def</span> <span>test_quest_load_fail</span><span>():</span> <span>""" Tests a quest load fail due to semver mismatch """</span> <span># generate a bad save data version </span> <span>save_data</span> <span>=</span> <span>deepcopy</span><span>(</span><span>DebugQuest</span><span>.</span><span>default_data</span><span>)</span> <span>save_data</span><span>[</span><span>DebugQuest</span><span>.</span><span>VERSION_KEY</span><span>]</span> <span>=</span> <span>str</span><span>(</span><span>DebugQuest</span><span>.</span><span>version</span><span>.</span><span>bump_major</span><span>())</span> <span># create a new game and try to load with the bad version </span> <span>quest</span> <span>=</span> <span>DebugQuest</span><span>()</span> <span>with</span> <span>pytest</span><span>.</span><span>raises</span><span>(</span><span>QuestLoadError</span><span>):</span> <span>quest</span><span>.</span><span>load</span><span>(</span><span>save_data</span><span>)</span> <span>def</span> <span>test_quest_load_save</span><span>():</span> <span>""" Tests a successful load with matching semvar """</span> <span># generate save data version </span> <span>save_data</span> <span>=</span> <span>deepcopy</span><span>(</span><span>DebugQuest</span><span>.</span><span>default_data</span><span>)</span> <span>save_data</span><span>[</span><span>DebugQuest</span><span>.</span><span>VERSION_KEY</span><span>]</span> <span>=</span> <span>str</span><span>(</span><span>DebugQuest</span><span>.</span><span>version</span><span>)</span> <span># create a new game and load the good version </span> <span>quest</span> <span>=</span> <span>DebugQuest</span><span>()</span> <span>quest</span><span>.</span><span>load</span><span>(</span><span>save_data</span><span>)</span> <span>assert</span> <span>quest</span><span>.</span><span>get_save_data</span><span>()</span> <span>==</span> <span>save_data</span>def test_quest_load_fail(): """ Tests a quest load fail due to semver mismatch """ # generate a bad save data version save_data = deepcopy(DebugQuest.default_data) save_data[DebugQuest.VERSION_KEY] = str(DebugQuest.version.bump_major()) # create a new game and try to load with the bad version quest = DebugQuest() with pytest.raises(QuestLoadError): quest.load(save_data) def test_quest_load_save(): """ Tests a successful load with matching semvar """ # generate save data version save_data = deepcopy(DebugQuest.default_data) save_data[DebugQuest.VERSION_KEY] = str(DebugQuest.version) # create a new game and load the good version quest = DebugQuest() quest.load(save_data) assert quest.get_save_data() == save_data
Enter fullscreen mode Exit fullscreen mode
It’s a little clunky, as I am copying out the default_data property directly to generate save files.
Auto-loading all the quests
The way I would like the quests to work is I add each quest as a Class (whose base class is Quest
), and then the module automatically loads this, so that I don’t have to manually maintain a list of quests somewhere.
The code I use for that is the following in the __init__.py
file in the quests
folder:
<span>from</span> <span>typing</span> <span>import</span> <span>Type</span><span>import</span> <span>os</span><span>import</span> <span>pkgutil</span><span>import</span> <span>importlib</span><span>import</span> <span>inspect</span><span>from</span> <span>..system</span> <span>import</span> <span>Quest</span><span>from</span> <span>..exceptions</span> <span>import</span> <span>QuestError</span><span>all_quests</span> <span>=</span> <span>{}</span><span>for</span> <span>_importer</span><span>,</span> <span>_name</span><span>,</span> <span>_</span> <span>in</span> <span>pkgutil</span><span>.</span><span>iter_modules</span><span>(</span><span>path</span><span>=</span><span>[</span><span>os</span><span>.</span><span>path</span><span>.</span><span>dirname</span><span>(</span><span>__file__</span><span>)]):</span><span>_module</span> <span>=</span> <span>importlib</span><span>.</span><span>import_module</span><span>(</span><span>"."</span> <span>+</span> <span>_name</span><span>,</span> <span>__package__</span><span>)</span><span>_classes</span> <span>=</span> <span>inspect</span><span>.</span><span>getmembers</span><span>(</span><span>_module</span><span>,</span> <span>inspect</span><span>.</span><span>isclass</span><span>)</span><span>for</span> <span>_parent</span><span>,</span> <span>_class</span> <span>in</span> <span>_classes</span><span>:</span><span>if</span> <span>Quest</span> <span>in</span> <span>_class</span><span>.</span><span>__bases__</span><span>:</span><span>if</span> <span>_class</span><span>.</span><span>__name__</span> <span>in</span> <span>all_quests</span><span>:</span><span>raise</span> <span>ValueError</span><span>(</span><span>f</span><span>"Duplicate quests found with name </span><span>{</span><span>_class</span><span>.</span><span>__name__</span><span>}</span><span>"</span><span>)</span><span>all_quests</span><span>[</span><span>_class</span><span>.</span><span>__name__</span><span>]</span> <span>=</span> <span>_class</span><span>def</span> <span>get_quest_by_name</span><span>(</span><span>name</span><span>:</span> <span>str</span><span>)</span> <span>-></span> <span>Type</span><span>[</span><span>Quest</span><span>]:</span><span>try</span><span>:</span><span>return</span> <span>all_quests</span><span>[</span><span>name</span><span>]</span><span>except</span> <span>KeyError</span> <span>as</span> <span>err</span><span>:</span><span>raise</span> <span>QuestError</span><span>(</span><span>f</span><span>"No quest name </span><span>{</span><span>name</span><span>}</span><span>"</span><span>)</span> <span>from</span> <span>err</span><span>from</span> <span>typing</span> <span>import</span> <span>Type</span> <span>import</span> <span>os</span> <span>import</span> <span>pkgutil</span> <span>import</span> <span>importlib</span> <span>import</span> <span>inspect</span> <span>from</span> <span>..system</span> <span>import</span> <span>Quest</span> <span>from</span> <span>..exceptions</span> <span>import</span> <span>QuestError</span> <span>all_quests</span> <span>=</span> <span>{}</span> <span>for</span> <span>_importer</span><span>,</span> <span>_name</span><span>,</span> <span>_</span> <span>in</span> <span>pkgutil</span><span>.</span><span>iter_modules</span><span>(</span><span>path</span><span>=</span><span>[</span><span>os</span><span>.</span><span>path</span><span>.</span><span>dirname</span><span>(</span><span>__file__</span><span>)]):</span> <span>_module</span> <span>=</span> <span>importlib</span><span>.</span><span>import_module</span><span>(</span><span>"."</span> <span>+</span> <span>_name</span><span>,</span> <span>__package__</span><span>)</span> <span>_classes</span> <span>=</span> <span>inspect</span><span>.</span><span>getmembers</span><span>(</span><span>_module</span><span>,</span> <span>inspect</span><span>.</span><span>isclass</span><span>)</span> <span>for</span> <span>_parent</span><span>,</span> <span>_class</span> <span>in</span> <span>_classes</span><span>:</span> <span>if</span> <span>Quest</span> <span>in</span> <span>_class</span><span>.</span><span>__bases__</span><span>:</span> <span>if</span> <span>_class</span><span>.</span><span>__name__</span> <span>in</span> <span>all_quests</span><span>:</span> <span>raise</span> <span>ValueError</span><span>(</span><span>f</span><span>"Duplicate quests found with name </span><span>{</span><span>_class</span><span>.</span><span>__name__</span><span>}</span><span>"</span><span>)</span> <span>all_quests</span><span>[</span><span>_class</span><span>.</span><span>__name__</span><span>]</span> <span>=</span> <span>_class</span> <span>def</span> <span>get_quest_by_name</span><span>(</span><span>name</span><span>:</span> <span>str</span><span>)</span> <span>-></span> <span>Type</span><span>[</span><span>Quest</span><span>]:</span> <span>try</span><span>:</span> <span>return</span> <span>all_quests</span><span>[</span><span>name</span><span>]</span> <span>except</span> <span>KeyError</span> <span>as</span> <span>err</span><span>:</span> <span>raise</span> <span>QuestError</span><span>(</span><span>f</span><span>"No quest name </span><span>{</span><span>name</span><span>}</span><span>"</span><span>)</span> <span>from</span> <span>err</span>from typing import Type import os import pkgutil import importlib import inspect from ..system import Quest from ..exceptions import QuestError all_quests = {} for _importer, _name, _ in pkgutil.iter_modules(path=[os.path.dirname(__file__)]): _module = importlib.import_module("." + _name, __package__) _classes = inspect.getmembers(_module, inspect.isclass) for _parent, _class in _classes: if Quest in _class.__bases__: if _class.__name__ in all_quests: raise ValueError(f"Duplicate quests found with name {_class.__name__}") all_quests[_class.__name__] = _class def get_quest_by_name(name: str) -> Type[Quest]: try: return all_quests[name] except KeyError as err: raise QuestError(f"No quest name {name}") from err
Enter fullscreen mode Exit fullscreen mode
This allows me to simply from quests import all_quests
to fetch all the quests, or, use the get_quest_by_name()
conevenience function to do the lookup
The tests can then loop through and instantiate all of the quest classes to double-check they’re implemented correctly according to the abstract base class:
<span>def</span> <span>test_quest_class_fail</span><span>():</span><span>""" Try to load a non-existant class """</span><span>with</span> <span>pytest</span><span>.</span><span>raises</span><span>(</span><span>QuestError</span><span>):</span><span>get_quest_by_name</span><span>(</span><span>"_does not exist_"</span><span>)</span><span>def</span> <span>test_get_quest</span><span>():</span><span>""" A successful class fetch """</span><span>assert</span> <span>get_quest_by_name</span><span>(</span><span>DebugQuest</span><span>.</span><span>__name__</span><span>)</span> <span>==</span> <span>DebugQuest</span><span>def</span> <span>test_all_quest_subclasses</span><span>():</span><span>""" Instantiate all quests to check abstract base class implementation """</span><span>for</span> <span>quest_class</span> <span>in</span> <span>all_quests</span><span>.</span><span>values</span><span>():</span><span>quest_class</span><span>()</span> <span># should succeed if correctly implemented </span><span>def</span> <span>test_quest_class_fail</span><span>():</span> <span>""" Try to load a non-existant class """</span> <span>with</span> <span>pytest</span><span>.</span><span>raises</span><span>(</span><span>QuestError</span><span>):</span> <span>get_quest_by_name</span><span>(</span><span>"_does not exist_"</span><span>)</span> <span>def</span> <span>test_get_quest</span><span>():</span> <span>""" A successful class fetch """</span> <span>assert</span> <span>get_quest_by_name</span><span>(</span><span>DebugQuest</span><span>.</span><span>__name__</span><span>)</span> <span>==</span> <span>DebugQuest</span> <span>def</span> <span>test_all_quest_subclasses</span><span>():</span> <span>""" Instantiate all quests to check abstract base class implementation """</span> <span>for</span> <span>quest_class</span> <span>in</span> <span>all_quests</span><span>.</span><span>values</span><span>():</span> <span>quest_class</span><span>()</span> <span># should succeed if correctly implemented </span>def test_quest_class_fail(): """ Try to load a non-existant class """ with pytest.raises(QuestError): get_quest_by_name("_does not exist_") def test_get_quest(): """ A successful class fetch """ assert get_quest_by_name(DebugQuest.__name__) == DebugQuest def test_all_quest_subclasses(): """ Instantiate all quests to check abstract base class implementation """ for quest_class in all_quests.values(): quest_class() # should succeed if correctly implemented
Enter fullscreen mode Exit fullscreen mode
This update to the quest system (still missing actual implementation of quest stages) should now be in a better position to have new quests defined
LGTM (40 Part Series)
1 LGTM Devlog 0: Teaching Git through playing a game
2 LGTM Devlog 1: New Logo, and jumping the gun by buying a domain
… 36 more parts…
3 LGTM Devlog 2: Design Ideas for the game I
4 LGTM Devlog 3: Design Ideas for the game 2
5 LGTM Devlog 4: Technical Decisions – GitHub API and Python
6 LGTM Devlog 5: Technical Decisions – Serverless Architecture
7 LGTM Devlog 6: Sprint 1 plans
8 LGTM Devlog 7: Creating a Firebase project
9 LGTM Devlog 8: Starting a git repo for an open source project from scratch
10 LGTM Devlog 9: Python Google Cloud Functions with Unit Tests and Linting
11 LGTM Devlog 10: Capturing the GitHub webhook for fork requests
12 LGTM Devlog 11: Writing the Serverless Function for receiving GitHub webhooks with Pydantic validation
13 LGTM Devlog 12: CI/CD with GitHub Actions to run Unit Tests and deploy Firebase Functions
14 LGTM Devlog 13: GitHub Branch Protection and Security
15 LGTM Devlog 14: Sprint 1 Retrospective
16 LGTM Devlog 15: Sprint 2 plans
17 LGTM Devlog 16: A serverless data base access rule conundrum
18 LGTM Devlog 17: Website and GitHub OAuth
19 LGTM Devlog 18: Python Serverless functions using GitHub API to validate users
20 LGTM Devlog 19: Game data/quest storage
21 LGTM Devlog 20: Python Abstract Base Class-based data/quest storage
22 LGTM Devlog 21: Deploying Pub/Sub-triggered Python Google Cloud Functions
23 LGTM Devlog 22: Modularization
24 LGTM Devlog 23: Sprint 2 Retrospective
25 LGTM Devlog 24: Sprint 3 plans
26 LGTM Devlog 25: Some cleanup
27 LGTM Devlog 26: Python Graphlib DAGs for Quest Stages
28 LGTM Devlog 27: Branching quests
29 LGTM Devlog 28: Game event loop using Google Cloud Scheduler and PubSub
30 LGTM Devlog 29: ORM for Firestore and __init__subclass__ dunders and metaclasses
31 LGTM Devlog 30: Sprint 3 Retrospective
32 LGTM Devlog 31: Sprint 4 Plan
33 LGTM Devlog 32: Secrets Management to avoid storing API keys in services
34 LGTM Devlog 33: Using PyGithub to post GitHub issues and comments
35 LGTM Devlog 34: Characters Posting on GitHub Issues
36 LGTM Devlog 35: Responding to the player’s answers on GitHub Issues Comments
37 LGTM Devlog 36: Character profiles! On GitHub
38 LGTM Devlog 37: Sprint 4 Retrospective
39 LGTM Devlog 38: Sprint 5 Plan
40 LGTM Devlog 39: Planning the story
原文链接:LGTM Devlog 20: Python Abstract Base Class-based data/quest storage
暂无评论内容