Coverage for .tox/cov/lib/python3.11/site-packages/confattr/types.py: 100%
155 statements
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-03 07:55 +0100
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-03 07:55 +0100
1#!./runmodule.sh
3import os
4import shutil
5import shlex
6import platform
7import re
8import abc
9import typing
10from collections.abc import Sequence, Callable, Mapping, MutableMapping
12from .subprocess_pipe import run_and_pipe, CompletedProcess
14if typing.TYPE_CHECKING:
15 from typing_extensions import Self
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'
22class AbstractType(abc.ABC):
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 '''
30 #: If this attribute is present it is used instead of the class name in the config file.
31 type_name: str
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
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.
42 .. automethod:: __str__
43 '''
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 '''
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 '''
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]
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 '''
71 def __init__(self, pattern: str) -> None:
72 self._compiled_pattern: 're.Pattern[str]' = re.compile(pattern)
74 def __getattr__(self, attr: str) -> object:
75 return getattr(self._compiled_pattern, attr)
77 def __str__(self) -> str:
78 return self._compiled_pattern.pattern
80 def __repr__(self) -> str:
81 return f'{type(self).__name__}({self._compiled_pattern.pattern!r})'
83class CaseInsensitiveRegex(Regex): # type: ignore [valid-type,misc] # mypy complains about inheriting from Regex because I have declared it as callable
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 '''
91 def __init__(self, pattern: str) -> None:
92 self._compiled_pattern = re.compile(pattern, flags=re.I)
94class OptionalExistingDirectory:
96 type_name = 'optional existing directory'
97 help = 'The path to an existing directory or an empty str to use a default path'
99 def __init__(self, value: str) -> None:
100 self.raw = value
101 if not self.raw:
102 self.expanded = ""
103 return
105 self.expanded = os.path.expanduser(self.raw)
106 if not os.path.isdir(self.expanded):
107 raise ValueError("No such directory: %r" % value)
109 def __bool__(self) -> bool:
110 return bool(self.raw)
112 def __str__(self) -> str:
113 return self.raw
115 def __repr__(self) -> str:
116 return '%s(%r)' % (type(self).__name__, self.raw)
118 def expand(self) -> str:
119 return self.expanded
121class SubprocessCommand:
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 '''
131 python_callbacks: 'MutableMapping[str, Callable[[SubprocessCommand, TYPE_CONTEXT], None]]' = {}
133 @classmethod
134 def register_python_callback(cls, name: str, func: 'Callable[[SubprocessCommand, TYPE_CONTEXT], None]') -> None:
135 cls.python_callbacks[name] = func
137 @classmethod
138 def unregister_python_callback(cls, name: str) -> None:
139 del cls.python_callbacks[name]
141 @classmethod
142 def has_python_callback(cls, name: str) -> bool:
143 return name in cls.python_callbacks
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
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
168 :param arg: The string to be parsed
169 :raises ValueError: if arg is invalid
171 Example:
172 If the input is ``arg = 'ENVVAR1=val ENVVAR2= cmd --arg1 --arg2'``
173 this function sets
174 .. code-block::
176 self.env = {'ENVVAR1' : 'val', 'ENVVAR2' : ''}
177 self.cmd = ['cmd', '--arg1', '--arg2']
178 '''
179 if not arg:
180 raise ValueError('cmd is empty')
182 cmd = shlex.split(arg)
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
195 raise ValueError('cmd consists of environment variables only, there is no command to be executed')
197 # ------- compare -------
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
204 # ------- custom methods -------
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)
209 def run(self, *, context: 'TYPE_CONTEXT|None') -> 'CompletedProcess[bytes]|None':
210 '''
211 Runs this command and returns when the command is finished.
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
225 if context is None:
226 return run_and_pipe(self.cmd, env=self._add_os_environ(self.env))
228 with context(self) as command:
229 return run_and_pipe(command.cmd, env=self._add_os_environ(command.env))
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)
237 def is_installed(self) -> bool:
238 return self.cmd[0] in self.python_callbacks or bool(shutil.which(self.cmd[0]))
240 # ------- to str -------
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)
250 def __repr__(self) -> str:
251 return '%s(%r, env=%r)' % (type(self).__name__, self.cmd, self.env)
253class SubprocessCommandWithAlternatives:
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.
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.
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 '''
269 SEP = '||'
271 #: The wild card used by :meth:`~confattr.types.SubprocessCommandWithAlternatives.editor` for the file name
272 WC_FILE_NAME = 'fn'
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)
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
301 raise FileNotFoundError('none of the commands is installed: %s' % self)
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]
311 def __str__(self) -> str:
312 return self.SEP.join(str(cmd) for cmd in self.commands)
314 def __repr__(self) -> str:
315 return '%s(%s)' % (type(self).__name__, self.commands)
318 def replace(self, wildcard: str, replacement: str) -> SubprocessCommand:
319 return self.get_preferred_command().replace(wildcard, replacement)
321 def run(self, context: 'TYPE_CONTEXT|None' = None) -> 'CompletedProcess[bytes]|None':
322 return self.get_preferred_command().run(context=context)