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
Uh oh, I refactored again. I wasn’t quite happy with how messy it was, how each Game, User, and Quest entity was handling their data, there was a lot of duplicate code when it came to communicating with the database, and when that happened.
For example, the game object prior to the latest change looks like this:
<span>class</span> <span>Game</span><span>:</span><span>@</span><span>classmethod</span><span>def</span> <span>from_user</span><span>(</span><span>cls</span><span>,</span> <span>user</span><span>:</span> <span>User</span><span>)</span> <span>-></span> <span>Union</span><span>[</span><span>Game</span><span>,</span> <span>NoGameType</span><span>]:</span><span>""" Create a game from a user """</span><span>key</span> <span>=</span> <span>cls</span><span>.</span><span>make_key</span><span>(</span><span>user</span><span>)</span><span>game</span> <span>=</span> <span>cls</span><span>(</span><span>key</span><span>)</span><span>game</span><span>.</span><span>user</span> <span>=</span> <span>user</span><span>docs</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>where</span><span>(</span><span>"user_key"</span><span>,</span> <span>"=="</span><span>,</span> <span>user</span><span>.</span><span>key</span><span>).</span><span>stream</span><span>()</span><span>for</span> <span>_</span> <span>in</span> <span>docs</span><span>:</span><span>return</span> <span>game</span><span>return</span> <span>NoGame</span><span>@</span><span>classmethod</span><span>def</span> <span>new_from_fork</span><span>(</span><span>cls</span><span>,</span> <span>user</span><span>:</span> <span>User</span><span>,</span> <span>fork_url</span><span>:</span> <span>str</span><span>)</span> <span>-></span> <span>Game</span><span>:</span><span>""" Save game with fork """</span><span>if</span> <span>not</span> <span>fork_url</span><span>:</span><span>raise</span> <span>ValueError</span><span>(</span><span>"Fork can't be blank"</span><span>)</span><span>key</span> <span>=</span> <span>cls</span><span>.</span><span>make_key</span><span>(</span><span>user</span><span>)</span><span>game</span> <span>=</span> <span>cls</span><span>(</span><span>key</span><span>)</span><span>game</span><span>.</span><span>user</span> <span>=</span> <span>user</span><span>game_doc</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game</span><span>.</span><span>key</span><span>).</span><span>get</span><span>()</span><span>if</span> <span>game_doc</span><span>.</span><span>exists</span><span>:</span><span>game_doc</span><span>.</span><span>reference</span><span>.</span><span>set</span><span>(</span><span>{</span><span>"fork_url"</span><span>:</span> <span>fork_url</span><span>,</span><span>"user_uid"</span><span>:</span> <span>user</span><span>.</span><span>uid</span><span>,</span><span>"user_key"</span><span>:</span> <span>user</span><span>.</span><span>key</span><span>,</span><span>},</span><span>merge</span><span>=</span><span>True</span><span>,</span><span>)</span><span>else</span><span>:</span><span>game_doc</span><span>.</span><span>reference</span><span>.</span><span>set</span><span>(</span><span>{</span><span>"fork_url"</span><span>:</span> <span>fork_url</span><span>,</span><span>"user_uid"</span><span>:</span> <span>user</span><span>.</span><span>uid</span><span>,</span><span>"user_key"</span><span>:</span> <span>user</span><span>.</span><span>key</span><span>,</span><span>"joined"</span><span>:</span> <span>firestore</span><span>.</span><span>SERVER_TIMESTAMP</span><span>,</span><span>}</span><span>)</span><span>return</span> <span>game</span><span>@</span><span>staticmethod</span><span>def</span> <span>make_key</span><span>(</span><span>user</span><span>:</span> <span>User</span><span>)</span> <span>-></span> <span>str</span><span>:</span><span>""" Game's key ARE user key due to 1:1 relationship """</span><span>return</span> <span>user</span><span>.</span><span>key</span><span>key</span><span>:</span> <span>str</span><span>user</span><span>:</span> <span>Union</span><span>[</span><span>User</span><span>,</span> <span>NoUserType</span><span>]</span><span>def</span> <span>__init__</span><span>(</span><span>self</span><span>,</span> <span>key</span><span>:</span> <span>str</span><span>):</span><span>self</span><span>.</span><span>key</span> <span>=</span> <span>key</span><span>self</span><span>.</span><span>user</span> <span>=</span> <span>NoUser</span><span>def</span> <span>assign_to_uid</span><span>(</span><span>self</span><span>,</span> <span>uid</span><span>:</span> <span>str</span><span>)</span> <span>-></span> <span>None</span><span>:</span><span>""" Assign a user to this game """</span><span>doc</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>self</span><span>.</span><span>key</span><span>).</span><span>get</span><span>()</span><span>if</span> <span>doc</span><span>.</span><span>exists</span><span>:</span><span>doc</span><span>.</span><span>reference</span><span>.</span><span>set</span><span>({</span><span>"user_uid"</span><span>:</span> <span>uid</span><span>},</span> <span>merge</span><span>=</span><span>True</span><span>)</span><span>def</span> <span>start_first_quest</span><span>(</span><span>self</span><span>)</span> <span>-></span> <span>None</span><span>:</span><span>""" Create starting quest if not exist """</span><span>QuestClass</span> <span>=</span> <span>Quest</span><span>.</span><span>get_first_quest</span><span>()</span><span>quest</span> <span>=</span> <span>QuestClass</span><span>.</span><span>from_game</span><span>(</span><span>self</span><span>)</span><span>quest</span><span>.</span><span>execute_stages</span><span>(</span><span>TickType</span><span>.</span><span>FULL</span><span>)</span><span>quest</span><span>.</span><span>save</span><span>()</span><span>def</span> <span>__repr__</span><span>(</span><span>self</span><span>):</span><span>return</span> <span>f</span><span>"</span><span>{</span><span>self</span><span>.</span><span>__class__</span><span>.</span><span>__name__</span><span>}</span><span>(key=</span><span>{</span><span>self</span><span>.</span><span>key</span><span>}</span><span>)"</span><span>class</span> <span>Game</span><span>:</span> <span>@</span><span>classmethod</span> <span>def</span> <span>from_user</span><span>(</span><span>cls</span><span>,</span> <span>user</span><span>:</span> <span>User</span><span>)</span> <span>-></span> <span>Union</span><span>[</span><span>Game</span><span>,</span> <span>NoGameType</span><span>]:</span> <span>""" Create a game from a user """</span> <span>key</span> <span>=</span> <span>cls</span><span>.</span><span>make_key</span><span>(</span><span>user</span><span>)</span> <span>game</span> <span>=</span> <span>cls</span><span>(</span><span>key</span><span>)</span> <span>game</span><span>.</span><span>user</span> <span>=</span> <span>user</span> <span>docs</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>where</span><span>(</span><span>"user_key"</span><span>,</span> <span>"=="</span><span>,</span> <span>user</span><span>.</span><span>key</span><span>).</span><span>stream</span><span>()</span> <span>for</span> <span>_</span> <span>in</span> <span>docs</span><span>:</span> <span>return</span> <span>game</span> <span>return</span> <span>NoGame</span> <span>@</span><span>classmethod</span> <span>def</span> <span>new_from_fork</span><span>(</span><span>cls</span><span>,</span> <span>user</span><span>:</span> <span>User</span><span>,</span> <span>fork_url</span><span>:</span> <span>str</span><span>)</span> <span>-></span> <span>Game</span><span>:</span> <span>""" Save game with fork """</span> <span>if</span> <span>not</span> <span>fork_url</span><span>:</span> <span>raise</span> <span>ValueError</span><span>(</span><span>"Fork can't be blank"</span><span>)</span> <span>key</span> <span>=</span> <span>cls</span><span>.</span><span>make_key</span><span>(</span><span>user</span><span>)</span> <span>game</span> <span>=</span> <span>cls</span><span>(</span><span>key</span><span>)</span> <span>game</span><span>.</span><span>user</span> <span>=</span> <span>user</span> <span>game_doc</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game</span><span>.</span><span>key</span><span>).</span><span>get</span><span>()</span> <span>if</span> <span>game_doc</span><span>.</span><span>exists</span><span>:</span> <span>game_doc</span><span>.</span><span>reference</span><span>.</span><span>set</span><span>(</span> <span>{</span> <span>"fork_url"</span><span>:</span> <span>fork_url</span><span>,</span> <span>"user_uid"</span><span>:</span> <span>user</span><span>.</span><span>uid</span><span>,</span> <span>"user_key"</span><span>:</span> <span>user</span><span>.</span><span>key</span><span>,</span> <span>},</span> <span>merge</span><span>=</span><span>True</span><span>,</span> <span>)</span> <span>else</span><span>:</span> <span>game_doc</span><span>.</span><span>reference</span><span>.</span><span>set</span><span>(</span> <span>{</span> <span>"fork_url"</span><span>:</span> <span>fork_url</span><span>,</span> <span>"user_uid"</span><span>:</span> <span>user</span><span>.</span><span>uid</span><span>,</span> <span>"user_key"</span><span>:</span> <span>user</span><span>.</span><span>key</span><span>,</span> <span>"joined"</span><span>:</span> <span>firestore</span><span>.</span><span>SERVER_TIMESTAMP</span><span>,</span> <span>}</span> <span>)</span> <span>return</span> <span>game</span> <span>@</span><span>staticmethod</span> <span>def</span> <span>make_key</span><span>(</span><span>user</span><span>:</span> <span>User</span><span>)</span> <span>-></span> <span>str</span><span>:</span> <span>""" Game's key ARE user key due to 1:1 relationship """</span> <span>return</span> <span>user</span><span>.</span><span>key</span> <span>key</span><span>:</span> <span>str</span> <span>user</span><span>:</span> <span>Union</span><span>[</span><span>User</span><span>,</span> <span>NoUserType</span><span>]</span> <span>def</span> <span>__init__</span><span>(</span><span>self</span><span>,</span> <span>key</span><span>:</span> <span>str</span><span>):</span> <span>self</span><span>.</span><span>key</span> <span>=</span> <span>key</span> <span>self</span><span>.</span><span>user</span> <span>=</span> <span>NoUser</span> <span>def</span> <span>assign_to_uid</span><span>(</span><span>self</span><span>,</span> <span>uid</span><span>:</span> <span>str</span><span>)</span> <span>-></span> <span>None</span><span>:</span> <span>""" Assign a user to this game """</span> <span>doc</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>self</span><span>.</span><span>key</span><span>).</span><span>get</span><span>()</span> <span>if</span> <span>doc</span><span>.</span><span>exists</span><span>:</span> <span>doc</span><span>.</span><span>reference</span><span>.</span><span>set</span><span>({</span><span>"user_uid"</span><span>:</span> <span>uid</span><span>},</span> <span>merge</span><span>=</span><span>True</span><span>)</span> <span>def</span> <span>start_first_quest</span><span>(</span><span>self</span><span>)</span> <span>-></span> <span>None</span><span>:</span> <span>""" Create starting quest if not exist """</span> <span>QuestClass</span> <span>=</span> <span>Quest</span><span>.</span><span>get_first_quest</span><span>()</span> <span>quest</span> <span>=</span> <span>QuestClass</span><span>.</span><span>from_game</span><span>(</span><span>self</span><span>)</span> <span>quest</span><span>.</span><span>execute_stages</span><span>(</span><span>TickType</span><span>.</span><span>FULL</span><span>)</span> <span>quest</span><span>.</span><span>save</span><span>()</span> <span>def</span> <span>__repr__</span><span>(</span><span>self</span><span>):</span> <span>return</span> <span>f</span><span>"</span><span>{</span><span>self</span><span>.</span><span>__class__</span><span>.</span><span>__name__</span><span>}</span><span>(key=</span><span>{</span><span>self</span><span>.</span><span>key</span><span>}</span><span>)"</span>class Game: @classmethod def from_user(cls, user: User) -> Union[Game, NoGameType]: """ Create a game from a user """ key = cls.make_key(user) game = cls(key) game.user = user docs = db.collection("game").where("user_key", "==", user.key).stream() for _ in docs: return game return NoGame @classmethod def new_from_fork(cls, user: User, fork_url: str) -> Game: """ Save game with fork """ if not fork_url: raise ValueError("Fork can't be blank") key = cls.make_key(user) game = cls(key) game.user = user game_doc = db.collection("game").document(game.key).get() if game_doc.exists: game_doc.reference.set( { "fork_url": fork_url, "user_uid": user.uid, "user_key": user.key, }, merge=True, ) else: game_doc.reference.set( { "fork_url": fork_url, "user_uid": user.uid, "user_key": user.key, "joined": firestore.SERVER_TIMESTAMP, } ) return game @staticmethod def make_key(user: User) -> str: """ Game's key ARE user key due to 1:1 relationship """ return user.key key: str user: Union[User, NoUserType] def __init__(self, key: str): self.key = key self.user = NoUser def assign_to_uid(self, uid: str) -> None: """ Assign a user to this game """ doc = db.collection("game").document(self.key).get() if doc.exists: doc.reference.set({"user_uid": uid}, merge=True) def start_first_quest(self) -> None: """ Create starting quest if not exist """ QuestClass = Quest.get_first_quest() quest = QuestClass.from_game(self) quest.execute_stages(TickType.FULL) quest.save() def __repr__(self): return f"{self.__class__.__name__}(key={self.key})"
Enter fullscreen mode Exit fullscreen mode
We have two functions for creating Game
objects: new_from_fork()
and from_user()
. There’s also the method assign_to_uid()
which writes a single property to the database.
This seems ok, but there’s a lot of duplicate code in the User
object, and Quest
objects.
So I decided to warp a lot of this duplicate function into an ORM base class so that User
, Quest
and Game
can all inherit these functions that will save and restore themselves from the database.
After the change, the new Game
object looks like this:
<span>class</span> <span>Game</span><span>(</span><span>Orm</span><span>,</span> <span>collection</span><span>=</span><span>"game"</span><span>,</span> <span>parent_orm</span><span>=</span><span>User</span><span>):</span><span>data</span><span>:</span> <span>GameData</span><span>storage_model</span> <span>=</span> <span>GameData</span><span>@</span><span>classmethod</span><span>def</span> <span>from_user</span><span>(</span><span>cls</span><span>,</span> <span>user</span><span>:</span> <span>User</span><span>)</span> <span>-></span> <span>Game</span><span>:</span><span>key</span> <span>=</span> <span>cls</span><span>.</span><span>make_key</span><span>(</span><span>user</span><span>)</span><span>game</span> <span>=</span> <span>cls</span><span>(</span><span>key</span><span>)</span><span>game</span><span>.</span><span>parent_key</span> <span>=</span> <span>user</span><span>.</span><span>key</span><span>return</span> <span>game</span><span>@</span><span>staticmethod</span><span>def</span> <span>make_key</span><span>(</span><span>user</span><span>:</span> <span>User</span><span>)</span> <span>-></span> <span>str</span><span>:</span><span>""" Game's key ARE user key due to 1:1 relationship """</span><span>return</span> <span>user</span><span>.</span><span>key</span><span>def</span> <span>set_fork_url</span><span>(</span><span>self</span><span>,</span> <span>fork_url</span><span>:</span> <span>str</span><span>)</span> <span>-></span> <span>None</span><span>:</span><span>self</span><span>.</span><span>data</span><span>.</span><span>fork_url</span> <span>=</span> <span>fork_url</span><span>class</span> <span>Game</span><span>(</span><span>Orm</span><span>,</span> <span>collection</span><span>=</span><span>"game"</span><span>,</span> <span>parent_orm</span><span>=</span><span>User</span><span>):</span> <span>data</span><span>:</span> <span>GameData</span> <span>storage_model</span> <span>=</span> <span>GameData</span> <span>@</span><span>classmethod</span> <span>def</span> <span>from_user</span><span>(</span><span>cls</span><span>,</span> <span>user</span><span>:</span> <span>User</span><span>)</span> <span>-></span> <span>Game</span><span>:</span> <span>key</span> <span>=</span> <span>cls</span><span>.</span><span>make_key</span><span>(</span><span>user</span><span>)</span> <span>game</span> <span>=</span> <span>cls</span><span>(</span><span>key</span><span>)</span> <span>game</span><span>.</span><span>parent_key</span> <span>=</span> <span>user</span><span>.</span><span>key</span> <span>return</span> <span>game</span> <span>@</span><span>staticmethod</span> <span>def</span> <span>make_key</span><span>(</span><span>user</span><span>:</span> <span>User</span><span>)</span> <span>-></span> <span>str</span><span>:</span> <span>""" Game's key ARE user key due to 1:1 relationship """</span> <span>return</span> <span>user</span><span>.</span><span>key</span> <span>def</span> <span>set_fork_url</span><span>(</span><span>self</span><span>,</span> <span>fork_url</span><span>:</span> <span>str</span><span>)</span> <span>-></span> <span>None</span><span>:</span> <span>self</span><span>.</span><span>data</span><span>.</span><span>fork_url</span> <span>=</span> <span>fork_url</span>class Game(Orm, collection="game", parent_orm=User): data: GameData storage_model = GameData @classmethod def from_user(cls, user: User) -> Game: key = cls.make_key(user) game = cls(key) game.parent_key = user.key return game @staticmethod def make_key(user: User) -> str: """ Game's key ARE user key due to 1:1 relationship """ return user.key def set_fork_url(self, fork_url: str) -> None: self.data.fork_url = fork_url
Enter fullscreen mode Exit fullscreen mode
Much cleaner! The first thing you’ll notice is the line:
<span>class</span> <span>Game</span><span>(</span><span>Orm</span><span>,</span> <span>collection</span><span>=</span><span>"game"</span><span>,</span> <span>parent_orm</span><span>=</span><span>User</span><span>):</span><span>class</span> <span>Game</span><span>(</span><span>Orm</span><span>,</span> <span>collection</span><span>=</span><span>"game"</span><span>,</span> <span>parent_orm</span><span>=</span><span>User</span><span>):</span>class Game(Orm, collection="game", parent_orm=User):
Enter fullscreen mode Exit fullscreen mode
This is a neat feature in Python allowing customisation of metaclasses and is described in PEP 487. It’s powered by the __init_subclass__
dunder, which runs when the class is initialised (compared to __init__
when the instance is initialised), which in this case looks like this (from the Orm
base class):
<span>def</span> <span>__init_subclass__</span><span>(</span><span>cls</span><span>,</span> <span>collection</span><span>:</span> <span>str</span><span>,</span> <span>parent_orm</span><span>:</span> <span>Union</span><span>[</span><span>Type</span><span>[</span><span>Orm</span><span>],</span> <span>NoParentType</span><span>]</span> <span>=</span> <span>NoParent</span><span>):</span><span>""" Set collection and parent """</span><span>cls</span><span>.</span><span>collection</span> <span>=</span> <span>collection</span><span>cls</span><span>.</span><span>parent_orm</span> <span>=</span> <span>parent_orm</span><span>cls</span><span>.</span><span>col_ref</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>collection</span><span>)</span><span>def</span> <span>__init_subclass__</span><span>(</span> <span>cls</span><span>,</span> <span>collection</span><span>:</span> <span>str</span><span>,</span> <span>parent_orm</span><span>:</span> <span>Union</span><span>[</span><span>Type</span><span>[</span><span>Orm</span><span>],</span> <span>NoParentType</span><span>]</span> <span>=</span> <span>NoParent</span> <span>):</span> <span>""" Set collection and parent """</span> <span>cls</span><span>.</span><span>collection</span> <span>=</span> <span>collection</span> <span>cls</span><span>.</span><span>parent_orm</span> <span>=</span> <span>parent_orm</span> <span>cls</span><span>.</span><span>col_ref</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>collection</span><span>)</span>def __init_subclass__( cls, collection: str, parent_orm: Union[Type[Orm], NoParentType] = NoParent ): """ Set collection and parent """ cls.collection = collection cls.parent_orm = parent_orm cls.col_ref = db.collection(collection)
Enter fullscreen mode Exit fullscreen mode
So, what is happening here is when Game
class is initialized, it is setting the collection
class variable, and the parent_orm
class variable. While we could have used __init__
to set the collection and parent_orm, this method provides a nice separation of concerns between the Game
class, whose class properties relate to the collection of games; and a specific instance of the Game
class which relate to a specific entry in this collection. Setting col_ref
in __init_subclass__
allows us to do Game.col_ref
without having to instantiate a Game object (which we shouldn’t need to do when referencing all Games).
The other changes is removing new_from_fork()
and replacing with a short set_fork_url()
setter. The reason for this is I realised both new_from_fork()
and from_user()
require a user
argument, so there’s a duplication here. assign_to_uid()
can now use the Orm
base class’s parent_key
property; and the quest stuff has been moved to a new QuestPage
object instead, where it’s more relevant.
The ORM
The Underlying ORM base class’s job is to map data between Firestore objects and Python objects. It has all the shared code that Game
, User
and new QuestPage
object needs. The top half of it looks like this:
<span>class</span> <span>Orm</span><span>(</span><span>ABC</span><span>):</span><span>""" ORM base class links stuff together """</span><span>@</span><span>property</span><span>@</span><span>abstractmethod</span><span>def</span> <span>storage_model</span><span>(</span><span>cls</span><span>)</span> <span>-></span> <span>Type</span><span>[</span><span>BaseModel</span><span>]:</span><span>""" Storage model """</span><span>return</span> <span>NotImplemented</span><span>collection</span><span>:</span> <span>ClassVar</span><span>[</span><span>str</span><span>]</span><span>parent_orm</span><span>:</span> <span>ClassVar</span><span>[</span><span>Union</span><span>[</span><span>Type</span><span>[</span><span>Orm</span><span>],</span> <span>NoParentType</span><span>]]</span><span>col_ref</span><span>:</span> <span>CollectionReference</span><span>def</span> <span>__init_subclass__</span><span>(</span><span>cls</span><span>,</span> <span>collection</span><span>:</span> <span>str</span><span>,</span> <span>parent_orm</span><span>:</span> <span>Union</span><span>[</span><span>Type</span><span>[</span><span>Orm</span><span>],</span> <span>NoParentType</span><span>]</span> <span>=</span> <span>NoParent</span><span>):</span><span>""" Set collection and parent """</span><span>cls</span><span>.</span><span>collection</span> <span>=</span> <span>collection</span><span>cls</span><span>.</span><span>parent_orm</span> <span>=</span> <span>parent_orm</span><span>cls</span><span>.</span><span>col_ref</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>collection</span><span>)</span><span>key</span><span>:</span> <span>Union</span><span>[</span><span>str</span><span>,</span> <span>NoKeyType</span><span>]</span><span>parent_key</span><span>:</span> <span>Union</span><span>[</span><span>str</span><span>,</span> <span>NoKeyType</span><span>]</span><span>data</span><span>:</span> <span>BaseModel</span><span>def</span> <span>__init__</span><span>(</span><span>self</span><span>,</span> <span>key</span><span>:</span> <span>Union</span><span>[</span><span>str</span><span>,</span> <span>NoKeyType</span><span>]</span> <span>=</span> <span>NoKey</span><span>):</span><span>self</span><span>.</span><span>key</span> <span>=</span> <span>key</span><span>self</span><span>.</span><span>data</span> <span>=</span> <span>self</span><span>.</span><span>storage_model</span><span>()</span><span>self</span><span>.</span><span>parent_key</span> <span>=</span> <span>NoKey</span><span>@</span><span>property</span><span>def</span> <span>parent</span><span>(</span><span>self</span><span>)</span> <span>-></span> <span>Union</span><span>[</span><span>Orm</span><span>,</span> <span>OrmNotFoundType</span><span>]:</span><span>if</span> <span>self</span><span>.</span><span>parent_orm</span> <span>is</span> <span>not</span> <span>NoParent</span> <span>and</span> <span>self</span><span>.</span><span>parent_key</span> <span>is</span> <span>not</span> <span>NoKey</span><span>:</span><span>return</span> <span>self</span><span>.</span><span>parent_orm</span><span>(</span><span>self</span><span>.</span><span>parent_key</span><span>)</span><span>return</span> <span>OrmNotFound</span><span>...</span><span>class</span> <span>Orm</span><span>(</span><span>ABC</span><span>):</span> <span>""" ORM base class links stuff together """</span> <span>@</span><span>property</span> <span>@</span><span>abstractmethod</span> <span>def</span> <span>storage_model</span><span>(</span><span>cls</span><span>)</span> <span>-></span> <span>Type</span><span>[</span><span>BaseModel</span><span>]:</span> <span>""" Storage model """</span> <span>return</span> <span>NotImplemented</span> <span>collection</span><span>:</span> <span>ClassVar</span><span>[</span><span>str</span><span>]</span> <span>parent_orm</span><span>:</span> <span>ClassVar</span><span>[</span><span>Union</span><span>[</span><span>Type</span><span>[</span><span>Orm</span><span>],</span> <span>NoParentType</span><span>]]</span> <span>col_ref</span><span>:</span> <span>CollectionReference</span> <span>def</span> <span>__init_subclass__</span><span>(</span> <span>cls</span><span>,</span> <span>collection</span><span>:</span> <span>str</span><span>,</span> <span>parent_orm</span><span>:</span> <span>Union</span><span>[</span><span>Type</span><span>[</span><span>Orm</span><span>],</span> <span>NoParentType</span><span>]</span> <span>=</span> <span>NoParent</span> <span>):</span> <span>""" Set collection and parent """</span> <span>cls</span><span>.</span><span>collection</span> <span>=</span> <span>collection</span> <span>cls</span><span>.</span><span>parent_orm</span> <span>=</span> <span>parent_orm</span> <span>cls</span><span>.</span><span>col_ref</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>collection</span><span>)</span> <span>key</span><span>:</span> <span>Union</span><span>[</span><span>str</span><span>,</span> <span>NoKeyType</span><span>]</span> <span>parent_key</span><span>:</span> <span>Union</span><span>[</span><span>str</span><span>,</span> <span>NoKeyType</span><span>]</span> <span>data</span><span>:</span> <span>BaseModel</span> <span>def</span> <span>__init__</span><span>(</span><span>self</span><span>,</span> <span>key</span><span>:</span> <span>Union</span><span>[</span><span>str</span><span>,</span> <span>NoKeyType</span><span>]</span> <span>=</span> <span>NoKey</span><span>):</span> <span>self</span><span>.</span><span>key</span> <span>=</span> <span>key</span> <span>self</span><span>.</span><span>data</span> <span>=</span> <span>self</span><span>.</span><span>storage_model</span><span>()</span> <span>self</span><span>.</span><span>parent_key</span> <span>=</span> <span>NoKey</span> <span>@</span><span>property</span> <span>def</span> <span>parent</span><span>(</span><span>self</span><span>)</span> <span>-></span> <span>Union</span><span>[</span><span>Orm</span><span>,</span> <span>OrmNotFoundType</span><span>]:</span> <span>if</span> <span>self</span><span>.</span><span>parent_orm</span> <span>is</span> <span>not</span> <span>NoParent</span> <span>and</span> <span>self</span><span>.</span><span>parent_key</span> <span>is</span> <span>not</span> <span>NoKey</span><span>:</span> <span>return</span> <span>self</span><span>.</span><span>parent_orm</span><span>(</span><span>self</span><span>.</span><span>parent_key</span><span>)</span> <span>return</span> <span>OrmNotFound</span> <span>...</span>class Orm(ABC): """ ORM base class links stuff together """ @property @abstractmethod def storage_model(cls) -> Type[BaseModel]: """ Storage model """ return NotImplemented collection: ClassVar[str] parent_orm: ClassVar[Union[Type[Orm], NoParentType]] col_ref: CollectionReference def __init_subclass__( cls, collection: str, parent_orm: Union[Type[Orm], NoParentType] = NoParent ): """ Set collection and parent """ cls.collection = collection cls.parent_orm = parent_orm cls.col_ref = db.collection(collection) key: Union[str, NoKeyType] parent_key: Union[str, NoKeyType] data: BaseModel def __init__(self, key: Union[str, NoKeyType] = NoKey): self.key = key self.data = self.storage_model() self.parent_key = NoKey @property def parent(self) -> Union[Orm, OrmNotFoundType]: if self.parent_orm is not NoParent and self.parent_key is not NoKey: return self.parent_orm(self.parent_key) return OrmNotFound ...
Enter fullscreen mode Exit fullscreen mode
With the bottom half related to the mechanics of saving and loading data from the database.
I’ve described the __init_subclass__
already, the rest of this ABC enforces that concrete implementations should supply their own storage_model
, a Pydantic model which the base class’s methods will use for creating and restoring data firestore. It also has a property called parent
which will return an instance of the parent object linked by a parent_key
property. For example if we had an instantiated Game
object, we could fetch it’s user by doing:
<span>game</span> <span>=</span> <span>Game</span><span>(</span><span>key</span><span>)</span><span>game</span><span>.</span><span>load</span><span>()</span><span>user</span> <span>=</span> <span>game</span><span>.</span><span>parent</span><span>game</span> <span>=</span> <span>Game</span><span>(</span><span>key</span><span>)</span> <span>game</span><span>.</span><span>load</span><span>()</span> <span>user</span> <span>=</span> <span>game</span><span>.</span><span>parent</span>game = Game(key) game.load() user = game.parent
Enter fullscreen mode Exit fullscreen mode
Because Game
objects know that their parent ORM object is User
, and Game
objects also store a parent_key
property. This is enough detail to return a User object.
QuestPages
I’ve done some big refactoring to the former Quest
object. It’s now been split into two: Quest
which contains everything related to running the Quest, and serves as a base class to concrete implementations of individual Quests; and QuestPage
which is subclassed from Orm
and relates only to saving and loading quests from the database. I imagine QuestPage
as a page in a quest log, which contains the “save data” relating to a Quest
which is the actual implementation of the quest.
There’s a bit of composition going on, as QuestPage
has one instance of quest
as a property.
<span>class</span> <span>QuestPage</span><span>(</span><span>Orm</span><span>,</span> <span>collection</span><span>=</span><span>"quest"</span><span>,</span> <span>parent_orm</span><span>=</span><span>Game</span><span>):</span><span>data</span><span>:</span> <span>QuestData</span><span>storage_model</span> <span>=</span> <span>QuestData</span><span>quest</span><span>:</span> <span>Quest</span><span>@</span><span>staticmethod</span><span>def</span> <span>make_key</span><span>(</span><span>game</span><span>:</span> <span>Game</span><span>,</span> <span>quest_name</span><span>:</span> <span>str</span><span>)</span> <span>-></span> <span>str</span><span>:</span><span>if</span> <span>not</span> <span>quest_name</span><span>:</span><span>raise</span> <span>ValueError</span><span>(</span><span>"quest_name must be valid"</span><span>)</span><span>return</span> <span>f</span><span>"</span><span>{</span><span>game</span><span>.</span><span>key</span><span>}</span><span>:</span><span>{</span><span>quest_name</span><span>}</span><span>"</span><span>@</span><span>classmethod</span><span>def</span> <span>from_game_get_first_quest</span><span>(</span><span>cls</span><span>,</span> <span>game</span><span>:</span> <span>Game</span><span>)</span> <span>-></span> <span>QuestPage</span><span>:</span><span>return</span> <span>cls</span><span>.</span><span>from_game_get_quest</span><span>(</span><span>game</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span><span>@</span><span>classmethod</span><span>def</span> <span>from_game_get_quest</span><span>(</span><span>cls</span><span>,</span> <span>game</span><span>:</span> <span>Game</span><span>,</span> <span>quest_name</span><span>:</span> <span>str</span><span>)</span> <span>-></span> <span>QuestPage</span><span>:</span><span>key</span> <span>=</span> <span>cls</span><span>.</span><span>make_key</span><span>(</span><span>game</span><span>,</span> <span>quest_name</span><span>)</span><span>quest</span> <span>=</span> <span>cls</span><span>(</span><span>key</span><span>,</span> <span>quest_name</span><span>)</span><span>return</span> <span>quest</span><span>@</span><span>classmethod</span><span>def</span> <span>iterate_all</span><span>(</span><span>cls</span><span>)</span> <span>-></span> <span>Generator</span><span>[</span><span>QuestPage</span><span>,</span> <span>None</span><span>,</span> <span>None</span><span>]:</span><span>""" Iterate over all quests, the generator yields loaded quest_pages """</span><span>docs</span> <span>=</span> <span>cls</span><span>.</span><span>col_ref</span><span>.</span><span>where</span><span>(</span><span>"complete"</span><span>,</span> <span>"!="</span><span>,</span> <span>True</span><span>).</span><span>stream</span><span>()</span><span>for</span> <span>doc</span> <span>in</span> <span>docs</span><span>:</span><span>data</span> <span>=</span> <span>cls</span><span>.</span><span>storage_model</span><span>.</span><span>parse_obj</span><span>(</span><span>doc</span><span>.</span><span>to_dict</span><span>())</span><span>quest_page</span> <span>=</span> <span>cls</span><span>(</span><span>doc</span><span>.</span><span>id</span><span>,</span> <span>data</span><span>.</span><span>quest_name</span><span>)</span><span>quest_page</span><span>.</span><span>data</span> <span>=</span> <span>data</span><span>quest_page</span><span>.</span><span>quest</span><span>.</span><span>load_raw</span><span>(</span><span>data</span><span>.</span><span>version</span><span>,</span> <span>data</span><span>.</span><span>serialized_data</span><span>)</span><span>yield</span> <span>quest_page</span><span>def</span> <span>__init__</span><span>(</span><span>self</span><span>,</span> <span>key</span><span>:</span> <span>str</span><span>,</span> <span>quest_name</span><span>):</span><span>super</span><span>().</span><span>__init__</span><span>(</span><span>key</span><span>)</span><span>self</span><span>.</span><span>quest</span> <span>=</span> <span>Quest</span><span>.</span><span>from_name</span><span>(</span><span>quest_name</span><span>,</span> <span>self</span><span>)</span><span>self</span><span>.</span><span>data</span><span>.</span><span>quest_name</span> <span>=</span> <span>quest_name</span><span>def</span> <span>load</span><span>(</span><span>self</span><span>)</span> <span>-></span> <span>None</span><span>:</span><span>""" Additionally parse the quest storage """</span><span>super</span><span>().</span><span>load</span><span>()</span><span>if</span> <span>isinstance</span><span>(</span><span>self</span><span>.</span><span>quest</span><span>,</span> <span>Quest</span><span>):</span><span>self</span><span>.</span><span>quest</span><span>.</span><span>load_raw</span><span>(</span><span>self</span><span>.</span><span>data</span><span>.</span><span>version</span><span>,</span> <span>self</span><span>.</span><span>data</span><span>.</span><span>serialized_data</span><span>)</span><span>def</span> <span>save</span><span>(</span><span>self</span><span>)</span> <span>-></span> <span>None</span><span>:</span><span>""" Additionally parse out the quest storage """</span><span>if</span> <span>isinstance</span><span>(</span><span>self</span><span>.</span><span>quest</span><span>,</span> <span>Quest</span><span>):</span><span>self</span><span>.</span><span>data</span><span>.</span><span>serialized_data</span> <span>=</span> <span>self</span><span>.</span><span>quest</span><span>.</span><span>save_raw</span><span>()</span><span>self</span><span>.</span><span>data</span><span>.</span><span>version</span> <span>=</span> <span>str</span><span>(</span><span>self</span><span>.</span><span>quest</span><span>.</span><span>version</span><span>)</span><span>super</span><span>().</span><span>save</span><span>()</span><span>...</span><span>class</span> <span>QuestPage</span><span>(</span><span>Orm</span><span>,</span> <span>collection</span><span>=</span><span>"quest"</span><span>,</span> <span>parent_orm</span><span>=</span><span>Game</span><span>):</span> <span>data</span><span>:</span> <span>QuestData</span> <span>storage_model</span> <span>=</span> <span>QuestData</span> <span>quest</span><span>:</span> <span>Quest</span> <span>@</span><span>staticmethod</span> <span>def</span> <span>make_key</span><span>(</span><span>game</span><span>:</span> <span>Game</span><span>,</span> <span>quest_name</span><span>:</span> <span>str</span><span>)</span> <span>-></span> <span>str</span><span>:</span> <span>if</span> <span>not</span> <span>quest_name</span><span>:</span> <span>raise</span> <span>ValueError</span><span>(</span><span>"quest_name must be valid"</span><span>)</span> <span>return</span> <span>f</span><span>"</span><span>{</span><span>game</span><span>.</span><span>key</span><span>}</span><span>:</span><span>{</span><span>quest_name</span><span>}</span><span>"</span> <span>@</span><span>classmethod</span> <span>def</span> <span>from_game_get_first_quest</span><span>(</span><span>cls</span><span>,</span> <span>game</span><span>:</span> <span>Game</span><span>)</span> <span>-></span> <span>QuestPage</span><span>:</span> <span>return</span> <span>cls</span><span>.</span><span>from_game_get_quest</span><span>(</span><span>game</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span> <span>@</span><span>classmethod</span> <span>def</span> <span>from_game_get_quest</span><span>(</span><span>cls</span><span>,</span> <span>game</span><span>:</span> <span>Game</span><span>,</span> <span>quest_name</span><span>:</span> <span>str</span><span>)</span> <span>-></span> <span>QuestPage</span><span>:</span> <span>key</span> <span>=</span> <span>cls</span><span>.</span><span>make_key</span><span>(</span><span>game</span><span>,</span> <span>quest_name</span><span>)</span> <span>quest</span> <span>=</span> <span>cls</span><span>(</span><span>key</span><span>,</span> <span>quest_name</span><span>)</span> <span>return</span> <span>quest</span> <span>@</span><span>classmethod</span> <span>def</span> <span>iterate_all</span><span>(</span><span>cls</span><span>)</span> <span>-></span> <span>Generator</span><span>[</span><span>QuestPage</span><span>,</span> <span>None</span><span>,</span> <span>None</span><span>]:</span> <span>""" Iterate over all quests, the generator yields loaded quest_pages """</span> <span>docs</span> <span>=</span> <span>cls</span><span>.</span><span>col_ref</span><span>.</span><span>where</span><span>(</span><span>"complete"</span><span>,</span> <span>"!="</span><span>,</span> <span>True</span><span>).</span><span>stream</span><span>()</span> <span>for</span> <span>doc</span> <span>in</span> <span>docs</span><span>:</span> <span>data</span> <span>=</span> <span>cls</span><span>.</span><span>storage_model</span><span>.</span><span>parse_obj</span><span>(</span><span>doc</span><span>.</span><span>to_dict</span><span>())</span> <span>quest_page</span> <span>=</span> <span>cls</span><span>(</span><span>doc</span><span>.</span><span>id</span><span>,</span> <span>data</span><span>.</span><span>quest_name</span><span>)</span> <span>quest_page</span><span>.</span><span>data</span> <span>=</span> <span>data</span> <span>quest_page</span><span>.</span><span>quest</span><span>.</span><span>load_raw</span><span>(</span><span>data</span><span>.</span><span>version</span><span>,</span> <span>data</span><span>.</span><span>serialized_data</span><span>)</span> <span>yield</span> <span>quest_page</span> <span>def</span> <span>__init__</span><span>(</span><span>self</span><span>,</span> <span>key</span><span>:</span> <span>str</span><span>,</span> <span>quest_name</span><span>):</span> <span>super</span><span>().</span><span>__init__</span><span>(</span><span>key</span><span>)</span> <span>self</span><span>.</span><span>quest</span> <span>=</span> <span>Quest</span><span>.</span><span>from_name</span><span>(</span><span>quest_name</span><span>,</span> <span>self</span><span>)</span> <span>self</span><span>.</span><span>data</span><span>.</span><span>quest_name</span> <span>=</span> <span>quest_name</span> <span>def</span> <span>load</span><span>(</span><span>self</span><span>)</span> <span>-></span> <span>None</span><span>:</span> <span>""" Additionally parse the quest storage """</span> <span>super</span><span>().</span><span>load</span><span>()</span> <span>if</span> <span>isinstance</span><span>(</span><span>self</span><span>.</span><span>quest</span><span>,</span> <span>Quest</span><span>):</span> <span>self</span><span>.</span><span>quest</span><span>.</span><span>load_raw</span><span>(</span><span>self</span><span>.</span><span>data</span><span>.</span><span>version</span><span>,</span> <span>self</span><span>.</span><span>data</span><span>.</span><span>serialized_data</span><span>)</span> <span>def</span> <span>save</span><span>(</span><span>self</span><span>)</span> <span>-></span> <span>None</span><span>:</span> <span>""" Additionally parse out the quest storage """</span> <span>if</span> <span>isinstance</span><span>(</span><span>self</span><span>.</span><span>quest</span><span>,</span> <span>Quest</span><span>):</span> <span>self</span><span>.</span><span>data</span><span>.</span><span>serialized_data</span> <span>=</span> <span>self</span><span>.</span><span>quest</span><span>.</span><span>save_raw</span><span>()</span> <span>self</span><span>.</span><span>data</span><span>.</span><span>version</span> <span>=</span> <span>str</span><span>(</span><span>self</span><span>.</span><span>quest</span><span>.</span><span>version</span><span>)</span> <span>super</span><span>().</span><span>save</span><span>()</span> <span>...</span>class QuestPage(Orm, collection="quest", parent_orm=Game): data: QuestData storage_model = QuestData quest: Quest @staticmethod def make_key(game: Game, quest_name: str) -> str: if not quest_name: raise ValueError("quest_name must be valid") return f"{game.key}:{quest_name}" @classmethod def from_game_get_first_quest(cls, game: Game) -> QuestPage: return cls.from_game_get_quest(game, FIRST_QUEST_NAME) @classmethod def from_game_get_quest(cls, game: Game, quest_name: str) -> QuestPage: key = cls.make_key(game, quest_name) quest = cls(key, quest_name) return quest @classmethod def iterate_all(cls) -> Generator[QuestPage, None, None]: """ Iterate over all quests, the generator yields loaded quest_pages """ docs = cls.col_ref.where("complete", "!=", True).stream() for doc in docs: data = cls.storage_model.parse_obj(doc.to_dict()) quest_page = cls(doc.id, data.quest_name) quest_page.data = data quest_page.quest.load_raw(data.version, data.serialized_data) yield quest_page def __init__(self, key: str, quest_name): super().__init__(key) self.quest = Quest.from_name(quest_name, self) self.data.quest_name = quest_name def load(self) -> None: """ Additionally parse the quest storage """ super().load() if isinstance(self.quest, Quest): self.quest.load_raw(self.data.version, self.data.serialized_data) def save(self) -> None: """ Additionally parse out the quest storage """ if isinstance(self.quest, Quest): self.data.serialized_data = self.quest.save_raw() self.data.version = str(self.quest.version) super().save() ...
Enter fullscreen mode Exit fullscreen mode
This extends the __init__()
, save()
, and load()
of the ORM base class in order to also save and load the quest data model distinct from the quest_page data model. The reason for this split is so that the quests themselves have their own variable space for quest-related data, while all the metadata – the version number, the completed quest list, etc. are store separately.
Together with the other changes, this refactor, which still hasn’t added any new functionality, has cleaned up the code once again. Hopefully i don’t have to do too many more of these
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 29: ORM for Firestore and __init__subclass__ dunders and metaclasses
暂无评论内容