Coverage for app / helpers / buildinfo.py: 95%

87 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-06 04:49 +0000

1""" 

2This module handles the generation and management of a `build-info.json` file, which stores 

3metadata about the current build and Git repository. It includes functionality to check, 

4generate, read, remove, and write build metadata based on the repository's state. 

5""" 

6import logging 

7import os 

8import json 

9from pathlib import Path 

10from datetime import datetime, timezone 

11from dulwich.repo import Repo 

12from dulwich.errors import NotGitRepository 

13 

14 

15PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent 

16BUILD_INFO_FILE = PROJECT_ROOT / "build-info.json" 

17 

18 

19def check_and_generate_build_info(): 

20 """ 

21 Checks if the file `build-info.json` exists and regenerates it if necessary. The function  

22 compares timestamps between the last build and the latest commit to determine if  

23 regeneration is needed. 

24 """ 

25 

26 if os.path.isfile(BUILD_INFO_FILE): 

27 logging.debug("%s exists. Checking timestamps...", BUILD_INFO_FILE) 

28 

29 try: 

30 # Load the existing `build-info.json` file into memory 

31 with open(BUILD_INFO_FILE, 'r', encoding='utf-8') as f: 

32 existing_build_info = json.load(f) 

33 

34 existing_commit_date = existing_build_info.get("commit_date", "") 

35 

36 # Open the repository 

37 repo = Repo(PROJECT_ROOT) 

38 

39 # Get the latest commit details 

40 head_ref = repo.head() 

41 latest_commit = repo[head_ref] 

42 latest_commit_date = datetime.fromtimestamp(latest_commit.commit_time).isoformat() 

43 

44 # Compare timestamps 

45 if existing_commit_date: 

46 existing_timestamp = (datetime.fromisoformat(existing_commit_date) 

47 .replace(tzinfo=timezone.utc)) 

48 latest_timestamp = (datetime.fromisoformat(latest_commit_date) 

49 .replace(tzinfo=timezone.utc)) 

50 

51 if latest_timestamp > existing_timestamp: 

52 logging.debug( 

53 "Latest commit is newer than the one in %s. " 

54 "Regenerating the build-info.json file...", BUILD_INFO_FILE 

55 ) 

56 _generate_build_info(repo) 

57 else: 

58 logging.debug("%s is up-to-date.", BUILD_INFO_FILE) 

59 else: 

60 logging.debug("Existing commit date is invalid. Regenerating...") 

61 _generate_build_info(repo) 

62 

63 except NotGitRepository: 

64 logging.warning("Git repository not found. Leaving existing build-info.json untouched.") 

65 

66 else: 

67 logging.debug("%s does not exist. Generating a new one...", BUILD_INFO_FILE) 

68 _generate_build_info() 

69 

70 

71def read_build_info() -> dict: 

72 """ 

73 Reads and parses the build information from a predefined file. 

74 

75 This function ensures that the predefined build information file 

76 exists and is in valid JSON format. The file is read into a Python 

77 dictionary, which is then validated and returned. 

78 

79 :return: A dictionary containing the build information read from the 

80 specified file. 

81 :rtype: dict 

82 :raises AssertionError: If the build information file does not exist. 

83 :raises AssertionError: If the parsed data is not a dictionary. 

84 """ 

85 if not BUILD_INFO_FILE.exists(): 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true

86 raise AssertionError(f"Build information file does not exist: {BUILD_INFO_FILE}") 

87 build_info = json.loads(BUILD_INFO_FILE.read_text()) 

88 if not isinstance(build_info, dict): 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true

89 raise AssertionError("Build information must be a dictionary") 

90 return build_info 

91 

92 

93def write_empty_build_info(): 

94 """ 

95 Writes an empty build information dictionary to a JSON file named `build-info.json`. 

96 

97 The function initializes a dictionary with keys for branch name, commit ID, 

98 committer, comment, build date, and commit date. It assigns an empty string 

99 to each of these keys. The dictionary is then serialized and written to a 

100 JSON file with a filename specified by the constant `BUILD_INFO_FILE`. 

101 

102 :raises FileNotFoundError: Raised if the specified file path does not exist 

103 or cannot be accessed for writing. 

104 """ 

105 build_info = { 

106 "branch": "", 

107 "commit_id": "", 

108 "committer": "", 

109 "comment": "", 

110 "build_date": "", 

111 "commit_date": "", 

112 } 

113 # Write to the build-info.json file 

114 with open(BUILD_INFO_FILE, 'w', encoding='utf-8') as f: 

115 json.dump(build_info, f, indent=4) 

116 

117 

118def remove_build_info(): 

119 """ 

120 Removes the build information file if it exists. If the file does not exist, 

121 the operation gracefully handles the absence without raising an exception. 

122 

123 :raises FileNotFoundError: Raised internally but handled by the function 

124 when the file does not exist. 

125 """ 

126 # remove any existing file 

127 try: 

128 BUILD_INFO_FILE.unlink() 

129 except FileNotFoundError: 

130 pass 

131 

132 

133def _generate_build_info(repo: Repo = None): 

134 """ 

135 Generates a `build-info.json` file containing metadata from the Git repository if it  

136 is available. If the repository is not found, default values are used instead. The  

137 generated file includes details such as the branch name, commit ID, committer, comment,  

138 build date, and commit date. 

139 """ 

140 build_date = datetime.now(timezone.utc).isoformat() # Build date in UTC 

141 

142 try: 

143 # Open the repository, if not passed in 

144 if not repo: 

145 repo = Repo(PROJECT_ROOT) 

146 

147 # Get the latest commit details 

148 head_ref = repo.head() 

149 

150 commit = repo[head_ref] 

151 commit_id = commit.id.decode() 

152 committer = commit.committer.decode() 

153 comment = commit.message.decode().strip() 

154 commit_date = datetime.fromtimestamp(commit.commit_time, timezone.utc).isoformat() 

155 branch = _get_commit_target_branch(repo) 

156 

157 build_info = { 

158 "branch": branch, 

159 "commit_id": commit_id, 

160 "committer": committer, 

161 "comment": comment, 

162 "build_date": build_date, 

163 "commit_date": commit_date, 

164 } 

165 

166 except NotGitRepository: 

167 # Fallback logic if the Git repository is not initialized or unavailable 

168 logging.warning("Git repository not found. Using default values.") 

169 build_info = { 

170 "branch": "", 

171 "commit_id": "", 

172 "committer": "", 

173 "comment": "", 

174 "build_date": build_date, 

175 "commit_date": "", 

176 } 

177 

178 # Write to the build-info.json file 

179 with open(BUILD_INFO_FILE, 'w', encoding='utf-8') as f: 

180 json.dump(build_info, f, indent=4) 

181 logging.debug("Generated %s successfully.", BUILD_INFO_FILE) 

182 

183 

184def _get_commit_target_branch(repo: Repo) -> str: 

185 """ 

186 Determines the branch that would receive the next commit in the current repository.  

187 If the repository is in a detached `HEAD` state, attempts to identify the branch that  

188 tracks the current commit, if any. 

189 """ 

190 try: 

191 # Read the HEAD reference 

192 head_ref = repo.refs[b"HEAD"] 

193 

194 if not head_ref: 

195 return "(none)" # No HEAD exists 

196 

197 # If HEAD points to a branch, extract the branch name 

198 if head_ref.startswith(b"refs/heads/"): 

199 return head_ref.decode("utf-8")[len("refs/heads/"):] # Current branch name 

200 

201 # If HEAD does not point to a branch, we are in a detached HEAD state. 

202 # Check for the branch containing the current HEAD commit. 

203 head_commit = head_ref 

204 

205 # Iterate through all refs to find the branch tracking the HEAD commit 

206 for ref in repo.refs: # repo.refs supports iteration over keys 

207 if ref.startswith(b"refs/heads/"): # Only check local branches 

208 if repo.refs[ref] == head_commit: # Check if the commit matches 

209 return ref.decode("utf-8")[len("refs/heads/"):] # Extract branch name 

210 

211 # No branch found tracking the current commit, or the repository is detached 

212 # without any tracking information 

213 return "(detached)" 

214 except Exception as e: # pylint: disable=broad-except 

215 logging.error("An unexpected error occurred: %s", e, exc_info=True) 

216 return f"(error: {e})" 

217 

218 

219__all__ = ["check_and_generate_build_info", "read_build_info", "write_empty_build_info", 

220 "remove_build_info", "BUILD_INFO_FILE", "PROJECT_ROOT"] 

221 

222if __name__ == "__main__": 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true

223 check_and_generate_build_info()