Repository: Textualize/textual

Issue:
Revise error codes
We need a way for a Textual app to generate a return code that would be used as the process return code.

- `App.return_code` should be 0 by default
- `App.exit` should accept an optional return code which will set `App.return_code`
- A fatal error should set a return code of 1. 

Textual apps should _not_ exit the process by default, but you could do something like this:

```python
my_app.run()
sys.exit(my_app.return_code)
```

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5c463bd7c..9acc6bd38 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 ### Added
 
 - TCSS styles `layer` and `layers` can be strings https://github.com/Textualize/textual/pull/3169
+- `App.return_code` for the app return code https://github.com/Textualize/textual/pull/3202
 
 ### Changed
 
diff --git a/src/textual/app.py b/src/textual/app.py
index 7e3a89c63..8601d42d3 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -480,6 +480,9 @@ class App(Generic[ReturnType], DOMNode):
 
         self._loop: asyncio.AbstractEventLoop | None = None
         self._return_value: ReturnType | None = None
+        """Internal attribute used to set the return value for the app."""
+        self._return_code: int | None = None
+        """Internal attribute used to set the return code for the app."""
         self._exit = False
         self._disable_tooltips = False
         self._disable_notifications = False
@@ -529,6 +532,23 @@ class App(Generic[ReturnType], DOMNode):
         """
         return self._return_value
 
+    @property
+    def return_code(self) -> int | None:
+        """The return code with which the app exited.
+
+        Non-zero codes indicate errors.
+        A value of 1 means the app exited with a fatal error.
+        If the app wasn't exited yet, this will be `None`.
+
+        Example:
+            The return code can be used to exit the process via `sys.exit`.
+            ```py
+            my_app.run()
+            sys.exit(my_app.return_code)
+            ```
+        """
+        return self._return_code
+
     @property
     def children(self) -> Sequence["Widget"]:
         """A view onto the app's immediate children.
@@ -649,16 +669,21 @@ class App(Generic[ReturnType], DOMNode):
         return self._screen_stacks[self._current_mode]
 
     def exit(
-        self, result: ReturnType | None = None, message: RenderableType | None = None
+        self,
+        result: ReturnType | None = None,
+        return_code: int = 0,
+        message: RenderableType | None = None,
     ) -> None:
         """Exit the app, and return the supplied result.
 
         Args:
             result: Return value.
+            return_code: The return code. Use non-zero values for error codes.
             message: Optional message to display on exit.
         """
         self._exit = True
         self._return_value = result
+        self._return_code = return_code
         self.post_message(messages.ExitApp())
         if message:
             self._exit_renderables.append(message)
@@ -1171,7 +1196,7 @@ class App(Generic[ReturnType], DOMNode):
             """Called when app is ready to process events."""
             app_ready_event.set()
 
-        async def run_app(app) -> None:
+        async def run_app(app: App) -> None:
             if message_hook is not None:
                 message_hook_context_var.set(message_hook)
             app._loop = asyncio.get_running_loop()
@@ -1914,6 +1939,7 @@ class App(Generic[ReturnType], DOMNode):
         Args:
             error: An exception instance.
         """
+        self._return_code = 1
         # If we're running via pilot and this is the first exception encountered,
         # take note of it so that we can re-raise for test frameworks later.
         if self.is_headless and self._exception is None:


Test patch:
diff --git a/tests/test_app.py b/tests/test_app.py
index 268eebe7b..09e810bab 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1,3 +1,5 @@
+import contextlib
+
 from textual.app import App, ComposeResult
 from textual.widgets import Button, Input
 
@@ -67,3 +69,40 @@ def test_setting_sub_title():
 
     app.sub_title = [True, False, 2]
     assert app.sub_title == "[True, False, 2]"
+
+
+async def test_default_return_code_is_zero():
+    app = App()
+    async with app.run_test():
+        app.exit()
+    assert app.return_code == 0
+
+
+async def test_return_code_is_one_after_crash():
+    class MyApp(App):
+        def key_p(self):
+            1 / 0
+
+    app = MyApp()
+    with contextlib.suppress(ZeroDivisionError):
+        async with app.run_test() as pilot:
+            await pilot.press("p")
+    assert app.return_code == 1
+
+
+async def test_set_return_code():
+    app = App()
+    async with app.run_test():
+        app.exit(return_code=42)
+    assert app.return_code == 42
+
+
+def test_no_return_code_before_running():
+    app = App()
+    assert app.return_code is None
+
+
+async def test_no_return_code_while_running():
+    app = App()
+    async with app.run_test():
+        assert app.return_code is None