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

1from django.core.exceptions import ImproperlyConfigured 

2 

3from netbox.registry import registry 

4 

5__all__ = ( 

6 'get_model_label', 

7 'register_model_graphql_type', 

8) 

9 

10 

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}' 

17 

18 

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 

24 

25 

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'} 

32 

33 

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 

41 

42 

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 

49 

50 

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 

62 

63 

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__}' 

67 

68 

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 

84 

85 

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) 

121 

122 

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 

162 

163 

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 ) 

178 

179 

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) 

187 

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) 

201 

202 return wrapper