Repository: Textualize/textual
Pull request: Let `textual run` run files that don't end in .py
It's not uncommon to create "standalone" commands in Python that don't end in a .py and which:

  `#!/usr/bin/env python`

or a variation on that theme. This commit makes it so that `textual run` will treat a file that's named and set to run like that as if it were a file that ends in .py.

See #1106.

Problem statement:
Enable `textual run` to run a command that doesn't have a `.py` extension
Currently, if you have a Textual app that is in a file that doesn't have a `.py` extension -- something you may do if you're creating a command that uses `#!/usr/bin/env python` to do the expected thing -- `textual run` can't run the command.

Hints from issue discussion:

Commit message: Let `textual run` run files that don't end in .py

It's not uncommon to create "standalone" commands in Python that don't end
in a .py and which:

  #!/usr/bin/ev python

or a variation on that theme. This commit makes it so that `textual run`
will treat a file that's named and set to run like that as if it were a file
that ends in .py.

See #1106.

Patch to review:
diff --git a/src/textual/_import_app.py b/src/textual/_import_app.py
index fcd39f9a65..7e9807ccf9 100644
--- a/src/textual/_import_app.py
+++ b/src/textual/_import_app.py
@@ -16,6 +16,23 @@ class AppFail(Exception):
     pass
 
 
+def shebang_python(candidate: Path):
+    """Does the given file look like it's run with Python?
+
+    Args:
+        candidate (Path): The candidate file to check.
+
+    Returns:
+        bool: ``True`` if it looks to #! python, ``False`` if not.
+    """
+    try:
+        with candidate.open("rb") as source:
+            first_line = source.readline()
+        return first_line.startswith(b"#!") and b"python" in first_line
+    except IOError:
+        return False
+
+
 def import_app(import_name: str) -> App:
     """Import an app from a path or import name.
 
@@ -42,7 +59,7 @@ def import_app(import_name: str) -> App:
     if drive:
         lib = os.path.join(drive, os.sep, lib)
 
-    if lib.endswith(".py"):
+    if lib.endswith(".py") or shebang_python(Path(lib)):
         path = os.path.abspath(lib)
         sys.path.append(str(Path(path).parent))
         try:


Code review comments:
diff_hunk: @@ -16,6 +16,23 @@ class AppFail(Exception):
     pass
 
 
+def shebang_python(candidate: Path):
+    """Does the given file look like it's run with Python?
+
+    Args:
+        candidate (Path): The candidate file to check.
+
+    Returns:
+        bool: ``True`` if it looks to #! python, ``False`` if not.
+    """
+    try:
+        with candidate.open("rb") as source:
+            first_line = source.readline()
+        return first_line.startswith(b"#!") and b"python" in first_line
path: src/textual/_import_app.py
text: @user1:
Could you put the return after the except? Just to keep the exception block as focused as possible.

@author:
Aye, good call, will do.

@author:
Done.

diff_hunk: @@ -16,6 +16,23 @@ class AppFail(Exception):
     pass
 
 
+def shebang_python(candidate: Path):
path: src/textual/_import_app.py
text: @user1:
Return type por favor.

@author:
🤦 

@author:
Done.