Repository: Textualize/textual
Pull request: Add app return codes.
Fixes #3189.

- [x] Docstrings on all new or modified functions / classes 
- [x] Updated documentation
- [x] Updated CHANGELOG.md (where appropriate)


Problem statement:
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)
```

Hints from issue discussion:

Commit message: Add tests for app return code.

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5c463bd7c8..9acc6bd380 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 7e3a89c634..4ea52f489a 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -480,6 +480,9 @@ def __init__(
 
         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,25 @@ def return_value(self) -> ReturnType | None:
         """
         return self._return_value
 
+    @property
+    def return_code(self) -> int:
+        """The return code with which the app exited.
+
+        Non-zero codes are for errors.
+        A value of 1 means the app exited with a fatal error.
+
+        Accessing this attribute before the app runs or while the app is running
+        is meaningless.
+
+        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 if self._return_code is not None else 1
+
     @property
     def children(self) -> Sequence["Widget"]:
         """A view onto the app's immediate children.
@@ -649,16 +671,22 @@ def _screen_stack(self) -> list[Screen]:
         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
+        print(f"Exiting; return code is {self._return_code}")
         self.post_message(messages.ExitApp())
         if message:
             self._exit_renderables.append(message)
diff --git a/tests/test_app.py b/tests/test_app.py
index 268eebe7b7..7a9e610fe3 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,35 @@ 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():
+    class MyApp(App):
+        pass
+
+    app = MyApp()
+    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 crash(self):
+            1 / 0
+
+    app = MyApp()
+    async with app.run_test():
+        with contextlib.suppress(ZeroDivisionError):
+            app.crash()
+    assert app.return_code == 1
+
+
+async def test_set_return_code():
+    class MyApp(App):
+        pass
+
+    app = MyApp()
+    async with app.run_test():
+        app.exit(return_code=42)
+    assert app.return_code == 42


Code review comments:
diff_hunk: @@ -529,6 +532,25 @@ def return_value(self) -> ReturnType | None:
         """
         return self._return_value
 
+    @property
+    def return_code(self) -> int:
path: src/textual/app.py
text: @user1:
The `return_code` property will be 1 when no error has occurred. That would be astonishing if you inspect it before the app exits.

I know you said it was "meaningless" but I don't think we should have meaningless properties... 

I would expect either the return code to be `0` or `None` if it hasn't been set.

@author:
Fair point.
As of da0cd5e, the return code is `None` before/while running the app.
(So as to avoid confusion from a successful exit with return code `0`.)

diff_hunk: @@ -649,16 +671,22 @@ def _screen_stack(self) -> list[Screen]:
         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
+        print(f"Exiting; return code is {self._return_code}")
path: src/textual/app.py
text: @user1:
Debug code