@@ -41,13 +41,163 @@ def schema(self) -> Dict[str, Any]:
4141 """
4242 raise NotImplementedError
4343
44+ @classmethod
45+ def _column_groups (cls , side : str ) -> List [Dict [str , Any ]]:
46+ """Read one side of the contract as a list of type groups.
47+
48+ A task says what its columns may be as a list of groups, each naming a
49+ set of interchangeable types and how many columns of that set it takes:
50+
51+ .. code-block:: python
52+
53+ "inputs": [
54+ {"types": [Date], "cardinality": 1},
55+ {"types": [Float, Integer], "cardinality": {"min": 1, "max": "n"}},
56+ ]
57+
58+ Grouping is what lets a task ask for one date column and any number of
59+ numeric ones at the same time. A single cardinality for the whole side
60+ cannot say that: it can only demand a total, so it would accept two
61+ dates and no numbers as readily as the intended shape.
62+
63+ The older flat spelling, ``inputs_types`` alongside
64+ ``inputs_cardinality``, is still read and means a single group, so
65+ tasks and plugins written against it keep working unchanged.
66+
67+ Parameters
68+ ----------
69+ side : str
70+ Either ``"inputs"`` or ``"outputs"``.
71+
72+ Returns
73+ -------
74+ list of dict
75+ One entry per group, each with ``"types"`` (a tuple of type
76+ classes), ``"min"`` (an int) and ``"max"`` (an int or ``"n"``).
77+ """
78+ metadata = cls .metadata
79+
80+ if side in metadata :
81+ declared = metadata [side ]
82+ else :
83+ declared = [
84+ {
85+ "types" : metadata [f"{ side } _types" ],
86+ "cardinality" : metadata [f"{ side } _cardinality" ],
87+ }
88+ ]
89+
90+ return [
91+ {
92+ "types" : tuple (group ["types" ]),
93+ ** cls ._bounds (group .get ("cardinality" , "n" )),
94+ }
95+ for group in declared
96+ ]
97+
98+ @staticmethod
99+ def _bounds (cardinality : Any ) -> Dict [str , Any ]:
100+ """Read a declared cardinality as a minimum and a maximum.
101+
102+ Parameters
103+ ----------
104+ cardinality : Any
105+ An int for an exact count, ``"n"`` for any number, or a mapping
106+ with ``"min"`` and ``"max"`` for a range whose ``"max"`` may itself
107+ be ``"n"``.
108+
109+ Returns
110+ -------
111+ dict
112+ A mapping with keys ``"min"`` and ``"max"``.
113+ """
114+ if isinstance (cardinality , dict ):
115+ return {
116+ "min" : cardinality .get ("min" , 0 ),
117+ "max" : cardinality .get ("max" , "n" ),
118+ }
119+ if cardinality == "n" :
120+ return {"min" : 0 , "max" : "n" }
121+ return {"min" : cardinality , "max" : cardinality }
122+
123+ @staticmethod
124+ def _total_cardinality (groups : List [Dict [str , Any ]]) -> Any :
125+ """State a whole side's cardinality the way the flat contract did.
126+
127+ Kept so every consumer of the two-key metadata, the column picker
128+ among them, reads the same value it always did for the tasks that
129+ declare a single group.
130+
131+ Parameters
132+ ----------
133+ groups : list of dict
134+ The normalised groups of one side.
135+
136+ Returns
137+ -------
138+ int or str
139+ The exact total when every group is exact, otherwise ``"n"``.
140+ """
141+ if all (group ["min" ] == group ["max" ] for group in groups ):
142+ return sum (group ["min" ] for group in groups )
143+ return "n"
144+
145+ @staticmethod
146+ def _type_name (dashai_type ) -> str :
147+ """Name a type the way the frontend and the columns themselves do.
148+
149+ A DashAI type reports its own name through ``display_name()``, which
150+ matches what a column emits via ``to_string()``. These lists may also
151+ hold foreign classes, for example HuggingFace dataset features or a
152+ plugin's own types, so those fall back to the class name.
153+
154+ Parameters
155+ ----------
156+ dashai_type : type
157+ One of the declared type classes.
158+
159+ Returns
160+ -------
161+ str
162+ The name to show for it.
163+ """
164+ getter = getattr (dashai_type , "display_name" , None )
165+ return getter () if callable (getter ) else dashai_type .__name__
166+
167+ @staticmethod
168+ def _cardinality_text (group : Dict [str , Any ]) -> str :
169+ """Say a group's cardinality the way an error message should read it.
170+
171+ Parameters
172+ ----------
173+ group : dict
174+ A normalised group.
175+
176+ Returns
177+ -------
178+ str
179+ For example ``"1"``, ``"n"``, ``"at least 1"`` or ``"1 to 3"``.
180+ """
181+ minimum , maximum = group ["min" ], group ["max" ]
182+ if maximum == "n" :
183+ return "n" if minimum == 0 else f"at least { minimum } "
184+ if minimum == maximum :
185+ return str (minimum )
186+ return f"{ minimum } to { maximum } "
187+
44188 @classmethod
45189 def get_metadata (cls ) -> Dict [str , Any ]:
46190 """Return serialisable metadata for the current task.
47191
48- Converts the ``inputs_types`` and ``outputs_types`` entries from class
49- objects to their string names so the result can be JSON-serialised by
50- the DashAI frontend.
192+ The contract is reported twice. ``"inputs"`` and ``"outputs"`` carry
193+ the per-group form, which is the only one that can tell "one date and
194+ any number of numbers" apart from "any number of dates or numbers".
195+ ``"inputs_types"`` and the three keys beside it carry the flattened
196+ view every existing consumer already reads, unchanged for the tasks
197+ that declare a single group.
198+
199+ Type classes become their string names throughout so the result can be
200+ JSON-serialised by the DashAI frontend.
51201
52202 Parameters
53203 ----------
@@ -58,32 +208,99 @@ def get_metadata(cls) -> Dict[str, Any]:
58208 -------
59209 Dict[str, Any]
60210 Dictionary with keys ``"inputs_types"``, ``"outputs_types"``,
61- ``"inputs_cardinality"``, and ``"outputs_cardinality"``.
211+ ``"inputs_cardinality"``, ``"outputs_cardinality"``, ``"inputs"``
212+ and ``"outputs"``.
62213 """
63- metadata = cls .metadata
214+ parsed_metadata : dict = {}
215+
216+ for side in ("inputs" , "outputs" ):
217+ groups = cls ._column_groups (side )
218+
219+ names : List [str ] = []
220+ for group in groups :
221+ for dashai_type in group ["types" ]:
222+ name = cls ._type_name (dashai_type )
223+ if name not in names :
224+ names .append (name )
225+
226+ parsed_metadata [f"{ side } _types" ] = names
227+ parsed_metadata [f"{ side } _cardinality" ] = cls ._total_cardinality (groups )
228+ parsed_metadata [side ] = [
229+ {
230+ "types" : [cls ._type_name (t ) for t in group ["types" ]],
231+ "min" : group ["min" ],
232+ "max" : group ["max" ],
233+ }
234+ for group in groups
235+ ]
64236
65- def _name (dashai_type ) -> str :
66- """Name the frontend uses, tolerating types from outside DashAI.
67-
68- A DashAI type reports its own name through ``display_name()``, which
69- matches what a column emits via ``to_string()``. These lists may
70- also hold foreign classes, for example HuggingFace dataset features
71- or a plugin's own types, so those fall back to the class name.
72- """
73- getter = getattr (dashai_type , "display_name" , None )
74- return getter () if callable (getter ) else dashai_type .__name__
75-
76- inputs_types = [_name (t ) for t in metadata ["inputs_types" ]]
77- outputs_types = [_name (t ) for t in metadata ["outputs_types" ]]
78-
79- parsed_metadata : dict = {
80- "inputs_types" : inputs_types ,
81- "outputs_types" : outputs_types ,
82- "inputs_cardinality" : metadata ["inputs_cardinality" ],
83- "outputs_cardinality" : metadata ["outputs_cardinality" ],
84- }
85237 return parsed_metadata
86238
239+ def _validate_side (
240+ self ,
241+ columns : List [str ],
242+ types : Dict [str , Any ],
243+ side : str ,
244+ ) -> None :
245+ """Check one side's columns against the groups the task declares.
246+
247+ Each column is charged to the first group that accepts its type and
248+ still has room, so a task asking for one date and any number of
249+ numbers reads a date, a number and a number as a full match rather
250+ than as three columns competing for one slot.
251+
252+ Parameters
253+ ----------
254+ columns : list of str
255+ The selected column names.
256+ types : dict
257+ The dataset's column types, keyed by column name.
258+ side : str
259+ Either ``"inputs"`` or ``"outputs"``.
260+
261+ Raises
262+ ------
263+ TypeError
264+ If a column's type belongs to none of the groups.
265+ ValueError
266+ If a group ends up with a number of columns outside its bounds.
267+ """
268+ groups = self ._column_groups (side )
269+ counts = [0 ] * len (groups )
270+ label = "Input" if side == "inputs" else "Output"
271+
272+ for column in columns :
273+ column_type = types [column ]
274+ matching = [
275+ index
276+ for index , group in enumerate (groups )
277+ if isinstance (column_type , group ["types" ])
278+ ]
279+ if not matching :
280+ raise TypeError (
281+ f"{ column_type } is not an allowed type for { side [:- 1 ]} columns."
282+ )
283+
284+ with_room = [
285+ index
286+ for index in matching
287+ if groups [index ]["max" ] == "n" or counts [index ] < groups [index ]["max" ]
288+ ]
289+ counts [(with_room or matching )[0 ]] += 1
290+
291+ for group , count in zip (groups , counts , strict = True ):
292+ if count < group ["min" ] or (group ["max" ] != "n" and count > group ["max" ]):
293+ of_types = (
294+ ""
295+ if len (groups ) == 1
296+ else " for columns of type "
297+ + ", " .join (self ._type_name (t ) for t in group ["types" ])
298+ )
299+ raise ValueError (
300+ f"{ label } cardinality ({ count } ) does not match task "
301+ f"cardinality ({ self ._cardinality_text (group )} ){ of_types } "
302+ )
303+
87304 def validate_dataset_for_task (
88305 self ,
89306 dataset : "DashAIDataset" ,
@@ -99,45 +316,14 @@ def validate_dataset_for_task(
99316 Dataset to be validated
100317 dataset_name : str
101318 Dataset name
319+ input_columns : list of str
320+ Names of the columns selected as inputs.
321+ output_columns : list of str
322+ Names of the columns selected as outputs.
102323 """
103- metadata = self .metadata
104- allowed_input_types = tuple (metadata ["inputs_types" ])
105- allowed_output_types = tuple (metadata ["outputs_types" ])
106- inputs_cardinality = metadata ["inputs_cardinality" ]
107- outputs_cardinality = metadata ["outputs_cardinality" ]
108324 types = dataset ._types
109- # Check input types
110- for input_col in input_columns :
111- input_col_type = types [input_col ]
112-
113- if not isinstance (input_col_type , allowed_input_types ):
114- raise TypeError (
115- f"{ input_col_type } is not an allowed type for input columns."
116- )
117-
118- # Check output types
119- for output_col in output_columns :
120- output_col_type = types [output_col ]
121-
122- if not isinstance (output_col_type , allowed_output_types ):
123- raise TypeError (
124- f"{ output_col_type } is not an allowed type for output columns."
125- )
126-
127- # Check input cardinality
128- if inputs_cardinality != "n" and len (input_columns ) != inputs_cardinality :
129- raise ValueError (
130- f"Input cardinality ({ len (input_columns )} ) does not"
131- f" match task cardinality ({ inputs_cardinality } )"
132- )
133-
134- # Check output cardinality
135- if outputs_cardinality != "n" and len (output_columns ) != outputs_cardinality :
136- raise ValueError (
137- f"Output cardinality ({ len (output_columns )} )"
138- f" does not "
139- f"match task cardinality ({ outputs_cardinality } )"
140- )
325+ self ._validate_side (input_columns , types , "inputs" )
326+ self ._validate_side (output_columns , types , "outputs" )
141327
142328 def prepare_for_task (
143329 self ,
0 commit comments