Coverage for src/pullapprove/forms.py: 62%

82 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-12-15 16:55 -0600

1""" 

2Configuration models for review forms. 

3 

4Follows GitHub's form schema structure: 

5https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-githubs-form-schema 

6""" 

7 

8from __future__ import annotations 

9 

10import hashlib 

11import json 

12from enum import Enum 

13from typing import Any, Literal 

14 

15from pydantic import ( 

16 BaseModel, 

17 ConfigDict, 

18 Field, 

19 field_validator, 

20) 

21 

22 

23class FormFieldType(str, Enum): 

24 CHECKBOXES = "checkboxes" 

25 DROPDOWN = "dropdown" 

26 INPUT = "input" 

27 TEXTAREA = "textarea" 

28 

29 

30class CheckboxOption(BaseModel): 

31 """A single checkbox option within a checkboxes field.""" 

32 

33 model_config = ConfigDict(extra="forbid") 

34 

35 label: str = Field(min_length=1) 

36 required: bool = False 

37 value: bool = False # Whether this checkbox is checked 

38 

39 

40class FormFieldBase(BaseModel): 

41 """Base class for form fields.""" 

42 

43 model_config = ConfigDict(extra="forbid") 

44 

45 type: FormFieldType 

46 label: str = "" 

47 description: str = "" 

48 

49 

50class CheckboxesField(FormFieldBase): 

51 """Multiple attestation checkboxes.""" 

52 

53 type: Literal["checkboxes"] 

54 label: str = Field(min_length=1) 

55 options: list[CheckboxOption] = Field(min_length=1) 

56 

57 

58class DropdownField(FormFieldBase): 

59 """Single-select dropdown.""" 

60 

61 type: Literal["dropdown"] 

62 label: str = Field(min_length=1) 

63 options: list[str] = Field(min_length=1) 

64 required: bool = False 

65 value: str | None = None 

66 

67 

68class InputField(FormFieldBase): 

69 """Single-line text input.""" 

70 

71 type: Literal["input"] 

72 label: str = Field(min_length=1) 

73 placeholder: str = "" 

74 required: bool = False 

75 value: str = "" 

76 

77 

78class TextareaField(FormFieldBase): 

79 """Multi-line text input.""" 

80 

81 type: Literal["textarea"] 

82 label: str = Field(min_length=1) 

83 placeholder: str = "" 

84 required: bool = False 

85 value: str = "" 

86 

87 

88# Union type for all field types 

89FormField = CheckboxesField | DropdownField | InputField | TextareaField 

90 

91 

92class ScopeFormModel(BaseModel): 

93 """Configuration for review forms.""" 

94 

95 model_config = ConfigDict(extra="forbid") 

96 

97 fields: list[FormField] = Field(min_length=1) 

98 

99 @field_validator("fields", mode="before") 

100 @classmethod 

101 def parse_fields(cls, fields: list[dict[str, Any] | FormField]) -> list[FormField]: 

102 """Parse field dicts into appropriate field types based on 'type' key.""" 

103 parsed = [] 

104 for field_data in fields: 

105 # Already a FormField instance, pass through 

106 if isinstance( 

107 field_data, CheckboxesField | DropdownField | InputField | TextareaField 

108 ): 

109 parsed.append(field_data) 

110 continue 

111 

112 field_type = field_data.get("type") 

113 match field_type: 

114 case "checkboxes": 

115 parsed.append(CheckboxesField(**field_data)) 

116 case "dropdown": 

117 parsed.append(DropdownField(**field_data)) 

118 case "input": 

119 parsed.append(InputField(**field_data)) 

120 case "textarea": 

121 parsed.append(TextareaField(**field_data)) 

122 case _: 

123 raise ValueError(f"Unknown form field type: {field_type}") 

124 return parsed 

125 

126 def compute_hash(self) -> str: 

127 """Compute deterministic hash of form definition. 

128 

129 Only includes semantic attributes that define the "form contract": 

130 - type, label, required, options 

131 Excludes presentational attributes: 

132 - description, placeholder, value 

133 """ 

134 serialized: list[dict[str, Any]] = [] 

135 for f in self.fields: 

136 field_dict = f.model_dump() if not isinstance(f, dict) else f 

137 # Extract only semantic attributes 

138 semantic: dict[str, Any] = { 

139 "type": field_dict["type"], 

140 "label": field_dict["label"], 

141 } 

142 if "required" in field_dict: 

143 semantic["required"] = field_dict["required"] 

144 if "options" in field_dict: 

145 # For checkboxes, extract label and required from each option 

146 if field_dict["type"] == "checkboxes": 

147 semantic["options"] = [ 

148 {"label": opt["label"], "required": opt.get("required", False)} 

149 for opt in field_dict["options"] 

150 ] 

151 else: 

152 # For dropdown, options is just a list of strings 

153 semantic["options"] = field_dict["options"] 

154 serialized.append(semantic) 

155 canonical = json.dumps(serialized, sort_keys=True) 

156 return hashlib.sha256(canonical.encode()).hexdigest()