odrive 0.6.9.dev0__py37-none-win_amd64.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.
Files changed (49) hide show
  1. odrive/__init__.py +142 -0
  2. odrive/api_client.py +54 -0
  3. odrive/config.py +1296 -0
  4. odrive/crypto.py +369 -0
  5. odrive/data/brakeRs.json +18 -0
  6. odrive/data/drvs.json +82 -0
  7. odrive/data/encoders.json +86 -0
  8. odrive/data/motors.json +128 -0
  9. odrive/data/odrive-micro-x1.json +48 -0
  10. odrive/data/odrive-micro-x3.json +40 -0
  11. odrive/data/odrive-micro-x4.json +40 -0
  12. odrive/data/odrive-s1-x4.json +52 -0
  13. odrive/data/odrive-v4.4.json +58 -0
  14. odrive/data/schema-brakeR.json +6 -0
  15. odrive/data/schema-drv.json +6 -0
  16. odrive/data/schema-encoders.json +6 -0
  17. odrive/data/schema-motors.json +6 -0
  18. odrive/data/schema-odrive.json +5 -0
  19. odrive/data/schema.json +244 -0
  20. odrive/database.py +184 -0
  21. odrive/dfu.py +195 -0
  22. odrive/dfuse/DfuDevice.py +297 -0
  23. odrive/dfuse/__init__.py +1 -0
  24. odrive/enums.py +415 -0
  25. odrive/firmware.py +102 -0
  26. odrive/hw_version.py +67 -0
  27. odrive/legacy.py +73 -0
  28. odrive/legacy_config.py +123 -0
  29. odrive/legacy_dfu.py +442 -0
  30. odrive/lib/libodrive-windows-x64.dll +0 -0
  31. odrive/libodrive.py +325 -0
  32. odrive/pyfibre/fibre/__init__.py +4 -0
  33. odrive/pyfibre/fibre/libfibre-windows-amd64.dll +0 -0
  34. odrive/pyfibre/fibre/libfibre.py +1078 -0
  35. odrive/pyfibre/fibre/libwinpthread-1.dll +0 -0
  36. odrive/pyfibre/fibre/shell.py +162 -0
  37. odrive/pyfibre/fibre/utils.py +133 -0
  38. odrive/release_api.py +184 -0
  39. odrive/rich_text.py +134 -0
  40. odrive/shell.py +144 -0
  41. odrive/utils.py +881 -0
  42. odrive/version.py +1 -0
  43. odrive-0.6.9.dev0.data/scripts/odrive_demo.py +50 -0
  44. odrive-0.6.9.dev0.data/scripts/odrivetool +263 -0
  45. odrive-0.6.9.dev0.data/scripts/odrivetool.bat +2 -0
  46. odrive-0.6.9.dev0.dist-info/METADATA +22 -0
  47. odrive-0.6.9.dev0.dist-info/RECORD +49 -0
  48. odrive-0.6.9.dev0.dist-info/WHEEL +5 -0
  49. odrive-0.6.9.dev0.dist-info/top_level.txt +1 -0
Binary file
@@ -0,0 +1,162 @@
1
+
2
+ import sys
3
+ import platform
4
+ import threading
5
+ import fibre
6
+
7
+ async def discovered_device(device,
8
+ interactive_variables, objects, discovered_devices,
9
+ mount, shutdown_token, logger):
10
+ """
11
+ Handles the discovery of new devices by displaying a
12
+ message and making the device available to the interactive
13
+ console
14
+ """
15
+ mount_result = await mount(device)
16
+ if mount_result is None:
17
+ logger.debug("ignoring device")
18
+ return
19
+
20
+ display_name, serial_number, var_name = mount_result
21
+
22
+ if serial_number in discovered_devices:
23
+ verb = "Reconnected"
24
+ index = discovered_devices.index(serial_number)
25
+ else:
26
+ verb = "Connected"
27
+ discovered_devices.append(serial_number)
28
+ index = len(discovered_devices) - 1
29
+
30
+ var_name = var_name + str(index)
31
+
32
+ # Publish new device to interactive console
33
+ interactive_variables[var_name] = device
34
+ device.__sealed__ = False
35
+ try:
36
+ device.__name__ = var_name
37
+ finally:
38
+ device.__sealed__ = True
39
+ objects.append(device)
40
+ globals()[var_name] = device # Add to globals so tab complete works
41
+ logger.notify("{} to {} as {}".format(verb, display_name, var_name))
42
+
43
+ # Subscribe to disappearance of the device
44
+ device._on_lost.add_done_callback(lambda x: lost_device(var_name, shutdown_token, logger))
45
+
46
+ def lost_device(interactive_name, shutdown_token, logger):
47
+ """
48
+ Handles the disappearance of a device by displaying
49
+ a message.
50
+ """
51
+ if not shutdown_token[0]:
52
+ logger.warn("Oh no {} disappeared".format(interactive_name))
53
+
54
+ def get_user_name(interactive_variables, obj):
55
+ queue = [(k, v) for k, v in interactive_variables.items() if isinstance(v, fibre.libfibre.RemoteObject)]
56
+
57
+ if not isinstance(obj, fibre.libfibre.RemoteObject):
58
+ return None
59
+
60
+ while len(queue):
61
+ k, v = queue.pop(0)
62
+ if v == obj:
63
+ return k
64
+ for key in dir(v.__class__):
65
+ class_member = getattr(v.__class__, key)
66
+ if not key.startswith('_') and isinstance(class_member, fibre.libfibre.RemoteAttribute):
67
+ queue.append((k + "." + (key if not class_member._magic_getter else "_" + key + "_property"), class_member._get_obj(v)))
68
+
69
+ return "anonymous_remote_object_" + str(self._obj_handle)
70
+
71
+ def launch_shell(args, mount,
72
+ interactive_variables,
73
+ objects,
74
+ print_banner, print_help,
75
+ logger):
76
+ """
77
+ Launches an interactive python or IPython command line
78
+ interface.
79
+ As devices are connected they are made available as
80
+ "dev0", "dev1", ...
81
+ The names of the variables can be customized by setting branding_short.
82
+ """
83
+
84
+ discovered_devices = []
85
+ shutdown_token = [False]
86
+ globals().update(interactive_variables)
87
+
88
+ fibre.libfibre.get_user_name = lambda obj: get_user_name(interactive_variables, obj)
89
+
90
+ # Connect to device
91
+ with fibre.Domain(args.path) as domain:
92
+ on_discovery = lambda dev: discovered_device(dev, interactive_variables, objects, discovered_devices, mount, shutdown_token, logger)
93
+ discovery = domain.run_discovery(on_discovery)
94
+
95
+ # Check if IPython is installed
96
+ if args.no_ipython:
97
+ use_ipython = False
98
+ else:
99
+ try:
100
+ import IPython
101
+ use_ipython = True
102
+ except:
103
+ print("Warning: you don't have IPython installed.")
104
+ print("If you want to have an improved interactive console with pretty colors,")
105
+ print("you should install IPython\n")
106
+ use_ipython = False
107
+
108
+ interactive_variables["help"] = lambda: print_help(args, len(discovered_devices) > 0)
109
+
110
+ # If IPython is installed, embed IPython shell, otherwise embed regular shell
111
+ if use_ipython:
112
+ # Override help function # pylint: disable=W0612
113
+ help = lambda: print_help(args, len(discovered_devices) > 0)
114
+ # to fix broken "%run -i script.py"
115
+ locals()['__name__'] = globals()['__name__']
116
+
117
+ if IPython.core.getipython.get_ipython() is None:
118
+ console = IPython.terminal.embed.InteractiveShellEmbed.instance(banner1='')
119
+ else:
120
+ console = IPython.terminal.embed.InteractiveShellEmbed(banner1='')
121
+
122
+ # hack to make IPython look like the regular console
123
+ console.runcode = console.run_cell
124
+ interact = console
125
+
126
+ # Catch ObjectLostError (since disconnect is not always an error)
127
+ default_exception_hook = console._showtraceback
128
+ def filtered_exception_hook(ex_class, ex, trace):
129
+ if(ex_class.__module__+'.'+ex_class.__name__ != 'fibre.libfibre.ObjectLostError'):
130
+ default_exception_hook(ex_class,ex,trace)
131
+
132
+ console._showtraceback = filtered_exception_hook
133
+ else:
134
+ # Enable tab complete if possible
135
+ try:
136
+ import readline # Works only on Unix
137
+ readline.parse_and_bind("tab: complete")
138
+ except:
139
+ sudo_prefix = "" if platform.system() == "Windows" else "sudo "
140
+ print("Warning: could not enable tab-complete. User experience will suffer.\n"
141
+ "Run `{}pip install readline` and then restart this script to fix this."
142
+ .format(sudo_prefix))
143
+
144
+ import code
145
+ console = code.InteractiveConsole(locals=interactive_variables)
146
+ interact = lambda: console.interact(banner='')
147
+
148
+ # Catch ObjectLostError (since disconnect is not alway an error)
149
+ console.runcode("import sys")
150
+ console.runcode("default_exception_hook = sys.excepthook")
151
+ console.runcode("def filtered_exception_hook(ex_class, ex, trace):\n"
152
+ " if ex_class.__module__ + '.' + ex_class.__name__ != 'fibre.libfibre.ObjectLostError':\n"
153
+ " default_exception_hook(ex_class,ex,trace)")
154
+ console.runcode("sys.excepthook=filtered_exception_hook")
155
+
156
+ # Launch shell
157
+ print_banner()
158
+ logger._skip_bottom_line = True
159
+ interact()
160
+
161
+ shutdown_token[0] = True
162
+ discovery.stop()
@@ -0,0 +1,133 @@
1
+
2
+ import sys
3
+ import time
4
+ import threading
5
+ import platform
6
+ import subprocess
7
+ import os
8
+
9
+ try:
10
+ if platform.system() == 'Windows':
11
+ import win32console
12
+ # TODO: we should win32console anyway so we could just omit colorama
13
+ import colorama
14
+ colorama.init()
15
+ except ImportError:
16
+ print("Could not init terminal features.")
17
+ sys.stdout.flush()
18
+ pass
19
+
20
+
21
+ ## Log utils ##
22
+
23
+ # TODO: use "logging" from Python standard library instead of this
24
+ class Logger():
25
+ """
26
+ Logs messages to stdout
27
+ """
28
+
29
+ COLOR_DEFAULT = 0
30
+ COLOR_GREEN = 1
31
+ COLOR_CYAN = 2
32
+ COLOR_YELLOW = 3
33
+ COLOR_RED = 4
34
+
35
+ _VT100Colors = {
36
+ COLOR_GREEN: '\x1b[92;1m',
37
+ COLOR_CYAN: '\x1b[96;1m',
38
+ COLOR_YELLOW: '\x1b[93;1m',
39
+ COLOR_RED: '\x1b[91;1m',
40
+ COLOR_DEFAULT: '\x1b[0m'
41
+ }
42
+
43
+ _Win32Colors = {
44
+ COLOR_GREEN: 0x0A,
45
+ COLOR_CYAN: 0x0B,
46
+ COLOR_YELLOW: 0x0E,
47
+ COLOR_RED: 0x0C,
48
+ COLOR_DEFAULT: 0x07
49
+ }
50
+
51
+ def __init__(self, verbose=True):
52
+ self._prefix = ''
53
+ self._skip_bottom_line = False # If true, messages are printed one line above the cursor
54
+ self._verbose = verbose
55
+ self._print_lock = threading.Lock()
56
+ if platform.system() == 'Windows':
57
+ self._stdout_buf = win32console.GetStdHandle(win32console.STD_OUTPUT_HANDLE)
58
+
59
+ def indent(self, prefix=' '):
60
+ indented_logger = Logger()
61
+ indented_logger._prefix = self._prefix + prefix
62
+ return indented_logger
63
+
64
+ def print_on_second_last_line(self, text, color):
65
+ """
66
+ Prints a text on the second last line.
67
+ This can be used to print a message above the command
68
+ prompt. If the command prompt spans multiple lines
69
+ there will be glitches.
70
+ If the printed text spans multiple lines there will also
71
+ be glitches (though this could be fixed).
72
+ """
73
+
74
+ if platform.system() == 'Windows':
75
+ # Windows <10 doesn't understand VT100 escape codes and the colorama
76
+ # also doesn't support the specific escape codes we need so we use the
77
+ # native Win32 API.
78
+ info = self._stdout_buf.GetConsoleScreenBufferInfo()
79
+ cursor_pos = info['CursorPosition']
80
+ scroll_rect=win32console.PySMALL_RECTType(
81
+ Left=0, Top=1,
82
+ Right=info['Window'].Right,
83
+ Bottom=cursor_pos.Y-1)
84
+ scroll_dest = win32console.PyCOORDType(scroll_rect.Left, scroll_rect.Top-1)
85
+ self._stdout_buf.ScrollConsoleScreenBuffer(
86
+ scroll_rect, scroll_rect, scroll_dest, # clipping rect is same as scroll rect
87
+ u' ', Logger._Win32Colors[color]) # fill with empty cells with the desired color attributes
88
+ line_start = win32console.PyCOORDType(0, cursor_pos.Y-1)
89
+ self._stdout_buf.WriteConsoleOutputCharacter(text, line_start)
90
+
91
+ else:
92
+ # Assume we're in a terminal that interprets VT100 escape codes.
93
+ # TODO: test on macOS
94
+
95
+ # Escape character sequence:
96
+ # ESC 7: store cursor position
97
+ # ESC 1A: move cursor up by one
98
+ # ESC 1S: scroll entire viewport by one
99
+ # ESC 1L: insert 1 line at cursor position
100
+ # (print text)
101
+ # ESC 8: restore old cursor position
102
+
103
+ self._print_lock.acquire()
104
+ sys.stdout.write('\x1b7\x1b[1A\x1b[1S\x1b[1L')
105
+ sys.stdout.write(Logger._VT100Colors[color] + text + Logger._VT100Colors[Logger.COLOR_DEFAULT])
106
+ sys.stdout.write('\x1b8')
107
+ sys.stdout.flush()
108
+ self._print_lock.release()
109
+
110
+ def print_colored(self, text, color):
111
+ if self._skip_bottom_line:
112
+ self.print_on_second_last_line(text, color)
113
+ else:
114
+ # On Windows, colorama does the job of interpreting the VT100 escape sequences
115
+ self._print_lock.acquire()
116
+ sys.stdout.write(Logger._VT100Colors[color] + text + Logger._VT100Colors[Logger.COLOR_DEFAULT] + '\n')
117
+ sys.stdout.flush()
118
+ self._print_lock.release()
119
+
120
+ def debug(self, text):
121
+ if self._verbose:
122
+ self.print_colored(self._prefix + text, Logger.COLOR_DEFAULT)
123
+ def success(self, text):
124
+ self.print_colored(self._prefix + text, Logger.COLOR_GREEN)
125
+ def info(self, text):
126
+ self.print_colored(self._prefix + text, Logger.COLOR_DEFAULT)
127
+ def notify(self, text):
128
+ self.print_colored(self._prefix + text, Logger.COLOR_CYAN)
129
+ def warn(self, text):
130
+ self.print_colored(self._prefix + text, Logger.COLOR_YELLOW)
131
+ def error(self, text):
132
+ # TODO: write to stderr
133
+ self.print_colored(self._prefix + text, Logger.COLOR_RED)
odrive/release_api.py ADDED
@@ -0,0 +1,184 @@
1
+ import enum
2
+ import hashlib
3
+ import os
4
+ import re
5
+ from typing import Optional
6
+
7
+ import appdirs
8
+
9
+ from odrive.api_client import ApiClient
10
+ from odrive.crypto import safe_b64decode, safe_b64encode
11
+ from odrive.hw_version import HwVersion
12
+
13
+ class ReleaseApi():
14
+ BASE_URL = '/releases'
15
+
16
+ def __init__(self, api_client: 'ApiClient'):
17
+ self._api_client = api_client
18
+
19
+ async def get_index(self, release_type: str):
20
+ outputs = await self._api_client.call('GET', ReleaseApi.BASE_URL + '/' + release_type + '/index')
21
+ return ReleaseIndex(outputs['files'], outputs['commits'], outputs['channels'])
22
+
23
+ async def load(self, manifest: dict, force_download: bool = False):
24
+ """
25
+ Downloads a firmware file and saves it in a cache directory.
26
+ If the file was already cached before, it is loaded from disk instead
27
+ (unless force_download is True).
28
+ Returns the path of the cached file.
29
+ """
30
+ cache_dir = os.path.join(appdirs.user_cache_dir("odrivetool", "ODrive Robotics"), 'firmware')
31
+ os.makedirs(cache_dir, exist_ok=True)
32
+ cache_path = os.path.join(cache_dir, manifest['content_key'] + '.elf')
33
+
34
+ if force_download or not os.path.isfile(cache_path):
35
+ content = await self._api_client.download(manifest['url'])
36
+ with open(cache_path, 'wb') as fp:
37
+ fp.write(content)
38
+
39
+ return cache_path
40
+
41
+
42
+ class VersionRelationship(enum.Enum):
43
+ UNKNOWN = enum.auto()
44
+ EQUAL = enum.auto()
45
+ UPGRADE = enum.auto()
46
+ DOWNGRADE = enum.auto()
47
+
48
+ class ChannelNotFoundError(Exception):
49
+ pass
50
+
51
+ class FirmwareNotFoundError(Exception):
52
+ pass
53
+
54
+ class ReleaseIndex():
55
+ def __init__(self, files, commits, channels):
56
+ self._files = {safe_b64decode(f['content']): f for f in files}
57
+ self._commits = commits
58
+ for c in commits:
59
+ c['content'] = safe_b64decode(c['content'])
60
+ for c in commits:
61
+ if 'board' in c:
62
+ c['board'] = HwVersion.from_tuple(c['board'])
63
+ self._open_channels = {c['channel']: c['commits'] for c in channels if not c['closed']}
64
+ self._closed_channels = {c['channel']: c['commits'] for c in channels if c['closed']}
65
+
66
+ @property
67
+ def open_channel_names(self):
68
+ return list(self._open_channels.keys())
69
+
70
+ def _get_filtered_versions(self, channel_info: list, has_file: Optional[str], **qualifiers: dict) -> list:
71
+ """
72
+ Returns a list of version info objects where all qualifiers match and
73
+ where the version name is contained in channel_info.
74
+
75
+ The list is ordered "most relevant first".
76
+
77
+ Specifically, it is sorted on two levels:
78
+ version name (as ordered in channel_info)
79
+ variant ("internal" before "public" before other or unspecified)
80
+ """
81
+ if 'product' in qualifiers:
82
+ import warnings
83
+ warnings.warn('The "product" qualifier is deprecated. Use "board" instead.', DeprecationWarning, stacklevel=0)
84
+
85
+ version_infos = [
86
+ c for c in self._commits
87
+ if (c['commit_hash'] in channel_info) and (has_file is None or has_file in c.get('index', [])) and all((k in c) and (c[k] == v) for k, v in qualifiers.items())
88
+ ]
89
+ return sorted(version_infos, key=lambda c: (
90
+ channel_info.index(c['commit_hash']),
91
+ {'internal': 0, 'public': 1}.get(c.get('variant', None), 2)))
92
+
93
+ def _get_channel_info(self, channel: str):
94
+ if channel in self._open_channels:
95
+ return self._open_channels[channel]
96
+ elif channel in self._closed_channels:
97
+ return self._closed_channels[channel]
98
+ else:
99
+ raise ChannelNotFoundError(f"Channel {channel} not found.")
100
+
101
+ def _get_manifest(self, version_info, file: Optional[str] = None):
102
+ content_key = version_info['content']
103
+
104
+ url = self._files[content_key]['url']
105
+ if not file is None:
106
+ base_url = url[:url.rfind('/')] # substring up to last /
107
+ url = base_url + '/' + file
108
+
109
+ return {
110
+ 'content_key': safe_b64encode(content_key),
111
+ 'commit_hash': version_info['commit_hash'],
112
+ 'release_date': self._files[content_key]['release_date'],
113
+ 'url': url
114
+ }
115
+
116
+ def get_latest(self, channel: str, file: Optional[str] = None, **qualifiers: dict):
117
+ """
118
+ Checks for the latest firmware on the specified channel with the specified
119
+ qualifiers (product, app).
120
+
121
+ If the specified channel is not found or empty, an exception is thrown.
122
+ Returns a metadata manifest for the release that was found.
123
+ """
124
+ channel_info = list(reversed(self._get_channel_info(channel)))
125
+ filtered_versions = self._get_filtered_versions(channel_info, has_file=file, **qualifiers)
126
+
127
+ if len(filtered_versions) == 0:
128
+ raise FirmwareNotFoundError()
129
+
130
+ return self._get_manifest(filtered_versions[0], file)
131
+
132
+ def get_version(self, version: str, file: Optional[str] = None, **qualifiers: dict):
133
+ """
134
+ Looks up the release information for the specified commit with the
135
+ specified qualifiers (product, app).
136
+
137
+ Returns a metadata manifest for the release that was found.
138
+ """
139
+ filtered_versions = self._get_filtered_versions(channel_info=[version], has_file=file, **qualifiers)
140
+
141
+ if len(filtered_versions) == 0:
142
+ raise FirmwareNotFoundError()
143
+
144
+ return self._get_manifest(filtered_versions[0], file)
145
+
146
+ def compare(self, from_version: Optional[str], to_version: str, channel: str, **qualifiers: dict):
147
+ """
148
+ Checks if the specified transition is an upgrade or not.
149
+ """
150
+
151
+ return VersionRelationship.UNKNOWN # TODO
152
+
153
+ if from_version is None:
154
+ return VersionRelationship.UNKNOWN
155
+
156
+ filtered_commits = self._get_filtered_versions(**qualifiers)
157
+
158
+ short_hashes = {h[:8]: h for h in filtered_commits.keys()}
159
+ from_version = short_hashes.get(from_version, from_version)
160
+
161
+ if not from_version in filtered_commits or not to_version in filtered_commits:
162
+ return VersionRelationship.UNKNOWN
163
+
164
+ from_file = filtered_commits[from_version]
165
+ to_file = filtered_commits[to_version]
166
+
167
+ if from_file == to_file:
168
+ return VersionRelationship.EQUAL
169
+
170
+ filtered_channel_commits = self._get_filtered_channel_commits(channel, filtered_commits)
171
+
172
+ if not from_file in filtered_channel_commits or not to_file in filtered_channel_commits:
173
+ return VersionRelationship.UNKNOWN
174
+
175
+ if filtered_channel_commits.index(from_file) < filtered_channel_commits.index(to_file):
176
+ return VersionRelationship.UPGRADE
177
+ else:
178
+ return VersionRelationship.DOWNGRADE
179
+
180
+
181
+ def format_version(version: str):
182
+ if re.match("^[0-9a-fA-F]*$", version) and (len(version) == 8 or len(version) == 40):
183
+ return version.lower()[:8]
184
+ return version
odrive/rich_text.py ADDED
@@ -0,0 +1,134 @@
1
+
2
+ import enum
3
+ from typing import Iterable, Union, Optional, List, Tuple, Sequence, cast
4
+
5
+ class Style(enum.IntEnum):
6
+ NONE = 0x00
7
+ BOLD = 0x01
8
+ ITALIC = 0x02
9
+ UNDERLINE = 0x04
10
+
11
+ class Color(enum.Enum):
12
+ DEFAULT = enum.auto() # the terminal's default color
13
+ COLOR_MASK = enum.auto()
14
+ RED = enum.auto()
15
+ YELLOW = enum.auto()
16
+ GREEN = enum.auto()
17
+ CYAN = enum.auto()
18
+ BLUE = enum.auto()
19
+ MAGENTA = enum.auto()
20
+ GRAY = enum.auto()
21
+
22
+
23
+ class RichText():
24
+ """
25
+ Represents a text consisting of styled/colored string segments.
26
+ """
27
+ segments: List[Tuple[str, Color, Color, Style]]
28
+
29
+ def __init__(self, val: Union[str, 'RichText', Sequence[Union[str, 'RichText']]], foreground: Optional[Color] = None, background: Optional[Color] = None, style: Optional[Style] = None):
30
+ """
31
+ val: A string, a RichText object or a list of strings or RichText
32
+ objects.
33
+ style: If specified, overrides the style of all segments in `val`.
34
+ If not specified, uncolored strings are set to the default color.
35
+ """
36
+
37
+ if not (isinstance(val, tuple) or isinstance(val, list)):
38
+ val = [cast(Union[str, 'RichText'], val)]
39
+
40
+ self.segments = []
41
+ for segment in val:
42
+ if isinstance(segment, str):
43
+ self.segments.append((segment, Color.DEFAULT, Color.DEFAULT, Style.NONE))
44
+ elif isinstance(segment, RichText):
45
+ self.segments.extend(segment.segments)
46
+ else:
47
+ raise Exception("unsupported argument")
48
+
49
+ # Override attributes
50
+ for i, (seg_str, seg_foreground, seg_background, seg_style) in enumerate(self.segments):
51
+ if not style is None:
52
+ seg_style = style
53
+ if not foreground is None:
54
+ seg_foreground = foreground
55
+ if not background is None:
56
+ seg_background = background
57
+ self.segments[i] = (seg_str, seg_foreground, seg_background, seg_style)
58
+
59
+ # Remove empty segments
60
+ i = 0
61
+ while i < len(self.segments):
62
+ if self.segments[i][0] == '':
63
+ self.segments.pop(i)
64
+ else:
65
+ i += 1
66
+
67
+ # Coalesce segments with identical attributes
68
+ i = 1
69
+ while i < len(self.segments):
70
+ if self.segments[i - 1][1:] == self.segments[i][1:]:
71
+ self.segments[i - 1] = (self.segments[i - 1][0] + self.segments.pop(i)[0],) + self.segments[i - 1][1:]
72
+ else:
73
+ i += 1
74
+
75
+ def __add__(self, other):
76
+ if isinstance(other, str) or isinstance(other, RichText):
77
+ return RichText([self, other])
78
+ else:
79
+ raise NotImplementedError(f"can only concatenate str and RichText (not {type(other).__name__}) to RichText.")
80
+
81
+ def __radd__(self, other):
82
+ if isinstance(other, str) or isinstance(other, RichText):
83
+ return RichText([other, self])
84
+ else:
85
+ raise NotImplementedError(f"can only concatenate RichText to str and RichText (not {type(other).__name__}).")
86
+
87
+ def __repr__(self):
88
+ return ''.join([s[0] for s in self.segments])
89
+
90
+ def join(self, others: Iterable[Union['RichText', str]]):
91
+ """
92
+ Analogous to str.join()
93
+ """
94
+ segments: List[Union[str, RichText]] = []
95
+ for item in others:
96
+ segments.append(self)
97
+ segments.append(item)
98
+ return RichText(segments[1:]) if len(segments) > 0 else RichText("")
99
+
100
+ def replace(self, old: str, new: str):
101
+ new_segments = [
102
+ RichText(seg[0].replace(old, new), *seg[1:])
103
+ for seg in self.segments
104
+ ]
105
+ return RichText(new_segments)
106
+
107
+ _VT100Colors = {
108
+ Color.DEFAULT: 39,
109
+ Color.GREEN: 92,
110
+ Color.CYAN: 96,
111
+ Color.YELLOW: 93,
112
+ Color.RED: 91,
113
+ Color.GRAY: 97,
114
+ }
115
+
116
+ def print_rich_text(text: RichText):
117
+ """
118
+ Prints `text` to stdout using VT100 escape codes for styling.
119
+ """
120
+ assert isinstance(text, RichText), type(text)
121
+ output = ''
122
+ for substr, foreground, background, style in text.segments:
123
+ output += '\x1b[0' # reset all text attributes
124
+ output += ';' + str(_VT100Colors[foreground])
125
+ output += ';' + str(_VT100Colors[background] + 10)
126
+ if style & Style.BOLD:
127
+ output += ';1'
128
+ if style & Style.UNDERLINE:
129
+ output += ';4'
130
+ output += 'm' + substr
131
+
132
+ if len(text.segments) > 0:
133
+ output += '\x1b[0m' # reset all attributes
134
+ print(output)