203 lines
9.3 KiB
Python
203 lines
9.3 KiB
Python
from django.core.exceptions import ImproperlyConfigured
|
|
|
|
from netbox.registry import registry
|
|
|
|
__all__ = (
|
|
'get_model_label',
|
|
'register_model_graphql_type',
|
|
)
|
|
|
|
|
|
def get_model_label(model):
|
|
"""
|
|
Return the canonical `app_label.model_name` label used to key GraphQL extensions in the registry. Both the
|
|
registration side and the lookup side must derive labels through this helper so they always agree.
|
|
"""
|
|
return f'{model._meta.app_label}.{model._meta.model_name}'
|
|
|
|
|
|
def _own_names(klass):
|
|
"""Concrete attributes only, since PEP 649 keeps annotations out of the class dict from Python 3.14 on."""
|
|
names = {name for name in vars(klass) if not name.startswith('__')}
|
|
names.discard('models')
|
|
return names
|
|
|
|
|
|
def _field_names(klass):
|
|
"""Python names from the class's own completed Strawberry definition, including fields it inherited."""
|
|
definition = vars(klass).get('__strawberry_definition__')
|
|
if definition is None:
|
|
return set()
|
|
return {field.python_name for field in definition.fields if field.python_name is not None} - {'models'}
|
|
|
|
|
|
def _all_names(klass):
|
|
"""Strawberry fields plus concrete attributes anywhere in the MRO, so no raw annotation is ever read."""
|
|
names = _field_names(klass)
|
|
for base in klass.__mro__:
|
|
if base is not object:
|
|
names |= _own_names(base)
|
|
return names
|
|
|
|
|
|
def _name_owner(klass, name):
|
|
"""Class in the MRO declaring `name` as a real attribute, or None when it is annotation-only."""
|
|
for base in klass.__mro__:
|
|
if name in vars(base):
|
|
return base
|
|
return None
|
|
|
|
|
|
def _core_names(cls):
|
|
"""
|
|
Return every name `cls` resolves (its own body and everything it inherits). Extensions are spliced in *after*
|
|
these bases, so any name already present here is provided by the core type and an extension cannot override it.
|
|
"""
|
|
names = _field_names(cls)
|
|
for klass in cls.__mro__:
|
|
if klass is object:
|
|
continue
|
|
names |= _own_names(klass)
|
|
return names
|
|
|
|
|
|
def _class_path(cls):
|
|
"""Module-qualified identity for startup errors, since bare class names collide across plugins."""
|
|
return f'{cls.__module__}.{cls.__qualname__}'
|
|
|
|
|
|
def _compose(cls, extensions):
|
|
"""
|
|
Build a subclass of `cls` with the extension mixins appended to its bases. `cls` is already decorated, so
|
|
the composed class needs no namespace of its own: fields and methods are inherited, zero-argument super()
|
|
in core methods keeps working, and core attributes win every MRO lookup.
|
|
"""
|
|
namespace = {'__module__': cls.__module__, '__qualname__': cls.__qualname__, '__doc__': cls.__doc__}
|
|
try:
|
|
return type(cls)(cls.__name__, (cls, *extensions), namespace)
|
|
except TypeError as exc:
|
|
raise ImproperlyConfigured(
|
|
f"Failed to compose GraphQL extension(s) {[_class_path(e) for e in extensions]} into core type "
|
|
f"'{cls.__name__}': {exc}. A GraphQL extension should be a plain @strawberry.type mixin that only "
|
|
f"adds fields. The extensions declare conflicting base class orders."
|
|
) from exc
|
|
|
|
|
|
def splice_extension_bases(cls, extensions):
|
|
"""
|
|
Return `cls` composed with the given extension mixins, or `cls` unchanged when there are none. Extensions are
|
|
strictly additive. An extension sharing ancestry with the core type, declaring a Python name the core type
|
|
already resolves, or declaring a name another extension claims raises ImproperlyConfigured.
|
|
"""
|
|
if not extensions:
|
|
return cls
|
|
core_names = _core_names(cls)
|
|
core_bases = set(cls.__mro__)
|
|
claimed = {}
|
|
for extension in extensions:
|
|
# A shared ancestor lets C3 interleave extension bases ahead of core hooks such as get_queryset().
|
|
if shared := [base for base in extension.__mro__ if base is not object and base in core_bases]:
|
|
raise ImproperlyConfigured(
|
|
f"GraphQL extension {_class_path(extension)} shares ancestry with core type "
|
|
f"'{cls.__name__}' ({_class_path(shared[0])}). An extension must be an independent "
|
|
f"mixin that does not inherit from core GraphQL classes."
|
|
)
|
|
for name in _all_names(extension):
|
|
if name in core_names:
|
|
raise ImproperlyConfigured(
|
|
f"GraphQL extension {_class_path(extension)} declares '{name}', which core type "
|
|
f"'{cls.__name__}' already provides."
|
|
)
|
|
if name in claimed:
|
|
# One shared helper base is harmless, two independent declarations of a name are not.
|
|
owner = _name_owner(extension, name)
|
|
if owner is None or owner is not _name_owner(claimed[name], name):
|
|
raise ImproperlyConfigured(
|
|
f"GraphQL extensions {_class_path(claimed[name])} and {_class_path(extension)} both "
|
|
f"declare '{name}' on '{cls.__name__}'."
|
|
)
|
|
claimed[name] = extension
|
|
return _compose(cls, extensions)
|
|
|
|
|
|
def validate_extension_final_names(core_type, extensions):
|
|
"""
|
|
Check extension fields against the final GraphQL names and python names of the decorated, unextended core
|
|
type. This covers names invisible before decoration: generated model fields, filter logical fields (AND,
|
|
OR, NOT, DISTINCT), and explicit aliases. NetBox disables auto camel casing, so a field's final name is
|
|
its explicit graphql_name or its python name.
|
|
"""
|
|
baseline_fields = core_type.__strawberry_definition__.fields
|
|
baseline_names = {f.graphql_name or f.python_name for f in baseline_fields}
|
|
baseline_python_names = {f.python_name for f in baseline_fields}
|
|
claimed_names = {}
|
|
claimed_python_names = {}
|
|
for extension in extensions:
|
|
for field in extension.__strawberry_definition__.fields:
|
|
name = field.graphql_name or field.python_name
|
|
if name in baseline_names:
|
|
raise ImproperlyConfigured(
|
|
f"GraphQL extension {_class_path(extension)} declares field '{name}', which collides with an "
|
|
f"existing field of that name on '{core_type.__name__}'."
|
|
)
|
|
# An aliased field still claims its python name and would suppress the generated core field.
|
|
if field.python_name in baseline_python_names:
|
|
raise ImproperlyConfigured(
|
|
f"GraphQL extension {_class_path(extension)} declares '{field.python_name}', which core type "
|
|
f"'{core_type.__name__}' already provides."
|
|
)
|
|
# Strawberry keys fields by python name, so a shared one silently replaces across extensions.
|
|
if field.python_name in claimed_python_names:
|
|
raise ImproperlyConfigured(
|
|
f"GraphQL extensions {_class_path(claimed_python_names[field.python_name])} and "
|
|
f"{_class_path(extension)} both declare '{field.python_name}' on '{core_type.__name__}'."
|
|
)
|
|
if name in claimed_names:
|
|
raise ImproperlyConfigured(
|
|
f"GraphQL extensions {_class_path(claimed_names[name])} and {_class_path(extension)} both "
|
|
f"declare field '{name}' on '{core_type.__name__}'."
|
|
)
|
|
claimed_python_names[field.python_name] = extension
|
|
claimed_names[name] = extension
|
|
|
|
|
|
def validate_extension_targets():
|
|
"""
|
|
Reject extensions whose target model never assembled a GraphQL type or filter, since they would otherwise
|
|
be silently discarded. Runs from the finalizer app after schema assembly.
|
|
"""
|
|
assembled = registry['plugins']['graphql_extensions_assembled']
|
|
for store in ('graphql_type_extensions', 'graphql_filter_extensions'):
|
|
for label, extensions in registry['plugins'][store].items():
|
|
if extensions and (store, label) not in assembled:
|
|
classes = ', '.join(_class_path(extension) for extension in extensions)
|
|
kind = 'output type' if store == 'graphql_type_extensions' else 'filter'
|
|
raise ImproperlyConfigured(
|
|
f"GraphQL extension target '{label}' has no registered {kind} for extension(s): {classes}."
|
|
)
|
|
|
|
|
|
def register_model_graphql_type(model, delegate, store_key, **kwargs):
|
|
"""
|
|
Decorator factory composing registered plugin extensions into a core GraphQL type or filter class. The
|
|
finalizer app assembles the schema during django.setup(), after every plugin has initialized, and
|
|
registering an extension once its target has assembled raises through the assembled-target set.
|
|
"""
|
|
label = get_model_label(model)
|
|
|
|
def wrapper(cls):
|
|
own_is_type_of = vars(cls).get('is_type_of')
|
|
core_type = delegate(model, **kwargs)(cls)
|
|
registry['plugins']['graphql_extensions_assembled'].add((store_key, label))
|
|
extensions = registry['plugins'][store_key].get(label)
|
|
if not extensions:
|
|
return core_type
|
|
validate_extension_final_names(core_type, extensions)
|
|
composed = splice_extension_bases(core_type, extensions)
|
|
# Only an is_type_of in the core's own body needs this, strawberry_django chains inherited ones itself.
|
|
if own_is_type_of is not None:
|
|
composed.is_type_of = own_is_type_of
|
|
return delegate(model, **kwargs)(composed)
|
|
|
|
return wrapper
|