densitty 0.8.2__py3-none-any.whl → 1.0.0__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.
densitty/detect.py CHANGED
@@ -9,14 +9,14 @@ from types import MappingProxyType
9
9
  from typing import Any, Callable, Optional, Sequence
10
10
  import time
11
11
 
12
- from . import ansi, ascii_art, binning, lineart, truecolor
13
- from . import plot as plotmodule
14
- from .util import FloatLike, ValueRange
12
+ from . import ansi, ascii_art, axis, binning, colorbar, lineart, plotting, smoothing, truecolor
13
+ from .util import FloatLike, ValueRange, make_value_range
15
14
 
16
15
  if sys.platform == "win32":
17
16
  # pylint: disable=import-error
18
17
  import ctypes
19
- from ctypes.windll.kernel32 import GetConsoleMode, GetStdHandle, SetConsoleMode
18
+
19
+ kernel32 = ctypes.windll.kernel32
20
20
  else:
21
21
  # All other platforms should have TERMIOS available
22
22
  import fcntl
@@ -86,10 +86,10 @@ if sys.platform == "win32":
86
86
  """Windows-based wrapper to avoid control code output to stdout"""
87
87
  prev_stdin_mode = ctypes.wintypes.DWORD(0)
88
88
  prev_stdout_mode = ctypes.wintypes.DWORD(0)
89
- GetConsoleMode(GetStdHandle(-10), ctypes.byref(prev_stdin_mode))
90
- SetConsoleMode(GetStdHandle(-10), 0)
91
- GetConsoleMode(GetStdHandle(-11), ctypes.byref(prev_stdout_mode))
92
- SetConsoleMode(GetStdHandle(-11), 7)
89
+ kernel32.GetConsoleMode(kernel32.GetStdHandle(-10), ctypes.byref(prev_stdin_mode))
90
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-10), 0)
91
+ kernel32.GetConsoleMode(kernel32.GetStdHandle(-11), ctypes.byref(prev_stdout_mode))
92
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
93
93
 
94
94
  # On Windows, don't try to be non-blocking, just read until terminator
95
95
  if length is None:
@@ -106,8 +106,8 @@ if sys.platform == "win32":
106
106
  break
107
107
  return response
108
108
  finally:
109
- SetConsoleMode(GetStdHandle(-10), ctypes.byref(prev_stdin_mode))
110
- SetConsoleMode(GetStdHandle(-11), ctypes.byref(prev_stdout_mode))
109
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-10), ctypes.byref(prev_stdin_mode))
110
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), ctypes.byref(prev_stdout_mode))
111
111
 
112
112
  else:
113
113
  # Not Windows, so use termios/fcntl:
@@ -304,7 +304,8 @@ def color_support(interactive=True, debug=False) -> ColorSupport:
304
304
  return ColorSupport.NONE
305
305
 
306
306
  env_term = os.environ.get("TERM", "")
307
- print(f"$TERM is {env_term}")
307
+ if debug:
308
+ print(f"$TERM is {env_term}")
308
309
 
309
310
  truecolor_terminals = ("truecolor", "xterm-kitty", "xterm-ghostty", "wezterm")
310
311
  if env_term in truecolor_terminals:
@@ -362,6 +363,13 @@ def color_support(interactive=True, debug=False) -> ColorSupport:
362
363
  return min(ColorSupport.ANSI_24BIT, color_cap)
363
364
 
364
365
  if curses:
366
+ # Curses is installed, but it may or may not have been set up. Try and see
367
+ try:
368
+ curses.tigetflag("RGB")
369
+ # If this gets an error, it can be an internal type not derived from Exception
370
+ # so just catch everything:
371
+ except: # pylint: disable=bare-except
372
+ curses.setupterm()
365
373
  if curses.tigetflag("RGB") == 1:
366
374
  # ncurses 6.0+ / terminfo added an "RGB" capability to indicate truecolor support
367
375
  return min(ColorSupport.ANSI_24BIT, color_cap)
@@ -371,6 +379,8 @@ def color_support(interactive=True, debug=False) -> ColorSupport:
371
379
  if curses.tigetnum("colors") == 256:
372
380
  return min(ColorSupport.ANSI_8BIT, color_cap)
373
381
 
382
+ curses_colors = curses.tigetnum("colors") # for use below
383
+
374
384
  if env_term.endswith("-256color") or env_term.endswith("-256"):
375
385
  if debug:
376
386
  print("Color detect: $TERM suffix in 8b list")
@@ -391,8 +401,9 @@ def color_support(interactive=True, debug=False) -> ColorSupport:
391
401
  print("Color detect: using terminal's Device Attributes")
392
402
  return da1_color_support(debug)
393
403
 
394
- if curses and curses.tigetnum("colors") >= 16:
395
- return min(ColorSupport.ANSI_4BIT, color_cap)
404
+ if curses:
405
+ if curses_colors >= 16:
406
+ return min(ColorSupport.ANSI_4BIT, color_cap)
396
407
 
397
408
  return ColorSupport.NONE
398
409
 
@@ -415,6 +426,24 @@ FADE_IN = MappingProxyType(
415
426
  }
416
427
  )
417
428
 
429
+ RAINBOW = MappingProxyType(
430
+ {
431
+ ColorSupport.NONE: ascii_art.EXTENDED,
432
+ ColorSupport.ANSI_4BIT: ansi.RAINBOW_16,
433
+ ColorSupport.ANSI_8BIT: ansi.RAINBOW,
434
+ ColorSupport.ANSI_24BIT: truecolor.RAINBOW,
435
+ }
436
+ )
437
+
438
+ REV_RAINBOW = MappingProxyType(
439
+ {
440
+ ColorSupport.NONE: ascii_art.EXTENDED,
441
+ ColorSupport.ANSI_4BIT: ansi.REV_RAINBOW_16,
442
+ ColorSupport.ANSI_8BIT: ansi.REV_RAINBOW,
443
+ ColorSupport.ANSI_24BIT: truecolor.REV_RAINBOW,
444
+ }
445
+ )
446
+
418
447
 
419
448
  def pick_colormap(maps: dict[ColorSupport, Callable]) -> Callable:
420
449
  """Detect color support and pick the best color map"""
@@ -422,21 +451,33 @@ def pick_colormap(maps: dict[ColorSupport, Callable]) -> Callable:
422
451
  return maps[support]
423
452
 
424
453
 
425
- def plot(data, colors=FADE_IN, **plotargs):
426
- """Wrapper for plot.Plot that picks colormap from dict"""
454
+ def plot(data, colors=FADE_IN, colorscale=False, **plotargs):
455
+ """Wrapper for plot.Plot that picks colormap from dict
456
+
457
+ Parameters
458
+ ----------
459
+ data : Sequence[Sequence[float]]
460
+ The data to be plotted.
461
+ colors : Dict mapping color support to color map
462
+ The color scale (e.g. GRAYSCALE, FADE_IN)
463
+ colorscale: bool
464
+ Display a vertical color scale on the right
465
+ (for horizontal, just directly create a colorbar
466
+ and output it after outputting the plot)
467
+ plotargs: any Plot() keyword arguments
468
+ """
427
469
  colormap = pick_colormap(colors)
428
- return plotmodule.Plot(data, colormap, **plotargs)
470
+ the_plot = plotting.Plot(data, colormap, **plotargs)
471
+ if colorscale:
472
+ colorbar.add_colorbar(the_plot)
473
+ return the_plot
429
474
 
430
475
 
431
476
  def histplot2d(
432
477
  points: Sequence[tuple[FloatLike, FloatLike]],
433
- bins: (
434
- int
435
- | tuple[int, int]
436
- | Sequence[FloatLike]
437
- | tuple[Sequence[FloatLike], Sequence[FloatLike]]
438
- ) = 10,
478
+ bins: binning.FullBinsArg = None,
439
479
  ranges: Optional[tuple[Optional[ValueRange], Optional[ValueRange]]] = None,
480
+ bin_size: Optional[FloatLike | tuple[FloatLike, FloatLike]] = None,
440
481
  align=True,
441
482
  drop_outside=True,
442
483
  colors=FADE_IN,
@@ -449,8 +490,9 @@ def histplot2d(
449
490
  """Wrapper for binning.histogram2d / plot.Plot to simplify 2-D histogram plotting"""
450
491
  binned_data, x_axis, y_axis = binning.histogram2d(
451
492
  points,
452
- bins,
493
+ bins=bins,
453
494
  ranges=ranges,
495
+ bin_size=bin_size,
454
496
  align=align,
455
497
  drop_outside=drop_outside,
456
498
  border_line=border_line,
@@ -460,6 +502,82 @@ def histplot2d(
460
502
  if scale is True:
461
503
  p.upscale()
462
504
  elif scale:
463
- p.upscale(max_expansion=(scale, scale))
505
+ p.upscale(max_expansion=scale)
506
+
507
+ return p
508
+
509
+
510
+ def densityplot2d(
511
+ points: Sequence[tuple[FloatLike, FloatLike]],
512
+ kernel: Optional[smoothing.SmoothingFunc] = None,
513
+ bins: binning.FullBinsArg = None,
514
+ ranges: Optional[tuple[Optional[ValueRange], Optional[ValueRange]]] = None,
515
+ align=True,
516
+ colors=FADE_IN,
517
+ border_line=True,
518
+ fractional_tick_pos=False,
519
+ **plotargs,
520
+ # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
521
+ ):
522
+ """Wrapper for smoothing.smooth2d / plot.Plot to simplify 2-D density plots"""
523
+
524
+ if not bins:
525
+ try:
526
+ terminal_size: Optional[os.terminal_size] = os.get_terminal_size()
527
+ except OSError:
528
+ terminal_size = plotting.default_terminal_size
529
+ if terminal_size is None:
530
+ raise OSError("No terminal size from os.get_terminal_size()")
531
+ size_x = terminal_size.columns - 10
532
+ size_y = terminal_size.lines - 4
533
+ bins = (size_x, size_y)
534
+
535
+ if kernel is None:
536
+ _, num_bins, bin_centers = binning.expand_bins_arg(bins)
537
+ if bin_centers:
538
+ # we were given a list of bin centers, so generate bounds from that:
539
+ x_bin_range = make_value_range((bin_centers[0][0], bin_centers[0][-1]))
540
+ y_bin_range = make_value_range((bin_centers[1][0], bin_centers[1][-1]))
541
+ x_width, y_width = smoothing.pick_kernel_bandwidth(
542
+ points, bins=num_bins, ranges=(x_bin_range, y_bin_range)
543
+ )
544
+ else:
545
+ x_width, y_width = smoothing.pick_kernel_bandwidth(points, bins=num_bins)
546
+ kernel = smoothing.gaussian_with_sigmas(x_width, y_width)
547
+
548
+ smoothed, x_axis, y_axis = smoothing.smooth2d(
549
+ points=points,
550
+ kernel=kernel,
551
+ bins=bins,
552
+ ranges=ranges,
553
+ align=align,
554
+ border_line=border_line,
555
+ fractional_tick_pos=fractional_tick_pos,
556
+ )
557
+ p = plot(smoothed, colors, x_axis=x_axis, y_axis=y_axis, **plotargs)
464
558
 
465
559
  return p
560
+
561
+
562
+ def grid_heatmap(data, x_labels, y_labels, colors=REV_RAINBOW, max_cell_size=0, **plotargs):
563
+ """Create a grid-style heatmap, with explicit X and Y labels for each bin"""
564
+ if max_cell_size == 0:
565
+ max_x_label = max(len(x) for x in x_labels)
566
+ max_cell_size = max_x_label + 2
567
+
568
+ num_rows = len(data)
569
+ num_cols = len(data[0])
570
+ if y_labels and len(y_labels) != num_rows:
571
+ raise ValueError("Number of Y labels does not match number of rows")
572
+ if x_labels and len(x_labels) != num_cols:
573
+ raise ValueError("Number of X labels does not match number of columns")
574
+
575
+ # value range along axis is arbitrary, but is needed to locate the labels along the axis
576
+ # so just use integers 0..N-1
577
+ x_axis = axis.Axis((0, num_cols - 1), dict(enumerate(x_labels)))
578
+ y_axis = axis.Axis((0, num_rows - 1), dict(enumerate(y_labels)))
579
+
580
+ plt = plot(data, colors=colors, x_axis=x_axis, y_axis=y_axis, flip_y=False, **plotargs)
581
+ plt.upscale(max_expansion=max_cell_size)
582
+
583
+ return plt
@@ -1,13 +1,20 @@
1
1
  """Two-dimensional histogram (density plot) with textual output."""
2
2
 
3
3
  import dataclasses
4
- from itertools import chain, zip_longest
5
4
  import os
6
5
  import sys
7
- from typing import Callable, Optional, Sequence
6
+ import typing
7
+ from itertools import chain, zip_longest
8
+ from typing import Any, Callable, Optional, Sequence
9
+
10
+ from . import ansi, lineart
8
11
 
9
- from . import ansi, axis, lineart
10
- from .util import FloatLike
12
+ if typing.TYPE_CHECKING:
13
+ from .axis import Axis
14
+ from .util import FloatLike
15
+ else:
16
+ Axis = Any
17
+ FloatLike = Any
11
18
 
12
19
  # pylint: disable=invalid-name
13
20
  # User can set this to provide a default if os.terminal_size() fails:
@@ -27,9 +34,17 @@ class Plot:
27
34
  font_mapping: dict = dataclasses.field(default_factory=lambda: lineart.basic_font)
28
35
  min_data: Optional[FloatLike] = None
29
36
  max_data: Optional[FloatLike] = None
30
- x_axis: Optional[axis.Axis] = None
31
- y_axis: Optional[axis.Axis] = None
37
+ x_axis: Optional[Axis] = None
38
+ y_axis: Optional[Axis] = None
32
39
  flip_y: bool = True # put the first row of data at the bottom of the output
40
+ to_right: Optional["Plot" | Sequence[str]] = None
41
+ right_padding: Optional[str] = None
42
+
43
+ def data_limits(self):
44
+ """Return (min,max) of the plot data."""
45
+ min_data = min(min(line) for line in self.data) if self.min_data is None else self.min_data
46
+ max_data = max(max(line) for line in self.data) if self.max_data is None else self.max_data
47
+ return (min_data, max_data)
33
48
 
34
49
  def as_ascii(self):
35
50
  """Output using direct characters (ASCII-art)."""
@@ -66,8 +81,7 @@ class Plot:
66
81
  Also flips data if requested
67
82
  """
68
83
 
69
- min_data = min(min(line) for line in self.data) if self.min_data is None else self.min_data
70
- max_data = max(max(line) for line in self.data) if self.max_data is None else self.max_data
84
+ min_data, max_data = self.data_limits()
71
85
  data_scale = max_data - min_data
72
86
  if data_scale == 0:
73
87
  # all data has the same value (or we were told it does)
@@ -84,6 +98,17 @@ class Plot:
84
98
  """Are there two pixels per output character?"""
85
99
  return self.is_color() and self.render_halfheight
86
100
 
101
+ def left_margin(self):
102
+ """Returns the column of the first plotted data"""
103
+ if self.y_axis:
104
+ if self.is_halfheight():
105
+ num_rows = (len(self.data) + 1) // 2
106
+ else:
107
+ num_rows = len(self.data)
108
+ y_axis_lines = self.y_axis.render_as_y(num_rows, False, False, False)
109
+ return lineart.display_len(y_axis_lines[0])
110
+ return 0
111
+
87
112
  def as_strings(self):
88
113
  """Scale data to 0..1 range and feed it through the appropriate output function"""
89
114
  if self.is_halfheight():
@@ -98,22 +123,34 @@ class Plot:
98
123
 
99
124
  if self.y_axis:
100
125
  axis_lines = self.y_axis.render_as_y(num_rows, False, bool(self.x_axis), self.flip_y)
126
+ left_margin = lineart.display_len(axis_lines[0])
101
127
  else:
102
128
  axis_lines = ["" for _ in range(num_rows + bool(self.x_axis))]
129
+ left_margin = 0
103
130
 
104
- left_margin = lineart.display_len(axis_lines[0])
105
131
  if self.x_axis:
106
132
  x_ticks, x_labels = self.x_axis.render_as_x(num_cols, left_margin)
107
133
  axis_lines[-1] = lineart.merge_lines(x_ticks, axis_lines[-1])
108
134
  axis_lines += [x_labels]
109
135
 
110
- for frame, plot_line in zip_longest(axis_lines, plot_lines, fillvalue=""):
111
- yield frame.translate(self.font_mapping) + plot_line
136
+ if isinstance(self.to_right, Plot):
137
+ lines_to_right = self.to_right.as_strings()
138
+ elif self.to_right is not None:
139
+ lines_to_right = self.to_right
140
+ else:
141
+ lines_to_right = []
142
+
143
+ padding = self.right_padding if self.right_padding is not None else ""
144
+
145
+ for frame, plot_line, glued_on in zip_longest(
146
+ axis_lines, plot_lines, lines_to_right, fillvalue=""
147
+ ):
148
+ yield frame.translate(self.font_mapping) + plot_line + padding + glued_on
112
149
 
113
- def show(self, printer=print):
150
+ def show(self, prefix="", printer=print):
114
151
  """Simple helper function to output/print a plot"""
115
152
  for line in self.as_strings():
116
- printer(line)
153
+ printer(prefix + line)
117
154
 
118
155
  def _compute_scaling_multipliers(
119
156
  self,
@@ -156,24 +193,29 @@ class Plot:
156
193
 
157
194
  def upscale(
158
195
  self,
159
- max_size: tuple[int, int] = (0, 0),
160
- max_expansion: tuple[int, int] = (3, 3),
161
- keep_aspect_ratio: bool = False,
196
+ max_size: int | tuple[int, int] = 0,
197
+ max_expansion: int | tuple[int, int] = 3,
198
+ keep_aspect_ratio: bool = True,
162
199
  ):
163
200
  """Scale up 'data' by repeating lines and values within lines.
164
201
 
165
202
  Parameters
166
203
  ----------
167
- max_size : tuple (int, int)
204
+ max_size : int or tuple (int, int)
168
205
  If positive: Maximum number of columns, maximum number of rows
169
206
  If zero: Use terminal size
170
207
  If negative: Use as offset from terminal size
171
208
  Default: Based on terminal size (0).
172
- max_expansion : tuple (int, int)
173
- maximum expansion factor in each direction. Default (3,3). 0=> No maximum
209
+ max_expansion : int or tuple (int, int)
210
+ maximum expansion factor in each direction. Default (3,3). None=> No maximum
174
211
  keep_aspect_ratio : bool
175
212
  Require that X and Y scaling are equal.
176
213
  """
214
+ if isinstance(max_size, int):
215
+ max_size = (max_size, max_size)
216
+
217
+ if isinstance(max_expansion, int) or max_expansion is None:
218
+ max_expansion = (max_expansion, max_expansion)
177
219
 
178
220
  float_mult = self._compute_scaling_multipliers(max_size, keep_aspect_ratio)
179
221
  col_mult = max(int(float_mult[0]), 1)
@@ -198,4 +240,20 @@ class Plot:
198
240
 
199
241
  # repeat each of those by the row multiplier
200
242
  self.data = repeat_each(x_expanded, row_mult)
243
+
244
+ # If we have axes, adjust for the new pixel/bin size
245
+ if self.x_axis:
246
+ self.x_axis.upscale(len(self.data[0]), col_mult)
247
+
248
+ if self.y_axis:
249
+ self.y_axis.upscale(len(self.data), row_mult)
250
+
251
+ # if we have a glued-on plot to the right (likely a colorbar), scale it up in Y to match
252
+ if isinstance(self.to_right, Plot):
253
+ self.to_right.upscale(max_size=0, max_expansion=(1, col_mult), keep_aspect_ratio=False)
201
254
  return self
255
+
256
+ def glue_on(self, to_right, padding=" "):
257
+ """Add lines or another plot to the right of this one."""
258
+ self.right_padding = padding
259
+ self.to_right = to_right