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

154 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2024-05-29 08:17 +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 

94class OptionalExistingDirectory: 

95 

96 type_name = 'optional existing directory' 

97 help = 'The path to an existing directory or an empty str to use a default path.' 

98 

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

100 self.raw = value 

101 if not self.raw: 

102 self.expanded = "" 

103 return 

104 

105 self.expanded = os.path.expanduser(self.raw) 

106 if not os.path.isdir(self.expanded): 

107 raise ValueError("No such directory: %r" % value) 

108 

109 def __bool__(self) -> bool: 

110 return bool(self.raw) 

111 

112 def __str__(self) -> str: 

113 return self.raw 

114 

115 def __repr__(self) -> str: 

116 return '%s(%r)' % (type(self).__name__, self.raw) 

117 

118 def expand(self) -> str: 

119 return self.expanded 

120 

121class SubprocessCommand: 

122 

123 type_name = 'command' 

124 help = '''\ 

125 A command to be executed as a subprocess. 

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

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

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

129 ''' 

130 

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

132 

133 @classmethod 

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

135 cls.python_callbacks[name] = func 

136 

137 @classmethod 

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

139 del cls.python_callbacks[name] 

140 

141 @classmethod 

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

143 return name in cls.python_callbacks 

144 

145 

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

147 self.cmd: 'Sequence[str]' 

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

149 if isinstance(arg, str): 

150 assert env is None 

151 self.parse_str(arg) 

152 elif isinstance(arg, SubprocessCommand): 

153 self.cmd = list(arg.cmd) 

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

155 if env: 

156 if self.env: 

157 self.env.update(env) 

158 else: 

159 self.env = env 

160 else: 

161 self.cmd = list(arg) 

162 self.env = env 

163 

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

165 ''' 

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

167 

168 :param arg: The string to be parsed 

169 :raises ValueError: if arg is invalid 

170 

171 Example: 

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

173 this function sets 

174 .. code-block:: 

175 

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

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

178 ''' 

179 if not arg: 

180 raise ValueError('cmd is empty') 

181 

182 cmd = shlex.split(arg) 

183 

184 self.env = {} 

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

186 if '=' in cmd[i]: 

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

188 self.env[var] = val 

189 else: 

190 self.cmd = cmd[i:] 

191 if not self.env: 

192 self.env = None 

193 return 

194 

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

196 

197 # ------- compare ------- 

198 

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

200 if isinstance(other, SubprocessCommand): 

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

202 return NotImplemented 

203 

204 # ------- custom methods ------- 

205 

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

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

208 

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

210 ''' 

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

212 

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

214 It takes the command to be executed as argument so that it can log the command 

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

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

217 :return: The completed process 

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

219 :raises CalledProcessError: if the called program failed 

220 ''' 

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

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

223 return None 

224 

225 if context is None: 

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

227 

228 with context(self) as command: 

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

230 

231 @staticmethod 

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

233 if env is None: 

234 return env 

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

236 

237 def is_installed(self) -> bool: 

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

239 

240 # ------- to str ------- 

241 

242 def __str__(self) -> str: 

243 if self.env: 

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

245 env += ' ' 

246 else: 

247 env = '' 

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

249 

250 def __repr__(self) -> str: 

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

252 

253class SubprocessCommandWithAlternatives: 

254 

255 type_name = 'command with alternatives' 

256 help = ''' 

257 One or more commands separated by ||. 

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

259 

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

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

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

263 

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

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

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

267 ''' 

268 

269 SEP = '||' 

270 

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

272 WC_FILE_NAME = 'fn' 

273 

274 @classmethod 

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

276 ''' 

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

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

279 ''' 

280 apps: 'Sequence[str]' 

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

282 apps = ('notepad',) 

283 elif visual: 

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

285 else: 

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

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

288 return cls(cmds) 

289 

290 

291 def get_preferred_command(self) -> SubprocessCommand: 

292 for cmd in self.commands: 

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

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

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

296 if exe: 

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

298 elif cmd.is_installed(): 

299 return cmd 

300 

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

302 

303 

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

305 if isinstance(commands, str): 

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

307 else: 

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

309 

310 

311 def __str__(self) -> str: 

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

313 

314 def __repr__(self) -> str: 

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

316 

317 

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

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

320 

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

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