Coverage for src/lektor_ng/types/primitives.py: 93%

115 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-08 16:45 +0000

1import re 

2from datetime import date, datetime 

3 

4from babel.dates import get_timezone 

5from markupsafe import Markup 

6 

7from lektor_ng.constants import PRIMARY_ALT 

8from lektor_ng.i18n import get_i18n_block 

9from lektor_ng.types.base import Type 

10from lektor_ng.utils import bool_from_string 

11 

12 

13class SingleInputType(Type): 

14 widget = "singleline-text" 

15 

16 def to_json(self, pad, record=None, alt=PRIMARY_ALT): 

17 rv = Type.to_json(self, pad, record, alt) 

18 rv["addon_label_i18n"] = get_i18n_block(self.options, "addon_label") or None 

19 return rv 

20 

21 

22class StringType(SingleInputType): 

23 def value_from_raw(self, raw): 

24 if raw.value is None: 

25 return raw.missing_value("Missing string") 

26 try: 

27 return raw.value.splitlines()[0].strip() 

28 except IndexError: 

29 return "" 

30 

31 

32class StringsType(Type): 

33 widget = "multiline-text" 

34 

35 def value_from_raw(self, raw): 

36 return [x.strip() for x in (raw.value or "").splitlines()] 

37 

38 

39class TextType(Type): 

40 widget = "multiline-text" 

41 

42 def value_from_raw(self, raw): 

43 if raw.value is None: 

44 return raw.missing_value("Missing text") 

45 return raw.value 

46 

47 

48class HtmlType(Type): 

49 widget = "multiline-text" 

50 

51 def value_from_raw(self, raw): 

52 if raw.value is None: 

53 return raw.missing_value("Missing HTML") 

54 return Markup(raw.value) 

55 

56 

57class IntegerType(SingleInputType): 

58 widget = "integer" 

59 

60 def value_from_raw(self, raw): 

61 if raw.value is None: 

62 return raw.missing_value("Missing integer value") 

63 try: 

64 return int(raw.value.strip()) 

65 except ValueError: 

66 try: 

67 return int(float(raw.value.strip())) 

68 except ValueError: 

69 return raw.bad_value("Not an integer") 

70 

71 

72class FloatType(SingleInputType): 

73 widget = "float" 

74 

75 def value_from_raw(self, raw): 

76 if raw.value is None: 

77 return raw.missing_value("Missing float value") 

78 try: 

79 return float(raw.value.strip()) 

80 except ValueError: 

81 return raw.bad_value("Not an integer") 

82 

83 

84class BooleanType(Type): 

85 widget = "checkbox" 

86 

87 def to_json(self, pad, record=None, alt=PRIMARY_ALT): 

88 rv = Type.to_json(self, pad, record, alt) 

89 rv["checkbox_label_i18n"] = get_i18n_block(self.options, "checkbox_label") 

90 return rv 

91 

92 def value_from_raw(self, raw): 

93 if raw.value is None: 

94 return raw.missing_value("Missing boolean") 

95 val = bool_from_string(raw.value.strip().lower()) 

96 if val is None: 

97 return raw.bad_value("Bad boolean value") 

98 return val 

99 

100 

101class DateType(SingleInputType): 

102 widget = "datepicker" 

103 

104 def value_from_raw(self, raw): 

105 if raw.value is None: 

106 return raw.missing_value("Missing date") 

107 try: 

108 return date(*map(int, raw.value.split("-"))) 

109 except Exception: 

110 return raw.bad_value("Bad date format") 

111 

112 

113class DateTimeType(SingleInputType): 

114 widget = "singleline-text" 

115 

116 def value_from_raw(self, raw): 

117 if raw.value is None: 

118 return raw.missing_value("Missing datetime") 

119 

120 # The previous version of this code allowed a timezone name, followed by a zone 

121 # offset. In that case the zone name would be ignored (unless the combined zone 

122 # name, including the offset, matched an IANA zone key). For the sake of 

123 # backwards compatibility we do the same here. 

124 m = re.match( 

125 r""" 

126 (?P<datetime> 

127 \d{4} - \d\d? - \d\d? # YY-MM-DD 

128 \s+ \d\d? : \d\d (?P<seconds> :\d\d )? # HH:MM[:SS] 

129 ) 

130 (?: \s+ 

131 (?P<timezone> 

132 # Long timezone keys, and — on Windows — names containing 

133 # certain characters (those that are not allowed in filenames) 

134 # give zoneinfo gas. 

135 # https://github.com/python/cpython/issues/96463 

136 [^<>:"|?*\x00-\x1f]{,100}? 

137 (?P<zoneoffset> [-+] \d\d (?: :? \d\d ){1,2} )? # ±HHMM[SS] 

138 ) 

139 )? 

140 \Z""", 

141 raw.value.strip(), 

142 re.DOTALL | re.VERBOSE, 

143 ) 

144 if m is None: 

145 return raw.bad_value("Bad datetime format") 

146 timezone, zoneoffset = m.group("timezone", "zoneoffset") 

147 tz = None 

148 if timezone is not None: 

149 try: 

150 tz = get_timezone(timezone) 

151 zoneoffset = None 

152 # NB: under py39 on windows with tzdata, zoneinfo.ZoneInfo can raise 

153 # ValueError 

154 except (LookupError, ValueError): 

155 if zoneoffset is None: 

156 return raw.bad_value(f"Unknown timezone {timezone!r}") 

157 

158 dt = m["datetime"] 

159 fmt = "%Y-%m-%d %H:%M" 

160 if m["seconds"] is not None: 

161 fmt += ":%S" 

162 if zoneoffset is not None: 

163 dt += f" {zoneoffset}" 

164 fmt += " %z" 

165 try: 

166 result = datetime.strptime(dt, fmt) 

167 except ValueError: 

168 return raw.bad_value("Invalid datetime") 

169 

170 if tz is None: 

171 return result 

172 

173 # as of babel 2.12, get_timezone can return either a pytz timezone 

174 # or a zoneinfo timezone 

175 assert result.tzinfo is None 

176 if hasattr(tz, "localize"): # pytz 

177 return tz.localize(result) 

178 return result.replace(tzinfo=tz)