Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1""" 

2Core module of defining commands. 

3 

4Inherit from the classes to add a new command: 

5- SimpleCommand 

6- ShellCommand 

7- DockerCommand 

8 

9Note that the module name of a newly defined command will be the command name. 

10For instance, if MyNewCommand is defined at my_command.py, 

11MyNewCommand can be invoked from "d++ my_command" at command-line. 

12""" 

13import argparse 

14import stat 

15from abc import ABC, ABCMeta, abstractmethod 

16from dataclasses import dataclass 

17from pathlib import Path 

18from typing import Dict, Generator, List, Optional, Tuple 

19 

20import taxonomy 

21from config import config 

22from errors import DppCommandListInternalError 

23from message import message 

24from processor.core.data import Worktree 

25from processor.core.docker import Docker 

26from processor.core.shell import Shell 

27 

28 

29class CommandRegistryMeta(type): 

30 """ 

31 Metaclass which auto registers class. 

32 

33 The module name will be command to invoke associated class. 

34 All registered commands can be retrieved via 'RegisteredCommands'. 

35 

36 Methods 

37 ------- 

38 get_commands : Dict[str, Command] 

39 Returns a dictionary of registered commands. 

40 

41 See Also 

42 -------- 

43 RegisteredCommands : Get a list of registered commands. 

44 """ 

45 

46 _commands: Dict[str, "Command"] = {} 

47 

48 def __new__(mcs, name, bases, attrs): 

49 new_class = type.__new__(mcs, name, bases, attrs) 

50 m = attrs["__module__"] 

51 if m != __name__ and not getattr(new_class, "_ignore_registry", False): 

52 CommandRegistryMeta._commands[m.split(".")[-1]] = new_class() 

53 return new_class 

54 

55 @staticmethod 

56 def get_commands() -> Dict[str, "Command"]: 

57 return CommandRegistryMeta._commands 

58 

59 

60class AbstractCommandRegistryMeta(CommandRegistryMeta, ABCMeta): 

61 """ 

62 Class that combines CommandRegistryMeta with ABCMeta. 

63 """ 

64 

65 pass 

66 

67 

68class BaseCommandRegistry(ABC, metaclass=AbstractCommandRegistryMeta): 

69 """ 

70 Base class of CommandRegistry. 

71 """ 

72 

73 pass 

74 

75 

76class Command(BaseCommandRegistry): 

77 """ 

78 Abstract class to implement a command. 

79 Inherit from this class to add a new command. 

80 The name of the command will be identical to the module name where the class is defined. 

81 If there are multiple classes inherited from this inside the module, 

82 the last one will be applied. 

83 """ 

84 

85 @property 

86 def group(self) -> str: 

87 """Represent the group of this command. It is not meaningful yet.""" 

88 raise NotImplementedError 

89 

90 @property 

91 def help(self) -> str: 

92 """Description of this command.""" 

93 raise NotImplementedError 

94 

95 @abstractmethod 

96 def __call__(self, argv: List[str]): 

97 """The actual behavior of the command.""" 

98 raise NotImplementedError 

99 

100 

101class RegisteredCommands: 

102 """ 

103 Descriptor to access registered commands. 

104 Pass a module name to retrieve an instance of associated class. 

105 """ 

106 

107 def __get__(self, instance, owner) -> Dict[str, Command]: 

108 return CommandRegistryMeta.get_commands() 

109 

110 def __set__(self, instance, value): 

111 raise DppCommandListInternalError() 

112 

113 

114class SimpleCommand(Command): 

115 """ 

116 Command that does not use docker. 

117 Not fully implemented yet. 

118 """ 

119 

120 @property 

121 def group(self) -> str: 

122 return "v1" 

123 

124 @abstractmethod 

125 def run(self, argv: List[str]) -> bool: 

126 raise NotImplementedError 

127 

128 def __call__(self, argv: List[str]) -> bool: 

129 return self.run(argv) 

130 

131 

132@dataclass 

133class ShellCommandArguments: 

134 commands: List[str] 

135 

136 

137class ShellCommand(Command): 

138 """ 

139 Command that does not use docker but shell instead. 

140 Not fully implemented yet. 

141 """ 

142 

143 def __init__(self): 

144 pass 

145 

146 @property 

147 def group(self) -> str: 

148 return "v1" 

149 

150 def __call__(self, argv: List[str]): 

151 shell_args = self.run(argv) 

152 with Shell() as shell: 

153 shell.send(shell_args.commands) 

154 

155 @abstractmethod 

156 def run(self, argv: List[str]) -> ShellCommandArguments: 

157 raise NotImplementedError 

158 

159 

160class DockerCommandScript(metaclass=ABCMeta): 

161 """ 

162 A list of "DockerCommand"s to be serially executed 

163 """ 

164 

165 def __init__(self, command_type: taxonomy.CommandType, command: List[str]): 

166 self.type = command_type 

167 self.lines: Tuple[str] = tuple(command) 

168 

169 @abstractmethod 

170 def before(self): 

171 """ 

172 Invoked before script is executed. 

173 """ 

174 raise NotImplementedError 

175 

176 @abstractmethod 

177 def step(self, linenr: int, line: str): 

178 """ 

179 Invoked before each line is executed. 

180 

181 Parameters 

182 ---------- 

183 linenr : int 

184 The current line number. 

185 line: str 

186 The current line to execute. 

187 """ 

188 pass 

189 

190 @abstractmethod 

191 def output(self, linenr: Optional[int], exit_code: Optional[int], output: str): 

192 """ 

193 Invoked after each line is executed. 

194 linenr is None if all commands are executed as if it is a script. 

195 

196 Parameters 

197 ---------- 

198 linenr : Optional[int] 

199 Index of the commands which has been executed. 

200 None if the type is CommandType.Script. 

201 exit_code: Optional[int] 

202 Exit code of the executed command. 

203 None when stream is set to False. 

204 output : str 

205 Captured stdout of the executed command. 

206 """ 

207 pass 

208 

209 @abstractmethod 

210 def after(self): 

211 """ 

212 Invoked after script is executed. 

213 """ 

214 raise NotImplementedError 

215 

216 def __iter__(self): 

217 return iter(self.lines) 

218 

219 def should_be_run_at_once(self) -> bool: 

220 """ 

221 Returns 

222 ------- 

223 bool 

224 Return true if it should be written to a file and executed at once, 

225 otherwise if it is sent to a container line by line. 

226 """ 

227 return self.type == taxonomy.CommandType.Script 

228 

229 

230class DockerCommandScriptGenerator(metaclass=ABCMeta): 

231 """ 

232 Factory class of DockerCommandScript. 

233 """ 

234 

235 def __init__(self, metadata: taxonomy.MetaData, worktree: Worktree, stream: bool): 

236 self._metadata = metadata 

237 self._worktree = worktree 

238 self._stream = stream 

239 

240 @property 

241 def metadata(self): 

242 """ 

243 Metadata information of the current script. 

244 """ 

245 return self._metadata 

246 

247 @property 

248 def worktree(self): 

249 """Worktree information of the current script.""" 

250 return self._worktree 

251 

252 @property 

253 def stream(self): 

254 """True if command should be streamed, otherwise False.""" 

255 return self._stream 

256 

257 @abstractmethod 

258 def create(self) -> Generator[DockerCommandScript, None, None]: 

259 """Yield DockerCommandScript.""" 

260 raise NotImplementedError 

261 

262 

263class DockerCommand(Command): 

264 """ 

265 Executes each command of DockerCommandLine one by one inside a docker container. 

266 Inherit from this class to implement a new command that should be run inside a docker. 

267 

268 Methods 

269 ------- 

270 __call__ : None 

271 Execute commands. 

272 """ 

273 

274 SCRIPT_NAME = "DPP_COMMAND_SCRIPT" 

275 

276 def __init__(self, parser: argparse.ArgumentParser): 

277 self.parser = parser 

278 self.environ: Dict[str, str] = {} 

279 

280 @property 

281 def group(self) -> str: 

282 return "v1" 

283 

284 def __call__(self, argv: List[str]): 

285 """ 

286 Execute commands inside a container. 

287 

288 Parameters 

289 ---------- 

290 argv : List[str] 

291 Command line argument vector. 

292 

293 Returns 

294 ------- 

295 None 

296 """ 

297 

298 def parse_exec_result(ec, output, line_number: Optional[int] = None) -> None: 

299 # Depending on 'stream' value, return value is a bit different. 

300 # 'exit_code' is None when 'stream' is True. 

301 # https://docker-py.readthedocs.io/en/stable/containers.html 

302 if ec is None: 

303 for stream_line in output: 

304 message.stdout_stream(stream_line.decode("utf-8", errors="ignore")) 

305 else: 

306 script.output(line_number, ec, output.decode("utf-8", errors="ignore")) 

307 

308 args = self.parser.parse_args(argv) 

309 

310 if args.jobs <= 0: 

311 raise ValueError("jobs must be greater than 0") 

312 else: 

313 config.DPP_PARALLEL_BUILD = str(args.jobs) 

314 

315 self.environ = args.env 

316 

317 script_generator = self.create_script_generator(args) 

318 worktree = script_generator.worktree 

319 stream = script_generator.stream 

320 rebuild_image = True if args.rebuild_image else False 

321 user = "" # root 

322 uid = args.uid if hasattr(args, "uid") and args.uid is not None else None 

323 

324 self.setup(script_generator) 

325 with Docker( 

326 script_generator.metadata.dockerfile, 

327 script_generator.worktree, 

328 self.environ, 

329 rebuild_image, 

330 user, 

331 uid, 

332 ) as docker: 

333 for script in script_generator.create(): 

334 script.before() 

335 if script.should_be_run_at_once(): 

336 file = Path(f"{worktree.host}/{DockerCommand.SCRIPT_NAME}") 

337 with open(file, "w+") as fp: 

338 fp.write("\n".join(script)) 

339 file.chmod( 

340 file.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH 

341 ) 

342 exit_code, output_stream = docker.send( 

343 f"{worktree.container}/{DockerCommand.SCRIPT_NAME}", stream 

344 ) 

345 parse_exec_result(exit_code, output_stream) 

346 else: 

347 for linenr, line in enumerate(script, start=1): 

348 script.step(linenr, line) 

349 exit_code, output_stream = docker.send(line, stream) 

350 parse_exec_result(exit_code, output_stream, linenr) 

351 script.after() 

352 self.teardown(script_generator) 

353 

354 @abstractmethod 

355 def create_script_generator( 

356 self, args: argparse.Namespace 

357 ) -> DockerCommandScriptGenerator: 

358 """ 

359 Return DockerExecInfo which has information of a command list to run inside docker container. 

360 

361 Parameters 

362 ---------- 

363 args : argparse.Namespace 

364 argparse.Namespace instance. 

365 

366 Returns 

367 ------- 

368 DockerCommandScriptGenerator 

369 An instance of generator class which is used to create DockerCommandScript. 

370 """ 

371 raise NotImplementedError 

372 

373 @abstractmethod 

374 def setup(self, generator: DockerCommandScriptGenerator): 

375 """ 

376 Invoked before container is created. 

377 

378 Parameters 

379 ---------- 

380 generator : DockerCommandScriptGenerator 

381 The current instance being used. 

382 """ 

383 raise NotImplementedError 

384 

385 @abstractmethod 

386 def teardown(self, generator: DockerCommandScriptGenerator): 

387 """ 

388 Invoked after container is destroyed. 

389 

390 Parameters 

391 ---------- 

392 generator : DockerCommandScriptGenerator 

393 The current instance being used. 

394 """ 

395 raise NotImplementedError