Coverage for texgit / repository / process_manager.py: 89%

90 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-22 02:50 +0000

1""" 

2A class for managing files and directories. 

3 

4A file manager provides a two-level abstraction for assigning names to paths. 

5It exists within a certain base directory. 

6Inside the base directory, it provides so-called "realms". 

7Each realm is a separate namespace. 

8With a realm, "names" are mapped to paths. 

9The file manager ensures that the same realm-name combination is always 

10assigned to the same path. 

11The first time it is queried, the path is created. 

12This path can be a file or a directory, depending on what was queried. 

13Every realm-name combination always uniquely identifies a path and there can 

14never be another realm-name combination pointing to the same path. 

15The paths are randomized to avoid potential clashes. 

16 

17Once the file manager is closed, the realm-name to path associations are 

18stored. 

19When a new file manager instance is created for the same base directory, the 

20associations of realms-names to paths are restored. 

21This means that a program that creates output files for certain commands can 

22then find these files again later. 

23""" 

24from os import environ 

25from os.path import getsize 

26from typing import Final, Iterable, Mapping 

27 

28from pycommons.ds.immutable_map import immutable_mapping 

29from pycommons.io.console import logger 

30from pycommons.io.path import Path, write_lines 

31from pycommons.processes.python import PYTHON_ENV, PYTHON_INTERPRETER 

32from pycommons.processes.shell import STREAM_CAPTURE, Command 

33from pycommons.types import type_error 

34 

35from texgit.repository.fix_path import replace_base_path 

36from texgit.repository.git_manager import GitManager, GitPath 

37 

38 

39def _write(orig: str, dest: Path) -> None: 

40 """ 

41 Write the string to the destination. 

42 

43 :param orig: the original string 

44 :param dest: the destination 

45 """ 

46 orig = str.rstrip(orig) 

47 with dest.open_for_write() as output: 

48 write_lines(map(str.rstrip, str.rstrip(orig).splitlines()), output) 

49 logger("Wrote r-stripped string of originally " 

50 f"{str.__len__(orig)} characters to {dest!r}, " 

51 f"produced file of size {getsize(dest)} bytes.") 

52 

53 

54def __get_sys_env() -> Mapping[str, str]: 

55 """ 

56 Get the system environment variables in the current environment. 

57 

58 :return: A mapping of variable names to values. 

59 """ 

60 base: dict[str, str] = dict(environ) 

61 base.update(PYTHON_ENV) 

62 return immutable_mapping(base) 

63 

64 

65#: the environment that we will pass on 

66SYS_ENV: Final[Mapping[str, str]] = __get_sys_env() 

67del __get_sys_env 

68 

69 

70class ProcessManager(GitManager): 

71 """A manager for processes.""" 

72 

73 def get_argument_file(self, name: str, prefix: str | None = None, 

74 suffix: str | None = None) -> tuple[Path, bool]: 

75 """ 

76 Create a file in the argument realm. 

77 

78 :param name: the ID for the file 

79 :param prefix: the optional prefix 

80 :param suffix: the optional suffix 

81 :return: the file, plus a `bool` indicating whether it was just 

82 created (`True`) or already existed (`False`) 

83 """ 

84 return self.get_file( 

85 "args", name, (str.strip(prefix) or None) if prefix else None, 

86 (str.strip(suffix) or None) if suffix else None) 

87 

88 def filter_argument(self, arg: str) -> str | None: 

89 """ 

90 Filter an argument to be passed to any given file. 

91 

92 This function can be used to rewire arguments of certain programs that 

93 we want to invoke to specific files. 

94 

95 :param arg: the argument 

96 :return: the filtered argument 

97 """ 

98 arg = str.strip(arg) 

99 if arg: 

100 if arg.startswith("(?") and arg.endswith("?)"): 

101 name: Final[str] = str.strip(arg[2:-2]) 

102 if str.__len__(name) <= 0: 

103 raise ValueError(f"Invalid ID in {arg!r}.") 

104 return self.get_argument_file(name)[0] 

105 return arg 

106 return None 

107 

108 def __execute(self, dest: Path, 

109 command: str | Iterable[str], 

110 working_dir: Path | None = None, 

111 stdin: str | None = None) -> None: 

112 """ 

113 Make a command and environment. 

114 

115 :param dest: the destination path 

116 :param command: the command 

117 :param working_dir: an optional working directory 

118 :param stdin: the standard input for the program, or `None` 

119 """ 

120 # process the command 

121 cmd_lst: Final[list[str]] = [command] if isinstance(command, str)\ 

122 else list(command) 

123 for i in range(list.__len__(cmd_lst) - 1, -1, -1): 

124 cmd: str = str.strip(cmd_lst[i]) 

125 if str.__len__(cmd) <= 0: 

126 del cmd_lst[i] 

127 continue 

128 

129 # process the arguments 

130 for i in range(list.__len__(cmd_lst) - 1, 0, -1): 

131 cmd = self.filter_argument(cmd_lst[i]) 

132 if cmd is None: 

133 del cmd_lst[i] 

134 continue 

135 cmd_lst[i] = cmd 

136 

137 if list.__len__(cmd_lst) <= 0: 

138 raise ValueError(f"Invalid command {command!r}.") 

139 

140 # Now we need to fix the command if we are running inside a virtual 

141 # environment. If we are running inside a virtual environment, it is 

142 # necessary to use the same Python interpreter that was used to run 

143 # texgit. We should also pass along all the Python-related 

144 # environment parameters. 

145 env: Mapping[str, str] = SYS_ENV 

146 if str.lower(cmd_lst[0]).startswith("python3"): 

147 cmd_lst[0] = PYTHON_INTERPRETER 

148 env = PYTHON_ENV 

149 

150 # execute the command and capture the output 

151 output: str = Command( 

152 command=cmd_lst, working_dir=working_dir, env=env, 

153 stdout=STREAM_CAPTURE, stdin=stdin).execute(True)[0] 

154 

155 replace: list[Path] = self._get_sensitive_paths() 

156 replace.append(dest) 

157 replace.sort(key=str.__len__, reverse=True) 

158 for base_dir in replace: # fix the base path 

159 output = replace_base_path(output, base_dir) 

160 _write(output, dest) 

161 

162 def get_output( 

163 self, name: str, command: str | Iterable[str], 

164 repo_url: str | None = None, 

165 relative_dir: str | None = None) -> Path: 

166 """ 

167 Get the output of a certain command. 

168 

169 :param name: the name for the output 

170 :param command: the command itself 

171 :param repo_url: the optional repository URL 

172 :param relative_dir: the optional directory inside the repository 

173 where the command should be executed 

174 :return: the path to the output and, if `repo_url` and `relative_dir` 

175 were not `None`, then a URL pointing to the directory in the 

176 repository, else `None` 

177 """ 

178 self._check_open() 

179 path, is_new = self.get_file("output", name) 

180 if not is_new: 

181 return path 

182 

183 if isinstance(repo_url, str): 

184 repo_url = str.strip(repo_url) or None 

185 elif repo_url is not None: 

186 raise type_error(repo_url, "repo_url", (str, None)) 

187 

188 if isinstance(relative_dir, str): 

189 relative_dir = str.strip(relative_dir) or None 

190 elif relative_dir is not None: 

191 raise type_error(relative_dir, "relative_dir", (str, None)) 

192 if (repo_url is None) != (relative_dir is None): 

193 raise ValueError(f"repo_url and relative_dir must either both be " 

194 f"None or neither, but they are {repo_url!r} " 

195 f"and {relative_dir!r}.") 

196 working_dir: Path | None = None 

197 if repo_url is not None: 

198 working_dir = self.get_git_dir(repo_url, relative_dir).path 

199 

200 self.__execute(dest=path, command=command, working_dir=working_dir) 

201 return path 

202 

203 def get_git_file( 

204 self, repo_url: str, relative_file: str, 

205 name: str | None = None, 

206 command: str | Iterable[str] | None = None) -> GitPath: 

207 """ 

208 Get a path to a postprocessed file from the given git repository. 

209 

210 :param repo_url: the repository url. 

211 :param relative_file: the relative path to the file 

212 :param name: the name for the output 

213 :param command: the command itself 

214 :return: a tuple of file and URL 

215 """ 

216 gf: Final[GitPath] = super().get_git_file(repo_url, relative_file) 

217 if command: 

218 name = str.strip(name) 

219 path, is_new = self.get_file("postprocessed", name) 

220 if is_new: 

221 self.__execute(dest=path, command=command, 

222 stdin=gf.path.read_all_str()) 

223 else: 

224 path = gf.path 

225 return GitPath(path, gf.repo, gf.repo.make_url(gf.path), gf.basename)