simonw / djp

A plugin system for Django
https://djp.readthedocs.io
Apache License 2.0
85 stars 2 forks source link

Support positioning in the installed_apps hook as well #12

Open offbyone opened 1 month ago

offbyone commented 1 month ago

I've got a plugin whose app has to be ready before any of the other apps that use it, but those apps' names won't be known at code time, so I want to anchor it to the sessions app, or something like that (as an aside, boy howdy I wish I could state dependencies in django apps, so that I could guarantee that $other_app.ready() was called before mine!)

It'd be grand if position support was generalized from the middleware to the apps list.

pirate commented 1 month ago

I also have this need, djp.Position(..., before/after=...) seems like it would work just as well for INSTALLED_APPS :)

pirate commented 1 month ago

After toying around over the last day in our (large-ish) codebase with a bunch of existing plugin hooks, I've found I like explicit pure functional insertion of settings more than the registering via mutation, but imo both have to exist to cover all cases (e.g. plugin that removes an INSTALLED_APP in favor of a different one):

some_plugin.py:

@djp.hookimpl
def get_INSTALLED_APPS():
    # simple case: add some apps to the list
    return ['django_plugin_simple_header']

@djp.hookimpl
def register_INSTALLED_APPS(INSTALLED_APPS):
    # complex case: mutate list to remove/swap out some other app, change ordering, etc.
    if 'some_app_to_replace' in INSTALLED_APPS:
        position = INSTALLED_APPS.index('some_app_to_replace')
        INSTALLED_APPS[position] = 'some_new_app'
    if 'some_app_to_remove' in INSTALLED_APPS:
        INSTLALED_APPS.remove('some_app_to_remove')

See:

settings.py usage:

INSTALLED_APPS = [
    # Django default apps, things that need to be loaded first, etc.
    'daphne',
    'django.contrib.admin',
    ...

    # ArchiveBox plugins
    *djp.get_plugins_INSTALLLED_APPS(),  # register by explicitly inserting apps in the middle (pure, no mutation)

    # 3rd-party apps from PyPI that need to be loaded last
    'admin_data_views',
    'django_extensions', 
    'django_huey',
    ..

    # bonus: create djp/apps.py that implements a .ready() to call plugin .ready hookimpls
    # so plugins can add a hook there too to run code after django setup is complete
    'djp',
]
# djp.get_register_INSTALLLED_APPS(INSTALLED_APPS)  # mutate list in-place (called at the end by register_plugins_settings(globals())

AUTHENTICATION_BACKENDS = [
    'django.contrib.auth.backends.RemoteUserBackend',
    'django.contrib.auth.backends.ModelBackend',
    *djp.get_plugins_AUTHENTICATION_BACKENDS(),
]

STATICFILES_DIRS = [
    *djp.get_plugins_STATICFILES_DIRS(),
    str(PACKAGE_DIR / TEMPLATES_DIR_NAME / 'static'),
]

# ... etc.

# at the end:
djp.register_plugins_settings(globals())   # this can access/mutate the entire settings obj

djp/__init__.py:

def get_plugins_INSTALLLED_APPS():
    """get the list of all the plugin provided INSTALLED_APPS, user defines where they go in list in settings.py"""
    return itertools.chain(*pm.hook.get_INSTALLED_APPS())

def register_plugins_INSTALLLED_APPS(INSTALLED_APPS):
    """Mutate the INSTALLED_APPS list in place to edit/remove/add apps in a specific position in the list"""
    pm.hook.register_INSTALLED_APPS(INSTALLED_APPS=INSTALLED_APPS)

# etc. ...

def register_plugins_settings(settings):
    # convert settings dict to a benedict so we can set values using settings.attr = xyz notation
    settings_as_obj = benedict(settings, keypath_separator=None)

    # set default values for settings that are used by plugins
    settings_as_obj.INSTALLED_APPS = settings_as_obj.get('INSTALLED_APPS', [])
    settings_as_obj.MIDDLEWARE = settings_as_obj.get('MIDDLEWARE', [])
    settings_as_obj.AUTHENTICATION_BACKENDS = settings_as_obj.get('AUTHENTICATION_BACKENDS', [])
    settings_as_obj.STATICFILES_DIRS = settings_as_obj.get('STATICFILES_DIRS', [])
    settings_as_obj.TEMPLATE_DIRS = settings_as_obj.get('TEMPLATE_DIRS', [])
    settings_as_obj.DJANGO_HUEY = settings_as_obj.get('DJANGO_HUEY', {'queues': {}})
    settings_as_obj.ADMIN_DATA_VIEWS = settings_as_obj.get('ADMIN_DATA_VIEWS', {'URLS': []})

    # call all the hook functions that mutate the settings values in-place
    register_plugins_INSTALLLED_APPS(settings_as_obj.INSTALLED_APPS)
    register_plugins_MIDDLEWARE(settings_as_obj.MIDDLEWARE)
    register_plugins_AUTHENTICATION_BACKENDS(settings_as_obj.AUTHENTICATION_BACKENDS)
    register_plugins_STATICFILES_DIRS(settings_as_obj.STATICFILES_DIRS)
    register_plugins_TEMPLATE_DIRS(settings_as_obj.TEMPLATE_DIRS)
    register_plugins_DJANGO_HUEY(settings_as_obj.DJANGO_HUEY)
    register_plugins_ADMIN_DATA_VIEWS(settings_as_obj.ADMIN_DATA_VIEWS)

    # calls Plugin.settings(settings) on each registered plugin
    pm.hook.settings(settings=settings_as_obj)

    # then finally update the settings globals() object will all the new settings
    settings.update(settings_as_obj)

I like this approach becuase I want to encourage the djp.get_XYZ functions to be used more than the djp.register_XYZ functions. The pure getters are easier to read and understand at a glance, the ordering is explicit, and you don't have to drill down to the stack to see what they change.

When there's inter-dependence between things then djp.register_XYZ variants are available, but you don't have to use them and it's better if mutation is only used when absolutely needed!