Repository: evennia/evennia

Issue:
[BUG - Develop] Global Scripts loading doesn't catch parse errors
#### Describe the bug
When there's an error with loading a global scripts module, e.g. it imports the `Room` class but that class has a syntax error, the loading error isn't caught and the traceback points to [this line](https://github.com/evennia/evennia/blob/251a70275bbd98a3e157cbb4c025597a4bb24ac9/evennia/utils/containers.py#L135) with a KeyError

#### To Reproduce
Steps to reproduce the behavior:
1. Create a simple global script which imports a class such as `Room` from your game dir, and add it to `settings.py`
2. Introduce a syntax error to your `Room` class, such as an extra space in an indent.
3. Reload the server.
4. See error

#### Expected behavior
It'd be more useful if there was an error thrown which indicated that the script had failed being loaded (and, ideally, why), as the current error invariably leads people to think there is a problem with the global scripts dict in `settings.py`.

#### Develop-branch commit
f093c8bcb

#### Additional context
I feel like it should be catching that kind of an error in [`class_from_module`](https://github.com/evennia/evennia/blob/f093c8bcb1321a44e33003cf91cf9f565e028de7/evennia/utils/utils.py#L1524) or it should at least be throwing an exception in [`load_data`](https://github.com/evennia/evennia/blob/f093c8bcb1321a44e33003cf91cf9f565e028de7/evennia/utils/containers.py#L202) but neither seem to be doing so. Instead, they just silently don't load the script.
[BUG - Develop] Evennia can no longer create global scripts from settings.py
#### Describe the bug
As of merging #2882 any attempt to create a new game with global scripts defined in settings does not create the script. It simply fails the assertion check and moves on without attempting to create them.

#### To Reproduce
Steps to reproduce the behavior:
1. Set up a new freshly-made game with `GLOBAL_SCRIPTS` defined in the settings. (I included what I used at the bottom.)
2. Attempt to start the game.
4. See error

#### Expected behavior
It's supposed to automatically create the script if it doesn't exist.

#### Develop-branch commit
b0f24f997

#### Additional context
```py
# added to typeclasses/scripts.py
from evennia.utils import logger

class TestScript(DefaultScript):
    key = "global_test_script"

    def at_repeat(self):
        logger.log_msg("This just prints a nice message.")
```

```py
# added to server/conf/settings.py
GLOBAL_SCRIPTS = {
    "global_test_script": {
        "typeclass": "typeclasses.scripts.TestScript",
        "persistent": True,
        "interval": 60,
        "desc": "A simple script to test creation.",
    },
}
```

```
2022-10-08 04:42:58 [!!] Traceback (most recent call last):
2022-10-08 04:42:58 [!!]   File "./evgames/evennia/evennia/utils/containers.py", line 214, in load_data
2022-10-08 04:42:58 [!!]     assert issubclass(script_typeclass, _BASE_SCRIPT_TYPECLASS)
2022-10-08 04:42:58 [!!] AssertionError
2022-10-08 04:42:58 [!!] GlobalScriptContainer could not start import global script global_test_script. It will be removed (skipped).
```


Discussion:
Hi @InspectorCaracal

Thanks for the steps to reproduce!

This is the stack trace I'm getting. I think it matches your description.

```shell
...evennia_mygame$ evennia start
Traceback (most recent call last):
  File "/home/henddher/Documents/github/evennia_venv/bin/evennia", line 10, in <module>
    main()
  File "/home/henddher/Documents/github/evennia/evennia/server/evennia_launcher.py", line 2273, in main
    init_game_directory(CURRENT_DIR, check_db=True)
  File "/home/henddher/Documents/github/evennia/evennia/server/evennia_launcher.py", line 1761, in init_game_directory
    check_database()
  File "/home/henddher/Documents/github/evennia/evennia/server/evennia_launcher.py", line 1462, in check_database
    evennia._init()
  File "/home/henddher/Documents/github/evennia/evennia/__init__.py", line 363, in _init
    GLOBAL_SCRIPTS.start()
  File "/home/henddher/Documents/github/evennia/evennia/utils/containers.py", line 188, in start
    self._load_script(key)
  File "/home/henddher/Documents/github/evennia/evennia/utils/containers.py", line 136, in _load_script
    typeclass = self.typeclass_storage[key]
KeyError: 'key'

```
@Henddher That's the one!
At first, I figure that there would be no `try/catch` around those loading functions. But, they are. I think the issue is that the exception is silently swallowed and the system proceeds as normal.

https://github.com/Henddher/evennia/blob/39f6505bf6438eaefbd0791e5c821578b63a3dca/evennia/utils/containers.py#L192-L201
```python
            for key, data in self.loaded_data.items():
                try:
                    typeclass = data.get("typeclass", settings.BASE_SCRIPT_TYPECLASS)
                    self.typeclass_storage[key] = class_from_module(typeclass)
                except ImportError as err:
                    logger.log_err(
                        f"GlobalScriptContainer could not start global script {key}: {err}"
                    )
                    #  👈 No raise?


    def get(self, key, default=None):
```

I've got a solution (I think!).

https://github.com/evennia/evennia/pull/2882/commits/d9bdabf4c183dc9f93986395fb6e836da2a005d8

Instead of simply "swallowing" the exception, I remove `key` from `self.loaded_data` essentially *skipping* the final load of that poorly constructed global script. All other global scripts will be attempted.

This is my `mygame/server/conf/settings.py`

```python
GLOBAL_SCRIPTS = {
    'nothingscript': {
            'typeclass': 'typeclasses.badscript.NothingScript',
            'desc': 'Nothing script'
    },
    'badscript': {
            'typeclass': 'typeclasses.badscript.BadScript',
            'desc': 'Bad script'
    },
    'defaultscript': {
            'desc': 'Default script'
    },
```

And my `mygame/typeclasses/badscript.py`

```python
from evennia import DefaultScript
from evennia import bullcrap

class BaddScript:
  pass

class NothingScript(DefaultScript):
  pass
```

And, when you run `evennia start` (or `evennia stop`), this is what the log contains now:

```text
2022-10-02 02:00:34 [..] Loaded.
2022-10-02 02:00:33 [..] Loading /home/henddher/Documents/github/evennia/evennia/server/server.py...
2022-10-02 02:00:34 [EE] GlobalScriptContainer could not start import global script nothingscript. It will be removed (skipped).
2022-10-02 02:00:34 [EE] GlobalScriptContainer could not start import global script badscript. It will be removed (skipped).
2022-10-02 02:00:34 [!!] Traceback (most recent call last):
2022-10-02 02:00:34 [!!]   File "/home/henddher/Documents/github/evennia/evennia/typeclasses/models.py", line 271, in set_class_from_typeclass
2022-10-02 02:00:34 [!!]     self.__class__ = class_from_module(self.db_typeclass_path)
2022-10-02 02:00:34 [!!]   File "/home/henddher/Documents/github/evennia/evennia/utils/utils.py", line 1569, in class_from_module
2022-10-02 02:00:34 [!!]     mod = importlib.import_module(testpath, package="evennia")
2022-10-02 02:00:34 [!!]   File "/home/henddher/.pyenv/versions/3.10.4/lib/python3.10/importlib/__init__.py", line 126, in import_module
2022-10-02 02:00:34 [!!]     return _bootstrap._gcd_import(name[level:], package, level)
2022-10-02 02:00:34 [!!]   File "<frozen importlib._bootstrap>", line 1050, in _gcd_import
2022-10-02 02:00:34 [!!]   File "<frozen importlib._bootstrap>", line 1027, in _find_and_load
2022-10-02 02:00:34 [!!]   File "<frozen importlib._bootstrap>", line 1006, in _find_and_load_unlocked
2022-10-02 02:00:34 [!!]   File "<frozen importlib._bootstrap>", line 688, in _load_unlocked
2022-10-02 02:00:34 [!!]   File "<frozen importlib._bootstrap_external>", line 883, in exec_module
2022-10-02 02:00:34 [!!]   File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
2022-10-02 02:00:34 [!!]   File "/home/henddher/Documents/github/evennia_mygame/typeclasses/badscript.py", line 2, in <module>
2022-10-02 02:00:34 [!!]     from evennia import bullcrap
2022-10-02 02:00:34 [!!] ImportError: cannot import name 'bullcrap' from 'evennia' (/home/henddher/Documents/github/evennia/evennia/__init__.py)
2022-10-02 02:00:34 [..] Loaded.
2022-10-02 02:00:34 [..] twistd 21.7.0 (/home/henddher/Documents/github/evennia_venv/bin/python 3.10.4) starting up.
2022-10-02 02:00:34 [..] reactor class: twisted.internet.epollreactor.EPollReactor.
2022-10-02 02:00:34 [..] Webserver starting on 4005
2022-10-02 02:00:34 [..] Evennia Server successfully started.
```

Thoughts?

I'll push it for review and maybe push another PR with a test (if I figure out how to test that).
The error is much clearer, which is a definite improvement, but I'd prefer if the game failed to start due to a broken global script rather than continuing to start without it. If I have a global script, the chances that I have other systems relying on it are _very_ high, so having the game start without that script available could cause a cascade of broken systems.
I would agree, but imo it's a philosophical topic ... 
I advocate for a flag to turn it OFF/ON!

Actually: there could be a GLOBAL flag to die upon bad global scripts and *another* at the script level:

```
GLOBAL_SCRIPTS = {
    'nothingscript': {
            'typeclass': 'typeclasses.badscript.NothingScript',
            'desc': 'Nothing script',
            'required': True 
    },
    'badscript': {
            'typeclass': 'typeclasses.badscript.BadScript',
            'desc': 'Bad script',
            'required': False
    },
    'defaultscript': {
            'desc': 'Default script'
            # required is True by default
    },
}
```

Thoughts?

I think that's unnecessarily complicated.

If you really want the game to start regardless of a completely broken module, then I would suggest following prior example and having the script use a default fallback typeclass like the other systems.
Then another one liner `raise err` would have worked too :smile: 

I could go either way - I don't feel attached to either one "school of thought"
Closed in #2882. 
Yeap! that's due to #2882. I'll take a look. Sorry about that.

https://github.com/evennia/evennia/blob/b0f24f9975e14fddeabef06a19b5b61575957b1c/evennia/utils/containers.py#L204-L206

@InspectorCaracal, what do you have for `settings.BASE_SCRIPT_TYPECLASS`?

And I wonder where is with respect to `settings.GLOBAL_SCRIPTS`.
Hi @InspectorCaracal

This is the problem:

At `evennia start`, we load and "cache" `settings.BASE_SCRIPT_TYPECLASS`. However, in your example, `TestScript` does **not** derives from `typeclasses.scripts.Script` so the assertion triggers and the script gets skipped. Because your `TestScript` is subclassing `DefaultScript` which is **not** what `settings.BASE_SCRIPT_TYPECLASS` expects.

```
> /home/henddher/Documents/github/evennia/evennia/utils/containers.py(207)load_data()
-> _BASE_SCRIPT_TYPECLASS = class_from_module(settings.BASE_SCRIPT_TYPECLASS)
(Pdb) p settings.BASE_SCRIPT_TYPECLASS
'typeclasses.scripts.Script'
```
I modified your example to `class TestScript(Script): ...`, retried it and worked (as expected?). One may argue that this is WAI (I wouldn't either way :wink:)

Now, the issue at hand is the following:

Is the `assert`ion too stringent or should it be relaxed to just `assert issubclass(typeclass, DefaultScript)`?

https://github.com/evennia/evennia/blob/b0f24f9975e14fddeabef06a19b5b61575957b1c/evennia/utils/containers.py#L212-L214

Semantically, what's the meaning of `settings.BASE_SCRIPT_TYPECLASS`. Is this an unbreakable contract or a "nice" default that can be circumvented?

Maybe others want to chime in?

Thanks. It looks like that's the cause for my game's code, as well.

> Semantically, what's the meaning of settings.BASE_SCRIPT_TYPECLASS. Is this an unbreakable contract or a "nice" default that can be circumvented?

To the best of my knowledge, it's intended to represent the default typeclass. If an already-existing script's typeclass module has an error, it'll load it in with that base typeclass instead, and if there's no typeclass passed when creating a script, it'll create it with that base typeclass.

That said, with the exception handling you added, I don't think the assertion is needed at all. `class_from_module` will raise an import error if it fails to import, so it'll be caught and the key will be removed.
> To the best of my knowledge, it's intended to represent the default typeclass. If an already-existing script's typeclass module has an error, it'll load it in with that base typeclass instead [...]

But if the class no longer exists for an already-existing script, using a completely different class (no matter which one in the DefaultScript hierarchy) would likely produce undesired results. 

... that actually makes me wonder ... when I tried to reproduce this issue, the server started to fail loading because scripts with bad classes (badscript.py I used for testing) was no longer in my typeclasses directory. So I think it didn't try to use any other class and it just failed.

IMHO, as I hinted in the PR, the options at this point should be to either relax the `assert` or leave it as is. I would argue against removing it. I'd argue that the value of keeping it (in either flavor) would compensate for when one creates a script that is invalid (i.e. not subclassing `DefaultScript`). The fact that `class_from_module` successfully loads a class does not guarantee that the class is a `DefaultScript` (or subclass of) - which *is* a main requirement.

> > To the best of my knowledge, it's intended to represent the default typeclass. If an already-existing script's typeclass module has an error, it'll load it in with that base typeclass instead [...]
> 
> But if the class no longer exists for an already-existing script, using a completely different class (no matter which one in the DefaultScript hierarchy) would likely produce undesired results.

Yes, and you get a warning/error message about it if it happens. It's not supposed to be a usable state, it's a fallback state when there's an error.
Replying separately because it's a separate topic:

> IMHO, as I hinted in the PR, the options at this point should be to either relax the `assert` or leave it as is. I would argue against removing it. I'd argue that the value of keeping it (in either flavor) would compensate for when one creates a script that is invalid (i.e. not subclassing `DefaultScript`). The fact that `class_from_module` successfully loads a class does not guarantee that the class is a `DefaultScript` (or subclass of) - which _is_ a main requirement.

As long as my completely functional global scripts are created on a new game, I don't actually have an opinion on how you do it. I just think the simplest way to achieve resolution for this specific issue would be to remove the assertion.
The assertion in this case is also showing an error message. The only difference is that it *skips* the "bad" script and continues with all others. Technically, doesn't fall back the "bad" script to anything.

I don't have a strong preference either. I just think that the assertion adds an extra check that we didn't have before and it catches a problem much earlier than before.

TBH, I have not tried removing the assertion and using `BadScript` and/or `WorstScript` from `test_containers.py`. Worth trying and seeing what the outcome is. I agree that removing the assertion would be easier but how much would it help in this case? I'd like to try it to see what happens exactly and *when*.
I missed this in the review - the assertion should be removed; BASE_SCRIPT_TYPECLASS is a fallback as @InspectorCaracal said, there should be no requirement to base all your scripts on it. 

If you provide a malformed script, it should give a traceback in log and not be loaded. 
I'll remove the assertion and update the PR. I might even reuse the test I added and just verify that the outcome is different (i.e. that the questionable script is *not* skipped).

I just noticed the PR was closed? I will try to repurposed it.
@Henddher I closed the PR since it wouldn't merge in its state. If you prefer to rework it rather than make a new one, I can reopen the PR again.
Nah, it's ok. I am pushing a new one and citing the closed one. I do wonder what's an accepted etiquette in cases like this.

I think that keeping conversations in PRs as they evolve is useful. In this case, since it's going 180 in the opposite direction, it may be a bit tricky because it might cause confusion. Although with the insights I'll provide in the new one, it may have been useful to keep all in the same PR. We'll see :smile: 

No worries @Griatch :+1: 

Resolving patch:
diff --git a/evennia/utils/containers.py b/evennia/utils/containers.py
--- a/evennia/utils/containers.py
+++ b/evennia/utils/containers.py
@@ -19,8 +19,6 @@
 from evennia.utils.utils import callables_from_module, class_from_module
 
 SCRIPTDB = None
-_BASE_SCRIPT_TYPECLASS = None
-
 
 class Container:
     """
@@ -201,27 +199,11 @@ def load_data(self):
         initialized.
 
         """
-        if self.loaded_data:
-            # we don't always load this, it collides with doc generation
-            global _BASE_SCRIPT_TYPECLASS
-            if not _BASE_SCRIPT_TYPECLASS:
-                _BASE_SCRIPT_TYPECLASS = class_from_module(settings.BASE_SCRIPT_TYPECLASS)
-
         if self.typeclass_storage is None:
             self.typeclass_storage = {}
             for key, data in list(self.loaded_data.items()):
-                try:
-                    typeclass = data.get("typeclass", settings.BASE_SCRIPT_TYPECLASS)
-                    script_typeclass = class_from_module(typeclass)
-                    assert issubclass(script_typeclass, _BASE_SCRIPT_TYPECLASS)
-                    self.typeclass_storage[key] = script_typeclass
-                except Exception:
-                    logger.log_trace(
-                        f"GlobalScriptContainer could not start import global script {key}. "
-                        "It will be removed (skipped)."
-                    )
-                    # Let's remove this key/value. We want to let other scripts load.
-                    self.loaded_data.pop(key)
+                typeclass = data.get("typeclass", settings.BASE_SCRIPT_TYPECLASS)
+                self.typeclass_storage[key] = class_from_module(typeclass, fallback=settings.BASE_SCRIPT_TYPECLASS)
 
     def get(self, key, default=None):
         """


Test patch:
diff --git a/evennia/utils/tests/data/broken_script.py b/evennia/utils/tests/data/broken_script.py
new file mode 100644
--- /dev/null
+++ b/evennia/utils/tests/data/broken_script.py
@@ -0,0 +1,10 @@
+"""
+Defines a script module with a broken import, to catch the specific error case
+in loading global scripts where the module can be parsed but has broken
+dependencies.
+"""
+
+from evennia import nonexistent_module, DefaultScript
+
+class BrokenScript(DefaultScript):
+    pass
diff --git a/evennia/utils/tests/test_containers.py b/evennia/utils/tests/test_containers.py
--- a/evennia/utils/tests/test_containers.py
+++ b/evennia/utils/tests/test_containers.py
@@ -4,78 +4,79 @@
 from django.conf import settings
 from django.test import override_settings
 from evennia.utils.utils import class_from_module
+from evennia import DefaultScript
 
-_BASE_SCRIPT_TYPECLASS = class_from_module(settings.BASE_SCRIPT_TYPECLASS)
+_BASE_TYPECLASS = class_from_module(settings.BASE_SCRIPT_TYPECLASS)
 
-class GoodScript(_BASE_SCRIPT_TYPECLASS):
-  pass
+class GoodScript(DefaultScript):
+    pass
 
-class BadScript:
-  """Not subclass of _BASE_SCRIPT_TYPECLASS,"""
-  pass
-
-class WorseScript(_BASE_SCRIPT_TYPECLASS):
-  """objects will fail upon call"""
-  @property
-  def objects(self):
-    from evennia import module_that_doesnt_exist
+class InvalidScript:
+    pass
 
 class TestGlobalScriptContainer(unittest.TestCase):
 
-  def test_init_with_no_scripts(self):
-    gsc = containers.GlobalScriptContainer()
-
-    self.assertEqual(len(gsc.loaded_data), 0)
-
-  @override_settings(GLOBAL_SCRIPTS={'script_name': {}})
-  def test_init_with_typeclassless_script(self):
+    def test_init_with_no_scripts(self):
+        gsc = containers.GlobalScriptContainer()
 
-    gsc = containers.GlobalScriptContainer()
+        self.assertEqual(len(gsc.loaded_data), 0)
 
-    self.assertEqual(len(gsc.loaded_data), 1)
-    self.assertIn('script_name', gsc.loaded_data)
+    @override_settings(GLOBAL_SCRIPTS={})
+    def test_start_with_no_scripts(self):
+        gsc = containers.GlobalScriptContainer()
 
-  def test_start_with_no_scripts(self):
-    gsc = containers.GlobalScriptContainer()
+        gsc.start()
 
-    gsc.start()
+        self.assertEqual(len(gsc.typeclass_storage), 0)
 
-    self.assertEqual(len(gsc.typeclass_storage), 0)
+    @override_settings(GLOBAL_SCRIPTS={'script_name': {}})
+    def test_start_with_typeclassless_script(self):
+        """No specified typeclass should fallback to base"""
+        gsc = containers.GlobalScriptContainer()
 
-  @override_settings(GLOBAL_SCRIPTS={'script_name': {}})
-  def test_start_with_typeclassless_script_defaults_to_base(self):
-    gsc = containers.GlobalScriptContainer()
+        gsc.start()
 
-    gsc.start()
+        self.assertEqual(len(gsc.typeclass_storage), 1)
+        self.assertIn('script_name', gsc.typeclass_storage)
+        self.assertEqual(gsc.typeclass_storage['script_name'], _BASE_TYPECLASS)
 
-    self.assertEqual(len(gsc.typeclass_storage), 1)
-    self.assertIn('script_name', gsc.typeclass_storage)
-    self.assertEqual(gsc.typeclass_storage['script_name'], _BASE_SCRIPT_TYPECLASS)
+    @override_settings(GLOBAL_SCRIPTS={'script_name': {'typeclass': 'evennia.utils.tests.test_containers.NoScript'}})
+    def test_start_with_nonexistent_script(self):
+        """Missing script class should fall back to base"""
+        gsc = containers.GlobalScriptContainer()
 
-  @override_settings(GLOBAL_SCRIPTS={'script_name': {'typeclass': 'evennia.utils.tests.test_containers.GoodScript'}})
-  def test_start_with_typeclassed_script_loads_it(self):
-    gsc = containers.GlobalScriptContainer()
+        gsc.start()
 
-    gsc.start()
+        self.assertEqual(len(gsc.typeclass_storage), 1)
+        self.assertIn('script_name', gsc.typeclass_storage)
+        self.assertEqual(gsc.typeclass_storage['script_name'], _BASE_TYPECLASS)
 
-    self.assertEqual(len(gsc.typeclass_storage), 1)
-    self.assertIn('script_name', gsc.typeclass_storage)
-    self.assertEqual(gsc.typeclass_storage['script_name'], GoodScript)
+    @override_settings(GLOBAL_SCRIPTS={'script_name': {'typeclass': 'evennia.utils.tests.test_containers.GoodScript'}})
+    def test_start_with_valid_script(self):
+        gsc = containers.GlobalScriptContainer()
 
-  @override_settings(GLOBAL_SCRIPTS={'script_name': {'typeclass': 'evennia.utils.tests.test_containers.BadScript'}})
-  def test_start_with_bad_typeclassed_script_skips_it(self):
-    gsc = containers.GlobalScriptContainer()
+        gsc.start()
 
-    gsc.start()
+        self.assertEqual(len(gsc.typeclass_storage), 1)
+        self.assertIn('script_name', gsc.typeclass_storage)
+        self.assertEqual(gsc.typeclass_storage['script_name'], GoodScript)
 
-    self.assertEqual(len(gsc.typeclass_storage), 0)
-    self.assertNotIn('script_name', gsc.typeclass_storage)
+    @override_settings(GLOBAL_SCRIPTS={'script_name': {'typeclass': 'evennia.utils.tests.test_containers.InvalidScript'}})
+    def test_start_with_invalid_script(self):
+        """Script class doesn't implement required methods methods"""
+        gsc = containers.GlobalScriptContainer()
 
-  @override_settings(GLOBAL_SCRIPTS={'script_name': {'typeclass': 'evennia.utils.tests.test_containers.WorstScript'}})
-  def test_start_with_worst_typeclassed_script_skips_it(self):
-    gsc = containers.GlobalScriptContainer()
+        with self.assertRaises(AttributeError) as err: 
+            gsc.start()
+        # check for general attribute failure on the invalid class to preserve against future code-rder changes
+        self.assertTrue(str(err.exception).startswith("type object 'InvalidScript' has no attribute"), err.exception)
 
-    gsc.start()
+    @override_settings(GLOBAL_SCRIPTS={'script_name': {'typeclass': 'evennia.utils.tests.data.broken_script.BrokenScript'}})
+    def test_start_with_broken_script(self):
+        """Un-importable script should traceback"""
+        gsc = containers.GlobalScriptContainer()
 
-    self.assertEqual(len(gsc.typeclass_storage), 0)
-    self.assertNotIn('script_name', gsc.typeclass_storage)
+        with self.assertRaises(Exception) as err: 
+            gsc.start()
+        # exception raised by imported module
+        self.assertTrue(str(err.exception).startswith("cannot import name 'nonexistent_module' from 'evennia'"), err.exception)