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

136 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-23 10:38 +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 

14if typing.TYPE_CHECKING: 

15 from typing_extensions import Self 

16 

17 

18#: 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>` 

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

20 

21 

22class AbstractType(abc.ABC): 

23 

24 ''' 

25 This class is merely for documentation purposes. 

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

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

28 ''' 

29 

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

31 type_name: str 

32 

33 #: 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>`. 

34 help: str 

35 

36 @abc.abstractmethod 

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

38 ''' 

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

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

41 

42 .. automethod:: __str__ 

43 ''' 

44 

45 @abc.abstractmethod 

46 def __str__(self) -> str: 

47 ''' 

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

49 ''' 

50 

51 @classmethod 

52 @abc.abstractmethod 

53 def get_instances(cls) -> 'Sequence[Self]': 

54 ''' 

55 If this method is implemented it returns a sequence of the allowed values. 

56 ''' 

57 

58 

59 

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

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

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

63 

64 type_name = 'regular expression' 

65 help = ''' 

66 A regular expression in python syntax. 

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

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

69 ''' 

70 

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

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

73 

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

75 return getattr(self._compiled_pattern, attr) 

76 

77 def __str__(self) -> str: 

78 return self._compiled_pattern.pattern 

79 

80 def __repr__(self) -> str: 

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

82 

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

84 

85 help = ''' 

86 A case insensitive regular expression in python syntax. 

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

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

89 ''' 

90 

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

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

93 

94 

95class SubprocessCommand: 

96 

97 type_name = 'command' 

98 help = '''\ 

99 A command to be executed as a subprocess. 

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

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

102 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. 

103 ''' 

104 

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

106 

107 @classmethod 

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

109 cls.python_callbacks[name] = func 

110 

111 @classmethod 

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

113 del cls.python_callbacks[name] 

114 

115 @classmethod 

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

117 return name in cls.python_callbacks 

118 

119 

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

121 self.cmd: 'Sequence[str]' 

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

123 if isinstance(arg, str): 

124 assert env is None 

125 self.parse_str(arg) 

126 elif isinstance(arg, SubprocessCommand): 

127 self.cmd = list(arg.cmd) 

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

129 if env: 

130 if self.env: 

131 self.env.update(env) 

132 else: 

133 self.env = env 

134 else: 

135 self.cmd = list(arg) 

136 self.env = env 

137 

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

139 ''' 

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

141 

142 :param arg: The string to be parsed 

143 :raises ValueError: if arg is invalid 

144 

145 Example: 

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

147 this function sets 

148 .. code-block:: 

149 

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

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

152 ''' 

153 if not arg: 

154 raise ValueError('cmd is empty') 

155 

156 cmd = shlex.split(arg) 

157 

158 self.env = {} 

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

160 if '=' in cmd[i]: 

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

162 self.env[var] = val 

163 else: 

164 self.cmd = cmd[i:] 

165 if not self.env: 

166 self.env = None 

167 return 

168 

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

170 

171 # ------- compare ------- 

172 

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

174 if isinstance(other, SubprocessCommand): 

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

176 return NotImplemented 

177 

178 # ------- custom methods ------- 

179 

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

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

182 

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

184 ''' 

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

186 

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

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

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

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

191 

192 :return: The completed process 

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

194 :raises CalledProcessError: if the called program failed 

195 ''' 

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

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

198 return None 

199 

200 if context is None: 

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

202 

203 with context(self) as command: 

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

205 

206 @staticmethod 

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

208 if env is None: 

209 return env 

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

211 

212 def is_installed(self) -> bool: 

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

214 

215 # ------- to str ------- 

216 

217 def __str__(self) -> str: 

218 if self.env: 

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

220 env += ' ' 

221 else: 

222 env = '' 

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

224 

225 def __repr__(self) -> str: 

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

227 

228class SubprocessCommandWithAlternatives: 

229 

230 type_name = 'command with alternatives' 

231 help = ''' 

232 One or more commands separated by ||. 

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

234 

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

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

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

238 

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

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

241 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. 

242 ''' 

243 

244 SEP = '||' 

245 

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

247 WC_FILE_NAME = 'fn' 

248 

249 @classmethod 

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

251 ''' 

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

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

254 ''' 

255 apps: 'Sequence[str]' 

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

257 apps = ('notepad',) 

258 elif visual: 

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

260 else: 

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

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

263 return cls(cmds) 

264 

265 

266 def get_preferred_command(self) -> SubprocessCommand: 

267 for cmd in self.commands: 

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

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

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

271 if exe: 

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

273 elif cmd.is_installed(): 

274 return cmd 

275 

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

277 

278 

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

280 if isinstance(commands, str): 

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

282 else: 

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

284 

285 

286 def __str__(self) -> str: 

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

288 

289 def __repr__(self) -> str: 

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

291 

292 

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

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

295 

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

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