Skip to content

Commit ffed0d3

Browse files
committed
fixes #787
1 parent 5ec06db commit ffed0d3

8 files changed

Lines changed: 248 additions & 161 deletions

File tree

fastcore/_modidx.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,7 @@
598598
'fastcore.test.ExceptionExpected.__enter__': ('test.html#exceptionexpected.__enter__', 'fastcore/test.py'),
599599
'fastcore.test.ExceptionExpected.__exit__': ('test.html#exceptionexpected.__exit__', 'fastcore/test.py'),
600600
'fastcore.test.ExceptionExpected.__init__': ('test.html#exceptionexpected.__init__', 'fastcore/test.py'),
601+
'fastcore.test.expect_fail': ('test.html#expect_fail', 'fastcore/test.py'),
601602
'fastcore.test.is_close': ('test.html#is_close', 'fastcore/test.py'),
602603
'fastcore.test.nequals': ('test.html#nequals', 'fastcore/test.py'),
603604
'fastcore.test.test': ('test.html#test', 'fastcore/test.py'),
@@ -748,6 +749,7 @@
748749
'fastcore.xtras.atomic_save': ('xtras.html#atomic_save', 'fastcore/xtras.py'),
749750
'fastcore.xtras.autostart': ('xtras.html#autostart', 'fastcore/xtras.py'),
750751
'fastcore.xtras.bunzip': ('xtras.html#bunzip', 'fastcore/xtras.py'),
752+
'fastcore.xtras.clean_cli_output': ('xtras.html#clean_cli_output', 'fastcore/xtras.py'),
751753
'fastcore.xtras.console_help': ('xtras.html#console_help', 'fastcore/xtras.py'),
752754
'fastcore.xtras.dataclass_src': ('xtras.html#dataclass_src', 'fastcore/xtras.py'),
753755
'fastcore.xtras.detect_mime': ('xtras.html#detect_mime', 'fastcore/xtras.py'),

fastcore/ansi.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@
1414

1515
def strip_terminal_queries(text):
1616
# Remove OSC sequences (like background color queries)
17-
text = re.sub(r'\x1b\][^\\x07]*\x07', '', text)
17+
text = re.sub('\x1b\\][^\x07]*\x07', '', text)
1818
# Remove DSR sequences (device status reports)
1919
return re.sub(r'\x1b\[[0-9]*n', '', text)
2020

2121

22-
def strip_ansi(source):
22+
def strip_ansi(source, term_queries:bool=False):
2323
"Remove ANSI escape codes from text."
24+
if term_queries: source = strip_terminal_queries(source)
2425
return _ANSI_RE.sub("", source)
2526

2627

fastcore/basics.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,11 +1098,12 @@ def __init__(self, f): self.f = f
10981098
def __get__(self, _, f_cls): return MethodType(self.f, f_cls)
10991099

11001100
# %% ../nbs/01_basics.ipynb #3f2733ef
1101-
def patch_to(cls, as_prop=False, cls_method=False, set_prop=False, nm=None, glb=None):
1101+
def patch_to(cls, as_prop=False, cls_method=False, set_prop=False, nm=None, glb=None, once:bool=False):
11021102
"Decorator: add `f` to `cls`"
11031103
if glb is None: glb = sys._getframe(1).f_globals
11041104
def _inner(f):
11051105
_nm = nm or f.__name__
1106+
onm = '_orig_'+_nm
11061107
for c_ in tuplify(cls):
11071108
nf = copy_func(f)
11081109
for o in functools.WRAPPER_ASSIGNMENTS: setattr(nf, o, getattr(f,o))
@@ -1112,17 +1113,17 @@ def _inner(f):
11121113
elif set_prop: attr = getattr(c_, _nm).setter(nf)
11131114
elif as_prop: attr = property(nf)
11141115
else:
1115-
onm = '_orig_'+_nm
1116-
if hasattr(c_, _nm) and not hasattr(c_, onm): setattr(c_, onm, getattr(c_, _nm))
1116+
if hasattr(c_, onm) and once: break
1117+
if hasattr(c_, _nm): setattr(c_, onm, getattr(c_, _nm))
11171118
attr = nf
11181119
setattr(c_, _nm, attr)
11191120
return glb.get(_nm, builtins.__dict__.get(_nm, None))
11201121
return _inner
11211122

11221123
# %% ../nbs/01_basics.ipynb #8faf7b86
1123-
def patch(f=None, *, as_prop=False, cls_method=False, set_prop=False, nm=None):
1124+
def patch(f=None, *, as_prop=False, cls_method=False, set_prop=False, nm=None, once:bool=False):
11241125
"Decorator: add `f` to the first parameter's class (based on f's type annotations)"
1125-
if f is None: return partial(patch, as_prop=as_prop, cls_method=cls_method, set_prop=set_prop, nm=nm)
1126+
if f is None: return partial(patch, as_prop=as_prop, cls_method=cls_method, set_prop=set_prop, nm=nm, once=once)
11261127
ann,glb,loc = get_annotations_ex(f)
11271128
if cls_method:
11281129
if 'cls' not in ann: raise TypeError(f"@patch with cls_method=True requires 'cls' to have a type annotation")
@@ -1131,7 +1132,7 @@ def patch(f=None, *, as_prop=False, cls_method=False, set_prop=False, nm=None):
11311132
if not ann: raise TypeError(f"@patch requires the first parameter of `{f.__name__}` to have a type annotation")
11321133
cls = next(iter(ann.values()))
11331134
cls = union2tuple(eval_type(cls, glb, loc))
1134-
return patch_to(cls, as_prop=as_prop, cls_method=cls_method, set_prop=set_prop, nm=nm, glb=sys._getframe(1).f_globals)(f)
1135+
return patch_to(cls, as_prop=as_prop, cls_method=cls_method, set_prop=set_prop, nm=nm, once=once, glb=sys._getframe(1).f_globals)(f)
11351136

11361137
# %% ../nbs/01_basics.ipynb #d1732261
11371138
def compile_re(pat):

fastcore/test.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_test.ipynb.
44

55
# %% auto #0
6-
__all__ = ['TEST_IMAGE', 'TEST_IMAGE_BW', 'exception', 'test_fail', 'test', 'nequals', 'test_eq', 'test_eq_type', 'test_ne',
7-
'is_close', 'test_close', 'test_is', 'test_shuffled', 'test_stdout', 'test_warns', 'test_fig_exists',
8-
'ExceptionExpected']
6+
__all__ = ['TEST_IMAGE', 'TEST_IMAGE_BW', 'exception', 'test_fail', 'expect_fail', 'test', 'nequals', 'test_eq', 'test_eq_type',
7+
'test_ne', 'is_close', 'test_close', 'test_is', 'test_shuffled', 'test_stdout', 'test_warns',
8+
'test_fig_exists', 'ExceptionExpected']
99

1010
# %% ../nbs/00_test.ipynb #76d8df3a
1111
from .imports import *
1212
from collections import Counter
13-
from contextlib import redirect_stdout
13+
from contextlib import redirect_stdout,contextmanager
1414

1515
# %% ../nbs/00_test.ipynb #2927d8a7
1616
def test_fail(f, msg='', contains='', exc=Exception, args=None, kwargs=None):
@@ -23,6 +23,16 @@ def test_fail(f, msg='', contains='', exc=Exception, args=None, kwargs=None):
2323
except Exception as e: assert False, f"Expected {exc.__name__} but got {type(e).__name__}: {e}. {msg}"
2424
assert False, f"Expected {exc.__name__} but none raised. {msg}"
2525

26+
# %% ../nbs/00_test.ipynb #8197c1de
27+
@contextmanager
28+
def expect_fail(exc=Exception, contains=''):
29+
"Context manager that fails unless body raises `exc` optionally containing `contains`"
30+
try: yield
31+
except exc as e:
32+
assert not contains or contains in str(e)
33+
return
34+
assert False, f"Expected {exc.__name__} but none raised"
35+
2636
# %% ../nbs/00_test.ipynb #a61150ba
2737
def test(a, b, cmp, cname=None):
2838
"`assert` that `cmp(a,b)`; display inputs and `cname or cmp.__name__` if it fails"

fastcore/xtras.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,21 @@
1010
'detect_mime', 'bunzip', 'loads', 'loads_multi', 'dumps', 'untar_dir', 'repo_details', 'shell', 'ssh',
1111
'rsync_multi', 'run', 'open_file', 'save_pickle', 'load_pickle', 'parse_env', 'expand_wildcards',
1212
'atomic_save', 'dict2obj', 'obj2dict', 'repr_dict', 'is_listy', 'mapped', 'IterLen', 'ReindexCollection',
13-
'SaveReturn', 'trim_wraps', 'save_iter', 'asave_iter', 'unqid', 'rtoken_hex', 'friendly_name',
14-
'n_friendly_names', 'exec_eval', 'get_source_link', 'sparkline', 'modify_exception', 'round_multiple',
15-
'set_num_threads', 'join_path_file', 'autostart', 'EventTimer', 'stringfmt_names', 'PartialFormatter',
16-
'partial_format', 'truncstr', 'utc2local', 'local2utc', 'trace', 'modified_env', 'ContextManagers',
17-
'shufflish', 'console_help', 'hl_md', 'type2str', 'dataclass_src', 'Unset', 'nullable_dc', 'make_nullable',
18-
'flexiclass', 'asdict', 'vars_pub', 'is_typeddict', 'is_namedtuple', 'CachedIter', 'CachedAwaitable',
19-
'reawaitable', 'is_async_callable', 'maybe_await', 'noopa', 'flexicache', 'time_policy', 'mtime_policy',
20-
'timed_cache']
13+
'SaveReturn', 'trim_wraps', 'save_iter', 'asave_iter', 'clean_cli_output', 'unqid', 'rtoken_hex',
14+
'friendly_name', 'n_friendly_names', 'exec_eval', 'get_source_link', 'sparkline', 'modify_exception',
15+
'round_multiple', 'set_num_threads', 'join_path_file', 'autostart', 'EventTimer', 'stringfmt_names',
16+
'PartialFormatter', 'partial_format', 'truncstr', 'utc2local', 'local2utc', 'trace', 'modified_env',
17+
'ContextManagers', 'shufflish', 'console_help', 'hl_md', 'type2str', 'dataclass_src', 'Unset', 'nullable_dc',
18+
'make_nullable', 'flexiclass', 'asdict', 'vars_pub', 'is_typeddict', 'is_namedtuple', 'CachedIter',
19+
'CachedAwaitable', 'reawaitable', 'is_async_callable', 'maybe_await', 'noopa', 'flexicache', 'time_policy',
20+
'mtime_policy', 'timed_cache']
2121

2222
# %% ../nbs/03_xtras.ipynb #3401d507
2323
from .imports import *
2424
from .foundation import *
2525
from .basics import *
26+
from .ansi import strip_ansi
27+
2628
from importlib import import_module
2729
from functools import wraps
2830
import string,time,dataclasses
@@ -594,6 +596,16 @@ def asave_iter(g):
594596
def _(*args, **kwargs): return _save_iter(g, *args, **kwargs)
595597
return _
596598

599+
# %% ../nbs/03_xtras.ipynb #45eb5141
600+
def clean_cli_output(txt:str, strip:bool=True):
601+
"Clean CLI output by handling alternate screen, carriage returns, and ANSI escapes"
602+
if '\x1b[?1049h' in txt:
603+
if '\x1b[?1049l' not in txt: return ''
604+
txt = txt.rsplit('\x1b[?1049l', 1)[-1]
605+
txt = txt.replace('\r\n', '\n')
606+
res = '\n'.join(l.rsplit('\r', 1)[-1] for l in txt.split('\n'))
607+
return strip_ansi(res, term_queries=True) if strip else res
608+
597609
# %% ../nbs/03_xtras.ipynb #35368de5
598610
def unqid(seeded=False):
599611
"Generate a unique id suitable for use as a Python identifier"

nbs/00_test.ipynb

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"#| export\n",
2121
"from fastcore.imports import *\n",
2222
"from collections import Counter\n",
23-
"from contextlib import redirect_stdout"
23+
"from contextlib import redirect_stdout,contextmanager"
2424
]
2525
},
2626
{
@@ -116,12 +116,48 @@
116116
"outputs": [],
117117
"source": [
118118
"def _fail_args(a):\n",
119-
" if a == 5:\n",
120-
" raise ValueError\n",
119+
" if a == 5: raise ValueError\n",
121120
"test_fail(_fail_args, args=(5,))\n",
122121
"test_fail(_fail_args, kwargs=dict(a=5))"
123122
]
124123
},
124+
{
125+
"cell_type": "code",
126+
"execution_count": null,
127+
"id": "8197c1de",
128+
"metadata": {},
129+
"outputs": [],
130+
"source": [
131+
"#| export\n",
132+
"@contextmanager\n",
133+
"def expect_fail(exc=Exception, contains=''):\n",
134+
" \"Context manager that fails unless body raises `exc` optionally containing `contains`\"\n",
135+
" try: yield\n",
136+
" except exc as e:\n",
137+
" assert not contains or contains in str(e)\n",
138+
" return\n",
139+
" assert False, f\"Expected {exc.__name__} but none raised\""
140+
]
141+
},
142+
{
143+
"cell_type": "markdown",
144+
"id": "80939ade",
145+
"metadata": {},
146+
"source": [
147+
"`expect_fail` is similar to `test_fail`, but is a context manager:"
148+
]
149+
},
150+
{
151+
"cell_type": "code",
152+
"execution_count": null,
153+
"id": "49a1d0dd",
154+
"metadata": {},
155+
"outputs": [],
156+
"source": [
157+
"with expect_fail(contains=\"foo\"): raise Exception(\"foobar\")\n",
158+
"with expect_fail(ValueError): raise ValueError()"
159+
]
160+
},
125161
{
126162
"cell_type": "code",
127163
"execution_count": null,
@@ -851,13 +887,7 @@
851887
]
852888
}
853889
],
854-
"metadata": {
855-
"kernelspec": {
856-
"display_name": "python3",
857-
"language": "python",
858-
"name": "python3"
859-
}
860-
},
890+
"metadata": {},
861891
"nbformat": 4,
862892
"nbformat_minor": 5
863893
}

0 commit comments

Comments
 (0)