• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

python-formate / formate / 4830233253

pending completion
4830233253

push

github

Dominic Davis-Foster
Factor out into _find_from_parents function.

14 of 14 new or added lines in 3 files covered. (100.0%)

699 of 727 relevant lines covered (96.15%)

0.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.0
/formate/utils.py
1
#!/usr/bin/env python3
2
#
3
#  utils.py
4
"""
1✔
5
Utility functions.
6
"""
7
#
8
#  Copyright © 2020-2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
9
#
10
#  Permission is hereby granted, free of charge, to any person obtaining a copy
11
#  of this software and associated documentation files (the "Software"), to deal
12
#  in the Software without restriction, including without limitation the rights
13
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
#  copies of the Software, and to permit persons to whom the Software is
15
#  furnished to do so, subject to the following conditions:
16
#
17
#  The above copyright notice and this permission notice shall be included in all
18
#  copies or substantial portions of the Software.
19
#
20
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21
#  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
22
#  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
23
#  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
24
#  DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
25
#  OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
26
#  OR OTHER DEALINGS IN THE SOFTWARE.
27
#
28

29
# stdlib
30
import ast
1✔
31
import os
1✔
32
import pathlib
1✔
33
import re
1✔
34
import sys
1✔
35
from contextlib import contextmanager
1✔
36
from itertools import starmap
1✔
37
from operator import itemgetter
1✔
38
from typing import TYPE_CHECKING, Dict, Iterator, List, Tuple, TypeVar
1✔
39

40
# 3rd party
41
import asttokens
1✔
42
import click
1✔
43
from consolekit import terminal_colours
1✔
44
from consolekit.tracebacks import TracebackHandler
1✔
45
from domdf_python_tools.import_tools import discover_entry_points_by_name
1✔
46
from domdf_python_tools.typing import PathLike
1✔
47

48
# this package
49
from formate.classes import EntryPoint, Hook
1✔
50
from formate.exceptions import HookNotFoundError
1✔
51

52
if TYPE_CHECKING:
53
        # stdlib
54
        from typing import NoReturn
55

56
__all__ = ("import_entry_points", "normalize", "syntaxerror_for_file", "Rewriter", "SyntaxTracebackHandler")
1✔
57

58
_normalize_pattern = re.compile(r"[-_.]+")
1✔
59

60

61
def normalize(name: str) -> str:
1✔
62
        """
63
        Normalize the given name into lowercase, with underscores replaced by hyphens.
64

65
        :param name: The hook name.
66
        """
67

68
        # From PEP 503 (public domain).
69

70
        return _normalize_pattern.sub('-', name).lower()
1✔
71

72

73
def import_entry_points(hooks: List[Hook]) -> Dict[str, EntryPoint]:
1✔
74
        """
75
        Given a list of hooks, import the corresponding entry point and
76
        return a mapping of entry point names to :class:`~.EntryPoint` objects.
77

78
        :param hooks:
79

80
        :raises: :exc:`~.HookNotFoundError` if no entry point can be found for a hook.
81
        """  # noqa: D400
82

83
        hook_names = [hook.name for hook in hooks]
1✔
84

85
        def name_match_func(name: str) -> bool:
1✔
86
                return normalize(name) in hook_names
1✔
87

88
        entry_points = {
1✔
89
                        normalize(k): v
90
                        for k,
91
                        v in {
92
                                        **discover_entry_points_by_name("formate_hooks", name_match_func=name_match_func),
93
                                        **discover_entry_points_by_name("formate-hooks", name_match_func=name_match_func),
94
                                        }.items()
95
                        }
96

97
        for hook in hooks:
1✔
98
                if hook.name not in entry_points:
1✔
99
                        raise HookNotFoundError(hook)
1✔
100

101
        return {e.name: e for e in (starmap(EntryPoint, entry_points.items()))}
1✔
102

103

104
class Rewriter(ast.NodeVisitor):
1✔
105
        """
106
        ABC for rewriting Python source files from an AST and a token stream.
107

108
        .. autosummary-widths:: 8/16
109
        """
110

111
        #: The original source.
112
        source: str
1✔
113

114
        #: The tokenized source.
115
        tokens: asttokens.ASTTokens
1✔
116

117
        replacements: List[Tuple[Tuple[int, int], str]]
1✔
118
        """
119
        The parts of code to replace.
120

121
        Each element comprises a tuple of ``(start char, end char)`` in :attr:`~.source`,
122
        and the new text to insert between these positions.
123
        """
124

125
        def __init__(self, source: str):
1✔
126
                self.source = source
1✔
127
                self.tokens = asttokens.ASTTokens(source, parse=True)
1✔
128
                self.replacements: List[Tuple[Tuple[int, int], str]] = []
1✔
129

130
                assert self.tokens.tree is not None
1✔
131

132
        def rewrite(self) -> str:
1✔
133
                """
134
                Rewrite the source and return the new source.
135

136
                :returns: The reformatted source.
137
                """
138

139
                tree = self.tokens.tree
1✔
140
                assert tree is not None
1✔
141
                self.visit(tree)
1✔
142

143
                reformatted_source = self.source
1✔
144

145
                # Work from the bottom up
146
                for (start, end), replacement in sorted(self.replacements, key=itemgetter(0), reverse=True):
1✔
147
                        source_before = reformatted_source[:start]
1✔
148
                        source_after = reformatted_source[end:]
1✔
149
                        reformatted_source = ''.join([source_before, replacement, source_after])
1✔
150

151
                return reformatted_source
1✔
152

153
        def record_replacement(self, text_range: Tuple[int, int], new_source: str) -> None:
1✔
154
                """
155
                Record a region of text to be replaced.
156

157
                :param text_range: The region of text to be replaced.
158
                :param new_source: The new text for that region.
159
                """
160

161
                self.replacements.append((text_range, new_source))
1✔
162

163

164
class SyntaxTracebackHandler(TracebackHandler):
1✔
165
        """
166
        Subclass of :class:`consolekit.tracebacks.TracebackHandler` to additionally handle :exc:`SyntaxError`.
167
        """
168

169
        def handle_SyntaxError(self, e: SyntaxError) -> "NoReturn":  # noqa: D102
1✔
170
                click.echo(terminal_colours.Fore.RED(f"Fatal: {e.__class__.__name__}: {e}"), err=True)
1✔
171
                sys.exit(126)
1✔
172

173
        def handle_HookNotFoundError(self, e: HookNotFoundError) -> "NoReturn":  # noqa: D102
1✔
174
                click.echo(terminal_colours.Fore.RED(f"Fatal: Hook not found: {e}"), err=True)
×
175
                sys.exit(126)
×
176

177

178
@contextmanager
1✔
179
def syntaxerror_for_file(filename: PathLike) -> Iterator:
1✔
180
        """
181
        Context manager to catch :exc:`SyntaxError` and set its filename to ``filename``
182
        if the current filename is ``<unknown>``.
183

184
        This is useful for syntax errors raised when parsing source into an AST.
185

186
        :param filename:
187

188
        .. clearpage::
189
        """  # noqa: D400
190

191
        try:
1✔
192
                yield
1✔
193
        except SyntaxError as e:
1✔
194
                if e.filename == "<unknown>":
1✔
195
                        e.filename = os.fspath(filename)
1✔
196

197
                raise e
1✔
198

199

200
_P = TypeVar("_P", bound=pathlib.Path)
1✔
201

202

203
def _find_from_parents(path: _P) -> _P:
1✔
204
        """
205
        Try to find ``path`` in the current directory or its parents.
206

207
        If the file can't be found ``path`` is returned.
208
        """
209

210
        if len(path.parts) == 1 and not path.exists():
1✔
211
                for parent in path.cwd().parents:
1✔
212
                        candidate = parent / path
1✔
213
                        if candidate.exists():
1✔
214
                                return candidate
×
215

216
        return path
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc