1# $Id: __init__.py 10353 2026-06-11 21:52:10Z milde $
2# Author: David Goodger <goodger@python.org>
3# Copyright: This module has been placed in the public domain.
4
5"""
6Miscellaneous utilities for the documentation utilities.
7"""
8
9from __future__ import annotations
10
11__docformat__ = 'reStructuredText'
12
13import itertools
14import os
15import os.path
16import re
17import sys
18import unicodedata
19from pathlib import PurePath, Path
20
21from docutils import ApplicationError, DataError
22from docutils import io, nodes
23# for backwards compatibility
24from docutils.nodes import unescape # noqa: F401 (imported but unused)
25
26TYPE_CHECKING = False
27if TYPE_CHECKING:
28 from collections.abc import Callable, Sequence, Iterable
29 from typing import Any, Final, Literal, TextIO
30
31 from docutils.utils._typing import TypeAlias
32
33 from docutils.nodes import StrPath
34 from docutils.frontend import Values
35
36 _ObserverFunc: TypeAlias = Callable[[nodes.system_message], None]
37
38
39class SystemMessage(ApplicationError):
40
41 def __init__(self, system_message: nodes.system_message, level: int,
42 ) -> None:
43 Exception.__init__(self, system_message.astext())
44 self.level = level
45
46
47class SystemMessagePropagation(ApplicationError):
48 pass
49
50
51class Reporter:
52
53 """
54 Info/warning/error reporter and ``system_message`` element generator.
55
56 Five levels of system messages are defined, along with corresponding
57 methods: `debug()`, `info()`, `warning()`, `error()`, and `severe()`.
58
59 There is typically one Reporter object per process. A Reporter object is
60 instantiated with thresholds for reporting (generating warnings) and
61 halting processing (raising exceptions), a switch to turn debug output on
62 or off, and an I/O stream for warnings. These are stored as instance
63 attributes.
64
65 When a system message is generated, its level is compared to the stored
66 thresholds, and a warning or error is generated as appropriate. Debug
67 messages are produced if the stored debug switch is on, independently of
68 other thresholds. Message output is sent to the stored warning stream if
69 not set to ''.
70
71 The Reporter class also employs a modified form of the "Observer" pattern
72 [GoF95]_ to track system messages generated. The `attach_observer` method
73 should be called before parsing, with a bound method or function which
74 accepts system messages. The observer can be removed with
75 `detach_observer`, and another added in its place.
76
77 .. [GoF95] Gamma, Helm, Johnson, Vlissides. *Design Patterns: Elements of
78 Reusable Object-Oriented Software*. Addison-Wesley, Reading, MA, USA,
79 1995.
80 """
81
82 # Reporter.get_source_and_line is patched in by ``RSTState.runtime_init``
83 get_source_and_line: Callable[[int|None], tuple[StrPath|None, int|None]]
84
85 levels: Final[Sequence[str]] = (
86 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'SEVERE')
87 """List of names for system message levels, indexed by level."""
88
89 # system message level constants:
90 DEBUG_LEVEL: Final = 0
91 INFO_LEVEL: Final = 1
92 WARNING_LEVEL: Final = 2
93 ERROR_LEVEL: Final = 3
94 SEVERE_LEVEL: Final = 4
95
96 def __init__(
97 self,
98 source: StrPath,
99 report_level: int,
100 halt_level: int,
101 stream: io.ErrorOutput|TextIO|str|Literal[False]|None = None,
102 debug: bool = False,
103 encoding: str|None = None,
104 error_handler: str = 'backslashreplace',
105 ) -> None:
106 """Low level instantiating. See also `new_reporter().`.
107
108 :Parameters:
109 - `source`: The path to or description of the source data.
110 - `report_level`: The level at or above which warning output will
111 be sent to `stream`.
112 - `halt_level`: The level at or above which `SystemMessage`
113 exceptions will be raised, halting execution.
114 - `debug`: Show debug (level=0) system messages?
115 - `stream`: Where warning output is sent. Can be file-like (has a
116 ``.write`` method), a string (file name, opened for writing),
117 '' (empty string) or `False` (for discarding all stream messages)
118 or `None` (implies `sys.stderr`; default).
119 - `encoding`: The output encoding.
120 - `error_handler`: The error handler for stderr output encoding.
121 """
122
123 self.source = source
124 """The path to or description of the source data."""
125
126 self.error_handler = error_handler
127 """The character encoding error handler."""
128
129 self.debug_flag = debug
130 """Show debug (level=0) system messages?"""
131
132 self.report_level = report_level
133 """The level at or above which warning output will be sent
134 to `self.stream`."""
135
136 self.halt_level = halt_level
137 """The level at or above which `SystemMessage` exceptions
138 will be raised, halting execution."""
139
140 if not isinstance(stream, io.ErrorOutput):
141 stream = io.ErrorOutput(stream, encoding, error_handler)
142
143 self.stream: io.ErrorOutput = stream
144 """Where warning output is sent."""
145
146 self.encoding: str = encoding or getattr(stream, 'encoding', 'ascii')
147 """The output character encoding."""
148
149 self.observers: list[_ObserverFunc] = []
150 """List of bound methods or functions to call with each system_message
151 created."""
152
153 self.max_level: int = -1
154 """The highest level system message generated so far."""
155
156 def attach_observer(self, observer: _ObserverFunc) -> None:
157 """
158 The `observer` parameter is a function or bound method which takes one
159 argument, a `nodes.system_message` instance.
160 """
161 self.observers.append(observer)
162
163 def detach_observer(self, observer: _ObserverFunc) -> None:
164 self.observers.remove(observer)
165
166 def notify_observers(self, message: nodes.system_message) -> None:
167 for observer in self.observers:
168 observer(message)
169
170 def system_message(self,
171 level: int,
172 message: str,
173 *children,
174 **kwargs: Any
175 ) -> nodes.system_message:
176 """
177 Return a system_message object.
178
179 Raise an exception or generate a warning if appropriate.
180 """
181 # `message` can be a `str` or `Exception` instance.
182 if isinstance(message, Exception):
183 message = str(message)
184
185 attributes = kwargs.copy()
186 if 'base_node' in kwargs:
187 source, line = get_source_line(kwargs['base_node'])
188 del attributes['base_node']
189 if source is not None:
190 attributes.setdefault('source', source)
191 if line is not None:
192 attributes.setdefault('line', line)
193 # assert source is not None, "line- but no source-argument"
194 if 'source' not in attributes:
195 # 'line' is absolute line number
196 try:
197 source, line = self.get_source_and_line(attributes.get('line'))
198 except AttributeError:
199 source, line = None, None
200 if source is not None:
201 attributes['source'] = source
202 if line is not None:
203 attributes['line'] = line
204 # assert attributes['line'] is not None, (message, kwargs)
205 # assert attributes['source'] is not None, (message, kwargs)
206 attributes.setdefault('source', self.source)
207
208 msg = nodes.system_message(message, level=level,
209 type=self.levels[level],
210 *children, **attributes)
211 if self.stream and (level >= self.report_level
212 or self.debug_flag and level == self.DEBUG_LEVEL
213 or level >= self.halt_level):
214 self.stream.write(msg.astext() + '\n')
215 if level >= self.halt_level:
216 raise SystemMessage(msg, level)
217 if level > self.DEBUG_LEVEL or self.debug_flag:
218 self.notify_observers(msg)
219 self.max_level = max(level, self.max_level)
220 return msg
221
222 def debug(self, *args, **kwargs: Any) -> nodes.system_message:
223 """
224 Level-0, "DEBUG": an internal reporting issue.
225
226 Typically, there is no effect on the processing. Level-0 system
227 messages are handled separately from the others.
228 """
229 if self.debug_flag:
230 return self.system_message(self.DEBUG_LEVEL, *args, **kwargs)
231
232 def info(self, *args, **kwargs: Any) -> nodes.system_message:
233 """
234 Level-1, "INFO": a minor issue that can be ignored.
235
236 Typically, there is no effect on processing and level-1 system
237 messages are not reported.
238 """
239 return self.system_message(self.INFO_LEVEL, *args, **kwargs)
240
241 def warning(self, *args, **kwargs: Any) -> nodes.system_message:
242 """
243 Level-2, "WARNING": an issue that should be addressed.
244
245 If ignored, there may be unpredictable problems with the output.
246 """
247 return self.system_message(self.WARNING_LEVEL, *args, **kwargs)
248
249 def error(self, *args, **kwargs: Any) -> nodes.system_message:
250 """
251 Level-3, "ERROR": an error that should be addressed.
252
253 If ignored, the output will contain errors.
254 """
255 return self.system_message(self.ERROR_LEVEL, *args, **kwargs)
256
257 def severe(self, *args, **kwargs: Any) -> nodes.system_message:
258 """
259 Level-4, "SEVERE": a severe error that must be addressed.
260
261 If ignored, the output will contain severe errors. Typically level-4
262 system messages are turned into exceptions which halt processing.
263 """
264 return self.system_message(self.SEVERE_LEVEL, *args, **kwargs)
265
266
267class ExtensionOptionError(DataError): pass # NoQA: E701
268class BadOptionError(ExtensionOptionError): pass # NoQA: E701
269class BadOptionDataError(ExtensionOptionError): pass # NoQA: E701
270class DuplicateOptionError(ExtensionOptionError): pass # NoQA: E701
271
272
273def extract_extension_options(field_list: nodes.field_list,
274 options_spec: dict[str, Callable[object], Any],
275 ) -> dict[str, Any]:
276 """
277 Return a dictionary mapping extension option names to converted values.
278
279 :Parameters:
280 - `field_list`: A flat field list without field arguments, where each
281 field body consists of a single paragraph only.
282 - `options_spec`: Dictionary mapping known option names to a
283 conversion function such as `int` or `float`.
284
285 :Exceptions:
286 - `KeyError` for unknown option names.
287 - `ValueError` for invalid option values (raised by the conversion
288 function).
289 - `TypeError` for invalid option value types (raised by conversion
290 function).
291 - `DuplicateOptionError` for duplicate options.
292 - `BadOptionError` for invalid fields.
293 - `BadOptionDataError` for invalid option data (missing name,
294 missing data, bad quotes, etc.).
295 """
296 option_list = extract_options(field_list)
297 return assemble_option_dict(option_list, options_spec)
298
299
300def extract_options(field_list: nodes.field_list
301 ) -> list[tuple[str, str|None]]:
302 """
303 Return a list of option (name, value) pairs from field names & bodies.
304
305 :Parameter:
306 `field_list`: A flat field list, where each field name is a single
307 word and each field body consists of a single paragraph only.
308
309 :Exceptions:
310 - `BadOptionError` for invalid fields.
311 - `BadOptionDataError` for invalid option data (missing name,
312 missing data, bad quotes, etc.).
313 """
314 option_list = []
315 for field in field_list:
316 if len(field[0].astext().split()) != 1:
317 raise BadOptionError(
318 'extension option field name may not contain multiple words')
319 name = str(field[0].astext().lower())
320 body = field[1]
321 if len(body) == 0:
322 data = None
323 elif (len(body) > 1
324 or not isinstance(body[0], nodes.paragraph)
325 or len(body[0]) != 1
326 or not isinstance(body[0][0], nodes.Text)):
327 raise BadOptionDataError(
328 'extension option field body may contain\n'
329 'a single paragraph only (option "%s")' % name)
330 else:
331 data = body[0][0].astext()
332 option_list.append((name, data))
333 return option_list
334
335
336def assemble_option_dict(option_list: list[tuple[str, str|None]],
337 options_spec: dict[str, Callable[object], Any],
338 ) -> dict[str, Any]:
339 """
340 Return a mapping of option names to values.
341
342 :Parameters:
343 - `option_list`: A list of (name, value) pairs (the output of
344 `extract_options()`).
345 - `options_spec`: Dictionary mapping known option names to a
346 conversion function such as `int` or `float`.
347
348 :Exceptions:
349 - `KeyError` for unknown option names.
350 - `DuplicateOptionError` for duplicate options.
351 - `ValueError` for invalid option values (raised by conversion
352 function).
353 - `TypeError` for invalid option value types (raised by conversion
354 function).
355 """
356 options = {}
357 for name, value in option_list:
358 convertor = options_spec[name] # raises KeyError if unknown
359 if convertor is None:
360 raise KeyError(name) # or if explicitly disabled
361 if name in options:
362 raise DuplicateOptionError('duplicate option "%s"' % name)
363 try:
364 options[name] = convertor(value)
365 except (ValueError, TypeError) as detail:
366 raise detail.__class__('(option: "%s"; value: %r)\n%s'
367 % (name, value, ' '.join(detail.args)))
368 return options
369
370
371class NameValueError(DataError): pass
372
373
374def extract_name_value(line):
375 """
376 Return a list of (name, value) from a line of the form "name=value ...".
377
378 :Exception:
379 `NameValueError` for invalid input (missing name, missing data, bad
380 quotes, etc.).
381 """
382 attlist = []
383 while line:
384 equals_index = line.find('=')
385 if equals_index == -1:
386 raise NameValueError('missing "="')
387 attname = line[:equals_index].strip()
388 if equals_index == 0 or not attname:
389 raise NameValueError('missing attribute name before "="')
390 line = line[equals_index+1:].lstrip()
391 if not line:
392 raise NameValueError(f'missing value after "{attname}="')
393 if line[0] in '\'"':
394 endquote_index = line.find(line[0], 1)
395 if endquote_index == -1:
396 raise NameValueError(
397 f'attribute "{attname}" missing end quote ({line[0]})')
398 if (len(line) > endquote_index + 1
399 and line[endquote_index + 1].strip()):
400 raise NameValueError(f'attribute "{attname}" end quote '
401 f'({line[0]}) not followed by whitespace')
402 data = line[1:endquote_index]
403 line = line[endquote_index+1:].lstrip()
404 else:
405 space_index = line.find(' ')
406 if space_index == -1:
407 data = line
408 line = ''
409 else:
410 data = line[:space_index]
411 line = line[space_index+1:].lstrip()
412 attlist.append((attname.lower(), data))
413 return attlist
414
415
416def new_reporter(source_path: StrPath, settings: Values) -> Reporter:
417 """
418 Return a new Reporter object.
419
420 :Parameters:
421 `source` : string
422 The path to or description of the source text of the document.
423 `settings` : optparse.Values object
424 Runtime settings.
425 """
426 reporter = Reporter(
427 source_path, settings.report_level, settings.halt_level,
428 stream=settings.warning_stream, debug=settings.debug,
429 encoding=settings.error_encoding,
430 error_handler=settings.error_encoding_error_handler)
431 return reporter
432
433
434def new_document(source_path: StrPath, settings: Values|None = None
435 ) -> nodes.document:
436 """
437 Return a new empty document object.
438
439 :Parameters:
440 `source_path` : str or pathlib.Path
441 The path to or description of the source text of the document.
442 `settings` : optparse.Values object
443 Runtime settings. If none are provided, a default core set will
444 be used. If you will use the document object with any Docutils
445 components, you must provide their default settings as well.
446
447 For example, if parsing rST, at least provide the rst-parser
448 settings, obtainable as follows:
449
450 Defaults for parser component::
451
452 settings = docutils.frontend.get_default_settings(
453 docutils.parsers.rst.Parser)
454
455 Defaults and configuration file customizations::
456
457 settings = docutils.core.Publisher(
458 parser=docutils.parsers.rst.Parser).get_settings()
459
460 """
461 # Import at top of module would lead to circular dependency!
462 from docutils import frontend
463 if settings is None:
464 settings = frontend.get_default_settings()
465 reporter = new_reporter(source_path, settings)
466 document = nodes.document(settings, reporter, source=source_path)
467 document.note_source(source_path, -1)
468 return document
469
470
471def clean_rcs_keywords(
472 paragraph: nodes.paragraph,
473 keyword_substitutions: Sequence[tuple[re.Pattern[[str], str]]],
474) -> None:
475 if len(paragraph) == 1 and isinstance(paragraph[0], nodes.Text):
476 textnode = paragraph[0]
477 for pattern, substitution in keyword_substitutions:
478 match = pattern.search(textnode)
479 if match:
480 paragraph[0] = nodes.Text(pattern.sub(substitution, textnode))
481 return
482
483
484def relative_path(source: StrPath|None, target: StrPath) -> str:
485 """
486 Build and return a path to `target`, relative to `source` (both files).
487
488 The return value is a `str` suitable to be included in `source`
489 as a reference to `target`.
490
491 :Parameters:
492 `source` : path-like object or None
493 Path of a file in the start directory for the relative path
494 (the file does not need to exist).
495 The value ``None`` is replaced with "<cwd>/dummy_file".
496 `target` : path-like object
497 End point of the returned relative path.
498
499 Differences to `os.path.relpath()`:
500
501 * Inverse argument order.
502 * `source` is assumed to be a FILE in the start directory (add a "dummy"
503 file name to obtain the path relative from a directory)
504 while `os.path.relpath()` expects a DIRECTORY as `start` argument.
505 * Always use Posix path separator ("/") for the output.
506 * Use `os.sep` for parsing the input
507 (changing the value of `os.sep` is ignored by `os.relpath()`).
508 * If there is no common prefix, return the absolute path to `target`.
509
510 Differences to `pathlib.PurePath.relative_to(other)`:
511
512 * pathlib offers an object oriented interface.
513 * `source` expects path to a FILE while `other` expects a DIRECTORY.
514 * `target` defaults to the cwd, no default value for `other`.
515 * `relative_path()` always returns a path (relative or absolute),
516 while `PurePath.relative_to()` raises a ValueError
517 if `target` is not a subpath of `other` (no ".." inserted).
518 """
519 source_parts = os.path.abspath(source or type(target)('dummy_file')
520 ).split(os.sep)
521 target_parts = os.path.abspath(target).split(os.sep)
522 # Check first 2 parts because '/dir'.split('/') == ['', 'dir']:
523 if source_parts[:2] != target_parts[:2]:
524 # Nothing in common between paths.
525 # Return absolute path, using '/' for URLs:
526 return '/'.join(target_parts)
527 source_parts.reverse()
528 target_parts.reverse()
529 while (source_parts and target_parts
530 and source_parts[-1] == target_parts[-1]):
531 # Remove path components in common:
532 source_parts.pop()
533 target_parts.pop()
534 target_parts.reverse()
535 parts = ['..'] * (len(source_parts) - 1) + target_parts
536 return '/'.join(parts)
537
538
539# Return 'stylesheet' or 'stylesheet_path' arguments as list.
540#
541# The original settings arguments are kept unchanged: you can test
542# with e.g. ``if settings.stylesheet_path: ...``.
543#
544# Differences to the depracated `get_stylesheet_reference()`:
545# * return value is a list
546# * no re-writing of the path (and therefore no optional argument)
547# (if required, use ``utils.relative_path(source, target)``
548# in the calling script)
549def get_stylesheet_list(settings: Values) -> list[str]:
550 """Retrieve list of stylesheet references from the settings object."""
551 assert not (settings.stylesheet and settings.stylesheet_path), (
552 'stylesheet and stylesheet_path are mutually exclusive.')
553 stylesheets = settings.stylesheet_path or settings.stylesheet or []
554 # programmatically set default may be string with comma separated list:
555 if not isinstance(stylesheets, list):
556 stylesheets = [path.strip() for path in stylesheets.split(',')]
557 if settings.stylesheet_path:
558 # expand relative paths if found in stylesheet-dirs:
559 stylesheets = [find_file_in_dirs(path, settings.stylesheet_dirs)
560 for path in stylesheets]
561 return stylesheets
562
563
564def find_file_in_dirs(path: StrPath, dirs: Iterable[StrPath]) -> str:
565 """
566 Search for `path` in the list of directories `dirs`.
567
568 Return the first expansion that matches an existing file.
569 """
570 path = Path(path)
571 if path.is_absolute():
572 return path.as_posix()
573 for d in dirs:
574 f = Path(d).expanduser() / path
575 if f.exists():
576 return f.as_posix()
577 return path.as_posix()
578
579
580def get_trim_footnote_ref_space(settings: Values) -> bool:
581 """
582 Return whether or not to trim footnote space.
583
584 If trim_footnote_reference_space is not None, return it.
585
586 If trim_footnote_reference_space is None, return False unless the
587 footnote reference style is 'superscript'.
588 """
589 if settings.setdefault('trim_footnote_reference_space', None) is None:
590 return getattr(settings, 'footnote_references', None) == 'superscript'
591 else:
592 return settings.trim_footnote_reference_space
593
594
595def get_source_line(node) -> tuple[StrPath|None, int|None]:
596 """
597 Return the "source" and "line" attributes from the `node` given or from
598 its closest ancestor.
599 """
600 while node:
601 if node.source or node.line:
602 return node.source, node.line
603 node = node.parent
604 return None, None
605
606
607def escape2null(text: str) -> str:
608 """Return a string with escape-backslashes converted to nulls."""
609 parts = []
610 start = 0
611 while True:
612 bs_index = text.find('\\', start)
613 if bs_index == -1:
614 parts.append(text[start:])
615 return ''.join(parts)
616 parts.extend((text[start:bs_index],
617 '\x00' + text[bs_index + 1:bs_index + 2]))
618 start = bs_index + 2 # skip character after escape
619
620
621def split_escaped_whitespace(text: str) -> list[str]:
622 """
623 Split `text` on escaped whitespace (null+space or null+newline).
624 Return a list of strings.
625 """
626 strings = text.split('\x00 ')
627 strings = [string.split('\x00\n') for string in strings]
628 # flatten list of lists of strings to list of strings:
629 return list(itertools.chain(*strings))
630
631
632def strip_combining_chars(text: str) -> str:
633 return ''.join(c for c in text if not unicodedata.combining(c))
634
635
636def find_combining_chars(text: str) -> list[int]:
637 """Return indices of all combining chars in Unicode string `text`.
638
639 >>> from docutils.utils import find_combining_chars
640 >>> find_combining_chars('A t̆ab̆lĕ')
641 [3, 6, 9]
642
643 """
644 return [i for i, c in enumerate(text) if unicodedata.combining(c)]
645
646
647def column_indices(text: str) -> list[int]:
648 """Indices of Unicode string `text` when skipping combining characters.
649
650 >>> from docutils.utils import column_indices
651 >>> column_indices('A t̆ab̆lĕ')
652 [0, 1, 2, 4, 5, 7, 8]
653
654 """
655 # TODO: account for asian wide chars here instead of using dummy
656 # replacements in the tableparser?
657 return [i for i, c in enumerate(text) if not unicodedata.combining(c)]
658
659
660east_asian_widths = {'W': 2, # Wide
661 'F': 2, # Full-width (wide)
662 'Na': 1, # Narrow
663 'H': 1, # Half-width (narrow)
664 'N': 1, # Neutral (not East Asian, treated as narrow)
665 'A': 1, # Ambiguous (s/b wide in East Asian context,
666 } # narrow otherwise, but that doesn't work)
667"""Mapping of result codes from `unicodedata.east_asian_widt()` to character
668column widths."""
669
670
671def column_width(text: str) -> int:
672 """Return the column width of text.
673
674 Correct ``len(text)`` for wide East Asian and combining Unicode chars.
675 """
676 width = sum(east_asian_widths[unicodedata.east_asian_width(c)]
677 for c in text)
678 # correction for combining chars:
679 width -= len(find_combining_chars(text))
680 return width
681
682
683def uniq(L: list) -> list:
684 r = []
685 for item in L:
686 if item not in r:
687 r.append(item)
688 return r
689
690
691def normalize_language_tag(tag: str) -> list[str]:
692 """Return a list of normalized combinations for a `BCP 47` language tag.
693
694 Example:
695
696 >>> from docutils.utils import normalize_language_tag
697 >>> normalize_language_tag('de_AT-1901')
698 ['de-at-1901', 'de-at', 'de-1901', 'de']
699 >>> normalize_language_tag('de-CH-x_altquot')
700 ['de-ch-x-altquot', 'de-ch', 'de-x-altquot', 'de']
701
702 """
703 # normalize:
704 tag = tag.lower().replace('-', '_')
705 # split (except singletons, which mark the following tag as non-standard):
706 tag = re.sub(r'_([a-zA-Z0-9])_', r'_\1-', tag)
707 subtags = list(tag.split('_'))
708 base_tag = (subtags.pop(0),)
709 # find all combinations of subtags
710 taglist = ['-'.join(base_tag + tags)
711 for n in range(len(subtags), 0, -1)
712 for tags in itertools.combinations(subtags, n)
713 ]
714 taglist += base_tag
715 return taglist
716
717
718def xml_declaration(encoding: str|Literal['unicode']|None = None) -> str:
719 """Return an XML text declaration.
720
721 Include an encoding declaration, if `encoding`
722 is not 'unicode', '', or None.
723 """
724 if encoding and encoding.lower() != 'unicode':
725 encoding_declaration = f' encoding="{encoding}"'
726 else:
727 encoding_declaration = ''
728 return f'<?xml version="1.0"{encoding_declaration}?>\n'
729
730
731class DependencyList:
732
733 """
734 List of dependencies, with file recording support.
735
736 Note that the output file is not automatically closed. You have
737 to explicitly call the close() method.
738 """
739
740 def __init__(self,
741 output_file: Literal['-'] | StrPath | None = None,
742 dependencies: Iterable[StrPath] = ()
743 ) -> None:
744 """
745 Initialize the dependency list, automatically setting the
746 output file to `output_file` (see `set_output()`) and adding
747 all supplied dependencies.
748
749 If output_file is None, no file output is done when calling add().
750 """
751 self.set_output(output_file)
752 self.add(*dependencies)
753
754 def set_output(self, output_file: Literal['-']|StrPath|None) -> None:
755 """
756 Set the output file and clear the list of already added
757 dependencies.
758
759 The specified file is immediately overwritten.
760
761 If `output_file` is '-', the output will be written to stdout.
762 The empty string or None stop output.
763 """
764 if output_file == '-':
765 self.file = sys.stdout
766 elif output_file:
767 self.file = open(output_file, 'w', encoding='utf-8')
768 else:
769 self.file = None
770 self.list = []
771
772 def add(self, *paths: StrPath) -> None:
773 """
774 Append `path` to `self.list` unless it is already there.
775
776 Also append to `self.file` unless it is already there
777 or `self.file is `None`.
778 """
779 for path in paths:
780 if isinstance(path, PurePath):
781 path = path.as_posix() # use '/' as separator
782 if path not in self.list:
783 self.list.append(path)
784 if self.file is not None:
785 self.file.write(path+'\n')
786
787 def close(self) -> None:
788 """
789 Close the output file.
790 """
791 if self.file is not sys.stdout:
792 self.file.close()
793 self.file = None
794
795 def __repr__(self) -> str:
796 try:
797 output_file = self.file.name
798 except AttributeError:
799 output_file = None
800 return '%s(%r, %s)' % (self.__class__.__name__, output_file, self.list)