omdev 0.0.0.dev486__py3-none-any.whl → 0.0.0.dev506__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.

Potentially problematic release.


This version of omdev might be problematic. Click here for more details.

Files changed (50) hide show
  1. omdev/.omlish-manifests.json +2 -2
  2. omdev/README.md +51 -0
  3. omdev/__about__.py +4 -2
  4. omdev/ci/cli.py +1 -1
  5. omdev/cli/clicli.py +37 -7
  6. omdev/dataclasses/cli.py +1 -1
  7. omdev/interp/cli.py +1 -1
  8. omdev/interp/types.py +3 -2
  9. omdev/interp/uv/provider.py +36 -0
  10. omdev/manifests/main.py +1 -1
  11. omdev/markdown/incparse.py +392 -0
  12. omdev/packaging/revisions.py +1 -1
  13. omdev/py/tools/pipdepup.py +150 -93
  14. omdev/pyproject/cli.py +2 -36
  15. omdev/pyproject/configs.py +1 -1
  16. omdev/pyproject/pkg.py +1 -1
  17. omdev/pyproject/reqs.py +8 -7
  18. omdev/pyproject/tools/aboutdeps.py +5 -0
  19. omdev/pyproject/tools/pyversions.py +47 -0
  20. omdev/pyproject/versions.py +40 -0
  21. omdev/scripts/ci.py +369 -26
  22. omdev/scripts/interp.py +51 -9
  23. omdev/scripts/lib/inject.py +8 -1
  24. omdev/scripts/lib/logs.py +117 -21
  25. omdev/scripts/pyproject.py +479 -76
  26. omdev/tools/git/cli.py +43 -13
  27. omdev/tools/json/formats.py +2 -0
  28. omdev/tools/jsonview/cli.py +19 -61
  29. omdev/tools/jsonview/resources/jsonview.html.j2 +43 -0
  30. omdev/tools/pawk/README.md +195 -0
  31. omdev/tools/sqlrepl.py +189 -78
  32. omdev/tui/apps/edit/main.py +5 -1
  33. omdev/tui/apps/irc/app.py +28 -20
  34. omdev/tui/apps/irc/commands.py +1 -1
  35. omdev/tui/rich/__init__.py +12 -0
  36. omdev/tui/rich/markdown2.py +219 -18
  37. omdev/tui/textual/__init__.py +41 -2
  38. omdev/tui/textual/app2.py +6 -1
  39. omdev/tui/textual/debug/__init__.py +10 -0
  40. omdev/tui/textual/debug/dominfo.py +151 -0
  41. omdev/tui/textual/debug/screen.py +24 -0
  42. omdev/tui/textual/devtools.py +187 -0
  43. omdev/tui/textual/logging2.py +20 -0
  44. omdev/tui/textual/types.py +45 -0
  45. {omdev-0.0.0.dev486.dist-info → omdev-0.0.0.dev506.dist-info}/METADATA +10 -6
  46. {omdev-0.0.0.dev486.dist-info → omdev-0.0.0.dev506.dist-info}/RECORD +50 -39
  47. {omdev-0.0.0.dev486.dist-info → omdev-0.0.0.dev506.dist-info}/WHEEL +0 -0
  48. {omdev-0.0.0.dev486.dist-info → omdev-0.0.0.dev506.dist-info}/entry_points.txt +0 -0
  49. {omdev-0.0.0.dev486.dist-info → omdev-0.0.0.dev506.dist-info}/licenses/LICENSE +0 -0
  50. {omdev-0.0.0.dev486.dist-info → omdev-0.0.0.dev506.dist-info}/top_level.txt +0 -0
@@ -6,6 +6,8 @@ from omlish import lang as _lang
6
6
  with _lang.auto_proxy_init(globals()):
7
7
  ##
8
8
 
9
+ from textual import LogGroup # noqa
10
+ from textual import LogVerbosity # noqa
9
11
  from textual import app # noqa
10
12
  from textual import binding # noqa
11
13
  from textual import constants # noqa
@@ -78,6 +80,8 @@ with _lang.auto_proxy_init(globals()):
78
80
  from textual.content import ContentType # noqa
79
81
  from textual.content import EMPTY_CONTENT # noqa
80
82
  from textual.content import Span # noqa
83
+ from textual.dom import DOMError # noqa
84
+ from textual.dom import DOMNode # noqa
81
85
  from textual.driver import Driver # noqa
82
86
  from textual.events import Action # noqa
83
87
  from textual.events import AppBlur # noqa
@@ -210,12 +214,38 @@ with _lang.auto_proxy_init(globals()):
210
214
  from textual.widgets import Tooltip # noqa
211
215
  from textual.widgets import Tree # noqa
212
216
  from textual.widgets import Welcome # noqa
213
- from textual.widgets.option_list import OptionDoesNotExist # noqa
214
- from textual.widgets.option_list import Option # noqa
217
+ from textual.widgets.markdown import MarkdownBlock # noqa
218
+ from textual.widgets.markdown import MarkdownFence # noqa
219
+ from textual.widgets.markdown import MarkdownStream # noqa
220
+ from textual.widgets.markdown import MarkdownTableOfContents # noqa
215
221
  from textual.widgets.option_list import DuplicateID # noqa
222
+ from textual.widgets.option_list import Option # noqa
223
+ from textual.widgets.option_list import OptionDoesNotExist # noqa
216
224
 
217
225
  ##
218
226
 
227
+ from textual_dev.client import DevtoolsClient # noqa
228
+ from textual_dev.client import DevtoolsConnectionError # noqa
229
+ from textual_dev.client import DevtoolsConsole # noqa
230
+ from textual_dev.client import DevtoolsLog # noqa
231
+
232
+ ##
233
+
234
+ from . devtools import ( # noqa
235
+ DevtoolsConfig,
236
+ connect_devtools,
237
+
238
+ DevtoolsAppMixin,
239
+
240
+ DevtoolsSetup,
241
+ DevtoolsManager,
242
+
243
+ DevtoolsLoggingHandler,
244
+ set_root_logger_to_devtools,
245
+ )
246
+
247
+ from . import debug # noqa
248
+
219
249
  from .app2 import ( # noqa
220
250
  App,
221
251
  )
@@ -224,3 +254,12 @@ with _lang.auto_proxy_init(globals()):
224
254
  PendingWritesDriverMixin,
225
255
  get_pending_writes_driver_class,
226
256
  )
257
+
258
+ from .logging2 import ( # noqa
259
+ translate_log_level,
260
+ )
261
+
262
+ from .types import ( # noqa
263
+ TopRightBottomLeft,
264
+ trbl_to_dict,
265
+ )
omdev/tui/textual/app2.py CHANGED
@@ -3,9 +3,14 @@ import typing as ta
3
3
  from textual.app import App as App_
4
4
  from textual.binding import BindingType # noqa
5
5
 
6
+ from .devtools import DevtoolsAppMixin
7
+
6
8
 
7
9
  ##
8
10
 
9
11
 
10
- class App(App_):
12
+ class App(
13
+ DevtoolsAppMixin,
14
+ App_,
15
+ ):
11
16
  BINDINGS: ta.ClassVar[ta.Sequence[BindingType]] = App_.BINDINGS # type: ignore[assignment]
@@ -0,0 +1,10 @@
1
+ # ruff: noqa: F401
2
+ # flake8: noqa: F401
3
+ from omlish import lang as _lang
4
+
5
+
6
+ with _lang.auto_proxy_init(globals()):
7
+ ##
8
+
9
+ from . import dominfo # noqa
10
+ from . import screen # noqa
@@ -0,0 +1,151 @@
1
+ """
2
+ TODO:
3
+ - walk textual.styles.RulesMap lol
4
+ """
5
+ import typing as ta
6
+
7
+ from textual.dom import DOMNode
8
+ from textual.widget import Widget
9
+
10
+ from omlish import dataclasses as dc
11
+
12
+ from ..types import trbl_to_dict
13
+
14
+
15
+ ##
16
+
17
+
18
+ @dc.dataclass()
19
+ class DomNodeInfo:
20
+ """Representation of a Textual DOM node for debugging."""
21
+
22
+ # Identity
23
+ oid: int
24
+ oidx: str
25
+ type: str
26
+ dom_id: str | None
27
+
28
+ # CSS / Styling
29
+ classes: list[str]
30
+ pseudo_classes: list[str]
31
+ # 'styles' contains the computed values relevant to layout (margin, padding, etc)
32
+ styles: dict[str, ta.Any]
33
+
34
+ # Geometry
35
+ # Region: The actual screen space allocated (x, y, w, h)
36
+ region: dict[str, int] | None
37
+ # Virtual Size: The size the widget 'wants' to be (scrollable area)
38
+ virtual_size: dict[str, int] | None
39
+ # Content Size: The size of the content inside the padding
40
+ content_size: dict[str, int] | None
41
+
42
+ # Hierarchy
43
+ children: list['DomNodeInfo'] = dc.field(default_factory=list)
44
+
45
+
46
+ def inspect_dom_node(node: DOMNode) -> DomNodeInfo:
47
+ """
48
+ Recursively builds a tree of DomNodeInfo objects from a Textual DOM node.
49
+
50
+ Args:
51
+ node: The root node to inspect (usually app.screen or a specific widget).
52
+
53
+ Returns:
54
+ DomNodeInfo: The root of the inspected tree.
55
+ """
56
+
57
+ # 1. Identity
58
+
59
+ oid = id(node)
60
+ oidx = f'{id(node):x}'
61
+ node_type = f'{type(node).__module__}.{type(node).__qualname__}'
62
+ dom_id = node.id
63
+
64
+ # 2. Styles
65
+
66
+ # We extract specific computed styles relevant to positioning debugging. Textual 'styles' property usually returns
67
+ # the computed/effective style.
68
+ styles_info: dict[str, ta.Any] = {}
69
+
70
+ # Extract only if the node supports styles (Most DOMNodes are Widgets, but check just in case)
71
+ if isinstance(node, Widget):
72
+ s = node.styles
73
+
74
+ # Layout Rules
75
+ styles_info['display'] = str(s.display)
76
+ styles_info['position'] = str(s.position) # relative, absolute
77
+ styles_info['dock'] = str(s.dock)
78
+ styles_info['layer'] = s.layer
79
+
80
+ # Sizing Rules (The 'constraints' set in CSS)
81
+ styles_info['width_rule'] = str(s.width) # e.g. '100%', 'auto', '10'
82
+ styles_info['height_rule'] = str(s.height)
83
+ styles_info['min_width'] = str(s.min_width)
84
+ styles_info['max_width'] = str(s.max_width)
85
+ styles_info['box_sizing'] = str(s.box_sizing) # border-box vs content-box
86
+
87
+ # Spacing (Critical for debugging gaps)
88
+ styles_info['margin'] = trbl_to_dict(s.margin)
89
+ styles_info['padding'] = trbl_to_dict(s.padding)
90
+ styles_info['border'] = trbl_to_dict(s.border)
91
+
92
+ # Alignment
93
+ styles_info['align'] = f'{s.align_horizontal} {s.align_vertical}'
94
+
95
+ # 3. Geometry
96
+
97
+ # Region is the absolute coordinates on the screen (or relative to parent layer). This is "What it was forced into".
98
+ region_info: dict[str, int] | None = None
99
+ if (region := getattr(node, 'region', None)) is not None:
100
+ region_info = {
101
+ 'x': region.x,
102
+ 'y': region.y,
103
+ 'width': region.width,
104
+ 'height': region.height,
105
+ }
106
+
107
+ # Virtual size is the scrollable area. This is often "What it wants to be" (if larger than region).
108
+ virtual_info: dict[str, int] | None = None
109
+ if (virtual_size := getattr(node, 'virtual_size', None)) is not None:
110
+ virtual_info = {
111
+ 'width': virtual_size.width,
112
+ 'height': virtual_size.height,
113
+ }
114
+
115
+ content_info: dict | None = None
116
+ if isinstance(node, Widget):
117
+ # Content region is inner size (region - padding - border)
118
+ content_info = {
119
+ 'width': node.content_region.width,
120
+ 'height': node.content_region.height,
121
+ }
122
+
123
+ # 4. Recursion & Sorting
124
+
125
+ # Sort by Y position first, then X position
126
+ sorted_children = sorted(
127
+ node.children,
128
+ key=lambda n: (n.region.y, n.region.x),
129
+ )
130
+
131
+ child_nodes = [
132
+ inspect_dom_node(child)
133
+ for child in sorted_children
134
+ ]
135
+
136
+ return DomNodeInfo(
137
+ oid=oid,
138
+ oidx=oidx,
139
+ type=node_type,
140
+ dom_id=dom_id,
141
+
142
+ classes=list(node.classes),
143
+ pseudo_classes=list(node.pseudo_classes),
144
+ styles=styles_info,
145
+
146
+ region=region_info,
147
+ virtual_size=virtual_info,
148
+ content_size=content_info,
149
+
150
+ children=child_nodes,
151
+ )
@@ -0,0 +1,24 @@
1
+ from textual.geometry import Region
2
+ from textual.screen import Screen
3
+ from textual.widget import Widget
4
+
5
+
6
+ ##
7
+
8
+
9
+ def get_screen_zbuffer(screen: Screen) -> list[list[list[tuple[Widget, Region]] | None]]:
10
+ width, height = screen.size
11
+
12
+ zbuffer: list[list[list[tuple[Widget, Region]] | None]] = [
13
+ [None for _ in range(width)]
14
+ for _ in range(height)
15
+ ]
16
+
17
+ for y in range(height):
18
+ for x in range(width):
19
+ try:
20
+ zbuffer[y][x] = list(screen.get_widgets_at(x, y))
21
+ except Exception: # noqa
22
+ pass
23
+
24
+ return zbuffer
@@ -0,0 +1,187 @@
1
+ # ruff: noqa: UP037 UP045
2
+ import inspect
3
+ import logging
4
+ import typing as ta
5
+
6
+ import textual.constants
7
+
8
+ from omlish import check
9
+ from omlish import dataclasses as dc
10
+ from omlish import lang
11
+
12
+ from .logging2 import translate_log_level
13
+
14
+
15
+ with lang.auto_proxy_import(globals()):
16
+ from textual_dev import client as tx_dev_client
17
+ from textual_dev import redirect_output as tx_dev_redirect_output
18
+
19
+
20
+ ##
21
+
22
+
23
+ @dc.dataclass(frozen=True, kw_only=True)
24
+ class DevtoolsConfig:
25
+ host: str = '127.0.0.1'
26
+
27
+ # https://github.com/Textualize/textual/blob/676045381b7178c3bc94b86901f20764e08aca49/src/textual/constants.py#L125
28
+ port: int = 8081
29
+
30
+ @classmethod
31
+ def from_env(cls) -> 'DevtoolsConfig':
32
+ return cls(
33
+ host=textual.constants.DEVTOOLS_HOST,
34
+ port=textual.constants.DEVTOOLS_PORT,
35
+ )
36
+
37
+
38
+ async def connect_devtools(config: DevtoolsConfig) -> ta.Optional['tx_dev_client.DevtoolsClient']:
39
+ try:
40
+ from textual_dev.client import DevtoolsClient # noqa
41
+ except ImportError:
42
+ # Dev dependencies not installed
43
+ return None
44
+
45
+ devtools = DevtoolsClient(
46
+ config.host,
47
+ config.port,
48
+ )
49
+
50
+ from textual_dev.client import DevtoolsConnectionError
51
+
52
+ try:
53
+ await devtools.connect()
54
+ except DevtoolsConnectionError as e: # noqa
55
+ return None
56
+
57
+ return devtools
58
+
59
+
60
+ ##
61
+
62
+
63
+ class DevtoolsAppMixin:
64
+ _skip_devtools_management: bool = False
65
+
66
+ def _install_devtools(self, devtools: 'tx_dev_client.DevtoolsClient') -> None:
67
+ check.none(getattr(self, 'devtools', None))
68
+ check.none(getattr(self, '_devtools_redirector', None))
69
+
70
+ # https://github.com/Textualize/textual/blob/676045381b7178c3bc94b86901f20764e08aca49/src/textual/app.py#L730-L741
71
+ setattr(self, 'devtools', devtools)
72
+ setattr(self, '_devtools_redirector', tx_dev_redirect_output.StdoutRedirector(devtools))
73
+
74
+ self._skip_devtools_management = True
75
+
76
+ async def _init_devtools(self) -> None:
77
+ if self._skip_devtools_management:
78
+ return
79
+
80
+ await super()._init_devtools() # type: ignore # noqa
81
+
82
+ async def _disconnect_devtools(self) -> None:
83
+ if self._skip_devtools_management:
84
+ return
85
+
86
+ await super()._disconnect_devtools() # type: ignore # noqa
87
+
88
+
89
+ ##
90
+
91
+
92
+ class DevtoolsSetup(lang.Func1[DevtoolsAppMixin, None]):
93
+ pass
94
+
95
+
96
+ class DevtoolsManager:
97
+ def __init__(
98
+ self,
99
+ config: DevtoolsConfig = DevtoolsConfig(),
100
+ ) -> None:
101
+ super().__init__()
102
+
103
+ self._config = config
104
+ self._devtools: ta.Optional['tx_dev_client.DevtoolsClient'] = None
105
+ self._setup: DevtoolsSetup | None = None
106
+
107
+ async def get_setup(self) -> DevtoolsSetup:
108
+ if self._setup is None:
109
+ check.none(self._devtools)
110
+
111
+ self._devtools = await connect_devtools(self._config)
112
+
113
+ self._setup = DevtoolsSetup(self._setup_app_dev_tools)
114
+
115
+ return self._setup
116
+
117
+ def _setup_app_dev_tools(self, app: DevtoolsAppMixin) -> None:
118
+ if (devtools := self._devtools) is None:
119
+ return
120
+
121
+ check.isinstance(app, DevtoolsAppMixin)._install_devtools(devtools) # noqa
122
+
123
+ async def aclose(self) -> None:
124
+ if (devtools := self._devtools) is not None and devtools.is_connected:
125
+ await devtools.disconnect()
126
+
127
+
128
+ ##
129
+
130
+
131
+ class DevtoolsLoggingHandler(logging.Handler):
132
+ """
133
+ TODO:
134
+ - reify caller from LogContextInfos
135
+ - queue worker, this blocks the asyncio thread lol
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ devtools: ta.Optional['tx_dev_client.DevtoolsClient'],
141
+ prototype_handler: logging.Handler | None = None,
142
+ ) -> None:
143
+ super().__init__()
144
+
145
+ self._devtools = devtools
146
+
147
+ if prototype_handler is not None:
148
+ self.setFormatter(prototype_handler.formatter)
149
+ for lf in prototype_handler.filters:
150
+ self.addFilter(lf)
151
+
152
+ def emit(self, record: logging.LogRecord) -> None:
153
+ if (devtools := self._devtools) is None or not devtools.is_connected:
154
+ return
155
+
156
+ msg = self.format(record)
157
+
158
+ caller = inspect.Traceback(
159
+ filename=record.filename,
160
+ lineno=record.lineno,
161
+ function=record.funcName,
162
+ code_context=None,
163
+ index=None,
164
+ )
165
+
166
+ group, verbosity = translate_log_level(record.levelno)
167
+
168
+ devtools.log(
169
+ tx_dev_client.DevtoolsLog(
170
+ msg,
171
+ caller=caller,
172
+ ),
173
+ group=group,
174
+ verbosity=verbosity,
175
+ )
176
+
177
+
178
+ def set_root_logger_to_devtools(devtools: ta.Optional['tx_dev_client.DevtoolsClient']) -> None:
179
+ from omlish.logs.std.standard import _locking_logging_module_lock # noqa
180
+ from omlish.logs.std.standard import StandardConfiguredLoggingHandler
181
+
182
+ with _locking_logging_module_lock():
183
+ std_handler = next((h for h in logging.root.handlers if isinstance(h, StandardConfiguredLoggingHandler)), None)
184
+
185
+ dt_handler = DevtoolsLoggingHandler(devtools, std_handler)
186
+
187
+ logging.root.handlers = [dt_handler]
@@ -0,0 +1,20 @@
1
+ import logging
2
+
3
+ from textual import LogGroup # noqa
4
+ from textual import LogVerbosity # noqa
5
+
6
+
7
+ ##
8
+
9
+
10
+ def translate_log_level(level: int) -> tuple[LogGroup, LogVerbosity]:
11
+ if level >= logging.ERROR:
12
+ return (LogGroup.ERROR, LogVerbosity.HIGH)
13
+ elif level >= logging.WARNING:
14
+ return (LogGroup.ERROR, LogVerbosity.HIGH)
15
+ elif level >= logging.INFO:
16
+ return (LogGroup.INFO, LogVerbosity.NORMAL)
17
+ elif level >= logging.DEBUG:
18
+ return (LogGroup.DEBUG, LogVerbosity.NORMAL)
19
+ else:
20
+ return (LogGroup.UNDEFINED, LogVerbosity.NORMAL)
@@ -0,0 +1,45 @@
1
+ import typing as ta
2
+
3
+ from textual.geometry import Spacing
4
+
5
+
6
+ ##
7
+
8
+
9
+ class TopRightBottomLeft(ta.Protocol):
10
+ """
11
+ A ducktype for at least the following:
12
+ - textual.geometry.Spacing - margin, padding
13
+ - textual.css._style_properties.Edges - border
14
+ """
15
+
16
+ @property
17
+ def top(self) -> ta.Any: ...
18
+
19
+ @property
20
+ def right(self) -> ta.Any: ...
21
+
22
+ @property
23
+ def bottom(self) -> ta.Any: ...
24
+
25
+ @property
26
+ def left(self) -> ta.Any: ...
27
+
28
+
29
+ @ta.overload
30
+ def trbl_to_dict(trbl: Spacing) -> dict[str, int]:
31
+ ...
32
+
33
+
34
+ @ta.overload
35
+ def trbl_to_dict(trbl: TopRightBottomLeft) -> dict[str, ta.Any]:
36
+ ...
37
+
38
+
39
+ def trbl_to_dict(trbl):
40
+ return {
41
+ 'top': trbl.top,
42
+ 'right': trbl.right,
43
+ 'bottom': trbl.bottom,
44
+ 'left': trbl.left,
45
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: omdev
3
- Version: 0.0.0.dev486
3
+ Version: 0.0.0.dev506
4
4
  Summary: omdev
5
5
  Author: wrmsr
6
6
  License-Expression: BSD-3-Clause
@@ -14,9 +14,9 @@ Classifier: Programming Language :: Python :: 3.13
14
14
  Requires-Python: >=3.13
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
- Requires-Dist: omlish==0.0.0.dev486
17
+ Requires-Dist: omlish==0.0.0.dev506
18
18
  Provides-Extra: all
19
- Requires-Dist: black~=25.11; extra == "all"
19
+ Requires-Dist: black~=26.1; extra == "all"
20
20
  Requires-Dist: pycparser~=2.23; extra == "all"
21
21
  Requires-Dist: pcpp~=1.30; extra == "all"
22
22
  Requires-Dist: docutils~=0.22; extra == "all"
@@ -27,9 +27,11 @@ Requires-Dist: mypy~=1.19; extra == "all"
27
27
  Requires-Dist: gprof2dot~=2025.4; extra == "all"
28
28
  Requires-Dist: segno~=1.6; extra == "all"
29
29
  Requires-Dist: rich~=14.2; extra == "all"
30
- Requires-Dist: textual~=6.8; extra == "all"
30
+ Requires-Dist: textual~=7.3; extra == "all"
31
+ Requires-Dist: textual-dev~=1.8; extra == "all"
32
+ Requires-Dist: textual-speedups~=0.2; extra == "all"
31
33
  Provides-Extra: black
32
- Requires-Dist: black~=25.11; extra == "black"
34
+ Requires-Dist: black~=26.1; extra == "black"
33
35
  Provides-Extra: c
34
36
  Requires-Dist: pycparser~=2.23; extra == "c"
35
37
  Requires-Dist: pcpp~=1.30; extra == "c"
@@ -46,7 +48,9 @@ Provides-Extra: qr
46
48
  Requires-Dist: segno~=1.6; extra == "qr"
47
49
  Provides-Extra: tui
48
50
  Requires-Dist: rich~=14.2; extra == "tui"
49
- Requires-Dist: textual~=6.8; extra == "tui"
51
+ Requires-Dist: textual~=7.3; extra == "tui"
52
+ Requires-Dist: textual-dev~=1.8; extra == "tui"
53
+ Requires-Dist: textual-speedups~=0.2; extra == "tui"
50
54
  Dynamic: license-file
51
55
 
52
56
  # Overview