1#!./runmodule.sh

2

3 import os

4 import shutil

5 import shlex

6 import platform

7 import re

8 import abc

9 import typing

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

11

12 from .subprocess_pipe import run_and_pipe, CompletedProcess

13

14 if 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>`

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

20

21

22 class 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

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

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

62 class 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

83 class 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 class 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

121 class 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

253 class 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)