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.
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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