Coverage for netbox/graphql/utils.py: 21%
100 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1from django.core.exceptions import ImproperlyConfigured
3from netbox.registry import registry
5__all__ = (
6 'get_model_label',
7 'register_model_graphql_type',
8)
11def get_model_label(model):
12 """
13 Return the canonical `app_label.model_name` label used to key GraphQL extensions in the registry. Both the
14 registration side and the lookup side must derive labels through this helper so they always agree.
15 """
16 return f'{model._meta.app_label}.{model._meta.model_name}'
19def _own_names(klass):
20 """Concrete attributes only, since PEP 649 keeps annotations out of the class dict from Python 3.14 on."""
21 names = {name for name in vars(klass) if not name.startswith('__')}
22 names.discard('models')
23 return names
26def _field_names(klass):
27 """Python names from the class's own completed Strawberry definition, including fields it inherited."""
28 definition = vars(klass).get('__strawberry_definition__')
29 if definition is None:
30 return set()
31 return {field.python_name for field in definition.fields if field.python_name is not None} - {'models'}
34def _all_names(klass):
35 """Strawberry fields plus concrete attributes anywhere in the MRO, so no raw annotation is ever read."""
36 names = _field_names(klass)
37 for base in klass.__mro__:
38 if base is not object:
39 names |= _own_names(base)
40 return names
43def _name_owner(klass, name):
44 """Class in the MRO declaring `name` as a real attribute, or None when it is annotation-only."""
45 for base in klass.__mro__:
46 if name in vars(base):
47 return base
48 return None
51def _core_names(cls):
52 """
53 Return every name `cls` resolves (its own body and everything it inherits). Extensions are spliced in *after*
54 these bases, so any name already present here is provided by the core type and an extension cannot override it.
55 """
56 names = _field_names(cls)
57 for klass in cls.__mro__:
58 if klass is object:
59 continue
60 names |= _own_names(klass)
61 return names
64def _class_path(cls):
65 """Module-qualified identity for startup errors, since bare class names collide across plugins."""
66 return f'{cls.__module__}.{cls.__qualname__}'
69def _compose(cls, extensions):
70 """
71 Build a subclass of `cls` with the extension mixins appended to its bases. `cls` is already decorated, so
72 the composed class needs no namespace of its own: fields and methods are inherited, zero-argument super()
73 in core methods keeps working, and core attributes win every MRO lookup.
74 """
75 namespace = {'__module__': cls.__module__, '__qualname__': cls.__qualname__, '__doc__': cls.__doc__}
76 try:
77 return type(cls)(cls.__name__, (cls, *extensions), namespace)
78 except TypeError as exc:
79 raise ImproperlyConfigured(
80 f"Failed to compose GraphQL extension(s) {[_class_path(e) for e in extensions]} into core type "
81 f"'{cls.__name__}': {exc}. A GraphQL extension should be a plain @strawberry.type mixin that only "
82 f"adds fields. The extensions declare conflicting base class orders."
83 ) from exc
86def splice_extension_bases(cls, extensions):
87 """
88 Return `cls` composed with the given extension mixins, or `cls` unchanged when there are none. Extensions are
89 strictly additive. An extension sharing ancestry with the core type, declaring a Python name the core type
90 already resolves, or declaring a name another extension claims raises ImproperlyConfigured.
91 """
92 if not extensions:
93 return cls
94 core_names = _core_names(cls)
95 core_bases = set(cls.__mro__)
96 claimed = {}
97 for extension in extensions:
98 # A shared ancestor lets C3 interleave extension bases ahead of core hooks such as get_queryset().
99 if shared := [base for base in extension.__mro__ if base is not object and base in core_bases]:
100 raise ImproperlyConfigured(
101 f"GraphQL extension {_class_path(extension)} shares ancestry with core type "
102 f"'{cls.__name__}' ({_class_path(shared[0])}). An extension must be an independent "
103 f"mixin that does not inherit from core GraphQL classes."
104 )
105 for name in _all_names(extension):
106 if name in core_names:
107 raise ImproperlyConfigured(
108 f"GraphQL extension {_class_path(extension)} declares '{name}', which core type "
109 f"'{cls.__name__}' already provides."
110 )
111 if name in claimed:
112 # One shared helper base is harmless, two independent declarations of a name are not.
113 owner = _name_owner(extension, name)
114 if owner is None or owner is not _name_owner(claimed[name], name):
115 raise ImproperlyConfigured(
116 f"GraphQL extensions {_class_path(claimed[name])} and {_class_path(extension)} both "
117 f"declare '{name}' on '{cls.__name__}'."
118 )
119 claimed[name] = extension
120 return _compose(cls, extensions)
123def validate_extension_final_names(core_type, extensions):
124 """
125 Check extension fields against the final GraphQL names and python names of the decorated, unextended core
126 type. This covers names invisible before decoration: generated model fields, filter logical fields (AND,
127 OR, NOT, DISTINCT), and explicit aliases. NetBox disables auto camel casing, so a field's final name is
128 its explicit graphql_name or its python name.
129 """
130 baseline_fields = core_type.__strawberry_definition__.fields
131 baseline_names = {f.graphql_name or f.python_name for f in baseline_fields}
132 baseline_python_names = {f.python_name for f in baseline_fields}
133 claimed_names = {}
134 claimed_python_names = {}
135 for extension in extensions:
136 for field in extension.__strawberry_definition__.fields:
137 name = field.graphql_name or field.python_name
138 if name in baseline_names:
139 raise ImproperlyConfigured(
140 f"GraphQL extension {_class_path(extension)} declares field '{name}', which collides with an "
141 f"existing field of that name on '{core_type.__name__}'."
142 )
143 # An aliased field still claims its python name and would suppress the generated core field.
144 if field.python_name in baseline_python_names:
145 raise ImproperlyConfigured(
146 f"GraphQL extension {_class_path(extension)} declares '{field.python_name}', which core type "
147 f"'{core_type.__name__}' already provides."
148 )
149 # Strawberry keys fields by python name, so a shared one silently replaces across extensions.
150 if field.python_name in claimed_python_names:
151 raise ImproperlyConfigured(
152 f"GraphQL extensions {_class_path(claimed_python_names[field.python_name])} and "
153 f"{_class_path(extension)} both declare '{field.python_name}' on '{core_type.__name__}'."
154 )
155 if name in claimed_names:
156 raise ImproperlyConfigured(
157 f"GraphQL extensions {_class_path(claimed_names[name])} and {_class_path(extension)} both "
158 f"declare field '{name}' on '{core_type.__name__}'."
159 )
160 claimed_python_names[field.python_name] = extension
161 claimed_names[name] = extension
164def validate_extension_targets():
165 """
166 Reject extensions whose target model never assembled a GraphQL type or filter, since they would otherwise
167 be silently discarded. Runs from the finalizer app after schema assembly.
168 """
169 assembled = registry['plugins']['graphql_extensions_assembled']
170 for store in ('graphql_type_extensions', 'graphql_filter_extensions'):
171 for label, extensions in registry['plugins'][store].items(): 171 ↛ 172line 171 didn't jump to line 172 because the loop on line 171 never started
172 if extensions and (store, label) not in assembled:
173 classes = ', '.join(_class_path(extension) for extension in extensions)
174 kind = 'output type' if store == 'graphql_type_extensions' else 'filter'
175 raise ImproperlyConfigured(
176 f"GraphQL extension target '{label}' has no registered {kind} for extension(s): {classes}."
177 )
180def register_model_graphql_type(model, delegate, store_key, **kwargs):
181 """
182 Decorator factory composing registered plugin extensions into a core GraphQL type or filter class. The
183 finalizer app assembles the schema during django.setup(), after every plugin has initialized, and
184 registering an extension once its target has assembled raises through the assembled-target set.
185 """
186 label = get_model_label(model)
188 def wrapper(cls):
189 own_is_type_of = vars(cls).get('is_type_of')
190 core_type = delegate(model, **kwargs)(cls)
191 registry['plugins']['graphql_extensions_assembled'].add((store_key, label))
192 extensions = registry['plugins'][store_key].get(label)
193 if not extensions: 193 ↛ 195line 193 didn't jump to line 195 because the condition on line 193 was always true
194 return core_type
195 validate_extension_final_names(core_type, extensions)
196 composed = splice_extension_bases(core_type, extensions)
197 # Only an is_type_of in the core's own body needs this, strawberry_django chains inherited ones itself.
198 if own_is_type_of is not None:
199 composed.is_type_of = own_is_type_of
200 return delegate(model, **kwargs)(composed)
202 return wrapper