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 }  
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 }  
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 }  
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 } ' labelnames', but got  " 
117-             f"{ signature .arg_names [2 ]}  ,
326+             f"Expected argument number  { arg_location . position   +   1 }   of { signature .name } ` 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