brkraw 0.3.11__py3-none-any.whl → 0.5.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.
- brkraw/__init__.py +9 -3
- brkraw/apps/__init__.py +12 -0
- brkraw/apps/addon/__init__.py +30 -0
- brkraw/apps/addon/core.py +35 -0
- brkraw/apps/addon/dependencies.py +402 -0
- brkraw/apps/addon/installation.py +500 -0
- brkraw/apps/addon/io.py +21 -0
- brkraw/apps/hook/__init__.py +25 -0
- brkraw/apps/hook/core.py +636 -0
- brkraw/apps/loader/__init__.py +10 -0
- brkraw/apps/loader/core.py +622 -0
- brkraw/apps/loader/formatter.py +288 -0
- brkraw/apps/loader/helper.py +797 -0
- brkraw/apps/loader/info/__init__.py +11 -0
- brkraw/apps/loader/info/scan.py +85 -0
- brkraw/apps/loader/info/scan.yaml +90 -0
- brkraw/apps/loader/info/study.py +69 -0
- brkraw/apps/loader/info/study.yaml +156 -0
- brkraw/apps/loader/info/transform.py +92 -0
- brkraw/apps/loader/types.py +220 -0
- brkraw/cli/__init__.py +5 -0
- brkraw/cli/commands/__init__.py +2 -0
- brkraw/cli/commands/addon.py +327 -0
- brkraw/cli/commands/config.py +205 -0
- brkraw/cli/commands/convert.py +903 -0
- brkraw/cli/commands/hook.py +348 -0
- brkraw/cli/commands/info.py +74 -0
- brkraw/cli/commands/init.py +214 -0
- brkraw/cli/commands/params.py +106 -0
- brkraw/cli/commands/prune.py +288 -0
- brkraw/cli/commands/session.py +371 -0
- brkraw/cli/hook_args.py +80 -0
- brkraw/cli/main.py +83 -0
- brkraw/cli/utils.py +60 -0
- brkraw/core/__init__.py +13 -0
- brkraw/core/config.py +380 -0
- brkraw/core/entrypoints.py +25 -0
- brkraw/core/formatter.py +367 -0
- brkraw/core/fs.py +495 -0
- brkraw/core/jcamp.py +600 -0
- brkraw/core/layout.py +451 -0
- brkraw/core/parameters.py +781 -0
- brkraw/core/zip.py +1121 -0
- brkraw/dataclasses/__init__.py +14 -0
- brkraw/dataclasses/node.py +139 -0
- brkraw/dataclasses/reco.py +33 -0
- brkraw/dataclasses/scan.py +61 -0
- brkraw/dataclasses/study.py +131 -0
- brkraw/default/__init__.py +3 -0
- brkraw/default/pruner_specs/deid4share.yaml +42 -0
- brkraw/default/rules/00_default.yaml +4 -0
- brkraw/default/specs/metadata_dicom.yaml +236 -0
- brkraw/default/specs/metadata_transforms.py +92 -0
- brkraw/resolver/__init__.py +7 -0
- brkraw/resolver/affine.py +539 -0
- brkraw/resolver/datatype.py +69 -0
- brkraw/resolver/fid.py +90 -0
- brkraw/resolver/helpers.py +36 -0
- brkraw/resolver/image.py +188 -0
- brkraw/resolver/nifti.py +370 -0
- brkraw/resolver/shape.py +235 -0
- brkraw/schema/__init__.py +3 -0
- brkraw/schema/context_map.yaml +62 -0
- brkraw/schema/meta.yaml +57 -0
- brkraw/schema/niftiheader.yaml +95 -0
- brkraw/schema/pruner.yaml +55 -0
- brkraw/schema/remapper.yaml +128 -0
- brkraw/schema/rules.yaml +154 -0
- brkraw/specs/__init__.py +10 -0
- brkraw/specs/hook/__init__.py +12 -0
- brkraw/specs/hook/logic.py +31 -0
- brkraw/specs/hook/validator.py +22 -0
- brkraw/specs/meta/__init__.py +5 -0
- brkraw/specs/meta/validator.py +156 -0
- brkraw/specs/pruner/__init__.py +15 -0
- brkraw/specs/pruner/logic.py +361 -0
- brkraw/specs/pruner/validator.py +119 -0
- brkraw/specs/remapper/__init__.py +27 -0
- brkraw/specs/remapper/logic.py +924 -0
- brkraw/specs/remapper/validator.py +314 -0
- brkraw/specs/rules/__init__.py +6 -0
- brkraw/specs/rules/logic.py +263 -0
- brkraw/specs/rules/validator.py +103 -0
- brkraw-0.5.0.dist-info/METADATA +81 -0
- brkraw-0.5.0.dist-info/RECORD +88 -0
- {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info}/WHEEL +1 -2
- brkraw-0.5.0.dist-info/entry_points.txt +13 -0
- brkraw/lib/__init__.py +0 -4
- brkraw/lib/backup.py +0 -641
- brkraw/lib/bids.py +0 -0
- brkraw/lib/errors.py +0 -125
- brkraw/lib/loader.py +0 -1220
- brkraw/lib/orient.py +0 -194
- brkraw/lib/parser.py +0 -48
- brkraw/lib/pvobj.py +0 -301
- brkraw/lib/reference.py +0 -245
- brkraw/lib/utils.py +0 -471
- brkraw/scripts/__init__.py +0 -0
- brkraw/scripts/brk_backup.py +0 -106
- brkraw/scripts/brkraw.py +0 -744
- brkraw/ui/__init__.py +0 -0
- brkraw/ui/config.py +0 -17
- brkraw/ui/main_win.py +0 -214
- brkraw/ui/previewer.py +0 -225
- brkraw/ui/scan_info.py +0 -72
- brkraw/ui/scan_list.py +0 -73
- brkraw/ui/subj_info.py +0 -128
- brkraw-0.3.11.dist-info/METADATA +0 -25
- brkraw-0.3.11.dist-info/RECORD +0 -28
- brkraw-0.3.11.dist-info/entry_points.txt +0 -3
- brkraw-0.3.11.dist-info/top_level.txt +0 -2
- tests/__init__.py +0 -0
- {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info/licenses}/LICENSE +0 -0
brkraw/core/formatter.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Render mapping/sequence data into structured, indented text using templates.
|
|
2
|
+
|
|
3
|
+
This module provides a small template renderer that supports:
|
|
4
|
+
- Mapping/sequence rendering via Python format strings.
|
|
5
|
+
- Optional wrapping and indentation.
|
|
6
|
+
- Per-value formatting specs for alignment, padding, repetition, and ANSI colors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import textwrap
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from string import Formatter
|
|
14
|
+
import re
|
|
15
|
+
from typing import Any, Callable, Literal, List, Dict, Optional, Union
|
|
16
|
+
|
|
17
|
+
MissingPolicy = Literal["error", "skip", "placeholder"]
|
|
18
|
+
FilterFunc = Callable[[Any], str]
|
|
19
|
+
|
|
20
|
+
_SPECIAL_VALUE_KEYS = {
|
|
21
|
+
"value",
|
|
22
|
+
"pattern",
|
|
23
|
+
"repeat",
|
|
24
|
+
"align",
|
|
25
|
+
"size",
|
|
26
|
+
"fill",
|
|
27
|
+
"gap",
|
|
28
|
+
"color",
|
|
29
|
+
"underline",
|
|
30
|
+
"bold",
|
|
31
|
+
"italic",
|
|
32
|
+
}
|
|
33
|
+
_ANSI_COLORS = {
|
|
34
|
+
"black": "30",
|
|
35
|
+
"red": "31",
|
|
36
|
+
"green": "32",
|
|
37
|
+
"yellow": "33",
|
|
38
|
+
"blue": "34",
|
|
39
|
+
"magenta": "35",
|
|
40
|
+
"cyan": "36",
|
|
41
|
+
"white": "37",
|
|
42
|
+
"gray": "90",
|
|
43
|
+
"reset": "0",
|
|
44
|
+
}
|
|
45
|
+
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _SafeFormatter(Formatter):
|
|
49
|
+
"""Formatter that blocks dunder access and supports mapping-based lookups."""
|
|
50
|
+
|
|
51
|
+
def get_field(self, field_name: str, args: Sequence[Any], kwargs: Mapping[str, Any]):
|
|
52
|
+
if "__" in field_name:
|
|
53
|
+
raise KeyError(field_name)
|
|
54
|
+
return super().get_field(field_name, args, kwargs)
|
|
55
|
+
|
|
56
|
+
def get_value(self, key, args, kwargs):
|
|
57
|
+
return super().get_value(key, args, kwargs)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _apply_color(text: str, color: Optional[str]) -> str:
|
|
61
|
+
if not color:
|
|
62
|
+
return text
|
|
63
|
+
code = _ANSI_COLORS.get(color, color)
|
|
64
|
+
return f"\033[{code}m{text}\033[0m"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _apply_style(
|
|
68
|
+
text: str,
|
|
69
|
+
*,
|
|
70
|
+
underline: bool = False,
|
|
71
|
+
bold: bool = False,
|
|
72
|
+
italic: bool = False,
|
|
73
|
+
) -> str:
|
|
74
|
+
if not any((underline, bold, italic)):
|
|
75
|
+
return text
|
|
76
|
+
codes = []
|
|
77
|
+
if underline:
|
|
78
|
+
codes.append("4")
|
|
79
|
+
if bold:
|
|
80
|
+
codes.append("1")
|
|
81
|
+
if italic:
|
|
82
|
+
codes.append("3")
|
|
83
|
+
return f"\033[{';'.join(codes)}m{text}\033[0m"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _visible_len(text: str) -> int:
|
|
87
|
+
return len(_ANSI_ESCAPE_RE.sub("", text))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pad_text(text: str, width: int, align: str, fill_char: str) -> str:
|
|
91
|
+
visible_len = _visible_len(text)
|
|
92
|
+
if width <= visible_len:
|
|
93
|
+
return text
|
|
94
|
+
pad = width - visible_len
|
|
95
|
+
if align == "right":
|
|
96
|
+
return f"{fill_char * pad}{text}"
|
|
97
|
+
if align == "center":
|
|
98
|
+
left = pad // 2
|
|
99
|
+
right = pad - left
|
|
100
|
+
return f"{fill_char * left}{text}{fill_char * right}"
|
|
101
|
+
return f"{text}{fill_char * pad}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _format_special_value(spec: Mapping[str, Any]) -> str:
|
|
105
|
+
base = spec.get("pattern", spec.get("value", ""))
|
|
106
|
+
text = str(base)
|
|
107
|
+
if "repeat" in spec:
|
|
108
|
+
text = text * int(spec["repeat"])
|
|
109
|
+
|
|
110
|
+
size = spec.get("size")
|
|
111
|
+
if size is None:
|
|
112
|
+
styled = _apply_color(text, spec.get("color"))
|
|
113
|
+
return _apply_style(
|
|
114
|
+
styled,
|
|
115
|
+
underline=bool(spec.get("underline", False)),
|
|
116
|
+
bold=bool(spec.get("bold", False)),
|
|
117
|
+
italic=bool(spec.get("italic", False)),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
align = spec.get("align", "left")
|
|
121
|
+
fill = spec.get("fill", spec.get("gap", " "))
|
|
122
|
+
if not fill:
|
|
123
|
+
fill = " "
|
|
124
|
+
fill_char = str(fill)[0]
|
|
125
|
+
width = int(size)
|
|
126
|
+
|
|
127
|
+
aligned = _pad_text(text, width, align, fill_char)
|
|
128
|
+
styled = _apply_color(aligned, spec.get("color"))
|
|
129
|
+
return _apply_style(
|
|
130
|
+
styled,
|
|
131
|
+
underline=bool(spec.get("underline", False)),
|
|
132
|
+
bold=bool(spec.get("bold", False)),
|
|
133
|
+
italic=bool(spec.get("italic", False)),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _apply_filters(value: Any, filters: Optional[Mapping[str, FilterFunc]]) -> str:
|
|
138
|
+
if isinstance(value, Mapping) and _SPECIAL_VALUE_KEYS.intersection(value):
|
|
139
|
+
return _format_special_value(value)
|
|
140
|
+
if filters is None:
|
|
141
|
+
return str(value)
|
|
142
|
+
if isinstance(value, str):
|
|
143
|
+
return value
|
|
144
|
+
type_name = type(value).__name__
|
|
145
|
+
if type_name in filters:
|
|
146
|
+
return filters[type_name](value)
|
|
147
|
+
return str(value)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _render_item(
|
|
151
|
+
item: Mapping[str, Any],
|
|
152
|
+
template: str,
|
|
153
|
+
formatter: _SafeFormatter,
|
|
154
|
+
on_missing: MissingPolicy,
|
|
155
|
+
placeholder: str,
|
|
156
|
+
filters: Optional[Mapping[str, FilterFunc]],
|
|
157
|
+
) -> str:
|
|
158
|
+
class Proxy(dict):
|
|
159
|
+
def __missing__(self, key):
|
|
160
|
+
if on_missing == "error":
|
|
161
|
+
raise KeyError(key)
|
|
162
|
+
if on_missing == "skip":
|
|
163
|
+
return None
|
|
164
|
+
return placeholder
|
|
165
|
+
|
|
166
|
+
def __getitem__(self, key):
|
|
167
|
+
missing = False
|
|
168
|
+
try:
|
|
169
|
+
val = super().__getitem__(key)
|
|
170
|
+
except KeyError:
|
|
171
|
+
val = self.__missing__(key)
|
|
172
|
+
missing = True
|
|
173
|
+
if missing and val is None and on_missing == "skip":
|
|
174
|
+
raise KeyError(key)
|
|
175
|
+
return _apply_filters(val, filters)
|
|
176
|
+
|
|
177
|
+
proxy = Proxy(item)
|
|
178
|
+
try:
|
|
179
|
+
return formatter.vformat(template, (), proxy)
|
|
180
|
+
except KeyError:
|
|
181
|
+
if on_missing == "skip":
|
|
182
|
+
return ""
|
|
183
|
+
raise
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def format_data(
|
|
187
|
+
data: Union[Mapping[str, Any], Sequence[Mapping[str, Any]]],
|
|
188
|
+
template: str,
|
|
189
|
+
*,
|
|
190
|
+
indent: int = 0,
|
|
191
|
+
width: Optional[int] = None,
|
|
192
|
+
on_missing: MissingPolicy = "error",
|
|
193
|
+
placeholder: str = "?",
|
|
194
|
+
max_output_length: Optional[int] = None,
|
|
195
|
+
filters: Optional[Mapping[str, FilterFunc]] = None,
|
|
196
|
+
) -> str:
|
|
197
|
+
formatter = _SafeFormatter()
|
|
198
|
+
if isinstance(data, Mapping):
|
|
199
|
+
rendered_items = [_render_item(data, template, formatter, on_missing, placeholder, filters)]
|
|
200
|
+
elif isinstance(data, Sequence) and not isinstance(data, (str, bytes)):
|
|
201
|
+
rendered_items = []
|
|
202
|
+
for item in data:
|
|
203
|
+
if not isinstance(item, Mapping):
|
|
204
|
+
raise TypeError("Sequence items must be mappings for templating.")
|
|
205
|
+
rendered = _render_item(item, template, formatter, on_missing, placeholder, filters)
|
|
206
|
+
if rendered:
|
|
207
|
+
rendered_items.append(rendered)
|
|
208
|
+
else:
|
|
209
|
+
raise TypeError("data must be a mapping or a sequence of mappings.")
|
|
210
|
+
|
|
211
|
+
joined = "\n".join(rendered_items)
|
|
212
|
+
if width:
|
|
213
|
+
joined = "\n".join(textwrap.fill(line, width=width) for line in joined.splitlines())
|
|
214
|
+
|
|
215
|
+
result = textwrap.indent(joined, " " * indent) if indent else joined
|
|
216
|
+
if max_output_length is not None and len(result) > max_output_length:
|
|
217
|
+
result = result[: max_output_length - 3] + "..."
|
|
218
|
+
return result
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _cell_value(cell: Any) -> str:
|
|
222
|
+
if isinstance(cell, Mapping) and "value" in cell:
|
|
223
|
+
return str(cell.get("value", ""))
|
|
224
|
+
return str(cell)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _cell_align(cell: Any) -> str:
|
|
228
|
+
if isinstance(cell, Mapping):
|
|
229
|
+
align = cell.get("align")
|
|
230
|
+
if align in {"left", "right", "center"}:
|
|
231
|
+
return align
|
|
232
|
+
return "left"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _cell_color(cell: Any, default_color: Optional[str]) -> Optional[str]:
|
|
236
|
+
if isinstance(cell, Mapping) and "color" in cell:
|
|
237
|
+
return cell.get("color") # type: ignore[return-value]
|
|
238
|
+
return default_color
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def compute_column_widths(
|
|
242
|
+
columns: Sequence[str],
|
|
243
|
+
rows: Sequence[Mapping[str, Any]],
|
|
244
|
+
*,
|
|
245
|
+
include_header: bool = True,
|
|
246
|
+
wrap_last: bool = True,
|
|
247
|
+
) -> Dict[str, int]:
|
|
248
|
+
widths: Dict[str, int] = {}
|
|
249
|
+
target_cols = columns[:-1] if wrap_last and columns else columns
|
|
250
|
+
if include_header:
|
|
251
|
+
for col in target_cols:
|
|
252
|
+
widths[col] = len(col.upper())
|
|
253
|
+
for row in rows:
|
|
254
|
+
for col in target_cols:
|
|
255
|
+
widths[col] = max(widths.get(col, 0), len(_cell_value(row.get(col, ""))))
|
|
256
|
+
return widths
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def format_table(
|
|
260
|
+
title: str,
|
|
261
|
+
columns: Sequence[str],
|
|
262
|
+
rows: Sequence[Mapping[str, Any]],
|
|
263
|
+
*,
|
|
264
|
+
width: int = 80,
|
|
265
|
+
colors: Optional[Mapping[str, str]] = None,
|
|
266
|
+
title_color: Optional[str] = None,
|
|
267
|
+
col_widths: Optional[Mapping[str, int]] = None,
|
|
268
|
+
gap: int = 2,
|
|
269
|
+
wrap_last: bool = True,
|
|
270
|
+
min_last_col_width: int = 30,
|
|
271
|
+
) -> str:
|
|
272
|
+
if not columns:
|
|
273
|
+
return ""
|
|
274
|
+
if col_widths is None:
|
|
275
|
+
col_widths = compute_column_widths(columns, rows, include_header=True, wrap_last=wrap_last)
|
|
276
|
+
|
|
277
|
+
header_cols = columns[:-1] if wrap_last else columns
|
|
278
|
+
header_row = (" " * gap).join(
|
|
279
|
+
_apply_style(
|
|
280
|
+
_pad_text(col, col_widths.get(col, len(col)), "center", " "),
|
|
281
|
+
underline=True,
|
|
282
|
+
)
|
|
283
|
+
for col in header_cols
|
|
284
|
+
)
|
|
285
|
+
if wrap_last:
|
|
286
|
+
header_row += (" " * gap) + _apply_style(
|
|
287
|
+
_pad_text(columns[-1], col_widths.get(columns[-1], len(columns[-1])), "center", " "),
|
|
288
|
+
underline=True,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
lines: List[str] = []
|
|
292
|
+
if title:
|
|
293
|
+
title_text = _apply_color(f"[ {title} ]", title_color)
|
|
294
|
+
lines.append(title_text)
|
|
295
|
+
lines.append(header_row)
|
|
296
|
+
|
|
297
|
+
if not rows:
|
|
298
|
+
lines.append("(none)")
|
|
299
|
+
return "\n".join(lines)
|
|
300
|
+
|
|
301
|
+
for row in rows:
|
|
302
|
+
prefix_parts = []
|
|
303
|
+
for col in header_cols:
|
|
304
|
+
value = _cell_value(row.get(col, ""))
|
|
305
|
+
align = _cell_align(row.get(col, ""))
|
|
306
|
+
padded = _pad_text(value, col_widths.get(col, len(value)), align, " ")
|
|
307
|
+
color = _cell_color(row.get(col, ""), colors.get(col) if colors else None)
|
|
308
|
+
prefix_parts.append(_apply_color(padded, color))
|
|
309
|
+
|
|
310
|
+
prefix = (" " * gap).join(prefix_parts)
|
|
311
|
+
prefix_plain_parts = []
|
|
312
|
+
for col in header_cols:
|
|
313
|
+
value = _cell_value(row.get(col, ""))
|
|
314
|
+
align = _cell_align(row.get(col, ""))
|
|
315
|
+
prefix_plain_parts.append(_pad_text(value, col_widths.get(col, len(value)), align, " "))
|
|
316
|
+
prefix_plain = (" " * gap).join(prefix_plain_parts)
|
|
317
|
+
|
|
318
|
+
if wrap_last:
|
|
319
|
+
desc = _cell_value(row.get(columns[-1], ""))
|
|
320
|
+
desc_color = _cell_color(row.get(columns[-1], ""), colors.get(columns[-1]) if colors else None)
|
|
321
|
+
indent = " " * (len(prefix_plain) + gap)
|
|
322
|
+
wrap_width = max(1, width - len(prefix_plain) - gap)
|
|
323
|
+
if desc:
|
|
324
|
+
if wrap_width < min_last_col_width:
|
|
325
|
+
lines.append(prefix)
|
|
326
|
+
wrapped = textwrap.fill(
|
|
327
|
+
desc,
|
|
328
|
+
width=width,
|
|
329
|
+
initial_indent=" " * gap,
|
|
330
|
+
subsequent_indent=" " * gap,
|
|
331
|
+
break_long_words=False,
|
|
332
|
+
break_on_hyphens=False,
|
|
333
|
+
)
|
|
334
|
+
for line in wrapped.splitlines():
|
|
335
|
+
lines.append(_apply_color(line, desc_color))
|
|
336
|
+
else:
|
|
337
|
+
wrapped = textwrap.fill(
|
|
338
|
+
desc,
|
|
339
|
+
width=wrap_width,
|
|
340
|
+
initial_indent="",
|
|
341
|
+
subsequent_indent="",
|
|
342
|
+
break_long_words=False,
|
|
343
|
+
break_on_hyphens=False,
|
|
344
|
+
)
|
|
345
|
+
wrapped_lines = wrapped.splitlines()
|
|
346
|
+
lines.append(f"{prefix}{' ' * gap}{_apply_color(wrapped_lines[0], desc_color)}")
|
|
347
|
+
for extra in wrapped_lines[1:]:
|
|
348
|
+
lines.append(f"{indent}{_apply_color(extra, desc_color)}")
|
|
349
|
+
else:
|
|
350
|
+
lines.append(prefix)
|
|
351
|
+
else:
|
|
352
|
+
lines.append(prefix)
|
|
353
|
+
|
|
354
|
+
return "\n".join(lines)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
__all__ = ["format_data", "format_table", "compute_column_widths"]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def __dir__() -> List[str]:
|
|
361
|
+
return sorted(__all__)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
if __name__ == "__main__":
|
|
365
|
+
import doctest
|
|
366
|
+
|
|
367
|
+
doctest.testmod()
|