66import json
77from typing import Any
88
9+ # Two component contracts are live. They differ in one field: v1 reports
10+ # `warnings`, a list of sentences, and v2 reports `advisories`, a list of
11+ # objects carrying a stable code, a severity and the setting each one is about.
12+ # Components migrate one at a time, so both are accepted and `envelope_advisories`
13+ # below gives a caller one shape to work with either way.
914CONTRACT_VERSION = "pg_play/component/v1"
15+ CONTRACT_VERSION_V2 = "pg_play/component/v2"
16+ SUPPORTED_CONTRACT_VERSIONS = (CONTRACT_VERSION , CONTRACT_VERSION_V2 )
17+ ADVISORY_SEVERITIES = ("warning" , "assumption" , "info" )
1018CAPABILITY_SCHEMA_VERSION = "pg_play/capabilities/v1"
1119MACHINE_INTERFACE = {
1220 "machine_flag" : "--machine" ,
@@ -58,6 +66,10 @@ def canonical_hash(value: Any) -> str:
5866def validate_envelope (value : Any , * , expected_component : str | None = None ) -> dict [str , Any ]:
5967 if not isinstance (value , dict ):
6068 raise ContractError ("component response must be a JSON object" )
69+ contract_version = value .get ("contract_version" )
70+ if contract_version not in SUPPORTED_CONTRACT_VERSIONS :
71+ raise ContractError (f"unsupported component contract: { contract_version !r} " )
72+ findings_field = "advisories" if contract_version == CONTRACT_VERSION_V2 else "warnings"
6173 required = {
6274 "contract_version" ,
6375 "component" ,
@@ -67,15 +79,13 @@ def validate_envelope(value: Any, *, expected_component: str | None = None) -> d
6779 "status" ,
6880 "result" ,
6981 "artifacts" ,
70- "warnings" ,
82+ findings_field ,
7183 "error" ,
7284 }
7385 if set (value ) != required :
7486 missing = sorted (required .difference (value ))
7587 extra = sorted (set (value ).difference (required ))
7688 raise ContractError (f"invalid component envelope fields: missing={ missing } , extra={ extra } " )
77- if value ["contract_version" ] != CONTRACT_VERSION :
78- raise ContractError (f"unsupported component contract: { value ['contract_version' ]!r} " )
7989 component = value ["component" ]
8090 if component not in COMPONENTS :
8191 raise ContractError (f"unknown component in envelope: { component !r} " )
@@ -93,10 +103,13 @@ def validate_envelope(value: Any, *, expected_component: str | None = None) -> d
93103 isinstance (artifact , dict ) for artifact in value ["artifacts" ]
94104 ):
95105 raise ContractError ("component artifacts must be a list of objects" )
96- if not isinstance (value ["warnings" ], list ) or not all (
97- isinstance (warning , str ) for warning in value ["warnings" ]
98- ):
99- raise ContractError ("component warnings must be a list of strings" )
106+ if findings_field == "warnings" :
107+ if not isinstance (value ["warnings" ], list ) or not all (
108+ isinstance (warning , str ) for warning in value ["warnings" ]
109+ ):
110+ raise ContractError ("component warnings must be a list of strings" )
111+ else :
112+ _validate_advisories (value ["advisories" ])
100113 if value ["error" ] is not None and not isinstance (value ["error" ], dict ):
101114 raise ContractError ("component error must be an object or null" )
102115 if isinstance (value ["error" ], dict ) and not {
@@ -107,6 +120,52 @@ def validate_envelope(value: Any, *, expected_component: str | None = None) -> d
107120 return value
108121
109122
123+ def _validate_advisories (advisories : Any ) -> None :
124+ if not isinstance (advisories , list ):
125+ raise ContractError ("component advisories must be a list of objects" )
126+ for advisory in advisories :
127+ if not isinstance (advisory , dict ):
128+ raise ContractError ("component advisories must be a list of objects" )
129+ missing = sorted ({"code" , "severity" , "setting" , "actual" , "message" }.difference (advisory ))
130+ if missing :
131+ raise ContractError (f"component advisory missing fields: { missing } " )
132+ if advisory ["severity" ] not in ADVISORY_SEVERITIES :
133+ raise ContractError (f"unknown advisory severity: { advisory ['severity' ]!r} " )
134+ for field in ("code" , "message" ):
135+ if not isinstance (advisory [field ], str ) or not advisory [field ]:
136+ raise ContractError (f"component advisory { field } must be a non-empty string" )
137+ for field in ("setting" , "actual" ):
138+ if advisory [field ] is not None and not isinstance (advisory [field ], str ):
139+ raise ContractError (f"component advisory { field } must be a string or null" )
140+
141+
142+ def envelope_advisories (envelope : dict [str , Any ]) -> list [dict [str , Any ]]:
143+ """The findings in an envelope, in the v2 shape whichever version sent it.
144+
145+ A v1 component reports sentences with no severity and no stable identifier,
146+ so each one is lifted to `severity: "warning"`: that is the only thing v1
147+ can express, and pretending to know more would be an invention. `code` stays
148+ None, which is how a caller tells a lifted line from a real advisory.
149+ """
150+ if "advisories" in envelope :
151+ return [dict (advisory ) for advisory in envelope ["advisories" ]]
152+ return [
153+ {
154+ "code" : None ,
155+ "severity" : "warning" ,
156+ "setting" : None ,
157+ "actual" : None ,
158+ "message" : message ,
159+ }
160+ for message in envelope .get ("warnings" ) or []
161+ ]
162+
163+
164+ def envelope_messages (envelope : dict [str , Any ]) -> list [str ]:
165+ """Just the sentences, for a report that has no room for the structure."""
166+ return [advisory ["message" ] for advisory in envelope_advisories (envelope )]
167+
168+
110169def validate_capabilities (
111170 value : Any ,
112171 * ,
@@ -132,7 +191,7 @@ def validate_capabilities(
132191 raise ContractError (
133192 f"unsupported capability schema: { value ['capability_schema_version' ]!r} "
134193 )
135- if value ["contract_version" ] != CONTRACT_VERSION :
194+ if value ["contract_version" ] not in SUPPORTED_CONTRACT_VERSIONS :
136195 raise ContractError (f"unsupported component contract: { value ['contract_version' ]!r} " )
137196 if value ["component" ] != expected_component :
138197 raise ContractError (
0 commit comments