Development

Beyond crafting content in-game, there’s a lot more development tooling that can be used to work on the core functions of django-moo.

Prerequisites

Getting Started

In the django-moo README there are instructions on setting up Docker and using Docker Compose to run the application stack. You’ll still want to follow them all:

Checkout the project and use Docker Compose to run the necessary components:

git clone https://gitlab.com/bubblehouse/django-moo
cd django-moo
docker compose up

Run migrate, collectstatic, and bootstrap the initial database with some sample objects and users:

docker compose run webapp manage.py migrate
docker compose run webapp manage.py collectstatic
docker compose run webapp manage.py moo_init
docker compose run webapp manage.py createsuperuser --username phil
docker compose run webapp manage.py moo_enableuser --wizard phil Wizard

Once this part is complete, though, you’ll want to open up the project folder in VSCode. You should be prompted to reopen the project as a Dev Container, if not, invoke “Dev Containers: Reopen in Container” from the command bar.

Environment Setup Details

The project uses uv for dependency management and pytest for testing.

Install Dependencies

uv sync

This installs all dependencies including development tools like PyLint, pytest, and coverage analysis.

Django Management Commands

Essential management commands for development. All examples use docker compose run webapp manage.py; if you’re working inside a Dev Container or have activated the project venv, the bare manage.py <command> form works equivalently.

# Migrate database to latest schema
docker compose run webapp manage.py migrate

# Initialize the game world with bootstrap data
docker compose run webapp manage.py moo_init

# Access Django interactive shell for debugging
docker compose run webapp manage.py shell

Creating Player Accounts

There are three ways to create a player account:

moo_createuser — creates a Django user and a linked MOO avatar in one command. Use this for agents, test accounts, or any regular player created from the CLI:

docker compose run webapp manage.py moo_createuser yourname YourAvatar --password secret

Omit --password to be prompted. Add --wizard to grant in-game wizard privileges.

Wizard account — the initial wizard account is a two-step flow because the admin interface needs a Django superuser:

docker compose run webapp manage.py createsuperuser --username yourname
docker compose run webapp manage.py moo_enableuser --wizard yourname YourWizardName

Web registration — regular players can sign up through the web interface at /accounts/signup/. The form creates a Django user and a linked MOO avatar in one step. Email verification is required by default (ACCOUNT_EMAIL_VERIFICATION = "mandatory").

The web registration form accepts: username, email, password, character name, gender (neuter/male/female/plural), and an optional description. If a user is already logged in when they reach the signup page, they are logged out automatically before the new registration is processed.

Using VSCode with django-moo

The first thing to do with your development environment is to make sure you can run the unit tests:

django-moo unit tests

In addition to testing core functionality, there’s also integration tests for the default verbs at creation time.

Running the Server

The Dev Containers use the Compose file normally, except for the Celery broker, which isn’t run by default. Instead the terminals you create will all be on the celery container instance, and you can run the Celery server in debug mode using the launch job on the “Run and Debug” tab.

django-moo debugging

Testing

Running Tests

# Run all tests with coverage reporting
uv run pytest -n auto --cov

# Run a specific test file
uv run pytest -n auto moo/core/tests/test_code.py

# Run a specific test function
uv run pytest -n auto moo/core/tests/test_code.py::test_trivial_printing

# Run with verbose output and stop on first failure
uv run pytest -vv -x

# Run with pdb debugger on failure
uv run pytest --pdb

Test Organization

Path

What it holds

moo/bootstrap/test.py

Minimal bootstrap data used by core unit tests via the t_init fixture

moo/bootstrap/default/

Production-shape bootstrap package (orchestrator + numbered scripts) used to populate playable databases

moo/bootstrap/default/verbs/

Verb sources for the default dataset, organised by root-class name

moo/core/tests/

Unit tests for models, permissions, the verb execution engine

moo/bootstrap/default/tests/

Integration tests for default verbs against a fully bootstrapped world

Shared pytest fixtures live in moo/conftest.py (notably t_init and t_wizard).

Writing Tests

All tests should:

  • Use @pytest.mark.django_db decorator to access the database

  • Test both success and failure cases

  • Follow PEP 8 naming conventions (test functions start with test_)

Core tests

Core tests exercise models and the verb execution engine without needing a full bootstrap. They typically use @pytest.mark.django_db alone:

import pytest
from moo.sdk import create, lookup
from moo.core.exceptions import PermissionDenied

@pytest.mark.django_db
def test_object_creation_and_properties():
    obj = create("Test Object")
    assert obj.name == "Test Object"

    obj.set_property("description", "A test object")
    assert obj.get_property("description") == "A test object"

@pytest.mark.django_db
def test_permission_denial():
    obj = create("Protected Object")
    obj.owner = lookup("Wizard")
    obj.save()

    with pytest.raises(PermissionDenied):
        obj.can_caller("write")

Default tests

Tests in moo/bootstrap/default/tests/ exercise verbs against a fully bootstrapped world.

They use two shared fixtures from moo/conftest.py:

  • t_init: Bootstraps the full game world by running default.py. Yields the system object (#1). Must be requested with @pytest.mark.parametrize("t_init", ["default"], indirect=True).

  • t_wizard: Returns the Wizard player object, which starts in The Laboratory.

Output sent to the player is captured via a _writer callback passed to code.ContextManager. Commands are executed with parse.interpret. After a verb modifies database state, call refresh_from_db() before asserting:

import pytest

from moo.core import code, parse
from moo.sdk import create, lookup
from moo.core.models import Object

@pytest.mark.django_db(transaction=True, reset_sequences=True)
@pytest.mark.parametrize("t_init", ["default"], indirect=True)
def test_drop_from_inventory(t_init: Object, t_wizard: Object):
    printed = []

    def _writer(msg):  # This is what `print()` ends up calling
        printed.append(msg)

    # set up a context with Wizard as the player
    with code.ContextManager(t_wizard, _writer) as ctx:
        system = lookup(1)
        lab = t_wizard.location
        player = lookup("Player")
        widget = create("widget", parents=[system.thing], location=t_wizard)

        # other calls to write() (besides print()) can be caught here
        with pytest.warns(RuntimeWarning, match=r"ConnectionError") as warnings:
            parse.interpret(ctx, "drop widget")
        assert [str(x.message) for x in warnings.list] == [
            f"ConnectionError(#{player.pk} (Player)): #{t_wizard.pk} (Wizard) drops widget."
        ]

        widget.refresh_from_db()
        assert widget.location == lab
        assert printed == ['You drop widget.']

Verbs can also be called directly as Python methods inside a code.ContextManager block. This is useful for testing helper verbs (message formatters, lock checks, etc.) without going through the command parser:

with code.ContextManager(t_wizard, _writer):
    system = lookup(1)
    widget = create("widget", parents=[system.thing], location=t_wizard)

    # Call the verb directly — equivalent to the MOO expression widget:drop_succeeded_msg()
    assert widget.drop_succeeded_msg() == f"You drop {widget.title()}."

    # Test moveto without the drop command
    lab = t_wizard.location
    widget.moveto(lab)
    widget.refresh_from_db()
    assert widget.location == lab

Testing lack of results is important too; for example, to test that a lock prevents movement, set a key property on the destination before calling moveto. The lock expression ["!", id] blocks the object whose id matches:

destination.set_property("key", ["!", widget.id])
widget.moveto(destination)
widget.refresh_from_db()
assert widget.location != destination  # move was blocked

For commands that call write() on other objects (as opposed to those that just use print()), wrap the parse.interpret call with pytest.warns:

with pytest.warns(RuntimeWarning, match=r"ConnectionError") as warnings:
    parse.interpret(ctx, "go north")
assert [str(x.message) for x in warnings.list] == [
    f"ConnectionError(#{t_wizard.pk} (Wizard)): You leave ...",
]

Code Quality Tools

# Run PyLint to check code quality
DJANGO_SETTINGS_MODULE=moo.settings.test uv run pylint moo

# View coverage report
uv run coverage report

# Format code with Ruff
uv run ruff format moo

Common Development Tasks

Debugging Objects in Django Shell

docker compose run webapp manage.py shell
>>> from moo.sdk import lookup
>>> sys = lookup(1)
>>> sys.root_class.name
'Root Class'
>>> sys.root_class.properties.all()
<QuerySet [...]>
>>> sys.root_class.verbs.all()
<QuerySet [...]>

Picking up bootstrap changes

For most iteration, you don’t need to reset anything. After editing a verb file or adding objects to a numbered bootstrap script, run:

docker compose run webapp manage.py moo_init --bootstrap default --sync

--sync re-runs the bootstrap against the existing database. get_or_create_object skips already-existing objects and load_verbs(replace=True) overwrites verb source in place.

Truly resetting the database

When you actually need to start over (a corrupt schema, an in-progress migration that can’t roll forward, an experimental dataset you want to throw away), recreate the postgres container rather than running migrate zero:

docker compose down -v
docker compose up -d
docker compose run webapp manage.py migrate
docker compose run webapp manage.py moo_init

-v drops the postgres volume so the next up starts from an empty database. You’ll need to re-run createsuperuser / moo_enableuser afterwards.

Performance Profiling

Use pytest-profiling to identify slow operations:

# Run tests with profiling
uv run pytest --profile

# View the generated profile
python -m pstats .prof

Documentation

Documentation is generated from source code docstrings using Sphinx:

# Build documentation locally
cd docs
uv run make html

# View the built documentation
open build/html/index.html

If autosummary fails to import a moved or removed verb file, wipe the generated stub directory and rebuild:

rm -rf docs/source/generated
cd docs && uv run make clean && uv run make html

Always include docstrings on:

  • All classes and methods

  • All module-level functions

  • Complex verb code

Git Workflow

  1. Create a feature branch: git checkout -b feat/my-feature

  2. Make changes and test locally

  3. Follow Conventional Commits for commit messages:

    • feat(core): add new feature

    • fix(shell): handle disconnect

    • docs(context): update docstring

  4. Push and create a merge request targeting main

  5. Address review feedback and ensure CI passes

CI/CD Pipeline

The project uses GitLab CI/CD. On each commit:

  1. Lint stage - PyLint checks code quality (minimum score 8.0)

  2. Test stage - pytest runs with coverage tracking

  3. Release stage (on main only) - Semantic versioning and Docker image build

  4. Deploy stage (on main only) - ReadTheDocs documentation build

Check .gitlab-ci.yml for the full pipeline configuration.