@@ -392,6 +392,97 @@ def __get_validators__(cls) -> Iterator[classmethod]:
392392 yield cls .validate # type: ignore
393393
394394
395+ class FormattedTime (dt .time ):
396+ """A time, provided as a datetime or a string in a specific format."""
397+
398+ TIME_FORMAT : ClassVar [Optional [str ]] = None
399+ """The specific format of the time."""
400+ TIMEZONE_TREATMENT : ClassVar [Literal ["forbid" , "permit" , "require" ]] = "permit"
401+ """How to treat the presence of timezone-related information."""
402+ DEFAULT_PATTERNS : Sequence [str ] = list (
403+ # 24 hour time pattern combinations
404+ map (
405+ "" .join ,
406+ itertools .product (
407+ ("%H:%M:%S" , "%H%M%S" ),
408+ ("" , ".%f" ),
409+ ("%p" , "%P" , "" ),
410+ ("%z" , "" ),
411+ ),
412+ )
413+ ) + list (
414+ # 12 hour time pattern combinations
415+ map (
416+ "" .join ,
417+ itertools .product (
418+ ("%I:%M:%S" , "%I%M%S" ),
419+ ("" , ".%f" ),
420+ ("%z" , "" ),
421+ (" %p" , "%p" , "%P" , " %P" , "" ),
422+ ),
423+ )
424+ )
425+ """A sequence of time format patterns to try if `TIME_FORMAT` is unset."""
426+
427+ @classmethod
428+ def convert_to_time (cls , value : dt .datetime ) -> dt .time :
429+ """
430+ Convert `datetime.datetime` to `datetime.time`. If datetime contains timezone info, that
431+ will be retained.
432+ """
433+ if value .tzinfo :
434+ return value .timetz ()
435+
436+ return value .time ()
437+
438+ @classmethod
439+ def parse_time (cls , string : str ) -> dt .time :
440+ """Attempt to parse a datetime using various formats in sequence."""
441+ string = string .strip ()
442+ if string .endswith ("Z" ): # Convert 'zulu' time to UTC.
443+ string = string [:- 1 ] + "+00:00"
444+
445+ for pattern in cls .DEFAULT_PATTERNS :
446+ try :
447+ datetime = dt .datetime .strptime (string , pattern )
448+ except ValueError :
449+ continue
450+
451+ time = cls .convert_to_time (datetime )
452+
453+ return time # pragma: no cover
454+ raise ValueError ("Unable to parse provided time" )
455+
456+ @classmethod
457+ def validate (cls , value : Union [dt .time , dt .datetime , str ]) -> dt .time | None :
458+ """Validate a passed time, datetime or string."""
459+ if value is None :
460+ return value
461+
462+ if isinstance (value , dt .time ):
463+ new_time = value
464+ elif isinstance (value , dt .datetime ):
465+ new_time = cls .convert_to_time (value )
466+ else :
467+ if cls .TIME_FORMAT is not None :
468+ try :
469+ new_time = dt .datetime .strptime (value , cls .TIME_FORMAT ) # type: ignore
470+ new_time = cls .convert_to_time (new_time ) # type: ignore
471+ except ValueError as err :
472+ raise ValueError (
473+ f"Unable to parse provided time in format { cls .TIME_FORMAT } "
474+ ) from err
475+ else :
476+ new_time = cls .parse_time (value )
477+
478+ if cls .TIMEZONE_TREATMENT == "forbid" and new_time .tzinfo :
479+ raise ValueError ("Provided time has timezone, but this is forbidden for this field" )
480+ if cls .TIMEZONE_TREATMENT == "require" and not new_time .tzinfo :
481+ raise ValueError ("Provided time missing timezone, but this is required for this field" )
482+
483+ return new_time
484+
485+
395486@lru_cache ()
396487@validate_arguments
397488def formatteddatetime (
@@ -412,6 +503,23 @@ def formatteddatetime(
412503 return type ("FormattedDatetime" , (FormattedDatetime , * FormattedDatetime .__bases__ ), dict_ )
413504
414505
506+ @lru_cache ()
507+ @validate_arguments
508+ def formattedtime (
509+ time_format : Optional [str ] = None ,
510+ timezone_treatment : Literal ["forbid" , "permit" , "require" ] = "permit" ,
511+ ) -> type [FormattedTime ]:
512+ """Return a formatted time class with a set time format and timezone treatment."""
513+ if time_format is None and timezone_treatment == "permit" :
514+ return FormattedTime
515+
516+ dict_ = FormattedTime .__dict__ .copy ()
517+ dict_ ["TIME_FORMAT" ] = time_format
518+ dict_ ["TIMEZONE_TREATMENT" ] = timezone_treatment
519+
520+ return type ("FormattedTime" , (FormattedTime , * FormattedTime .__bases__ ), dict_ )
521+
522+
415523class ReportingPeriod (dt .date ):
416524 """A reporting period field, with the type of reporting period supplied"""
417525
0 commit comments