Python Asteroids+Invaders on GitHub

Having found a way that I think will work, I’ll try again today to install the ‘ignore’ capability. Hamsters. And success!

Here are my closing remarks from yesterday, emphasis mine today:

The cause of the problem is the use of ABC, the superclass that provides abstract methods. ABC uses its own metaclass, ABCMeta. I could almost certainly make IgnoreThese work by removing all the abstract class stuff and using our own metaclass instead.

I also tried making my metaclass inherit from ABC and type, but that didn’t work. Making IgnoreThese inherit ABCMeta and my base class inherit ABC does seem to work. So I think I could try using the new scheme with my class inheriting ABCMeta. However, I have no idea what the implications of that might be. Seems far too risky.

Of the two alternatives, stopping the use of ABC in the Flyer hierarchy seems more reasonable, but would replace a strong mechanism that is part of Python with a weaker mechanism that is hand-rolled. Since I clearly do not fully understand the metaclass machinery, this does not seem like a good trade-off. Investigation may continue.

I’m going to try this again for a number of reasons, or should I say rationalizations:

  • If you never go too far, you’re not going far enough;
  • I value understanding a bit about the guts of the systems I use;
  • It really does seem that the feature will improve readability;
  • It’s only a toy project;
  • It’s a toy project that shows what happens on real projects;
  • I can always take it back out.

I probably cannot know the “real” reason why I’ll do it. One of the hamsters in my head probably fell off his wheel or something like that.

I’ll start by making my test a bit more like the real thing, by inheriting a class that uses ABC in the test.

As soon as i do this much, I start getting the dreaded message that pytest can’t collect up everything it needs:


class AbstractClass(ABC):
    @abstractmethod
    def must(self):
        pass


class Base(ABC):

    def must(self):
        pass

My alleged fix goes into the IgnoreThese class:

from abc import ABCMeta


class IgnoreThese(ABCMeta):

    @classmethod
    def __prepare__(metacls, name, bases, ignore=None):
        result = {}
        if ignore:
            for name in ignore:
                result[name] = lambda x: None
        return result

    def __new__(cls, name, bases, classdict, ignore=None):
        result = type.__new__(cls, name, bases, dict(classdict))
        return result

My tests all run green. Commit: IgnoreThese now inherits from ABCMeta. Tests green so far.

Now my test matches the real situation more exactly, and we can try the real thing again. I’m set up to do Bumper, so let’s try it again.

class Bumper(InvadersFlyer,
             metaclass=IgnoreThese,
             ignore=["interact_with_bumper",
                     "interact_with_invaderexplosion",
                     "interact_with_invaderfleet",
                     "interact_with_invaderplayer",
                     "interact_with_invadershot",
                     "interact_with_playerexplosion",
                     "interact_with_shield",
                     "interact_with_playershot",
                     "interact_with_shotcontroller",
                     "interact_with_shotexplosion",
                     "interact_with_topbumper"]):

The tests are green so far. I have not removed any of Bumper’s default methods yet.

But if I do, aren’t Python and PyCharm going to object to my missing out some abstract methods? I don’t recall that happening yesterday. Let’s find out. Ah. I do get a warning about the abstracts, but the game works perfectly after one change, to the lambda in IgnoreThese:

    @classmethod
    def __prepare__(metacls, name, bases, ignore=None):
        result = {}
        if ignore:
            for name in ignore:
                result[name] = lambda s, o, f: None
        return result

The interact_with_* methods take three arguments, so the lambda I assign needs three as well. However, this breaks my test, which used only one. I wonder if a lambda can use *args. Yes it can!

    @classmethod
    def __prepare__(metacls, name, bases, ignore=None):
        result = {}
        if ignore:
            for name in ignore:
                result[name] = lambda *args: None
        return result

The tests all run, Bumper does not directly implement any of its required but unused methods, instead listing all 11 of them in ignore, and the game works perfectly.

As my friend Dr F said recently: “It’s alive!”

Commit: Bumper uses IgnoreThese to override unused interactions.

Reflection

We’d better think about this. The bumper class is greatly improved, down to 52 lines from its prior 72. I strongly suspect that we could ignore rect, mask, draw, and tick as well. The ignore thing doesn’t care about argument lists now that we have that *args in there. We’ll hold off on that for now, maybe forever.

The most immediate issue that I see is that PyCharm has a yellow card for me, because I’m apparently not implementing all the required abstract methods. Now our full plan, I think, was to replace the use of @abstractmethod for the methods that can be ignored, with an implementation in the superclass of NotImplementedError.

That plan is a bit unfortunate, because it causes a run-time failure rather than a compiler message, but the compiler message is a weak warning anyway, and the real trouble always comes at run time.

A day or so ago I thought of a test that could be written, one that takes instances of all Flyers and sends them all possible interact_with messages, ensuring that they do not blow up. I think that the hardest part of that test would be creating the instances, since the various Flyers have different constructors. But it could be done and it should be done.

If I decide to go ahead with this scheme—and I surely will—I’ll implement that test. Maybe not first, but I will do it. Absolutely. Certainly. Probably.

I’ll take a break here and cool down from the high of success, so as to have a prayer of assessing this idea fairly. But at this writing, I’m quite sure that we’ll go ahead with it. Here are my summary thoughts as of now:

Summary

The more compact notation saying what calls are ignored seems far more readable to me than a page-long list of two-line methods all saying pass. I think that the combination of the test and the game code both working says that the implementation of IgnoreThese is robust enough to be used.

However, I freely grant that while I am quite sure I can explain what it does, I do not know the full ramifications of the Python class definition and attribute lookup in the presence of metaclasses. I think it’s solid and safe as we use it here, and I am uncertain what would happen if it were used in conjunction with a more complex class network.

I believe we’re safe here, but that if someone were to adopt this scheme at home, something could go wrong that I would not be able to explain. We’re quite near the bottom of the bag of tricks. But this trick, in this usage, seems solid to me.

I shall reflect and decide later to go ahead with it what to do.

See you next time!