Coverage for src / lexigram / admin / schema / numeric.py: 27%

95 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1from __future__ import annotations 

2 

3from dataclasses import dataclass 

4from typing import Any 

5 

6from lexigram.admin.schema.base import SchemaField 

7from lexigram.admin.schema.exceptions import FieldError 

8from lexigram.result import Err, Ok, Result 

9from lexigram.ui import Element, NumberInput 

10 

11CURRENCY_SYMBOLS: dict[str, str] = { 

12 "USD": "$", 

13 "EUR": "\u20ac", 

14 "GBP": "\u00a3", 

15 "JPY": "\u00a5", 

16 "CAD": "$", 

17 "AUD": "$", 

18 "CHF": "CHF", 

19 "CNY": "\u00a5", 

20 "SEK": "kr", 

21 "NOK": "kr", 

22} 

23 

24 

25@dataclass(frozen=True, kw_only=True) 

26class NumberField(SchemaField[int | float]): 

27 """A numeric input field supporting integers and floats.""" 

28 

29 def render_form( 

30 self, value: float | None, *, errors: list[str] | None = None 

31 ) -> Element: 

32 kwargs: dict[str, Any] = { 

33 "name": self.name, 

34 "value": value if value is not None else "", 

35 } 

36 if self.placeholder is not None: 

37 kwargs["placeholder"] = self.placeholder 

38 if self.label is not None: 

39 kwargs["label"] = self.label 

40 if errors: 

41 kwargs["error"] = errors[0] 

42 return NumberInput(**kwargs).render() 

43 

44 def render_column(self, record: Any, value: float | None) -> Element: 

45 if value is None: 

46 return Element("span", "\u2014", class_="text-muted") 

47 return Element("span", str(value)) 

48 

49 def from_form(self, raw: str | None) -> Result[int | float | None, FieldError]: 

50 if raw is None: 

51 return Ok(None) 

52 stripped = raw.strip() 

53 if not stripped: 

54 if self.nullable: 

55 return Ok(None) 

56 return Err(FieldError("Must be a number")) 

57 try: 

58 if "." in stripped: 

59 return Ok(float(stripped)) 

60 return Ok(int(stripped)) 

61 except (ValueError, TypeError): 

62 return Err(FieldError("Must be a number")) 

63 

64 def to_form(self, value: float | None) -> str: 

65 if value is None: 

66 return "" 

67 return str(value) 

68 

69 

70@dataclass(frozen=True, kw_only=True) 

71class IntegerField(NumberField): 

72 """An integer-only input field.""" 

73 

74 def from_form(self, raw: str | None) -> Result[int | None, FieldError]: # type: ignore[override] 

75 if raw is None: 

76 return Ok(None) 

77 stripped = raw.strip() 

78 if not stripped: 

79 if self.nullable: 

80 return Ok(None) 

81 return Err(FieldError("Must be an integer")) 

82 try: 

83 if "." in stripped: 

84 return Err(FieldError("Must be an integer")) 

85 return Ok(int(stripped)) 

86 except (ValueError, TypeError): 

87 return Err(FieldError("Must be an integer")) 

88 

89 def render_column(self, record: Any, value: float | None) -> Element: 

90 if value is None: 

91 return Element("span", "\u2014", class_="text-muted") 

92 return Element("span", str(int(value))) 

93 

94 

95@dataclass(frozen=True, kw_only=True) 

96class FloatField(NumberField): 

97 """A float-only input field.""" 

98 

99 def from_form(self, raw: str | None) -> Result[float | None, FieldError]: 

100 if raw is None: 

101 return Ok(None) 

102 stripped = raw.strip() 

103 if not stripped: 

104 if self.nullable: 

105 return Ok(None) 

106 return Err(FieldError("Must be a number")) 

107 try: 

108 return Ok(float(stripped)) 

109 except (ValueError, TypeError): 

110 return Err(FieldError("Must be a number")) 

111 

112 

113@dataclass(frozen=True, kw_only=True) 

114class CurrencyField(NumberField): 

115 """A currency input field with configurable currency symbol.""" 

116 

117 currency: str = "USD" 

118 

119 def render_column(self, record: Any, value: float | None) -> Element: 

120 if value is None: 

121 return Element("span", "\u2014", class_="text-muted") 

122 symbol = CURRENCY_SYMBOLS.get(self.currency, self.currency) 

123 return Element("span", f"{symbol}{float(value):.2f}") 

124 

125 def from_form(self, raw: str | None) -> Result[float | None, FieldError]: 

126 if raw is None: 

127 return Ok(None) 

128 stripped = raw.strip() 

129 if not stripped: 

130 if self.nullable: 

131 return Ok(None) 

132 return Err(FieldError("Must be a number")) 

133 try: 

134 return Ok(float(stripped)) 

135 except (ValueError, TypeError): 

136 return Err(FieldError("Must be a number"))