Coverage for .tox/cov/lib/python3.11/site-packages/confattr/types.py: 100%

135 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-12 20:24 +0200

1#!./runmodule.sh 

2 

3import os 

4import shutil 

5import shlex 

6import platform 

7import re 

8import abc 

9import typing 

10from collections.abc import Sequence, Callable, Mapping, MutableMapping 

11 

12from .subprocess_pipe import run_and_pipe, CompletedProcess 

13 

14 

15#: The data type of a context manager factory which can be passed to :meth:`SubprocessCommand.run() <confattr.types.SubprocessCommand.run>` and :meth:`SubprocessCommandWithAlternatives.run() <confattr.types.SubprocessCommandWithAlternatives.run>` 

16TYPE_CONTEXT: 'typing.TypeAlias' = 'Callable[[SubprocessCommand], typing.ContextManager[SubprocessCommand]] | None' 

17 

18 

19class AbstractType(abc.ABC): 

20 

21 ''' 

22 This class is merely for documentation purposes. 

23 It shows which special methods and attributes are considered by this library 

24 for the data types which are used in a :class:`~confattr.config.Config`. 

25 ''' 

26 

27 #: If this attribute is present it is used instead of the class name in the config file. 

28 type_name: str 

29 

30 #: A help for this data type must be provided, either in this attribute or by adding it to :attr:`Primitive.help_dict <confattr.formatters.Primitive.help_dict>`. 

31 help: str 

32 

33 @abc.abstractmethod 

34 def __init__(self, value: str) -> None: 

35 ''' 

36 The **constructor** must create an equal objet if it is passed the return value of :meth:`~confattr.types.AbstractType.__str__`. 

37 Optionally you may want to also accept another data type as argument for creating the default value. 

38 

39 .. automethod:: __str__ 

40 ''' 

41 

42 @abc.abstractmethod 

43 def __str__(self) -> str: 

44 ''' 

45 This method must return a str representation of this object which is suitable to be written to a config file. 

46 ''' 

47 

48 

49 

50Regex: 'Callable[[str], re.Pattern[str]]' 

51# when https://github.com/python/typing/issues/213 is implemented I could add more methods 

52class Regex: # type: ignore [no-redef] 

53 

54 type_name = 'regular expression' 

55 help = ''' 

56 A regular expression in python syntax. 

57 You can specify flags by starting the regular expression with `(?aiLmsux)`. 

58 https://docs.python.org/3/library/re.html#regular-expression-syntax 

59 ''' 

60 

61 def __init__(self, pattern: str) -> None: 

62 self._compiled_pattern: 're.Pattern[str]' = re.compile(pattern) 

63 

64 def __getattr__(self, attr: str) -> object: 

65 return getattr(self._compiled_pattern, attr) 

66 

67 def __str__(self) -> str: 

68 return self._compiled_pattern.pattern 

69 

70 def __repr__(self) -> str: 

71 return f'{type(self).__name__}({self._compiled_pattern.pattern!r})' 

72 

73class CaseInsensitiveRegex(Regex): # type: ignore [valid-type,misc] # mypy complains about inheriting from Regex because I have declared it as callable 

74 

75 help = ''' 

76 A case insensitive regular expression in python syntax. 

77 You can make it case sensitive by wrapping the pattern in `(?-i:...)`. 

78 https://docs.python.org/3/library/re.html#regular-expression-syntax 

79 ''' 

80 

81 def __init__(self, pattern: str) -> None: 

82 self._compiled_pattern = re.compile(pattern, flags=re.I) 

83 

84 

85class SubprocessCommand: 

86 

87 type_name = 'command' 

88 help = '''\ 

89 A command to be executed as a subprocess. 

90 The command is executed without a shell so redirection or wildcard expansion is not possible. 

91 Setting environment variables and piping like in a POSIX shell, however, are implemented in python and should work platform independently. 

92 If you need a shell write the command to a file, insert an appropriate shebang line, make the file executable and set this value to the file. 

93 ''' 

94 

95 python_callbacks: 'MutableMapping[str, Callable[[SubprocessCommand, TYPE_CONTEXT], None]]' = {} 

96 

97 @classmethod 

98 def register_python_callback(cls, name: str, func: 'Callable[[SubprocessCommand, TYPE_CONTEXT], None]') -> None: 

99 cls.python_callbacks[name] = func 

100 

101 @classmethod 

102 def unregister_python_callback(cls, name: str) -> None: 

103 del cls.python_callbacks[name] 

104 

105 @classmethod 

106 def has_python_callback(cls, name: str) -> bool: 

107 return name in cls.python_callbacks 

108 

109 

110 def __init__(self, arg: 'SubprocessCommand|Sequence[str]|str', *, env: 'Mapping[str, str]|None' = None) -> None: 

111 self.cmd: 'Sequence[str]' 

112 self.env: 'Mapping[str, str]|None' 

113 if isinstance(arg, str): 

114 assert env is None 

115 self.parse_str(arg) 

116 elif isinstance(arg, SubprocessCommand): 

117 self.cmd = list(arg.cmd) 

118 self.env = dict(arg.env) if arg.env else None 

119 if env: 

120 if self.env: 

121 self.env.update(env) 

122 else: 

123 self.env = env 

124 else: 

125 self.cmd = list(arg) 

126 self.env = env 

127 

128 def parse_str(self, arg: str) -> None: 

129 ''' 

130 Parses a string as returned by :meth:`~confattr.types.SubprocessCommand.__str__` and initializes this objcet accordingly 

131 

132 :param arg: The string to be parsed 

133 :raises ValueError: if arg is invalid 

134 

135 Example: 

136 If the input is ``arg = 'ENVVAR1=val ENVVAR2= cmd --arg1 --arg2'`` 

137 this function sets 

138 .. code-block:: 

139 

140 self.env = {'ENVVAR1' : 'val', 'ENVVAR2' : ''} 

141 self.cmd = ['cmd', '--arg1', '--arg2'] 

142 ''' 

143 if not arg: 

144 raise ValueError('cmd is empty') 

145 

146 cmd = shlex.split(arg) 

147 

148 self.env = {} 

149 for i in range(len(cmd)): 

150 if '=' in cmd[i]: 

151 var, val = cmd[i].split('=', 1) 

152 self.env[var] = val 

153 else: 

154 self.cmd = cmd[i:] 

155 if not self.env: 

156 self.env = None 

157 return 

158 

159 raise ValueError('cmd consists of environment variables only, there is no command to be executed') 

160 

161 # ------- compare ------- 

162 

163 def __eq__(self, other: typing.Any) -> bool: 

164 if isinstance(other, SubprocessCommand): 

165 return self.cmd == other.cmd and self.env == other.env 

166 return NotImplemented 

167 

168 # ------- custom methods ------- 

169 

170 def replace(self, wildcard: str, replacement: str) -> 'SubprocessCommand': 

171 return SubprocessCommand([replacement if word == wildcard else word for word in self.cmd], env=self.env) 

172 

173 def run(self, *, context: 'TYPE_CONTEXT|None') -> 'CompletedProcess[bytes]|None': 

174 ''' 

175 Runs this command and returns when the command is finished. 

176 

177 :param context: returns a context manager which can be used to stop and start an urwid screen. 

178 It takes the command to be executed so that it can log the command 

179 and it returns the command to be executed so that it can modify the command, 

180 e.g. processing and intercepting some environment variables. 

181 

182 :return: The completed process 

183 :raises OSError: e.g. if the program was not found 

184 :raises CalledProcessError: if the called program failed 

185 ''' 

186 if self.cmd[0] in self.python_callbacks: 

187 self.python_callbacks[self.cmd[0]](self, context) 

188 return None 

189 

190 if context is None: 

191 return run_and_pipe(self.cmd, env=self._add_os_environ(self.env)) 

192 

193 with context(self) as command: 

194 return run_and_pipe(command.cmd, env=self._add_os_environ(command.env)) 

195 

196 @staticmethod 

197 def _add_os_environ(env: 'Mapping[str, str]|None') -> 'Mapping[str, str]|None': 

198 if env is None: 

199 return env 

200 return dict(os.environ, **env) 

201 

202 def is_installed(self) -> bool: 

203 return self.cmd[0] in self.python_callbacks or bool(shutil.which(self.cmd[0])) 

204 

205 # ------- to str ------- 

206 

207 def __str__(self) -> str: 

208 if self.env: 

209 env = ' '.join('%s=%s' % (var, shlex.quote(val)) for var, val in self.env.items()) 

210 env += ' ' 

211 else: 

212 env = '' 

213 return env + ' '.join(shlex.quote(word) for word in self.cmd) 

214 

215 def __repr__(self) -> str: 

216 return '%s(%r, env=%r)' % (type(self).__name__, self.cmd, self.env) 

217 

218class SubprocessCommandWithAlternatives: 

219 

220 type_name = 'command with alternatives' 

221 help = ''' 

222 One or more commands separated by ||. 

223 The first command where the program is installed is executed. The other commands are ignored. 

224 

225 If the command name starts with a '$' it is interpreted as an environment variable containing the name of the program to be executed. 

226 This is useful to make use of environment variables like EDITOR. 

227 If the environment variable is not set the program is considered to not be installed and the next command is tried instead. 

228 

229 The command is executed without a shell so redirection or wildcard expansion is not possible. 

230 Setting environment variables and piping like in a POSIX shell, however, are implemented in python and should work platform independently. 

231 If you need a shell write the command to a file, insert an appropriate shebang line, make the file executable and set this value to the file. 

232 ''' 

233 

234 SEP = '||' 

235 

236 #: The wild card used by :meth:`~confattr.types.SubprocessCommandWithAlternatives.editor` for the file name 

237 WC_FILE_NAME = 'fn' 

238 

239 @classmethod 

240 def editor(cls, *, visual: bool) -> 'SubprocessCommandWithAlternatives': 

241 ''' 

242 Create a command to open a file in a text editor. 

243 The ``EDITOR``/``VISUAL`` environment variables are respected on non-Windows systems. 

244 ''' 

245 apps: 'Sequence[str]' 

246 if platform.system() == 'Windows': 

247 apps = ('notepad',) 

248 elif visual: 

249 apps = ('$VISUAL', 'xdg-open',) 

250 else: 

251 apps = ('$EDITOR', 'rifle', 'vim', 'nano', 'vi') 

252 cmds = [[app, cls.WC_FILE_NAME] for app in apps] 

253 return cls(cmds) 

254 

255 

256 def get_preferred_command(self) -> SubprocessCommand: 

257 for cmd in self.commands: 

258 if cmd.cmd[0].startswith('$'): 

259 env = cmd.cmd[0][1:] 

260 exe = os.environ.get(env, None) 

261 if exe: 

262 return SubprocessCommand([exe] + list(cmd.cmd[1:])) 

263 elif cmd.is_installed(): 

264 return cmd 

265 

266 raise FileNotFoundError('none of the commands is installed: %s' % self) 

267 

268 

269 def __init__(self, commands: 'Sequence[SubprocessCommand|Sequence[str]|str]|str') -> None: 

270 if isinstance(commands, str): 

271 self.commands = [SubprocessCommand(cmd) for cmd in commands.split(self.SEP)] 

272 else: 

273 self.commands = [SubprocessCommand(cmd) for cmd in commands] 

274 

275 

276 def __str__(self) -> str: 

277 return self.SEP.join(str(cmd) for cmd in self.commands) 

278 

279 def __repr__(self) -> str: 

280 return '%s(%s)' % (type(self).__name__, self.commands) 

281 

282 

283 def replace(self, wildcard: str, replacement: str) -> SubprocessCommand: 

284 return self.get_preferred_command().replace(wildcard, replacement) 

285 

286 def run(self, context: 'TYPE_CONTEXT|None' = None) -> 'CompletedProcess[bytes]|None': 

287 return self.get_preferred_command().run(context=context)