Skip to content

Commit 0eea35f

Browse files
authored
Merge pull request #13 from mutating/develop
0.0.12
2 parents 60f0819 + b8e991c commit 0eea35f

7 files changed

Lines changed: 1185 additions & 39 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,15 @@ for path in crawler.go(token=TimeoutToken(0.0001)): # Limit the iteration time t
157157

158158
> ↑ Follow these rules to avoid accidentally "baking" an expired token inside a crawler object.
159159
160+
By default, cancellation stops iteration silently — the caller cannot tell it apart from natural exhaustion. Pass `raise_on_cancel=...` to make the crawler raise an exception on cancellation instead:
161+
162+
```python
163+
for path in Crawler('.', token=TimeoutToken(0.0001), raise_on_cancel=True):
164+
print(path)
165+
```
166+
167+
> `raise_on_cancel=True` re-raises the native `cantok` exception; `raise_on_cancel=MyError("...")` raises that exact instance; `raise_on_cancel=MyError` instantiates the class with the cantok message and raises that. Default is `False` (silent).
168+
160169

161170
## Combination
162171

dirstree/crawlers/crawler.py

Lines changed: 85 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,36 @@
11
from pathlib import Path
2-
from typing import Any, Callable, Collection, Dict, Generator, List, Optional, Union
2+
from typing import (
3+
Any,
4+
Callable,
5+
Collection,
6+
Dict,
7+
Generator,
8+
List,
9+
Optional,
10+
Type,
11+
Union,
12+
)
313

414
import pathspec
5-
from cantok import AbstractToken, DefaultToken
15+
from cantok import AbstractToken, CancellationError, DefaultToken
616
from printo import describe_data_object, not_none
717
from sigmatch import PossibleCallMatcher
18+
from sigmatch.errors import SignatureMismatchError, SignatureNotFoundError
819

920
from dirstree.crawlers.abstract import AbstractCrawler
1021
from dirstree.errors import IncompatibleCrawlerOptionsError
1122

1223

24+
def _exception_class_accepts_single_positional(cls: type) -> bool:
25+
try:
26+
PossibleCallMatcher('.').match(cls, raise_exception=True)
27+
except SignatureNotFoundError:
28+
return True
29+
except SignatureMismatchError:
30+
return False
31+
return True
32+
33+
1334
# TODO: add typing tests
1435
class Crawler(AbstractCrawler):
1536
"""
@@ -40,6 +61,7 @@ def __init__( # noqa: PLR0913
4061
token: AbstractToken = DefaultToken(), # noqa: B008
4162
only_files: bool = True,
4263
freeze: bool = False,
64+
raise_on_cancel: Union[bool, BaseException, Type[BaseException]] = False,
4365
) -> None:
4466
if extensions is not None and not only_files:
4567
raise IncompatibleCrawlerOptionsError(
@@ -56,6 +78,19 @@ def __init__( # noqa: PLR0913
5678
if filter is not None:
5779
PossibleCallMatcher('.').match(filter, raise_exception=True)
5880

81+
if not (
82+
isinstance(raise_on_cancel, (bool, BaseException))
83+
or (
84+
isinstance(raise_on_cancel, type)
85+
and issubclass(raise_on_cancel, BaseException)
86+
and _exception_class_accepts_single_positional(raise_on_cancel)
87+
)
88+
):
89+
raise TypeError(
90+
'raise_on_cancel must be a bool, a BaseException instance, '
91+
'or a BaseException subclass whose constructor accepts a single positional argument.',
92+
)
93+
5994
self.paths = paths
6095
self.extensions = extensions
6196
self.exclude = exclude if exclude is not None else []
@@ -64,6 +99,13 @@ def __init__( # noqa: PLR0913
6499
self.only_files = only_files
65100
self.frozen = freeze
66101

102+
if isinstance(raise_on_cancel, bool):
103+
self.raise_on_cancel: bool = raise_on_cancel
104+
self.cancellation_exception: Optional[Union[BaseException, Type[BaseException]]] = None
105+
else:
106+
self.raise_on_cancel = True
107+
self.cancellation_exception = raise_on_cancel
108+
67109
self.addictional_repr_filters: Dict[str, Callable[[Any], bool]] = {}
68110

69111
def __repr__(self) -> str:
@@ -74,9 +116,14 @@ def __repr__(self) -> str:
74116
'token': lambda x: not isinstance(x, DefaultToken),
75117
'only_files': lambda x: x is False,
76118
'freeze': lambda x: x is True,
119+
'raise_on_cancel': lambda x: x is not False,
77120
}
78121
filters.update(self.addictional_repr_filters)
79122

123+
displayed_raise_on_cancel: Union[bool, BaseException, Type[BaseException]] = (
124+
self.cancellation_exception if self.cancellation_exception is not None else self.raise_on_cancel
125+
)
126+
80127
return describe_data_object(
81128
self.__class__.__name__,
82129
self.paths,
@@ -87,41 +134,57 @@ def __repr__(self) -> str:
87134
'token': self.token,
88135
'only_files': self.only_files,
89136
'freeze': self.frozen,
137+
'raise_on_cancel': displayed_raise_on_cancel,
90138
},
91139
filters=filters, # type: ignore[arg-type]
92140
)
93141

142+
def _check_token(self, token: AbstractToken) -> bool:
143+
if token:
144+
return True
145+
if self.raise_on_cancel:
146+
try:
147+
token.check()
148+
except CancellationError as original_exception:
149+
if self.cancellation_exception is None:
150+
raise
151+
if isinstance(self.cancellation_exception, type):
152+
raise self.cancellation_exception(str(original_exception)) from original_exception
153+
raise self.cancellation_exception from original_exception
154+
return False
155+
94156
def _traverse(self, token: AbstractToken) -> Generator[Path, None, None]:
95157
excludes_spec = pathspec.PathSpec.from_lines('gitwildmatch', self.exclude)
96158

97159
for path in self.paths:
160+
if not self._check_token(token):
161+
return
98162
base_path = Path(path)
99-
if token:
100-
for child_path in base_path.rglob('*'):
101-
if (
102-
(not self.only_files or child_path.is_file())
103-
and not (
104-
excludes_spec.match_file(child_path)
105-
or (child_path.is_dir() and excludes_spec.match_file(f'{child_path}/'))
106-
)
107-
and (self.extensions is None or child_path.suffix in self.extensions)
108-
and (self.filter is None or self.filter(child_path))
109-
):
110-
yield child_path
111-
112-
if not token:
113-
break
114-
else:
115-
break
163+
for child_path in base_path.rglob('*'):
164+
if (
165+
(not self.only_files or child_path.is_file())
166+
and not (
167+
excludes_spec.match_file(child_path)
168+
or (child_path.is_dir() and excludes_spec.match_file(f'{child_path}/'))
169+
)
170+
and (self.extensions is None or child_path.suffix in self.extensions)
171+
and (self.filter is None or self.filter(child_path))
172+
):
173+
yield child_path
174+
175+
if not self._check_token(token):
176+
return
177+
self._check_token(token)
116178

117179
def go(self, token: AbstractToken = DefaultToken()) -> Generator[Path, None, None]: # noqa: B008
118-
token = token + self.token
180+
instance_token = self.token
181+
token = token + instance_token
119182

120183
if self.frozen:
121184
snapshot = list(self._traverse(token))
122185
for path in snapshot:
123-
if not token:
124-
break
186+
if not self._check_token(token):
187+
return
125188
yield path
126189
else:
127190
yield from self._traverse(token)

dirstree/crawlers/python_crawler.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from pathlib import Path
2-
from typing import Callable, List, Optional, Union
2+
from typing import Callable, List, Optional, Type, Union
33

44
from cantok import AbstractToken, DefaultToken
55

@@ -14,9 +14,10 @@ def __init__(
1414
filter: Optional[Callable[[Path], bool]] = None, # noqa: A002
1515
token: AbstractToken = DefaultToken(), # noqa: B008
1616
freeze: bool = False,
17+
raise_on_cancel: Union[bool, BaseException, Type[BaseException]] = False,
1718
) -> None:
1819
super().__init__(
19-
*paths, extensions=('.py',), exclude=exclude, filter=filter, token=token, freeze=freeze,
20+
*paths, extensions=('.py',), exclude=exclude, filter=filter, token=token, freeze=freeze, raise_on_cancel=raise_on_cancel,
2021
)
2122
self.addictional_repr_filters = {
2223
'extensions': lambda x: False, # noqa: ARG005

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "dirstree"
7-
version = "0.0.11"
7+
version = "0.0.12"
88
authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }]
99
description = 'Another library for iterating through the contents of a directory'
1010
readme = "README.md"

tests/conftest.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,58 @@
11
import os
22
from pathlib import Path
3+
from typing import Tuple, Type, Union
34

45
import pytest
6+
from cantok import CancellationError, SimpleToken
7+
8+
9+
def extract_cancellation_message(token_class: Type[SimpleToken]) -> str:
10+
"""Instantiate a cancelled token of ``token_class`` and return the message that
11+
cantok raises from ``.check()``.
12+
13+
``token_class`` must accept ``cancelled=True`` as a constructor argument (the
14+
canonical example is ``SimpleToken``). The trailing ``raise AssertionError``
15+
is a contract assertion: cantok's API guarantees that ``.check()`` on a
16+
cancelled token raises, so the only way we ever reach it is a cantok-side
17+
contract violation.
18+
"""
19+
try:
20+
token_class(cancelled=True).check()
21+
except CancellationError as original_exception:
22+
return str(original_exception)
23+
raise AssertionError('cantok contract violation: .check() on a cancelled token must raise')
24+
25+
26+
def predict_raised_exception(
27+
raise_on_cancel_value: Union[bool, BaseException, Type[BaseException]],
28+
native_message: str,
29+
) -> Tuple[Type[BaseException], str]:
30+
"""Return ``(expected_type, expected_message)`` for a given ``raise_on_cancel`` form.
31+
32+
Maps each of the three truthy flag forms to what the iteration is expected
33+
to raise when the cancellation fires:
34+
35+
- ``True`` → cantok ``CancellationError`` with cantok's native message;
36+
- instance → that instance's type with its own message (``str(instance)``);
37+
- class → that class with cantok's native message (the constructor is
38+
called with ``str(original_exception)``).
39+
40+
``False`` accepts the type only for caller convenience (parametrize lists
41+
often share a wider ``bool`` type), but passing it is a programming error:
42+
the function is meaningful only when a raise is expected, so ``False`` hits
43+
the trailing assertion. ``native_message`` is the message cantok would emit
44+
for the token used in the test (typically
45+
``extract_cancellation_message(SimpleToken)`` for pre-cancelled SimpleToken
46+
scenarios, or extracted inline from the actual token for mid-iteration
47+
scenarios).
48+
"""
49+
if raise_on_cancel_value is True:
50+
return CancellationError, native_message
51+
if isinstance(raise_on_cancel_value, type):
52+
return raise_on_cancel_value, native_message
53+
if isinstance(raise_on_cancel_value, BaseException):
54+
return type(raise_on_cancel_value), str(raise_on_cancel_value)
55+
raise AssertionError(f'predict_raised_exception is not meaningful for {raise_on_cancel_value!r}')
556

657

758
@pytest.fixture(params=[str, Path])

0 commit comments

Comments
 (0)