cs.deco 20250306__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cs/deco.py ADDED
@@ -0,0 +1,1107 @@
1
+ #!/usr/bin/python
2
+ #
3
+ # Decorators.
4
+ # - Cameron Simpson <cs@cskk.id.au> 02jul2017
5
+ #
6
+
7
+ r'''
8
+ Assorted function decorators.
9
+ '''
10
+
11
+ from collections import defaultdict
12
+ from contextlib import contextmanager
13
+ from inspect import isgeneratorfunction, ismethod, signature, Parameter
14
+ import sys
15
+ import traceback
16
+ import typing
17
+
18
+ from cs.gimmicks import warning
19
+
20
+ __version__ = '20250306'
21
+
22
+ DISTINFO = {
23
+ 'keywords': ["python2", "python3"],
24
+ 'classifiers': [
25
+ "Programming Language :: Python",
26
+ "Programming Language :: Python :: 3",
27
+ ],
28
+ 'install_requires': ['cs.gimmicks'],
29
+ }
30
+
31
+ def ALL(func):
32
+ ''' Include this function's name in its module's `__all__` list.
33
+
34
+ Example:
35
+
36
+ from cs.deco import ALL
37
+
38
+ __all__ = []
39
+
40
+ def obscure_function(...):
41
+ ...
42
+
43
+ @ALL
44
+ def well_known_function(...):
45
+ ...
46
+ '''
47
+ sys.modules[func.__module__].__all__.append(func.__name__)
48
+ return func
49
+
50
+ def fmtdoc(func):
51
+ ''' Decorator to replace a function's docstring with that string
52
+ formatted against the function's module `__dict__`.
53
+
54
+ This supports simple formatted docstrings:
55
+
56
+ ENVVAR_NAME = 'FUNC_DEFAULT'
57
+
58
+ @fmtdoc
59
+ def func():
60
+ """Do something with os.environ[{ENVVAR_NAME}]."""
61
+ print(os.environ[ENVVAR_NAME])
62
+
63
+ This gives `func` this docstring:
64
+
65
+ Do something with os.environ[FUNC_DEFAULT].
66
+
67
+ *Warning*: this decorator is intended for wiring "constants"
68
+ into docstrings, not for dynamic values. Use for other types
69
+ of values should be considered with trepidation.
70
+ '''
71
+ func.__doc__ = func.__doc__.format(**sys.modules[func.__module__].__dict__)
72
+ return func
73
+
74
+ def decorator(deco):
75
+ ''' Wrapper for decorator functions to support optional arguments.
76
+
77
+ The actual decorator function ends up being called as:
78
+
79
+ mydeco(func, *da, **dkw)
80
+
81
+ allowing `da` and `dkw` to affect the behaviour of the decorator `mydeco`.
82
+
83
+ Examples:
84
+
85
+ # define your decorator as if always called with func and args
86
+ @decorator
87
+ def mydeco(func, *da, arg2=None):
88
+ ... decorate func subject to the values of da and arg2
89
+
90
+ # mydeco called with defaults
91
+ @mydeco
92
+ def func1(...):
93
+ ...
94
+
95
+ @ mydeco called with nondefault arguments
96
+ @mydeco('foo', arg2='bah')
97
+ def func2(...):
98
+ ...
99
+ '''
100
+
101
+ def decorate(func, *dargs, **dkwargs):
102
+ ''' Final decoration when we have the function and the decorator arguments.
103
+ '''
104
+ # decorate func
105
+ decorated = deco(func, *dargs, **dkwargs)
106
+ # catch mucked decorators which forget to return the new function
107
+ assert decorated is not None, (
108
+ "deco:%r(func:%r,...) -> None" % (deco, func)
109
+ )
110
+ if decorated is not func:
111
+ # We got a wrapper function back, pretty up the returned wrapper.
112
+ # Try functools.update_wrapper, otherwise do stuff by hand.
113
+ try:
114
+ from functools import update_wrapper # pylint: disable=import-outside-toplevel
115
+ update_wrapper(decorated, func)
116
+ except (AttributeError, ImportError):
117
+ try:
118
+ decorated.__name__ = getattr(func, '__name__', str(func))
119
+ except AttributeError:
120
+ pass
121
+ doc = getattr(func, '__doc__', None) or ''
122
+ try:
123
+ decorated.__doc__ = doc
124
+ except AttributeError:
125
+ warning("cannot set __doc__ on %r", decorated)
126
+ func_module = getattr(func, '__module__', None)
127
+ try:
128
+ decorated.__module__ = func_module
129
+ except AttributeError:
130
+ pass
131
+ return decorated
132
+
133
+ def metadeco(*da, **dkw):
134
+ ''' Compute either the wrapper function for `func`
135
+ or a decorator expecting to get `func` when used.
136
+
137
+ If there is at least one positional parameter
138
+ and it is callable the it is presumed to be the function to decorate;
139
+ decorate it directly.
140
+
141
+ Otherwise return a decorator using the provided arguments,
142
+ ready for the subsequent function.
143
+ '''
144
+ if len(da) > 0 and callable(da[0]):
145
+ # `func` is already supplied, pop it off and decorate it now.
146
+ func = da[0]
147
+ da = tuple(da[1:])
148
+ return decorate(func, *da, **dkw)
149
+
150
+ # `func` is not supplied, collect the arguments supplied and return a
151
+ # decorator which takes the subsequent callable and returns
152
+ # `deco(func, *da, **kw)`.
153
+ return lambda func: decorate(func, *da, **dkw)
154
+
155
+ metadeco.__name__ = getattr(deco, '__name__', repr(deco))
156
+ metadeco.__doc__ = getattr(deco, '__doc__', '')
157
+ metadeco.__module__ = getattr(deco, '__module__', None)
158
+ return metadeco
159
+
160
+ @decorator
161
+ def attr(func, **attrs):
162
+ ''' A decorator to set attributes on a function.
163
+ Example:
164
+
165
+ @attr(hook_names=('hook1', 'hook2'))
166
+ def func():
167
+ .....
168
+
169
+ This is just a more overt and clear form of:
170
+
171
+ def func():
172
+ .....
173
+
174
+ func.hook_names=('hook1', 'hook2')
175
+ '''
176
+ if not attrs:
177
+ raise ValueError('@attr: no attributes upplied')
178
+ for attr, value in attrs.items():
179
+ setattr(func, attr, value)
180
+ return func
181
+
182
+ @decorator
183
+ def contextdecorator(cmgrfunc):
184
+ ''' A decorator for a context manager function `cmgrfunc`
185
+ which turns it into a decorator for other functions.
186
+
187
+ This supports easy implementation of "setup" and "teardown"
188
+ code around other functions without the tedium of defining
189
+ the wrapper function itself. See the examples below.
190
+
191
+ The resulting context manager accepts an optional keyword
192
+ parameter `provide_context`, default `False`. If true, the
193
+ context returned from the context manager is provided as the
194
+ first argument to the call to the wrapped function.
195
+
196
+ Note that the context manager function `cmgrfunc`
197
+ has _not_ yet been wrapped with `@contextmanager`,
198
+ that is done by `@contextdecorator`.
199
+
200
+ This decorator supports both normal functions and generator functions.
201
+
202
+ With a normal function the process is:
203
+ * call the context manager with `(func,a,kw,*da,**dkw)`,
204
+ returning `ctxt`,
205
+ where `da` and `dkw` are the positional and keyword parameters
206
+ supplied when the decorator was defined.
207
+ * within the context
208
+ return the value of `func(ctxt,*a,**kw)` if `provide_context` is true
209
+ or the value of `func(*a,**kw)` if not (the default)
210
+
211
+ With a generator function the process is:
212
+ * obtain an iterator by calling `func(*a,**kw)`
213
+ * for iterate over the iterator, yielding its results,
214
+ by calling the context manager with `(func,a,kw,**da,**dkw)`,
215
+ around each `next()`
216
+ Note that it is an error to provide a true value for `provide_context`
217
+ if the decorated function is a generator function.
218
+
219
+ Some examples follow.
220
+
221
+ Trace the call and return of a specific function:
222
+
223
+ @contextdecorator
224
+ def tracecall(func, a, kw):
225
+ """ Trace the call and return from some function.
226
+ This can easily be adapted to purposes such as timing a
227
+ function call or logging use.
228
+ """
229
+ print("call %s(*%r,**%r)" % (func, a, kw))
230
+ try:
231
+ yield
232
+ except Exception as e:
233
+ print("exception from %s(*%r,**%r): %s" % (func, a, kw, e))
234
+ raise
235
+ else:
236
+ print("return from %s(*%r,**%r)" % (func, a, kw))
237
+
238
+ @tracecall
239
+ def f():
240
+ """ Some function to trace.
241
+ """
242
+
243
+ @tracecall(provide_context=True):
244
+ def f(ctxt, *a, **kw):
245
+ """ A function expecting the context object as its first argument,
246
+ ahead of whatever other arguments it would normally require.
247
+ """
248
+
249
+ See who is making use of a generator's values,
250
+ when a generator might be invoked in one place and consumed elsewhere:
251
+
252
+ from cs.py.stack import caller
253
+
254
+ @contextdecorator
255
+ def genuser(genfunc, *a, **kw):
256
+ user = caller(-4)
257
+ print(f"iterate over {genfunc}(*{a!r},**{kw!r}) from {user}")
258
+ yield
259
+
260
+ @genuser
261
+ def linesof(filename):
262
+ with open(filename) as f:
263
+ yield from f
264
+
265
+ # obtain a generator of lines here
266
+ lines = linesof(__file__)
267
+
268
+ # perhaps much later, or in another function
269
+ for lineno, line in enumerate(lines, 1):
270
+ print("line %d: %d words" % (lineno, len(line.split())))
271
+
272
+ Turn on "verbose mode" around a particular function:
273
+
274
+ import sys
275
+ import threading
276
+ from cs.context import stackattrs
277
+
278
+ class State(threading.local):
279
+ def __init__(self):
280
+ # verbose if stderr is on a terminal
281
+ self.verbose = sys.stderr.isatty()
282
+
283
+ # per thread global state
284
+ state = State()
285
+
286
+ @contextdecorator
287
+ def verbose(func):
288
+ with stackattrs(state, verbose=True) as old_attrs:
289
+ if not old_attrs['verbose']:
290
+ print(f"enabled verbose={state.verbose} for function {func}")
291
+ # yield the previous verbosity as the context
292
+ yield old_attrs['verbose']
293
+
294
+ # turn on verbose mode
295
+ @verbose
296
+ def func(x, y):
297
+ if state.verbose:
298
+ # print if verbose
299
+ print("x =", x, "y =", y)
300
+
301
+ # turn on verbose mode and also pass in the previous state
302
+ # as the first argument
303
+ @verbose(provide_context=True):
304
+ def func2(old_verbose, x, y):
305
+ if state.verbose:
306
+ # print if verbose
307
+ print("old_verbosity =", old_verbose, "x =", x, "y =", y)
308
+ '''
309
+ # turn the function into a context manager
310
+ cmgr = contextmanager(cmgrfunc)
311
+
312
+ # prepare a new decorator which wraps functions in a context
313
+ # manager using `cmgrfunc`
314
+ @decorator
315
+ def cmgrdeco(func, *da, provide_context=False, **dkw):
316
+ ''' Decorator for functions which wraps calls to the function
317
+ in a context manager, optionally supplying the context
318
+ as the first argument to the called function.
319
+ '''
320
+ if isgeneratorfunction(func):
321
+ if provide_context:
322
+ raise ValueError(
323
+ "provide_context may not be true when func:%s is a generator" %
324
+ (func,)
325
+ )
326
+
327
+ def wrapped(*a, **kw):
328
+ ''' Wrapper function:
329
+ * obtain an iterator by calling `func(*a,**kw)`
330
+ * iterate over the iterator, yielding its results,
331
+ by calling the context manager with `(func,a,kw,**da,**dkw)`,
332
+ around each `next()`
333
+ '''
334
+ it = func(*a, **kw)
335
+ while True:
336
+ with cmgr(func, a, kw, *da, **dkw):
337
+ try:
338
+ value = next(it)
339
+ except StopIteration:
340
+ break
341
+ yield value
342
+
343
+ else:
344
+
345
+ def wrapped(*a, **kw):
346
+ ''' Wrapper function:
347
+ * call the context manager with `(func,a,kw,**da,**dkw)`,
348
+ returning `ctxt`
349
+ * within the context
350
+ return the value of `func(ctxt,*a,**kw)`
351
+ if `provide_context` is true
352
+ or the value of `func(*a,**kw)` if not (the default)
353
+ '''
354
+ with cmgr(func, a, kw, *da, **dkw) as ctxt:
355
+ if provide_context:
356
+ a = [ctxt] + list(a)
357
+ return func(*a, **kw)
358
+
359
+ return wrapped
360
+
361
+ return cmgrdeco
362
+
363
+ @decorator
364
+ def logging_wrapper(log_call, stacklevel_increment=1):
365
+ ''' Decorator for logging call shims
366
+ which bumps the `stacklevel` keyword argument so that the logging system
367
+ chooses the correct frame to cite in messages.
368
+
369
+ Note: has no effect on Python < 3.8 because `stacklevel` only
370
+ appeared in that version.
371
+ '''
372
+ if (sys.version_info.major, sys.version_info.minor) < (3, 8):
373
+ # do not wrap older Python log calls, no stacklevel keyword argument
374
+ return log_call
375
+
376
+ def log_func_wrapper(*a, **kw):
377
+ stacklevel = kw.pop('stacklevel', 1)
378
+ return log_call(*a, stacklevel=stacklevel + stacklevel_increment + 1, **kw)
379
+
380
+ log_func_wrapper.__name__ = log_call.__name__
381
+ log_func_wrapper.__doc__ = log_call.__doc__
382
+ return log_func_wrapper
383
+
384
+ @decorator
385
+ def OBSOLETE(func, suggestion=None):
386
+ ''' A decorator for obsolete functions or classes.
387
+
388
+ Use:
389
+
390
+ @OBSOLETE
391
+ def func(...):
392
+
393
+ or
394
+
395
+ @OBSOLETE("new_func_name")
396
+ def func(...):
397
+
398
+ This emits a warning log message before calling the decorated function.
399
+ Only one warning is emitted per calling location.
400
+ '''
401
+
402
+ callers = set()
403
+
404
+ def OBSOLETE_func_wrapper(*args, **kwargs):
405
+ ''' Wrap `func` to emit an "OBSOLETE" warning before calling `func`.
406
+ '''
407
+ frame = traceback.extract_stack(None, 2)[0]
408
+ caller = frame[0], frame[1]
409
+ if caller not in callers:
410
+ callers.add(caller)
411
+ prefix = (
412
+ "OBSOLETE call" if suggestion is None else
413
+ ("OBSOLETE (suggest %r) call" % suggestion)
414
+ )
415
+ fmt = "%s to %s:%d:%s(), called from %s:%d:%s"
416
+ fmtargs = [
417
+ prefix, func.__code__.co_filename, func.__code__.co_firstlineno,
418
+ func.__name__, frame[0], frame[1], frame[2]
419
+ ]
420
+ warning(fmt, *fmtargs)
421
+ return func(*args, **kwargs)
422
+
423
+ funcname = getattr(func, '__name__', str(func))
424
+ funcdoc = getattr(func, '__doc__', None) or ''
425
+ doc = "OBSOLETE " + funcname
426
+ func.__doc__ = doc + '\n\n' + funcdoc
427
+ if suggestion:
428
+ doc += ' suggestion: ' + suggestion
429
+ OBSOLETE_func_wrapper.__name__ = '@OBSOLETE(%s)' % (funcname,)
430
+ return OBSOLETE_func_wrapper
431
+
432
+ @OBSOLETE(suggestion='cs.cache.cachedmethod')
433
+ def cached(*a, **kw):
434
+ ''' Former name for @cachedmethod.
435
+ '''
436
+ from cs.cache import cachedmethod
437
+ return cachedmethod(*a, **kw)
438
+
439
+ @OBSOLETE(suggestion='cs.cache.cachedmethod')
440
+ def cachedmethod(*a, **kw):
441
+ ''' @cachedmethod is now in cs.cache.
442
+ '''
443
+ from cs.cache import cachedmethod
444
+ return cachedmethod(*a, **kw)
445
+
446
+ def contextual(func):
447
+ ''' Wrap a simple function as a context manager.
448
+
449
+ This was written to support users of `@strable`,
450
+ which requires its `open_func` to return a context manager;
451
+ this turns an arbitrary function into a context manager.
452
+
453
+ Example promoting a trivial function:
454
+
455
+ >>> f = lambda: 3
456
+ >>> cf = contextual(f)
457
+ >>> with cf() as x: print(x)
458
+ 3
459
+ '''
460
+
461
+ @contextmanager
462
+ def cmgr(*a, **kw):
463
+ ''' Wrapper for `func` as a context manager.
464
+ '''
465
+ yield func(*a, **kw)
466
+
467
+ func_name = getattr(func, '__name__', str(func))
468
+ cmgr.__name__ = '@contextual(%s)' % func_name
469
+ cmgr.__doc__ = func.__doc__
470
+ return cmgr
471
+
472
+ @decorator
473
+ def strable(func, open_func=None):
474
+ ''' Decorator for functions which may accept a `str`
475
+ instead of their core type.
476
+
477
+ Parameters:
478
+ * `func`: the function to decorate
479
+ * `open_func`: the "open" factory to produce the core type
480
+ if a string is provided;
481
+ the default is the builtin "open" function.
482
+ The returned value should be a context manager.
483
+ Simpler functions can be decorated with `@contextual`
484
+ to turn them into context managers if need be.
485
+
486
+ The usual (and default) example is a function to process an
487
+ open file, designed to be handed a file object but which may
488
+ be called with a filename. If the first argument is a `str`
489
+ then that file is opened and the function called with the
490
+ open file.
491
+
492
+ Examples:
493
+
494
+ @strable
495
+ def count_lines(f):
496
+ return len(line for line in f)
497
+
498
+ class Recording:
499
+ "Class representing a video recording."
500
+ ...
501
+ @strable(open_func=Recording)
502
+ def process_video(r):
503
+ ... do stuff with `r` as a Recording instance ...
504
+
505
+ *Note*: use of this decorator requires the `cs.pfx` module.
506
+ '''
507
+ from cs.pfx import Pfx # pylint: disable=import-outside-toplevel
508
+ if open_func is None:
509
+ open_func = open
510
+
511
+ if isgeneratorfunction(func):
512
+
513
+ def accepts_str(arg, *a, **kw):
514
+ if isinstance(arg, str):
515
+ with Pfx(arg):
516
+ with open_func(arg) as opened:
517
+ for item in func(opened, *a, **kw):
518
+ yield item
519
+ else:
520
+ for item in func(arg, *a, **kw):
521
+ yield item
522
+ else:
523
+
524
+ def accepts_str(arg, *a, **kw):
525
+ if isinstance(arg, str):
526
+ with Pfx(arg):
527
+ with open_func(arg) as opened:
528
+ return func(opened, *a, **kw)
529
+ return func(arg, *a, **kw)
530
+
531
+ return accepts_str
532
+
533
+ def observable_class(property_names, only_unequal=False):
534
+ ''' Class decorator to make various instance attributes observable.
535
+
536
+ Parameters:
537
+ * `property_names`:
538
+ an interable of instance property names to set up as
539
+ observable properties. As a special case a single `str` can
540
+ be supplied if only one attribute is to be observed.
541
+ * `only_unequal`:
542
+ only call the observers if the new property value is not
543
+ equal to the previous proerty value. This requires property
544
+ values to be comparable for inequality.
545
+ Default: `False`, meaning that all updates will be reported.
546
+ '''
547
+
548
+ if isinstance(property_names, str):
549
+ property_names = (property_names,)
550
+
551
+ # pylint: disable=protected-access
552
+ def make_observable_class(cls):
553
+ ''' Annotate the class `cls` with observable properties.
554
+ '''
555
+
556
+ # push the per instance initialisation
557
+ old_init = cls.__init__
558
+
559
+ def new_init(self, *a, **kw):
560
+ ''' New init, much like the old init...
561
+ '''
562
+ self._observable_class__observers = defaultdict(set)
563
+ old_init(self, *a, **kw)
564
+
565
+ cls.__init__ = new_init
566
+
567
+ def add_observer(self, attr, observer):
568
+ ''' Add an observer on `.attr` to this instance.
569
+ '''
570
+ self._observable_class__observers[attr].add(observer)
571
+
572
+ cls.add_observer = add_observer
573
+
574
+ def remove_observer(self, attr, observer):
575
+ ''' Remove an observer on `.attr` from this instance.
576
+ '''
577
+ self._observable_class__observers[attr].remove(observer)
578
+
579
+ cls.remove_observer = remove_observer
580
+
581
+ def report_observation(self, attr):
582
+ ''' Notify all the observers of the current value of `attr`.
583
+ '''
584
+ val_attr = '_' + attr
585
+ value = getattr(self, val_attr, None)
586
+ for observer in self._observable_class__observers[attr]:
587
+ try:
588
+ observer(self, attr, value)
589
+ except Exception as e: # pylint: disable=broad-except
590
+ warning(
591
+ "%s.%s=%r: observer %s(...) raises: %s",
592
+ self,
593
+ val_attr,
594
+ value,
595
+ observer,
596
+ e,
597
+ exc_info=True
598
+ )
599
+
600
+ cls.report_observation = report_observation
601
+
602
+ def make_property(cls, attr):
603
+ ''' Make `cls.attr` into a property which reports setattr events.
604
+ '''
605
+ val_attr = '_' + attr
606
+
607
+ def getter(self):
608
+ return getattr(self, val_attr)
609
+
610
+ getter.__name__ = attr
611
+ get_prop = property(getter)
612
+ setattr(cls, attr, get_prop)
613
+
614
+ def setter(self, new_value):
615
+ ''' Set the attribute value and tell all the observers.
616
+ '''
617
+ old_value = getattr(self, val_attr, None)
618
+ setattr(self, val_attr, new_value)
619
+ if not only_unequal or old_value != new_value:
620
+ self.report_observation(attr)
621
+
622
+ setter.__name__ = attr
623
+ set_prop = get_prop.setter(setter)
624
+ setattr(cls, attr, set_prop)
625
+
626
+ for property_name in property_names:
627
+ if hasattr(cls, property_name):
628
+ raise ValueError("%s.%s already exists" % (cls, property_name))
629
+ make_property(cls, property_name)
630
+
631
+ return cls
632
+
633
+ return make_observable_class
634
+
635
+ @decorator
636
+ def default_params(func, _strict=False, **param_defaults):
637
+ ''' Decorator to provide factory functions for default parameters.
638
+
639
+ This decorator accepts the following keyword parameters:
640
+ * `_strict`: default `False`; if true only replace genuinely
641
+ missing parameters; if false also replace the traditional
642
+ `None` placeholder value
643
+ The remaining keyword parameters are factory functions
644
+ providing the respective default values.
645
+
646
+ Atypical one off direct use:
647
+
648
+ @default_params(dbconn=open_default_dbconn,debug=lambda:settings.DB_DEBUG_MODE)
649
+ def dbquery(query, *, dbconn):
650
+ dbconn.query(query)
651
+
652
+ Typical use as a decorator factory:
653
+
654
+ # in your support module
655
+ uses_ds3 = default_params(ds3client=get_ds3client)
656
+
657
+ # calling code which needs a ds3client
658
+ @uses_ds3
659
+ def do_something(.., *, ds3client, ...):
660
+ ... make queries using ds3client ...
661
+
662
+ This replaces the standard boilerplate and avoids replicating
663
+ knowledge of the default factory as exhibited in this legacy code:
664
+
665
+ def do_something(.., *, ds3client=None, ...):
666
+ if ds3client is None:
667
+ ds3client = get_ds3client()
668
+ ... make queries using ds3client ...
669
+ '''
670
+ if not param_defaults:
671
+ raise ValueError("@default_params(%s): no defaults?" % (func,))
672
+
673
+ def update_kw(kw):
674
+ ''' Update keyword parameters `kw` from the `param_defaults`.
675
+ '''
676
+ for param_name, param_default in param_defaults.items():
677
+ try:
678
+ v = kw[param_name]
679
+ except KeyError:
680
+ kw[param_name] = param_default()
681
+ else:
682
+ if v is None and not _strict:
683
+ kw[param_name] = param_default()
684
+
685
+ if isgeneratorfunction(func):
686
+
687
+ def defaulted_func(*a, **kw):
688
+ update_kw(kw)
689
+ yield from func(*a, **kw)
690
+ else:
691
+
692
+ def defaulted_func(*a, **kw):
693
+ update_kw(kw)
694
+ return func(*a, **kw)
695
+
696
+ defaulted_func.__name__ = func.__name__
697
+ # TODO: get the indent from some aspect of stripped_dedent
698
+ defaulted_func.__doc__ = '\n '.join(
699
+ [
700
+ getattr(func, '__doc__', '') or '',
701
+ '',
702
+ 'This function also accepts the following optional keyword parameters:',
703
+ *[
704
+ '* `%s`: default from `%s()`' % (param_name, param_default)
705
+ for param_name, param_default in sorted(param_defaults.items())
706
+ ],
707
+ ]
708
+ )
709
+ sig0 = signature(func)
710
+ sig = sig0
711
+ modified_params = []
712
+ for param in sig0.parameters.values():
713
+ modified_param = None
714
+ try:
715
+ param_default = param_defaults[param.name]
716
+ except KeyError:
717
+ pass
718
+ else:
719
+ modified_param = param.replace(
720
+ annotation=typing.Optional[param.annotation],
721
+ default=None if param_default is param.empty else param_default,
722
+ )
723
+ if modified_param is None:
724
+ modified_param = param.replace()
725
+ modified_params.append(modified_param)
726
+ sig = sig.replace(parameters=modified_params)
727
+ defaulted_func.__signature__ = sig
728
+ return defaulted_func
729
+
730
+ @decorator
731
+ def uses_cmd_options(
732
+ func, _strict=False, _options_param_name='options', **option_defaults
733
+ ):
734
+ ''' A decorator to provide default keyword arguments
735
+ from the prevailing `cs.cmdutils.BaseCommandOptions`
736
+ if available, otherwise from `option_defaults`.
737
+
738
+ This exists to provide plumbing free access to options set
739
+ up by a command line invocation using `cs.cmdutils.BaseCommand`.
740
+
741
+ If no `option_defaults` are provided, a single `options`
742
+ keyword argument is provided which is the prevailing
743
+ `BaseCommand.Options` instance.
744
+
745
+ The decorator accepts two optional "private" keyword arguments
746
+ commencing with underscores:
747
+ * `_strict`: default `False`; if true then an `option_defaults`
748
+ will only be applied if the argument is _missing_ from the
749
+ function arguments, otherwise it will be applied if the
750
+ argument is missing or `None`
751
+ * `_options_param_name`: default `'options'`; this is the
752
+ name of the single `options` keyword argument which will be
753
+ supplied if there are no `option_defaults`
754
+
755
+ Examples:
756
+
757
+ @uses_cmd_options(doit=True, quiet=False)
758
+ def func(x, *, doit, quiet, **kw):
759
+ if not quiet:
760
+ print("something", x, kw)
761
+ if doit:
762
+ ... do the thing ...
763
+ ... etc ...
764
+
765
+ @uses_cmd_options()
766
+ def func(x, *, options, **kw):
767
+ if not options.quiet:
768
+ print("something", x, kw)
769
+ if options.doit:
770
+ ... do the thing ...
771
+ ... etc ...
772
+ '''
773
+
774
+ def uses_cmd_wrapper(*func_a, **func_kw):
775
+ # fill in the func_kw from the defaults
776
+ # and keep a record of the chosen values
777
+ # run with the prevailing BaseCommand suitably updated
778
+ try:
779
+ from cs.cmdutils import BaseCommand
780
+ from cs.context import stackattrs
781
+ except ImportError:
782
+ # missing cs.cmdutils or cs.context,
783
+ # make an options with no attributes
784
+ class Options:
785
+ '''Dummy options object for accruing attributes.'''
786
+
787
+ options = Options()
788
+ else:
789
+ options_class = BaseCommand.Options
790
+ options = options_class.default() or options_class()
791
+ option_updates = {}
792
+ if not option_defaults:
793
+ option_defaults[_options_param_name] = options
794
+ for option_name, option_default in option_defaults.items():
795
+ if _strict:
796
+ # skip if the option is not provided by the caller
797
+ if option_name in func_kw:
798
+ continue
799
+ elif func_kw.get(option_name) is not None:
800
+ # skip if the option is not provided by the caller
801
+ # or is provided as None
802
+ continue
803
+ option_value = getattr(options, option_name, None)
804
+ if option_value is None:
805
+ option_value = option_default
806
+ option_updates[option_name] = option_value
807
+ func_kw.update(option_updates)
808
+ with stackattrs(options, **option_updates):
809
+ with options:
810
+ return func(*func_a, **func_kw)
811
+
812
+ return uses_cmd_wrapper
813
+
814
+ uses_doit = uses_cmd_options(doit=True)
815
+ uses_force = uses_cmd_options(force=False)
816
+ uses_quiet = uses_cmd_options(quiet=False)
817
+ uses_verbose = uses_cmd_options(verbose=False)
818
+
819
+ # pylint: disable=too-many-statements
820
+ @decorator
821
+ def promote(func, params=None, types=None):
822
+ ''' A decorator to promote argument values automatically in annotated functions.
823
+
824
+ If the annotation is `Optional[some_type]` or `Union[some_type,None]`
825
+ then the promotion will be to `some_type` but a value of `None`
826
+ will be passed through unchanged.
827
+
828
+ The decorator accepts optional parameters:
829
+ * `params`: if supplied, only parameters in this list will
830
+ be promoted
831
+ * `types`: if supplied, only types in this list will be
832
+ considered for promotion
833
+
834
+ For any parameter with a type annotation, if that type has a
835
+ `.promote(value)` class method and the function is called with a
836
+ value not of the type of the annotation, the `.promote` method
837
+ will be called to promote the value to the expected type.
838
+
839
+ Note that the `Promotable` mixin provides a `.promote()`
840
+ method which promotes `obj` to the class if the class has a
841
+ factory class method `from_`*typename*`(obj)` where *typename*
842
+ is `obj.__class__.__name__`.
843
+ A common case for me is lexical objects which have a `from_str(str)`
844
+ factory to produce an instance from its textual form.
845
+
846
+ Additionally, if the `.promote(value)` class method raises a `TypeError`
847
+ and `value` has a `.as_`*typename* attribute
848
+ (where *typename* is the name of the type annotation),
849
+ if that attribute is an instance method of `value`
850
+ then promotion will be attempted by calling `value.as_`*typename*`()`
851
+ otherwise the attribute will be used directly
852
+ on the presumption that it is a property.
853
+
854
+ A typical `promote(cls, obj)` method looks like this:
855
+
856
+ @classmethod
857
+ def promote(cls, obj):
858
+ if isinstance(obj, cls):
859
+ return obj
860
+ ... recognise various types ...
861
+ ... and return a suitable instance of cls ...
862
+ raise TypeError(
863
+ "%s.promote: cannot promote %s:%r",
864
+ cls.__name__, obj.__class__.__name__, obj)
865
+
866
+ Example:
867
+
868
+ >>> from cs.timeseries import Epoch
869
+ >>> from typeguard import typechecked
870
+ >>>
871
+ >>> @promote
872
+ ... @typechecked
873
+ ... def f(data, epoch:Epoch=None):
874
+ ... print("epoch =", type(epoch), epoch)
875
+ ...
876
+ >>> f([1,2,3], epoch=12.0)
877
+ epoch = <class 'cs.timeseries.Epoch'> Epoch(start=0, step=12)
878
+
879
+ Example using a class with an `as_P` instance method:
880
+
881
+ >>> class P:
882
+ ... def __init__(self, x):
883
+ ... self.x = x
884
+ ... @classmethod
885
+ ... def promote(cls, obj):
886
+ ... raise TypeError("dummy promote method")
887
+ ...
888
+ >>> class C:
889
+ ... def __init__(self, n):
890
+ ... self.n = n
891
+ ... def as_P(self):
892
+ ... return P(self.n + 1)
893
+ ...
894
+ >>> @promote
895
+ ... def p(p: P):
896
+ ... print("P =", type(p), p.x)
897
+ ...
898
+ >>> c = C(1)
899
+ >>> p(c)
900
+ P = <class 'cs.deco.P'> 2
901
+
902
+ *Note*: one issue with this is due to the conflict in name
903
+ between this decorator and the method it looks for in a class.
904
+ The `promote` _method_ must appear after any methods in the
905
+ class which are decorated with `@promote`, otherwise the
906
+ `promote` method supplants the name `promote` making it
907
+ unavailable as the decorator.
908
+ I usually just make `.promote` the last method.
909
+
910
+ Failing example:
911
+
912
+ class Foo:
913
+ @classmethod
914
+ def promote(cls, obj):
915
+ ... return promoted obj ...
916
+ @promote
917
+ def method(self, param:Type, ...):
918
+ ...
919
+
920
+ Working example:
921
+
922
+ class Foo:
923
+ @promote
924
+ def method(self, param:Type, ...):
925
+ ...
926
+ # promote method as the final method of the class
927
+ @classmethod
928
+ def promote(cls, obj):
929
+ ... return promoted obj ...
930
+ '''
931
+ sig = signature(func)
932
+ if params is not None:
933
+ for param_name in params:
934
+ if param_name not in sig.parameters:
935
+ raise ValueError(
936
+ "@promote(%r,params=%r): no %r parameter in signature (sig.parameters=%r)"
937
+ % (func, params, param_name, dict(sig.parameters))
938
+ )
939
+ promotions = {} # mapping of arg->(type,promote)
940
+ for param_name, param in sig.parameters.items():
941
+ if params is not None and param_name not in params:
942
+ continue
943
+ annotation = param.annotation
944
+ if annotation is Parameter.empty:
945
+ continue
946
+ # recognise optional parameters and use their primary type
947
+ optional = False
948
+ if param.default is not Parameter.empty:
949
+ anno_origin = typing.get_origin(annotation)
950
+ anno_args = typing.get_args(annotation)
951
+ # recognise Optional[T], which becomes Union[T,None]
952
+ if (anno_origin is typing.Union and len(anno_args) == 2
953
+ and anno_args[-1] is type(None)):
954
+ optional = True
955
+ annotation, _ = anno_args
956
+ optional = True
957
+ if types is not None and annotation not in types:
958
+ continue
959
+ try:
960
+ promote_method = annotation.promote
961
+ except AttributeError:
962
+ continue
963
+ if not callable(promote_method):
964
+ continue
965
+ promotions[param_name] = (param, annotation, promote_method, optional)
966
+ if not promotions:
967
+ warning("@promote(%s): no promotable parameters", func)
968
+ return func
969
+
970
+ def promote_args(a, kw):
971
+ ''' Promote the position and keyword arguments.
972
+ '''
973
+ bound_args = sig.bind(*a, **kw)
974
+ arg_mapping = bound_args.arguments
975
+ # we don't import cs.pfx (many dependencies!)
976
+ # pylint: disable=unnecessary-lambda-assignment
977
+ get_context = lambda: (
978
+ "@promote(%s.%s)(%s=%s:%r)" % (
979
+ func.__module__, func.__name__, param_name, arg_value.__class__.
980
+ __name__, arg_value
981
+ )
982
+ )
983
+ for param_name, (param, annotation, promote_method,
984
+ optional) in promotions.items():
985
+ try:
986
+ arg_value = arg_mapping[param_name]
987
+ except KeyError:
988
+ # parameter not supplied
989
+ if param.default is Parameter.empty:
990
+ continue
991
+ # fill in the default values
992
+ arg_value = param.default
993
+ if isinstance(arg_value, annotation):
994
+ # already of the desired type
995
+ continue
996
+ try:
997
+ try:
998
+ promoted_value = promote_method(arg_value)
999
+ except TypeError as te:
1000
+ # see if the value has an as_TypeName() method
1001
+ as_method_name = "as_" + annotation.__name__
1002
+ try:
1003
+ as_annotation = getattr(arg_value, as_method_name)
1004
+ except AttributeError:
1005
+ # no .as_TypeName, reraise the original TypeError
1006
+ raise te # pylint: disable=raise-missing-from
1007
+ else:
1008
+ if ismethod(as_annotation) and as_annotation.__self__ is arg_value:
1009
+ # bound instance method of arg_value
1010
+ try:
1011
+ as_value = as_annotation()
1012
+ except (TypeError, ValueError) as e:
1013
+ raise TypeError(
1014
+ "%s: %s.%s(): %s" %
1015
+ (get_context(), param_name, as_method_name, e)
1016
+ ) from e
1017
+ else:
1018
+ # assuming a property or even a plain attribute
1019
+ as_value = as_annotation
1020
+ arg_value = as_value
1021
+ except TypeError:
1022
+ # promotion fails
1023
+ if (optional and arg_value is param.default
1024
+ and param.default is not Parameter.empty):
1025
+ # allow omitted/unconverted optional value with default None
1026
+ continue
1027
+ raise
1028
+ else:
1029
+ arg_value = promoted_value
1030
+ arg_mapping[param_name] = arg_value
1031
+ return bound_args.args, bound_args.kwargs
1032
+
1033
+ if isgeneratorfunction(func):
1034
+
1035
+ def promoting_func(*a, **kw):
1036
+ a, kw = promote_args(a, kw)
1037
+ yield from func(*a, **kw)
1038
+
1039
+ else:
1040
+
1041
+ def promoting_func(*a, **kw):
1042
+ a, kw = promote_args(a, kw)
1043
+ return func(*a, **kw)
1044
+
1045
+ return promoting_func
1046
+
1047
+ # pylint: disable=too-few-public-methods
1048
+ class Promotable:
1049
+ ''' A mixin class which supports the `@promote` decorator.
1050
+ '''
1051
+
1052
+ @classmethod
1053
+ def promote(cls, obj, **from_t_kw):
1054
+ ''' Promote `obj` to an instance of `cls` or raise `TypeError`.
1055
+ This method supports the `@promote` decorator.
1056
+
1057
+ This base method will call the `from_`*typename*`(obj,**from_t_kw)`
1058
+ class factory method if present, where *typename* is
1059
+ `obj.__class__.__name__`.
1060
+
1061
+ Subclasses may override this method to promote other types,
1062
+ typically:
1063
+
1064
+ @classmethod
1065
+ def promote(cls, obj):
1066
+ if isinstance(obj, cls):
1067
+ return obj
1068
+ ... various specific type promotions
1069
+ ... not done via a from_typename factory method
1070
+ # fall back to Promotable.promote
1071
+ return super().promote(obj)
1072
+
1073
+ An typical `from_`*typename*` factory method:
1074
+
1075
+ class Foo(Promotable):
1076
+
1077
+ def __init__(self, dbkey, dbrow):
1078
+ self.key = dbkey
1079
+ self.row_data = row
1080
+
1081
+ @classmethod
1082
+ def from_str(cls, s : str):
1083
+ """Accept a database key string, return a `Foo` instance."""
1084
+ row = db_lookup(s)
1085
+ return cls(s, row)
1086
+
1087
+ This supports using `@promote` on functions with `Foo` instances:
1088
+
1089
+ @promote
1090
+ def do_it(foo : Foo):
1091
+ ... work with foo ...
1092
+
1093
+ but calling it as:
1094
+
1095
+ do_it("foo_key_value")
1096
+ '''
1097
+ if isinstance(obj, cls):
1098
+ return obj
1099
+ try:
1100
+ from_type = getattr(cls, f'from_{obj.__class__.__name__}')
1101
+ except AttributeError:
1102
+ pass
1103
+ else:
1104
+ return from_type(obj, **from_t_kw)
1105
+ raise TypeError(
1106
+ f'{cls.__name__}.promote: cannot promote {obj.__class__.__name__}:{obj!r}'
1107
+ )