33from typing import Iterable , Literal , Optional , Type , get_args , get_origin
44from warnings import warn
55
6+
7+ from .._lib .auxiliary import allows_none , strip_none
8+
69from .tag import Tag , TagValue
710
811OptionsReturnType = list [tuple [str , TagValue , bool , tuple [str ]]]
912""" label, choice value, is-tip, tupled-label """
1013OptionLabel = str
11- RichOptionLabel = OptionLabel | tuple [OptionLabel ]
14+ RichOptionLabel = OptionLabel | tuple [OptionLabel , ... ]
1215OptionsType = (
1316 list [TagValue ]
1417 | tuple [TagValue , ...]
@@ -171,8 +174,9 @@ def __post_init__(self):
171174
172175 # Determine options from annotation
173176 if not self .options :
174- if get_origin (self .annotation ) is Literal :
175- self .options = get_args (self .annotation )
177+ pt = self ._get_possible_types ()
178+ if len (pt ) == 1 and pt [0 ][0 ] is Literal :
179+ self .options = pt [0 ][1 ]
176180 elif self .annotation is not Enum :
177181 # We take whatever is in the annotation, hoping there are some values.
178182 # However, the symbol Enum itself (injected in Tag.__post_init__) will not bring us any benefit.
@@ -181,10 +185,14 @@ def __post_init__(self):
181185 #
182186 # @dataclass
183187 # class dc:
184- # field: Color -> Tag(annotation=enum.Color)
185- self .options = self .annotation
188+ # field: Color | None -> Tag(annotation=enum.Color)
189+ #
190+ # Why strip_none? The `| None` type will be readded later, we now clean it to pure Enum
191+ # for detection purposes.
192+ self .options = strip_none (self .annotation )
186193
187194 # Disabling annotation is not a nice workaround, but it is needed for the `super().update` to be processed
195+ orig_ann = self .annotation
188196 self .annotation = type (self )
189197 reset_name = not self .label
190198 super ().__post_init__ ()
@@ -207,6 +215,24 @@ def __post_init__(self):
207215 self .options = self .val
208216 self .val = None
209217
218+ if (self .options and orig_ann is not None and allows_none (orig_ann )) or (
219+ not self .options and orig_ann is None
220+ ):
221+ # None is among the options, like
222+ # * `Options("one", None, "two")`
223+ # * `Optional[Literal["one", "two"]]`
224+ # * `SelectTag()`
225+ # But ignore the case when the annotation is whole None with some options: `SelectTag(options=...)`.
226+ opt = self ._build_options ()
227+ if None not in opt .values ():
228+ for char in ("∅" , "-" , "None" ):
229+ if char not in opt :
230+ # Put the None option to the first place
231+ self .options = {char : None , ** {k : v for k , v in opt .items ()}}
232+ break
233+ else :
234+ raise ValueError ("Cannot demark a None option." )
235+
210236 def __hash__ (self ): # every Tag child must have its own hash method to be used in Annotated
211237 return super ().__hash__ ()
212238
@@ -231,7 +257,7 @@ def _get_selected_keys(self):
231257 return [k for k , val , * _ in self ._get_options () if val in self .val ]
232258
233259 @classmethod
234- def _repr_val (cls , v ):
260+ def _repr_val (cls , v ) -> str :
235261 if cls ._is_a_callable_val (v ):
236262 return v .__name__
237263 if isinstance (v , Tag ):
@@ -240,23 +266,28 @@ def _repr_val(cls, v):
240266 return str (v .value )
241267 return str (v )
242268
243- def _build_options (self ) -> dict [OptionLabel , TagValue ]:
244- """Whereas self.options might have different format, this returns a canonic dict."""
269+ def _build_options (self ) -> dict [RichOptionLabel , TagValue ]:
270+ """Whereas self.options might have different format,
271+ this returns a canonic dict.
272+ The keys are all strs or all tuples.
273+ """
245274
246275 if self .options is None :
247276 return {}
248277 if isinstance (self .options , dict ):
249- # assure the key is a str or their tuple
250- return {
251- (tuple (str (k ) for k in key ) if isinstance (key , tuple ) else str (key )): self ._get_tag_val (v )
252- for key , v in self .options .items ()
253- }
278+ # assure the keys are either strs or tuple of strs
279+ keys = self .options .keys ()
280+ if any (isinstance (k , tuple ) for k in keys ):
281+ keys = ((tuple (str (k ) for k in key ) if isinstance (key , tuple ) else (str (key ),)) for key in keys )
282+ else :
283+ keys = (str (key ) for key in keys )
284+ return {key : self ._get_tag_val (v ) for key , v in zip (keys , self .options .values ())}
254285 if isinstance (self .options , Iterable ):
255286 return {self ._repr_val (v ): self ._get_tag_val (v ) for v in self .options }
256287 if isinstance (self .options , type ) and issubclass (self .options , Enum ): # Enum type, ex: options=ColorEnum
257288 return {str (v .value ): self ._get_tag_val (v ) for v in list (self .options )}
258289
259- warn (f"Not implemented options: { self .options } " )
290+ raise ValueError (f"Not implemented options: { self .options } " )
260291
261292 def _get_options (self , delim = " - " ) -> OptionsReturnType :
262293 """Return a list of tuples (label, choice value, is tip, tupled-label).
@@ -281,10 +312,12 @@ def _get_options(self, delim=" - ") -> OptionsReturnType:
281312 options = self ._build_options ()
282313
283314 keys = options .keys ()
284- labels : Iterable [tuple [str , tuple [str ]]]
315+ labels : Iterable [tuple [str , tuple [str , ... ]]]
285316 """ First is the str-label, second is guaranteed to be a tupled label"""
286317
287318 if len (options ) and isinstance (next (iter (options )), tuple ):
319+ # As options come from the _build_options, we are sure that if the first is a tuple,
320+ # the others are tuples too.
288321 labels = self ._span_to_lengths (keys , delim )
289322 else :
290323 labels = ((key , (key ,)) for key in keys )
0 commit comments