2323can crop up, e.g the cache descriptors.
2424"""
2525
26- from typing import Callable , Optional , Tuple , Type , Union
26+ import enum
27+ from typing import Callable , Mapping , Optional , Tuple , Type , Union
2728
29+ import attr
2830import mypy .types
2931from mypy .erasetype import remove_instance_last_known_values
3032from mypy .errorcodes import ErrorCode
3133from mypy .nodes import ARG_NAMED_OPT , ListExpr , NameExpr , TempNode , TupleExpr , Var
3234from mypy .plugin import (
35+ ClassDefContext ,
36+ Context ,
3337 FunctionLike ,
3438 FunctionSigContext ,
3539 MethodSigContext ,
40+ MypyFile ,
3641 Plugin ,
3742)
3843from mypy .typeops import bind_self
4146 CallableType ,
4247 Instance ,
4348 NoneType ,
49+ Options ,
4450 TupleType ,
4551 TypeAliasType ,
4652 TypeVarType ,
4753 UninhabitedType ,
4854 UnionType ,
4955)
56+ from mypy_zope import plugin as mypy_zope_plugin
57+ from pydantic .mypy import plugin as mypy_pydantic_plugin
5058
5159PROMETHEUS_METRIC_MISSING_SERVER_NAME_LABEL = ErrorCode (
5260 "missing-server-name-label" ,
5361 "`SERVER_NAME_LABEL` required in metric" ,
5462 category = "per-homeserver-tenant-metrics" ,
5563)
5664
65+ PROMETHEUS_METRIC_MISSING_FROM_LIST_TO_CHECK = ErrorCode (
66+ "metric-type-missing-from-list" ,
67+ "Every Prometheus metric type must be included in the `prometheus_metric_fullname_to_label_arg_map`." ,
68+ category = "per-homeserver-tenant-metrics" ,
69+ )
70+
71+
72+ class Sentinel (enum .Enum ):
73+ # defining a sentinel in this way allows mypy to correctly handle the
74+ # type of a dictionary lookup and subsequent type narrowing.
75+ UNSET_SENTINEL = object ()
76+
77+
78+ @attr .s (auto_attribs = True )
79+ class ArgLocation :
80+ keyword_name : str
81+ """
82+ The keyword argument name for this argument
83+ """
84+ position : int
85+ """
86+ The 0-based positional index of this argument
87+ """
88+
89+
90+ prometheus_metric_fullname_to_label_arg_map : Mapping [str , Optional [ArgLocation ]] = {
91+ # `Collector` subclasses:
92+ "prometheus_client.metrics.MetricWrapperBase" : ArgLocation ("labelnames" , 2 ),
93+ "prometheus_client.metrics.Counter" : ArgLocation ("labelnames" , 2 ),
94+ "prometheus_client.metrics.Histogram" : ArgLocation ("labelnames" , 2 ),
95+ "prometheus_client.metrics.Gauge" : ArgLocation ("labelnames" , 2 ),
96+ "prometheus_client.metrics.Summary" : ArgLocation ("labelnames" , 2 ),
97+ "prometheus_client.metrics.Info" : ArgLocation ("labelnames" , 2 ),
98+ "prometheus_client.metrics.Enum" : ArgLocation ("labelnames" , 2 ),
99+ "synapse.metrics.LaterGauge" : ArgLocation ("labelnames" , 2 ),
100+ "synapse.metrics.InFlightGauge" : ArgLocation ("labels" , 2 ),
101+ "synapse.metrics.GaugeBucketCollector" : ArgLocation ("labelnames" , 2 ),
102+ "prometheus_client.registry.Collector" : None ,
103+ "prometheus_client.registry._EmptyCollector" : None ,
104+ "prometheus_client.registry.CollectorRegistry" : None ,
105+ "prometheus_client.process_collector.ProcessCollector" : None ,
106+ "prometheus_client.platform_collector.PlatformCollector" : None ,
107+ "prometheus_client.gc_collector.GCCollector" : None ,
108+ "synapse.metrics._gc.GCCounts" : None ,
109+ "synapse.metrics._gc.PyPyGCStats" : None ,
110+ "synapse.metrics._reactor_metrics.ReactorLastSeenMetric" : None ,
111+ "synapse.metrics.CPUMetrics" : None ,
112+ "synapse.metrics.jemalloc.JemallocCollector" : None ,
113+ "synapse.util.metrics.DynamicCollectorRegistry" : None ,
114+ "synapse.metrics.background_process_metrics._Collector" : None ,
115+ #
116+ # `Metric` subclasses:
117+ "prometheus_client.metrics_core.Metric" : None ,
118+ "prometheus_client.metrics_core.UnknownMetricFamily" : ArgLocation ("labels" , 3 ),
119+ "prometheus_client.metrics_core.CounterMetricFamily" : ArgLocation ("labels" , 3 ),
120+ "prometheus_client.metrics_core.GaugeMetricFamily" : ArgLocation ("labels" , 3 ),
121+ "prometheus_client.metrics_core.SummaryMetricFamily" : ArgLocation ("labels" , 3 ),
122+ "prometheus_client.metrics_core.InfoMetricFamily" : ArgLocation ("labels" , 3 ),
123+ "prometheus_client.metrics_core.HistogramMetricFamily" : ArgLocation ("labels" , 3 ),
124+ "prometheus_client.metrics_core.GaugeHistogramMetricFamily" : ArgLocation (
125+ "labels" , 4
126+ ),
127+ "prometheus_client.metrics_core.StateSetMetricFamily" : ArgLocation ("labels" , 3 ),
128+ "synapse.metrics.GaugeHistogramMetricFamilyWithLabels" : ArgLocation (
129+ "labelnames" , 4
130+ ),
131+ }
132+ """
133+ Map from the fullname of the Prometheus `Metric`/`Collector` classes to the keyword
134+ argument name and positional index of the label names. This map is useful because
135+ different metrics have different signatures for passing in label names and we just need
136+ to know where to look.
137+
138+ This map should include any metrics that we collect with Prometheus. Which corresponds
139+ to anything that inherits from `prometheus_client.registry.Collector`
140+ (`synapse.metrics._types.Collector`) or `prometheus_client.metrics_core.Metric`. The
141+ exhaustiveness of this list is enforced by `analyze_prometheus_metric_classes`.
142+
143+ The entries with `None` always fail the lint because they don't have a `labelnames`
144+ argument (therefore, no `SERVER_NAME_LABEL`), but we include them here so that people
145+ can notice and manually allow via a type ignore comment as the source of truth
146+ should be in the source code.
147+ """
148+
149+ # Unbound at this point because we don't know the mypy version yet.
150+ # This is set in the `plugin(...)` function below.
151+ MypyPydanticPluginClass : Type [Plugin ]
152+ MypyZopePluginClass : Type [Plugin ]
153+
57154
58155class SynapsePlugin (Plugin ):
156+ def __init__ (self , options : Options ):
157+ super ().__init__ (options )
158+ self .mypy_pydantic_plugin = MypyPydanticPluginClass (options )
159+ self .mypy_zope_plugin = MypyZopePluginClass (options )
160+
161+ def set_modules (self , modules : dict [str , MypyFile ]) -> None :
162+ """
163+ This is called by mypy internals. We have to override this to ensure it's also
164+ called for any other plugins that we're manually handling.
165+
166+ Here is how mypy describes it:
167+
168+ > [`self._modules`] can't be set in `__init__` because it is executed too soon
169+ > in `build.py`. Therefore, `build.py` *must* set it later before graph processing
170+ > starts by calling `set_modules()`.
171+ """
172+ super ().set_modules (modules )
173+ self .mypy_pydantic_plugin .set_modules (modules )
174+ self .mypy_zope_plugin .set_modules (modules )
175+
176+ def get_base_class_hook (
177+ self , fullname : str
178+ ) -> Optional [Callable [[ClassDefContext ], None ]]:
179+ def _get_base_class_hook (ctx : ClassDefContext ) -> None :
180+ # Run any `get_base_class_hook` checks from other plugins first.
181+ #
182+ # Unfortunately, because mypy only chooses the first plugin that returns a
183+ # non-None value (known-limitation, c.f.
184+ # https://github.com/python/mypy/issues/19524), we workaround this by
185+ # putting our custom plugin first in the plugin order and then calling the
186+ # other plugin's hook manually followed by our own checks.
187+ if callback := self .mypy_pydantic_plugin .get_base_class_hook (fullname ):
188+ callback (ctx )
189+ if callback := self .mypy_zope_plugin .get_base_class_hook (fullname ):
190+ callback (ctx )
191+
192+ # Now run our own checks
193+ analyze_prometheus_metric_classes (ctx )
194+
195+ return _get_base_class_hook
196+
59197 def get_function_signature_hook (
60198 self , fullname : str
61199 ) -> Optional [Callable [[FunctionSigContext ], FunctionLike ]]:
62- if fullname in (
63- "prometheus_client.metrics.Counter" ,
64- "prometheus_client.metrics.Histogram" ,
65- "prometheus_client.metrics.Gauge" ,
66- # TODO: Add other prometheus_client metrics that need checking as we
67- # refactor, see https://github.com/element-hq/synapse/issues/18592
68- ):
69- return check_prometheus_metric_instantiation
200+ # Strip off the unique identifier for classes that are dynamically created inside
201+ # functions. ex. `synapse.metrics.jemalloc.JemallocCollector@185` (this is the line
202+ # number)
203+ if "@" in fullname :
204+ fullname = fullname .split ("@" , 1 )[0 ]
205+
206+ # Look for any Prometheus metrics to make sure they have the `SERVER_NAME_LABEL`
207+ # label.
208+ if fullname in prometheus_metric_fullname_to_label_arg_map .keys ():
209+ # Because it's difficult to determine the `fullname` of the function in the
210+ # callback, let's just pass it in while we have it.
211+ return lambda ctx : check_prometheus_metric_instantiation (ctx , fullname )
70212
71213 return None
72214
@@ -90,7 +232,44 @@ def get_method_signature_hook(
90232 return None
91233
92234
93- def check_prometheus_metric_instantiation (ctx : FunctionSigContext ) -> CallableType :
235+ def analyze_prometheus_metric_classes (ctx : ClassDefContext ) -> None :
236+ """
237+ Cross-check the list of Prometheus metric classes against the
238+ `prometheus_metric_fullname_to_label_arg_map` to ensure the list is exhaustive and
239+ up-to-date.
240+ """
241+
242+ fullname = ctx .cls .fullname
243+ # Strip off the unique identifier for classes that are dynamically created inside
244+ # functions. ex. `synapse.metrics.jemalloc.JemallocCollector@185` (this is the line
245+ # number)
246+ if "@" in fullname :
247+ fullname = fullname .split ("@" , 1 )[0 ]
248+
249+ if any (
250+ ancestor_type .fullname
251+ in (
252+ # All of the Prometheus metric classes inherit from the `Collector`.
253+ "prometheus_client.registry.Collector" ,
254+ "synapse.metrics._types.Collector" ,
255+ # And custom metrics that inherit from `Metric`.
256+ "prometheus_client.metrics_core.Metric" ,
257+ )
258+ for ancestor_type in ctx .cls .info .mro
259+ ):
260+ if fullname not in prometheus_metric_fullname_to_label_arg_map :
261+ ctx .api .fail (
262+ f"Expected { fullname } to be in `prometheus_metric_fullname_to_label_arg_map`, "
263+ f"but it was not found. This is a problem with our custom mypy plugin. "
264+ f"Please add it to the map." ,
265+ Context (),
266+ code = PROMETHEUS_METRIC_MISSING_FROM_LIST_TO_CHECK ,
267+ )
268+
269+
270+ def check_prometheus_metric_instantiation (
271+ ctx : FunctionSigContext , fullname : str
272+ ) -> CallableType :
94273 """
95274 Ensure that the `prometheus_client` metrics include the `SERVER_NAME_LABEL` label
96275 when instantiated.
@@ -103,18 +282,49 @@ def check_prometheus_metric_instantiation(ctx: FunctionSigContext) -> CallableTy
103282 Python garbage collection, and Twisted reactor tick time, which shouldn't have the
104283 `SERVER_NAME_LABEL`. In those cases, use a type ignore comment to disable the
105284 check, e.g. `# type: ignore[missing-server-name-label]`.
285+
286+ Args:
287+ ctx: The `FunctionSigContext` from mypy.
288+ fullname: The fully qualified name of the function being called,
289+ e.g. `"prometheus_client.metrics.Counter"`
106290 """
107291 # The true signature, this isn't being modified so this is what will be returned.
108- signature : CallableType = ctx .default_signature
292+ signature = ctx .default_signature
293+
294+ # Find where the label names argument is in the function signature.
295+ arg_location = prometheus_metric_fullname_to_label_arg_map .get (
296+ fullname , Sentinel .UNSET_SENTINEL
297+ )
298+ assert arg_location is not Sentinel .UNSET_SENTINEL , (
299+ f"Expected to find { fullname } in `prometheus_metric_fullname_to_label_arg_map`, "
300+ f"but it was not found. This is a problem with our custom mypy plugin. "
301+ f"Please add it to the map. Context: { ctx .context } "
302+ )
303+ # People should be using `# type: ignore[missing-server-name-label]` for
304+ # process-level metrics that should not have the `SERVER_NAME_LABEL`.
305+ if arg_location is None :
306+ ctx .api .fail (
307+ f"{ signature .name } does not have a `labelnames`/`labels` argument "
308+ "(if this is untrue, update `prometheus_metric_fullname_to_label_arg_map` "
309+ "in our custom mypy plugin) and should probably have a type ignore comment, "
310+ "e.g. `# type: ignore[missing-server-name-label]`. The reason we don't "
311+ "automatically ignore this is the source of truth should be in the source code." ,
312+ ctx .context ,
313+ code = PROMETHEUS_METRIC_MISSING_SERVER_NAME_LABEL ,
314+ )
315+ return signature
109316
110317 # Sanity check the arguments are still as expected in this version of
111318 # `prometheus_client`. ex. `Counter(name, documentation, labelnames, ...)`
112319 #
113320 # `signature.arg_names` should be: ["name", "documentation", "labelnames", ...]
114- if len (signature .arg_names ) < 3 or signature .arg_names [2 ] != "labelnames" :
321+ if (
322+ len (signature .arg_names ) < (arg_location .position + 1 )
323+ or signature .arg_names [arg_location .position ] != arg_location .keyword_name
324+ ):
115325 ctx .api .fail (
116- f"Expected the 3rd argument of { signature .name } to be ' labelnames', but got "
117- f"{ signature .arg_names [2 ]} " ,
326+ f"Expected argument number { arg_location . position + 1 } of { signature .name } to be ` labelnames`/`labels`, "
327+ f"but got { signature .arg_names [arg_location . position ]} " ,
118328 ctx .context ,
119329 )
120330 return signature
@@ -137,7 +347,11 @@ def check_prometheus_metric_instantiation(ctx: FunctionSigContext) -> CallableTy
137347 # ...
138348 # ]
139349 # ```
140- labelnames_arg_expression = ctx .args [2 ][0 ] if len (ctx .args [2 ]) > 0 else None
350+ labelnames_arg_expression = (
351+ ctx .args [arg_location .position ][0 ]
352+ if len (ctx .args [arg_location .position ]) > 0
353+ else None
354+ )
141355 if isinstance (labelnames_arg_expression , (ListExpr , TupleExpr )):
142356 # Check if the `labelnames` argument includes the `server_name` label (`SERVER_NAME_LABEL`).
143357 for labelname_expression in labelnames_arg_expression .items :
@@ -476,10 +690,13 @@ def is_cacheable(
476690
477691
478692def plugin (version : str ) -> Type [SynapsePlugin ]:
693+ global MypyPydanticPluginClass , MypyZopePluginClass
479694 # This is the entry point of the plugin, and lets us deal with the fact
480695 # that the mypy plugin interface is *not* stable by looking at the version
481696 # string.
482697 #
483698 # However, since we pin the version of mypy Synapse uses in CI, we don't
484699 # really care.
700+ MypyPydanticPluginClass = mypy_pydantic_plugin (version )
701+ MypyZopePluginClass = mypy_zope_plugin (version )
485702 return SynapsePlugin
0 commit comments