1- import re
21from datetime import datetime , timedelta
2+ from typing import Union
33from pydantic_core import core_schema
44from pydantic import GetCoreSchemaHandler
55
6+
67class DateHour (str ):
78 '''Класс для работы с временными метками с границами периодов.'''
89
9- patterns = [
10- (re .compile (r'^\d{4}$' ), '%Y' ), # 2024
11- (re .compile (r'^\d{4}-\d{2}$' ), '%Y-%m' ), # 2024-01
12- (re .compile (r'^\d{4}-\d{2}-\d{2}$' ), '%Y-%m-%d' ), # 2024-01-15
13- (re .compile (r'^\d{4}-\d{2}-\d{2} \d{2}$' ), '%Y-%m-%d %H' ), # 2024-01-15 14
14- (re .compile (r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$' ), '%Y-%m-%d %H:%M:%S' ), # 2024-01-15 14:59:59
15- ]
16-
17- def __new__ (cls , value : str | datetime ) -> 'DateHour' :
10+ _formats = {
11+ '%Y-%m-%d %H:%M:%S' : 'hour' , # секунды
12+ '%Y-%m-%d %H:%M' : 'hour' , # минуты
13+ '%Y-%m-%d %H' : 'hour' , # Час
14+ '%Y-%m-%d' : 'day' , # День
15+ '%Y-%m' : 'month' , # Месяц
16+ '%Y' : 'year' , # Год
17+ }
18+
19+ def __new__ (cls , value : Union [str , datetime ]) -> 'DateHour' :
1820 if isinstance (value , datetime ):
1921 value = value .strftime ('%Y-%m-%d %H:%M:%S' )
22+
2023 if not isinstance (value , str ):
2124 raise ValueError (f'Ожидалась строка, получен { type (value )} : { value } ' )
2225
23- matched_format = None
24- for pattern , date_format in cls .patterns :
25- if pattern .fullmatch (value ):
26- matched_format = date_format
27- break
26+ dt , format_type = cls ._parse_string (value )
2827
29- if not matched_format :
30- raise ValueError (
31- f'Некорректный формат даты: "{ value } ". '
32- f'Поддерживаемые форматы: "YYYY", "YYYY-MM", "YYYY-MM-DD", "YYYY-MM-DD HH", "YYYY-MM-DD HH:MM:SS"'
33- )
28+ normalized_dt = dt .replace (minute = 0 , second = 0 , microsecond = 0 )
29+ normalized_str = normalized_dt .strftime ('%Y-%m-%d %H:%M:%S' )
3430
35- try :
36- value = datetime .strptime (value , matched_format ).replace (minute = 0 , second = 0 , microsecond = 0 )
37- except ValueError as e :
38- raise ValueError (f'Неверная дата/время: { e } ' )
39-
40- return super ().__new__ (cls , value )
31+ instance = super ().__new__ (cls , normalized_str )
32+ instance ._format_type = format_type
33+ return instance
34+
35+ @classmethod
36+ def _parse_string (cls , value : str ) -> tuple [datetime , str ]:
37+ '''Парсит строку в datetime и возвращает тип формата.'''
38+ errors = []
39+
40+ for fmt , format_type in cls ._formats .items ():
41+ try :
42+ dt = datetime .strptime (value , fmt )
43+ return dt , format_type
44+ except ValueError as e :
45+ errors .append (f"{ fmt } : { e } " )
46+ continue
47+
48+ # Если ни один формат не подошел
49+ supported_formats = "\n - " .join (cls ._formats .keys ())
50+ raise ValueError (
51+ f'Не удалось распарсить дату: "{ value } ".\n '
52+ f'Поддерживаемые форматы:\n - { supported_formats } '
53+ )
54+
55+ def _get_datetime (self ) -> datetime :
56+ '''Возвращает datetime объект из строки.'''
57+ return datetime .strptime (self , '%Y-%m-%d %H:%M:%S' )
4158
4259 def _get_start_datetime (self ) -> datetime :
43- '''Возвращает начало периода'''
44- for pattern , date_format in self .patterns :
45- if pattern .fullmatch (self ):
46- dt = datetime .strptime (self , date_format )
47- format_type = self ._get_format_type ()
48-
49- if format_type == 'year' :
50- return dt .replace (month = 1 , day = 1 , hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
51- elif format_type == 'month' :
52- return dt .replace (day = 1 , hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
53- elif format_type == 'day' :
54- return dt .replace (hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
55- elif format_type == 'hour' :
56- return dt .replace (minute = 0 , second = 0 , microsecond = 0 )
57- elif format_type == 'full' :
58- return dt .replace (minute = 0 , second = 0 , microsecond = 0 )
59- raise ValueError (f'Не удалось распарсить дату: { self } ' )
60+ '''Возвращает начало периода.'''
61+ dt = self ._get_datetime ()
62+
63+ if self ._format_type == 'year' :
64+ return dt .replace (month = 1 , day = 1 , hour = 0 )
65+ elif self ._format_type == 'month' :
66+ return dt .replace (day = 1 , hour = 0 )
67+ elif self ._format_type == 'day' :
68+ return dt .replace (hour = 0 )
69+ else : # hour
70+ return dt # Уже нормализовано до начала часа
6071
6172 def _get_stop_datetime (self ) -> datetime :
62- '''Возвращает конец периода'''
63- for pattern , date_format in self .patterns :
64- if pattern .fullmatch (self ):
65- dt = datetime .strptime (self , date_format )
66- format_type = self ._get_format_type ()
67-
68- if format_type == 'year' :
69- # Конец года: 31 декабря 23:00:00
70- return dt .replace (month = 12 , day = 31 , hour = 23 , minute = 0 , second = 0 , microsecond = 0 )
71- elif format_type == 'month' :
72- # Конец месяца: последний день 23:00:00
73- if dt .month == 12 :
74- next_month = dt .replace (year = dt .year + 1 , month = 1 , day = 1 )
75- else :
76- next_month = dt .replace (month = dt .month + 1 , day = 1 )
77- last_day = next_month - timedelta (days = 1 )
78- return last_day .replace (hour = 23 , minute = 0 , second = 0 , microsecond = 0 )
79- elif format_type == 'day' :
80- # Конец дня: 23:00:00
81- return dt .replace (hour = 23 , minute = 0 , second = 0 , microsecond = 0 )
82- elif format_type == 'hour' :
83- # Для часа: тот же час
84- return dt .replace (minute = 0 , second = 0 , microsecond = 0 )
85- elif format_type == 'full' :
86- # Для полного времени: нормализуем до часа
87- return dt .replace (minute = 0 , second = 0 , microsecond = 0 )
88- raise ValueError (f'Не удалось распарсить дату: { self } ' )
89-
90- def _get_format_type (self ) -> str :
91- '''Возвращает тип формата'''
92- if re .fullmatch (r'^\d{4}$' , self ):
93- return 'year'
94- elif re .fullmatch (r'^\d{4}-\d{2}$' , self ):
95- return 'month'
96- elif re .fullmatch (r'^\d{4}-\d{2}-\d{2}$' , self ):
97- return 'day'
98- elif re .fullmatch (r'^\d{4}-\d{2}-\d{2} \d{2}$' , self ):
99- return 'hour'
100- elif re .fullmatch (r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$' , self ):
101- return 'full'
102- else :
103- raise ValueError (f'Неизвестный формат: { self } ' )
73+ '''Возвращает конец периода.'''
74+ dt = self ._get_datetime ()
75+
76+ if self ._format_type == 'year' :
77+ # Конец года: 31 декабря 23:00:00
78+ return dt .replace (month = 12 , day = 31 , hour = 23 )
79+ elif self ._format_type == 'month' :
80+ # Конец месяца: последний день 23:00:00
81+ if dt .month == 12 :
82+ next_month = dt .replace (year = dt .year + 1 , month = 1 , day = 1 )
83+ else :
84+ next_month = dt .replace (month = dt .month + 1 , day = 1 )
85+ last_day = next_month - timedelta (days = 1 )
86+ return last_day .replace (hour = 23 )
87+ elif self ._format_type == 'day' :
88+ # Конец дня: 23:00:00
89+ return dt .replace (hour = 23 )
90+ else : # hour
91+ # Для часа: тот же час
92+ return dt
10493
10594 @property
10695 def start (self ) -> str :
107- '''Начало периода в формате YYYY-MM-DD HH:MM:SS'''
96+ '''Начало периода в формате YYYY-MM-DD HH:MM:SS. '''
10897 return self ._get_start_datetime ().strftime ('%Y-%m-%d %H:%M:%S' )
10998
11099 @property
111100 def stop (self ) -> str :
112- '''Конец периода в формате YYYY-MM-DD HH:MM:SS'''
101+ '''Конец периода в формате YYYY-MM-DD HH:MM:SS. '''
113102 return self ._get_stop_datetime ().strftime ('%Y-%m-%d %H:%M:%S' )
114103
115104 def __sub__ (self , hours : int ) -> 'DateHour' :
116- '''Вычитает указанное количество часов от начала периода'''
105+ '''Вычитает указанное количество часов от начала периода. '''
117106 start_dt = self ._get_start_datetime ()
118107 new_dt = start_dt - timedelta (hours = hours )
119- return DateHour (new_dt . strftime ( '%Y-%m-%d %H:%M:%S' ) )
108+ return DateHour (new_dt )
120109
121110 def __add__ (self , hours : int ) -> 'DateHour' :
122- '''Добавляет указанное количество часов к началу периода'''
111+ '''Добавляет указанное количество часов к началу периода. '''
123112 start_dt = self ._get_start_datetime ()
124113 new_dt = start_dt + timedelta (hours = hours )
125- return DateHour (new_dt . strftime ( '%Y-%m-%d %H:%M:%S' ) )
114+ return DateHour (new_dt )
126115
127116 @classmethod
128117 def __get_pydantic_core_schema__ (
129118 cls ,
130119 source : type ,
131120 handler : GetCoreSchemaHandler
132121 ) -> core_schema .CoreSchema :
133- def validate (value : str ) -> 'DateHour' :
122+ def validate (value : Union [ str , datetime ] ) -> 'DateHour' :
134123 if isinstance (value , cls ):
135124 return value
136125 return cls (value )
137126
138127 return core_schema .no_info_plain_validator_function (
139128 function = validate ,
140- serialization = core_schema .plain_serializer_function_ser_schema (lambda v : str ( v ) )
129+ serialization = core_schema .plain_serializer_function_ser_schema (str )
141130 )
142131
143132
144133if __name__ == '__main__' :
145- # Тест 1: Год
146- dh1 = DateHour ("2024" )
147- print (f"DateHour('2024').start = { dh1 .start } " ) # 2024-01-01 00:00:00
148- print (f"DateHour('2024').stop = { dh1 .stop } " ) # 2024-12-31 23:00:00
149- print (f"DateHour('2024') - 1 = { (dh1 - 1 ).start } " ) # 2023-12-31 23:00:00
150-
151- print ()
152-
153- # Тест 2: Месяц
154- dh2 = DateHour ("2024-01" )
155- print (f"DateHour('2024-01').start = { dh2 .start } " ) # 2024-01-01 00:00:00
156- print (f"DateHour('2024-01').stop = { dh2 .stop } " ) # 2024-01-31 23:00:00
157-
158- print ()
159-
160- # Тест 3: День
161- dh3 = DateHour ("2024-01-15" )
162- print (f"DateHour('2024-01-15').start = { dh3 .start } " ) # 2024-01-15 00:00:00
163- print (f"DateHour('2024-01-15').stop = { dh3 .stop } " ) # 2024-01-15 23:00:00
164-
165- print ()
166-
167- # Тест 4: Час
168- dh4 = DateHour ("2024-01-15 14" )
169- print (f"DateHour('2024-01-15 14').start = { dh4 .start } " ) # 2024-01-15 14:00:00
170- print (f"DateHour('2024-01-15 14').stop = { dh4 .stop } " ) # 2024-01-15 14:00:00
171-
172- print ()
173-
174- # Тест 5: Полное время (нормализуется до часа)
175- dh5 = DateHour ("2024-01-15 14:59:59" )
176- print (f"DateHour('2024-01-15 14:59:59').start = { dh5 .start } " ) # 2024-01-15 14:00:00
177- print (f"DateHour('2024-01-15 14:59:59').stop = { dh5 .stop } " ) # 2024-01-15 14:00:00
134+ print ("=== Тестирование DateHour с определением типа при парсинге ===" )
135+
136+ # Тест 1: Разные форматы
137+ test_cases = [
138+ "2024" , # Год
139+ "2024-01" , # Месяц
140+ "2024-01-15" , # День
141+ "2024-01-15 14" , # Час
142+ "2024-01-15 14:30" , # Время с минутами
143+ "2024-01-15 14:30:45" , # Полное время
144+ datetime (2024 , 1 , 15 , 14 , 30 , 45 ), # datetime объект
145+ ]
146+
147+ for i , case in enumerate (test_cases , 1 ):
148+ try :
149+ dh = DateHour (case )
150+ print (f"{ i } . { case !r:25} -> { dh !r:25} | тип: { dh ._format_type :6} | start: { dh .start :19} | stop: { dh .stop :19} " )
151+ except Exception as e :
152+ print (f"{ i } . { case !r:25} -> ОШИБКА: { e } " )
153+
154+ print ("\n === Арифметические операции ===" )
155+ dh = DateHour ("2024-01-15 14:30:00" )
156+ print (f"Исходный: { dh } -> start: { dh .start } " )
157+ print (f"+2 часа: { (dh + 2 )} -> start: { (dh + 2 ).start } " )
158+ print (f"-3 часа: { (dh - 3 )} -> start: { (dh - 3 ).start } " )
159+
160+ print ("\n === Проверка границ периодов ===" )
161+ print (f"Год 2024: { DateHour ('2024' ).start } - { DateHour ('2024' ).stop } " )
162+ print (f"Месяц 2024-01: { DateHour ('2024-01' ).start } - { DateHour ('2024-01' ).stop } " )
163+ print (f"День 2024-01-15: { DateHour ('2024-01-15' ).start } - { DateHour ('2024-01-15' ).stop } " )
164+ print (f"Час 2024-01-15 14: { DateHour ('2024-01-15 14' ).start } - { DateHour ('2024-01-15 14' ).stop } " )
0 commit comments