laser-prynter 0.2.5__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.
- laser_prynter/__init__.py +0 -0
- laser_prynter/bench.py +227 -0
- laser_prynter/colour/__init__.py +0 -0
- laser_prynter/colour/c.py +104 -0
- laser_prynter/colour/gradient.py +266 -0
- laser_prynter/log.py +171 -0
- laser_prynter/pp.py +75 -0
- laser_prynter-0.2.5.dist-info/METADATA +54 -0
- laser_prynter-0.2.5.dist-info/RECORD +12 -0
- laser_prynter-0.2.5.dist-info/WHEEL +5 -0
- laser_prynter-0.2.5.dist-info/licenses/LICENSE +28 -0
- laser_prynter-0.2.5.dist-info/top_level.txt +1 -0
|
File without changes
|
laser_prynter/bench.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
'''
|
|
3
|
+
A wee utility that I use to benchmark katas
|
|
4
|
+
author: github.com/tmck-code
|
|
5
|
+
|
|
6
|
+
usage:
|
|
7
|
+
|
|
8
|
+
func_groups = [
|
|
9
|
+
[f1,f2],
|
|
10
|
+
[f3,f4,f5],
|
|
11
|
+
[f6],
|
|
12
|
+
]
|
|
13
|
+
tests = [
|
|
14
|
+
( (arg1, arg2), {}, result1, ),
|
|
15
|
+
( (arg3, arg4), {}, result2, ),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
bench.bench(
|
|
19
|
+
tests=tests,
|
|
20
|
+
func_groups=func_groups,
|
|
21
|
+
n=100_000,
|
|
22
|
+
sort=('BENCH_SORT' in os.environ)
|
|
23
|
+
)
|
|
24
|
+
'''
|
|
25
|
+
|
|
26
|
+
from collections import Counter, namedtuple
|
|
27
|
+
from functools import lru_cache, wraps
|
|
28
|
+
from itertools import chain, repeat
|
|
29
|
+
import operator
|
|
30
|
+
import pickle
|
|
31
|
+
import time, sys, os
|
|
32
|
+
from typing import Callable, Any
|
|
33
|
+
import statistics
|
|
34
|
+
|
|
35
|
+
from laser_prynter import pp
|
|
36
|
+
|
|
37
|
+
Test = namedtuple('Test', 'args kwargs expected n')
|
|
38
|
+
class NoExpectation:
|
|
39
|
+
'Denotes that a test/benchmark has no expected result (i.e. just benchmark it)'
|
|
40
|
+
|
|
41
|
+
def set_function_module(func):
|
|
42
|
+
'Set the module of a function'
|
|
43
|
+
if func.__module__ != '__main__':
|
|
44
|
+
return
|
|
45
|
+
module = sys.modules[func.__module__]
|
|
46
|
+
if hasattr(module, '__file__'):
|
|
47
|
+
# if the function is defined in the main module, set the module to the filename
|
|
48
|
+
func.__module__ = os.path.basename(str(module.__file__)).split('.')[0]
|
|
49
|
+
else:
|
|
50
|
+
# if the module is not a file, set the module to the current directory
|
|
51
|
+
func.__module__ = os.path.basename(os.getcwd())
|
|
52
|
+
|
|
53
|
+
@lru_cache
|
|
54
|
+
def _load_serialised_args(serialised_args):
|
|
55
|
+
return pickle.loads(serialised_args)
|
|
56
|
+
|
|
57
|
+
def timeit_func(func, args, kwargs, expected: object = NoExpectation, n: int = 10_000):
|
|
58
|
+
'Time a function with arguments and return the result, whether it is correct, and the times'
|
|
59
|
+
|
|
60
|
+
if os.environ.get('DEBUG'):
|
|
61
|
+
pp.ppd({'func': func, 'args': args, 'kwargs': kwargs, 'expected': expected, 'n': n})
|
|
62
|
+
|
|
63
|
+
start, times = 0, Counter()
|
|
64
|
+
# some functions may modify the input arguments, so a new copy is needed for every test
|
|
65
|
+
# "pickle" is used instead of "deepcopy" as it's much faster
|
|
66
|
+
args_ser = pickle.dumps(args)
|
|
67
|
+
# ensure that the function module is meaningful (replace it if it's just "__main__")
|
|
68
|
+
set_function_module(func)
|
|
69
|
+
for _ in range(n):
|
|
70
|
+
try:
|
|
71
|
+
start = time.time()
|
|
72
|
+
func(*_load_serialised_args(args_ser), **kwargs)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
finally:
|
|
76
|
+
times[time.time()-start] += 1
|
|
77
|
+
try:
|
|
78
|
+
result = func(*pickle.loads(args_ser), **kwargs)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
result = e
|
|
81
|
+
return result, expected is NoExpectation or result == expected, times
|
|
82
|
+
|
|
83
|
+
def _sum_times(times: Counter) -> float:
|
|
84
|
+
'sum the values*counts in a Counter'
|
|
85
|
+
return sum(map(operator.mul, *zip(*times.items())))
|
|
86
|
+
|
|
87
|
+
def _avg_times(times: Counter) -> float:
|
|
88
|
+
return _sum_times(times) / times.total()
|
|
89
|
+
|
|
90
|
+
def _median_times(times: Counter) -> float:
|
|
91
|
+
return statistics.median(list(times.elements()))
|
|
92
|
+
|
|
93
|
+
TEST_STATUS = {
|
|
94
|
+
False: pp.ps('fail', 'red'),
|
|
95
|
+
True: pp.ps('pass', 'green'),
|
|
96
|
+
}
|
|
97
|
+
TRUNCATE = 40
|
|
98
|
+
|
|
99
|
+
def _truncate(s: str, n: int=TRUNCATE) -> str:
|
|
100
|
+
'Truncate a string to n characters'
|
|
101
|
+
if len(s) <= n:
|
|
102
|
+
return s
|
|
103
|
+
return s[:n-10] + '...' + str(s)[-10:]
|
|
104
|
+
|
|
105
|
+
def _format_time(i: float) -> str:
|
|
106
|
+
'''
|
|
107
|
+
Format a time in seconds to a human-readable string, rounding to the nearest unit
|
|
108
|
+
e.g. 0.0000001 -> "100 ns"
|
|
109
|
+
Uses chars from the "mathematical italic small" unicode block for the units
|
|
110
|
+
'''
|
|
111
|
+
unit = 's'
|
|
112
|
+
for u in ('s ', '𝑚s', '𝜇s', '𝑛s'):
|
|
113
|
+
if i >= 1:
|
|
114
|
+
unit = u
|
|
115
|
+
break
|
|
116
|
+
i = i*10**3
|
|
117
|
+
return f'{i:7.03f} {unit}'
|
|
118
|
+
|
|
119
|
+
RECORD_SEP = '│'
|
|
120
|
+
BORDER_SEP = '─'
|
|
121
|
+
HEADER_SEP = '┆'
|
|
122
|
+
BORDER_END, BORDER_PATTERN = '★', '-⎽__⎽-⎻⎺⎺⎻'
|
|
123
|
+
|
|
124
|
+
def gen_border():
|
|
125
|
+
w = os.get_terminal_size().columns
|
|
126
|
+
n = int(w/len(BORDER_PATTERN))
|
|
127
|
+
r = max(int(n%len(BORDER_PATTERN)/2)-1, 0)
|
|
128
|
+
b = (f'{BORDER_END}{" "*r}{BORDER_PATTERN*n}{" "*r}{BORDER_END}'
|
|
129
|
+
f'\n{BORDER_SEP*w}')
|
|
130
|
+
return b
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _print_header(s: str, test: Test) -> None:
|
|
134
|
+
'Print the result of a timed test'
|
|
135
|
+
print('\n{s:s}{border:s}\n\n{n_s:s}: {n:,d}, {args_s:s}: {args:20s}{kwargs_s:s}: {kwargs:20s}\n'.format(**{
|
|
136
|
+
's': s,
|
|
137
|
+
'border': pp.ps(gen_border(), 'brightyellow'),
|
|
138
|
+
'n_s': pp.ps('n', 'bold'),
|
|
139
|
+
'n': test.n,
|
|
140
|
+
'args_s': pp.ps('args', 'bold'),
|
|
141
|
+
'args': _truncate(str(test.args)+', '),
|
|
142
|
+
'kwargs_s': pp.ps('kwargs', 'bold'),
|
|
143
|
+
'kwargs': _truncate(str(test.kwargs)),
|
|
144
|
+
}))
|
|
145
|
+
|
|
146
|
+
def _print_result_header(width: int=1) -> None:
|
|
147
|
+
msg = '{funcs:s}{status:<5s} {sep:s} {total:^10s} {sep:s} {median:^10s}'.format(**{
|
|
148
|
+
'funcs': f'{"function":<{width}s}'.format('function'),
|
|
149
|
+
'status': 'status',
|
|
150
|
+
'total': 'Σ ',
|
|
151
|
+
'median': 'x̄',
|
|
152
|
+
'sep': HEADER_SEP,
|
|
153
|
+
})
|
|
154
|
+
border = BORDER_SEP*len(msg)
|
|
155
|
+
print(msg, border, sep='\n')
|
|
156
|
+
|
|
157
|
+
def _print_result(func: Callable, result: Any, correct: bool, times: Counter, width: int=1, colour: str='', extra: str='') -> None:
|
|
158
|
+
fail_sep, status_msg = '\n', ''
|
|
159
|
+
if not correct:
|
|
160
|
+
if os.get_terminal_size().columns >= 100:
|
|
161
|
+
fail_sep = ' '
|
|
162
|
+
result = _truncate(str(result))
|
|
163
|
+
status_msg = pp.ps(f'{fail_sep}>> {result=}', 'yellow')
|
|
164
|
+
|
|
165
|
+
msg = '{func_name:s}{status:<s} {sep:s} {total:s} {sep:s} {median:s} {extra:s}{status_msg:s}'.format(**{
|
|
166
|
+
'func_name': pp.ps(f'{func.__module__+"."+func.__name__+", ":<{width}s}', style=colour),
|
|
167
|
+
'total': _format_time(_sum_times(times)),
|
|
168
|
+
'median': _format_time(_median_times(times)),
|
|
169
|
+
'status': TEST_STATUS[correct],
|
|
170
|
+
'extra': extra,
|
|
171
|
+
'status_msg': status_msg,
|
|
172
|
+
'width': width+2,
|
|
173
|
+
'sep': RECORD_SEP,
|
|
174
|
+
})
|
|
175
|
+
print(msg)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def timeit(n=10_000):
|
|
179
|
+
'Decorator to time a function'
|
|
180
|
+
def decorator_with_args(func):
|
|
181
|
+
@wraps(func)
|
|
182
|
+
def wrapper(*args, **kwargs):
|
|
183
|
+
result, correct, times = timeit_func(func, args, kwargs, NoExpectation, n)
|
|
184
|
+
_print_result(func, result, correct, times)
|
|
185
|
+
return wrapper
|
|
186
|
+
return decorator_with_args
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def bench(tests, func_groups, n: int=10_000, sort: bool=False):
|
|
190
|
+
'Run a series of timed tests on a list of functions'
|
|
191
|
+
s, group_colours = '', ['yellow', 'brightred', 'cyan', 'bold']
|
|
192
|
+
|
|
193
|
+
if os.environ.get('DEBUG'):
|
|
194
|
+
pp.ppd({'tests': tests, 'func_groups': func_groups, 'n': n, 'sort': sort}, indent=None)
|
|
195
|
+
for func_group in func_groups:
|
|
196
|
+
for func in func_group:
|
|
197
|
+
set_function_module(func)
|
|
198
|
+
width = max(len(func.__module__)+len(func.__name__)+3 for func in chain.from_iterable(func_groups))
|
|
199
|
+
|
|
200
|
+
if 'BENCH_SORT' in os.environ:
|
|
201
|
+
sort = True
|
|
202
|
+
|
|
203
|
+
for test in tests:
|
|
204
|
+
test, results = Test(*test, n=n), []
|
|
205
|
+
_print_header(s, test)
|
|
206
|
+
pp.pps('results:', 'bold')
|
|
207
|
+
_print_result_header(width)
|
|
208
|
+
for funcs, group_colour in zip(func_groups, group_colours):
|
|
209
|
+
for func in funcs:
|
|
210
|
+
result, correct, times = timeit_func(func, *test)
|
|
211
|
+
_print_result(func, result, correct, times, width, group_colour)
|
|
212
|
+
results.append((func, result, correct, times, width, group_colour))
|
|
213
|
+
if sort:
|
|
214
|
+
pp.pps('\nsorted by time:', 'bold')
|
|
215
|
+
_print_result_header(width)
|
|
216
|
+
base, extra = 0, ''
|
|
217
|
+
|
|
218
|
+
for _, results in enumerate(sorted(results, key=lambda r: _median_times(r[3]))):
|
|
219
|
+
if not results[2]:
|
|
220
|
+
continue
|
|
221
|
+
if base == 0:
|
|
222
|
+
base = _median_times(results[3])
|
|
223
|
+
else:
|
|
224
|
+
x = _median_times(results[3]) / base
|
|
225
|
+
extra = pp.ps(f' ↓ x{x:.2f}', 'bold')
|
|
226
|
+
_print_result(*results, extra=extra)
|
|
227
|
+
s = '\n'
|
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'This module contains functions for converting between RGB and ANSI colour codes.'
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from typing import Literal, TypeAlias, NamedTuple, Any, Tuple
|
|
6
|
+
|
|
7
|
+
# Valid components of an RGB tuple.
|
|
8
|
+
_RGB_COMPONENT: TypeAlias = Literal['r', 'g', 'b']
|
|
9
|
+
_RGB_COMPONENTS: Tuple[_RGB_COMPONENT,
|
|
10
|
+
_RGB_COMPONENT, _RGB_COMPONENT] = ('r', 'g', 'b')
|
|
11
|
+
|
|
12
|
+
# Multipliers for each component of the RGB tuple in the ANSI colour code formula.
|
|
13
|
+
_RGB_COMPONENT_MULTIPLIER: MappingProxyType[_RGB_COMPONENT, int] = MappingProxyType({
|
|
14
|
+
'r': 36,
|
|
15
|
+
'g': 6,
|
|
16
|
+
'b': 1
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# component _must_ be one of 'r', 'g', or 'b'
|
|
21
|
+
def _ansi_to_rgb_component(n: int, component: _RGB_COMPONENT) -> int:
|
|
22
|
+
'''
|
|
23
|
+
Reverses the rgb_to_ansi formula to calculate a specific component of the RGB tuple
|
|
24
|
+
given an ANSI colour code, and the component to calculate.
|
|
25
|
+
|
|
26
|
+
n = 16 + (36 * r) + (6 * g) + (1 * b)
|
|
27
|
+
'''
|
|
28
|
+
if n < 16 or n >= 232:
|
|
29
|
+
return 0
|
|
30
|
+
# raise ValueError(f'Invalid ANSI colour code for RGB conversion: {n}')
|
|
31
|
+
|
|
32
|
+
i = (
|
|
33
|
+
(n - 16) # 16 is the base value
|
|
34
|
+
# divide by 36/6/1
|
|
35
|
+
// _RGB_COMPONENT_MULTIPLIER[component]
|
|
36
|
+
% 6 # each value can be 0-5
|
|
37
|
+
)
|
|
38
|
+
if i == 0:
|
|
39
|
+
return 0
|
|
40
|
+
# I have nfi what this does, but it is crucial
|
|
41
|
+
return (14135 + (10280 * i)) // 256
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ansi_to_rgb(n: int) -> tuple[int, int, int]:
|
|
45
|
+
'Reverses the rgb_to_ansi formula to calculate an RGB tuple given an ANSI colour code.'
|
|
46
|
+
return (
|
|
47
|
+
_ansi_to_rgb_component(n, 'r'),
|
|
48
|
+
_ansi_to_rgb_component(n, 'g'),
|
|
49
|
+
_ansi_to_rgb_component(n, 'b'),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cube_coords_to_ansi(r: int, g: int, b: int) -> int:
|
|
54
|
+
''''
|
|
55
|
+
The golden formula. Via https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit:
|
|
56
|
+
0- 7: standard colors (as in ESC [ 30–37 m)
|
|
57
|
+
8- 15: high intensity colors (as in ESC [ 90–97 m)
|
|
58
|
+
16-231: 6 × 6 × 6 cube (216 colors): 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5)
|
|
59
|
+
232-255: grayscale from dark to light in 24 steps
|
|
60
|
+
'''
|
|
61
|
+
return 16 + (
|
|
62
|
+
r * _RGB_COMPONENT_MULTIPLIER['r'] +
|
|
63
|
+
g * _RGB_COMPONENT_MULTIPLIER['g'] +
|
|
64
|
+
b * _RGB_COMPONENT_MULTIPLIER['b']
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Valid (supported) ANSI styles.
|
|
69
|
+
_ANSI_STYLES: TypeAlias = Literal['fg', 'bg']
|
|
70
|
+
_ANSI_ESCAPE_CODES: MappingProxyType[_ANSI_STYLES, str] = MappingProxyType({
|
|
71
|
+
'fg': '38;5',
|
|
72
|
+
'bg': '48;5',
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
RESET: str = '\033[0m'
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ANSIColour(NamedTuple):
|
|
79
|
+
'Represents a terminal colour in both RGB and ANSI formats.'
|
|
80
|
+
ansi_n: int
|
|
81
|
+
rgb: tuple[int, int, int]
|
|
82
|
+
|
|
83
|
+
def __escape(self, escape: str) -> str:
|
|
84
|
+
'Returns an ANSI escape code for setting the colour.'
|
|
85
|
+
return f'\033[{escape};{self.ansi_n}m'
|
|
86
|
+
|
|
87
|
+
def escape_code(self, style: _ANSI_STYLES) -> str:
|
|
88
|
+
'Returns the ANSI escape code for setting the colour.'
|
|
89
|
+
return self.__escape(_ANSI_ESCAPE_CODES[style])
|
|
90
|
+
|
|
91
|
+
def colorise(self, text: Any, style: _ANSI_STYLES = 'bg') -> str:
|
|
92
|
+
'Returns the text with the colour applied.'
|
|
93
|
+
return f'{self.escape_code(style)}{text}{RESET}'
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def from_cube_coords(r: int, g: int, b: int) -> ANSIColour:
|
|
97
|
+
'Creates an ANSIColour from an RGB tuple.'
|
|
98
|
+
ansi = cube_coords_to_ansi(r, g, b)
|
|
99
|
+
return ANSIColour(rgb=ansi_to_rgb(ansi), ansi_n=ansi)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def from_ansi(n: int) -> ANSIColour:
|
|
103
|
+
'Creates an ANSIColour from an ANSI colour code.'
|
|
104
|
+
return ANSIColour(ansi_n=n, rgb=ansi_to_rgb(n))
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from itertools import repeat
|
|
3
|
+
from itertools import chain
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from itertools import starmap
|
|
6
|
+
from functools import partial
|
|
7
|
+
import operator
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from typing import List, TypeAlias, Iterable, Literal, Iterator, Dict
|
|
11
|
+
|
|
12
|
+
from laser_prynter.colour import c
|
|
13
|
+
|
|
14
|
+
Cell: TypeAlias = c.ANSIColour
|
|
15
|
+
Row = List[Cell]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Face:
|
|
20
|
+
rows: List[Row]
|
|
21
|
+
with_rotations: bool = True
|
|
22
|
+
rotations: List[Face] = field(default_factory=list)
|
|
23
|
+
flipped_rotations: List[Face] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
def __post_init__(self):
|
|
26
|
+
if self.with_rotations:
|
|
27
|
+
self.rotations = [Face._rot90(
|
|
28
|
+
self.rows, n, flip=False) for n in range(4)]
|
|
29
|
+
self.flipped_rotations = [Face._rot90(
|
|
30
|
+
self.rows, n, flip=True) for n in range(4)]
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _rot90(rows: List[Row], n: int = 1, flip: bool = False) -> Face:
|
|
34
|
+
'Rotate a matrix 90 degrees, n times, optionally flipped'
|
|
35
|
+
if flip:
|
|
36
|
+
rows = list(reversed(rows))
|
|
37
|
+
for _ in range(n):
|
|
38
|
+
rows = list(zip(*rows[::-1]))
|
|
39
|
+
return Face(rows, with_rotations=False)
|
|
40
|
+
|
|
41
|
+
def rot90(self, n: int = 1, flip: bool = False) -> Face:
|
|
42
|
+
'Rotate a matrix 90 degrees, n times, optionally flipped'
|
|
43
|
+
if flip:
|
|
44
|
+
return self.flipped_rotations[n]
|
|
45
|
+
return self.rotations[n]
|
|
46
|
+
|
|
47
|
+
def __iter__(self) -> Iterator[Row]:
|
|
48
|
+
yield from self.rows
|
|
49
|
+
|
|
50
|
+
def __next__(self) -> Row:
|
|
51
|
+
return next(self.__iter__())
|
|
52
|
+
|
|
53
|
+
def __getitem__(self, i: int) -> Row:
|
|
54
|
+
return self.rows[i]
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def empty_face(width: int = 6) -> Face:
|
|
58
|
+
return Face([[c.from_ansi(256)] * width] * width)
|
|
59
|
+
|
|
60
|
+
def iter_s(self, padding_top: int = 0, padding_bottom: int = 0, cell_width: int = 15) -> Iterable[str]:
|
|
61
|
+
for row in self.__iter__():
|
|
62
|
+
p = [cell.colorise(' '*cell_width) for cell in row]
|
|
63
|
+
# r = [cell.colorise(f'{cell.ansi_n:^{cell_width}}') for cell in row]
|
|
64
|
+
r = [cell.colorise(f'{str(cell.rgb):^{cell_width}}')
|
|
65
|
+
for cell in row]
|
|
66
|
+
|
|
67
|
+
for row in chain(repeat(p, padding_top), [r], repeat(p, padding_bottom)):
|
|
68
|
+
yield ''.join(row)
|
|
69
|
+
|
|
70
|
+
def print(self, padding_top: int = 0, padding_bottom: int = 0, cell_width: int = 6) -> None:
|
|
71
|
+
'Print the face, with optional cell padding top/bottom to make it more "square"'
|
|
72
|
+
|
|
73
|
+
print('\n'.join(self.iter_s(padding_top, padding_bottom, cell_width)))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
ANSI_COLOURS = re.compile(r"""
|
|
77
|
+
\x1b # literal ESC
|
|
78
|
+
\[ # literal [
|
|
79
|
+
[;\d]* # zero or more digits or semicolons
|
|
80
|
+
[A-Za-z] # a letter
|
|
81
|
+
""", re.VERBOSE)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Faces:
|
|
86
|
+
faces: list[list[Face]]
|
|
87
|
+
|
|
88
|
+
def __iter__(self) -> Iterator[Face]:
|
|
89
|
+
for face_row in self.faces:
|
|
90
|
+
for face in face_row:
|
|
91
|
+
yield face
|
|
92
|
+
|
|
93
|
+
def __next__(self) -> Face:
|
|
94
|
+
return next(self.__iter__())
|
|
95
|
+
|
|
96
|
+
def iter_rows(self) -> Iterable[Row]:
|
|
97
|
+
for face_row in self.faces:
|
|
98
|
+
for row in zip(*face_row):
|
|
99
|
+
yield row
|
|
100
|
+
|
|
101
|
+
def iter_s(self, padding_top: int = 0, padding_bottom: int = 0, cell_width: int = 6) -> Iterable[str]:
|
|
102
|
+
for face_row in self.faces:
|
|
103
|
+
for row in zip(*[face.iter_s(padding_top, padding_bottom, cell_width) for face in face_row]):
|
|
104
|
+
yield ''.join(row)
|
|
105
|
+
|
|
106
|
+
def as_str(self, padding_top: int = 0, padding_bottom: int = 0, cell_width: int = 6) -> str:
|
|
107
|
+
s = ''
|
|
108
|
+
for row in self.iter_s(padding_top, padding_bottom, cell_width):
|
|
109
|
+
s += row + '\n'
|
|
110
|
+
return s
|
|
111
|
+
|
|
112
|
+
def print(self, padding_top: int = 0, padding_bottom: int = 0, cell_width: int = 6) -> None:
|
|
113
|
+
'Print the faces of the cube, with optional cell padding top/bottom to make it more "square"'
|
|
114
|
+
|
|
115
|
+
print(self.as_str(padding_top, padding_bottom, cell_width))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def distance(c1: tuple[int, int, int], c2: tuple[int, int, int]) -> float:
|
|
119
|
+
return abs(sum(starmap(operator.sub, zip(c2, c1))))
|
|
120
|
+
|
|
121
|
+
# In[69]: c1, c2 = (95, 135, 0), (0, 135, 255)
|
|
122
|
+
# r = interp_xyz(c1, c2, 20)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def lerp(v0: float, v1: float, t: int) -> float:
|
|
126
|
+
'''
|
|
127
|
+
Precise method for iterpolation, which guarantees v = v1 when t = 1.
|
|
128
|
+
This method is monotonic only when v0 * v1 < 0.
|
|
129
|
+
Lerping between same values might not produce the same value
|
|
130
|
+
(from: https://en.wikipedia.org/wiki/Linear_interpolation#Programming_language_support)
|
|
131
|
+
'''
|
|
132
|
+
return round((1 - (t/10)) * v0 + (t/10) * v1, 2)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def interp(v0: float, v1: float, n_t: int) -> list[float]:
|
|
136
|
+
i = partial(lerp, v0, v1)
|
|
137
|
+
ts = list(map(lambda x: x/((n_t-1)/10), range(0, n_t)))
|
|
138
|
+
return list(map(i, ts))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def interp_xyz(c1: tuple[int, int, int], c2: tuple[int, int, int], n_t: int) -> list[float]:
|
|
142
|
+
return list(zip(*starmap(interp, zip(c1, c2, repeat(n_t)))))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class RGBCube:
|
|
147
|
+
faces: Faces
|
|
148
|
+
width: int = 6
|
|
149
|
+
|
|
150
|
+
def print(self) -> None:
|
|
151
|
+
self.faces.print()
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def str_width(self) -> int:
|
|
155
|
+
return max(len(ANSI_COLOURS.sub('', line)) for line in self.faces.iter_s())
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def compare_rows(r1: Row, r2: Row) -> bool:
|
|
159
|
+
return all(c1 == c2 for c1, c2 in zip(r1, r2))
|
|
160
|
+
|
|
161
|
+
def find_face_with_edge(self, face: Face, edge_type: str = 'ts') -> Face:
|
|
162
|
+
if edge_type == 'ts':
|
|
163
|
+
edge = face[0]
|
|
164
|
+
elif edge_type == 'bs':
|
|
165
|
+
edge = face[-1]
|
|
166
|
+
elif edge_type == 'lhs':
|
|
167
|
+
edge = [r[0] for r in face]
|
|
168
|
+
elif edge_type == 'rhs':
|
|
169
|
+
edge = [r[-1] for r in face]
|
|
170
|
+
|
|
171
|
+
for face in self.faces:
|
|
172
|
+
for rot in range(4):
|
|
173
|
+
for flip in (False, True):
|
|
174
|
+
if edge_type == 'ts' and RGBCube.compare_rows(face.rot90(rot, flip=flip)[-1], edge):
|
|
175
|
+
return face.rot90(rot, flip=flip)
|
|
176
|
+
elif edge_type == 'bs' and RGBCube.compare_rows(face.rot90(rot, flip=flip)[0], edge):
|
|
177
|
+
return face.rot90(rot, flip=flip)
|
|
178
|
+
elif edge_type == 'lhs' and RGBCube.compare_rows([r[-1] for r in face.rot90(rot, flip=flip)], edge):
|
|
179
|
+
return face.rot90(rot, flip=flip)
|
|
180
|
+
elif edge_type == 'rhs' and RGBCube.compare_rows([r[0] for r in face.rot90(rot, flip=flip)], edge):
|
|
181
|
+
return face.rot90(rot, flip=flip)
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def from_ranges(c1: Literal[c._RGB_COMPONENT], c2: c._RGB_COMPONENT, c3: c._RGB_COMPONENT) -> RGBCube:
|
|
185
|
+
'''
|
|
186
|
+
Create a 6x6x6 cube of RGB values, where each face is a 6x6 grid of cells.
|
|
187
|
+
Takes an 'order' of RGB components, where
|
|
188
|
+
- c1 is iterated once per face
|
|
189
|
+
- c2 is iterated once per row
|
|
190
|
+
- c3 is iterated once per cell
|
|
191
|
+
'''
|
|
192
|
+
faces = []
|
|
193
|
+
for r1 in range(6):
|
|
194
|
+
face = []
|
|
195
|
+
for r2 in range(6):
|
|
196
|
+
row = []
|
|
197
|
+
for r3 in range(6):
|
|
198
|
+
row.append(
|
|
199
|
+
c.from_cube_coords(**{c1: r1, c2: r2, c3: r3})
|
|
200
|
+
)
|
|
201
|
+
face.append(row)
|
|
202
|
+
faces.append([Face(face)])
|
|
203
|
+
return RGBCube(Faces(faces))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass
|
|
207
|
+
class RGBCubeCollection:
|
|
208
|
+
cubes: Dict[str, RGBCube]
|
|
209
|
+
|
|
210
|
+
def __post_init__(self):
|
|
211
|
+
self.width = os.get_terminal_size().columns
|
|
212
|
+
|
|
213
|
+
def print(self, grid_sep: str = ' '*2, padding_top: int = 0, padding_bottom: int = 0, cell_width: int = 6) -> None:
|
|
214
|
+
groups, current_group, current_width = [], {}, 0
|
|
215
|
+
for name, cube in self.cubes.items():
|
|
216
|
+
if sum(c.str_width for c in current_group.values()) + cube.str_width <= self.width:
|
|
217
|
+
current_group[name] = cube
|
|
218
|
+
else:
|
|
219
|
+
groups.append(current_group)
|
|
220
|
+
current_group = {name: cube}
|
|
221
|
+
groups.append(current_group)
|
|
222
|
+
|
|
223
|
+
for g in groups:
|
|
224
|
+
for name, c in g.items():
|
|
225
|
+
print(f'{name:<{c.str_width}s}', end=grid_sep)
|
|
226
|
+
print()
|
|
227
|
+
for rows in zip(*[c.faces.iter_s(padding_top, padding_bottom, cell_width) for n, c in g.items()]):
|
|
228
|
+
print(grid_sep.join(rows))
|
|
229
|
+
|
|
230
|
+
def find_face_with_edge(collection: RGBCubeCollection, face_name: str, face: Face, edge_type: str) -> Face:
|
|
231
|
+
for n, cube in collection.cubes.items():
|
|
232
|
+
if n == face_name:
|
|
233
|
+
continue
|
|
234
|
+
f = cube.find_face_with_edge(face, edge_type)
|
|
235
|
+
if f:
|
|
236
|
+
return f, n
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def create_cube(f1, f1_name, cube_collection):
|
|
240
|
+
f2, f2_name = find_face_with_edge(cube_collection, f1_name, f1, 'lhs')
|
|
241
|
+
f3, f3_name = find_face_with_edge(cube_collection, f1_name, f1, 'bs')
|
|
242
|
+
f4, f4_name = find_face_with_edge(cube_collection, f1_name, f1, 'ts')
|
|
243
|
+
f5, f5_name = find_face_with_edge(cube_collection, f3_name, f3, 'rhs')
|
|
244
|
+
f6, f6_name = find_face_with_edge(cube_collection, f3_name, f3, 'bs')
|
|
245
|
+
|
|
246
|
+
faces = [
|
|
247
|
+
[Face.empty_face(6), f4, Face.empty_face(6)],
|
|
248
|
+
[f2, f1, Face.empty_face(6)],
|
|
249
|
+
[Face.empty_face(6), f3, f5],
|
|
250
|
+
[Face.empty_face(6), f6, Face.empty_face(6)],
|
|
251
|
+
]
|
|
252
|
+
Faces(faces).print(padding_top=0, padding_bottom=1, cell_width=15)
|
|
253
|
+
|
|
254
|
+
c1 = f2[2][3].rgb
|
|
255
|
+
c2 = f4[2][3].rgb
|
|
256
|
+
|
|
257
|
+
print(f'c1: {c1}, c2: {c2}')
|
|
258
|
+
|
|
259
|
+
g = interp_xyz(c1, c2, 10)
|
|
260
|
+
for r, g, b in g:
|
|
261
|
+
print(
|
|
262
|
+
'\033[48;5;{};{};{}m'.format(
|
|
263
|
+
int(r), int(g), int(b)
|
|
264
|
+
) + f'{str((r, g, b)):^10s}' + '\033[0m'
|
|
265
|
+
)
|
|
266
|
+
# print(c.from_rgb(r, g, b).colorise(' '*8))
|
laser_prynter/log.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'''
|
|
2
|
+
A module to create loggers with custom handlers and a custom formatter.
|
|
3
|
+
|
|
4
|
+
usage examples to initialise a logger:
|
|
5
|
+
```python
|
|
6
|
+
# 1. initialise logger to stderr:
|
|
7
|
+
logger = getLogger('my_logger', level=logging.DEBUG, stream=sys.stderr)
|
|
8
|
+
|
|
9
|
+
# 2. initialise logger to file:
|
|
10
|
+
logger = getLogger('my_logger', level=logging.DEBUG, filename='my_log.log')
|
|
11
|
+
|
|
12
|
+
# 3. initialise logger to both stderr and file:
|
|
13
|
+
logger = getLogger('my_logger', level=logging.DEBUG, stream=sys.stderr, files={LogLevel.INFO: 'info.log'})
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
usage examples to log messages:
|
|
17
|
+
```python
|
|
18
|
+
logger.info('This is a basic info message')
|
|
19
|
+
# {"timestamp": "2024-12-09T15:05:43.904417+10:00", "msg": "This is a basic info message", "event": {}}
|
|
20
|
+
|
|
21
|
+
logger.info('This is an info message', {'key': 'value'})
|
|
22
|
+
# {"timestamp": "2024-12-09T15:05:43.904600+10:00", "msg": "This is an info message", "event": {"key": "value"}}
|
|
23
|
+
|
|
24
|
+
logger.debug('This is a debug message', 'arg1', 'arg2', {'key': 'value'})
|
|
25
|
+
# {"timestamp": "2024-12-09T15:05:43.904749+10:00", "msg": "This is a debug message", "event": {"args": ["arg1", "arg2"], "key": "value"}}
|
|
26
|
+
```
|
|
27
|
+
'''
|
|
28
|
+
|
|
29
|
+
from datetime import datetime
|
|
30
|
+
import io
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
from logging.handlers import TimedRotatingFileHandler
|
|
34
|
+
import os
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
from laser_prynter.pp import _json_default
|
|
38
|
+
|
|
39
|
+
class LogLevel:
|
|
40
|
+
'An enum type for log levels.'
|
|
41
|
+
CRITICAL = logging.CRITICAL
|
|
42
|
+
ERROR = logging.ERROR
|
|
43
|
+
WARNING = logging.WARNING
|
|
44
|
+
INFO = logging.INFO
|
|
45
|
+
DEBUG = logging.DEBUG
|
|
46
|
+
NOTSET = logging.NOTSET
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
DEFAULT_LOG_LEVEL = LogLevel.INFO
|
|
50
|
+
|
|
51
|
+
class LogFormatter(logging.Formatter):
|
|
52
|
+
'Custom log formatter that formats log messages as JSON, aka "Structured Logging".'
|
|
53
|
+
def __init__(self, defaults: dict = {}):
|
|
54
|
+
'''
|
|
55
|
+
Initializes the log formatter with optional default context.
|
|
56
|
+
- `defaults` is a dictionary of default context values to include in every log message.
|
|
57
|
+
'''
|
|
58
|
+
self.defaults = defaults
|
|
59
|
+
super().__init__()
|
|
60
|
+
|
|
61
|
+
def format(self, record) -> str:
|
|
62
|
+
'Formats the log message as JSON.'
|
|
63
|
+
|
|
64
|
+
args, kwargs = None, {}
|
|
65
|
+
if isinstance(record.args, tuple):
|
|
66
|
+
if len(record.args) == 1:
|
|
67
|
+
args = record.args
|
|
68
|
+
elif len(record.args) > 1:
|
|
69
|
+
*args, kwargs = record.args
|
|
70
|
+
elif isinstance(record.args, dict):
|
|
71
|
+
kwargs = record.args
|
|
72
|
+
|
|
73
|
+
record.msg = json.dumps(
|
|
74
|
+
{
|
|
75
|
+
'timestamp': datetime.now().astimezone().isoformat(),
|
|
76
|
+
'level': record.levelname,
|
|
77
|
+
'name': record.name,
|
|
78
|
+
'msg': record.msg,
|
|
79
|
+
'event': {'args': args} if args else {} | kwargs or {},
|
|
80
|
+
**({'context': self.defaults} if self.defaults else {}),
|
|
81
|
+
},
|
|
82
|
+
default=_json_default,
|
|
83
|
+
)
|
|
84
|
+
record.args = ()
|
|
85
|
+
return super().format(record)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _getLogger(
|
|
89
|
+
name: str,
|
|
90
|
+
level: int = logging.CRITICAL,
|
|
91
|
+
handlers: list[logging.Handler] = [],
|
|
92
|
+
context: dict = {},
|
|
93
|
+
) -> logging.Logger:
|
|
94
|
+
'''
|
|
95
|
+
Creates a logger with the given name, level, and handlers.
|
|
96
|
+
- If no handlers are provided, the logger will not output any logs.
|
|
97
|
+
- This function requires the handlers to be initialized when passed as args.
|
|
98
|
+
- the same log level is applied to all handlers.
|
|
99
|
+
'''
|
|
100
|
+
|
|
101
|
+
# create the root logger
|
|
102
|
+
logger = logging.getLogger()
|
|
103
|
+
logger.setLevel(level)
|
|
104
|
+
|
|
105
|
+
# close/remove any existing handlers
|
|
106
|
+
while logger.handlers:
|
|
107
|
+
for handler in logger.handlers:
|
|
108
|
+
handler.close()
|
|
109
|
+
logger.removeHandler(handler)
|
|
110
|
+
|
|
111
|
+
# create the logger
|
|
112
|
+
logger = logging.getLogger(name)
|
|
113
|
+
logger.setLevel(level)
|
|
114
|
+
|
|
115
|
+
# close/remove any existing handlers
|
|
116
|
+
while logger.handlers:
|
|
117
|
+
for handler in logger.handlers:
|
|
118
|
+
handler.close()
|
|
119
|
+
logger.removeHandler(handler)
|
|
120
|
+
|
|
121
|
+
# add the new handlers
|
|
122
|
+
for handler in handlers:
|
|
123
|
+
logger.addHandler(handler)
|
|
124
|
+
|
|
125
|
+
if logger.handlers:
|
|
126
|
+
# only set the first handler to use the custom formatter
|
|
127
|
+
logger.handlers[0].setFormatter(LogFormatter(defaults=context))
|
|
128
|
+
|
|
129
|
+
return logger
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def getLogger(
|
|
133
|
+
name: str,
|
|
134
|
+
level: int = -1,
|
|
135
|
+
stream: io.TextIOBase = sys.stdout,
|
|
136
|
+
files: dict[LogLevel, str] = {},
|
|
137
|
+
context: dict = {},
|
|
138
|
+
) -> logging.Logger:
|
|
139
|
+
'''
|
|
140
|
+
Creates a logger with the given name, level, and handlers.
|
|
141
|
+
- `name` is the name of the logger.
|
|
142
|
+
- `stream` is the output stream for the logger (default is STDERR).
|
|
143
|
+
- `files` is a dictionary of log levels and filenames for file handlers.
|
|
144
|
+
- The keys are log levels (e.g., LogLevel.INFO, LogLevel.DEBUG).
|
|
145
|
+
- The values are the filenames to log to at the corresponding level.
|
|
146
|
+
- The file handlers will use `TimedRotatingFileHandler` to rotate logs at midnight and keep 7 backups.
|
|
147
|
+
- `level` is the log level for the logger and all handlers (default is INFO).
|
|
148
|
+
- if `level` is not provided, it will check the environment variable `LOG_LEVEL` and use its value if it exists
|
|
149
|
+
- otherwise it defaults to `LogLevel.INFO`.
|
|
150
|
+
'''
|
|
151
|
+
|
|
152
|
+
if level == -1:
|
|
153
|
+
if 'LOG_LEVEL' in os.environ:
|
|
154
|
+
level = getattr(logging, os.environ['LOG_LEVEL'].upper())
|
|
155
|
+
else:
|
|
156
|
+
level = DEFAULT_LOG_LEVEL
|
|
157
|
+
|
|
158
|
+
handlers = []
|
|
159
|
+
if stream:
|
|
160
|
+
handler = logging.StreamHandler(stream)
|
|
161
|
+
handler.setLevel(level)
|
|
162
|
+
handlers.append(handler)
|
|
163
|
+
|
|
164
|
+
for flevel, filename in files.items():
|
|
165
|
+
handler = TimedRotatingFileHandler(
|
|
166
|
+
filename, when='midnight', backupCount=7, encoding='utf-8',
|
|
167
|
+
)
|
|
168
|
+
handler.setLevel(flevel)
|
|
169
|
+
handlers.append(handler)
|
|
170
|
+
|
|
171
|
+
return _getLogger(name, level, handlers, context=context)
|
laser_prynter/pp.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from dataclasses import asdict, is_dataclass, dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
import json
|
|
4
|
+
import random
|
|
5
|
+
from types import FunctionType
|
|
6
|
+
|
|
7
|
+
from pygments import highlight, console
|
|
8
|
+
from pygments.lexers import JsonLexer, OutputLexer
|
|
9
|
+
from pygments.formatters import Terminal256Formatter
|
|
10
|
+
from pygments.styles import get_style_by_name, get_all_styles
|
|
11
|
+
|
|
12
|
+
STYLES = (
|
|
13
|
+
'dracula', 'fruity', 'gruvbox-dark', 'gruvbox-light', 'lightbulb', 'material', 'native',
|
|
14
|
+
'one-dark', 'perldoc', 'tango',
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def _isnamedtuple(obj: object):
|
|
18
|
+
return isinstance(obj, tuple) and hasattr(obj, '_fields')
|
|
19
|
+
|
|
20
|
+
def _normalise(obj: object):
|
|
21
|
+
'step through obj and normalise namedtuples to dicts'
|
|
22
|
+
if isinstance(obj, dict): return {k: _normalise(v) for k, v in obj.items()}
|
|
23
|
+
if isinstance(obj, list): return [_normalise(i) for i in obj]
|
|
24
|
+
if _isnamedtuple(obj): return obj._asdict()
|
|
25
|
+
return obj
|
|
26
|
+
|
|
27
|
+
def _json_default(obj: object):
|
|
28
|
+
'Default JSON serializer, supports most main class types'
|
|
29
|
+
if isinstance(obj, str): return obj # str
|
|
30
|
+
elif isinstance(obj, list): return [_json_default(i) for i in obj]
|
|
31
|
+
elif is_dataclass(obj): return asdict(obj) # dataclass
|
|
32
|
+
elif isinstance(obj, datetime): return obj.isoformat() # datetime
|
|
33
|
+
elif isinstance(obj, FunctionType): return f'{obj.__name__}()' # function
|
|
34
|
+
elif hasattr(obj, '__slots__'): return {k: getattr(obj, k) for k in obj.__slots__} # class with slots.
|
|
35
|
+
elif hasattr(obj, '__name__'): return obj.__name__ # function/class name
|
|
36
|
+
elif hasattr(obj, '__dict__'): return obj.__dict__ # class
|
|
37
|
+
return str(obj)
|
|
38
|
+
|
|
39
|
+
def ppd(d_obj, indent=2, style='dracula', random_style=False):
|
|
40
|
+
'pretty-print a dict'
|
|
41
|
+
d = _normalise(d_obj) # convert any namedtuples to dicts
|
|
42
|
+
|
|
43
|
+
if random_style:
|
|
44
|
+
style = random.choice(STYLES)
|
|
45
|
+
code = json.dumps(d, indent=indent, default=_json_default)
|
|
46
|
+
|
|
47
|
+
if style is None:
|
|
48
|
+
print(code)
|
|
49
|
+
else:
|
|
50
|
+
print(highlight(
|
|
51
|
+
code = code,
|
|
52
|
+
lexer = JsonLexer(),
|
|
53
|
+
formatter = Terminal256Formatter(style=get_style_by_name(style))
|
|
54
|
+
).strip())
|
|
55
|
+
|
|
56
|
+
def ppj(j: str, indent: int=None, style: str='dracula', random_style: bool=False) -> None:
|
|
57
|
+
'pretty-print a JSON string'
|
|
58
|
+
ppd(json.loads(j), indent=indent, style=style, random_style=random_style)
|
|
59
|
+
|
|
60
|
+
def ps(s: str, style: str='yellow', random_style: bool=False) -> str:
|
|
61
|
+
'add color to a string'
|
|
62
|
+
if random_style:
|
|
63
|
+
style = random.choice(console.dark_colors + console.light_colors)
|
|
64
|
+
return console.colorize(style, s)
|
|
65
|
+
|
|
66
|
+
def pps(s: str, style: str='yellow', random_style: bool=False) -> None:
|
|
67
|
+
'pretty-print a string'
|
|
68
|
+
print(ps(s, style=style, random_style=random_style))
|
|
69
|
+
|
|
70
|
+
def demo() -> None:
|
|
71
|
+
'demonstrate pretty-printing colours'
|
|
72
|
+
|
|
73
|
+
for s in STYLES:
|
|
74
|
+
ppd({'message': {'Hello': 'World', 'The answer is': 42}, 'style': s}, style=s, indent=None)
|
|
75
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: laser-prynter
|
|
3
|
+
Version: 0.2.5
|
|
4
|
+
Summary: terminal/cli/python helpers for colour and pretty-printing
|
|
5
|
+
Author-email: tmck-code <tmck01@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/tmck-code/laser-prynter
|
|
7
|
+
Project-URL: Issues, https://github.com/tmck-code/laser-prynter/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: pygments>=2.19.2
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# laser-prynter
|
|
18
|
+
terminal/cli/python helpers for colour and pretty-printing
|
|
19
|
+
|
|
20
|
+
- [laser_prynter](#laser_prynter)
|
|
21
|
+
- [`bench`](#bench)
|
|
22
|
+
- [`laser_prynter`](#laser_prynter-1)
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## `laser_prynter`
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
https://github.com/user-attachments/assets/cce8f690-e411-459f-a04f-8e9bef533e4a
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## `bench`
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
https://github.com/user-attachments/assets/4af823b0-8d18-4086-9754-c76c65b66898
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from laser_prynter import bench
|
|
42
|
+
|
|
43
|
+
bench.bench(
|
|
44
|
+
tests=[
|
|
45
|
+
(
|
|
46
|
+
(range(2),), # args
|
|
47
|
+
{}, # kwargs
|
|
48
|
+
[0,1], # expected
|
|
49
|
+
)
|
|
50
|
+
],
|
|
51
|
+
func_groups=[ [list] ],
|
|
52
|
+
n=100
|
|
53
|
+
)
|
|
54
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
laser_prynter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
laser_prynter/bench.py,sha256=1nmDjp-8QPP6QGAYIGUOgASdmDqk1yM5V2s2ROmeGt4,7679
|
|
3
|
+
laser_prynter/log.py,sha256=BnhFTwTzHskIlLEiZSRgfn51JEzfgd3e4x0_lX-Ad_o,5858
|
|
4
|
+
laser_prynter/pp.py,sha256=HyChRTcpEhFtM5a9dyNWghr9Y1q_HZCPwkNUdsiJX9c,2917
|
|
5
|
+
laser_prynter/colour/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
laser_prynter/colour/c.py,sha256=WYji8YnYef10iwW0iuPLlasqVV5CXNUNlFreYs3LevM,3468
|
|
7
|
+
laser_prynter/colour/gradient.py,sha256=sTjTGNl8rStubVRJ7aYA4e41XZ4tnT_GF8-ZkRatiLg,9472
|
|
8
|
+
laser_prynter-0.2.5.dist-info/licenses/LICENSE,sha256=NIfFgUC076pLuzRpq-VGlQay6_41rkkMaOJfYEN-M7E,1500
|
|
9
|
+
laser_prynter-0.2.5.dist-info/METADATA,sha256=ckNeh8jyP143LBZGroY_jKK650MM9njOHOXSxZLZ_Hw,1214
|
|
10
|
+
laser_prynter-0.2.5.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
11
|
+
laser_prynter-0.2.5.dist-info/top_level.txt,sha256=PG6nRKi_gi_kNhRrI1I91s7Z4hh2wLj7fpDQrp5FaFI,14
|
|
12
|
+
laser_prynter-0.2.5.dist-info/RECORD,,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024, Tom McKeesick
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
laser_prynter
|