From 055da77e07aeffdfb817c760adcb43d4afbb4f6d Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 14 Apr 2022 21:47:26 -0700 Subject: [PATCH 1/3] make Layout context management async --- docs/source/about/changelog.rst | 2 +- src/idom/core/layout.py | 7 +--- src/idom/core/serve.py | 2 +- tests/test_core/test_hooks.py | 70 ++++++++++++++++----------------- tests/test_core/test_layout.py | 68 ++++++++++++++++---------------- 5 files changed, 73 insertions(+), 76 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 87625e949..0f14cca2c 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -444,7 +444,7 @@ See :ref:`Custom JavaScript Components` for details on the new interface. - Make docs section margins larger - :issue:`450` - Search broken in docs - :issue:`443` - Move src/idom/client out of Python package - :issue:`429` -- Use composition instead of classes with Layout and LifeCycleHook - :issue:`412` +- Use composition instead of classes async with Layout and LifeCycleHook - :issue:`412` - Remove Python language extension - :issue:`282` - Add keys to models so React doesn't complain of child arrays requiring them - :issue:`255` diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index 1f67bd586..b8b2346ae 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -61,9 +61,6 @@ class LayoutEvent(NamedTuple): """A list of event data passed to the event handler.""" -_Self = TypeVar("_Self", bound="Layout") - - class Layout: """Responsible for "rendering" components. That is, turning them into VDOM.""" @@ -84,7 +81,7 @@ def __init__(self, root: "ComponentType") -> None: raise TypeError(f"Expected a ComponentType, not {type(root)!r}.") self.root = root - def __enter__(self: _Self) -> _Self: + async def __aenter__(self) -> Layout: # create attributes here to avoid access before entering context manager self._event_handlers: EventHandlerDict = {} @@ -98,7 +95,7 @@ def __enter__(self: _Self) -> _Self: return self - def __exit__(self, *exc: Any) -> None: + async def __aexit__(self, *exc: Any) -> None: root_csid = self._root_life_cycle_state_id root_model_state = self._model_states_by_life_cycle_state_id[root_csid] self._unmount_model_states([root_model_state]) diff --git a/src/idom/core/serve.py b/src/idom/core/serve.py index af21f40f7..fd21c55bf 100644 --- a/src/idom/core/serve.py +++ b/src/idom/core/serve.py @@ -39,7 +39,7 @@ async def serve_json_patch( recv: RecvCoroutine, ) -> None: """Run a dispatch loop for a single view instance""" - with layout: + async with layout: try: async with create_task_group() as task_group: task_group.start_soon(_single_outgoing_loop, layout, send) diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 77d73da01..6aac2fec1 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -22,7 +22,7 @@ def SimpleComponentWithHook(): with pytest.raises(RuntimeError, match="No life cycle hook is active"): await SimpleComponentWithHook().render() - with idom.Layout(SimpleComponentWithHook()) as layout: + async with idom.Layout(SimpleComponentWithHook()) as layout: await layout.render() @@ -35,7 +35,7 @@ def SimpleStatefulComponent(): sse = SimpleStatefulComponent() - with idom.Layout(sse) as layout: + async with idom.Layout(sse) as layout: patch_1 = await render_json_patch(layout) assert patch_1.path == "" assert_same_items( @@ -75,7 +75,7 @@ def SimpleStatefulComponent(): sse = SimpleStatefulComponent() - with idom.Layout(sse) as layout: + async with idom.Layout(sse) as layout: await layout.render() await layout.render() await layout.render() @@ -108,7 +108,7 @@ def Inner(): state, set_inner_state.current = idom.use_state(make_default) return idom.html.div(state) - with idom.Layout(Outer()) as layout: + async with idom.Layout(Outer()) as layout: await layout.render() assert constructor_call_count.current == 1 @@ -141,7 +141,7 @@ def Counter(): count.current, set_count.current = idom.hooks.use_state(0) return idom.html.div(count.current) - with idom.Layout(Counter()) as layout: + async with idom.Layout(Counter()) as layout: await layout.render() for i in range(4): @@ -308,7 +308,7 @@ def CheckNoEffectYet(): effect_triggers_after_final_render.current = not effect_triggered.current return idom.html.div() - with idom.Layout(OuterComponent()) as layout: + async with idom.Layout(OuterComponent()) as layout: await layout.render() assert effect_triggered.current @@ -336,7 +336,7 @@ def cleanup(): return idom.html.div() - with idom.Layout(ComponentWithEffect()) as layout: + async with idom.Layout(ComponentWithEffect()) as layout: await layout.render() assert not cleanup_triggered.current @@ -375,7 +375,7 @@ def cleanup(): return idom.html.div() - with idom.Layout(OuterComponent()) as layout: + async with idom.Layout(OuterComponent()) as layout: await layout.render() assert not cleanup_triggered.current @@ -406,7 +406,7 @@ def effect(): return idom.html.div() - with idom.Layout(ComponentWithMemoizedEffect()) as layout: + async with idom.Layout(ComponentWithMemoizedEffect()) as layout: await layout.render() assert effect_run_count.current == 1 @@ -449,7 +449,7 @@ def cleanup(): return idom.html.div() - with idom.Layout(ComponentWithEffect()) as layout: + async with idom.Layout(ComponentWithEffect()) as layout: await layout.render() assert cleanup_trigger_count.current == 0 @@ -476,7 +476,7 @@ async def effect(): return idom.html.div() - with idom.Layout(ComponentWithAsyncEffect()) as layout: + async with idom.Layout(ComponentWithAsyncEffect()) as layout: await layout.render() await asyncio.wait_for(effect_ran.wait(), 1) @@ -496,7 +496,7 @@ async def effect(): return idom.html.div() - with idom.Layout(ComponentWithAsyncEffect()) as layout: + async with idom.Layout(ComponentWithAsyncEffect()) as layout: await layout.render() component_hook.latest.schedule_render() @@ -527,7 +527,7 @@ async def effect(): return idom.html.div() - with idom.Layout(ComponentWithLongWaitingEffect()) as layout: + async with idom.Layout(ComponentWithLongWaitingEffect()) as layout: await layout.render() await effect_ran.wait() @@ -554,7 +554,7 @@ def bad_effect(): return idom.html.div() with assert_idom_logged(match_message=r"Layout post-render effect .* failed"): - with idom.Layout(ComponentWithEffect()) as layout: + async with idom.Layout(ComponentWithEffect()) as layout: await layout.render() # no error @@ -575,7 +575,7 @@ def bad_cleanup(): return idom.html.div() with assert_idom_logged(match_error=r"Layout post-render effect .* failed"): - with idom.Layout(ComponentWithEffect()) as layout: + async with idom.Layout(ComponentWithEffect()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() # no error @@ -604,7 +604,7 @@ def bad_cleanup(): match_message=r"Pre-unmount effect .*? failed", error_type=ValueError, ): - with idom.Layout(OuterComponent()) as layout: + async with idom.Layout(OuterComponent()) as layout: await layout.render() set_key.current("second") await layout.render() # no error @@ -629,7 +629,7 @@ def Counter(initial_count): ) return idom.html.div() - with idom.Layout(Counter(0)) as layout: + async with idom.Layout(Counter(0)) as layout: await layout.render() assert saved_count.current == 0 @@ -659,7 +659,7 @@ def ComponentWithUseReduce(): saved_dispatchers.append(idom.hooks.use_reducer(reducer, 0)[1]) return idom.html.div() - with idom.Layout(ComponentWithUseReduce()) as layout: + async with idom.Layout(ComponentWithUseReduce()) as layout: for _ in range(3): await layout.render() saved_dispatchers[-1]("increment") @@ -679,7 +679,7 @@ def ComponentWithRef(): used_callbacks.append(idom.hooks.use_callback(lambda: None)) return idom.html.div() - with idom.Layout(ComponentWithRef()) as layout: + async with idom.Layout(ComponentWithRef()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() @@ -705,7 +705,7 @@ def cb(): used_callbacks.append(cb) return idom.html.div() - with idom.Layout(ComponentWithRef()) as layout: + async with idom.Layout(ComponentWithRef()) as layout: await layout.render() set_state_hook.current(1) await layout.render() @@ -733,7 +733,7 @@ def ComponentWithMemo(): used_values.append(value) return idom.html.div() - with idom.Layout(ComponentWithMemo()) as layout: + async with idom.Layout(ComponentWithMemo()) as layout: await layout.render() set_state_hook.current(1) await layout.render() @@ -758,7 +758,7 @@ def ComponentWithMemo(): used_values.append(value) return idom.html.div() - with idom.Layout(ComponentWithMemo()) as layout: + async with idom.Layout(ComponentWithMemo()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() @@ -785,7 +785,7 @@ def ComponentWithMemo(): used_values.append(value) return idom.html.div() - with idom.Layout(ComponentWithMemo()) as layout: + async with idom.Layout(ComponentWithMemo()) as layout: await layout.render() component_hook.latest.schedule_render() deps_used_in_memo.current = None @@ -810,7 +810,7 @@ def ComponentWithMemo(): used_values.append(value) return idom.html.div() - with idom.Layout(ComponentWithMemo()) as layout: + async with idom.Layout(ComponentWithMemo()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() @@ -830,7 +830,7 @@ def ComponentWithRef(): used_refs.append(idom.hooks.use_ref(1)) return idom.html.div() - with idom.Layout(ComponentWithRef()) as layout: + async with idom.Layout(ComponentWithRef()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() @@ -865,7 +865,7 @@ def some_effect_that_uses_count(): return idom.html.div() - with idom.Layout(CounterWithEffect()) as layout: + async with idom.Layout(CounterWithEffect()) as layout: await layout.render() await did_effect.wait() did_effect.clear() @@ -893,7 +893,7 @@ def some_memo_func_that_uses_count(): return idom.html.div() - with idom.Layout(CounterWithEffect()) as layout: + async with idom.Layout(CounterWithEffect()) as layout: await layout.render() await did_memo.wait() did_memo.clear() @@ -918,7 +918,7 @@ def ComponentUsesContext(): value.current = idom.use_context(Context) return html.div() - with idom.Layout(ComponentProvidesContext()) as layout: + async with idom.Layout(ComponentProvidesContext()) as layout: await layout.render() assert value.current == "something" @@ -927,7 +927,7 @@ def ComponentUsesContext(): value.current = idom.use_context(Context) return html.div() - with idom.Layout(ComponentUsesContext()) as layout: + async with idom.Layout(ComponentUsesContext()) as layout: await layout.render() assert value.current == "something" @@ -958,7 +958,7 @@ def ComponentInContext(): render_count.current += 1 return html.div() - with idom.Layout(ComponentProvidesContext()) as layout: + async with idom.Layout(ComponentProvidesContext()) as layout: await layout.render() assert render_count.current == 1 @@ -995,7 +995,7 @@ def MemoizedComponentUsesContext(): render_count.current += 1 return html.div() - with idom.Layout(ComponentProvidesContext()) as layout: + async with idom.Layout(ComponentProvidesContext()) as layout: await layout.render() assert render_count.current == 1 assert value.current == 0 @@ -1041,7 +1041,7 @@ def Inner(): inner_render_count.current += 1 return html.div() - with idom.Layout(Root()) as layout: + async with idom.Layout(Root()) as layout: await layout.render() assert outer_render_count.current == 1 assert inner_render_count.current == 1 @@ -1097,7 +1097,7 @@ def Right(): right_used_value.current = idom.use_context(RightContext) return idom.html.div() - with idom.Layout(Root()) as layout: + async with idom.Layout(Root()) as layout: await layout.render() assert left_render_count.current == 1 assert right_render_count.current == 1 @@ -1142,7 +1142,7 @@ def bad_effect(): error_type=ValueError, match_error="The error message", ): - with idom.Layout(ComponentWithEffect()) as layout: + async with idom.Layout(ComponentWithEffect()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() # no error @@ -1159,7 +1159,7 @@ def SetStateDuringRender(): set_state(state + 1) return html.div(state) - with Layout(SetStateDuringRender()) as layout: + async with Layout(SetStateDuringRender()) as layout: await layout.render() assert render_count.current == 1 await layout.render() diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index ddeb9f4ae..fb6f6267a 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -76,7 +76,7 @@ def SimpleComponent(): tag, set_state_hook.current = idom.hooks.use_state("div") return idom.vdom(tag) - with idom.Layout(SimpleComponent()) as layout: + async with idom.Layout(SimpleComponent()) as layout: path, changes = await render_json_patch(layout) assert path == "" @@ -102,7 +102,7 @@ async def test_component_can_return_none(): def SomeComponent(): return None - with idom.Layout(SomeComponent()) as layout: + async with idom.Layout(SomeComponent()) as layout: assert (await layout.render()).new == {"tagName": ""} @@ -120,7 +120,7 @@ def Child(): state, child_set_state.current = idom.hooks.use_state(0) return idom.html.div(state) - with idom.Layout(Parent(key="p")) as layout: + async with idom.Layout(Parent(key="p")) as layout: path, changes = await render_json_patch(layout) assert path == "" @@ -183,7 +183,7 @@ def BadChild(): with assert_idom_logged(match_error="error from bad child"): - with idom.Layout(Main()) as layout: + async with idom.Layout(Main()) as layout: patch = await render_json_patch(layout) assert_same_items( patch.changes, @@ -239,7 +239,7 @@ def BadChild(): with assert_idom_logged(match_error="error from bad child"): - with idom.Layout(Main()) as layout: + async with idom.Layout(Main()) as layout: patch = await render_json_patch(layout) assert_same_items( patch.changes, @@ -282,7 +282,7 @@ def Main(): def Child(): return {"tagName": "div", "children": {"tagName": "h1"}} - with idom.Layout(Main()) as layout: + async with idom.Layout(Main()) as layout: patch = await render_json_patch(layout) assert_same_items( patch.changes, @@ -337,7 +337,7 @@ def Outer(): def Inner(): return idom.html.div() - with idom.Layout(Outer()) as layout: + async with idom.Layout(Outer()) as layout: await layout.render() assert len(live_components) == 2 @@ -380,7 +380,7 @@ def wrapper(*args, **kwargs): def Root(): return idom.html.div() - with idom.Layout(Root()) as layout: + async with idom.Layout(Root()) as layout: await layout.render() assert len(live_hooks) == 1 @@ -419,7 +419,7 @@ def Outer(): def Inner(): return idom.html.div() - with idom.Layout(Outer()) as layout: + async with idom.Layout(Outer()) as layout: await layout.render() assert len(live_hooks) == 2 @@ -456,7 +456,7 @@ def AnyComponent(): run_count.current += 1 return idom.html.div() - with idom.Layout(AnyComponent()) as layout: + async with idom.Layout(AnyComponent()) as layout: await layout.render() assert run_count.current == 1 @@ -488,7 +488,7 @@ def Parent(): def Child(): return idom.html.div() - with idom.Layout(Parent()) as layout: + async with idom.Layout(Parent()) as layout: await layout.render() hook.latest.schedule_render() @@ -502,7 +502,7 @@ async def test_log_on_dispatch_to_missing_event_handler(caplog): def SomeComponent(): return idom.html.div() - with idom.Layout(SomeComponent()) as layout: + async with idom.Layout(SomeComponent()) as layout: await layout.deliver(LayoutEvent(target="missing", data=[])) assert re.match( @@ -546,7 +546,7 @@ def bad_trigger(): return idom.html.div(children) - with idom.Layout(MyComponent()) as layout: + async with idom.Layout(MyComponent()) as layout: await layout.render() for i in range(3): event = LayoutEvent(good_handler.target, []) @@ -597,7 +597,7 @@ def callback(): return idom.html.button({"onClick": callback, "id": "good"}, "good") - with idom.Layout(RootComponent()) as layout: + async with idom.Layout(RootComponent()) as layout: await layout.render() for _ in range(3): event = LayoutEvent(good_handler.target, []) @@ -619,7 +619,7 @@ def Outer(): def Inner(): return idom.html.div("hello") - with idom.Layout(Outer()) as layout: + async with idom.Layout(Outer()) as layout: update = await render_json_patch(layout) assert_same_items( update.changes, @@ -658,7 +658,7 @@ def Inner(finalizer_id): registered_finalizers.add(finalizer_id) return idom.html.div(finalizer_id) - with idom.Layout(Outer()) as layout: + async with idom.Layout(Outer()) as layout: await layout.render() pop_item.current() @@ -686,7 +686,7 @@ def HasEventHandlerAtRoot(): event_handler.current = weakref(button["eventHandlers"]["onClick"].function) return button - with idom.Layout(HasEventHandlerAtRoot()) as layout: + async with idom.Layout(HasEventHandlerAtRoot()) as layout: await layout.render() for i in range(3): @@ -708,7 +708,7 @@ def HasNestedEventHandler(): event_handler.current = weakref(button["eventHandlers"]["onClick"].function) return idom.html.div(idom.html.div(button)) - with idom.Layout(HasNestedEventHandler()) as layout: + async with idom.Layout(HasNestedEventHandler()) as layout: await layout.render() for i in range(3): @@ -733,7 +733,7 @@ def ComponentReturnsDuplicateKeys(): else: return idom.html.div() - with idom.Layout(ComponentReturnsDuplicateKeys()) as layout: + async with idom.Layout(ComponentReturnsDuplicateKeys()) as layout: with assert_idom_logged( error_type=ValueError, match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", @@ -768,7 +768,7 @@ def Outer(): def Inner(): return idom.html.div() - with idom.Layout(Outer()) as layout: + async with idom.Layout(Outer()) as layout: await layout.render() old_inner_hook = inner_hook.latest @@ -790,7 +790,7 @@ def raise_error(): with assert_idom_logged(match_error="bad event handler"): - with idom.Layout(ComponentWithBadEventHandler()) as layout: + async with idom.Layout(ComponentWithBadEventHandler()) as layout: await layout.render() event = LayoutEvent(bad_handler.target, []) await layout.deliver(event) @@ -815,7 +815,7 @@ def Child(state): with assert_idom_logged( r"Did not render component with model state ID .*? - component already unmounted", ): - with idom.Layout(Parent()) as layout: + async with idom.Layout(Parent()) as layout: await layout.render() old_hook = child_hook.latest @@ -859,7 +859,7 @@ def some_effect(): return idom.html.div(name) - with idom.Layout(Root()) as layout: + async with idom.Layout(Root()) as layout: await layout.render() assert effects == ["mount x"] @@ -894,7 +894,7 @@ def SomeComponent(): ] ) - with idom.Layout(SomeComponent()) as layout: + async with idom.Layout(SomeComponent()) as layout: await layout.render() set_items.current([2, 3]) @@ -926,7 +926,7 @@ def HasState(): state.current = idom.hooks.use_state(random.random)[0] return idom.html.div() - with idom.Layout(Root()) as layout: + async with idom.Layout(Root()) as layout: await layout.render() for i in range(5): @@ -955,7 +955,7 @@ def SomeComponent(): handler = component_static_handler.use(lambda: None) return html.button({"onAnotherEvent": handler}) - with idom.Layout(Root()) as layout: + async with idom.Layout(Root()) as layout: await layout.render() assert element_static_handler.target in layout._event_handlers @@ -1001,7 +1001,7 @@ def SecondComponent(): use_effect(lambda: lambda: second_used_state.set_current(None)) return html.div() - with idom.Layout(Root()) as layout: + async with idom.Layout(Root()) as layout: await layout.render() assert first_used_state.current == "first" @@ -1060,7 +1060,7 @@ async def record_if_state_is_reset(): key=child_key, ) - with idom.Layout(Parent()) as layout: + async with idom.Layout(Parent()) as layout: await layout.render() await did_call_effect.wait() assert effect_calls_without_state == ["some-key", "key-0"] @@ -1088,7 +1088,7 @@ def Root(): def Child(): use_state(lambda: did_init_state.set_current(did_init_state.current + 1)) - with Layout(Root()) as layout: + async with Layout(Root()) as layout: await layout.render() assert did_init_state.current == 1 @@ -1113,7 +1113,7 @@ def Root(): {event_name: event_handler.use(lambda: did_trigger.set_current(True))} ) - with Layout(Root()) as layout: + async with Layout(Root()) as layout: await layout.render() await layout.deliver(LayoutEvent(event_handler.target, [])) assert did_trigger.current @@ -1142,7 +1142,7 @@ def Root(): def Child(): use_effect(lambda: lambda: did_unmount.set_current(True)) - with Layout(Root()) as layout: + async with Layout(Root()) as layout: await layout.render() set_toggle.current() @@ -1177,7 +1177,7 @@ def SomeComponent(): render_count.current += 1 return html.div() - with idom.Layout(Root()) as layout: + async with idom.Layout(Root()) as layout: for _ in range(4): await layout.render() root_hook.latest.schedule_render() @@ -1199,7 +1199,7 @@ def SomeComponent(): render_count.current += 1 return html.div() - with idom.Layout(Root()) as layout: + async with idom.Layout(Root()) as layout: for _ in range(4): await layout.render() root_hook.latest.schedule_render() @@ -1223,7 +1223,7 @@ def bad_should_render(new): error_type=ValueError, match_error="The error message", ): - with idom.Layout(Root()) as layout: + async with idom.Layout(Root()) as layout: await layout.render() root_hook.latest.schedule_render() await layout.render() From 245fb23c4709a492fa59dbfc2f78c6a45864accb Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 14 Apr 2022 21:53:58 -0700 Subject: [PATCH 2/3] fix LayoutType --- src/idom/core/types.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/idom/core/types.py b/src/idom/core/types.py index cdac08b50..3943a154b 100644 --- a/src/idom/core/types.py +++ b/src/idom/core/types.py @@ -51,7 +51,6 @@ def should_render(self: _OwnType, new: _OwnType) -> bool: """Whether the new component instance should be rendered.""" -_Self = TypeVar("_Self") _Render = TypeVar("_Render", covariant=True) _Event = TypeVar("_Event", contravariant=True) @@ -66,11 +65,14 @@ async def render(self) -> _Render: async def deliver(self, event: _Event) -> None: """Relay an event to its respective handler""" - def __enter__(self: _Self) -> _Self: + async def __aenter__(self) -> LayoutType[_Render, _Event]: """Prepare the layout for its first render""" - def __exit__( - self, exc_type: Type[Exception], exc_value: Exception, traceback: TracebackType + async def __aexit__( + self, + exc_type: Type[Exception], + exc_value: Exception, + traceback: TracebackType, ) -> Optional[bool]: """Clean up the view after its final render""" From 023c3dedbe8748be9a3030d12d738007d8a41f9b Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 14 Apr 2022 22:04:26 -0700 Subject: [PATCH 3/3] changelog entry --- docs/source/about/changelog.rst | 4 +++- docs/source/about/contributor-guide.rst | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 0f14cca2c..8a49a2747 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -23,7 +23,9 @@ more info, see the :ref:`Contributor Guide `. Unreleased ---------- -Nothing yet... +Changed: + +- :pull:`730` - Layout context management is not async 0.38.0-a2 diff --git a/docs/source/about/contributor-guide.rst b/docs/source/about/contributor-guide.rst index caf882977..47cefe3e1 100644 --- a/docs/source/about/contributor-guide.rst +++ b/docs/source/about/contributor-guide.rst @@ -92,15 +92,15 @@ might look like: **Added** - - A really cool new feature - :pull:`123` + - :pull:`123` - A really cool new feature **Changed** - - The behavior of some existing feature - :pull:`456` + - :pull:`456` - The behavior of some existing feature **Fixed** - - Some really bad bug - :issue:`789` + - :issue:`789` - Some really bad bug .. note::